В первой части статьи был рассмотрен механизм парсинга объектов 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);
}

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

Комментарии (7)


  1. Ketovdk
    26.12.2019 15:02

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

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


  1. lair
    26.12.2019 15:55
    +1

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

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


  1. lair
    26.12.2019 16:03

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

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


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

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


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

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


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

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


    1. VokiLook Автор
      26.12.2019 16:17

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

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


      1. lair
        26.12.2019 16:20

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

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


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

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


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


        1. VokiLook Автор
          26.12.2019 16:48

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


  1. lair
    26.12.2019 16:13

    Алсо.


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