Pull to refresh

Кастомный JsonConverter: уменьшаем связность и экономим ресурсы

Reading time 9 min
Views 7.7K

Введение

Технически это продолжение публикации Как не дать пользователю заснуть во время загрузки большого набора данных. Провожу "капитальный рефакторинг" корпоративной системы, которая используется 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

TransferJsonConverter

NuGet: Net.Leksi.TransferJsonConverter 1.1.3

Tags:
Hubs:
+7
Comments 4
Comments Comments 4

Articles