Работа с библиотекой Newtonsoft.Json на реальном примере. Часть 2

    В первой части статьи был рассмотрен механизм парсинга объектов JSON с динамически изменяющейся структурой. Данные объекты приводились к типам пространства имен newtonsoft.json.linq, и затем преобразовывались в структуры языка C#. В комментариях к первой части было много критики, по большей части обоснованной. Во второй части я постараюсь учесть все замечания и пожелания.



    Далее речь пойдет о подготовке классов для более тонкой настройки преобразования данных, но в начале необходимо вернуться к самому парсингу JSON. Напомню в первой части были использованы методы Parse() и ToObject<Т>() классов JObject и JArray пространства имен newtonsoft.json.linq:

    HttpClient httpClient = new HttpClient();
    string request = "https://api.exmo.com/v1/trades/?pair=BTC_USD,ETH_USD";
    HttpResponseMessage response = 
        (await httpClient.GetAsync(request)).EnsureSuccessStatusCode();
    string responseBody = await response.Content.ReadAsStringAsync();
    
    JObject jObject = JObject.Parse(responseBody);
    Dictionary<string, List<Order>> dict = 
        jObject.ToObject<Dictionary<string, List<Order>>>();
    

    Необходимо отметить, что в пространстве имен newtonsof.json в классе JsonConvert есть статический метод DeserializeObject<>, позволяющий преобразовывать строки напрямую в структуры C#, соответствующие объектам и массивам нотации JSON:

    Dictionary<string, List<Order>> JsonObject = 
        JsonConvert.DeserializeObject<Dictionary<string, List<Order>>>(responseBody);
    List<string> Json_Array = JsonConvert.DeserializeObject<List<string>>(responseBody);
    

    И в дальнейшем в статье будет использован именно этот метод, поэтому в программу нужно добавить using newtonsoft.json.

    Кроме того, есть возможность еще больше сократить количество промежуточных преобразований — после установки библиотеки Microsoft.AspNet.WebApi.Client (так же доступна через NuGet), данные можно будет парсить прямо из потока используя метод ReadAsAsync:

    Dictionary<string, List<Order>> JsonObject = await
        (await httpClient.GetAsync(request)).
        EnsureSuccessStatusCode().Content.
        ReadAsAsync<Dictionary<string, List<Order>>>();
    

    За подсказку спасибо lair.

    Подготовка класса для преобразования


    Вернемся к нашему классу Order:

        class Order
        {
            public int trade_id { get; set; }
            public string type { get; set; }
            public double quantity { get; set; }
            public double price { get; set; }
            public double amount { get; set; }
            public int date { get; set; }
        }
    

    Напомню, он был создан на основе формата, предложенного JSON C# Class Generator`ом. Есть два момента, которые при работе с объектами данного класса могут вызвать сложности.

    Первый — в таком виде свойства нашего класса нарушают правила наименования полей. Ну и кроме того, логично для объекта типа Order ожидать что его идентификатор будет называться OrderID (а не traid_id, как происходило в примерах из первой части). Чтобы связать элемент структуры JSON и свойство класса с произвольным именем, необходимо перед свойством добавить атрибут JsonProperty:

        class Order
        {
            [JsonProperty("trade_id")]
            public int OrderID { get; set; }
            [JsonProperty("type")]
            public string Type { get; set; }
            [JsonProperty("quantity")]
            public double Quantity { get; set; }
            [JsonProperty("price")]
            public double Price { get; set; }
            [JsonProperty("amount")]
            public double Amount { get; set; }
            [JsonProperty("date")]
            public int Date { get; set; }
        }
    

    В результате, значение, соответствующее элементу “trade_id”, будет записано в свойство OrderID, “type” в Type и т.д. Так же атрибут JsonProperty необходим для сериализации / десериализации свойств, имеющих модификаторы private или static — по умолчанию такие свойства игнорируются.
    В итоге, код для составления списка всех OrderID сделок по валютным парам BTC_USD и ETH_USD, может выглядеть следующим образом:

    using HttpClient httpClient = new HttpClient();
    string request = "https://api.exmo.com/v1/trades/?pair=BTC_USD,ETH_USD";
    string responseBody = await
        (await httpClient.GetAsync(request)).
        EnsureSuccessStatusCode().
        Content.ReadAsStringAsync();
    
    Dictionary<string, List<Order>> PairList = 
        JsonConvert.DeserializeObject<Dictionary<string, List<Order>>>(responseBody);
    
    List<int> IDs = new List<int>();
    foreach (var pair in PairList)
        foreach (var order in pair.Value)
            IDs.Add(order.OrderID);
    

    Вторая сложность при работе с данным классом будет заключаться в свойстве Date. Как можно увидеть, JSON C# Class Generator определил элемент “date” как простое целочисленное число. Но гораздо удобнее было бы, чтобы свойство Date нашего класса имело тип специально созданный для дат — DateTime. Как это сделать — будет описано далее.

    Особенности работы с датами


    Начальную фразу статьи в документации по newtonsof.json, с описанием работы с датами, можно примерно перевести как “DateTime в JSON — это тяжко”. Проблема заключается в том, что сама спецификация JSON не содержит информации о том, какой синтаксис необходимо применять для описания даты и времени.

    Все относительно неплохо, когда дата в JSON строке представлена в текстовом виде и формат представления соответствует одному из трех вариантов: “Майкрософт” (в настоящее время считается устаревшим), “JavaScript” (Unix время) и вариант ISO 8601. Примеры допустимых форматов:

    Dictionary<string, DateTime> d = 
        new Dictionary<string, DateTime> { { "date", DateTime.Now } };
    string isoDate = JsonConvert.SerializeObject(d);
                // {"date":"2019-12-19T14:10:31.3708939+03:00"}
    
    JsonSerializerSettings microsoftDateFormatSettings = new JsonSerializerSettings
    {
        DateFormatHandling = DateFormatHandling.MicrosoftDateFormat
    };
    string microsoftDate = JsonConvert.SerializeObject(d, microsoftDateFormatSettings);
                // {"date":"\/Date(1576753831370+0300)\/"}
    
    string javascriptDate = JsonConvert.SerializeObject(d, new JavaScriptDateTimeConverter());
                // {"date":new Date(1576753831370)}
    

    Однако в случае с биржей Exmo, все немного сложнее. В описании API биржи указано, что дата и время указываются в формате Unix (JavaScript). И, теоретически, добавив к нашему свойству Date класса Order функцию преобразования формата из класса JavaScriptDateTimeConverter(), мы должны получить дату, приведенную к типу DateTime:

        class Order
        {
            [JsonProperty("trade_id")]
            public int OrderID { get; set; }
            [JsonProperty("type")]
            public string Type { get; set; }
            [JsonProperty("quantity")]
            public double Quantity { get; set; }
            [JsonProperty("price")]
            public double Price { get; set; }
            [JsonProperty("amount")]
            public double Amount { get; set; }
            [JsonProperty("date", ItemConverterType = typeof(JavaScriptDateTimeConverter))]
            public DateTime Date { get; set; }
        }
    

    Однако в этом случае, при попытке парсинга данных в переменную типа DateTime появляется уже знакомое по первой части статьи исключение Newtonsoft.Json.JsonReaderException. Происходит это по причине того, что функция преобразования класса JavaScriptDateTimeConverter не умеет конвертировать числовые данные в тип DateTime (это работает только для строк).



    Возможный выход из данной ситуации — написать свой собственный класс преобразования форматов. На самом деле такой класс уже есть и его можно использовать, предварительно подключив пространство имен Newtonsoft.Json.Converters (обратите внимание, что обратная функция — конвертирования из DateTime в формат JSON в данном классе не реализована):

        class UnixTimeToDatetimeConverter : DateTimeConverterBase
        {
            private static readonly DateTime _epoch = 
                new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
    
    
            public override void WriteJson(JsonWriter writer, object value, 
                JsonSerializer serializer)
            {
                throw new NotImplementedException();
            }
    
            public override object ReadJson(JsonReader reader, Type objectType, 
                object existingValue, JsonSerializer serializer)
            {
                if (reader.Value == null)
                {
                    return null;
                }
                return _epoch.AddSeconds(Convert.ToDouble(reader.Value)).ToLocalTime();
            }
        }
    

    Остается только подключить нашу функцию к свойству Date класса Order. Для этого необходимо использовать атрибут JsonConverter:

        class Order
        {
            [JsonProperty("trade_id")]
            public int OrderID { get; set; }
            [JsonProperty("type")]
            public string Type { get; set; }
            [JsonProperty("quantity")]
            public double Quantity { get; set; }
            [JsonProperty("price")]
            public double Price { get; set; }
            [JsonProperty("amount")]
            public double Amount { get; set; }
            [JsonProperty("date")]
            [JsonConverter(typeof(UnixTimeToDatetimeConverter))]
            public DateTime Date { get; set; }
        }
    

    Теперь наше свойство Date имеет тип DateTime и мы можем, например, сформировать список сделок, за последние 10 минут:

    HttpClient httpClient = new HttpClient();
    string request = "https://api.exmo.com/v1/trades/?pair=BTC_USD,ETH_USD";
    string responseBody = await
        (await httpClient.GetAsync(request)).
        EnsureSuccessStatusCode().
        Content.ReadAsStringAsync();
    
    Dictionary<string, List<Order>> PairList = 
        JsonConvert.DeserializeObject<Dictionary<string, List<Order>>>(responseBody);
    
    List<Order> Last10minuts = new List<Order>();
    foreach (var pair in PairList)
        foreach (var order in pair.Value)
            if (order.Date > DateTime.Now.AddMinutes(-10))
                Last10minuts.Add(order);
    

    Полный текст программы
    using System;
    using System.Threading.Tasks;
    using System.Collections.Generic;
    using System.Net.Http;
    using Newtonsoft.Json;
    using Newtonsoft.Json.Converters;
    using Newtonsoft.Json.Serialization;
    
    namespace JSONObjects
    {
        class Order 
        {
            [JsonProperty("pair")]
            public string Pair { get; set; }
            [JsonProperty("trade_id")]
            public int OrderID { get; set; }
            [JsonProperty("type")]
            public string Type { get; set; }
            [JsonProperty("quantity")]
            public double Quantity { get; set; }
            [JsonProperty("price")]
            public double Price { get; set; }
            [JsonProperty("amount")]
            public double Amount { get; set; }
            [JsonProperty("date")]
            [JsonConverter(typeof(UnixTimeToDatetimeConverter))]
            public DateTime Date { get; set; }
        }
        class UnixTimeToDatetimeConverter : DateTimeConverterBase
        {
            private static readonly DateTime _epoch = 
                new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
    
    
            public override void WriteJson(JsonWriter writer, object value, 
                JsonSerializer serializer)
            {
                throw new NotImplementedException();
            }
    
            public override object ReadJson(JsonReader reader, Type objectType,
                object existingValue, JsonSerializer serializer)
            {
                if (reader.Value == null)
                {
                    return null;
                }
    
                return _epoch.AddSeconds(Convert.ToDouble(reader.Value)).ToLocalTime();
            }
    
    
        }
        class Program
        {
            public static async Task Main(string[] args)
            {
                using HttpClient httpClient = new HttpClient();
                string request = "https://api.exmo.com/v1/trades/?pair=BTC_USD,ETH_USD";
                string responseBody = await
                    (await httpClient.GetAsync(request)).
                    EnsureSuccessStatusCode().
                    Content.ReadAsStringAsync();
    
                Dictionary<string, List<Order>> PairList = JsonConvert.
                    DeserializeObject<Dictionary<string, List<Order>>>(responseBody);
    
                List<Order> Last10minuts = new List<Order>();
                foreach (var pair in PairList)
                    foreach (var order in pair.Value)
                        if (order.Date > DateTime.Now.AddMinutes(-10))
                            Last10minuts.Add(order);
    
            }
        }
    }
    


    Подмена имен элементов JSON


    Ранее мы работали с командой trades биржи. Данная команда возвращает объекты со следующими полями:

    public class BTCUSD
    {
        public int trade_id { get; set; }
        public string type { get; set; }
        public string quantity { get; set; }
        public string price { get; set; }
        public string amount { get; set; }
        public int date { get; set; }
    }
    

    Команда биржи user_open_orders возвращает очень похожую структуру:

    public class BTCUSD
    {
        public string order_id { get; set; }
        public string created { get; set; }
        public string type { get; set; }
        public string pair { get; set; }
        public string quantity { get; set; }
        public string price { get; set; }
        public string amount { get; set; }
    }
    

    Поэтому имеет смысл адаптировать класс Order, чтобы в него можно было преобразовывать не только данные команды trade, но также и данные команды user_open_orders.

    Отличия заключаются в том, что появился новый элемент pair, содержащий название валютной пары, trade_id заменен на order_id (и теперь это строка), а date стала created и тоже является строкой.

    Начнем с того, что добавим возможность сохранения полей order_id и created в соответствующие поля OrderID и Date класса Order. Для этого подготовим класс OrderDataContractResolver, в котором будет происходить подмена имен полей для парсинга (потребуются пространства имен System.Reflection и Newtonsoft.Json.Serialization):

      class OrderDataContractResolver : DefaultContractResolver
        {
            public static readonly OrderDataContractResolver Instance = 
                new OrderDataContractResolver();
    
            protected override JsonProperty CreateProperty(MemberInfo member, 
                MemberSerialization memberSerialization)
            {
                var property = base.CreateProperty(member, memberSerialization);
                if (property.DeclaringType == typeof(Order))
                {
                    if (property.PropertyName.Equals("trade_id", 
                        StringComparison.OrdinalIgnoreCase))
                    {
                        property.PropertyName = "order_id";
                    }
                    if (property.PropertyName.Equals("date", 
                        StringComparison.OrdinalIgnoreCase))
                    {
                        property.PropertyName = "created";
                    }
                }
                return property;
            }
        }
    

    Далее этот класс необходимо указать в качестве параметра метода DeserializeObject следующим образом:

    Dictionary<string, List<Order>> PairList = 
        JsonConvert.DeserializeObject<Dictionary<string, List<Order>>>(responseBody,
            new JsonSerializerSettings { ContractResolver = 
            OrderDataContractResolver.Instance });
    

    В результате, такая JSON структура, полученная в качестве ответа на команду user_open_orders:

    {"BTC_USD":[{"order_id":"4722868563","created":"1577349229","type":"sell","pair":"BTC_USD","quantity":"0.002","price":"8362.2","amount":"16.7244"}]}
    

    будет преобразована в такую структуру данных:



    Обратите внимание, что для корректной работы программы, тип поля OrderID в классе Order пришлось заменить на string.

    Полный текст программы
    using System;
    using System.Threading.Tasks;
    using System.Collections.Generic;
    using System.Net.Http;
    using Newtonsoft.Json;
    using Newtonsoft.Json.Linq;
    using Newtonsoft.Json.Converters;
    using Newtonsoft.Json.Serialization;
    using System.Reflection;
    
    namespace JSONObjects
    {
        class Order
        {
            [JsonProperty("pair")]
            public string Pair { get; set; }
            [JsonProperty("trade_id")]
            public string OrderID { get; set; }
            [JsonProperty("type")]
            public string Type { get; set; }
            [JsonProperty("quantity")]
            public double Quantity { get; set; }
            [JsonProperty("price")]
            public double Price { get; set; }
            [JsonProperty("amount")]
            public double Amount { get; set; }
            [JsonProperty("date")]
            [JsonConverter(typeof(UnixTimeToDatetimeConverter))]
            public DateTime Date { get; set; }
        }
        class OrderDataContractResolver : DefaultContractResolver
        {
            public static readonly OrderDataContractResolver Instance = 
                new OrderDataContractResolver();
    
            protected override JsonProperty CreateProperty(MemberInfo member, 
                MemberSerialization memberSerialization)
            {
                var property = base.CreateProperty(member, memberSerialization);
                if (property.DeclaringType == typeof(Order))
                {
                    if (property.PropertyName.Equals("trade_id", 
                        StringComparison.OrdinalIgnoreCase))
                    {
                        property.PropertyName = "order_id";
                    }
                    if (property.PropertyName.Equals("date", 
                        StringComparison.OrdinalIgnoreCase))
                    {
                        property.PropertyName = "created";
                    }
                }
                return property;
            }
        }
        class UnixTimeToDatetimeConverter : DateTimeConverterBase
        {
            private static readonly DateTime _epoch = 
                new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
    
    
            public override void WriteJson(JsonWriter writer, object value, 
                JsonSerializer serializer)
            {
                throw new NotImplementedException();
            }
    
            public override object ReadJson(JsonReader reader, Type objectType,
                object existingValue, JsonSerializer serializer)
            {
                if (reader.Value == null)
                {
                    return null;
                }
    
                return _epoch.AddSeconds(Convert.ToDouble(reader.Value)).ToLocalTime();
            }
        }
        class Program
        {
            public static async Task Main(string[] args)
            {
                using HttpClient httpClient = new HttpClient();
                string request = "https://api.exmo.com/v1/trades/?pair=BTC_USD,ETH_USD";
                string responseBody = await
                    (await httpClient.GetAsync(request)).
                    EnsureSuccessStatusCode().
                    Content.ReadAsStringAsync();
    
                Dictionary<string, List<Order>> PairList = 
                    new Dictionary<string, List<Order>>();
                JObject jObject = JObject.Parse(responseBody);
                foreach (var pair in jObject)
                {
                    List<Order> orders = new List<Order>();
                    foreach (var order in pair.Value.ToObject<List<Order>>())
                    {
                        order.Pair = pair.Key;
                        orders.Add(order);
                    }
                    PairList.Add(pair.Key, orders);
                }
    
    
                responseBody = "{\"BTC_USD\":[{\"order_id\":\"4722868563\"," +
                  "\"created\":\"1577349229\",\"type\":\"sell\"," +
                  "\"pair\":\"BTC_USD\",\"quantity\":\"0.002\"," +
                  "\"price\":\"8362.2\",\"amount\":\"16.7244\"}]}";
                Dictionary<string, List<Order>> PairList1 =
                    JsonConvert.DeserializeObject<Dictionary<string, List<Order>>>
                    (responseBody, new JsonSerializerSettings { ContractResolver = 
                    OrderDataContractResolver.Instance });
    
            }
        }
    }
    }



    Как можно заметить, при вызове команды user_open_orders, ответ содежит поле “pair”, в случае же команды trade информация о валютной паре содержится только в значении ключа. Поэтому придется либо заполнить поле Pair уже после парсинга:

    foreach (var pair in PairList)
        foreach (var order in pair.Value)
            order.Pair = pair.Key;
    

    Либо же воспользоваться объектом JObject:

    Dictionary<string, List<Order>> PairList = new Dictionary<string, List<Order>>();
    JObject jObject = JObject.Parse(responseBody);
    foreach (var pair in jObject)
    {
        List<Order> orders = new List<Order>();
        foreach (var order in pair.Value.ToObject<List<Order>>())
        {
            order.Pair = pair.Key;
            orders.Add(order);
        }
        PairList.Add(pair.Key, orders);
    }
    

    Что в конечном итоге приведет к созданию следующей структуры данных:

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 8

      0
      “type” в Type и т.д. Так же атрибут JsonProperty необходим для сериализации / десериализации свойств, имеющих модификаторы private или static — по умолчанию такие свойства игнорируются.

      Если поле в C# называется Type, а в Json type, то он и так приведет
      Ну и я бы не называл эти данные динамическими, все же у них есть точные названия и типы полей, на которые вы опираетесь. Работать с действительно динамическими данными — головная боль та еще
        +1
        За подсказку спасибо liar.

        … а за невнимательность — не спасибо ни разу.

          +1
          в классе JsonConvert есть статический метод DeserializeObject<>

          … который всего лишь обертка вокруг JsonSerializer.Deserialize, который и лучше использовать, если вы делаете много десерилизации.


          Чтобы связать элемент структуры JSON и свойство класса с произвольным именем, необходимо перед свойством добавить атрибут JsonProperty

          … а чтобы не расставлять лишние атрибуты для JSON в "нормальной" конвенции, можно подключить CamelCasePropertyNamesContractResolver.


          Так же атрибут JsonProperty необходим для сериализации / десериализации свойств, имеющих модификаторы private или static — по умолчанию такие свойства игнорируются.

          Не надо десериализовать ничего в static-свойства.


          Поэтому имеет смысл адаптировать класс Order, чтобы в него можно было преобразовывать не только данные команды trade, но также и данные команды user_open_orders.

          А зачем? Хорошо видно, что это решение потом ведет к костылям.

            0
            А зачем? Хорошо видно, что это решение потом ведет к костылям.

            Чтобы не создавать под каждую команду свой класс, например. И вообще, удобно же когда данные оформлены в едином стиле.
              0
              Чтобы не создавать под каждую команду свой класс, например.

              А зачем тогда вообще пользоваться типизованными DTO? Возьмите dynamic.


              И вообще, удобно же когда данные оформлены в едином стиле.

              "Единый стиль" не подразумевает использования одного и того же класса для семантически разных объектов. Сделка и открытый ордер — разные сущности.


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

                0
                Да я согласен — это далеко не лучший пример с точки зрения здравого смысла. Я долго сомневался стоит ли вообще этот раздел размещать. Видимо, не нужно было…
            0

            Алсо.


            _epoch.AddSeconds(Convert.ToDouble(reader.Value)).ToLocalTime()

            DateTimeOffset.FromUnixTimeSeconds


            List<Order> Last10minuts = new List<Order>();
            foreach (var pair in PairList)
            foreach (var order in pair.Value)
            if (order.Date > DateTime.Now.AddMinutes(-10))
            Last10minuts.Add(order);


            PairList
            .SelectMany(pair => pair.Value)
            .Where(order => order.Date > DateTime.Now.AddMinutes(-10))
              0
              Ещё хорошо бы DateTime.Now вынести за пределы запроса, и использовать одно и то же значения для всех сравнений, а не запрашивать у системы новое время каждый раз.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое