Учебник по DtoKit
In English DtoKit Дерево класса Дерево интерфейса Сужающие интерфейсы AddDtoKit(...) DtoBuilder Build<T>() и BuildOfType(Type type) ValueRequestEventArgs Build<T>(object helper) и BuildOfType(Type type, object helper) DtoJsonConverterFactory Сериализация Десериализация

DtoKit

Библиотека DtoKit предназначена для предотвращения зависимости модели и представления приложения, как серверной, так и клиентской частей, от конкретной реализации классов объектов предметной области. В то же время, её использование позволит избежать переноса данных между уровнями приложения с помощью Data Transfer Objects (DTO) или Plain Old CLR Object (POCO). Сказанное относится к построению объектов предметной области, их сериализации в JSON и десериализации из JSON для взаимодействия между клиентом и сервером.

Дерево класса

Деревом класса из предметной области будем называть связный ациклический граф (собственно, дерево), корнем которого является рассматриваемый класс, узлами - агрегированные классы из предметной области, листьями - открытые для записи свойства.

Рассмотрим пример из предметной области, связанной с расписанием движения морских судов.

Для краткости приведём только основные свойства сущностей, хотя они могут содержать и методы, но в данном контексте это неважно.

Для класса ShipCall имеем следующее дерево (имена узлов опущены для простоты восприятия):

Как видим, дерево класса может быть бесконечным.

Дерево интерфейса

Теперь мы можем разработать интерфейсы только для чтения, которые покрывают все свойства в наших классах предметной области и в рамках предметной области, то есть ключи БД нигде фигурировать не будут. Заставим классы их реализовать явно, если потребуется, это не добавит никакой логики. Мы будем редактировать классы, но это ни на что не повлияет, так как новые свойства будут лишь "псевдонимами" свойств класса.

Также сразу учтём, что нам нужно будет неявно всё же использовать ключевые свойства на уровнях загрузки и передачи, хотя бы для запросов клиента к конкретным объектам на сервере (дальше увидим, что не только для этого), поэтому пометим такие свойства специальным атрибутом [Key].

Вот что получилось:

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

Например, дерево интерфейса IShipCall выглядит так (имена узлов опущены для простоты восприятия):

Как видим, дерево интерфейса тоже может быть бесконечным.

Сужающие интерфейсы

Предположим, мы хотим вывести список судозаходов клиенту в виде таблицы. Целиком нам объекты не понадобятся, но и не хочется делать отдельный класс для строки таблицы, так как его "физический" смысл будет неясным.

Пусть нам нужно вывести в таблицу название линии, название парохода, название порта прибытия/отхода, номер вояжа, время прибытия/убытия, дополнительную информацию. Тогда мы делаем новое дерево интерфейсов, которое содержит только эти данные:

Например, дерево интерфейса IShipCallForList выглядит так (имена узлов опущены для простоты восприятия):

Также в классе ShipCall у нас есть свойство AdditionalInfo, которое загружается долго из-за отдельного запроса к БД. Поэтому мы хотим сначала выводить пользователю таблицу, а после этого постепенно обновлять эти ячейки.

Заведём ещё один интерфейс:

Дерево интерфейса IShipCallAdditionInfo выглядит так (имена узлов опущены для простоты восприятия):

AddDtoKit(...)

Этот метод должен быть вызван во время конфигурирования механизма внедрения зависимости.

Через параметр IServiceCollection services нужно передать коллекцию сервисов, предоставляемую хостом, а параметр Action configure должен содержать метод, который регистриует все интерфейсы, которые введены для классов предметной области, в коллекции сервисов, предоставляемой библиотекой DtoKit.

В нашем случае это будет выглядеть так:

Следует иметь в виду, что в данном контексте время жизни может быть только ServiceLifetime.Transient, другие вызовут исключительную ситуацию.

DtoBuilder

Класс DtoBuilder предназначен для загрузки объектов из хранилища, например, БД. Имеет особенности:

Экземпляр DtoBuilder нужно получить через механизм внедрения зависимости, он уже зарегистрирован там при вызове AddDtoKit(...):

