Как обрабатывать как отдельный элемент, так и массив для одного и того же свойства с помощью JSON.net

102

Я пытаюсь исправить свою библиотеку SendGridPlus для работы с событиями SendGrid, но у меня возникают проблемы с непоследовательной обработкой категорий в API.

В следующем примере полезной нагрузки, взятой из справочника API SendGrid , вы заметите, что categoryсвойство для каждого элемента может быть либо одной строкой, либо массивом строк.

[
  {
    "email": "john.doe@sendgrid.com",
    "timestamp": 1337966815,
    "category": [
      "newuser",
      "transactional"
    ],
    "event": "open"
  },
  {
    "email": "jane.doe@sendgrid.com",
    "timestamp": 1337966815,
    "category": "olduser",
    "event": "open"
  }
]

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

Есть ли другой способ справиться с этим с помощью Json.Net?

Роберт Маклоус
источник

Ответы:

205

Лучший способ справиться с этой ситуацией - использовать собственный JsonConverter .

Прежде чем мы перейдем к конвертеру, нам нужно определить класс для десериализации данных. Для Categoriesсвойства, которое может различаться между отдельным элементом и массивом, определите его как a List<string>и пометьте его [JsonConverter]атрибутом, чтобы JSON.Net знал, что нужно использовать специальный преобразователь для этого свойства. Я бы также рекомендовал использовать [JsonProperty]атрибуты, чтобы свойствам членов можно было дать осмысленные имена независимо от того, что определено в JSON.

class Item
{
    [JsonProperty("email")]
    public string Email { get; set; }

    [JsonProperty("timestamp")]
    public int Timestamp { get; set; }

    [JsonProperty("event")]
    public string Event { get; set; }

    [JsonProperty("category")]
    [JsonConverter(typeof(SingleOrArrayConverter<string>))]
    public List<string> Categories { get; set; }
}

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

class SingleOrArrayConverter<T> : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        return (objectType == typeof(List<T>));
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        JToken token = JToken.Load(reader);
        if (token.Type == JTokenType.Array)
        {
            return token.ToObject<List<T>>();
        }
        return new List<T> { token.ToObject<T>() };
    }

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

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

Вот короткая программа, демонстрирующая преобразователь в действии с вашими примерами данных:

class Program
{
    static void Main(string[] args)
    {
        string json = @"
        [
          {
            ""email"": ""john.doe@sendgrid.com"",
            ""timestamp"": 1337966815,
            ""category"": [
              ""newuser"",
              ""transactional""
            ],
            ""event"": ""open""
          },
          {
            ""email"": ""jane.doe@sendgrid.com"",
            ""timestamp"": 1337966815,
            ""category"": ""olduser"",
            ""event"": ""open""
          }
        ]";

        List<Item> list = JsonConvert.DeserializeObject<List<Item>>(json);

        foreach (Item obj in list)
        {
            Console.WriteLine("email: " + obj.Email);
            Console.WriteLine("timestamp: " + obj.Timestamp);
            Console.WriteLine("event: " + obj.Event);
            Console.WriteLine("categories: " + string.Join(", ", obj.Categories));
            Console.WriteLine();
        }
    }
}

И, наконец, вот результат вышеизложенного:

email: john.doe@sendgrid.com
timestamp: 1337966815
event: open
categories: newuser, transactional

email: jane.doe@sendgrid.com
timestamp: 1337966815
event: open
categories: olduser

Скрипка: https://dotnetfiddle.net/lERrmu

РЕДАКТИРОВАТЬ

Если вам нужно пойти другим путем, то есть сериализовать, сохранив тот же формат, вы можете реализовать WriteJson()метод конвертера, как показано ниже. (Обязательно удалите CanWriteпереопределение или измените его на возврат true, иначе WriteJson()он никогда не будет вызван.)

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        List<T> list = (List<T>)value;
        if (list.Count == 1)
        {
            value = list[0];
        }
        serializer.Serialize(writer, value);
    }

Скрипка: https://dotnetfiddle.net/XG3eRy

Брайан Роджерс
источник
5
Отлично! Ты мужчина. К счастью, я уже сделал все остальное об использовании JsonProperty, чтобы сделать свойства более значимыми. Спасибо за удивительно полный ответ. :)
Роберт Маклоус
Нет проблем; Рад, что ты нашел это полезным.
Брайан Роджерс
1
Превосходно! Это то, что я искал. @BrianRogers, если вы когда-нибудь будете в Амстердаме, выпейте за меня!
Mad Dog Tannen
2
@israelaltar Вам не нужно добавлять конвертер к DeserializeObjectвызову, если вы используете [JsonConverter]атрибут в свойстве list в своем классе, как показано в ответе выше. Если вы не используете атрибут, то да, вам нужно передать конвертер в DeserializeObject.
Брайан Роджерс
1
@ShaunLangley Чтобы преобразователь использовал массив вместо списка, измените все ссылки List<T>в преобразователе на T[]и измените .Countна .Length. dotnetfiddle.net/vnCNgZ
Брайан Роджерс
6

