DTO в JS

Kate

Administrator
Команда форума
Информационные системы предназначены для обработки данных, а DTO (Data Transfer Object) является важным концептом в современной разработке. В “классическом” понимании DTO являются простыми объектами (без логики), описывающими структуры данных, передаваемых “по проводам” между разнесенными процессами (remote processes). Зачастую данные "по проводам" передаются в виде JSON.

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

Тем не менее, DTO являются важным классом объектов в приложениях и в этой статье я покажу JS-код, который на данный момент считаю оптимальным для DTO (в рамках стандартов ECMAScript 2015+).

Структура данных​

Во-первых, в коде должна быть отражена сама структура данных. Лучше всего это делать с использованием классов (аннотации JSDoc помогают ориентироваться в типах данных):

class ConfigEmailAuth {
/** @type {string} */
pass;
/** @type {string} */
user;
}
Это пример простой структуры данных, где каждый атрибут является примитивом. Если некоторые атрибуты сами являются структурами, то класс выглядит примерно так:

class ConfigEmail {
/** @type {ConfigEmailAuth} */
auth;
/** @type {string} */
from;
/** @type {string} */
host;
/** @type {number} */
port;
/** @type {boolean} */
secure;
}

Создание объектов​

Как правило, создание экземпляра DTO в половине случаев связано разбором имеющейся структуры данных, полученной "по проводам" с "другой стороны". Поэтому конструктор DTO получает на вход некоторый JS-объект, из которого пытается извлечь знакомые ему данные:

/**
* @param {ConfigEmailAuth|null} data
*/
constructor(data = null) {
this.pass = data?.pass;
this.user = data?.user;
}
В конструкторе структуры со сложными атрибутами используются конструкторы для соответствующих атрибутов:

/**
* @param {ConfigEmail} data
*/
constructor(data = null) {
this.auth = (data?.auth instanceof ConfigEmailAuth)
? data.auth : new ConfigEmailAuth(data?.auth);
this.from = data?.from || 'default@from.com';
this.host = data?.host || 'localhost';
this.port = data?.port || 465;
this.secure = data?.secure || true;
}
Если какой-то атрибут представляет из себя массив, то в конструкторе его разбор выглядит примерно так:

class ConfigItems {
/** @type {Item[]} */
items;

/**
* @param {ConfigItems} data
*/
constructor(data = null) {
this.items = Array.isArray(data?.items)
? data.items.map((one) => (one instanceof Item) ? one : new Item(one))
: [];
}
}
Если какие-то данные должны быть сохранены в атрибуте без разбора, то это тоже возможно (хотя к DTO имеет такое себе отношение):

class SomeDto {
/** @type {Object} */
unknownStruct;

/**
* @param {SomeDto} data
*/
constructor(data = null) {
this.unknownStruct = data?.unknownStruct;
}

}

Метаданные​

Метаданные - это информация о коде. Метаданные позволяют отследить, где используются соответствующие атрибуты объекта:

class SaleOrder {
/** @type {number} */
amount;
/** @type {number} */
id;
}

SaleOrder.AMOUNT = 'amount';
SaleOrder.ID = 'id';
Например, при выборке данных из БД:

const query = trx.from('sale');
query.select([
{[SaleOrder.ID]: 'saleId'},
{[SaleOrder.AMOUNT]: 'totalAmount'},
// ...
]);
Результирующую выборку можно напрямую передавать в конструктор SaleOrder, а затем получившийся DTO выкидывать на web в качестве ответа.

Резюме​

Если сводить воедино все три составляющих DTO (структура, конструктор, метаданные), то получается примерно такой es-модуль:

import ConfigEmailAuth from './ConfigEmailAuth.mjs';

export default class ConfigEmail {
/** @type {ConfigEmailAuth} */
auth;
/** @type {string} */
from;
// ...

/**
* @param {ConfigEmail} data
*/
constructor(data = null) {
this.auth = (data?.auth instanceof ConfigEmailAuth)
? data.auth : new ConfigEmailAuth(data?.auth);
this.from = data?.from || 'default@from.com';
// ...
}

}

