.NET
April 17

Боремся с "union types"

Когда чуть-чуть поднимаешь голову как разработчик, начинают встречаться на твоем пути всякие задачи, о существовании которых не всегда мог предположить. Часто эти проблемы вызваны сторонними разработчиками и даже не столько в своем проекте, сколько в чужом, от которого что-то зависит. Например, данные.

Еще интереснее, когда твой код работает, а потом, в какой-то момент, перестает или встречает наконец ту проблему, которая была заложена когда-то, кем-то, потому что технологии, которыми там пользуются, позволяют это сделать. О чем же мы говорим?

Начнем, как водится с проблемы.

Допустим, мы разрабатываем приложение, которое получает данные по api из стороннего сервиса. Предположим (хотя это и не редкость), api плохо задокументирован и ориентироваться в получаемых объектах приходится "на ощупь".

Итак, мы написали модели для объектов, начинаем работать с api и в какой-то момент ловим исключение (мы же не эти, мы обрабатываем исключения). Смотрим в логах, а там ошибка в парсинге данных. Странно, вроде все было правильно на этапе подготовки моделей. Вникаем в проблему глубже и оказывается, что объект, который приходит в большой модели может принимать не два состояния: либо он есть, либо null, а целых четыре! (тут можно подставить другое число, но принцип останется тем же).

Справедливости ради, с точки зрения получаемого значения разницы между пустым объектом {} и null нет, они равнозначно игнорируются (или нет, в зависимости от конфигурации) конвертером.

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

Есть некая модель, отвечающая за шаги (задачи). А есть другая модель, которая отвечает за наполнение и настройку шагов (задач), называется моделька StepSource. Это не особо важно в контексте рассказа, просто знайте.
У этой модельки много есть всякого, но что нас сегодня интересует - объект Block, а внутри него объект Source. Пока все нормально, модель с несколькими вложениями.
Дальше, у модели Source есть свойство (поле, параметр, you-name-it) options, которое:

1.

// может принимать форму массива с объектами:
"options": [
                        {
                            "is_correct": false,
                            "text": "a + b = 5",
                            "feedback": "В данном случае числа будут читаться как строки и не сложатся"
                        },
                        {
                            "is_correct": true,
                            "text": "a + b = 23",
                            "feedback": ""
                        },
                        {
                            "is_correct": false,
                            "text": "a + b = 2",
                            "feedback": "Посмотрите внимательно на код"
                        },
                        {
                            "is_correct": false,
                            "text": "a + b = 3",
                            "feedback": "Посмотрите внимательно на код"
                        },
                        {
                            "is_correct": false,
                            "text": "a + b = 32",
                            "feedback": "Посмотрите внимательно на код"
                        },
                        {
                            "is_correct": false,
                            "text": "Будет ошибка",
                            "feedback": "Код верный"
                        }
                    ]

2.

//может быть просто объектом с несколькими полями
"options": {
                        "is_checkbox": false,
                        "is_randomize_rows": true,
                        "is_randomize_columns": false,
                        "sample_size": -1
                    }

3.

// может быть пустым объектом:
"options": {}

4. А может и отсутствовать вовсе!

И вот впервые встретившись с таким... многообразием вариантов, почему-то первым на ум приходит мем:

Но, если отбросить эмоции, то проблему надо решать. А как правильно? Или даже по-другому: а как её решить? Как обработать четыре абсолютно разных варианта?

Решение

Самое простое, что может прийти в голову в таком случае — это представить тип свойства Source как object. Но проблему это не решит, так как внутри тоже могут быть варианты.

Можно представить его как dynamic, но проблема останется и все равно надо будет заморачиваться с парсингом входящих в него объектов, что в свою очередь принесет множество других проблем.

Прежде чем мы обратимся к нашему решению, пару слов о том, с чем мы имеем дело. Это — так называемые union types, части объектов, которые используются в составных объектах в языках программирования, которые это позволяют. Из популярного это JavaScript и Python. А вот в языках со строгой типизацией, вроде C#, Java и других придется выкручиваться, обрабатывая все многообразие форм, которое может вернуться из такого не строгого api.

Итак, непосредственно решение.

Для начала, общая структура получаемого объекта:

