Come gestire sia un singolo elemento che un array per la stessa proprietà utilizzando JSON.net


101

Sto cercando di correggere la mia libreria SendGridPlus per gestire gli eventi SendGrid, ma ho qualche problema con il trattamento incoerente delle categorie nell'API.

Nel seguente payload di esempio preso dal riferimento all'API SendGrid , noterai che la categoryproprietà per ogni elemento può essere una singola stringa o un array di stringhe.

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

Sembra che le mie opzioni per rendere JSON.NET in questo modo stiano riparando la stringa prima che arrivi o configurando JSON.NET per accettare i dati errati. Preferisco non eseguire alcuna analisi delle stringhe se riesco a farla franca.

C'è un altro modo per gestirlo utilizzando Json.Net?

Risposte:


203

Il modo migliore per gestire questa situazione è utilizzare un'abitudine JsonConverter.

Prima di arrivare al convertitore, dovremo definire una classe in cui deserializzare i dati. Per la Categoriesproprietà che può variare tra un singolo elemento e un array, definirla come a List<string>e contrassegnarla con un [JsonConverter]attributo in modo che JSON.Net sappia utilizzare il convertitore personalizzato per quella proprietà. Suggerirei inoltre di utilizzare gli [JsonProperty]attributi in modo che alle proprietà dei membri possano essere assegnati nomi significativi indipendentemente da ciò che è definito in 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; }
}

Ecco come implementerei il convertitore. Si noti che ho reso il convertitore generico in modo che possa essere utilizzato con stringhe o altri tipi di oggetti secondo necessità.

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();
    }
}

Ecco un breve programma che mostra il convertitore in azione con i tuoi dati di esempio:

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();
        }
    }
}

E infine, ecco l'output di quanto sopra:

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

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

Violino: https://dotnetfiddle.net/lERrmu

MODIFICARE

Se devi andare dall'altra parte, cioè serializzare, mantenendo lo stesso formato, puoi implementare il WriteJson()metodo del convertitore come mostrato di seguito. (Assicurati di rimuovere l' CanWriteoverride o cambiarlo per tornare true, altrimenti WriteJson()non verrà mai chiamato.)

    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
Perfetto! Tu sei l'uomo. Fortunatamente, avevo già fatto tutte le altre cose sull'uso di JsonProperty per rendere le proprietà più significative. Grazie per una risposta incredibilmente completa. :)
Robert McLaws

Nessun problema; contento che tu l'abbia trovato utile.
Brian Rogers

1
Eccellente! Questo è quello che stavo cercando. @BrianRogers, se mai vieni ad Amsterdam, i drink sono su di me!
Mad Dog Tannen

2
@israelaltar Non è necessario aggiungere il convertitore alla DeserializeObjectchiamata se si utilizza l' [JsonConverter]attributo nella proprietà list nella classe, come mostrato nella risposta sopra. Se non utilizzi l'attributo, allora sì, dovresti passare il convertitore a DeserializeObject.
Brian Rogers

1
@ShaunLangley Per fare in modo che il convertitore utilizzi un array invece di un elenco, modifica tutti i riferimenti a List<T>nel convertitore in T[]e .Countpassa a .Length. dotnetfiddle.net/vnCNgZ
Brian Rogers

6

Ci stavo lavorando da anni e grazie a Brian per la sua risposta. Tutto quello che sto aggiungendo è la risposta di 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

poi nella tua classe:

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

Spero che questo ti faccia risparmiare tempo


Errori di battitura: <JsonConverter (GetType (SingleValueArrayConverter (Of YourObject)))> _ Proprietà pubblica YourLocalName As List (Of YourObject)
GlennG

3

Come variazione minore alla grande risposta di Brian Rogers , ecco due versioni ottimizzate di SingleOrArrayConverter<T>.

In primo luogo, ecco una versione che funziona per tutti List<T>per ogni tipo Tche non è di per sé una raccolta:

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;
    }
}

Può essere utilizzato come segue:

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);

Appunti:

  • Il convertitore evita la necessità di precaricare l'intero valore JSON in memoria come una JTokengerarchia.

  • Il convertitore non si applica agli elenchi i cui elementi sono anche serializzati come raccolte, ad es List<string []>

  • L' canWriteargomento booleano passato al costruttore controlla se ri-serializzare gli elenchi di un singolo elemento come valori JSON o come array JSON.

  • Il convertitore ReadJson()utilizza existingValueif pre-allocato in modo da supportare il popolamento dei membri dell'elenco get-only.

In secondo luogo, ecco una versione che funziona con altre raccolte generiche come 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();
        }
    }
}

Quindi, se il tuo modello utilizza, ad esempio, uno ObservableCollection<T>per alcuni T, puoi applicarlo come segue:

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; }
}

Appunti:

  • Oltre alle note e alle limitazioni per SingleOrArrayListConverter, il TCollectiontipo deve essere di lettura / scrittura e deve avere un costruttore senza parametri.

