Как стать автором
Обновить

DTO в JS

Время на прочтение7 мин
Количество просмотров29K

Информационные системы предназначены для обработки данных, а 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. Вот только пару лет назад слегка попустило.

Этот абзац я вставил в начало публикации специально для того, чтобы при сохранении он перенесся в конец текста, а второй абзац стал первым. Вот такая занимательная бага есть сегодня на Хабре.

Теги:
Хабы:
Всего голосов 8: ↑5 и ↓3+2
Комментарии50

Публикации

Истории

Работа

Ближайшие события

One day offer от ВСК
Дата16 – 17 мая
Время09:00 – 18:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область