由于对象的当前状态无效,操作无效(System.Text.Json)

12

我们有一个API,它简单地将传入的JSON文档发布到消息总线,并为每个文档分配了一个GUID。我们正在从.Net Core 2.2升级到3.1,并打算使用新的System.Text.Json库替换NewtonSoft。

我们反序列化传入的文档,将GUID分配给其中一个字段,然后重新序列化再发送到消息总线。不幸的是,重新序列化失败,抛出异常由于对象当前的状态无效,因此操作无效

这里有一个控制器展示了这个问题:

using System;
using System.Net;
using Project.Models;
using Microsoft.AspNetCore.Mvc;
using System.IO;
using System.Text;
using System.Text.Json;

namespace Project.Controllers
{
    [Route("api/test")]
    public class TestController : Controller
    {
        private const string JSONAPIMIMETYPE = "application/vnd.api+json";

        public TestController()
        {
        }

        [HttpPost("{eventType}")]
        public async System.Threading.Tasks.Task<IActionResult> ProcessEventAsync([FromRoute] string eventType)
        {
            try
            {
                JsonApiMessage payload;

                using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8)) {
                    string payloadString = await reader.ReadToEndAsync();

                    try {
                        payload = JsonSerializer.Deserialize<JsonApiMessage>(payloadString);
                    }
                    catch (Exception ex) {
                        return StatusCode((int)HttpStatusCode.BadRequest);
                    }
                }

                if ( ! Request.ContentType.Contains(JSONAPIMIMETYPE) )
                {
                    return StatusCode((int)HttpStatusCode.UnsupportedMediaType);
                }

                Guid messageID = Guid.NewGuid();
                payload.Data.Id = messageID.ToString();

                // we would send the message here but for this test, just reserialise it
                string reserialisedPayload = JsonSerializer.Serialize(payload);

                Request.HttpContext.Response.ContentType = JSONAPIMIMETYPE;
                return Accepted(payload);
            }
            catch (Exception ex) 
            {
                return StatusCode((int)HttpStatusCode.InternalServerError);
            }
        }
    }
}

JsonApiMessage对象的定义如下:

using System.Text.Json;
using System.Text.Json.Serialization;

namespace Project.Models
{
    public class JsonApiMessage
    {
        [JsonPropertyName("data")]
        public JsonApiData Data { get; set; }

        [JsonPropertyName("included")]
        public JsonApiData[] Included { get; set; }
    }

    public class JsonApiData
    {
        [JsonPropertyName("type")]
        public string Type { get; set; }

        [JsonPropertyName("id")]
        public string Id { get; set; }

        [JsonPropertyName("attributes")]
        public JsonElement Attributes { get; set; }

        [JsonPropertyName("meta")]
        public JsonElement Meta { get; set; }

        [JsonPropertyName("relationships")]
        public JsonElement Relationships { get; set; }
    }
}

一个典型的调用看起来像这样:

POST http://localhost:5000/api/test/event
Content-Type: application/vnd.api+json; charset=UTF-8

{
  "data": {
    "type": "test",
    "attributes": {
      "source": "postman",
      "instance": "jg",
      "level": "INFO",
      "message": "If this comes back with an ID, the API is probably working"
    }
  }
}

当我在Visual Studio的断点处检查payload的内容时,顶层看起来没问题,但是JsonElement的部分看起来不透明,所以我不知道它们是否已被正确解析。它们的结构可能会有所变化,因此我们只关心它们是有效的JSON。在旧的NewtonSoft版本中,它们是JObject

在GUID添加后,在检查点检查payload对象时,它会出现,但我怀疑问题与对象中的其他元素是只读的或类似的东西有关。


1
@JeremyGooch 你应该发布一个最小化的示例 - 字符串、类型和对JsonSerializer.Deserialize<JsonApiMessage>的调用。我怀疑错误与JSON无关。你应该发布完整的异常,包括堆栈跟踪,而不仅仅是消息。堆栈跟踪显示了异常抛出的位置以及导致它的调用。你可以通过Exception.ToString()轻松获取它,或者在调试器的异常弹出窗口中单击Copy Exception Details来获取它。 - Panagiotis Kanavos
1
它是可重现的。演示fiddle:https://dotnetfiddle.net/xzo5Ht - dbc
顺便问一下,你为什么要首先使用这段代码呢?为什不将JsonApiMessage作为操作参数传递呢?这样你就可以避免绕过流水线和System.Text.Json反序列化的优化,并且不会失去任何好处。通过使用StreamReader并将数据缓存到payloadString中,你将获得完整的分配。 - Panagiotis Kanavos
如果您在Linqpad中尝试该代码,您会发现de序列化对象包含Undefined元数据和关系值,这是由于缺少属性造成的。 - Panagiotis Kanavos
@JeremyGooch,这段代码的目的是什么?你试图以错误的方式复制ASP.NET Core已经实现的功能。创建一个具有所需形状的DTO,并将其作为参数传递,作为返回值返回。ASP.NET Core将使用System.Text.Json进行(反)序列化,从而最小化分配。当前代码引入了太多的错误。 - Panagiotis Kanavos
显示剩余4条评论
2个回答

18

您的问题可以通过以下更简单的示例进行复现。定义以下模型:

public class JsonApiMessage
{
    public JsonElement data { get; set; }
}

然后尝试对空的JSON对象进行反序列化和重新序列化,如下所示:

var payload = JsonSerializer.Deserialize<JsonApiMessage>("{}");
var newJson = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });

