Всем привет! Меня зовут Александр, я разрабатываю low-code платформу Eftech.Factory в компании Effective Technologies. В этой статье я хочу поделиться тем, как и почему в стеке нашего продукта появился Node.js. Рассмотрим одно из основных преимуществ Node.js (внезапно это JavaScript) и то, как он помогает нам сэкономить время в два раза на разработку и сопровождение.

Из-за названия статьи может возникнуть путаница: чаще всего, когда речь идет об Angular на бэкенде, подразумевается Server Side Rendering (SSR). Однако в данной статье мы не будем обсуждать SSR, а сосредоточимся на переиспользовании кода и использовании Angular на бэкенде. Давайте начнем! 

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

Зачем нам Node.js? 

Всё начинается с задачи обработки данных. Команда аналитиков формулирует требования к данным, в том числе и требования к проверке этих данных.  На самом деле, мы сталкиваемся с двумя задачами: задача для фронтенда, чтобы наш интерфейс был отзывчивым и удобным, и задача для бэкенда, чтобы наше приложение было безопасным и надежным. Не стоит спрашивать, почему нельзя ограничиться проверками только на фронтенде — надеюсь, вы так не делаете! =) 

Чаще всего эти задачи передаются бэкенд и фронтенд разработчикам, которые реализуют одну и ту же логику. И вот тут мы подходим к главному аргументу в пользу использования Node.js: проверки на фронтенде мы реализуем на TypeScript, так почему бы не использовать те же проверки и на бэкенде? Преимущества очевидны:

1. Экономия времени и ресурсов — нам больше не нужно писать два раза одну и ту же валидацию;

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

3. Единое поведение реализации — например, в разных языках могут  быть по-разному реализованы работа с числами: округление, переполнение и другие нюансы.

Таким образом, использование единого подхода к валидации на фронтенде и бэкенде не только упрощает процесс разработки, но и делает его более надёжным.

Что же делать с Angular?

Вижу цель — иду к ней! На фронтенде мы используем  Angular, а на бэкенде разнообразие сервисов из PHP и Golang. Как же нам реализовать единую валидацию? Ответ на поверхности - переписать PHP сервисы на Node.js и использовать код с фронтенда. Однако всё не так просто. Чтобы переиспользовать код с фронтенда, необходимо соблюсти несколько условий в реализации:

  • Angular — тут нужно отметить, что архитектура Angular является одной из причин, по которой мы его выбрали. У Angular понятные модули, готовая система внедрения зависимостей (DI) и достаточно мощные реактивные формы, которые можно переиспользовать независимо от DOM и API браузера. Именно это мы и сделали.

  • Структура данных — нам нужно единое описание структуры данных и правил ее проверки. Это может быть описание цепочки валидаторов для полей. В нашем случае, в low-code на базе JSON Schema, мы описываем элементы UI, в которых есть блок validation с правилами проверок, реализованных с помощью небольших функций, которые мы называем helper. Почему мы не валидируем по JSON Schema? Дело в том,  что не всегда структура данных соответствует UI, а сами проверки могут быть весьма сложными.

Скрытый текст

Тем не менее JSON Schema служит для проверок самого low-code, подсказок, документации и интерфейса Eftech.Studio. Ой, что-то я отвлекся — об этом в следующий раз =)

  • Логика проверок также должна обладать определенным уровнем абстракций и не иметь прямых зависимостей от того или иного окружения. Все зависимости от API браузера и Node.js мы выносим в слой сервисов и передаем через dependency injection (DI - внедрение зависимостей), Таким образом, с помощью DI мы можем переопределять поведение сервиса или целого валидатора.  Когда это может понадобиться? Например, если  мы хотим проверить существование записи: на фронтенде мы делаем HTTP-запрос, а на бэкенде  — запрос к базе данных.  Для этого мы выносим FindObject как зависимость c соответствующими реализациям на фронтенде и бэкенде для ExistValidator. 

Рассмотрим, как это работает на практике:

Нам понадобится единое описание проверок данных. В нашем случае это описание хранится в low-code:

{
 "@title": "Добавление пользователя",
 "type": "object",
 "properties": {
   "email": {
     "type": "string",
     "validation": [
       {
         "rule": {
           "$match": {
             "pattern": "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{1,1000}$"
           }
         },
         "message": "Некорректный адрес электронной почты",
         "events": [
           "onUpdate",
           "onBackend"
         ]
       },
       {
         "rule": {
           "$api-data": {
             "query": "user/email_exists"
           }
         },
         "message": "Пользователь с таким адресом электронной почты уже зарегистрирован в системе",
         "events": [
           "onBackend"
         ]
       }
     ]
   }
 }
}

В этом примере описано две проверки на соответствие паттерну email и существование пользователя в сервисе данных. Важно отметить, что первая проверка будет выполнена как на фронтенде, так и на бэкенде, тогда как вторая проверка сработает только на бекенде, чтобы избежать излишней нагрузки. Ключевые слова, начинающиеся с $, представляют собой названия вспомогательных функций (хелперов). В общем случае это могут быть названия валидаторов, а описание может находиться в коде общего слоя. 