ConfigEmail.AUTH = 'auth';
ConfigEmail.FROM = 'from';
// ...
Подобный подход позволяет извлекать знакомые подструктуры данных из больших структур (конфигурационных файлов, ответов от сервисов и т.п.), непосредственно относящиеся к текущему контексту, а IDE, за счёт аннотаций, имеет возможность помогать разработчику ориентироваться в этой подструктуре.

"Вот и всё, что я могу сказать об этом." (с)

Послесловие​

Коллега @chemaxa отметил в комменте, что "есть же уже давно https://github.com/OAI/OpenAPI-Specification ... и есть куча генераторов кода под разные языки для dto разной степени паршивости которые выдают код описанный в статье." Я попробовал использовать генератор для "javascript" (на выходе код ES2015+), получил вот это (убрал только пустые строки):

ConfigEmail.js
Что привлекло внимание. Во-первых, двухступенчатое создание объекта:

constructor() {
ConfigEmail.initialize(this);
}
static initialize(obj) {}
Полагаю, что вынос инициализирующего кода из конструктора в статический метод минимизирует потребление памяти в runtime - статические методы share'ятся между всеми экземплярами класса, а не "навешиваются" на каждый экземпляр.

Во-вторых, значения по-умолчанию вешаются на прототип, используемый для создания экземпляров класса:

ConfigEmail.prototype['from'] = 'default@from.com';
Это опять-таки направлено на минимизацию потребления памяти при массовом создании экземпляров.

В третьих, отсутствуют метаданные об используемых в DTO атрибутах:

ConfigEmail.AUTH = 'auth';
Наверное, с точки зрения разрабов этого генератора кода такая информация показалась им излишней и при необходимости её вполне можно добавить в генератор.

В общем, на мой взгляд, вполне неплохое совпадение изложенного в моей публикации (структура, конструктор, метаданные) с практикой (структура, конструктор). Что касается статических методов, то Safary только 26-го апреля этого года научилась понимать статику (хотя того же эффекта можно добиться за счёт прямого переноса методов в класс: ConfigEmail.initialize = function(obj){}).

Хочу отметить, что я не считаю, что DTO уместны только лишь в разрыве "браузер" - "сервер". IMHO, везде, где данные можно представить в виде JSON'а, для его разбора и структуризации пригодна вот такая структура. В том числе и для выборки данных из хранилищ, и для загрузки конфигурационных параметров приложения из файлов. Тянуть во все эти случаи OpenAPI-генератор мне представляется сомнительным.

Коллега @nin-jin в своём комментарии справедливо заметил, что хорошо бы "проверять типы полей перед их сохранением". Я могу согласиться, что было бы правильным приводить типы данных к ожидаемым (нормализовать данные). Кстати, так и делается в OpenAPI-генераторе:

if (data.hasOwnProperty('from')) {
obj['from'] = ApiClient.convertToType(data['from'], 'String');
}
Хотя синтаксис его собственной платформы $mol, кажется мне более "прозрачным":

import {$mol_data_string as Str} from 'mol_data_all'
const ConfigEmail = Rec({
from: Str
})
Насколько я понял, функция Rec предназначена для создания простых дата-объектов (объекты без логики, только с данными) "на лету". В общем-то это и есть DTO, только режима runtime, а не уровня кода (те же метаданные в моём примере позволяют программисту "метить" места использования соотв. DTO и находить их без запуска приложения).

Что касается его ремарки "делать все поля опциональными - сомнительное решение", то в данной публикации я рассматривал DTO прежде всего как фильтр, структурирующий JSON данные - на вход подаётся некоторый объект, а фильтр извлекает из него (или его части) знакомую ему структуру. Это не валидатор, поэтому - норм. Валидатор уже потом может брать готовую структуру, пробегать по ней и принимать решения, что делать, если чего-то для чего-то не хватает.

Коллеге @DmitryKoterov просто спасибо за заботу о моём душевном здоровье, но воспользоваться его советом перейти на TypeScript я не смогу - мешает старая психологическая травма, полученная лет 10-15 назад, когда я пытался понять, почему GWT-приложение в production mode работает иначе, чем в dev, и видел совсем не тот код, который создавал я и мои коллеги. Мы писали на прекрасной Java, а там был обфусцированный и минифицированный JavaScript. Меня так это контузило, что я потом очень долго писал на PHP. Вот только пару лет назад слегка попустило.

Источник статьи: https://habr.com/ru/post/567040/
 
Сверху