Могучие Typescript Декораторы — как работают, во что компилируются и для каких прикладных задач применимы

    Каждый Ангуляр разработчик видел декораторы в тайпскрипт коде. Их используют, чтобы описать Модули, сконфигурировать Dependency Injection или настроить компонент. Другими словами, декораторы используются, чтобы описать дополнительную информацию, или метаданные, для фреймворка или компилятора (в случае Ангуляра). При чем, Ангуляр лишь один из примеров. Существуют многие другие библиотеки, использующие декораторы для простоты и наглядности кода, как декларативный подход. Как .NET разработчик в прошлом, я вижу много сходства между TS декораторами и .NET аттрибутами. Наконец, набирающий популярность NestJS фреймворк для бекенд приложений (абстракция над Node), также построен на интенсивном использовании декораторов и декларативном подходе. Как это все работает и каким образом использовать декораторы в своем коде, чтобы он был более удобным и читабельным? Мы все понимаем, что после компиляции TS кода мы получаем Javascript код. В котором нет понятия декоратор, как и многих других Typescript особенностей. Поэтому для меня наиболее интересным является вопрос, во что превращается декоратор после компиляции. Занимаясь этим вопросом, я сделал выступление на митапе в Минске и хочу поделиться статьей.



    Содержание


    • Примеры декораторов
    • Общая информация о декораторах
    • Декораторы для функций
    • Декораторы для классов
    • Декораторы для полей или свойств класса
    • Декораторы для параметров — домашняя работа
    • Существующие библиотеки
    • Заключение

    Примеры декораторов


    Наиболее яркие на мой взгляд примеры работы с декораторами можно найти в Ангуляре. Давайте взглянем на пару из них, перед погружением в технические подробности.


    • for Module declaration

    @NgModule({
      imports: [
        CommonModule,
      ],
      exports: [
      ],
    })
    export class NbThemeModule {}

    • for component declaration

    @Component({
      selector: 'nb-card-header',
      template: `<ng-content></ng-content>`,
    })
    export class NbCardHeaderComponent {}

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


    Чтобы начать пользоваться декораторами, стоит проверить tsconfig.json файл, в нем должны быть включены опции emitDecoratorMetadata и experimentalDecorators, так как это все еще экспериментальная функциональность.


    {
      "compilerOptions": {
        "module": "commonjs",
        "declaration": true,
        "removeComments": true,
        "emitDecoratorMetadata": true,
        "experimentalDecorators": true,
        "target": "es2017",
      },
    }

    Общая информация о декораторах


    Согласно документации, Декоратор — это специальный вид описания, который можно присоединить к декларации класса, метода, get свойства, свойства или параметра. Декораторы используют форму @expression, то есть при использовании ставится символ @ перед именем декоратора. Хотя по сути expression может быть любая функция. Эта функция будет вызвана в процессе выполнения программы, причем вызывающий код добавит аргументы с информацией о том объекте, который был задекорирован.


    Другими словами, декоратор — это способ добавить дополнительное поведение классу, функции, свойству или параметру. Это можно отнестик парадигме мета-программирования или декларативного программирования.


    Важно, что декоратор — это лишь функция. При использовании, среда исполнения сначала вызовет функцию-декоратор, и только потом будет выполнен основной сценарий объекта (если код декоратора содержит этот вызов). При наличии нескольких декораторов, они будет вызваны по очереди, сверху вниз.


    Декораторы для функций


    Начнем с наиболее очевидного случая — декоратора для функции. Определение в самом Typescript выглядит следующим образом:


    declare type MethodDecorator = 
        <T>(
            target: Object, 
            propertyKey: string | symbol, 
            descriptor: TypedPropertyDescriptor<T>) 
    => TypedPropertyDescriptor<T> | void;

    Это функция, принимающая несколько аргументов. А именно:


    • объект, у которого данная функция была вызвана
    • имя функции
    • дескриптор функции

    Дескриптор выглядит так:


    interface TypedPropertyDescriptor<T> {
        enumerable?: boolean;
        configurable?: boolean;
        writable?: boolean;
        value?: T;
        get?: () => T;
        set?: (value: T) => void;
    }

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


    Стоит отметить, что функция-декоратор будет вызвана не вашим кодом, компилятор сам подставит в нее нужные аргументы. В примере чуть ниже мы посмотрим скомпилированный пример Javascript кода.


    Чтобы рассмотреть пример, нам понадобится какой-нибудь понятный и полезный сценарий. Например — измерение производительности функции.


    class TestServiceDeco {
    
        @LogTime()
            testLogging() {
            ...
        }
    }

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

    Для нашего сценария код декоратора может выглядеть таким образом:


    function LogTime() {
        return (target: Object, propertyName: string, descriptor: TypedPropertyDescriptor<Function>) => {
            const method = descriptor.value;
            descriptor.value = function(...args) {
                console.time(propertyName || 'LogTime');
                const result = method.apply(this, args);
                console.timeEnd(propertyName || 'LogTime');
                return result;
            };
        };
    }

    Как я сказал ранее, декоратор — это функция, которая возвращает функцию определенного типа. В примере видны аргументы этой функции — target, propertyName и дескриптор функции. Их компилятор подставит в вызывающий код.


    Дескриптор функции здесь позволяет переопределить поведение — подменить искомую функцию на новую, которая уже следует заданной декоратором логике. Наша логика подразумевает засечь момент старта функции, и ее завершения, и вывести разницу в консоль. Конечно не стоит забывать вернуть значение искомой функции.


    Скомпилированнй Javascript код будет выглядеть следующим образом


    "use strict";
    Object.defineProperty(exports, "__esModule", { value: true });
    function LogTime() {
        return (target, propertyName, descriptor) => {
            const method = descriptor.value;
            descriptor.value = function (...args) {
                console.time(propertyName || 'LogTime');
                const result = method.apply(this, args);
                console.timeEnd(propertyName || 'LogTime');
                return result;
            };
        };
    }
    exports.LogTime = LogTime;

    Тут никаких сюрпризов, все примерно как и в Typescript коде. А вот код вызывающий уже интереснее:


    Object.defineProperty(exports, "__esModule", { value: true });
    const log_time_decorator_1 = require("../src/samples/log-time.decorator");
    class TestServiceDeco {
        testLogging() {
    ...    }
    }
    __decorate([
        log_time_decorator_1.LogTime(),
        __metadata("design:type", Function),
        __metadata("design:paramtypes", []),
        __metadata("design:returntype", void 0)
    ], TestServiceDeco.prototype, "testLogging", null);

    Тут уже видна системная функция __decorate, в которую передается наш декоратор вместе с дополнительными аргументами.


    Заметим, что в качестве target аргумент подставлен prototype класса, в котором определена функция.

    Подставленный компилятором код, вызывающий функцию __decorate, будет выполнен в процессе интерпретации кода, сразу после интерпретации класса. Но сам код нашего декоратора будет вызываться каждый раз, когда вызывается исходная функция. Это ключевое отличие от следующего вида декораторов.


    Декораторы для классов


    Этот вид декораторов как правило используется, чтобы добавить классу метаданных. Где и как они будут использованы — уже другой вопрос. В Ангуляре — это подсказки компилятору. Но есть и более понятные сценарии — например Dependency Injection. Давайте напишем свой простой и легкий контейнер зависимостей на основе декоратора класса. Например, мы бы могли его использовать следующим образом:


    @CustomBehavior({
        singleton: false,
    })
    class TestServiceDeco {
        constructor() {
            console.log('TestServiceDeco ctor');
        }
    }

    Прежде чем приступить, посмотрим формальное определение декоратора класса в Typescript:


    declare type ClassDecorator = 
        <TFunction extends Function>(target: TFunction) 
        => TFunction | void;

    Таким образом наш декоратор будет выглядить таким образом:


    import 'reflect-metadata';
    
    interface Metadata {
        singleton?: boolean;
    }
    
    function CustomBehavior(metadata: Metadata) {
        return function(ctor: Function) {
            Reflect.defineMetadata('metadataKey', metadata, ctor);
        }
    }

    Мы определили интерфейс для придания структуры нашим метаданным. Самая важная для нас информация — является ли данный класс singelton-ом или же его можно инстанциировать многократно. Дальше мы просто сохраняем данную информацию для дальнейшего использования.


    Пара важных моментов:


    • сейчас в качестве target аргумента мы ожидаем конструктор класса
    • мы начали использовать reflect-metadata

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


    import 'reflect-metadata';
    
    const instancesMap: Map<Object, Object> = new Map<Object, Object>();
    
    function getInstance<T>(tType: new () => T): T {
        let metadata = Reflect.getMetadata('metadataKey', tType) as Metadata;
        if (metadata.singleton) {
            if (!instancesMap.has(tType)) {
                instancesMap.set(tType, new tType());
            }
            return instancesMap.get(tType) as T;
        } else {
            return new tType() as T;
        }
    }

    • наш контейнер состоит из единственной функции getInstance, в которую будет передаваться тип, класс, экземпляр которого необходимо создать
    • с помощью Reflect.getMetadata мы получаем информацию, которую передали с помощью декоратора. Так как эта функция возвращает any, нам приходится добавлять as Metadata для приведения к своему типу
    • так как нам необходимо создавать экземпляры, нам нужен конструктор. Поэтому накладываем ограничение tType: new () => T
    • и конечно нужен какой-то способ хранения созданных экземпляров, в нашем простом случае это Map

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


    Теперь можем где угодно в коде вызывать getInstance, а как может быть создан класс уже прописано в его декораторе.


    Я не стал приводить скомпилированный код данного декоратора и класса, он не сильно отличается от прошлого примера. Но ключевым моментом является тот факт, что код декоратора класса будет выполнен только один раз при интерпретации Javascript кода этого файла.

    Декораторы для полей или свойств класса


    Еще одна область применения декораторов относится к свойствам класса. Тут открывается целый спектр прикладных задач, но наиболее насущной, на мой взгляд, является валидация данных. Представьте, есть класс Person с полем Age, значения которого по логике приложения должно быть между 18 и 60. Давайте сделаем данную проверку с помощью декоратора:


    class Person {
        @Age(18, 60)
        age: number;
    }

    Снова обратимся к формальному определению:


    declare type PropertyDecorator = 
        (target: Object, propertyKey: string | symbol) => void;

    Наш декоратор для валидации выглядит следующим образом:


    import 'reflect-metadata';
    
    function Age(from: number, to: number) {
        return function (object: Object, propertyName: string) {
            const metadata = {
                propertyName: propertyName,
                range: { from, to },
            };
            Reflect.defineMetadata(`validationMetadata_${propertyName}`, metadata, object.constructor);
        };
    }

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


    Скомпилированный код:


    class Person {
    ...
    }
    __decorate([
        age_decorator_1.Age(18, 60),
        __metadata("design:type", Number)
    ], Person.prototype, "age", void 0);

    Видим, что сразу после определения класса компилятор поместил свою функцию __decorate, в которую передал наш декоратор с параметрами.


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


    Возвращаясь к валидации, ее необходимо описать отдельно:


    function validate<T>(object: T) {
        const properties = Object.getOwnPropertyNames(object);
        properties.forEach(propertyName => {
            let metadata = Reflect.getMetadata(metaKey + propertyName, object.constructor);
            if (metadata && metadata.range) {
                const value = object[metadata.propertyName];
                if (value < metadata.range.from || value > metadata.range.to) {
                    throw new Error('Validation failed');
                }
            }
        });
    }

    В примере, конечно же, мы делаем одну единственную проверку. Реальный сценарий будет несколько сложнее.


    Пример вызова:


    const person = new Person();
    person.age = 40;
    validate(person);
    // > validation passed
    
    person.age = 16;
    validate(person);
    // > validation error

    Декораторы для параметров функций


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


    Формально же, декоратор для параметра функции выглядит таким образом:


    declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;

    Существующие библиотеки


    Class-Validator


    Я бы хотел привести библиотеку Class-Validator, использование которой для меня лично очень удобно. Ее декораторы постоянно используются в коде моих проектов.


    export class Post {
    
        @Length(10, 20)
        title: string;
    
        @IsInt()
        @Min(0)
        @Max(10)
        rating: number;
    
        @IsEmail()
        email: string;
    }
    
    ...
    
    validate(object).then(errors => { // array of validation errors
        if (errors.length > 0) {
            console.log("validation failed. errors: ", errors);
        } else {
            console.log("validation succeed");
        }
    });

    Думаю, из примера все понятно. Прочие же детали можно найти в репозитории.


    Интересный факт в том, что именно эта библиотека используется по умолчанию в фреймворке NestJS когда применяется @UsePipes(new ValidationPipe()) для валидации всех входящих http запросов.


    Заключение


    Потенциал Typescript по созданию удобного, простого для прочтения и надежного кода очень велик. Его можно использовать сразу в нескольких парадигмах, в том числе для мета-программирования. Декораторы, даже будучи экспериментальной функциональностью, дают возможности для решения целого спектра прикладных задач, помогают сделать код простым для прочтения и более удобным для работы, помогают в решении таких задач как логирование, измерение производительности, проверки, дополнительное поведение… Чем и пользуются такие фреймворки как Angular и NestJS. Понимание декораторов помогает писать код более красиво (пусть это и субьективная оценка).


    Пишите код, улучшайте его, вычишайте его, делайте его красивым, тестируйте и наконец наслаждайтесь как проделанной работой, так и самим процессом создания кода!


    p.s. пока писал (вернее переводил свою же статью), нашел еще одну тут же на хабре, которая хорошо раскрывает тему декораторов. Привожу ссылку

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 33

      +1


      Чудесный пример безалаберности разработчиков. Кому-то показалась хорошей идея затащить в библиотеку валидации все варианты номеров телефонов со всего мира — довольно бесполезная штука в свете существования NAT от мира телефонии — добавочных номеров.

        0
        6.7 мегабайт гуглопочты сами себя не напишут, знаете ли.
        Но это конечно полная жесть — за полмегабайта можно, например, PDFки собирать, или еще какую-нибудь комплексную и сложную штуку делать. Или закачать портянку телефонов, на редкость бесполезную.
          0
          думаю, у них были какие-то причины. я не знал о такой особенности. спасибо, что заметили.
          0
          Еще одна область применения декораторов относится к свойствам класса. Тут открывается целый спектр прикладных задач

          Этот «спектр прикладных задач» на самом деле очень грустный — в нынешнем виде получить ссылку на инстанс класса из декоратора (грубо говоря, на «правильный» внутриклассовый this) нормальным способом можно примерно никак — декоратор будет оперировать только на прототипе, а не на инстансе, в то время как для свойств класса самый смак «спектра прикладных задач» будет как раз связан с инстансом, но уж никак не с прототипом.

          Есть конечно разные способы а-ля «удаление гланд через задний проход» (Object.defineProperty внутри Object.defineProperty), но они имеют свои проблемы. Ну и кстати говоря, валидация отдельной функцией, как у вас в статье — это тоже «гланды». Идиоматичная валидация не должна требовать каких-то сторонних вызовов, а тупо отрабатывать на set.

          ЗЫ: Другие декораторы — очень полезная штука. Property decorator — увы.
            0

            Не понял в чём проблема объявить свои get/set обработчики, которые выполняются всегда на экземпляре?

              0
              Они перекроют отдекорированное поле. Вы можете либо оставить поле, но тогда вам get/set придётся заводить на другое имя, либо вы заведете get/set на это же имя, но тогда всё, что там изначально было — помрёт. А быть там может многое, не только просто поле, если, например, декораторов было несколько.

              Короче, функциональное программирование на других декораторах вполне норм, и выполнение кода, если не делать сайд-эффектов и прочих резких движений, приобретает вид decorateA(decorateB(decorateC(proto))). C property decorator — хренушки. Только вон как в статье, с неявными соглашениями относительно того, что будет лежать в и извлекаться из метаданных Reflect. Понятно, что ни о какой совместной работе разных либ при таком подходе не может идти и речи.
                0

                Ничего не понял. Вам на вход приходит дескриптор, у которого есть get/set — просто дёргаете их для получения/установки значения.

                  0
                  Пример с кодом можно?
                    0
                    class Foo extends Object { 
                    
                      @log
                      get bar() { return 5 }
                      set bar(val: number) { }
                    
                      [Symbol.toStringTag]: string
                    
                    }
                    
                    function log<
                      Host extends object,
                      Field extends keyof Host,
                      Value extends Host[Field]
                    >(
                      host: Host,
                      field: Field,
                      descr: TypedPropertyDescriptor<Value>
                    ) {
                    
                      return {
                    
                        ...descr,
                    
                        get(this: Host) {
                          const val = descr.get!.call(this)
                          console.log('%s.%s => %s', this, field, val)
                          return val
                        },
                    
                        set(this: Host, val: Value) {
                          console.log('%s.%s <= %s', this, field, val)
                          descr.set!.call(this, val)
                        },
                    
                      }
                    }
                    
                    const foo = new Foo
                    foo[Symbol.toStringTag] = 'foo'
                    
                    foo.bar
                    foo.bar = 7
                    foo.bar

                    Playground Link

                      –1
                      Скажите, а как так получилось, что я вам несколько постов пишу про недостатки property decorator, а вы якобы в ответ пишете пример с accessor decorator?
                        0
                        Я, собственно, поясню, в чем тут проблема — декораторы удобны в первую очередь из-за того, что они крайне лаконичны. Если лаконичности нет — то можно и «руками» обернуть классы и всё остальное — выйдет длиннее, зато очень очевидно.

                        Поэтому когда вы предлагаете вместо
                        public bar = 5;

                        написать геттер, сеттер, и приватное хранилище значения bar — то теряется солидная часть смысла использования декораторов, теперь с тем же успехом можно написать
                          get bar() { return logGet(this._bar) }
                          set bar(val: number) { this._bar = logSet(val) }

                        И выйдет уже даже не длиннее.
                          0

                          Так писать приходится не из-за дикораторов, а потому, что инициализации полей помещаются в конструктор и соответственно при наследовании одно и то же поле переприсваиваивается по нескольку раз при создании объекта. Ни чем хорошим это не заканчивается. На чуть более сложной логике это и медленно, и источник нежелательного поведения.


                          Ваш второй код можно переписать так:


                          @log @mem
                          get bar() { return 5 }
                          set bar(val) {}

                          Заодно, бесплатно получаете ленивую инициализацию.


                          Но ещё практичней, конечно, вообще использовать методы:


                          @log @mem
                          bar( val = 5 ) { return val }

                          Тогда сможете ещё и легко делегировать:


                          @log @mem
                          lol( val? : number ) {
                              return this.bar( val )
                          }
                  0
                  Хотя вы меня натолкнули на идею — если делать Object.getOwnPropertyDescriptor(), разбирать ответ, переопределять имеющееся свойство «вглубь» (под какой-нибудь символ, например), и оборачивать его в get/set декоратора — можно, наверное, зафигачить всё так, чтоб оно снаружи выглядело как код без сайд-эффектов.

                  Надо будет поиграться.
                  0
                  да, к сожалению у Typescript как и у Javascript есть свои ограничения. но тем не менее, это удобнее чем размещать сами настройки валидации вдали от класса. а так, я полностью согласен. иметь всю валидацию в объекте сразу при присвоении значения было бы много круче. но это уже .NET
                    0

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

                      +1
                      иметь всю валидацию в объекте сразу при присвоении значения было бы много круче. но это уже .NET

                      Вот тут не очень понятно.
                      Аттрибуты в .NET же это просто кусок метаданных.
                      Они сами по себе ничего не делают.
                      Они даже приметивнее чем декораторы в TypeScript.
                      Там точно так же надо извращатсья с рефлексией как у вас в статье, чтобы осуществлять валидацию через аттрибуты.
                      Или я вас не правильно понял?
                        0
                        может быть, я что-то сам запамятовал про .NET. мне казалось, что там все равно больше возможностей доступа к объекту, но возможно вы правы. не проверял к сожалению
                      +2
                      Идиоматичная валидация не должна требовать каких-то сторонних вызовов, а тупо отрабатывать на set.

                      Идиоматичная валидация должна отрабатывать либо в конструкторе иммутабельного объекта, либо в отдельной функции/методе валидации, возвращающей список ошибок валидации.


                      Валидация на set — зло, потому что в случае нескольких связанных/зависимых полей можно запросто получить сильнейшую попоболь: при последовательной модификации нескольких связанных свойств валидации начнут мешаться. Нужно будет либо добавлять некие "транзакции" (т.е. фактически конструктор/билдер/фабрику), либо объект в какой-то момент окажется в промежуточном невалидном состоянии, либо нужно будет извращаться с порядком изменений, чтобы не триггернуть ошибку валидации.


                      Каноничный пример убогости property validators: дан объект "дата", у которого есть изменяемые свойства "день", "месяц" и "год" — попробуйте сделать валидацию этих свойств на property validators, чтобы 1) никаким изменением свойств нельзя было выставить дату "2020-02-31" и 2) при изменении даты с валидной "2020-01-31" на валидную "2020-02-01" пользователь не получал бы ошибку валидации, если он вдруг начнёт с изменения месяца.

                        0
                        Нужно будет либо добавлять некие «транзакции» (т.е. фактически конструктор/билдер/фабрику), либо объект в какой-то момент окажется в промежуточном невалидном состоянии

                        Эм. А если у вас валидация отдельно — оно как-то иначе будет, что ли? Точно так же и будет: провели валидацию в какой-то неправильный момент — получили облом (а забыли и не провели — тоже облом).

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

                        ЗЫ: Валидация, кстати, не обязана делать throw, если ей что-то не нравится.
                          0
                          А если у вас валидация отдельно — оно как-то иначе будет, что ли?

                          Да, мы делаем транзакцию — но нам не надо городить её поверх совершенно чуждого механизма валидации на сеттерах. Мы позволяем объекту быть в невалидном состоянии и не проверяем в сеттерах вообще ничего — но только до определённого момента. В результате у нас сеттеры примитивные, а вся логика проверок (любой сложности) сконцентрирована в одном месте, а не размазана тонким слоем по декораторам/атрибутам/аннотациям пропертей.


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

                          А с сеттерами пользователю придётся думать "а если я поменяю вот это свойство, не получу ли я ошибку валидации?" и "а если после ошибки я поменял другое свойство, первая ошибка пропала или нет?". Если нужно защитить пользователя от создания невалидного состояния, и при этом не вводить дополнительную функцию, то лучший способ — конструктор иммутабельного объекта. Если объект создался, он гарантированно правильный, и никогда неправильным не станет.


                          провели валидацию в какой-то неправильный момент — получили облом

                          Правильный момент для валидации данных — это либо непосредственно перед отправкой данных (при нажатии кнопки submit), либо сразу по приёму данных. Вызов функции валидации в эти моменты вполне естественнен, его легко контролировать и трудно забыть.


                          Пре-валидировать по мере заполнения полей конечно тоже можно — для быстрого фидбека и улучшенного UX, — но нужно понимать, что это второстепенный сценарий, и там как раз легко забыть или потерять при редизайне. Если нужно превалидировать на ходу — всегда можно повесить вызов validate() по мере ввода, по событию.


                          Валидация, кстати, не обязана делать throw, если ей что-то не нравится.

                          Ну да, я поэтому и написал про "функцию, возвращающей список ошибок валидации". С сеттерами трудно такое сделать прозрачно.

                            0
                            Мы позволяем объекту быть в невалидном состоянии

                            Это делается и с сеттерами — заводите в объекте флажок invalid, и наслаждаетесь. Или вообще коллекцию, в которой храните список Error. Как угодно. Разница тут — именно во внешнем API. И я продолжаю утверждать, что внешний API с отдельной функцией валидации — кривой.

                            В результате у нас сеттеры примитивные, а вся логика проверок (любой сложности) сконцентрирована в одном месте

                            Это неплохо, если у вас формочки, которые вы проверяете на submit. Формочки — не у всех, да и в любом случае, нормальные люди прекратили проверять формочки только при submit еще в 2010 году. Ждать, пока конечный пользователь нажмёт на кнопку, и только потом радостно говорить ему «ага! ты всё неправильно ввёл!» — это моветон и лишние потери времени на повторную навигацию по форме. Если вы продолжаете так делать — вы плохой фронтэндер, совершенно не заботящийся об UX.

                            А с сеттерами пользователю придётся думать «а если я поменяю вот это свойство, не получу ли я ошибку валидации?» и «а если после ошибки я поменял другое свойство, первая ошибка пропала или нет?».

                            Именно поэтому объект, который предполагается изменять транзакциями — должен ими и изменяться. Скажем, изменять у даты отдельно день, месяц, и год — не имеет смысла.

                            Если нужно защитить пользователя от создания невалидного состояния, и при этом не вводить дополнительную функцию, то лучший способ — конструктор иммутабельного объекта.

                            Иммутабельные объекты в рамках нашей беседы ничем не отличаются от объектов, которые нельзя отправить в невалидное состояние в ходе последовательных изменений, приводящих к валидному.

                            Пре-валидировать по мере заполнения полей конечно тоже можно — для быстрого фидбека и улучшенного UX, — но нужно понимать, что это второстепенный сценарий, и там как раз легко забыть или потерять при редизайне.

                            В 2020 году это как раз таки первостепенный сценарий. И вешать общий validate() на onchange конкретных полей и выполнять полную валидацию всего в ответ на изменения чего-то одного — отличный рецепт к созданию тормозов на ровном месте.
                              0
                              заводите в объекте флажок invalid

                              Во-первых, запуск пересчёта этого флажка в каждом сеттере — это тоже самое, что вызов validate() на каждое изменение. Потому что если я поменял свойство x.foo на невалидное, этот флажок взведётся, а если я после этого поменяю свойство x.bar на валидное, то в сеттере bar придётся решать: "опа, у нас тут invalid стоит, сбрасываем или где-то в другом месте осталась ошибка?" И я либо сбрасываю флаг (что неверно), либо бегу опрашивать все свойства "как вы там? можно уже флаг сбрасывать?"


                              Во-вторых, на кой ляд мне в классе дополнительные поля, не имеющие отношения к предметной области? А если в моём классе уже есть бизнес-свойство Invalid? Искать другое имя? Решение с флагом — инвазивное, заставляет меня менять класс. Функция же валидации может запросто быть внешней к классу, она вообще может динамически подтягиваться из какого-нибудь менеджера валидаций со сложными правилами ("если VIP-клиент, то валидируем так, иначе валидируем эдак"). Я легко могу построить пайплайн валидаций, пропуская объект через разные rules, и мне не надо менять в объекте ничего, его можно держать чистым от левых метаданных.


                              Формочки — не у всех

                              У тех, у кого не формочки, либо просто нет серьёзных забот о валидации и они могут позволить сохранить состояние моментально, проверив лишь одно свойство на сеттере, либо у них всё равно есть скрытый submit (например, по закрытию окна, по уходу со страницы, через интервал времени и проч).


                              И вешать общий validate() на onchange конкретных полей и выполнять полную валидацию всего в ответ на изменения чего-то одного — отличный рецепт к созданию тормозов на ровном месте.

                              Если у вас зависимые свойства, то у вас нет выхода, кроме как валидировать объект целиком. Делать это на изменение ли поля или на сабмит — это уже деталь, если у вас нет тяжёлых задач вроде обращений к базе данных, то тормозов не будет, а если есть — придётся валидировать на сабмите, никуда не денешься.


                              Но проблема в том, что сегодня у вас простые объекты, и вы решили выбрать property validation как наиболее простую и популярную модель, а завтра правила усложнятся и вам придётся всё это выбрасывать и делать нормальные валидаторы.


                              Поэтому лучше сразу делать правильно и никогда не иметь этих проблем. Функция-валидатор по сложности одинакова или даже проще проперти-валидаторов, так как ей не нужны никакие библиотеки декораторов, логика проверок находится в одном месте и написана на том же языке, что и прочий код, а не на мета-надстройке, которую нужно дополнительно учить, транспилировать и проч. Если хочется DSL, то есть fluent-syntax, который легко совмещается с обычным синтаксисом. Если нужно временно поменять валидацию — просто пишешь ещё одну функцию, не трогая сам объект и существующий код валидаци; в отличие от метаданных, вызов имеющейся функции легко поменять на экспериментальную, легко вернуть обратно, легко сделать юнит-тест, легко сделать A/B-тест.

                                0
                                Во-первых, запуск пересчёта этого флажка в каждом сеттере — это тоже самое, что вызов validate() на каждое изменение.

                                Зависит от деталей реализации. Всегда можно хранить не один флажок, а пачку.

                                Во-вторых, на кой ляд мне в классе дополнительные поля, не имеющие отношения к предметной области? А если в моём классе уже есть бизнес-свойство Invalid? Искать другое имя?

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

                                Главный профит относительно валидации «сбоку» через допфункцию — отсутствие возможности забыть провалидировать, или провалидировать в неправильный момент с точки зрения транзакций.

                                Функция же валидации может запросто быть внешней к классу, она вообще может динамически подтягиваться из какого-нибудь менеджера валидаций со сложными правилами («если VIP-клиент, то валидируем так, иначе валидируем эдак»).

                                Это вообще ничего не меняет. Говорю же, мы ведем разговор о внешнем API некоторой модельной библиотеки. Под капотом у неё можно быть всё, что угодно, в том числе и «менеджер валидаций со сложными правилами».

                                Если у вас зависимые свойства, то у вас нет выхода, кроме как валидировать объект целиком.

                                Разумеется нет. Возможны оптимизации — кому, как не самому объекту, знать о том, что конкретно от чего у него зависит? Если у вас сторонний validate() — там да, оптимизировать тоже можно, но придётся в этом стороннем validate() как-то получать информацию о зависимостях, или хардкодить её.

                                Поэтому лучше сразу делать правильно и никогда не иметь этих проблем.

                                YANGI.
                                Ваш тезис — это банальный путь в никуда, когда любители «сразу делать правильно» начинают городить схемы и абстракции на хайлоад и миллионы пользователей, хотя на самом деле пользователей у них 5, и в будущем могло бы быть 50, если б они не разорились, неспешно делая правильнейшую архитектуру вместо фич.

                                Но даже и в отрыве от этого, валидация «сбоку» — это не «сразу правильно», это сомнительное решение, функционально ничем не отличающееся от валидации в более тесной связке с моделью, но обладающее минусами именно из-за отдельности (можно забыть или неправильно применить, если нужно знать что-то о модели — нужно формировать метаданные по неким соглашениям и отправлять их в валидацию).

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

                                Это если у вас простая дубовая модель, по которой можно гонять validate() без потерь производительности. Если у вас что-то сложное — опа, нужно устраивать оптимизации, то есть либо хардкодить в этом вашем «одном месте» знания о модели (которая находится в другом месте), либо передавать из модели метаданные — привет, «мета-надстройка».
                                  +1
                                  Всегда можно хранить не один флажок, а пачку.

                                  Всегда можно никакого лишнего состояния не хранить и не париться с синхронизацией всех этих флажков.


                                  кому, как не самому объекту, знать о том, что конкретно от чего у него зависит?

                                  Если речь о бизнес-объекте — да, это часто так. Но там речь идёт о валидности в контексте бизнес-логики.


                                  Если же речь о DTO — то нет, валидность DTO зависит от контекста. Один и тот же DTO может быть валидным в контексте одной системы и невалидным в контексте другой. Например, если ваш объект принимается из веб-формы, потом сохраняется в БД, а потом экспортируется во внешнюю систему, то финальное ограничение на длину поля — это комбинация ограничений от БД (например, там длина 32 символа) и от внешней системы (например, там ограничение 16 символов). Добавляете экспорт в третью систему — накладывается ещё одно ограничение. Если валидация прибита к классам, вам нужно вручную высчитать максимальную длину поля с учётом всех ограничений, поменять метаданные класса, захардкодить там @maxLength(16) и потерять информацию о том, по какой причине макс. длина поля именно 16 символов, а не 32. Если валидация в функциях и вам надо добавить ещё одну комбинацию ограничений поверх существующих — вы просто добавляете ещё одну функцию в пайплайн, не меняя ничего, и вы всегда точно знаете, какая подсистема какие ограничения накладывает, и можете легко логгировать, какое именно правило стрельнуло.


                                  придётся в этом стороннем validate() как-то получать информацию о зависимостях, или хардкодить её.

                                  Если вы прибиваете валидацию гвоздями к DTO, то вы именно что хардкодите. Правила валидности входящих данных — это вещь, внешняя к данным. Эти правила определяются не самим DTO, а принимающей системой, контекстом.


                                  начинают городить схемы и абстракции

                                  DSL валидации, метаданные, прибиваемые к пропертям, работа через рефлексию или транспиляцию — это и есть лишние абстракции. Одна простая функция валидации — это минимально возможный вариант, дубовее просто некуда. Чтобы начать писать функции валидации, никакой архитектуры не нужно. Не нужна рефлексия. Не нужна транспиляция. Не нужен DSL. Не нужны сторонние библиотеки для поддержки всего этого. Всё готово из коробки.


                                  Это если у вас простая дубовая модель, по которой можно гонять validate() без потерь производительности. Если у вас что-то сложное — опа, нужно устраивать оптимизации

                                  Если правила валидации простые, то валидация на сеттерах не даёт никакого преимущества перед функцией полной валидации — всё летает.


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


                                  либо хардкодить в этом вашем «одном месте» знания о модели (которая находится в другом месте), либо передавать из модели метаданные — привет, «мета-надстройка».

                                  См. выше — так как правила валидации данных зависят от принимающей системы, то и кодироваться они должны в принимающей системе. В гомогенной среде вроде JavaScript передать эти знания с сервера на клиент легко — просто делайте функции, которые работают как на сервере, так и в браузере, и используйте их на обоих сторонах. В гетерогенной среде (скажем C# и JS) придётся написать два набора функций на двух языках, но это намного проще, чем искать две похожих библиотеки для валидации на метаданных.

                      +1
                      Могу дополнить про декораторы свои наблюдения, для тех кто их пишет:
                      1) Если декоратор допускает использование его без аргументов — не поленитесь написать реализацию, чтобы можно было писать и так @SomeDecorator и так @SomeDecorator()
                      2) Будьте внимательны при реализации декораторов для методов, которые могут быть асинхронными — это особая магия.

                      Привет декоратору LogTime
                        0
                        1. Декораторы и так-то не простые штуки. Не надо их ещё более усложнять разными сигнатурами. Лучше если будет одна понятная сигнатура. Вариант со скобками может быть легко расширен в будущем.
                        2. А как их реализуют? Детектируют асинхронную функцию и заворачивают в асинхронную обёртку с эвейтом на оригинальной?
                          0
                          спасибо! насчет асинхронности думал, но пока не проверял. спасибо за коментарий.
                          0
                          Не понимаю как вы получили функцию внутри класса, у меня TS ругается
                          declare var Decorator: any;
                          class Lol {
                            @Decorator
                            function kek() {
                              // Это же не 
                            }
                          }
                          
                            0
                            да, это был ошибочный копи/паст пример. исправил. спасибо!
                            0
                            Декораторы нельзя назначить на функции из-за function hoisting. В какой момент должен сработать декоратор, в начале или когда код доходит до определения функции?

                            К тому же, декоратор на функцию легко делается через обертку
                            const myFuncion = Decorator(() => { .... })
                            
                              0

                              Только у функции при этом теряется имя, поэтому приходится писать как-то так:


                              const myFuncion = Decorator(function myFuncion() { .... })
                                0
                                да, спасибо! к сожалению это не так легко и лаконично, как обычные декораторы.
                                0

                                Декораторы это классно. Единственное смущает что как я читал в свое время включение в стандарт задерживается спором по одному из типов декораторов который в стандарт предлагают ввести совсем не в том виде в каком мы его сейчас применяем. Ну и по reflect-metadata сразу наткнулся но одну существенную проблему. Я не могу получить список полей класса для которых определены метаданные. А это означает что многое из функционала становится невозможным к реализации. Да я могу получить список полей объекта. Но если поле отсутствует в объекте я его никак не могу получить из метаданных. Например я создаю декоратор что поле является обязательным. Но никак не могу до него достучаться средствами рефлексии. По это у поводу было даже issue в reflect-metadata которое было закрыто с формулировкой что в проекте стандарта нет и в библиотеке не будет

                                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                Самое читаемое