Затем использовать одним из двух способов (T - запрашиваемый интерфейс):

В обоих случаях метод вернёт построенный объект. Если нужно загрузить уже существующий объект, его нужно присвоить свойству Target. В противном случае объект будет получен через внедрение зависимости.

Build<T>() и BuildOfType(Type type)

При использовании этого способа нужно подписаться на событие ValueRequest:

При вызове метода Build<T>() происходит обход дерева запрошенного интерфейса в ширину и в каждом узле и листе вызывается обработчик события, которому передаётся аргумент типа ValueRequestEventArgs.

ValueRequestEventArgs

Члены:

public Type RootType { get; } тип запрошенного интерфейса
public string Path { get; } путь от корня дерева класса - имена свойств через /
public bool IsNullable { get; } указывает на возможность присвоить свойству значение null. Основывается на наличии ? в типе свойства; DtoBuilder запрещает присваивать null, если значение равно false
public bool IsLeaf { get; } указывает, что текущий узел в дереве интерфейса является листом. Это означает, что его тип не был зарегистрирован при вызове AddDtoKit(...)
public Type NominalType { get; } тип свойства текущего узла в дереве интерфейса
public object? Value { get; set; } возвращает текущее значение свойства и служит для присвоения нового значения. В случае узла объект уже предоставлен
public bool IsCommited { get; set; } при присвоении значения true узлы и листья поддерева с корнем в данном узле пропускаются.

В случае узла можно никаких действий не предпринимать, так как объект уже присвоен. В случае листа необходимо явно присвоить значение свойству Value, иначе возникнет исключение.

Build<T>(object helper) и BuildOfType(Type type, object helper)

helper - объект произвольного класса, содержащий публичные методы, помеченные особыми атрибутами и имеющие особую метрику.

[Startup] необязательный метод, который в случае наличия вызывается перед началом построения объекта
[Shutdown] необязательный метод, который в случае наличия вызывается после окончания построения объекта
[Before] необязательный метод, который в случае наличия вызывается перед запросом значения каждого узла или листа
[After] необязательный метод, который в случае наличия вызывается после запроса значения каждого узла или листа
[Path("/...")] метод, обязательный для листьев и необязательный для узлов. Если определён для узла, должен возвращать значение аргумента value, если остаётся предоставленный объект, либо null, либо ссылку на другой объект, по решению разработчика. Если не определён для листа, вызывается исключение. Один метод может иметь несколько атрибутов [Path("/...")]. Все пути должны быть различны в пределах класса

Параметры для методов, отмеченных [Before], [After], [Path("/...")]:

string path путь от корня дерева класса
Type type тип свойства текущего узла в дереве интерфейса
object? value исходное значение свойства текущего узла. Для листа имеет значение default. Следует иметь в виду, что если свойство-лист имеет какое-то значение по умолчаниию при создании объекта, на него рассчитывать нельзя, так как оно теряется.
bool isLeaf указывает, что текущий узел в дереве интерфейса является листом. Это означает, что его тип не был зарегистрирован при вызове AddDtoKit(...)
bool isNullable указывает на возможность присвоить свойству значение null. Основывается на наличии ? в типе свойства; DtoBuilder запрещает присваивать null, если значение равно false
ref bool isCommited при присвоении значения true узлы и листья поддерева с корнем в данном узле пропускаются.

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

Например, можно написать программу, которая выведет все пути:

/
/ID_LINE
/ID_SHIPCALL
/AdditionalInfo
/Arrival
/Departure
/Voyage
/Port
/Port/ID_PORT
/Port/Name
/PrevCall
/PrevCall/ID_LINE
/PrevCall/ID_SHIPCALL
/Route
/Route/ID_LINE
/Route/ID_ROUTE
/Route/Line
/Route/Line/ID_LINE
/Route/Line/Name
/Route/Vessel
/Route/Vessel/ID_VESSEL
/Route/Vessel/Brutto
/Route/Vessel/CallSign
/Route/Vessel/Height
/Route/Vessel/Length
/Route/Vessel/Name
/Route/Vessel/Netto
/Route/Vessel/Width
/Route/Vessel/Port
/Route/Vessel/Port/ID_PORT
/Route/Vessel/Port/Name

