Як обробити як один елемент, так і масив для одного і того ж властивості за допомогою JSON.net


101

Я намагаюся виправити свою бібліотеку 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?

Відповіді:


203

Найкращий спосіб впоратися з цією ситуацією - це користування користувачем 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

Fiddle: https://dotnetfiddle.net/lERrmu

EDIT

Якщо вам потрібно піти іншим шляхом, тобто серіалізувати, зберігаючи той самий формат, ви можете реалізувати 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);
    }

Fiddle: https://dotnetfiddle.net/XG3eRy


5
Ідеально! Ти людина. На щастя, я вже зробив усі інші речі щодо використання JsonProperty, щоб зробити властивості більш значущими. Дякую за надзвичайно повну відповідь. :)
Роберт Маклауз

Нема проблем; радий, що ти вважаєш це корисним.
Брайан Роджерс

1
Відмінно! Це те, що я шукав. @BrianRogers, якщо ти коли-небудь був в Амстердамі, напої на мене!
Mad Dog Tannen

2
@israelaltar Вам не потрібно додавати перетворювач до DeserializeObjectвиклику, якщо ви використовуєте [JsonConverter]атрибут у списку властивостей вашого класу, як показано у відповіді вище. Якщо ви не використовуєте атрибут, то, так, вам потрібно буде передати конвертер 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)))> _ Публічна власність 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()використовує функцію existingValueif, попередньо виділену таким чином, щоб підтримувати популяризацію членів списку лише для отримання.

По-друге, ось версія, яка працює з іншими загальними колекціями, такими як 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();
        }
    }
}

Тоді, якщо ваша модель використовує, скажімо, an 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 тип повинен бути читання / запис і мати конструктор без параметрів.

Демо-скрипка з базовими одиничними тестами тут .


0

У мене була дуже схожа проблема. Мій запит на Json був для мене абсолютно невідомим. Я лише знав.

У ній буде 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;
    }

Тож тепер кожного разу, коли я отримую AnonymousmObject, я можу повторювати Словник, і кожен раз, коли є мій прапор "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] .категорія [0]', рядок 17, позиція 27. '
Роберт Маклауз

+ "\" подія \ ": \" оброблено \ ", \ n" + "} \ n" + "]";
Роберт Маклауз

Він обробив перший об'єкт прекрасно і вирішив жоден масив не красиво. Але коли я створив масив для 2-го об’єкта, він не вдався.
Роберт Маклауз

@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 .

І він пропонує більше, ніж відповідь на питання: Якщо ви шукаєте щось за ідентифікатором, ви знаєте, що ви отримаєте масив назад з одним результатом або без нього. TheToObjectCollectionSafe<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 (і робочі категорії працюють).

Я також створив обробник з базовим auth як варіант. Дивіться файли ashx та приклади.

Дякую!

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.