Введение
Технически это продолжение публикации Как не дать пользователю заснуть во время загрузки большого набора данных. Провожу "капитальный рефакторинг" корпоративной системы, которая используется 20 лет. Некоторые свои решения излагаю здесь в надежде, что кому-то пригодится, а также чтобы узнать что-то новое из комментариев.
Проблема №1
У нас есть старая база данных, в которой за 20 лет чего только не завелось. Также в ней первичные ключи из 2 полей, когда-то то было актуально. Новую систему приходится строить на ней, чтобы можно было раньше начать ею пользоваться, какое-то время даже параллельно, постепенно перенося функциональность. Когда-то, возможно, эта структура базы данных будет заменена тоже. Мы хотим, чтобы модели ни на сервере приложений, ни на клиенте ничего не знали об устройстве БД. Очевидное решение - загружать из БД и передавать на клиента объекты с ключами БД, а использовать их через интерфейсы, без ключей.
Проблема №2
Объекты для таблицы для таблицы не должны содержать полную структуру, достаточно нескольких отображаемых полей для визуального контакта, поиска и сортировки. С другой стороны, не хотелось бы заводить разные классы для вывода в таблицу и загрузки единичного объекта. Логично использовать один класс, но заполнять его сущности в зависимости от потребностей, пользуясь для доступа разными интерфейсами.
Что можно сделать штатными средствами System.Text.Json?
Рассмотрим несколько вариантов.
public interface IPreCat
{
string Breed { get; }
}
public interface ICatForListing
{
string Breed { get; }
string Name { get; }
}
public interface IPaw
{
Longitude Longitude { get; }
Latitude Latitude { get; }
List<IClaw> Claws { get; }
}
public interface IClaw
{
double Sharpness { get; }
}
public interface IMustache
{
double Length { get; }
}
public interface ITail
{
double Length { get; }
double Thickness { get; }
}
public class StringIntId
{
public string StringId { get; set; }
public int IntId { get; set; }
}
public class Cat: PreCat, ICat, ICatForListing
{
...
public StringIntId Id { get; set; }
public string Name { get; set; }
public List<IPaw> Paws { get; init; } = new();
public IMustache? Mustache { get; set; } = null;
public ITail? Tail { get; set; } = null;
public override string ToString()
{
return $"{{{GetType().Name}:\n\tbreed: {Breed},\n\tname: {Name},\n\tpaws: [\n\t\t{string.Join(",\n\t\t", Paws)}\n\t],\n\tmustache: {Mustache},\n\ttail: {Tail}\n}}";
}
}
...
[Test]
public void Test1()
{
// (1)
Cat cat = CreateCat() as Cat;
Console.WriteLine(cat);
// (2)
string json = JsonSerializer.Serialize<Cat>(cat);
Console.WriteLine(json);
// (3)
json = JsonSerializer.Serialize(cat);
Console.WriteLine(json);
// (4)
json = JsonSerializer.Serialize<ICatForListing>(cat);
Console.WriteLine(json);
// (5)
json = JsonSerializer.Serialize<ICat>(cat);
Console.WriteLine(json);
}
Строим котика (1), смотрим, как он выводится строкой:
{Cat:
breed: Havana,
name: Murka,
paws: [
{Paw: longitude: Front, latitude: Left, claws: 5},
{Paw: longitude: Rear, latitude: Left, claws: 4},
{Paw: longitude: Front, latitude: Right, claws: 5},
{Paw: longitude: Rear, latitude: Right, claws: 3}
],
mustache: ,
tail: {Tail: length:25, thickness: 3}
}
Пишем его в JSON типизируя своим классом (2):
{"Id":{"StringId":"weadsfdfadsgsag","IntId":1},"Name":"Murka",
"Paws":[{"Longitude":1,"Latitude":1,
"Claws":[{"Sharpness":1},{"Sharpness":1},
{"Sharpness":1},{"Sharpness":1},{"Sharpness":1}]},
{"Longitude":2,"Latitude":1,
"Claws":[{"Sharpness":2},{"Sharpness":2},
{"Sharpness":2},{"Sharpness":2}]},
{"Longitude":1,"Latitude":2,
"Claws":[{"Sharpness":1},{"Sharpness":1},
{"Sharpness":1},{"Sharpness":1},{"Sharpness":1}]},
{"Longitude":2,"Latitude":2,
"Claws":[{"Sharpness":2},{"Sharpness":2},{"Sharpness":2}]}],
"Mustache":null,"Tail":{"Length":25,"Thickness":3},"Breed":"Havana"}
Пишем его в JSON типизируя object
(3):
{"Id":{"StringId":"weadsfdfadsgsag","IntId":1},"Name":"Murka",
"Paws":[{"Longitude":1,"Latitude":1,
"Claws":[{"Sharpness":1},{"Sharpness":1},
{"Sharpness":1},{"Sharpness":1},{"Sharpness":1}]},
{"Longitude":2,"Latitude":1,
"Claws":[{"Sharpness":2},{"Sharpness":2},
{"Sharpness":2},{"Sharpness":2}]},
{"Longitude":1,"Latitude":2,
"Claws":[{"Sharpness":1},{"Sharpness":1},
{"Sharpness":1},{"Sharpness":1},{"Sharpness":1}]},
{"Longitude":2,"Latitude":2,
"Claws":[{"Sharpness":2},{"Sharpness":2},{"Sharpness":2}]}],
"Mustache":null,"Tail":{"Length":25,"Thickness":3},"Breed":"Havana"}
Видим, что результаты (1) и (2) - одинаковые. Даже сюда попал Id
, но только у самого котика, так как усы, лапы, когти и хвост представлены интерфейсами. Если бы мы их представили реализациями, то мы не смогли бы либо обратиться через интерфейсы к самому котику, либо эти интерфейсы зависели бы от реализаций частей кота. Оба эти варианта нам не подходят. Также нам придётся тащить в таблицу много лишних свойств. И ещё не очень хорошо, по-моему, что enum
попадает в виде числа (например, ..."Longitude":1,"Latitude":1...
, здесь они означают "перед-зад" и "лево-право"). В принципе, можно настроить, чтобы значения по умолчанию (default) не передавались, но, например, у нас список лап создаётся в конструкторе, и вообще, объекты могли быть загружены раньше и полностью, и уже потом вдруг понадобилось их в таблицу на клиента передать.
Запишем в JSON упрощённого кота для таблицы (3):
public interface ICatForListing
{
string Breed { get; }
string Name { get; }
}
{"Breed":"Havana","Name":"Murka"}
Что же, получилось коротко, но без ключей.
И наконец, запишем полного кота (4):
{"Name":"Murka",
"Paws":[{"Longitude":1,"Latitude":1,
"Claws":[{"Sharpness":1},{"Sharpness":1},
{"Sharpness":1},{"Sharpness":1},{"Sharpness":1}]},
{"Longitude":2,"Latitude":1,
"Claws":[{"Sharpness":2},{"Sharpness":2},
{"Sharpness":2},{"Sharpness":2}]},
{"Longitude":1,"Latitude":2,
"Claws":[{"Sharpness":1},{"Sharpness":1},
{"Sharpness":1},{"Sharpness":1},{"Sharpness":1}]},
{"Longitude":2,"Latitude":2,
"Claws":[{"Sharpness":2},{"Sharpness":2},
{"Sharpness":2}]}],
"Mustache":null,
"Tail":{"Length":25,"Thickness":3}}
Само собой, нет ключей, пропала порода (Breed
), так как мы её в ICat
не включили по какой-то причине.
Получается, что ни один вариант нас не удовлетворил, и нам ничего другого не остаётся делать, кроме как написать ...
... кастомный конвертер
На всякий случай напомню, как кастомный конвертер встраивается в классы сериализации JSON, предоставляемые в пространстве имен System.Text.Json. С точки зрения паттернов проектирования здесь применяется "Стратегия". Объект нашего конвертера (или фабрики конвертеров, что мы будем на самом деле использовать) добавляется в список Converters
объекта класса JsonSerializerOptions
, который передаётся методам JsonSerializer.Serialize(...)
и JsonSerializer.Deserialize(...)
. Наш конвертер должен уметь отвечать на вопрос, конвертирует ли он объекты запрошенного типа. Если да, то такие объекты будут в дальнейшем передаваться ему.
OurCuctomConverter converter = new();
JsonSerializerOptions options = new();
options.Converters.Add(converter);
string json = JsonSerializer.Serialize(cat, options);
Подумаем, что мы хотели бы получить.
При сериализации:
Чтобы можно было зарегистрировать любое количество интерфейсов и классов, и, если класс или его предок, или какой-то из их интерфейсов зарегистрированы, то свойства из этого зарегистрированного типа попадают в JSON.
Чтобы помеченное специальным атрибутом [Key] свойство также попадало в JSON.
При десериализации:
Иметь возможность предоставлять готовый объект для его заполнения из JSON.
Если какое-то свойство целевого объекта само является и уже присвоено, заполнять его, а не присваивать новый объект.
Если всё же необходимо создать новый объект, то использовать механизм внедрения зависимостей.
Для массива верхнего уровня, то есть когда мы получаем JSON:
[{...}, {...}, ..., {...}]
, хотим, чтобы можно было заполнить существующий список/коллекцию, причём, одним из двух способов: заполняя заново или дописывая в хвост. Второй вариант можно использовать, например, чтобы подгружать данные при большом их количестве (см. https://habr.com/ru/post/653395/)
В обоих случаях:
Так как нужно конвертировать несколько разных типов, наш конвертер должен быть не
JsonConverter
, аJsonConverterFactory
.
Итак, наследуем от System.Text.Json.Serialization.JsonConverterFactory
:
public class TransferJsonConverterFactory : JsonConverterFactory
{
Нам нужно реализовать абстрактные методы:
public abstract bool CanConvert(Type typeToConvert);
public abstract JsonConverter?
CreateConverter(Type typeToConvert, JsonSerializerOptions options);
К реализации вернёмся позже, когда рассмотрим, как регистрировать типы и внедрять зависимости.
Внедрение зависимостей и регистрация типов
Попытаемся это совместить. Причиной для этого может служить, то, что некоторые типы могут быть уже зарегистрированы на хосте как сервисы, а другие - нет. Так как в системном IServiceProvider
нам уже ничего не зарегистрировать, заведём свой, а системный, если он доступен, будем использовать. Для этого создадим класс, реализующий этот интерфейс:
internal class ServiceProviderImpl : IServiceProvider
{
private readonly IServiceProvider? _parentServiceProvider;
private readonly Dictionary<Type, Func<IServiceProvider, object>?> _services = new();
public ServiceProviderImpl(IServiceProvider? parentServiceProvider = null)
{
_parentServiceProvider = parentServiceProvider;
}
public void AddTransient(Type key, Func<IServiceProvider, object>? func)
{
_services[key] = func;
}
public bool IsRegistered<T>()
{
return IsRegistered(typeof(T));
}
public bool IsRegistered(Type serviceType)
{
return _services.ContainsKey(serviceType);
}
public List<Type> GetRegistered()
{
return _services.Keys.ToList();
}
#region Реализация IServiceProvider
public object? GetService(Type serviceType)
{
if (_services.ContainsKey(serviceType))
{
if (_services[serviceType] is {} service)
{
return service.Invoke(this);
}
if (serviceType.IsClass
&& serviceType.GetConstructor(new Type[] { }) is {})
{
object? result = _parentServiceProvider?
.GetService(serviceType);
if (result is {})
{
return result;
}
return Activator.CreateInstance(serviceType);
}
}
return _parentServiceProvider?.GetService(serviceType);
}
#endregion
}
Мы ассоциировали некоторый внешний сервис-провайдер. Мы можем зарегистрировать тип с помощью одного из перегруженных методов AddTransient(...)
. Имя метода нам как бы напоминает, что объект должен создаваться при каждом вызове GetService(...)
или GetRequiredService(...)
. Мы можем передать истанциируемый тип или фабричный метод, тогда будет создаваться этот тип или работать фабричный метод, независимо от внешнего сервис-провайдера. Если передаём только регистрируемый тип, то пытаемся получить новый объект из внешнего сервис-провайдера, а если там его не делают, то вызвать публичный конструктор без параметров. Также наша реализация отвечает на вопрос, зарегистрирован ли тип.
Нашу реализацию сервис-провайдера мы включаем отношением композиции:
internal ServiceProviderImpl ServiceProvider { get; init; }
public TransferJsonConverterFactory(IServiceProvider? serviceProvider)
{
ServiceProvider = new ServiceProviderImpl(serviceProvider);
}
И вот перед нами реализация первого абстрактного метода:
public override bool CanConvert(Type typeToConvert)
{
// Если вызвана десериализация для одного из типов-заглушек:
// AppendableListStub<> или RewritableListStub<>,
if (ServiceProvider.GetRegistered().Any(t => typeof(ListStub<>)
.MakeGenericType(new Type[] { t })
.IsAssignableFrom(typeToConvert))
)
{
return true;
}
return ServiceProvider.IsRegistered(typeToConvert);
}
Заглушки, которые проверяются сначала, используются для десеризации JSON-массива, как мы хотели выше. То есть, если мы десериализуем в новый список, то просто вызываем десериализатор с нужным типом, и наш кастомный конвертер вообще не участвует. Например:
List<Cat> cats = JsonSerializer.Deserialize<List<Cat>>(json);
В случае, если мы предоставили свой список, мы поступаем по-другому. Например, для заполнения списка заново:
ObservableCollection<ICatForListing> cats;
...
TransferJsonConverterFactory serializer =
new TransferJsonConverterFactory(null)
.AddTransient<ICatForListing, Cat>()
;
JsonSerializerOptions options = new();
options.Converters.Add(serializer);
serializer.Target = cats;
JsonSerializer.Deserialize<RewritableListStub<ICatForListing>>(
jsonString, options);
При этом, если в списке есть объекты которые мы десериализуем нашим конвертером, то их тушки используются повторно. Такое вот переселение душ.
Обратим внимание на свойство:
public object? Target
{ ... }
Как раз сюда мы цепляем существующий объект, чтобы заполнять его.
А вот реализация второго абстрактного метода:
public override JsonConverter? CreateConverter(Type typeToConvert,
JsonSerializerOptions options)
{
JsonConverter converter;
Type? type = ServiceProvider.GetRegistered().Where(
t => typeof(ListStub<>).MakeGenericType(new Type[] { t })
.IsAssignableFrom(typeToConvert)
).FirstOrDefault((Type?)null);
if (type is not null)
{
converter = (JsonConverter)Activator.CreateInstance(
typeof(ListDeserializer<>)
.MakeGenericType(new Type[] { type }),
args: new object[] { this,
typeToConvert == typeof(AppendableListStub<>)
.MakeGenericType(new Type[] { type }) }
)!;
}
else
{
converter = (JsonConverter)Activator.CreateInstance(
typeof(DtoConverter<>).MakeGenericType(
new Type[] { typeToConvert }),
args: new object[] { this }
)!;
}
return converter;
}
Здесь действуем почти так же, как в случае CanConvert(...)
: если запрашивается один из типов-заглушек для списков, создаём конвертер ListDeserializer<>
, в противном случае - DtoConverter<>
. Оба класса являются наследниками JsonConverter<>
.
Не будем их код здесь приводить, так как он достаточно объёмный. При желании его можно посмотреть в исходниках.
Обратим только внимание на то, что наша фабрика ассоциируется в эти объекты, поэтому, хотя мы не имеем к ним прямого доступа, как и они друг к другу, но через фабрику осуществляется доступ к зарегистрированным типам и целевому объекту.
Вывод
Кастомные конвертеры нам строить и жить помогают.
Полезные ссылки
Как написать настраиваемые преобразователи для сериализации JSON (маршалинг) в .NET
brager17
Немножко вброшу про различные возможности проверки на null в современном C# ;)
sharplab
a-tk
А как же тривиальное?
leksiq Автор
Если бы не return, то да
a-tk
А какая разница? Всё равно будет ветка, которая будет возвращать значение.
Invoke
вернётnull
Дальше есть
??