Видим, что для PrevCall запрашиваются только ключи.

DtoJsonConverterFactory

Класс DtoJsonConverterFactory является фабрикой кастомных Json конвертеров для работы с интерфейсами, зарегистрированными через AddDtoKit(...), а также с их коллекциями стандартными средствами из пространства имён System.Text.Json.*. Имеет особенности:

Экземпляр DtoJsonConverterFactory нужно получить через механизм внедрения зависимости, он уже зарегистрирован там при вызове AddDtoKit(...), создать экземпляр System.Text.Json.JsonSerializerOptions, добавить фабрику в его конвертеры, а затем использовать обычный механизм, подставляя аргумент JsonSerializerOptions:

Сериализация

Свойства, используемые только при сериализации:

public KeysProcessing KeysProcessing { get; set; } Указывает, как поступать с ключевыми свойствами:
  • KeysProcessing.OnlyKeysForRepeats
  • для повторно сериализуемых объектов в рамках текущего Json выводятся только ключевые свойства и специальное поле {..., "$keyOnly": true} (по умолчанию)
  • KeysProcessing.Usual
  • сериализация обычным способом: все открытые для записи свойства класса выводятся в Json
  • KeysProcessing.OnlyKeys
  • выводятся только ключевые свойства и специальное поле {..., "$keyOnly": true}
Значение этого поля уже нельзя поменять после запуска первой сериализации
public bool WithMagic при присвоении значения true в каждый Json-объект добавляется специальное поле {"$magic": "applied", ...}. Это удобно для визуальнй проверки, что все нужные объекты сериализуются именно с помощью DtoJsonConverterFactory

Десериализация

Свойства, используемые только при десериализации:

public bool UseEndOfDataNull { get; set; } при десериализации Json-массива верхнего уровня указывает на возможно частичное содержимое. Последний элемент очередного массива, равный null означает конец коллекции и устанавливает IsEndOfData = true. Тот факт, что передача коллекции будет происходить частями сообщается клиенту отдельно, например, через http-заголовок
public bool IsEndOfData { get; } значение true означает, что UseEndOfDataNull == true и пришёл null, то есть передача коллекции по частям завершена. Тот факт, что передача коллекции будет происходить частями сообщается клиенту отдельно, например, через http-заголовок
public object? Target { get; set; } при присвоении объекта данному свойству Json десериализуется в него, вместо создания нового объекта. Не рекомендуется читать это свойство, так как в процессе десериализации оно меняется непредсказуемо с точки зрения наблюдателя

Также при десериализации Json-массива верхнего уровня можно использовать специальные фиктивные типы для управления заполнением целевой коллекции объектов. В этом случае целевая коллекция обязательно должна существовать и перед десериализацией её нужно присвоить свойству Target. Фиктивный тип указывается в качестве параметра типа при вызове JsonSerializer.Deserialize<...>(...). Возвращаемое значение такого вызова следует игнорировать, так как это просто заглушка, а фактически заполняется коллекция, предварительно присвоенная свойству Target. Смысл использования данной техники в том, чтобы работать на клиенте с ObservableCollection, связанной с интерфейсом пользователя.

RewritableListStub<T> заполняет целевую коллекцию с начала, повторно использую уже содержащиеся в ней объекты, удаляя в конце лишние или добавляя недостающие
AppendableListStub<T> добавляет объекты в конец целевой коллекции. Удобно для заполнения частями.
UpdateableListStub<T> Обновляет объекты целевой коллекции, находя их по ключевым свойствам.
Например, в в примере с дополнительной информацией по судозаходу, мы сначала загружаем судозаходы с пустой дополнительной информацией, а затем загружаем только дополнительную информацию, вписывая её в нужные строки. Так как дополнительная информация по легенде вычисляется медленно, пользователь не ждёт, а получает таблицу быстро, а потом видит и дополнительную информацию.