Боремся с "union types"
Когда чуть-чуть поднимаешь голову как разработчик, начинают встречаться на твоем пути всякие задачи, о существовании которых не всегда мог предположить. Часто эти проблемы вызваны сторонними разработчиками и даже не столько в своем проекте, сколько в чужом, от которого что-то зависит. Например, данные.
Еще интереснее, когда твой код работает, а потом, в какой-то момент, перестает или встречает наконец ту проблему, которая была заложена когда-то, кем-то, потому что технологии, которыми там пользуются, позволяют это сделать. О чем же мы говорим?
Начнем, как водится с проблемы.
Допустим, мы разрабатываем приложение, которое получает данные по api из стороннего сервиса. Предположим (хотя это и не редкость), api плохо задокументирован и ориентироваться в получаемых объектах приходится "на ощупь".
Итак, мы написали модели для объектов, начинаем работать с api и в какой-то момент ловим исключение (мы же не эти, мы обрабатываем исключения). Смотрим в логах, а там ошибка в парсинге данных. Странно, вроде все было правильно на этапе подготовки моделей. Вникаем в проблему глубже и оказывается, что объект, который приходит в большой модели может принимать не два состояния: либо он есть, либо null, а целых четыре! (тут можно подставить другое число, но принцип останется тем же).
Справедливости ради, с точки зрения получаемого значения разницы между пустым объектом{}иnullнет, они равнозначно игнорируются (или нет, в зависимости от конфигурации) конвертером.
Вот живой пример, с которым пришлось столкнуться нашей команде в процессе разработки:
Есть некая модель, отвечающая за шаги (задачи). А есть другая модель, которая отвечает за наполнение и настройку шагов (задач), называется моделька StepSource. Это не особо важно в контексте рассказа, просто знайте.
У этой модельки много есть всякого, но что нас сегодня интересует - объект Block, а внутри него объект Source. Пока все нормально, модель с несколькими вложениями.
Дальше, у модели Source есть свойство (поле, параметр, you-name-it) options, которое:
// может принимать форму массива с объектами:
"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": "Код верный"
}
]
//может быть просто объектом с несколькими полями
"options": {
"is_checkbox": false,
"is_randomize_rows": true,
"is_randomize_columns": false,
"sample_size": -1
}// может быть пустым объектом:
"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;
}Здесь обратим внимание на два момента:
- Мы определяем сразу возможные варианты:
- когда внутри находится список объектов
- когда внутри только один объект (другого типа)
- когда внутри ничего нет
- Наличие аннотации
[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:
Он решает несколько задач. Во-первых, теперь можно и прочитать и записать значение. То есть, можно как получить, так и отправить объект; полноценно его использовать.
Во-вторых, внутри он определяет, что получено и какой тип должен получиться на выходе.
var token = JToken.Load(reader);— получаем неопределенный объект- Определяем, является ли полученный объект списком (коллекцией), если да, то сериализуем в список объектов
OptionItemи возвращаем новый объектOptionsWrapperсо свойствомList<OptionItem>.
if (token.Type == JTokenType.Array)
{
var list = token.ToObject<List<OptionItem>>(serializer);
return new OptionsWrapper { OptionsList = list };
}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, увы
По такому же принципу можно обрабатывать и бОльшее число вариантов от внешних сервисов. Однако, пожелаем вам пореже с такими "чудесами" сталкиваться.