Есть ли способ в сериализации Json.NET различать "null, потому что нет" и "null, потому что null"?

Я работаю в ASP.NET webapi codebase, где мы в значительной степени полагаемся на автоматическую поддержку десериализации JSON тел сообщений в .NET-объекты через JSON.NET.

Как часть построения поддержки патчей для одного из наших ресурсов, я очень хотел бы различать необязательное свойство в объекте JSON, который не присутствует, по сравнению с тем же свойством, которое явно равно null. Мое намерение состоит в том, чтобы использовать первое для "не меняйте то, что там", и "удалите эту вещь".

Кто-нибудь знает, можно ли пометить мои CTO DTO, чтобы при десериализации, что JSON.NET может сказать мне, в каком случае это было? Сейчас они просто подошли к нулю, и я не могу понять, почему.

И наоборот, если кто-то может придумать лучший дизайн, который не требует, чтобы я делал это так, сохраняя при этом глагол патча, я хотел бы услышать ваше предложение.

В качестве конкретного примера рассмотрим эту полезную нагрузку, которая будет передана для размещения:

{
  "field1": "my field 1",
  "nested": {
    "nested1": "something",
    "nested2": "else"
  }
}

Теперь, если я просто хочу обновить поле1, я должен отправить это как HTTP-патч:

{
  "field1": "new field1 value"
}

и вложенные значения останутся нетронутыми. Однако, если я отправил это сообщение:

{
  "nested": null
}

Я хочу знать, что это означает, что я должен явно удалить вложенные данные.

Ответ 1

Если вы используете Json.Net API LINQ-to-JSON (JTokens, JObjects и т.д.) для анализа JSON, вы можете определить разницу между null и поле, которое просто не существует в JSON. Например:

JToken root = JToken.Parse(json);

JToken nested = root["nested"];
if (nested != null)
{
    if (nested.Type == JTokenType.Null)
    {
        Console.WriteLine("nested is set to null");
    }
    else
    {
        Console.WriteLine("nested has a value: " + nested.ToString());
    }
}
else
{
    Console.WriteLine("nested does not exist");
}

Fiddle: https://dotnetfiddle.net/VJO7ay

UPDATE

Если вы десериализируетесь на конкретные объекты с помощью веб-API, вы все равно можете использовать эту концепцию, создав пользовательский JsonConverter для обработки ваших DTO. Уловка заключается в том, что на ваших DTO должно быть место для хранения статуса поля во время десериализации. Я бы предложил использовать схему на основе словаря следующим образом:

enum FieldDeserializationStatus { WasNotPresent, WasSetToNull, HasValue }

interface IHasFieldStatus
{
    Dictionary<string, FieldDeserializationStatus> FieldStatus { get; set; }
}

class FooDTO : IHasFieldStatus
{
    public string Field1 { get; set; }
    public BarDTO Nested { get; set; }
    public Dictionary<string, FieldDeserializationStatus> FieldStatus { get; set; }
}

class BarDTO : IHasFieldStatus
{
    public int Num { get; set; }
    public string Str { get; set; }
    public bool Bool { get; set; }
    public decimal Dec { get; set; }
    public Dictionary<string, FieldDeserializationStatus> FieldStatus { get; set; }
}

Пользовательский конвертер затем будет использовать выше метод LINQ-to-JSON для чтения JSON для десериализованного объекта. Для каждого поля целевого объекта он добавит элемент к этому объекту FieldStatus, который указывает, было ли поле имело значение, было явно установлено значение null или не существовало в JSON. Вот как выглядит код:

class DtoConverter : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        return (objectType.IsClass && 
                objectType.GetInterfaces().Any(i => i == typeof(IHasFieldStatus)));
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        var jsonObj = JObject.Load(reader);
        var targetObj = (IHasFieldStatus)Activator.CreateInstance(objectType);

        var dict = new Dictionary<string, FieldDeserializationStatus>();
        targetObj.FieldStatus = dict;

        foreach (PropertyInfo prop in objectType.GetProperties())
        {
            if (prop.CanWrite && prop.Name != "FieldStatus")
            {
                JToken value;
                if (jsonObj.TryGetValue(prop.Name, StringComparison.OrdinalIgnoreCase, out value))
                {
                    if (value.Type == JTokenType.Null)
                    {
                        dict.Add(prop.Name, FieldDeserializationStatus.WasSetToNull);
                    }
                    else
                    {
                        prop.SetValue(targetObj, value.ToObject(prop.PropertyType, serializer));
                        dict.Add(prop.Name, FieldDeserializationStatus.HasValue);
                    }
                }
                else
                {
                    dict.Add(prop.Name, FieldDeserializationStatus.WasNotPresent);
                }
            }
        }

        return targetObj;
    }

    public override bool CanWrite
    {
        get { return false; }
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }
}