Если реализацию проверки хелпером $match мы можем переиспользовать без каких либо изменений за счет использования String.match(), то с хелпером $api-data все немного сложнее — в обоих случаях и с фронтенда, и с бэкенда отправляется HTTP-запрос в сервис данных, но на фронтенде у нас реализация запроса на HttpClient от Angular, а на бэкенде undici

Реализация самого хелпера не будет отличаться на фронтенде и бэкенде, чтобы его использовать мы помечаем класс для dependency injector Angular с помощью декоратора Injectable. На бэкенде мы используем NestJS, соответственно для бэкенда мы также помечаем класс для DI с помощью декоратора InjectableGlobal, который мы позаимствовали из NestJS для исключения коллизии в именовании. 

@Injectable({
 providedIn: 'root',
})
@InjectableGlobal()
export class ApiDataHelper<T> implements Helper {

 constructor(protected http: HttpClient) {}

 apply(options: unknown, context: unknown): Observable<T> {}
}

Важно отметить, что использование NestJS очень упрощает задачу по работе с зависимостями. Ранее мы использовали реализацию ReflectiveInjector из Angular в сервисах на Node.js, однако начиная с 16-й версии ее удалили.

Теперь добавим на бэкенде класс, совместимый по API  с HttpClient

Injectable()
@InjectableGlobal()
export class HttpService<T> {
 
 public get(url, options?: HttpOptions): Observable<T> {}
  
 public post(url, body?: unknown, options: HttpOptions = {}): Observable<T> {}
}

Останется только переопределить соответствующий класс в модуле NestJS на бэкенде:

@Module({
 providers: [
   {
     provide: BaseHttpService,
     useValue: HttpService,
   },
 ],
})
export class ApiModule {}

Поздравляю! У нас готова реализация, которую осталось только применить. Здесь нам на помощь придут реактивные формы Angular. Результаты работы хелперов  обернуты в RxJS потоки, что позволяет использовать их без каких-либо изменений в asyncValidators контролов Angular:

control.setAsyncValidators(validators)

На бэкенде также необходимо создать контролы, заполнить их значениями и проверить результат валидации:

validate(
 schema: Schema,
 data: unknown,
 path = '/',
 context: ApplyContext
): Observable<ValidateResult> {
   // Cоздаем форму из контролов ангуляр
   let form = this.schemaService.create(schema, context); 
   // Заполняем данные из POST
   form.reset(data);

   return form.statusChanges.pipe(
     startWith(form.status),
     filter((status) => status !== 'PENDING'),
     map(() => {
       // Формируем ответ с ошибками, если они есть
       const errors = this.buildErrors(form); 
       return this.buildResponse(form, data, errors);
     })
   );
}

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

Что в итоге?

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

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

Комментарии (6)


  1. michael_v89
    07.11.2024 17:01

    В нашем случае это описание хранится в low-code:

    {
      "rule": {
        "$match": {
          "pattern": "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{1,1000}$"
        }
      },
      "events": [
        "onUpdate",
        "onBackend"
      ]
    }
    

    Каким боком это low-code, если это самый настоящий код?

    [
      "return form.statusChanges.pipe(",
      "  startWith(form.status),",
      "  filter((status) => status !== 'PENDING'),",
      "  map(() => {",
      "    // Формируем ответ с ошибками, если они есть",
      "    const errors = this.buildErrors(form);",
      "    return this.buildResponse(form, data, errors);",
      "  })",
      ");",
    ]
    

    Тогда это тоже low-code.


    1. hachucha
      07.11.2024 17:01

      Нет. Второе это код. Первое это лишь конфиг, но не код.


      1. michael_v89
        07.11.2024 17:01

        А сможете сформулировать, в чем разница? В обоих случаях это JSON с данными, для которого нужен интерпретатор.

        Первый код это измененная форма этого кода.

        {
          if: "match('^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{1,1000}$')",
          then: ["onUpdate()", "onBackend()"],
        }
        

        Это тоже конфиг или уже код?


    1. alex912004 Автор
      07.11.2024 17:01

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


  1. dmitriy_shchukin
    07.11.2024 17:01

    Приветствую! Как вы решаете (и решаете ли вообще) проблемы производительности. Что мы видим:

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

    • интерпретатор "конфигурации" в сущности, понятные фреймворку. В данном случае это формы angular. В это время пользователь будет смотреть на наш классный лоадер?

    • переизбыток и компрометация информации о валидации. Мы показываем в открытом виде, то что будем проверять на сервере, кроме того клиент узнает то, что будет проверяться ТОЛЬКО на сервере. Выглядит как дыра и повод для размышления для пентестеров.

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

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


    1. alex912004 Автор
      07.11.2024 17:01

      Добрый день! В данном контексте мы не испытывали проблем с производительностью, этому может быть несколько причин:

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

      - наши приложения это чаще всего сложные B2B приложения и основное место работы ПК, при этом если требуется мобильная версия, то, несмотря на адаптив из коробки, готовятся отдельные формы - менее нагруженные и другим пользовательским опытом

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

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

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

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

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

      - случае с кодогенерацией у нас есть A -> B -> C, где A - конфигурация, B - кодогенератор, C - код, который исполняется

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