而且你将会得到一个异常(demo fiddle #1 在这里):

System.InvalidOperationException: Operation is not valid due to the current state of the object.
   at System.Text.Json.JsonElement.WriteTo(Utf8JsonWriter writer)
   at System.Text.Json.Serialization.Converters.JsonConverterJsonElement.Write(Utf8JsonWriter writer, JsonElement value, JsonSerializerOptions options)

问题似乎在于JsonElement是一个结构体,而该结构体的默认值无法序列化。实际上,仅执行JsonSerializer.Serialize(new JsonElement());就会抛出相同的异常(演示fiddle#2here)。 (这与JObject形成对比,后者是引用类型,其默认值当然为null。)
那么,您有哪些选择?您可以使所有JsonElement属性为空,并在重新序列化时设置IgnoreNullValues = true
public class JsonApiData
{
    [JsonPropertyName("type")]
    public string Type { get; set; }

    [JsonPropertyName("id")]
    public string Id { get; set; }

    [JsonPropertyName("attributes")]
    public JsonElement? Attributes { get; set; }

    [JsonPropertyName("meta")]
    public JsonElement? Meta { get; set; }

    [JsonPropertyName("relationships")]
    public JsonElement? Relationships { get; set; }
}

然后:

var reserialisedPayload  = JsonSerializer.Serialize(payload, new JsonSerializerOptions { IgnoreNullValues = true });

这里是第三个演示fiddle 示例

或者,在.NET 5或更高版本中,您可以使用[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]标记所有JsonElement属性:

public class JsonApiData
{
    // Remainder unchanged

    [JsonPropertyName("attributes")]
    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
    public JsonElement Attributes { get; set; }

    [JsonPropertyName("meta")]
    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
    public JsonElement Meta { get; set; }

    [JsonPropertyName("relationships")]
    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
    public JsonElement Relationships { get; set; }
}

这样做将导致未初始化的元素在序列化过程中被跳过,而无需修改序列化选项。
演示fiddle#4 在此处
或者,您可以通过将除 Id 以外的所有JSON属性绑定到 JsonExtensionData 属性来简化数据模型,如下所示:
public class JsonApiData
{
    [JsonPropertyName("id")]
    public string Id { get; set; }

    [JsonExtensionData]
    public Dictionary<string, JsonElement> ExtensionData { get; set; }
}

这种方法避免了在重新序列化时手动设置IgnoreNullValues的需要,因此ASP.NET Core会自动正确地重新序列化模型。

示例fiddle #5 在这里


3
异常是正确的 - 对象的状态无效。 MetaRelasionships 元素是非空的,但 JSON 字符串中不包含它们。反序列化后的对象以那些无法序列化的属性的Undefined值结束。
    [JsonPropertyName("meta")]
    public JsonElement? Meta { get; set; }

    [JsonPropertyName("relationships")]
    public JsonElement? Relationships { get; set; }

快速解决方法是将这些属性更改为JsonElement?。这将允许正确的反序列化和序列化。默认情况下,缺少的元素将被视为null:
"meta": null,
"relationships": null

要忽略它们,请添加IgnoreNullValues =true选项:
var newJson = JsonSerializer.Serialize(payload, new JsonSerializerOptions 
                           { WriteIndented = true,IgnoreNullValues =true });
< p>真正的解决方案是摆脱所有这些代码。它妨碍了System.Text.Json的使用。如果单独使用,ASP.NET Core使用管道读取输入流而不分配任何内容,并对有效载荷进行反序列化,将反序列化对象作为参数调用方法,并最小限度地进行分配。任何返回值都以同样的方式进行序列化。

< p>但问题的代码大量分配内存-它将输入缓存在StreamReader中,然后整个有效负载在payloadString中缓存,然后又在payload对象中缓存。反向过程也使用临时字符串。此代码使用的RAM至少是ASP.NET Core使用量的两倍。

< p>行动代码应该只是:

[HttpPost("{eventType}")]
public async Task<IActionResult> ProcessEventAsync([FromRoute] string eventType,
                                                   MyApiData payload)
{
    Guid messageID = Guid.NewGuid();
    payload.Data.Id = messageID.ToString();

    return Accepted(payload);
}

这里的MyApiData是一个强类型对象。Json示例的形状对应于:

public class Attributes
{
    public string source { get; set; }
    public string instance { get; set; }
    public string level { get; set; }
    public string message { get; set; }
}

public class Data
{
    public string type { get; set; }
    public Attributes attributes { get; set; }
}

public class MyApiData
{
    public Data data { get; set; }
    public Data[] included {get;set;}
}

所有其他检查都由 ASP.NET Core 自己执行 - ASP.NET Core 将拒绝任何没有正确 MIME 类型的 POST 请求。如果请求格式不正确,它将返回 400。如果代码抛出异常,它将返回 500。


1
谢谢。我们不能使用强类型对象作为JSON的底层内容可能会变化。我们还必须使用StreamReader,因为传入负载的Content-Type是application/vnd.api+json而不是application/json,所以使用ASP.NET框架的声明式反序列化无法工作。尽管如此,您在处理传入JSON中的空值和抑制输出中的空元素方面的解决方案效果很好。 - Jeremy Gooch
1
@JeremyGooch 你不需要做那些事情,或者你应该以不同的方式去做。你可以通过添加字典来使用可扩展DTOs。你可以在ASP.NET Core配置中指定自定义格式化程序(这就是如何添加XML)。如果你确实需要,你可以通过Request/Response 管道BodyReader和BodyWriter属性来使用它们。 - Panagiotis Kanavos
1
@JeremyGooch 我查看了JSON:API,元素都有很好的定义。虽然有很多元素,但已经被定义好了。至少有一个.NET Core客户端库(https://github.com/json-api-dotnet/JsonApiDotNetCore)。它没有使用System.Text.Json,但如果您想创建自己的实现,可以将其用作参考。通过使用*格式化程序*和属性,您可以避免在操作和控制器中添加序列化细节。最起码,您可以借用DTO类。 - Panagiotis Kanavos

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接