Вышеуказанный конвертер будет работать на любом объекте, который реализует интерфейс IHasFieldStatus. (Обратите внимание, что вам не нужно реализовывать метод WriteJson в конвертере, если вы не намерены делать что-то обычное для сериализации. Поскольку CanWrite возвращает false, конвертер не будет использоваться во время сериализации.)

Теперь, чтобы использовать конвертер в веб-API, вам нужно вставить его в конфигурацию. Добавьте это к вашему методу Application_Start():

var config = GlobalConfiguration.Configuration;
var jsonSettings = config.Formatters.JsonFormatter.SerializerSettings;
jsonSettings.C‌​onverters.Add(new DtoConverter());

Если вы предпочитаете, вы можете украсить каждый DTO атрибутом [JsonConverter], подобным этому, вместо настройки конвертера в глобальной конфигурации:

[JsonConverter(typeof(DtoConverter))]
class FooDTO : IHasFieldStatus
{
    ...
}

При наличии инфраструктуры конвертера вы можете затем опросить словарь FieldStatus на DTO после десериализации, чтобы узнать, что произошло для какого-либо конкретного поля. Вот полное демо (консольное приложение):

public class Program
{
    public static void Main()
    {
        ParseAndDump("First run", @"{
            ""field1"": ""my field 1"",
            ""nested"": {
                ""num"": null,
                ""str"": ""blah"",
                ""dec"": 3.14
            }
        }");

        ParseAndDump("Second run", @"{
            ""field1"": ""new field value""
        }");

        ParseAndDump("Third run", @"{
            ""nested"": null
        }");
    }

    private static void ParseAndDump(string comment, string json)
    {
        Console.WriteLine("--- " + comment + " ---");

        JsonSerializerSettings settings = new JsonSerializerSettings();
        settings.Converters.Add(new DtoConverter());

        FooDTO foo = JsonConvert.DeserializeObject<FooDTO>(json, settings);

        Dump(foo, "");

        Console.WriteLine();
    }

    private static void Dump(IHasFieldStatus dto, string indent)
    {
        foreach (PropertyInfo prop in dto.GetType().GetProperties())
        {
            if (prop.Name == "FieldStatus") continue;

            Console.Write(indent + prop.Name + ": ");
            object val = prop.GetValue(dto);
            if (val is IHasFieldStatus)
            {
                Console.WriteLine();
                Dump((IHasFieldStatus)val, "  ");
            }
            else
            {
                FieldDeserializationStatus status = dto.FieldStatus[prop.Name];
                if (val != null) 
                    Console.Write(val.ToString() + " ");
                if (status != FieldDeserializationStatus.HasValue)
                    Console.Write("(" + status + ")");
                Console.WriteLine();
            }
        }
    }   
}

Вывод:

--- First run ---
Field1: my field 1 
Nested: 
  Num: 0 (WasSetToNull)
  Str: blah 
  Bool: False (WasNotPresent)
  Dec: 3.14 

--- Second run ---
Field1: new field value 
Nested: (WasNotPresent)

--- Third run ---
Field1: (WasNotPresent)
Nested: (WasSetToNull)

Fiddle: https://dotnetfiddle.net/xyKrg2

Ответ 2

Вы можете добавить некоторые метаданные к своим объектам JSON и (скорее всего) DTO. Это потребует дополнительной обработки, но довольно прозрачно и недвусмысленно выполняет то, что вам нужно (при условии, что вы можете назвать новое поле таким, что знаете, что оно не столкнется с фактическими данными).

{
  "deletedItems": null,
  "field1": "my field 1",
  "nested": {
    "deletedItems": null,
    "nested1": "something",
    "nested2": "else"
  }
}
{
  "deletedItems": "nested",
  "field1": "new value",
  "nested": null
}

В качестве альтернативы вы можете добавить свойство "isDeleted" для каждого поля, если ваша объектная модель лучше подходит, но это звучит намного больше, чем список удаленных полей.

Ответ 3

Я не хочу увлекать этот вопрос, но я добавил несколько иной подход к этой проблеме: fooobar.com/info/452616/....

Подход заключается в замене полей в десериализуемом типе на структуру, которая автоматически отслеживает значения (даже null) через свойство IsSet.