Я работал над этим целую вечность, и спасибо Брайану за его ответ. Все, что я добавляю, это ответ vb.net !:

Public Class SingleValueArrayConverter(Of T)
sometimes-array-and-sometimes-object
    Inherits JsonConverter
    Public Overrides Sub WriteJson(writer As JsonWriter, value As Object, serializer As JsonSerializer)
        Throw New NotImplementedException()
    End Sub

    Public Overrides Function ReadJson(reader As JsonReader, objectType As Type, existingValue As Object, serializer As JsonSerializer) As Object
        Dim retVal As Object = New [Object]()
        If reader.TokenType = JsonToken.StartObject Then
            Dim instance As T = DirectCast(serializer.Deserialize(reader, GetType(T)), T)
            retVal = New List(Of T)() From { _
                instance _
            }
        ElseIf reader.TokenType = JsonToken.StartArray Then
            retVal = serializer.Deserialize(reader, objectType)
        End If
        Return retVal
    End Function
    Public Overrides Function CanConvert(objectType As Type) As Boolean
        Return False
    End Function
End Class

тогда в вашем классе:

 <JsonProperty(PropertyName:="JsonName)> _
 <JsonConverter(GetType(SingleValueArrayConverter(Of YourObject)))> _
    Public Property YourLocalName As List(Of YourObject)

Надеюсь, это сэкономит вам время

грант
источник
Опечатки: <JsonConverter (GetType (SingleValueArrayConverter (Of YourObject)))> _ Public Property YourLocalName As List (Of YourObject)
GlennG
3

В качестве небольшого изменения в большой ответ по Брайан Роджерс , вот две Подправлены версии SingleOrArrayConverter<T>.

Во-первых, вот версия, которая работает для всех List<T>типов, Tкоторые сами по себе не являются коллекцией:

public class SingleOrArrayListConverter : JsonConverter
{
    // Adapted from this answer https://stackoverflow.com/a/18997172
    // to /programming/18994685/how-to-handle-both-a-single-item-and-an-array-for-the-same-property-using-json-n
    // by Brian Rogers https://stackoverflow.com/users/10263/brian-rogers
    readonly bool canWrite;
    readonly IContractResolver resolver;

    public SingleOrArrayListConverter() : this(false) { }

    public SingleOrArrayListConverter(bool canWrite) : this(canWrite, null) { }

    public SingleOrArrayListConverter(bool canWrite, IContractResolver resolver)
    {
        this.canWrite = canWrite;
        // Use the global default resolver if none is passed in.
        this.resolver = resolver ?? new JsonSerializer().ContractResolver;
    }

    static bool CanConvert(Type objectType, IContractResolver resolver)
    {
        Type itemType;
        JsonArrayContract contract;
        return CanConvert(objectType, resolver, out itemType, out contract);
    }

    static bool CanConvert(Type objectType, IContractResolver resolver, out Type itemType, out JsonArrayContract contract)
    {
        if ((itemType = objectType.GetListItemType()) == null)
        {
            itemType = null;
            contract = null;
            return false;
        }
        // Ensure that [JsonObject] is not applied to the type.
        if ((contract = resolver.ResolveContract(objectType) as JsonArrayContract) == null)
            return false;
        var itemContract = resolver.ResolveContract(itemType);
        // Not implemented for jagged arrays.
        if (itemContract is JsonArrayContract)
            return false;
        return true;
    }

    public override bool CanConvert(Type objectType) { return CanConvert(objectType, resolver); }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        Type itemType;
        JsonArrayContract contract;

        if (!CanConvert(objectType, serializer.ContractResolver, out itemType, out contract))
            throw new JsonSerializationException(string.Format("Invalid type for {0}: {1}", GetType(), objectType));
        if (reader.MoveToContent().TokenType == JsonToken.Null)
            return null;
        var list = (IList)(existingValue ?? contract.DefaultCreator());
        if (reader.TokenType == JsonToken.StartArray)
            serializer.Populate(reader, list);
        else
            // Here we take advantage of the fact that List<T> implements IList to avoid having to use reflection to call the generic Add<T> method.
            list.Add(serializer.Deserialize(reader, itemType));
        return list;
    }

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

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var list = value as ICollection;
        if (list == null)
            throw new JsonSerializationException(string.Format("Invalid type for {0}: {1}", GetType(), value.GetType()));
        // Here we take advantage of the fact that List<T> implements IList to avoid having to use reflection to call the generic Count method.
        if (list.Count == 1)
        {
            foreach (var item in list)
            {
                serializer.Serialize(writer, item);
                break;
            }
        }
        else
        {
            writer.WriteStartArray();
            foreach (var item in list)
                serializer.Serialize(writer, item);
            writer.WriteEndArray();
        }
    }
}