public class StepSource
{
    [JsonProperty("id")]
    public int? Id { get; set; }

    [JsonProperty("block")]
    public Block? Block { get; set; }
    // другие свойства
}
public class Block
{
    [JsonProperty("name")]
    public string? Name { get; set; }
    
    [JsonProperty("options")]
    public BlockOptions? Options { get; set; }
    
    [JsonProperty("source")]
    public Source? Source { get; set; }
	
	// другие свойства
}
public class Source
 {
     [JsonProperty("options")]
     public OptionsWrapper? Options { get; set; }
     
     // другие свойства
 }

На этом этапе немного задержимся. Как видно, и у Block и у Source есть свойство Options, которое в некоторых деталях схоже между собой. Это порождает желание объединить их. Но так делать не надо. Потом это может сыграть злую шутку в самый неподходящий момент.

Продолжим. Проблема, обозначенная выше, касается свойства Options у объекта Source. Значит, нам нужно на этапе парсинга правильно определить тип получаемого значения и привести его к нужному объекту.

Для этой задачи определим следующий класс:

[JsonConverter(typeof(OptionsConverter))]
public class OptionsWrapper
{
    public List<OptionItem>? OptionsList { get; set; }
    public OptionSettings? Settings { get; set; }
    public bool IsEmpty => OptionsList == null && Settings == null;
}

Здесь обратим внимание на два момента:

  1. Мы определяем сразу возможные варианты:
    1. когда внутри находится список объектов
    2. когда внутри только один объект (другого типа)
    3. когда внутри ничего нет
  2. Наличие аннотации [JsonConverter(typeof(OptionsConverter))]

Теперь рассмотрим, что у нас получается.

 public class OptionItem
 {
     [JsonProperty("is_correct")]
     public bool IsCorrect { get; set; }
     
     [JsonProperty("text")]
     public string Text { get; set; }
     
     [JsonProperty("feedback")]
     public string Feedback { get; set; }
 }
 public class OptionSettings
 {
     [JsonProperty("is_checkbox")]
     public bool IsCheckbox { get; set; }
     
     [JsonProperty("is_randomize_rows")]
     public bool IsRandomizeRows { get; set; }
     
     [JsonProperty("is_randomize_columns")]
     public bool IsRandomizeColumns { get; set; }
     
     [JsonProperty("sample_size")]
     public int SampleSize { get; set; }
 }

И самое главное, кастомный (самописный) JsonConverter:

Картинкой оказалось нагляднее.

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

Во-вторых, внутри он определяет, что получено и какой тип должен получиться на выходе.

Пройдемся по блокам код:

  1. var token = JToken.Load(reader); — получаем неопределенный объект
  2. Определяем, является ли полученный объект списком (коллекцией), если да, то сериализуем в список объектов OptionItem и возвращаем новый объект OptionsWrapper со свойством List<OptionItem>.
if (token.Type == JTokenType.Array)
{
    var list = token.ToObject<List<OptionItem>>(serializer);
    return new OptionsWrapper { OptionsList = list };
}

3.

if (token.Type == JTokenType.Object)
{
    var obj = (JObject)token;
    if (!obj.HasValues)
        return new OptionsWrapper(); 
    var props = obj.Properties()
                   .Select(p => p.Name)
                   .ToList();
    if (props.Contains("is_checkbox") 
     || props.Contains("is_randomize_rows") 
     || props.Contains("sample_size"))
    {
        var settings = obj.ToObject<OptionSettings>(serializer);
        return new OptionsWrapper { Settings = settings };
    }
}

А если это объект, то продолжаем проверку.

Если у объекта нет значений, то возвращаем пустой объект OptionsWrapper.

А если у объекта есть свойства с именами (указаны внутри блока if), то создается и возвращается новый объект OptionsWrapper с созданным свойством OptionSettings

В обратную сторону это работает так же: проверяем, что в себе "несет" OptionsWrapper и в зависимости от этого сериализуем объект, список или пустое объявление json ('{}')

Отметим так же, что это работает с библиотекой Newtonsoft.Json и не работает (не работало) с System.Text.Json, увы

По такому же принципу можно обрабатывать и бОльшее число вариантов от внешних сервисов. Однако, пожелаем вам пореже с такими "чудесами" сталкиваться.