Информационные системы предназначены для обработки данных, а 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
/** * OpenAPI Petstore * This is a sample server Petstore server. For this sample, you can use the api key \"special-key\" to test the authorization filters * * The version of the OpenAPI document: 1.0.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech * Do not edit the class manually. * */ import ApiClient from '../ApiClient'; import ConfigEmailAuth from './ConfigEmailAuth'; /** * The ConfigEmail model module. * @module model/ConfigEmail * @version 1.0.0 */ class ConfigEmail { /** * Constructs a new <code>ConfigEmail</code>. * @alias module:model/ConfigEmail */ constructor() { ConfigEmail.initialize(this); } /** * Initializes the fields of this object. * This method is used by the constructors of any subclasses, in order to implement multiple inheritance (mix-ins). * Only for internal use. */ static initialize(obj) { } /** * Constructs a <code>ConfigEmail</code> from a plain JavaScript object, optionally creating a new instance. * Copies all relevant properties from <code>data</code> to <code>obj</code> if supplied or a new instance if not. * @param {Object} data The plain JavaScript object bearing properties of interest. * @param {module:model/ConfigEmail} obj Optional instance to populate. * @return {module:model/ConfigEmail} The populated <code>ConfigEmail</code> instance. */ static constructFromObject(data, obj) { if (data) { obj = obj || new ConfigEmail(); if (data.hasOwnProperty('auth')) { obj['auth'] = ConfigEmailAuth.constructFromObject(data['auth']); } if (data.hasOwnProperty('from')) { obj['from'] = ApiClient.convertToType(data['from'], 'String'); } } return obj; } } /** * @member {module:model/ConfigEmailAuth} auth */ ConfigEmail.prototype['auth'] = undefined; /** * @member {String} from * @default 'default@from.com' */ ConfigEmail.prototype['from'] = 'default@from.com'; export default ConfigEmail;
Что привлекло внимание. Во-первых, двухступенчатое создание объекта:
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. Вот только пару лет назад слегка попустило.
Этот абзац я вставил в начало публикации специально для того, чтобы при сохранении он перенесся в конец текста, а второй абзац стал первым. Вот такая занимательная бага есть сегодня на Хабре.