public static partial class JsonExtensions
{
    public static JsonReader MoveToContent(this JsonReader reader)
    {
        while ((reader.TokenType == JsonToken.Comment || reader.TokenType == JsonToken.None) && reader.Read())
            ;
        return reader;
    }

    internal static Type GetListItemType(this Type type)
    {
        // Quick reject for performance
        if (type.IsPrimitive || type.IsArray || type == typeof(string))
            return null;
        while (type != null)
        {
            if (type.IsGenericType)
            {
                var genType = type.GetGenericTypeDefinition();
                if (genType == typeof(List<>))
                    return type.GetGenericArguments()[0];
            }
            type = type.BaseType;
        }
        return null;
    }
}

Его можно использовать следующим образом:

var settings = new JsonSerializerSettings
{
    // Pass true if you want single-item lists to be reserialized as single items
    Converters = { new SingleOrArrayListConverter(true) },
};
var list = JsonConvert.DeserializeObject<List<Item>>(json, settings);

Ноты:

  • Конвертер избавляет от необходимости предварительно загружать все значение JSON в память в виде JTokenиерархии.

  • Конвертер не применяется к спискам, элементы которых также сериализованы как коллекции, например List<string []>

  • Логический canWriteаргумент, переданный конструктору, определяет, следует ли повторно сериализовать одноэлементные списки как значения JSON или как массивы JSON.

  • Преобразователь ReadJson()использует existingValueпредварительно выделенный if, чтобы поддерживать заполнение членов списка только для получения.

Во-вторых, вот версия, которая работает с другими общими коллекциями, такими как ObservableCollection<T>:

public class SingleOrArrayCollectionConverter<TCollection, TItem> : JsonConverter
    where TCollection : ICollection<TItem>
{
    // Adapted from this answer https://stackoverflow.com/a/18997172
    // to /programming/18994685/how-to-handle-both-a-single-item-and-an-array-for-the-same-property-using-json-n
    // by Brian Rogers https://stackoverflow.com/users/10263/brian-rogers
    readonly bool canWrite;

    public SingleOrArrayCollectionConverter() : this(false) { }

    public SingleOrArrayCollectionConverter(bool canWrite) { this.canWrite = canWrite; }

    public override bool CanConvert(Type objectType)
    {
        return typeof(TCollection).IsAssignableFrom(objectType);
    }

    static void ValidateItemContract(IContractResolver resolver)
    {
        var itemContract = resolver.ResolveContract(typeof(TItem));
        if (itemContract is JsonArrayContract)
            throw new JsonSerializationException(string.Format("Item contract type {0} not supported.", itemContract));
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        ValidateItemContract(serializer.ContractResolver);
        if (reader.MoveToContent().TokenType == JsonToken.Null)
            return null;
        var list = (ICollection<TItem>)(existingValue ?? serializer.ContractResolver.ResolveContract(objectType).DefaultCreator());
        if (reader.TokenType == JsonToken.StartArray)
            serializer.Populate(reader, list);
        else
            list.Add(serializer.Deserialize<TItem>(reader));
        return list;
    }

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

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        ValidateItemContract(serializer.ContractResolver);
        var list = value as ICollection<TItem>;
        if (list == null)
            throw new JsonSerializationException(string.Format("Invalid type for {0}: {1}", GetType(), value.GetType()));
        if (list.Count == 1)
        {
            foreach (var item in list)
            {
                serializer.Serialize(writer, item);
                break;
            }
        }
        else
        {
            writer.WriteStartArray();
            foreach (var item in list)
                serializer.Serialize(writer, item);
            writer.WriteEndArray();
        }
    }
}

Затем, если ваша модель использует, скажем, ObservableCollection<T>для некоторых T, вы можете применить его следующим образом:

class Item
{
    public string Email { get; set; }
    public int Timestamp { get; set; }
    public string Event { get; set; }

    [JsonConverter(typeof(SingleOrArrayCollectionConverter<ObservableCollection<string>, string>))]
    public ObservableCollection<string> Category { get; set; }
}