Giocare demo con i test di unità di base qui .


0

Ho avuto un problema molto simile. La mia richiesta Json era completamente sconosciuta per me. Lo sapevo solo.

Ci sarà un objectId in esso e alcune coppie di valori chiave anonime E array.

L'ho usato per un modello EAV che ho fatto:

La mia richiesta JSON:

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

La mia classe ho definito:

[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; }
}

e ora che voglio deserializzare attributi sconosciuti con il suo valore e gli array al suo interno, il mio convertitore ha questo aspetto:

   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;
    }

Così ora ogni volta che ottengo un AnonymObject posso iterare attraverso il dizionario e ogni volta che c'è il mio Flag "ValueDummyForEAV" passo alla lista, leggo la prima riga e divido i valori. Dopodiché cancello la prima voce dalla lista e proseguo con l'iterazione dal Dizionario.

Forse qualcuno ha lo stesso problema e può usarlo :)

Saluti Andre


0

Puoi usare un JSONConverterAttributeas trovato qui: http://james.newtonking.com/projects/json/help/

Presumendo che tu abbia una classe che assomiglia

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; }
}

Decoreresti la proprietà della categoria come mostrato qui:

    [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();
  }
}

Grazie per questo, ma ancora non risolve il problema. Quando arriva un array effettivo, genera comunque un errore prima che il mio codice possa essere eseguito anche per un oggetto che ha un array effettivo. 'Informazioni aggiuntive: token imprevisto durante la deserializzazione dell'oggetto: String. Percorso "[2] .category [0]", riga 17, posizione 27. "
Robert McLaws

+ "\" evento \ ": \" elaborato \ ", \ n" + "} \ n" + "]";
Robert McLaws,

Ha elaborato bene il primo oggetto e non ha gestito nessun array magnificamente. Ma quando ho creato un array per il 2 ° oggetto, non è riuscito.
Robert McLaws,

@AdvancedREI Senza vedere il tuo codice, immagino che stai lasciando il lettore posizionato in modo errato dopo aver letto il JSON. Invece di provare a usare direttamente il lettore, è meglio caricare un oggetto JToken dal lettore e andare da lì. Vedi la mia risposta per un'implementazione funzionante del convertitore.
Brian Rogers

Dettagli molto migliori nella risposta di Brian. Usa quello :)
Tim Gabrhel

0

Per gestire questo devi usare un JsonConverter personalizzato. Ma probabilmente lo avevi già in mente. Stai solo cercando un convertitore che puoi usare immediatamente. E questo offre più di una semplice soluzione per la situazione descritta. Faccio un esempio con la domanda posta.

Come usare il mio convertitore:

Posiziona un attributo JsonConverter sopra la proprietà. 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; }
}

E questo è il mio convertitore:

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();
        }
    }
}

E questo convertitore utilizza la seguente classe:

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);
        }
    }
}

Cosa fa esattamente? Se si inserisce l'attributo del convertitore, il convertitore verrà utilizzato per questa proprietà. Puoi usarlo su un oggetto normale se ti aspetti un array json con 1 o nessun risultato. Oppure lo usi su un oggetto in IEnumerablecui ti aspetti un oggetto json o un array json. (Sappi che un array- object[]- è un IEnumerable) Uno svantaggio è che questo convertitore può essere posizionato solo sopra una proprietà perché pensa di poter convertire tutto. E stai attento . A stringè anche un fileIEnumerable .

E offre più di una risposta alla domanda: se cerchi qualcosa per id, sai che otterrai un array con uno o nessun risultato. IlToObjectCollectionSafe<TResult>() metodo può gestirlo per te.

È utilizzabile per Single Result vs Array usando JSON.net e gestisce sia un singolo elemento che un array per la stessa proprietà e può convertire un array in un singolo oggetto.

L'ho fatto per le richieste REST su un server con un filtro che restituiva un risultato in un array ma volevo ottenere il risultato come un singolo oggetto nel mio codice. E anche per una risposta del risultato OData con risultato espanso con un elemento in un array.

Divertiti con esso.


-2

Ho trovato un'altra soluzione in grado di gestire la categoria come stringa o array utilizzando object. In questo modo non ho bisogno di fare confusione con il serializzatore json.

Per favore, dai un'occhiata se hai tempo e dimmi cosa ne pensi. https://github.com/MarcelloCarreira/sendgrid-csharp-eventwebhook

Si basa sulla soluzione su https://sendgrid.com/blog/tracking-email-using-azure-sendgrid-event-webhook-part-1/ ma ho anche aggiunto la conversione della data dal timestamp, aggiornato le variabili per riflettere l'attuale modello SendGrid (e ha fatto funzionare le categorie).

Ho anche creato un gestore con autenticazione di base come opzione. Vedi i file ashx e gli esempi.

Grazie!

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.