Ноты:

  • Помимо примечаний и ограничений для SingleOrArrayListConverter, TCollectionтип должен быть доступен для чтения и записи и иметь конструктор без параметров.

Демонстрационная скрипка с основными модульными тестами здесь .

dbc
источник
0

У меня была очень похожая проблема. Мой Json Request был мне совершенно неизвестен. Я только знал.

В нем будет objectId и несколько анонимных пар ключ-значение И массивов.

Я использовал его для модели EAV, которую я сделал:

Мой запрос JSON:

{objectId ": 2," firstName ":" Hans "," email ": [" a@b.de "," a@c.de "]," name ":" Andre "," something ": [" 232 "," 123 "]}

Мой класс я определил:

[JsonConverter(typeof(AnonyObjectConverter))]
public class AnonymObject
{
    public AnonymObject()
    {
        fields = new Dictionary<string, string>();
        list = new List<string>();
    }

    public string objectid { get; set; }
    public Dictionary<string, string> fields { get; set; }
    public List<string> list { get; set; }
}

и теперь, когда я хочу десериализовать неизвестные атрибуты с их значением и массивами в нем, мой конвертер выглядит так:

   public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        AnonymObject anonym = existingValue as AnonymObject ?? new AnonymObject();
        bool isList = false;
        StringBuilder listValues = new StringBuilder();

        while (reader.Read())
        {
            if (reader.TokenType == JsonToken.EndObject) continue;

            if (isList)
            {
                while (reader.TokenType != JsonToken.EndArray)
                {
                    listValues.Append(reader.Value.ToString() + ", ");

                    reader.Read();
                }
                anonym.list.Add(listValues.ToString());
                isList = false;

                continue;
            }

            var value = reader.Value.ToString();

            switch (value.ToLower())
            {
                case "objectid":
                    anonym.objectid = reader.ReadAsString();
                    break;
                default:
                    string val;

                    reader.Read();
                    if(reader.TokenType == JsonToken.StartArray)
                    {
                        isList = true;
                        val = "ValueDummyForEAV";
                    }
                    else
                    {
                        val = reader.Value.ToString();
                    }
                    try
                    {
                        anonym.fields.Add(value, val);
                    }
                    catch(ArgumentException e)
                    {
                        throw new ArgumentException("Multiple Attribute found");
                    }
                    break;
            }

        }

        return anonym;
    }

Итак, теперь каждый раз, когда я получаю AnonymObject, я могу перебирать словарь, и каждый раз, когда появляется мой флаг «ValueDummyForEAV», я переключаюсь на список, читаю первую строку и разделяю значения. После этого я удаляю первую запись из списка и продолжаю итерацию из Словаря.

Может у кого-то такая же проблема и может это использовать :)

С уважением, Андре

Андре Фриче
источник
0

Вы можете использовать JSONConverterAttributeздесь: http://james.newtonking.com/projects/json/help/

Предполагая, что у вас есть класс, который выглядит как

public class RootObject
{
    public string email { get; set; }
    public int timestamp { get; set; }
    public string smtpid { get; set; }
    public string @event { get; set; }
    public string category[] { get; set; }
}

Вы бы украсили свойство категории, как показано здесь:

    [JsonConverter(typeof(SendGridCategoryConverter))]
    public string category { get; set; }

public class SendGridCategoryConverter : JsonConverter
{
  public override bool CanConvert(Type objectType)
  {
    return true; // add your own logic
  }

  public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
  {
   // do work here to handle returning the array regardless of the number of objects in 
  }

  public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
  {
    // Left as an exercise to the reader :)
    throw new NotImplementedException();
  }
}
Тим Габрхел
источник
Спасибо, но проблема не решена. Когда поступает реальный массив, он по-прежнему вызывает ошибку, прежде чем мой код может даже выполняться для объекта, имеющего реальный массив. 'Дополнительная информация: Неожиданный токен при десериализации объекта: String. Путь '[2] .category [0]', строка 17, позиция 27. '
Роберт Маклоус
+ "\" событие \ ": \" обработано \ ", \ n" + "} \ n" + "]";
Роберт Маклоус
Он отлично обработал первый объект и красиво не обработал никакого массива. Но когда я создал массив для второго объекта, это не удалось.
Роберт Маклоус
@AdvancedREI Не видя вашего кода, я бы предположил, что вы оставляете читателя неправильно позиционированным после прочтения JSON. Вместо того, чтобы пытаться использовать считыватель напрямую, лучше загрузить объект JToken из считывателя и перейти оттуда. См. Мой ответ о работающей реализации конвертера.
Брайан Роджерс
Гораздо более подробная информация в ответе Брайана. Используй это :)
Тим Гэбрхел
0

Чтобы справиться с этим, вы должны использовать собственный JsonConverter. Но вы, вероятно, уже имели это в виду. Вы просто ищете конвертер, который можно сразу использовать. И это предлагает больше, чем просто решение описанной ситуации. Приведу пример с заданным вопросом.

Как использовать мой конвертер:

Поместите атрибут JsonConverter над свойством. JsonConverter(typeof(SafeCollectionConverter))

public class SendGridEvent
{
    [JsonProperty("email")]
    public string Email { get; set; }

    [JsonProperty("timestamp")]
    public long Timestamp { get; set; }

    [JsonProperty("category"), JsonConverter(typeof(SafeCollectionConverter))]
    public string[] Category { get; set; }

    [JsonProperty("event")]
    public string Event { get; set; }
}

А это мой конвертер:

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;

namespace stackoverflow.question18994685
{
    public class SafeCollectionConverter : JsonConverter
    {
        public override bool CanConvert(Type objectType)
        {
            return true;
        }

        public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
        {
            //This not works for Populate (on existingValue)
            return serializer.Deserialize<JToken>(reader).ToObjectCollectionSafe(objectType, serializer);
        }     

        public override bool CanWrite => false;

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

И этот конвертер использует следующий класс:

using System;

namespace Newtonsoft.Json.Linq
{
    public static class SafeJsonConvertExtensions
    {
        public static object ToObjectCollectionSafe(this JToken jToken, Type objectType)
        {
            return ToObjectCollectionSafe(jToken, objectType, JsonSerializer.CreateDefault());
        }

        public static object ToObjectCollectionSafe(this JToken jToken, Type objectType, JsonSerializer jsonSerializer)
        {
            var expectArray = typeof(System.Collections.IEnumerable).IsAssignableFrom(objectType);

            if (jToken is JArray jArray)
            {
                if (!expectArray)
                {
                    //to object via singel
                    if (jArray.Count == 0)
                        return JValue.CreateNull().ToObject(objectType, jsonSerializer);

                    if (jArray.Count == 1)
                        return jArray.First.ToObject(objectType, jsonSerializer);
                }
            }
            else if (expectArray)
            {
                //to object via JArray
                return new JArray(jToken).ToObject(objectType, jsonSerializer);
            }

            return jToken.ToObject(objectType, jsonSerializer);
        }

        public static T ToObjectCollectionSafe<T>(this JToken jToken)
        {
            return (T)ToObjectCollectionSafe(jToken, typeof(T));
        }

        public static T ToObjectCollectionSafe<T>(this JToken jToken, JsonSerializer jsonSerializer)
        {
            return (T)ToObjectCollectionSafe(jToken, typeof(T), jsonSerializer);
        }
    }
}

Что именно он делает? Если вы поместите атрибут конвертера, конвертер будет использоваться для этого свойства. Вы можете использовать его для обычного объекта, если вы ожидаете, что массив json будет иметь 1 или нет результата. Или вы используете его там, IEnumerableгде вы ожидаете объект json или массив json. (Знайте, что array- object[]- это IEnumerable). Недостатком является то, что этот преобразователь может быть размещен только над объектом, поскольку он думает, что может преобразовать все. И будьте осторожны . А stringтакжеIEnumerable .

И он предлагает больше, чем ответ на вопрос: если вы ищете что-то по идентификатору, вы знаете, что получите обратно массив с одним результатом или без результата. ВToObjectCollectionSafe<TResult>()Метод может справиться с этим для вас.

Это можно использовать для Single Result vs Array с использованием JSON.net и обрабатывать как один элемент, так и массив для одного и того же свойства, а также можно преобразовать массив в один объект.

Я сделал это для запросов REST на сервере с фильтром, который возвращал один результат в массиве, но хотел получить результат обратно как один объект в моем коде. А также для ответа результата OData с расширенным результатом с одним элементом в массиве.

Получайте удовольствие от этого.

Роберто Б.
источник
-2

Я нашел другое решение, которое может обрабатывать категорию как строку или массив с помощью объекта. Таким образом, мне не нужно путаться с сериализатором json.

Пожалуйста, взгляните на него, если у вас есть время, и скажите мне, что вы думаете. https://github.com/MarcelloCarreira/sendgrid-csharp-eventwebhook

Он основан на решении https://sendgrid.com/blog/tracking-email-using-azure-sendgrid-event-webhook-part-1/ но я также добавил преобразование даты из метки времени, обновил переменные, чтобы отразить текущая модель SendGrid (и категории работают).

Я также создал обработчик с базовой аутентификацией в качестве опции. См. Файлы ashx и примеры.

Спасибо!

МарчеллоКаррейра
источник