Русский
Русский
English
Статистика
Реклама

Nodejs

Инструменты Node.js разработчика. Какие ODM нам нужны

18.10.2020 22:23:47 | Автор: admin

ODM - Object Document Mapper - используется преимущественно для доступа к документоориенриирвоанным базам данных, к которым относятся MongoDB, CouchDB, ArangoDB, OrientDB (последние две базы данных гибридные) и некоторые другие.

Прежде чем перейти к рассмотрению вопроса, озвученного в названии сообщения, приведу статистику скачивания пакетов из публичного регистра npm.

Таблица

Статистика скачивания пакетов для работы с реляционными и документоориентированными базами данных из публичного регистра npm

Пакет (npm)

Количество скачиваний в неделю

База данных

pg

1 660 369

PostgreSQL

mysql

713 548

MySQL

mongodb (из них 1 034 051 вместе с mongoose)

1 974 992

MongoDB

mongoose

1 034 051

MongoDB

nano

60 793

CouchDB

PouchDB

25 707

CouchDB

arangojs

7 625

ArangoDB

orientjs

598

OrientDB

Из таблицы можно сделать очень интересные выводы:

1) Использование MongoDB в проектах на nodejs почти сравнялось с использованием реляционных баз данных, и превысило использование одной отдельно взятой реляционной базы данных PostgreSQL или MySQL;

2) В половине проектов для доступа к MongoDB используется библиотека mongoose (ODM);

3) В половине проектов для доступа к MongoDB не используется библиотека mongoose. А это означает, что не используется сторонняя ODM, так как другой популярной библиотеки ODM для MongoDB нет.

4) Такие базы данных как ArangoDB, OrtintDB существенно менее популярны, чем MongoDB, и даже CoucbDB, и не имеют общепризнанных ODM.

Сначала остановлюсь на мотивации использования документоориентированных баз данных в проектах на Node.js. Отставим в сторону маркетинговую составляющую, хотя ее доля возможно более 50% из общего количества в приведенной статистике. Будем исходить что это все же кому-то действительно нужно, по-настоящему.

Модель данных в документоориентирвоанных базах данных близка к привычной многим в реляционных базах данных. Только вместо таблицы - коллекция, вместо строки таблицы - документ. Дает ли применение коллекции документов вместо таблицы преимущества и какие? Рассмотрим возможные варианты аргументации.

1) Отсутствие жесткой схемы. Сейчас это уже не аргумент. Так как MySQL и PostgrSQL обзавелись типом данных json, который может то же самое и даже больше (например остается возможность запросов с JOIN). Но даже не это главное. Как показывает статистика, половина проектов на MongoDB использует mongoose, в которой задается жесткая схема документа в коллекции.

2) Встроенные документы. Сейчас тоже не аргумент. MySQL и PostgrSQL имеют тип данных json, которые может то же самое. Надо отдать должное, что внедрение этого типа данных было ускорено развитием NoSQL.

3) Репликация и шардирование. Да, да и еще раз да. Вот то, что может быть аргументом "за" использование документоориентированных баз данных в проекте.

От чего придется отказаться, отказавшись от использования реляционных баз данных: как известно, ACID и JOIN. (Хотя тут есть варианты)

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

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

CouchDB, также как и реляционные базы данных, испытала влияние MongoDB, и в одной из новых версий получила более удобный язык запросов mango, который похож на язык запросов MongoDB.

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

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

В плане репликации и шардирования ArangoDB напоминает CouchDB. И даже гарантирует ACID в Еnterprise версии продукта.

JOIN в ArangoDB также не является проблемой. Вот так будет выглядеть классический запрос MANY-TO-MANY:

 FOR a IN author      FOR ba IN bookauthor      FILTER a._id == ba.author        FOR b IN book        FILTER b._id == ba.book        SORT a.name, b.title    RETURN { author: a, book: b }

Это не выполняемый код на JavaScript с полным перебором всех записей (как может показаться на первый взгляд). Это такой очень оригинальный язык запросов, с синтаксисом JavaScript который является аналогом SQL и называется AQL. Подробности можно узнать в статье на Хабре.

ArangoDB также позволяет публиковать REST сервисы прямо в базе данных (Foxx) и делать поисковые запросы, включая нечеткий поиск (fuzzy search), что позволяет строить поиск без Elasticsearch (Elasticsearch конечно более мощный инструмент, но проблему составляет синхронизация данных в основной базе данных и в Elasticsearch).

Однако, на сегодняшний день нет ODM, которая позволила бы удобно и надежно работать с ArangoDB, и это существенный сдерживающий фактор в её использовании в реальном проекте. Поэтому я неоднократно приступал к поиску решения для ODM. В конце концов решение начало приобретать явные очертания и я его представляю в этом сообщении.

Самое существенное, что есть в решении - это понимание того функционала, который должна решать ODM. В этом смысле я хочу сослаться на проект universal-router (см. сообщение на Хабре), который из всего многообразия функций, к которым мы уже успели привыкнуть в роутерах React.js или Vue.js, выделили главный функционал на котором построили свое решение.

Если говорить об ODM, таким функционалом, без которого не обойтись, является по моему мнению ровно три функции:
1) Преобразование JSON (полученного из базы данных) в типизированную модель или коллекцию;

2) Преобразование типизированной модели или коллекции в JSON для сохранения в базе данных;

3) Преобразование типизированной модели или коллекции в JSON для отправки клиенту.

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

При этом есть смысл максимально использовать возможности Typescript (например декораторы, рефлексию, метаданные).

export class Reposytory {  private db: Database;  constructor() {    this.db = db;  }  @collection(Author)  async authorFindAll() {    const row = await this.db.query({      query: 'for row in authors return row',      bindVars: {},    });    return row.all();  }  @model(Author)  async authorCreate(author: Author): Promise<Author> {    const doc = await this.db.query({      query: 'insert @author into authors let doc = NEW return doc',      bindVars: { 'author': author }    });    return doc.next();  }}

Код декораторов будет довольно лаконичен:

export function collection(Constructor: new(...args: any[]) => void) {  return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor): void => {      const originalValue = descriptor.value;      descriptor.value = async function(...args: any[]) {        const plainData = await originalValue.apply(this, args)        const data = new Array();        plainData.forEach((item: any) => data.push(new Constructor(item)));        return data;      }    }}export function model(Constructor: new(...args: any[]) => void) {  return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor): void => {      const originalValue = descriptor.value;      descriptor.value = async function(...args: any[]) {        const plainData = await originalValue.apply(this, args)        return new Constructor(plainData);      }    }}

Что касается модели документа, я построил схему модели на таких декораторах:

attr(Type?) - атрибут - то есть то что сохраняется в базе данных и не является вычислимым параметром;

optional(Type?) - не обязательное значение;

array(Type?) - типизированная коллекция;

translatable(Type?) - значение, сохраняющееся в базе данных виде объекта и возвращающееся клиенту в виде строки с выбранной локалью;

group(...args: string[]) - список групп клиентов, которым доступно значение.

На таких декораторах можно построить описание объекта:

import {Model, ModelType} from '../src';import {optional, attr, array, getter, group, _type, translatable} from '../src';import {Translatable, TranslatableType} from '../src';interface AddressType extends ModelType {    city: string,    street: Translatable,    house: string,    appartment?: number,}interface AuthorType extends ModelType{    name: Translatable,    address: AddressType,}export class Address extends Model<AddressType> implements AddressType {    @attr()    @group('admin', 'user')    @translatable(Translatable)    public city!: string;    @attr()    @group('admin')    @translatable(Translatable)    public street!: Translatable;    @attr()    @group('admin')    public house!: string;    @attr()    @optional()    @group('admin')    public appartment?: number;}export class Author extends Model<AuthorType> implements AuthorType  {    @attr(Translatable)    @translatable(Translatable)    @group('admin', 'user')    public name!: string;    @attr(Address)    @group('admin', 'user')    public address!: Address}

Результаты исследования представлены в репозитарии.

Желающих обсудить приглашаю в чат https://t.me/arangodb_odm

Подробнее..
Категории: Javascript , Node.js , Nosql , Nodejs , Mongodb , Odm , Arangodb , Couchdb , Orientdb

Jsqry лучше, чем jq

30.10.2020 16:06:21 | Автор: admin

В своей прошлой статье на Хабре я писал про библиотеку Jsqry, которая предоставляет простой и удобный язык запросов (DSL) к объектам JSON. С тех пор прошло много времени и библиотека тоже получила свое развитие. Отдельный повод для гордости библиотека имеет 98% покрытие кода тестами. Однако в этой статье речь не совсем о ней.


Думаю, многие из вас знакомы с инструментом jq, который является практически стандартом де-факто для работы с JSON в командной строке и скриптах. Я тоже являлся её активным пользователем. Но меня все время беспокоила неоправданная сложность и неинтуитивность синтаксиса запросов этой утилиты. И не меня одного, вот лишь несколько цитат с hacker news:


I have been using jq for years and still can't get it to work quite how I would expect it to.

I have the same issue with jq. I need to use my google fu to figure out how to do anything more than a simple select.

I don't know what the term would be, mental model, but I just can't get jq to click. Mostly because i only need it every once in a while. It's frustrating for me because it seems quite powerful.

I know I might be a dissenting opinion here, but I can never wrap my head around jq. I can manage jq ., jq .foo and jq -r, but beyond that, the DSL is just opaque to me.

Let's just say it: jq is an amazing tool, but the DSL is just bad.

Yeah, I find jq similar to writing regexes: I always have to look up the syntax, only get it working after some confusion why my patterns aren't matching, then forget it all in a few days so have to relearn it again later.

Одним словом, вы уже наверное догадались. Пришла идея, а почему бы не обратить мою JS библиотеку в исполняемый файл для командной строки. Здесь есть один нюанс. Библиотека написана на JS и её DSL также опирается на JS. Это значит, что надо найти способ упаковать программу и какой-нибудь JS-runtime в самодостаточный исполняемый файл.


jsqry GraalVM edition


Для тех кто еще не в теме (неужели еще есть такие? oO) напомню, что GraalVM это такая прокачанная JVM от Oracle с дополнительными возможностями, самые заметные из которых:


  1. Полиглотная JVM возможность бесшовного совместного запуска Java, Javascript, Python, Ruby, R, и т.д. кода
  2. Поддержка AOT-компиляции компиляция Java прямо в нативный бинарник
  3. Улучшения в JIT-компиляторе Java.

Освежить представление о Graal можно, например, в этой хабра-статье.


Теоретически, объединение возможностей пунктов 1. и 2. должно решить поставленную задачу обратить код на JS в исполняемый файл.


Так родился проект https://github.com/jsqry/jsqry-cli. Правда, не спешите добавлять в закладки в данный момент проект deprecated. Идея оказалась рабочей, но непрактичной. Дело в том, что размер исполняемого файла получался 99 Мб. Как-то не очень хорошо для простой утилиты командной строки. Тем более, если сравнить с jq с её размером 3.7 Мб для последней версии для Linux 64.


В идеале хотелось бы получить размер не больше мегабайта.


Тем не менее, решил оставить этот репозиторий как практический пример того как собрать из Java + JS кода исполняемый файл при помощи GraalVM.


Небольшой обзор этого решения


Решение имеет основной код запускаемого приложения в единственном файле App.java. Этот код выполняет обработку параметров командной строки, используя стандартную java-библиотеку Apache Commons CLI.


Далее java-код вызывает код на javascript из файлов, находящихся в директории ресурсов src/main/resources.


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


scripts.add(new String(Files.readAllBytes(Paths.get(jsFileResource.toURI()))));

под Граалем (то есть, будучи скомпилированным через native-image) падало с


java.nio.file.FileSystemNotFoundException: Provider "resource" not installed

Выручил древний "хак" для чтения строки из InputStream


scripts.add(new Scanner(jsFileResource.openStream()).useDelimiter("\\A").next());

Короче говоря, надеяться на 100% поддержку всех функций стандартной Java Граалем все еще не приходится.


Недавно аналогичной неприятной находкой оказалось отсутствие поддержки java.awt.Graphics. Это помешало использовать GraalVM для реализации AWS Lambda для конвертации картинок.


jsqry QuickJS edition


Где-то в это же время я узнал о новом компактном движке JS QuickJS от гениального французского программиста Фабриса Беллара. В своем составе этот инструмент несёт компилятор qjsc джаваскрипта в исполняемый файл. Также поддерживается почти полная совместимость с ES2020. То что нужно!


Таким образом, появилась вторая инкарнация CLI-версии jsqry: https://github.com/jsqry/jsqry-cli2.
Этот подход оказался более жизнеспособным и уже принес несколько релизов.


Итак, что же такое jsqry?


jsqry это маленькая утилита командной строки (похожая на jq) для выполнения запросов к JSON используя "человеческий" DSL.


Цель этой разработки представить функционал JS библиотеки jsqry в форме интерфейса командной строки.


Примеры использования


запрос


$ echo '[{"name":"John","age":30},         {"name":"Alice","age":25},         {"name":"Bob","age":50}]' | jsqry 'name'[  "John",  "Alice",  "Bob"]

первый элемент


$ echo '[{"name":"John","age":30},         {"name":"Alice","age":25},         {"name":"Bob","age":50}]' | jsqry -1 'name'"John"

использование параметризации запроса


$ echo '[{"name":"John","age":30},{"name":"Alice","age":25},{"name":"Bob","age":50}]' \    | jsqry '[ _.age>=? && _.name.toLowerCase().startsWith(?) ]' --arg 30 --arg-str joh [  {    "name": "John",    "age": 30  }]

использование в роли простого JSON pretty-printer


$ echo '[{"name":"John","age":30},{"name":"Alice","age":25},{"name":"Bob","age":50}]' \ | jsqry[  {    "name": "John",    "age": 30  },  {    "name": "Alice",    "age": 25  },  {    "name": "Bob",    "age": 50  }]

Выходной JSON утилиты по умолчанию отформатирован. И раскрашен!


что-то более хитрое


Отфильтровать элементы больше 2, добавить к каждому 100, отсортировать по убыванию и взять 2 последних элемента. Комбинируя эти возможности вы можете строить сколь угодно сложные запросы. Узнать больше о поддерживаемом DSL.


$ echo '[1,2,3,4,5]' | jsqry '[_>2] {_+100} s(-_) [-2:]'[  104,  103]

полная мощь JS


Поскольку jsqry вмещает полноценный JS-движок в исполняемом файле менее 1 Мб, полная мощь JS в ваших руках!


$ echo '["HTTP://EXAMPLE.COM/123",          "https://www.Google.com/search?q=test",          "https://www.YouTube.com/watch?v=_OBlgSz8sSM"]' \ | jsqry '{ _.match(/:\/\/([^\/]+)\//)[1].toLowerCase() }'[  "example.com",  "www.google.com",  "www.youtube.com"]

help-сообщение


$ jsqryjsqry ver. 0.1.2Usage: echo $JSON | jsqry 'query' -1,--first     return first result element -h,--help      print help and exit -v,--version   print version and exit -c,--compact   compact output (no pretty-print) -u,--unquote   unquote output string(s) -as ARG, --arg-str ARG  supply string query argument -a ARG, --arg ARG      supply query argument of any other type

Небольшое сравнение с jq


А здесь я подготовил небольшое практическое сравнение jq и jsqry на примерах.


Установка


Текущая версия (на момент написания): 0.1.2.


К сожалению, только Linux x64 поддерживается в данный момент. Надеюсь, поддержка других платформ будет скоро добавлена. Буду рад здесь вашей помощи.


Чтобы установить или обновить утилиту, просто выполните в командной строке приведенную ниже команду:


$ sudo bash -e -c "wget https://github.com/jsqry/jsqry-cli2/releases/download/v0.1.2/jsqry-linux-amd64 -O/usr/local/bin/jsqrychmod +x /usr/local/bin/jsqryecho \"jsqry \$(jsqry -v) installed successfully\" "

О тестировании CLI-утилиты


При разработке утилиты на GitHub хотелось реализовать какое-то подобие автоматического тестирования. Юнит-тесты довольно просто писать, когда вы работаете на уровне языка программирования. Интереснее дело обстоит, если хочется протестировать CLI-утилиту как единое целое, как черный ящик. Благо, в нашем случае это должно быть просто и логично, поскольку утилита представляет собой то, что функциональщики бы назвали чистой функцией выход определяется исключительно входом.


Попытав Гугл запросами вида "bash unit testing" и отметя варианты BATS, ShellSpec, Bach и несколько других подходов, как чересчур тяжеловесные для моего случая, а также самописную систему тестирования (картинка про 14 стандартов), остановился на решении tush, гениальном в своей простоте.


Тесты на tush представляют собой текстовый файл в таком синтаксисе


$ command --that --should --execute correctly| expected stdout output$ command --that --will --cause error@ expected stderr output? expected-exit-code

Причем tush разбирает только строки начинающиеся на $, |, @ и ? все остальные могут быть любым текстом, например описанием соответствующих тестов. При запуске теста инструмент запускает все строки, начинающиеся на $ и просто сравнивает реальный вывод с ожидаемым, используя обычный diff. В случае отличия тест заканчивается неудачей, а diff отличия выводится пользователю. Пример:


$ /bin/bash /home/xonix/proj/jsqry-cli2/tests.sh--- tests.tush expected+++ tests.tush actual@@ -1,5 +1,5 @@ $ jsqry -v-| 0.1.2+| 0.1.1 $ jsqry -h | jsqry ver. 0.1.1!!! TESTS FAILED !!!

Таким образом удалось покрыть тестами базовые сценарии работы с утилитой в виде одного файла
tests.tush.


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


Удалось этот тестовый сценарий реализовать в виде GitHub Action, который запускается на каждый коммит, гарантируя корректность каждого изменения и предоставляя замечательный бейдж:


Build and test


Другие особенности решения


Раскрашивание JSON


Добавить раскраску выходного JSON оказалось на удивление просто. Решение основано на подходе из проекта zvakanaka/color-json с немного оптимизированными цветами, которые были подобраны на основе прекраснейшего StackOverflow комментария. Для примера привожу сравнение раскраски с jq. В моей версии строки более яркие, а null имеет красный цвет для пущей заметности.


screenshot


Понятно, что раскрашивание будет выполняться только при работе с терминалом.


Подключение npm-версии библиотеки в QuickJS


Опишу немного процесс по которому npm-версия библиотеки jsqry подтягивается и включается в результирующий исполняемый файл. Для этого присутствует стандартный package.json с необходимой версией библиотеки. Библиотека вытягивается стандартным npm i. Затем используется небольшой скрипт prepare-for-qjs.py, роль которого состоит в замене экспортирования в стиле nodejs на экспортирование в стиле модулей ES, только последнее поддерживается движком QuickJS. Далее уже полученный файл импортируется в основной код утилиты jsqry-cli.js.


Чтение входной строки в UTF-8 в QuickJS


В случае QuickJS некоторой мороки стоит считывание строки из stdin. Дело в том, что минималистичная стандартная библиотека, доступная в QuickJS, реализует только считывание байтов. Поэтому понадобился некоторый ручной код, чтоб перегнать байтики UTF-8 в JS-строку. К счастью, его не пришлось изобретать, а удалось позаимствовать из другого проекта на QuickJS: twardoch/svgop.


Сборка утилиты


Сборка утилиты выполняется скриптом build.sh.


Перечислю несколько "фишек" этого скрипта, которые оказались весьма полезными.


Первое скрипт безусловно вызывает в самом конце скрипт тестов tests.sh. Это гарантирует, что каждая вновь собранная версия утилиты будет протестирована, а сборка развалится если тесты будут неудачны.


Второе скрипт build.sh автоматически скачивает и компилирует заданную версию QuickJS, а скрипт tests.sh выполняет то же для инструмента тестирования tush. Очень удобно можно мгновенно продолжить разработку проекта на другой машине без лишних телодвижений.


Третий момент. В самом конце сборки выполняется команда ls -lh jsqry чтоб показать размер результирующего файла. Как я уже упомянул, я немного параноидален на этот счет, хочется чтоб CLI-утилита имела наименьший размер. Я рад, что это принесло полезный побочный результат помогло устранить регрессию, выявленную этой проверкой в одном из прошлых релизов QuickJS.


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


В качестве послесловия


Прошу заинтересовавшихся попробовать представленную утилиту как замену jq. Также буду рад услышать ваши пожелания по доработке и конструктивную критику.

Подробнее..
Категории: Javascript , Node.js , *nix , Nodejs , Cli , Json , Jq , Ecmascript , Quickjs , Query

Marko.js фронтенд от ebay.com

23.11.2020 00:11:51 | Автор: admin
Marko.js не так популярен, как Angular, React.js, Vue.js или Svelte. Marko.js это проект ebay.com, который с 2015 года стал достоянием opensource. Собственно, именно на этой библиотеке построен фронтенд ebay.com, что позволяет сделать заключение о её практической ценности для разработчиков.

Ситуация с Marko.js, немного напоминает ситуацию с фреймворком Ember.js, который, несмотря на то что работает в качестве фронтенда нескольких высоко-нагруженных сайтов (например Linkedin) о нем мало знает среднестатистический разработчик. В случае с Marko.js можно утверждать, что совсем не знает.

Marko.js неистово быстр, особенно при серверном рендеринге. Что касается серверного рендеринга, то скорость Marko.js, скорее всего, останется недосягаемой для его неторопливых собратьев, и этому есть объективные причины. О них и поговорим в предлагаемом материале.

SSR-first фреймворк


Marko.js может стать основой для классического фронтенда (с серверным рендерингом), для одностраничного приложения (с клиентским рендерингом) и для изоморфного/универсального приложения (пример которого будет рассмотрен далее). Но все же, Marko.js можно считать SSR-first библиотекой, то есть ориентированной, в первую очередь, на серверный рендеринг. От других компонентных фреймворков, Marko.js отличает то, серверный вариант компонента не строит DOM, который потом сериализуется в строку, а реализован как поток вывода. Чтобы было ясно о чем речь, приведу листинг простого серверного компонента:

// Compiled using marko@4.23.9 - DO NOT EDIT"use strict";var marko_template = module.exports = require("marko/src/html").t(__filename),    marko_componentType = "/marko-lasso-ex$1.0.0/src/components/notFound/index.marko",    marko_renderer = require("marko/src/runtime/components/renderer");function render(input, out, __component, component, state) {  var data = input;  out.w("<p>Not found</p>");}marko_template._ = marko_renderer(render, {    ___implicit: true,    ___type: marko_componentType  });marko_template.meta = {    id: "/marko-lasso-ex$1.0.0/src/components/notFound/index.marko"  };

Идея, что серверный компонент не должен быть таким же как клиентский компонент, представляется очень естественной. И на этой основе изначально строилась библиотека Marko.js. Я могу предположить, что в случае с другими фреймворками, которые изначально строились как клиент-ориентированные, серверный рендеринг приматывался скотчем к уже существующей весьма сложной кодовой базе. Отсюда возникло это архитектурно неверное решение, когда на стороне сервера воссоздается DOM, чтобы существующий клиентский код мог быть переиспользован без изменений.

Почему это важно


Прогресс в создании одностраничных приложений, который наблюдается с широким распространением Angular, React.js, Vue.js, наряду с положительными моментами, выявил и несколько фатальных просчетов клиент-ориентированной архитектуры. В далеком 2013 году Spike Brehm из Airbnb опубликовал программную статью, в которой соответствующий раздел называется Ложка дегтя в бочке меда. При этом все отрицательные пункты бьют по бизнесу:

  • увеличивается время загрузки первой страницы;
  • контент не индексируется поисковиками;
  • проблемы с доступностью для людей с ограниченными возможностями.

Как альтернатива, наконец, были созданы фреймворки для разработки изоморфных/универсальных приложений: Next.js и Nust.js. И тут начинает вступать в игру другой фактор производительность. Всем известно, что node.js не так хорош, если его нагружать сложными расчетами. А в случае, когда мы на сервере создаем DOM, а потом запускаем его сериализацию, node.js очень быстро выдыхается. Да, мы можем поднимать бесконечное количество реплик node.js. Но может быть попробовать сделать все то же но на Marko.js?

Как начать работать с Marko.js


Для первого знакомства рекомендую начать как описано в документации с команды npx @marko/create --template lasso-express.

В результате получим основу для дальнейшей разработки проектов с настроенным сервером Express.js и компоновщиком Lasso (этот компоновщик является разработкой ebay.com и с ним проще всего интегрироваться).

Компоненты в Marko.js, как правило, располагаются в каталогах /components в файлах с расширением .marko. Код компонентов интуитивно понятен. Как сказано в документации, если Вы знаете html, то вы знаете Marko.js.

Компонент рендерится на сервере, и потом воссоздается (hydrate) на клиенте. То есть, на клиенте мы получаем не статический html, а полноценный клиентский компонент, с состоянием и событиями.

При запуске проекта в режиме разработки работает горячая перезагрузка (hot reloading).

Для построения сложного приложения, нам, скорее всего, кроме библиотеки компонентов необходимо иметь еще кое-что, например роутинг, стор, каркас для создания изоморфных/универсальных приложений. И тут, увы, проблемы те же, с которыми первые годы сталкивались разработчики React.js нет готовых решений и общеизвестных подходов. Поэтому все, что было до этого момента, можно назвать вступлением к беседе о построении приложения на основе Marko.js.

Строим изоморфное/универсальное приложение


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

Marko.js позволяет задавать и менять имя тэга или компонент динамически (то есть программно) этим и воспользуемся. Сопоставим роутам имена компонентов. Поскольку из коробки в Marko.js роутинга нет (интересно узнать как это построено на ebay.com) воспользуемся пакетом, который как раз для таких случаев universal-router:

const axios = require('axios');const UniversalRouter = require('universal-router');module.exports = new UniversalRouter([  { path: '/home', action: (req) => ({ page: 'home' }) },  {    path: '/user-list',    action: async (req) => {      const {data: users} = await axios.get('http://localhost:8080/api/users');      return { page: 'user-list', data: { users } };    }  },  {    path: '/users/:id',    action: async (req) => {      const {data: user} = await axios.get(`http://localhost:8080/api/users/${req.params.id}`);      return { page: 'user', data: { req, user } };    }  },  { path: '(.*)', action: () => ({ page: 'notFound' }) }])

Функционал пакета universal-router прост до безобразия. Он разбирает строку url, и вызывает с разобранной строкой асинхронную функцию action(req), внутри которой мы можем, например, получить доступ к разобранным параметрам строки (req.params.id). А поскольку функция action(req) вызывается асинхронно, мы можем прямо здесь инициализировать данные запросами к API.

У нас, как вы помните, в прошлом разделе был создан проект командой npx @marko/create --template lasso-express. Возьмем его как основу для нашего изоморфного/универсального приложения. Для этого немного изменим файл server.js

app.get('/*', async function(req, res) {    const { page, data } = await router.resolve(req.originalUrl);    res.marko(indexTemplate, {            page,            data,        });});

Также изменим шаблон загружаемой страницы:

<lasso-page/><!doctype html><html lang="en">  <head>    <meta charset="UTF-8"/>    <title>Marko | Lasso + Express</title>    <lasso-head/>    <style>      .container{        margin-left: auto;        margin-right: auto;        width: 800px;    }    </style>  </head>  <body>    <sample-header title="Lasso + Express"/>    <div class="container">      <router page=input.page data=input.data/>    </div>    <lasso-body/>    <!--    Page will automatically refresh any time a template is modified    if launched using the browser-refresh Node.js process launcher:    https://github.com/patrick-steele-idem/browser-refresh    -->    <browser-refresh/>  </body></html>

Компонент <router/> как раз та часть которая будет отвечать за загрузку динамических компонентов, имена которых получаем из роутера в атрибуте page.

<layout page=input.page>  <${state.component} data=state.data/></layout>import history from '../../history'import router from '../../router'class {  onCreate({ page, data }) {    this.state = {      component: require(`../${page}/index.marko`),      data    }    history.listen(this.handle.bind(this))  }  async handle({location}) {    const route = await router.resolve(location);    this.state.data = route.data;    this.state.component = require(`../${route.page}/index.marko`);  }}

Традиционно, Marko.js имеет состояние this.state, изменение которого вызывает изменение представления компонента, чем мы и пользуемся.

Работу с историей приходится реализовывать также самостоятельно:

const { createBrowserHistory } = require('history')const parse = require('url-parse')const deepEqual = require('deep-equal')const isNode = new Function('try {return !!process.env;}catch(e){return false;}') //eslint-disable-linelet historyif (!isNode()) {  history = createBrowserHistory()  history.navigate = function (path, state) {    const parsedPath = parse(path)    const location = history.location    if (parsedPath.pathname === location.pathname &&      parsedPath.query === location.search &&      parsedPath.hash === location.hash &&      deepEqual(state, location.state)) {      return    }    const args = Array.from(arguments)    args.splice(0, 2)    return history.push(...[path, state, ...args])  }} else {  history = {}  history.navigate = function () {}  history.listen = function () {}}module.exports = history

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

import history from '../../history'<a on-click("handleClick") href=input.href><${input.renderBody}/></a>class {  handleClick(e) {    e.preventDefault()    history.navigate(this.input.href)  }}

Для удобства изучения материала я представил результаты в репозитарии.

apapacy@gmail.com
22 ноября 2020 года
Подробнее..

Разработка сервера для многопользовательской игры с помощью nodejs и magx

08.12.2020 12:05:45 | Автор: admin

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


Я хочу рассказать про библиотеку magx, используя которую можно не задумываться о сетевой составляющей (коммуникации сервера и клиентов), a сразу сконцентрироваться на разработке логики игры и клиентского UI.


При разработке архитектуры многопользовательсой игры обычно рассматриваются 2 подхода: с авторитарным сервером и не-авторитарным (авторитарным клиентом). Оба эти подхода поддерживаются библиотекой magx. Начнем с более простого подхода не-авторитарного.


Не-авторитарный сервер


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


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


С помощью библиотеки magx такой сервер можно реализовать всего в несколько строк кода:


import * as http from "http"import { Server, RelayRoom } from "magx"const server = http.createServer()const magx = new Server(server)magx.define("relay", RelayRoom)// start serverconst port = process.env.PORT || 3001server.listen(port, () => {  console.info(`Server started on http://localhost:${port}`)})

Теперь, чтобы подключить клиентов к этому серверу и начать их взаимодействие, достаточно установить js библиотеку:


npm install --save magx-client


и подключить ее к проекту:


import { Client } from "magx-client"

Также можно воспользовать прямой ссылкой для использования в HTML:


<script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/magx-client@0.7.1/dist/magx.js"></script>

После подключиеня, всего несколько строк позволят настроить соединение и взаимодействие с сервером:


// authenticate to serverawait client.authenticate()// create or join roomconst rooms = await client.getRooms("relay")room = rooms.length   ? await client.joinRoom(rooms[0].id)  : await client.createRoom("relay")console.log("you joined room", name)// handle state patchesroom.onPatch((patch) => updateState(patch))// handle state snapshotroom.onSnapshot((snapshot) => setState(snapshot))// handle joined playersroom.onMessage("player_join", (id) => console.log("player join", id))// handle left playersroom.onMessage("player_leave", (id) => console.log("player leave", id))

Детальный пример как построить взаимодействие между клиентами и не-авторитарным сервером описано в соответствующем примере в проекте magx-examples.


Авторитарный сервер


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


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


При таком подходе логика на клиенте сводится к минимуму, клиент начинает отвечать только за отрисовку состояния игры и обработку действий игрока. Преимущество такого подхода в том, что он значительно усложняет клиентам возможность использования нечестных приёмов (cheating).


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


Данные/состояние каждой комнаты изолировано и синхронизируется только с клиентам комнаты. Одним из важнейших элементов авторитарного сервера это система управления состоянием. Mosx рекомендованная система управления состоянием, но архитектуре magx нет зависимости на какую-либо систему, поэтому выбор всегда остается за вами.


Описание игрового состояния


Так как вся логика игры должна строиться на основе состояния, то первым делом необходимо его описать. С помощью mosx это сделать достаточно просто необходимо создать классы для каждого типа объектов состояния и обернуть его декоратором @mx.Object, а перед каждым свойством, по которому необходимо отслеживать изменение состояния для синхронизации с клиентами необходимо поставить декоратор @mx. Давайте рассмотрим пример состояния с коллекцией игроков:


@mx.Objectexport class Player {  @mx public x = Math.floor(Math.random() * 400)  @mx public y = Math.floor(Math.random() * 400)}@mx.Objectexport class State {  @mx public players = new Map<string, Player>()  public createPlayer(id: string) {    this.players.set(id, new Player())  }  public removePlayer(id: string) {    this.players.delete(id)  }  public movePlayer(id: string, movement: any) {    const player = this.players.get(id)    if (!player) { return }    player.x += movement.x ? movement.x * 10 : 0    player.y += movement.y ? movement.y * 10 : 0  }}

Единственное ограничение, которое необходимо учесть при проектировании состояния необходимость использования Map() вместо вложенных объектов. Массивы (Array) и все примитивные типы (number, string, boolean) могут быть использованы без ограничений.


Описание игровой комнаты


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


export class MosxStateRoom extends Room<State> {  public createState(): any {    // create state    return new State()  }  public createPatchTracker(state: State) {    // create state change tracker    return Mosx.createTracker(state)  }  public onCreate(params: any) {    console.log("MosxStateRoom created!", params)  }  public onMessage(client: Client, type: string, data: any) {    if (type === "move") {      console.log(`MosxStateRoom received message from ${client.id}`, data)      this.state.movePlayer(client.id, data)    }  }  public onJoin(client: Client, params: any) {    console.log(`Player ${client.id} joined MosxStateRoom`, params)    client.send("hello", "world")    this.state.createPlayer(client.id)  }  public onLeave(client: Client) {    this.state.removePlayer(client.id)  }  public onClose() {    console.log("MosxStateRoom closed!")  }}

Регистрация комнаты на сервере


Последний шаг зарегистрировать комнату и сервер готов.


const magx = new Server(server, params)magx.define("mosx-state", MosxStateRoom)

Полный исходный код рассмотренного примера доступен в репозитарии magx-examples.


Почему стоит обратить внимание на этот проект?


В заключение хотел бы упомянуть о преимуществах используемых библиотек:


Mosx


  1. Простой и удобный способ описания состояния через декораторы @mx
  2. Возможность применять декоратор @mx к вычисляемым свойствам.
  3. Возможность создавать приватные объекты @mx.Object.private и приватные свойства @mx.private, с различным уровнем доступа для разных игроков.
  4. Динамически изменять доступ игроков к приватным объектам.
  5. Встроенный механизм объединения объектов в группы для удобного управления правами доступа к приватным данным
  6. Возможность делать копию состояния для каждого игрока
  7. Полная поддержка Typescript
  8. Минимум зависимостей (на библиотеки MobX и patchpack для сжатия пакетов)

Magx


  1. Простой и понятный API.
  2. Широкие возможности по кастомизации компонент сервера:
    • Механизм коммуникации клиента с сервером (по умолчанию используется webockets)
    • База данных для хранения данных комнат и сессий пользователей (по умолчанию используется локальное хранилище)
    • Механизм коммуникации серверов при масштабировании (из коробки реализован механизм коммуникации в кластере)
    • Способ авторизации и верификации пользователей (по умолчанию используются локальный сессии)
  3. Возможность работать в кластере из коробки
  4. Встроенные комнаты: лобби и relay (для не авторитарного сервера)
  5. JS Библиотека Magx-client для работы с сервером
  6. Мониторинговая консоль magx-monitor для управления комнатами сервера, их клиентами и просмотр состояния
  7. Полная поддержка Typescript
  8. Минимум зависимостей (на библиотеку notepack.io для уменьшения сетевого трафика)

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

Подробнее..

Первый парсер на деревне

24.12.2020 16:21:15 | Автор: admin
Сегодня мы померяемся парсерами. Точнее, померяем эффективность разных вариантов JavaScript-парсеров на примере одной простой задачи преобразования строки конкретного формата в объект.


Вопросы сложностей организации многопоточности в JavaScript оставим за рамками этой статьи и сосредоточимся на различных вариантах и малоизвестных способах использования регулярных выражений для разбора строки buffers-атрибута узла плана PostgreSQL.

То есть из строки вида 'Buffers: shared hit=123 read=456, local hit=789' мы хотим как можно быстрее получить JSON такого формата:

{  "shared-hit"  : 123, "shared-read" : 456, "local-hit"   : 789}

Выглядит вроде все тривиально, правда же?


Немного предыстории


Откуда вообще возникла такая задача разбирать строки как можно быстрее?

Я уже рассказывал, что у нас в Тензоре используется много сотен серверов PostgreSQL. И чтобы приглядывать за актуальной производительностью запросов на них, мы разработали коллектор-анализатор логов этой СУБД, который выцепляет из потока от сервера планы запросов, разбирает их и вычисляет нагрузку для каждого отдельного узла, что не так уж и просто.

То есть надо уметь сидеть на потоке и быстро-быстро анализировать (а потому иметь максимальную производительность и минимальный прирост памяти) примерно вот такие блоки текста, а среди них и наши buffers-строки:

Hash Left Join (actual time=9.248..51.659 rows=551 loops=1)  Hash Cond: (c.reloftype = t.oid)  Buffers: shared hit=5814 read=251 dirtied=63  ->  Hash Join (actual time=2.990..7.148 rows=551 loops=1)        Hash Cond: (c.relnamespace = nc.oid)        Buffers: shared hit=4249 read=2        ->  Seq Scan on pg_class c (actual time=0.046..3.922 rows=555 loops=1)              Filter: ((relkind = ANY ('{r,v,f,p}'::"char"[])) AND (pg_has_role(relowner, 'USAGE'::text) OR has_table_privilege(oid, 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'::text) OR has_any_column_privilege(oid, 'SELECT, INSERT, UPDATE, REFERENCES'::text)))              Rows Removed by Filter: 3308              Buffers: shared hit=1829        ->  Hash (actual time=2.931..2.931 rows=7 loops=1)              Buckets: 1024  Batches: 1  Memory Usage: 9kB              Buffers: shared hit=2420 read=2              ->  Seq Scan on pg_namespace nc (actual time=0.035..2.912 rows=7 loops=1)                    Filter: (NOT pg_is_other_temp_schema(oid))                    Rows Removed by Filter: 784                    Buffers: shared hit=2420 read=2  ->  Hash (actual time=6.199..6.199 rows=1629 loops=1)        Buckets: 2048  Batches: 1  Memory Usage: 277kB        Buffers: shared hit=105 read=162 dirtied=63        ->  Hash Join (actual time=0.338..5.640 rows=1629 loops=1)              Hash Cond: (t.typnamespace = nt.oid)              Buffers: shared hit=105 read=162 dirtied=63              ->  Seq Scan on pg_type t (actual time=0.015..4.910 rows=1629 loops=1)                    Buffers: shared hit=57 read=162 dirtied=63              ->  Hash (actual time=0.307..0.307 rows=791 loops=1)                    Buckets: 1024  Batches: 1  Memory Usage: 86kB                    Buffers: shared hit=48                    ->  Seq Scan on pg_namespace nt (actual time=0.004..0.121 rows=791 loops=1)                          Buffers: shared hit=48

Формат строки


В общем случае, формат описан в исходниках PostgreSQL. Если представить его в виде JS-кода, то получится что-то вроде:

const keys = [  ['shared', ['hit', 'read', 'dirtied', 'written']], ['local',  ['hit', 'read', 'dirtied', 'written']], ['temp',   ['read', 'written']] // да, тут другой набор ключей 2-го уровня];let str = 'Buffers: ' + // константное начало  keys    .filter(([keyo, keysi]) => node[keyo])    .map(([keyo, keysi]) => [        keyo      , ...keysi          .filter(keyi => node[keyo][keyi] > 0)          .map(keyi => `${keyi}=${node[keyo][keyi]}`)      ].join(' ') // внутри собираем сегменты через пробел    )    .join(', ');  // снаружи - через запятая-пробел

Методика тестирования


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

Buffers: shared hit=31770Buffers: shared hit=1159Buffers: shared hit=255Buffers: shared hit=2579 read=2961 dirtied=3Buffers: shared hit=3 read=1Buffers: shared hit=205 read=44Buffers: shared hit=230 read=34 dirtied=3Buffers: shared hit=13Buffers: shared hit=5Buffers: shared hit=6...

Чтобы исключить возможное влияние GC, запускать наши тесты будем с ключами --expose-gc --initial-old-space-size=1024. Оцениваем всех участников по двум показателям: общее время работы и прирост объема памяти, который пришлось использовать (и на чистку которого потом придется потратить время GC и ресурсы CPU).

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

const fs = require('fs');const heapdump = require('heapdump');const buffers = fs.readFileSync('buffers.txt').toString().split('\n');const parseBuffers = line => {// -- 8< --// ...// -- 8< --};global.gc();// нулевое состояние до тестаheapdump.writeSnapshot();const hrb = process.hrtime();for (let line of buffers) {  let obj = parseBuffers(line);}const hre = process.hrtime(hrb);// состояние памяти после тестаheapdump.writeSnapshot();const usec = hre[0] * 1e+9 + hre[1];console.log(usec);

Что ж Приступим, и постараемся грамотно использовать все возможности, которые дают нам современные версии языка.


И начнем с самого простого.

Бронза: обыкновенный .split


const parseBuffers = line => {  let rv = {};  line.slice('Buffers: '.length)              // "константное" начало нас не интересует    .split(', ').forEach(part => {            // 'shared ..., local ..., temp ...' => ['shared ...', 'local ...', 'temp ...']      let [kind, ...pairs] = part.split(' '); // 'shared hit=1 read=2' => ['shared', ['hit=1', 'read=2']]      pairs.forEach(pair => {        let [type, val] = pair.split('=');    // 'hit=1' => ['hit', '1']        rv[`${kind}-${type}`] = Number(val);  // ['shared-hit'] = 1      });    });  return rv;};

Time, avg: 544msSize Delta: +14.8MB: - (sliced string) : +6.8 // сегменты строк без 'Buffers: ' - (string)        : +6.3 // строки имен ключей - (array)         : +1.7 // массивы pairs

Серебро: .lastIndex + итерация по .matchAll(RegExp)


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

С именами ключей все понятно давайте сгенерируем их все заранее, их же всего 10 вариантов. А вот .slice строки мы использовали, только лишь чтобы каждый раз сдвинуть начало анализа на одинаковое начало 'Buffers: '. А нельзя ли как-то сделать это без порождения новых строк?

Оказывается, можно, если использовать принудительную установку re.lastIndex для глобального RegExp.
Подробнее про g- и y-ключи и использование .lastIndex для более точного применения RegExp.

На этот раз будем искать в строке только те ключевые слова, которые нас интересуют:

const buffersRE = /(shared|local|temp)|(hit|read|dirtied|written)=(\d+)/g;const buffersKeys = {  'shared' : {    'hit'     : 'shared-hit'  , 'read'    : 'shared-read'  , 'dirtied' : 'shared-dirtied'  , 'written' : 'shared-written'  }, 'local' : {    'hit'     : 'local-hit'  , 'read'    : 'local-read'  , 'dirtied' : 'local-dirtied'  , 'written' : 'local-written'  }, 'temp' : {    'read'    : 'temp-read'  , 'written' : 'temp-written'  }};const parseBuffers = line => {  let rv = {};  let keys;  buffersRE.lastIndex = 9; // сдвигаем начало поиска на 'Buffers: '.length  for (let match of line.matchAll(buffersRE)) {    if (match[1]) {      keys = buffersKeys[match[1]];    }    else {      rv[keys[match[2]]] = Number(match[3]);    }  }  return rv;};

Time, avg: 270msSize Delta: +8.5MB

Золото: полнопозиционный .match(RegExp)


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

Чтобы не заниматься двухуровневым спуском по словарю за именами ключей, построим такой страшный RegExp, каждая захватываемая позиция которого всегда соответствует одному и тому же имени ключа. Позиции отсутствующих в строке ключей будут заполнены в match-массиве undefined:

const buffersRE = /^Buffers:(?:,? shared(?: hit=(\d+))?(?: read=(\d+))?(?: dirtied=(\d+))?(?: written=(\d+))?)?(?:,? local(?: hit=(\d+))?(?: read=(\d+))?(?: dirtied=(\d+))?(?: written=(\d+))?)?(?:,? temp(?: read=(\d+))?(?: written=(\d+))?)?$/;const buffersKeys = ['shared-hit', 'shared-read', 'shared-dirtied', 'shared-written', 'local-hit', 'local-read', 'local-dirtied', 'local-written', 'temp-read', 'temp-written'];const parseBuffers = line =>   line.match(buffersRE)    .slice(1) // в match[0] лежит исходная строка, которая нам не нужна    .reduce(      (rv, val, idx) => (val !== undefined && (rv[buffersKeys[idx]] = Number(val)), rv)    , {}    );

Time, avg: 111msSize Delta: +8.5MB

Наблюдательный читатель сразу же задаст вопрос разве не будет быстрее, если убрать из регулярки константное начало '^Buffers:':

const buffersRE = /(?:,? shared(?: hit=(\d+))?(?: read=(\d+))?(?: dirtied=(\d+))?(?: written=(\d+))?)?(?:,? local(?: hit=(\d+))?(?: read=(\d+))?(?: dirtied=(\d+))?(?: written=(\d+))?)?(?:,? temp(?: read=(\d+))?(?: written=(\d+))?)?$/;

Ведь результат от этого не должен никак измениться? Но нет, такой вариант на четверть хуже:

Time, avg: 140ms

Дело в том, что наш полный RegExp /^...$/ не содержит ни одной переменной части, а в случае без начала для каждой позиции этого сегмента приходится проверять, не начинается ли тут один из хвостов (shared ...|local ...|temp ...) что требует гораздо больше ресурсов, чем просто впустую проверить совпадение двух подстрок.

Вне конкурса: скрещиваем ужа и ежа


В предыдущем варианте мы все-таки внесли начало строки в регулярку, и оно проверяется каждый раз! Давайте же воспользуемся методом с .lastIndex:

  • но он работает только с глобальными RegExp
  • для глобального RegExp обычный .match захватывает сразу всю строку, а не позиционно
  • для получения позиционного набора нам придется использовать первый (а на самом деле, единственный) результат итератора .matchAll


const buffersRE = /(?:,? shared(?: hit=(\d+))?(?: read=(\d+))?(?: dirtied=(\d+))?(?: written=(\d+))?)?(?:,? local(?: hit=(\d+))?(?: read=(\d+))?(?: dirtied=(\d+))?(?: written=(\d+))?)?(?:,? temp(?: read=(\d+))?(?: written=(\d+))?)?$/g;const buffersKeys = ['shared-hit', 'shared-read', 'shared-dirtied', 'shared-written', 'local-hit', 'local-read', 'local-dirtied', 'local-written', 'temp-read', 'temp-written'];const parseBuffers = line => {  buffersRE.lastIndex = 8; // 'Buffers:'.length  return line.matchAll(buffersRE).next().value    .slice(1)    .reduce(      (rv, val, idx) => (val !== undefined && (rv[buffersKeys[idx]] = Number(val)), rv)    , {}    );};

Time, avg: 304msSize Delta: +8.5MB

То есть наш странный гибрид по памяти никакого выигрыша не дал, а по скорости проиграл обоим своим родителям.

Итого


В сегодняшнем забеге кубок вручается обычному полнопозиционному .match(RegExp). Ура, товарищи!
Подробнее..

Server-Side Rendering с нуля до профи

18.01.2021 14:21:57 | Автор: admin

  • В данной статье мы разберем влияние SSR на SEO оптимизацию приложения.
  • Пройдем с вами путь по переносу обычного React приложения на SSR.
  • Разберем обработку асинхронных операций в SSR приложениях.
  • Посмотрим, как делать SSR в приложениях с Redux Saga.
  • Настроим Webpack 5 для работы с SSR приложением.
  • А также рассмотрим тонкости работы SSR: Генерация HTML Meta Tags, Dynamic Imports, работа с LocalStorage, debugging и прочее.




Пару лет назад, работая над своим продуктом Cleverbrush мой друг и я столкнулись с проблемой SEO оптимизации. Созданный нами сайт, который по идее должен был продавать наш продукт, а это был обычный Single Page React Application, не выводился в Google выборке даже по ключевым словам! В ходе детального разбора данной проблемы родилась библиотека iSSR, а также наш сайт начал появляться на первой странице Google. Итак, давайте по порядку!

Проблема



Главной проблемой Single Page приложений является то, что сервер отдает клиенту пустую HTML страницу. Её формирование происходит только после того как весь JS будет скачан (это весь ваш код, библиотеки, фреймверк). Это в большинстве случаев более 2-х мегабайт размера + задержки на обработку кода.

Даже если Google-бот умеет выполнять JS, он получает контент только спустя некоторое время, критичное для ранжирования сайта. Google-бот попросту видит пустую страницу несколько секунд! Это плохо!

Google начинает выдавать красные карты если ваш сайт рендерится более 3-х секунд. First Contentful Paint, Time to Interactive это метрики которые будут занижены при Single Page Application. Подробнее читайте здесь.

Также есть менее продвинутые поисковые системы, которые попросту не умеют работать с JS. В них Single Page Application не будут индексироваться.

На ранжирование сайта еще влияет множество факторов, часть из них мы разберем далее в этой статье.

Рендеринг



Существует несколько путей как решить проблему пустой странички при загрузке, рассмотрим несколько из них:

Static Site Generation (SSG). Сделать пререндер сайта перед тем как его загрузить на сервер. Очень простое и эффективное решение. Отлично подходит для простых веб страничек, без взаимодействия с backend API.

Server-Side Rendering (SSR). Рендерить контент в рантайме на сервере. При таком подходе мы сможем делать запросы backend API и отдавать HTML вместе с необходимым содержимым.

Server-Side Rendering (SSR)



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

  • У нас должен быть сервер, который выполняет наше приложение точно так же, как делал бы это пользователь в браузере. Делая запросы на необходимые ресурсы, отображая весь необходимый HTML, наполняя состояние.
  • Сервер отдает клиенту наполненный HTML, наполненное состояние, а также отдает все необходимые JS, CSS и прочие ресурсы.
  • Клиент, получая HTML и ресурсы, синхронизирует состояние и работает с приложением как с обычным Single Page Application. При этом важным моментом является то, что состояние должно синхронизироваться.


Схематично SSR приложение выглядит вот так:

SSR

Из вышеописанной работы SSR приложения мы можем выделить проблемы:

  • Приложение делится на сервер и клиент. То есть у нас по сути получается 2 приложения. Данное разделение должно быть минимально иначе поддержка такого приложения будет сложной.
  • Сервер должен уметь обрабатывать запросы к API с данными. Данные операции асинхронные, являются Side Effects. По умолчанию renderToString метод React работающий с сервером синхронный и не может работать с асинхронными операциями.
  • На клиенте приложение должно синхронизировать состояние и продолжать работать как обычное SPA приложение.


iSSR



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

Посмотрим на примере, как просто перенести обычное SPA приложение на SSR.

К примеру, у нас есть простейшее приложение с асинхронной логикой.

Код приложения
import React, { useState, useEffect } from 'react';import { render } from 'react-dom';const getTodos = () => {  return fetch('https://jsonplaceholder.typicode.com/todos')    .then(data => data.json())};const TodoList = () => {  const [todos, setTodos] = useState([]);  useEffect(() => {    getTodos()      .then(todos => setTodos(todos))  }, []);  return (    <div>      <h1>Hi</h1>      <ul>        {todos.map(todo => (          <li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>        ))}      </ul>    </div>  )}render(  <TodoList />,  document.getElementById('root'));



Данный код рендерит список выполненных задач, используя сервис jsonplaceholder для эмуляции взаимодействия с API.

Сделаем данное приложение SSR!



Шаг 1. Установка зависимостей



Для установки iSSR нужно выполнить:

npm install @issr/core --savenpm install @issr/babel-plugin --save-dev


Для настройки базовой билд системы установим:

npm install @babel/core @babel/preset-react babel-loader webpack webpack-cli nodemon-webpack-plugin --save-dev


Один из неочевидных моментов разработки SSR приложений является то, что некоторые API и библиотеки могут работать на клиенте но не работать на сервере. Одним из таких API является fetch. Данный метод отсутствует в nodejs где будет выполняться серверная логика нашего приложения. Для того, чтобы у нас приложение работало одинаково установим пакет:

npm install node-fetch --save


Для сервера будем использовать express, но это не важно, можно использовать любой другой фреймверк:

npm install express --save


Добавим модуль для сериализации состояния приложения на сервере:
npm install serialize-javascript --save


Шаг 2. Настройка webpack.config.js



webpack.config.js
const path = require('path');const NodemonPlugin = require('nodemon-webpack-plugin');const commonConfig = {  module: {    rules: [      {        test: /\.jsx$/,        exclude: /node_modules/,        use: [          {            loader: 'babel-loader',            options: {              presets: [                '@babel/preset-react'              ],              plugins: [                '@issr/babel-plugin'              ]            }          }        ]      }    ]  },  resolve: {    extensions: [      '.js',      '.jsx'    ]  }}module.exports = [  {    ...commonConfig,    target: 'node',    entry: './src/server.jsx',    output: {      path: path.resolve(__dirname, './dist'),      filename: 'index.js',    },    plugins: [      new NodemonPlugin({        watch: path.resolve(__dirname, './dist'),      })    ]  },  {    ...commonConfig,    entry: './src/client.jsx',    output: {      path: path.resolve(__dirname, './public'),      filename: 'index.js',    }  }];



  • Для компиляции SSR приложения конфигурационный файл webpack должен состоять из двух конфигураций (MultiCompilation). Одна для сборки сервера, вторая для сборки клиента. Мы передаем в module.exports массив.
  • Для конфигурации сервера нам нужно задать target: 'node'. Для клиента задавать target не обязательно. По умолчанию конфигурация webpack имеет target: web. target: node позволяет webpack обрабатывать сервер код, модули по умолчанию, такие как path, child_process и прочее.
  • const commonConfig общая часть настроек. Так как код сервера и клиента имеет общую структуру приложения, они должны обрабатывать JS одинаково.


В babel-loader необходимо добавить плагин:
@issr/babel-plugin
Это вспомогательный модуль @issr/babel-plugin позволяющий отследить асинхронные операции в приложении. Замечательно работает с babel/typescript-preset, и прочими babel плагинами.

Шаг 3. Модификация кода.



Вынесем общую логику нашего приложения в отдельный файл App.jsx. Это нужно для того, чтобы в файлах client.jsx и server.jsx осталась только логика рендеринга, ничего больше. Таким образом весь код приложения у нас будет общий.

App.jsx
import React from 'react';import fetch from 'node-fetch';import { useSsrState, useSsrEffect } from '@issr/core';const getTodos = () => {  return fetch('https://jsonplaceholder.typicode.com/todos')    .then(data => data.json())};export const App = () => {  const [todos, setTodos] = useSsrState([]);  useSsrEffect(async () => {    const todos = await getTodos()    setTodos(todos);  });  return (    <div>      <h1>Hi</h1>      <ul>        {todos.map(todo => (          <li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>        ))}      </ul>    </div>  );};



client.jsx
import React from 'react';import { hydrate } from 'react-dom';import { App } from './App';hydrate(  <App />,  document.getElementById('root'));



Мы поменяли стандартный render метод React на hydrate, который работает для SSR приложений.

server.jsx
import React from 'react';import express from 'express';import { renderToString } from 'react-dom/server';import { App } from './App';const app = express();app.use(express.static('public'));app.get('/*', async (req, res) => {const html = renderToString(<App />);  res.send(`  <!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Title</title></head><body>    <div id="root">${html}</div></body></html>`);});app.listen(4000, () => {  console.log('Example app listening on port 4000!');});



В коде сервера обратите внимание, что мы должны расшаривать папку с собранным SPA приложением webpack:
app.use(express.static('public'));
Таким образом, полученный с сервера HTML будет работать далее как обычный SPA

Шаг 4. Обработка асинхронности.



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

Для обработки асинхронных операций их нужно обернуть в хук useSsrEffect из пакета @issr/core:

App.jsx
import React from 'react';import fetch from 'node-fetch';import { useSsrEffect } from '@issr/core';const getTodos = () => {  return fetch('https://jsonplaceholder.typicode.com/todos')    .then(data => data.json())};export const App = () => {  const [todos, setTodos] = useState([]);  useSsrEffect(async () => {    const todos = await getTodos()    setTodos(todos);  });  return (    <div>      <h1>Hi</h1>      <ul>        {todos.map(todo => (          <li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>        ))}      </ul>    </div>  );};



В server.jsx заменим стандартный renderToString на serverRender из пакета @issr/core:

server.jsx
import React from 'react';import express from 'express';import { serverRender } from '@issr/core';import serialize from 'serialize-javascript';import { App } from './App';const app = express();app.use(express.static('public'));app.get('/*', async (req, res) => {  const { html } = await serverRender(() => <App />);  res.send(`  <!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Title</title></head><body>    <div id="root">${html}</div>    <script src="http://personeltest.ru/aways/habr.com/index.js"></script></body></html>`);});app.listen(4000, () => {  console.log('Example app listening on port 4000!');});



Если запустить приложение сейчас, то ничего не произойдет! Мы не увидим результата выполнения асинхронной функции getTodos. Почему так происходит? Мы забыли синхронизировать состояние. Давайте исправим это.

В App.jsx заменим стандартный setState на useSsrState из пакета @issr/core:

App.jsx
import React from 'react';import fetch from 'node-fetch';import { useSsrState, useSsrEffect } from '@issr/core';const getTodos = () => {  return fetch('https://jsonplaceholder.typicode.com/todos')    .then(data => data.json())};export const App = () => {  const [todos, setTodos] = useSsrState([]);  useSsrEffect(async () => {    const todos = await getTodos()    setTodos(todos);  });  return (    <div>      <h1>Hi</h1>      <ul>        {todos.map(todo => (          <li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>        ))}      </ul>    </div>  );};



Внесем изменения в client.jsx для синхронизации состояния переданного с сервера на клиент:

client.jsx
import React from 'react';import { hydrate } from 'react-dom';import createSsr from '@issr/core';import { App } from './App';const SSR = createSsr(window.SSR_DATA);hydrate(  <SSR>    <App />  </SSR>,  document.getElementById('root'));



window.SSR_DATA это объект, переданный с сервера с кешированнным состоянием, для синхронизации на клиенте.

Сделаем передачу состояние на сервере:

server.jsx
import React from 'react';import express from 'express';import { serverRender } from '@issr/core';import serialize from 'serialize-javascript';import { App } from './App';const app = express();app.use(express.static('public'));app.get('/*', async (req, res) => {  const { html, state } = await serverRender(() => <App />);  res.send(`  <!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Title</title>    <script>      window.SSR_DATA = ${serialize(state, { isJSON: true })}    </script></head><body>    <div id="root">${html}</div>    <script src="http://personeltest.ru/aways/habr.com/index.js"></script></body></html>`);});app.listen(4000, () => {  console.log('Example app listening on port 4000!');});



Обратите внимание, что функция serverRender передает не только HTML, но и состояние, которое прошло через useSsrState, мы его передаем на клиент, в качестве глобальной переменной SSR_DATA. На клиенте, данное состояние будет автоматически синхронизировано.

Шаг 5. Билд скрипты.


Осталось добавить скрипты запуска в package.json:

"scripts": { "start": "webpack -w --mode development", "build": "webpack"},


Redux и прочие State Management библиотеки



iSSR отлично поддерживает разные state management библиотеки. В ходе работы над iSSR я заметил, что React State Management библиотеки делятся на 2 типа:
  1. Реализует работу с Side Effects на слое React. Например Redux Thunk превращает вызов Redux dispatch в асинхронный метод, а значит мы можем имплементить SSR как в примере выше для setState. Пример с redux-thunk доступен по ссылке
  2. Реализуют работу с Side Effects на отдельном от React слое. Например Redux Saga выносит работу с асинхронными операциями в Саги.


Рассмотрим пример реализации SSR для приложения с Redux Saga.

Мы не будем рассматривать этот пример так детально как предыдущий. Ознакомится с полным кодом можно по ссылке.

Redux Saga


*Для лучшего понимания происходящего, читайте предыдущую главу

Сервер запускает наше приложение через serverRender, код выполняется последовательно, выполняя все операции useSsrEffect.

Концептуально Redux работая с сагами не выполняет никаких асинхронных операций. Наша задача отправить action для старта асинхронной операции в слое Саг, отдельных от нашего react-flow. В примере по ссылке выше, в контейнере Redux мы выполняем

useSsrEffect(() => { dispatch(fetchImage());});


Это не асинхронная операция! Но iSSR понимает, что что то произошло в системе. iSSR будет идти по остальным React компонентам выполняя все useSsrEffect если таковые будут и по завершению iSSR вызовет каллбек:

const { html } = await serverRender(() => ( <Provider store={store}>   <App /> </Provider>), async () => { store.dispatch(END); await rootSaga.toPromise();});


Таким образом мы можем обрабатывать асинхронные операции не только на уровне с React но и на других уровнях, в данном случае мы в начале поставили на выполнение нужные нам саги, после чего в callback serverRender запустили и дождались их окончания.

Я подготовил много примеров использования iSSR, вы можете их найти по ссылке.

SSR трюки



На пути в разработке SSR приложений существуют множество проблем. Проблема асинхронных операций это только одна из них. Давайте посмотрим на другие распространенные проблемы.

HTML Meta Tags для SSR



Немаловажным аспектом в разработке SSR является использование правильных HTML meta tags. Они сообщают поисковому боту ключевую информацию на странице.
Для реализации данной задачи рекомендую использовать один из модулей:
React-Helmet-Async
React-Meta-Tags
Я подготовил несколько примеров:
React-Helmet-Async
React-Meta-Tags

Dynamic Imports


Чтобы снизить размер финального бандла приложения, принято приложение делить на части. Например dynamic imports webpack позволяет автоматически разбить приложение. Мы можем вынести отдельные страницы в чанки или блоки. При SSR мы должны уметь обрабатывать данные фрагменты приложения как одно целое. Для этого рекомендую использовать замечательный модуль @loadable

Dummies


Некоторые компоненты или фрагменты страницы можно не рендерить на сервере. Например, если у вас есть пост и комментарии, не целесообразно обрабатывать обе асинхронные операции. Данные поста более приоритетны чем комментарии к нему, именно эти данные формируют SEO нагрузку вашего приложения. По этому мы можем исключать не важные части при помощи проверок типа:
if (typeof windows === 'undefined') {}


localStorage, хранение данных


NodeJS не поддерживает localStorage. Для хранения сессионных данных мы используем cookie вместо localStorage. Файлы cookie отправляются автоматически по каждому запросу. Файлы cookie имеют ограничения, например:
  • Файлы cookie это старый способ хранения данных, они дают ограничение в 4096 байт (фактически 4095) на один файл cookie.
  • localStorage это реализация интерфейса хранилища. Он хранит данные без даты истечения срока действия и очищается только с помощью JavaScript или очистки кеша браузера / локально сохраненных данных в отличие от истечения срока действия файлов cookie.


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

React Server Components


React Server Components возможно будет хорошим дополнением для SSR. Его идеей является снижение нагрузки на Bundle за счет выполнения компонент на сервере и выдачи готового JSON React дерева. Нечто подобное мы видели в Next.JS. Читайте подробнее по ссылке

Роутинг


React Router из коробки поддерживает SSR. Отличие в том, что на server используется StaticRouter с переданным текущим URL, а на клиенте Router определяет URL автоматически при помощи location API. Пример

Debugging


Дебаг на сервере может выполняться также как и любой дебаг node.js приложений через inpsect.
Для этого нужно добавить в webpack.config для nodejs приложения:
devtool: 'source-map'
А в настройки NodemonPlugin:
new NodemonPlugin({  watch: path.resolve(__dirname, './dist'),  nodeArgs: [    '--inspect'  ]})

Также, для улучшения работы с source map можно добавить модуль
npm install source-map-support --save-dev

В nodeArgs опций NodemonPlugin добавить:
--require=source-map-support/register
Пример

Next.JS


Если вы создаете приложение с нуля, рекомендую обратить внимание на данный фреймверк. Это самое популярное решение на данный момент для создания с нуля приложений с поддержкой SSR. Из плюсов можно выделить то, что все идет из коробки (билд система, роутер). Из минусов необходимо переписывать существующее приложение, использовать подходы Next.JS.

SEO это не только SSR!



Критерии Google бота для SEO оптимизации включают множество метрик. Рендер данных, получение первого байта и т.д. это лишь часть метрик! При SEO оптимизации приложения необходимо минимизировать вес картинок, бандла, грамотно использовать HTML теги и HTML мета теги и прочее.
Для проверки вашего сайта при SEO оптимизации можно воспользоваться:
lighthouse
sitechecker
pagespeed

Выводы


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

Перевод Зависимости JavaScript Все, что вы когда-либо хотели знать, но боялись спросить

02.03.2021 16:05:07 | Автор: admin

Ваше подробное руководство по пяти типам зависимости

Привет, хабровчане. Для будущих учащихся на курсе "JavaScript Developer. Professional" подготовили перевод материала.

Также приглашаем всех желающих на открытый вебинар по теме
Vue 3 возможности новой версии одного из самых популярных фронтенд фреймворков. На занятии участники вместе с экспертом:
рассмотрят ключевые отличия в синтаксисе vue2 от vue3;
разберут, как работать с vue-router и VueX в новой версии фреймворка;
cоздадут проект на Vue 3 с нуля с помощью Vue-cli.


Независимо от того, являетесь ли Вы back-end разработчиком, работающим с Node.js, или front-end разработчиком, использующим Node.js только в качестве инструмента для пакетирования и комплектации, Вы наверняка наткнулись на систему зависимостей.

Но почему их 5 типов (да, это не опечатка, есть 5 типов зависимостей), и для какого случая они используются? На эти вопросы мы ответим сегодня, так что сядьте поудобнее и расслабьтесь, потому что это будет интересно.

. . .

Normal (runtime) dependencies (cтандартные (во время выполнения программы) зависимости)

Давайте начнем с простого, хорошо?

Стандартные зависимости это те, которые вы видите в списке "dependencies" в вашем файле package.json. В большинстве случаев они указывают только имя (для своего ключа) и версию (значение), а затем NPM (node package manager или любой другой менеджер пакетов) позаботится об их захвате из глобального регистра (на npmjs.org).

Однако, это еще не все. Вместо точного номера версии вашей зависимости вы можете указать:

  • Примерный вариант. Вы можете работать с обычными числовыми операторами сравнения, указывая версии больше одного определенного числа (т.е. >1.2.0 ), любой версии ниже или равной другому числу (т.е. <=1.2.0 ), а также можете обыгрывать любую из их комбинаций (т.е. >= , > , <) . Вы также можете указать эквивалентную версию с помощью оператора ~ перед номером версии (т.е. "lodash":"~1.2.2, который загрузит что угодно между 1.2.2 и 1.3.0 или, другими словами, только патчи). И мы также можем указать "совместимую" версию с другим номером, который использует semver для понимания совместимости (т.е. "lodash": "^1.2.0", которая не загрузит ничего, из того что включает в себя изменение с нарушением или отсутствием каких-либо функций).

  • URL-АДРЕС. Правильно, вы даже можете обойтись без версии и напрямую ссылаться на определенный URL, по сути загрузив этот модуль откуда-нибудь еще (например, с Github или напрямую скачав tarball-файл).

  • Локальный файл. Да, вы даже можете непосредственно обращаться к одному из ваших локальных файлов. Это очень удобно, если вы разрабатываете модуль и хотите протестировать его на проекте, прежде чем выпускать его на NPM. С помощью опции "Локальный файл" вы можете сделать ссылку на папку вашего локального модуля. Вы можете использовать как полный, так и частичный пути, при условии, что вы используете префикс-функцию с помощью file:// .

Когда ты будешь использовать стандартные зависимости?

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

Это может показаться простым, но если, например, ваш проект на самом деле является модулем, то вам нужно более внимательно разобраться в том, что это значит. Если ваш модуль предназначен для использования с другими проектами, такими как React или Babel, ваши зависимости не должны включать их, так как они, как предполагается, уже присутствуют, тем не менее, они все равно нужны (мы это увидим позже, где они появятся через секунду).

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

. . .

Peer dependencies (Равные зависимости)

Peer dependencies это особый вид зависимостей, в том смысле, что они предназначены для указания тех пакетов, которые требуются вашему проекту, или, вернее, с которыми он совместим. Тем не менее, вы можете не запрашивать их конкретно в своем коде.

Подождите, зависимость, которая вам не требуется? Я знаю, это звучит странно, но подумайте о плагине Babel, он нуждается в Babel в том смысле, что если его там нет, то он не сможет работать, однако, он может и не требовать Babel явно для чего-либо.

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

Когда ты будешь использовать Peer dependencies?

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

Другими словами:

  • Когда она вам нужна, но нет необходимости употреблять ее сразу и однозначно. Тогда это peer dependency.

  • Когда она вам нужна, но она уже должна быть установлена другим проектом. Тогда это peer dependency.

Примерами того, когда вы хотите использовать peerDependencies, являются:

  • Babel плагины. Ты хочешь декларировать такие же вещи, как и сам Babel, в качестве равной зависимости (peer dependency).

  • Express middleware packages (Экспресс-пакеты для промежуточной обработки): Это всего лишь один пример модуля NPM, который требует использования peer dependencies. Вы хотите определить приложение Express как зависимость, но не жесткую, в противном случае каждое промежуточное ПО (middleware) будет каждый раз инсталлировать всю структуру заново.

  • Если вы собираете Micro Frontend (Микрофронтенд), пытаясь решить, какие зависимости являются внешними (чтобы они не были связаны), а какие нет. Peer dependencies могут быть хорошим решением для этого.

  • Bit components. Если вы создаете и публикуете front-end компоненты, например, когда вы совместно используете React-компоненты на Bit (Github), вам нужно назначить react библиотеку как peer dependency. Это позволит удостовериться, что нужная библиотека доступна в хостинговом проекте без установки ее в качестве прямой зависимости от него.

Например, взгляните на этот React компонент, который я опубликовал некоторое время назад: это простая кнопка, которую можно выбрать (вы нажимаете на нее и она остается выбранной до тех пор, пока не будет нажата снова).

Снимок экрана component, как видно на Bits component hub.

Если вы установите его, вы получите полный код, который содержит файл package.json, в котором перечислены все peer dependencies:

Обратите внимание, что пакет не имеет прямой зависимости, несмотря на то, что для работы ему необходима React-библиотека.

Это также помогает сохранить размер нашего компонента как можно меньше (1KB) ничего лишнего не добавляется.

. . .

Dev Dependencies (Dev зависимости)

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

Зависимости процесса разработки (Development dependencies) предназначены для того, чтобы содержать только те модули, которые вы используете на этапе разработки вашего проекта.

Да, но есть и другие, например, инструменты для подшивки (linting tools), документация и тому подобное.

Дело в том, что если вы можете запустить проект без использования этого набора инструментов, тогда это dev dependency.

Принцип их работы заключается в том, что зависимости разработки устанавливаются только при запуске npm install или npm link из корневой папки вашего проекта.

Подумайте об этом, в этом случае имеет смысл установить их, так как вы разрабатываете свой собственный проект. Но если вы устанавливаете свой проект внутри другого, то этот процесс будет игнорировать ваши dev dependencies.

Когда ты будешь использовать dev dependencies?

Любая зависимость, которая не требуется для производственного процесса, скорее всего, будет считаться dev dependencies (зависимости развития).

Дело в том, что это отлично работает для всех модулей, кроме вашего хост-проекта.

Все установленные внутри хост-проекта зависимости будут пропускать dev-зависимости модулей во время инсталляции, но всякий раз, когда вы повторно запускаете npm install в хост-проекте, он будет устанавливать заново все пропущенные ранее dev-зависимости модулей.

Поэтому рассмотрим использование Dev зависимостей только для пакетов и модулей, которые будут использоваться другими проектами.

. . .

Связанные зависимости (Bundled Dependencies)

Они предназначены для тех случаев, когда вы упаковываете свой проект в один файл. Это делается с помощью команды npm pack, которая превращает вашу папку в тарбол (tarball-файл).

Теперь, если вы сделаете это и добавите имена некоторых из ваших зависимостей в массив под названием bundledDependencies (забавный факт: если вы используете bundleDependencies, это тоже будет работать), то тарбол также будет содержать эти зависимости внутри этого массива.

{...   "dependencies": {    "lodash": "1.0.2",    "request": "4.0.1"  },  "bundledDependencies": ["lodash"]...}

Посмотрите на эту часть файла package.json, с такой установкой, при запуске команды npm pack вы получите файл tarball, также содержащий пакет lodash внутри.

Это очень удобно при распространении ваших пакетов в однофайловом формате (помните, что вы также можете указать их URL или локальный путь как стандартную зависимость).

. . .

Дополнительные зависимости (Optional dependencies)

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

Обычно, когда вы запускаете npm install и процесс не может установить обычную зависимость по какой-либо причине (будь то из-за сбоя в сети, не может найти файл, версию или что-нибудь ещё), то он выдаст ошибку и отменит процесс установки.

Дополнительные зависимости вместо этого позволят NPM продолжать работу. Конечно, вы отвечаете за написание кода, который способен справиться с этими отсутствующими зависимостями, например:

let foo = null;try {  foo = require("foo-dep");} catch (e) {  foo = require("./local-polyfill")}//... use foo from here on out

Когда вы будете использовать дополнительные зависимости (optional dependencies)?

Очевидный пример использования этого типа зависимостей когда вы используете ненадежный источник. Я спрашиваю, зачем это делать? Хороший вопрос. Понятия не имею, но вы определенно можете это сделать, единственная проблема в том, что вам нужно убедиться, что ваш код может с этим справиться, если отсутствуют зависимости.

Но другой интересный вариант использования, это установка действительно необязательных зависимостей (optional dependencies). Я имею в виду, что иногда у вас могут быть специфические для системы зависимости, для таких вещей, как совместимость с CI(Continuous Integration)-платформой. В таких сценариях, когда используете платформу, вы захотите установить эти зависимости, в другом случае, проигнорируете.

Для таких ситуаций можно использовать обычную npm install, когда вы используете полный набор зависимостей, а затем использовать npm install --no-optional, когда вы хотите избежать их. Таким образом, вы пропустите эти опции и сосредоточитесь только на обязательных зависимостях.

. . .

Каждый, кто когда-либо пользовался NPM в тот или иной момент, слышал о стандартных и dev-зависимостях. Остальные 3, однако, можно считать менее популярными собратьями.

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

Знали ли вы, что у вас так много вариантов? Оставьте комментарий ниже, если вы использовали некоторые из менее распространенных и расскажите нам, как вы их использовали!


Узнать подробнее о курсе "JavaScript Developer. Professional".

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

Подробнее..

NEST.JS. Работа с ошибками. Мысли и рецепты

14.03.2021 10:05:42 | Автор: admin

Холивар...

Одни умные люди говорят: "эксепшены - это зло, используйте монады!", другие кричат: "коды ошибок ещё никто не отменял!", а третьи включают механизм исключений в языки программирования.. Однако, у большинства статей, который попадались мне на глаза, есть (при всём уважении к авторам!) два проблемных момента:

  • Некоторая... академичность. Разобрано много и интересно, но заканчивается всё стандартным: "ваш выбор зависит от вашей ситуации".

  • Абсолютно отсутствуют упоминания о бюджете. Никто же не будет спорить, что теоретически мерседес лучше, чем восьмёрка по всем показателям кроме.. цены.

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


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

Стартовые условия.

Выделим основные: язык, фреймворк, тип приложения. Раскроем кратко каждый пункт:

ЯЗК

Платформа и ЯП, очень сильно влияют на выбор подходов по работе с ошибками.

К примеру, в go не стоит вопрос, использовать ли исключения - там их нет. В функциональных языках, в частности в F#, было бы очень странно не использовать монады или discriminated union'ы (возврат одного из нескольких возможных типов значений), т. к. это там это реализовано очень удобным и естественным образом. В C#, монады тоже можно сделать, но получается намного больше букв. А это не всем нравится, мне например - не очень. Правда, последнее время всё чаще упоминается библиотека https://www.nuget.org/packages/OneOf/, которая фактически добавляет в язык discriminated union'ы.

А к чему нас подталкивает javascript/typescript?... К анархии! Можно много за что ругать JS и вполне по делу, но точно не за отсутствие гибкости.

Скорее уж за сверхгибкость )). В общем, мы вольны делать так, как нам хочется. Но тут есть, небольшая проблема - когда у вас в команде 10 человек и каждый делает как ему хочется.. получается не очень. Даже если каждый подход в отдельности - неплох.

ФРЕЙМВОРК

С nestjs уже интереснее. Выброс исключений из прикладного кода предлагается нам в документации как основной механизм возврата неуспешных ответов. То есть, если взять обычное http приложение, то чтобы клиенту вернулся статус 404 нам надо бросить NotFoundException..

На самом деле, довольно спорная концепция. И это можно обойти, причём разными способами. Убеждённые сторонники монад вполне могут делать что-то такое:

@Controller()class SomeController {  @Post()  do (): Either<SomeResult, SomeError> {    ...  }}

Для этого, правда придётся написать кое-какой обвязочный код, но можно. Мы не стали.

Важно также, что Фреймворк делает практически всё для того, чтобы нам не приходилось заботиться об устойчивости процесса приложения . Nest сам выстраивает для нас "конвейер" обработки запроса и оборачивает всё это в удобный глобальный "try/catch", который ловит всё.

Правда иногда случаются казусы

Например в одной из старых версий nest'а мы столкнулись с тем, что ошибка, вылетевшая из функции переданной в декоратор @Transform() (из пакета class-transformer) почему-то клала приложение насмерть. В версии 7.5.5 это не воспроизводится, но от подобных вещей, конечно никто не застрахован.

ТИП ПРИЛОЖЕНИЯ

Самое важное. Мы не пишем софт для спутников. Там вряд ли можно было бы себе позволить что-то в стиле "сервис временно недоступен, попробуйте позже". Для нас же - это вполне ожидаемая ситуация. Нежелательная, конечно, но и не фатальная.

Мы пишем веб-сервисы. Есть http-сервисы, есть rpc (на redis и RabbitMQ, смотрим в сторону gRPC), гибридные тоже есть. В любом случае, мы стараемся внутреннюю логику приложения абстрагировать от транспорта, чтобы в любой момент можно было добавить новый.

Мы фокусируемся на том, что у нас есть запрос, есть его обработчик и есть ответ (который иногда void). И мы допускаем, что обработка запроса может по каким-то причинам оказаться неудачной. В этом случае, либо запрос будет повторён (успешно), либо будет зафиксирован и затем исправлен баг.

При таком подходе, важно, чтобы ошибки не могли привести данные в неконсистентное состояние. Помогают нам в этом две вещи:

  • Транзакционность. То есть, либо получилось всё, либо не получилось ничего.

  • Идемпотентность. Повторное выполнение одной и той же команды не ломает и не меняет состояние системы.

Транзакции (особенно распределённые) и идемпотентность выходят за рамки данной статьи. Но во многом, эти вещи являются основой надёжности.

Ближе к делу.

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

КОНФИГУРАЦИЯ ПРИЛОЖЕНИЯ.

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

@Injectable()export class SomeModuleConfig {  public readonly someUrl: URL;public readonly someFile: string;public readonly someArrayOfNumbers: number[];  constructor (source: ConfigurationSource) {    // Бросит ConfigurationException если не удастся распарсить Url. Можно    // также проверять его доступность, например, при помощи пакета is-reachable    this.someUrl = source.getUrl('env.SOME_URL');// Бросит ConfigurationException если файл не существует или на него нет прав.this.someFile = source.getFile('env.SOME_FILE_PATH');// Бросит ConfigurationException если там не перечисленные через запятую числаthis.someArrayOfNumbers = source.getNumbers('env.NUMBERS')  }}

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

Подход к валидации

Мы написали свои валидаторы. Их преимущетсво в том, что мы не только валидируем данные, но в ряде случаев, можем сделать дополнительные проверки (доступность файла или удалённого ресурса, как в примере выше, например).

Однако, вполне можно использовать joi или json-схемы (может ещё есть варианты) - кому что больше нравится.

Неизменным должно быть одно - всё валидируется на старте.

УРОВНИ АБСТРАКЦИИ.

Мы максимально чётко разделяем бизнес-код и инфраструктурный код. И всё инфраструктурное выносим в библиотеки. Более менее очевидно, но всё же приведу пример:

// Задача: скачать файл по ссылке.const response = await axios.get(url, { responseType: 'stream' });const { contentType, filename } = this.parseHeaders(response);const file = createWriteStream(path);response.data.pipe(file);file.on('error', reject);file.on('finish', () => resolve({ contentType, filename, path }));

Такому коду не место не только в бизнес-логике, но вообще в приложении. В нём нет ничего уникального, привязывающего его к какому-то контексту. Ему место в библиотеке, скажем в классе NetworkFile. Вызывающий же код может выглядеть примерно так:

const file: NetworkFile = await NetworkFile.download('https://download.me/please', {  saveAs: 'path/to/directory'});

Фактически, мы заворачиваем в подобные переиспользуемые "смысловые" абстракции почти все нативные нодовские вызовы и вызовы сторонних библиотек. Стратегия обработки ошибок в этих обёртках: "поймать -> завернуть -> бросить". Пример простейшей реализации такого класса:

export class NetworkFile {private constructor (  public readonly filename: string,    public readonly path: string,    public readonly contentType: string,    public readonly url: string  ) {}    // В примере выше у нас метод download принимает вторым аргументов объект опций  // Таким образом мы можем кастомизировать наш класс: он может записывать файл на диск  // или не записывать, например.  // Но тут для примера - самая простая реализация.  public static async download (url: string, path: string): Promise<NetworkFile> {    return new Promise<NetworkFile>(async (resolve, reject) => {      try {      const response = await axios.get(url, { responseType: 'stream' });        const { contentType, filename } = this.parseHeaders(response);        const file = createWriteStream(path);        response.data.pipe(file);// Здесь мы отловим и завернём все ошибки связанную с записью данных в файл.        file.on('error', reject(new DownloadException(url, error));        file.on('finish', () => {        resolve(new NetworkFile(filename, path, contentType, url));        })    } catch (error) {        // А здесь, отловим и завернём ошибки связанные с открытием потока или скачиванием        // файла по сети.        reject(new DownloadException(url, error))      }    });  }private static parseHeaders (    response: AxiosResponse  ): { contentType: string, filename: string } {    const contentType = response.headers['content-type'];    const contentDisposition = response.headers['content-disposition'];    const filename = contentDisposition// parse - сторонний пакет content-disposition      ? parse(contentDisposition)?.parameters?.filename as string      : null;    if (typeof filename !== 'string') {      // Создавать здесь специальный тип ошибки нет смысла, т. к. на уровень выше      // она завернётся в DownloadException.      throw new Error(`Couldn't parse filename from header: ${contentDisposition}`);    }    return { contentType, filename };  }}
Promise constructor anti-pattern

Считается не круто использовать new Promise() вообще, и async-коллбэк внутри в частности. Вот и вот - релевантные посты на stackoverflow по этому поводу.

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

Уследить за потоком управления в таком маленьком классе (на самом деле, его боевая версия лишь немногим больше) - не проблема. А в итоге, вызывающий код работает только с одним типом исключений: DownloadException, внутрь которого завёрнута причина, по которой файл скачать не удалось. И причина носит исключительно информативный характер и не влияет на дальнейшую работу приложения, т. к.:

В БИЗНЕС-КОДЕ НИГДЕ НЕ НАДО ПИСАТЬ TRY / CATCH.

Серьёзно, о таких вещах, как закрытие дескрипторов и коннектов не должна заботиться бизнес-логика! Если вам прям очень надо написать try / catch в коде приложения, подумайте.. либо вы пишете то, что должно быть вынесено в библиотеку. Либо.. вам придётся объяснить товарищам по команде, почему именно здесь необходимо нарушить правило (хоть и редко, но такое всё же бывает).

Так почему не надо в сервисе ничего ловить? Для начала:

ЧТО М СЧИТАЕМ ИСКЛЮЧИТЕЛЬНОЙ СИТУАЦИЕЙ?

Откровенно говоря, в этом месте мы сломали немало копий. В конце концов, копья кончились, и мы пришли к концепции холивар-agnostic. Зачем нам отвечать на этот провокационный вопрос? В нём очень легко утонуть, причём мы будем не первыми утопленниками )

Наша концепция проста - при возникновении любой ошибки мы, без споров о её исключительности, завершаем работу обработчика. Никакого геройства - никто не пытается спасать положение!

Не смогли считать файл - до свиданья. Не смогли распарсить ответ от стороннего API - до свидания. В базе duplicate key - до свидания. Не можем найти указанную сущность - до свидания. Максимально просто. И механизм throw, даёт нам удобную возможность осуществить этот быстрый выход без написания дополнительного кода.

В основном исключения ругают за две вещи:

  • Плохой перформанс. Нас это не очень волнует, т. к. мы не highload. Если он нас всё же в какой-то момент настигнет, мы, пересмотрим подходы там, где это будет реально критично. Сделаем бенчмарки... Хотя, готов поспорить, оверхед на исключения будет не главной нашей проблемой.

  • Запутывание потока управления программы. Это как оператор goto который уже давно не применяется в высокоуровневых программах. Вот только в нашем случае, goto бывает только в одно место - к выходу. А ранний return из функции - отнють не считается анти-паттерном. Напротив - это очень широко используемый способ уменьшить вложенность кода.

ВИД ОШИБОК

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

Мы используем 5 типов рантайм-исключений (про конфигурационные уже говорил выше):

abstract class AuthenticationException extends Exception {  public readonly type = 'authentication';}abstract class NotAllowedException extends Exception {public readonly type = 'authorization';}abstract class NotFoundException extends Exception {  public readonly type = 'not_found';}abstract class ClientException extends Exception {  public readonly type = 'client';}abstract class ServerException extends Exception {  public readonly type = 'server';}

Эти классы семантически соответствуют HTTP-кодам 401, 403, 404, 400 и 500. Конечно, это не вся палитра из спецификации, но нам хватает. Благодаря соглашению, что всё, что вылетает из любого места приложения должно быть унаследовано от указанных типов, их легко автоматически замапить на HTTP ответы.

А если не HTTP? Тут надо смотреть конкретный транспорт. К примеру один из используемых у нас вариантов подразумевает получения сообщения из очереди RabbitMQ и отправку ответного сообщения в конце. Для сериализации ответа мы используем.. что-то типа either:

interface Result<T> {data?: T;  error?: Exception}

Таким образом, есть всё необходимое, чтобы клиентский код мог интерпретировать ошибку принятым у нас способом.

Базовый класс Exception выглядит примерно так:

export abstract class Exception {  abstract type: string;  constructor (    public readonly code: number,    public readonly message: string,    public readonly inner?: any  ) {}toString (): string {    // Здесь логика сериализации, работа со стек-трейсами, вложенными ошибками и проч...  }}
Коды ошибок

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

  • Бывает такое, что клиентское приложение должно предпринять различные действия в зависимости от пришедшей от сервера ошибки. С кодами мы можем решить это не добавляя новых http статусов и без, прости Господи, парсинга сообщений.

  • Мы сможем автоматически сформировать и поддерживать индексированный справочник ошибок, которым потом будет пользоваться наша служба технической поддержки. Там будет более подробное описание ошибок, с указанием возможных способов их исправления, паролями и явками - куда бежать.

Насколько это всё нужно и полезно - жизнь покажет

Поле inner - это внутренняя ошибка, которая может быть "завёрнута" в исключение (см. пример с NetworkFile).

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

ПРИМЕР ИСПОЛЬЗОВАНИЯ

Опустим AuthenticationException - он используется у нас только в модуле контроля доступа. Разберём более типовые примеры и начнём ошибок валидации:

import { ValidatorError } from 'class-validator';// ....export interface RequestValidationError {  // Массив - потому что ошибка может относиться к нескольким полям.  properties: string[];  errors: { [key: string]: string };nested?: RequestValidationError[]}// Небольшая трансформация стандартной ошибки class-validator'а в более удобный// "наш" формат.const mapError = (error: ValidationError): RequestValidationError => ({  properties: [error.property],  errors: error.constraints,  nested: error.children.map(mapError)});// Сами цифры не имеют значения.export const VALIDATION_ERROR_CODE = 4001;export class ValidationException extends ClientException {  constructor (errors: ValidationError[]) {    const projections: ValErrorProjection[] = ;    super(      VALIDATION_ERROR_CODE,      'Validation failed!',      errors.map(mapError)    );  }}

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

app.useGlobalPipes(new ValidationPipe({  exceptionFactory: errors => new ValidationException(errors);   });)

Соответственно, на выходе наш ValidationException замапится на BadRequestException с кодом 400 - потому что он ClientException.

Другой пример, с NotFoundException:

export const EMPLOYEE_NOT_FOUND_ERROR_CODE = 50712;export class EmployeeNotFoundException extends NotFoundException {  constructor (employeeId: number) {  super(      EMPLOYEE_NOT_FOUND_ERROR_CODE,      `Employee id = ${employeeId} not found!`    );  }}

Не очень приятно писать такие классы - всегда возникает лёгкое чувство... бойлерплейта ) Но зато, как приятно их потом использовать!

// Вместо, что не даст нам ни кодов, ни типа - ничего:throw new Error('...тут мы должны сформировать внятное сообщение...')// Простоthrow new EmployeeNotFoundException(id);

Сценарий использования NotAllowedException похож на предыдущий. Пользовать может иметь доступ к роуту getEmployeeById, но не иметь права запрашивать определённые категории работников. Соответственно, мы в сервисе можем проверить его доступ и выкинуть ошибку такого вида:

export const EMPLOYEE_NOT_ALLOWED_ERROR_CODE = 40565;export class EmployeeNotAllowedException extends NotAllowedException {  constructor (userId: number, employeeId: number) {  super(      EMPLOYEE_NOT_ALLOWED_ERROR_CODE,      `User id = ${userId} is not allowed to query employee id = ${employeeId}!`    );  }}

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

МАППИНГ

Мапятся внутренние ошибки на транспорто-специфичные в едином GlobalExceptionFilter, фильтр этот на вход получает один или несколько форматтеров. Задача форматтеров - преобразование вылетевшей ошибки в её конечный вид, можно сказать сериализация.

export interface IExceptionsFormatter {  // Verbose - флаг, который мы держим в конфигурации. Он используется для того,  // чтобы в девелоперской среде всегда на клиент отдавалась полная инфа о  // ошибке, а на проде - нет.  format (exception: unknown, verbose: boolean): unknown;    // При помощи этого метода можно понять, подходит ли данных форматтер  // для этого типа приложения или нет.  match (host: ArgumentsHost): boolean;}@Module({})export class ExceptionsModule {  public static forRoot (options: ExceptionsModuleOptions): DynamicModule {    return {      module: ExceptionsModule,      providers: [        ExceptionsModuleConfig,        {          provide: APP_FILTER,          useClass: GlobalExceptionsFilter        },        {          provide: 'FORMATTERS',          useValue: options.formatters        }      ]    };  }}const typesMap = new Map<string, number>().set('authentication', 401).set('authorization', 403).set('not_found', 404).set('client', 400).set('server', 500);@Catch()export class GlobalExceptionsFilter implements ExceptionFilter {  constructor (    @InjectLogger(GlobalExceptionsFilter) private readonly logger: ILogger,    @Inject('FORMATTERS') private readonly formatters: IExceptionsFormatter[],    private readonly config: ExceptionsModuleConfig  ) { }  catch (exception: Exception, argumentsHost: ArgumentsHost): Observable<any> {    this.logger.error(exception);    const formatter = this.formatters.find(x => x.match(argumentsHost));    const payload = formatter?.format(exception, this.config.verbose) || 'NO FORMATTER';    // В случае http мы ставим нужный статус-код и возвращаем ответ.if (argumentsHost.getType() === 'http') {      const request = argumentsHost.switchToHttp().getResponse();      const status = typesMap.get(exception.type) || 500;      request.status(status).send(payload);      return EMPTY;    }// В случае же RPC - бросаем дальше, транспорт разберётся.    return throwError(payload);  }}

Бывает конечно, что мы где-то напортачили и из сервиса вылетело что-то не унаследованное от Exception. На этот случай у нас есть ещё интерцептор, который все ошибки, не являющиеся экземплярами наследников Exception, заворачивает в new UnexpectedException(error) и прокидывает дальше. UnexpectedException естественно наследуется от ServerException. Для нас возникновение такой ошибки - иногда некритичный, но всё же баг, который фиксируется и исправляется.


В принципе, это всё. Для 95% наших задач этого вполне хватает. Способ может и не "канонический", но удобный и вполне рабочий - то, к чему мы и стремились.

И всё же бывают ситуации

КОГДА ВСЁ НЕ ТАК ЯСНО.

Приведу два примера:

Первый. Операции допускающие частичный успех. Мы стараемся избегать таких вещей, но как избежать, если нам нужно реализовать загрузку данных из csv-файла а бизнес требует, чтобы при возникновении ошибок в отдельных строках, остальные всё же были загружены?

В таких случаях всё-таки приходится проявлять "фантазию", например, обернуть в try/catch обработку каждой строки csv-файла. И в блоке catch писать ошибку в "отчёт". Тоже не бином Ньютона )

Второй. Я сознательно не написал выше реализацию DownloadException.

export class DOWNLOAD_ERROR_CODE = 5506;export class DownloadException extends ServerException {  constructor (url: string, inner: any) {    super(      DOWNLOAD_ERROR_CODE,      `Failed to download file from ${url}`,      inner    );  }}

Почему ServerException? Потому что, в общем случае, клиенту всё равно почему сервер не смог куда-то там достучаться. Для него это просто какая-то ошибка, в которой он не виноват.

Однако, теоретически может быть такая ситуация, что мы пытаемся скачать файл по ссылке, предоставленной клиентом. И тогда, в случае неудачи, клиент должен получить 400 или может быть 404, но не 500.

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

ЗАКЛЮЧЕНИЕ

Большое спасибо всем, кто дочитал до конца. Не думаю, что я в этой статье кому-то "открыл Америку". И я, конечно, далёк от навязывания кому-либо, чего-либо. И всё же, надеюсь, что эта попытка структурировать работу с ошибками для кого-то послужит отправной точкой в разработке собственного подхода под собственные задачи.

P. S. К сожалению, опенсорс в нашей компании в процессе согласования, поэтому привести реальный используемый код я не могу. Когда будет такая возможность, мы выложим на github библиотеку, при помощи которой работаем с исключениями. А за одно и некоторые другие пакеты, которые могут оказаться кому-то полезными.

P. P. S. Буду очень благодарен, за комментарии, особенно если в них будут практические примеры ситуаций, в которых у нас могут возникнуть трудности при использовании данного подхода. С удовольствием, поучаствую в обсуждении.

Подробнее..

Перевод Дорожная карта для разработчиков Node.js на 2021 год

20.03.2021 18:12:42 | Автор: admin

Node.js изменил правила игры с момента его выпуска и даже крупные компании, такие как Uber, Medium, PayPal и Walmart, перешли на Node.js. На этой платформе можно создавать действительно мощные приложения, отслеживание в реальном времени, движки видео- и текстового чата, приложения для социальных сетей и т. д. Владение Node.js становится одним из самых крутых навыков для разработчиков, и вот мой план развития, на основе собственного опыта и советов. Прежде чем углубляться в материал, убедитесь, что у вас есть четкая цель: то, что вы хотите разработать, иначе есть вероятность, что вы откажетесь от обучения. Цель поможет вам в первую очередь сосредоточиться на том, чтобы овладеть важнейшим навыками, а не выяснять, нужно ли вам их изучать или нет.


Предпосылки

1 JavaScript

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

  • Стрелочные функции

  • Типы

  • Выражения

  • Функции

  • Лексические структуры

  • this

  • Циклы и область видимости

  • Массивы

  • Шаблонные литералы

  • Строгий режим

  • ES6/ES7

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

  • Таймеры

  • Промисы

  • Замыкания

  • Event Loop

  • Асинхронное программирование и колбеки

2. NPM

Node Package Manager - это крупнейший в мире реестр программного обеспечения с более чем 800 000 пакетов. Правильное применение NPM сильно поможет вам, управлять пакетами с помощью NPM довольно удобно, когда мы разрабатываем приложения, с рядом зависимостей.

NPM состоит из трех компонентов:

  • Интерфейс командной строки (CLI): работает в терминале, через него с NPM работает большинство разработчиков.

  • Реестр: Большая публичная база данных программного обеспечения JavaScript и метаинформации о них.

  • Веб-сайт: открывайте для себя новые пакеты и управлять другими аспектами работы с npm.

NPM используется для управления несколькими версиями кода и его зависимостями, для запуска пакетов без их загрузки (с помощью npx) и многого другого.

3. Базовые знания по Node.js

Эмиттеры событий: Это объекты в Node.js, которые запускают события, отправляя сообщение, о завершении действия. Мы также можем написать код, который прослушивает события от эмиттера. Например, если вы работали с внешним интерфейсом, то, вероятно, знаете, сколько взаимодействий нужно обрабатывать в приложениях: щелчки мышью (и не только щелчки), нажатия кнопок клавиатуры и т.д.. Точно так же в серверной среде в Node.js мы можем построить аналогичную систему, используя модуль событий, с классом EventEmitter, для обработки наших событий.

Колбеки (обратные вызовы): Это функции, вызываемые при завершении задачи, что предотвращает любые блокировки, позволяя при этом выполняться остальной части кода. Поскольку в Node.js нам приходится работать с множеством асинхронных задач, чтобы приложения были быстрее, они нужны повсюду. Например,

Буферы: Класс Buffer предназначен для обработки сырых бинарных данных. Они соотносятся с некоторой необработанной памяти, выделенной вне V8. Буферы - это массив целых чисел, размер которых нельзя изменить, даже имея множество методов специально для двоичных данных. Например, целые числа в буфере представляют собой байт со значениями от 0 до 255 включительно; если вы пропишете в console.log() экземпляр Buffer, то получите цепочку значений в шестнадцатеричном формате.

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

Навыки разработки

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

Протоколы HTTP/HTTPS: Фундаментальные знания о том, как данные передаются с помощью протоколов передачи данных и понимание принципов работы HTTP и HTTPS - это мастхэв для каждого backend-разработчика. HTTPS использует протокол, известный как TLS, для шифрования соединения. В бэкэнд-среде есть чему поучиться, вы запутаетесь, если не знаете, как работает Интернет. Вот 4 метода запроса, основа всей коммуникации в сети:

  • GET: чтобы получить ресурс

  • POST: чтобы создать новые ресурсы

  • PUT: чтобы обновить ресурс

  • PATCH: чтобы изменить ресурс

  • DELETE: Используется для удаления ресурса, идентифицированного URL-адресом

  • OPTIONS: Запрашивает разрешённые варианты коммуникации с URL или сервером

Веб фреймворки

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

Express.js: Предоставляет крайне минималистичный интерфейс и инструменты,, довольно гибкие в использовании, поставляется с большим количеством модулей npm, которые напрямую подключаются к Express.

Meteor.js: Отличный фреймворк, поставляется со встроенными обработчиками MongoDB, поддерживает GraphQL. Когда вы создаете проект командой meteor create myapp и запускаете его, у веб-страницы уже есть серверная часть MongoDB. Можно работать с Meteor.js как с эффективной альтернативой которая ускорит и упростит разработку. Если приложение простое, я рекомендую придерживаться Express..

Sails.js: MVC платформа позволяет быстро создавать REST API, одностраничные приложения и приложения реального времени. Если вы хотите овладеть серьезными навыками, настоятельно рекомендуется использовать Sails.js, он дает множество преимуществ, таких как поддержка соединения в реальном времени с помощью WebSockets, сипользуется подход соглашение по конфигурации.

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

Nest.js: Вдохновленный Angular и написанный на TypeScript, Nest.js под капотом работает на Express.js, а значит совместим с большей частью промежуточного программного обеспечения на Express. С помощью Nest.js вы можете создать эффективное и масштабируемое приложение;он предоставляет отличную структуру для организации кода в отдельные модули.

Управление базами данных

Изучая Node.js, вы будете работать с большим количеством бэкэнд-материалов, и если вы новичок, вам следует с самого начала придерживаться MySQL и вообще SQL. Поскольку вы получите четкое и краткое разъяснение того, как мы разрабатываем бэкэнд-системы, выход за рамки SQL или MySQL от случая к случаю, когда будете работать с новыми типами проектов, есть вероятность, что вам понадобиться изучить другие материалы по бекенду.

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

SQL Server: Система управления реляционными базами данных, разработанная Microsoft, поддерживает ANSI SQL (стандарт языка SQL). Однако SQL поставляется со своими собственными реализациями.

MySQL: Серверное ПО с открытым исходным кодом, еще одна система управления базами данных. С MySQL, мы получаем гибкость выбора, поскольку мы можем изменять исходный код в соответствии с потребностями. MySQL - довольно простая альтернатива Oracle Database и Microsoft SQL server.

PostgreSQL: Еще одно ПО с открытым исходным кодом. Она работает во всех основных операционных системах, включая Linux, UNIX и Windows. PostgreSQL поддерживает большую часть стандарта SQL, предлагая при этом замечательные функции: внешние ключи, триггеры, транзакции, мультиверсионное управление параллелизмом (MVCC) и т. д.

MariaDB: Усовершенствованная версия MySQL, с мощными функциями, улучшениями безопасности и производительности, которых вы не найдете в MySQL. Есть несколько причин, по которым вы должны выбрать MariaDB вместо MySQL для крупномасштабных приложений. Например, в MariaDB пул соединений больше, а именно до 200 000+ соединений,. Короче говоря, MariaDB быстрее MySQL .

Облачные службы баз данных

Azure CosmosDB: Служба базы данных, которая распределена по всему миру, вы можете управлять своими данными удаленно, использование облачных баз данных даёт много преимуществ, этот инструмент немного упрощает масштабирование и управление большими приложениями. Кроме того, она поддерживает несколько моделей данных с использованием одного бэкэнда, это означает, что её можно использовать для моделей документа, ключа-значения, реляционных и графовых моделей. Поскольку она не полагается на какие-либо схемы, можно назвать её базой данных NoSQL, но она поддерживает язык запросов и транзакции ACID .

Amazon DynamoDB: Насколько мне известно, Amazon DynamoDB - отличная альтернатива, если у вас уже есть некоторый опыт работы с SQL, это полностью управляемая служба базы данных NoSQL, обеспечивающая производительность и масштабируемость. Вы можете создавать таблицы баз данных, которые могут хранить и извлекать любой объем данных и обслуживать любой уровень трафика запросов.

2. Базы данных NoSQL

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

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

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

Apache Cassandra

Высокопроизводительная распределенная база данных с прекрасным масштабированием, предназначенная для обработки большого количества данных на многих серверах без единой точки отказа. Она отличается от других систем управления реляционными базами данных. Распределённая архитектура основана на DynamoDB от Amazon и на модели данных BigTable от Google.

LiteDB

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

3. Поисковые системы

Примечание: Если вы думаете, зачем нам поисковая система, то вот пример: мы используем Google в качестве поисковой системы, но это само по себе целое веб-приложение. Solr и ElasticSearch являются внутренними фреймворками, и если предоставляется какой-либо тип набора данных, он создает индекс поверх него, делая эти данные доступными для поиска на сервере. Вы можете поддерживать веб-сайт с миллионами пользователей с помощью Solr в качестве поисковой системы.

ElasticSearch

Система поиска и аналитики, построенная на Apache Lucene и разработанная на Java. Используя ElasticSearch, вы можете хранить и анализировать огромные объемы данных в режиме реального времени. Поскольку она выполняет поиск по индексу вместо поиска по тексту, ElasticSearch также обеспечивает высокую производительность поиска. По сути, она использует документы на основе структуры вместо таблиц и схем, которые поставляются с обширным REST API для хранения и поиска данных. Вы можете думать об ElasticSearch как о сервере, который обрабатывает JSON запросы и возвращает вам данные JSON.

Solr

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

Кеширование

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

Memory Cache

Этот метод также обычно называют кэшированием, поскольку в большинстве случаев кэширование связано с памятью на серверах. В этом методе часть памяти сервера используется в качестве кеша, где мы храним все данные, необходимые для уменьшения количества сетевых вызовов в наших приложениях. В Node.js у нас есть node-cache и memory-cache отличные библиотеки для обработки кеш-памяти на сервере Node.

Распределенный кеш

В этом методе кэширования мы объединяем память нескольких сетей в единое хранилище данных, которое позже используем в качестве окончательного кеша данных, чтобы обеспечить быстрый доступ к данным. Этот метод особенно широко используется при большом объеме данных и огромном количестве сетевых вызовов одновременно, что позволяет выполнять инкрементное расширение и масштабирование за счет добавления дополнительной серверной памяти в кластер. Redis - одна из самых известных вещей, когда дело доходит до распределенного кеширования, но вы можете выйти за её рамки, изучив также Memcached.

Движки шаблонов

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

  • Mustache.js

  • Handlebars

  • EJS

Коммуникация в реальном времени

Socket.io

Когда дело доходит до понимания взаимодействия в реальном времени в Socket.IO, если вы только начинаете работать в качестве бекенд разработчика, основная логика взаимодействия в реальном времени лежит между клиентом и сервером. Библиотека позволяет передавать двунаправленные данные между клиентом и сервером, вы можете думать о двунаправленном потоке данных как о синхронном потоке данных между двумя терминалами для достижения коммуникации в реальном времени, эти типы поведения включаются, когда клиент имеет Socket.IO в браузере вместе с сервером, интегрированным с пакетом Socket.IO. А данные можно отправлять в виде JSON запросов.

API

REST

До REST API-интерфейсы разрабатывались для удаленного вызова процедур (RPC), и API-интерфейсы выглядели как некоторый локально выполняемый код. Многие технологии пытались решить эту проблему, используя RPC-подобные стеки, чтобы скрыть основную проблему, и после этого был введен REST для лучшего построения веб-API.

В REST архитектура построена с использованием простых HTTP-вызовов для связи вместо сложных опций, таких как COBRA, COM + RPC. В REST вызовы представляют собой сообщения, основанные на стандартах HTTP для описания этих сообщений. В экосистеме Node.js вы можете выбрать node-rest-client и Axios, оба служат довольно хорошим сервисом для ускорения веб-приложений.

GraphQL

Отличная альтернатива REST. GraphQL использует API-интерфейсы, которые отдают предпочтение предоставлению клиентам именно тех данных, которые они запрашивают. Гибкая и удобная для разработчиков альтернатива, поскольку вы можете развернуть ее даже в среде IDE, известной как GraphiQL. Вы также получаете преимущества добавления или исключения полей без влияния на существующие запросы и построения API любым предпочтительным методом.

Тестирование

Фреймворки для юнит-тестирования

В модульном тестировании мы тестируем отдельные модули/компоненты изолированно, где модуль может быть наименьшей тестируемой частью кода в приложении. Ниже перечислены одни из лучших фреймворков для модульного тестирования в Node.js:

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

  • Mocha: Он обслуживает старые стандарты фреймворков модульного тестирования для приложений Node и поддерживает асинхронные операции, такие как колбеки, промисы с расширяемыми и настраиваемыми ассертами.

  • Chai: Его можно использовать вместе с Mocha и как библиотеку ассертов TDD/BDD для Node.js, которую можно использовать в сочетании с любой платформой тестирования на основе JavaScript.

Моки

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

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

Я перечислил несколько замечательных постов, чтобы понять, как можно использовать Sinon и Jasmine для мока данных.

Некоторые полезные библиотеки для Node.js

Подробнее..

Fastify.js не только самый быстрый веб-фреймворк для node.js

04.05.2021 18:23:50 | Автор: admin
Последние 10 лет среди веб-фреймворков для node.js самой большой популярностью пользуется Express.js. Всем, кто с ним работал, известно, что сложные приложения на Express.js бывает сложно структурировать. Но, как говорится, привычка вторая натура. От Express.js бывает сложно отказаться. Как, например, сложно бросить курить. Кажется, что нам непременно нужна эта бесконечная цепь middleware, и если у нас забрать возможность создавать их по любому поводу и без повода проект остановится.

Отрадно, что сейчас, наконец, появился достойный претендент на место главного веб-фреймворка всех и вся я имею в виду не Fastify.js, а, конечно же, Nest.js. Хотя по количественным показателям популярности, до Express.js ему очень и очень далеко.

Таблица. Показатели популярности пакетов по данным npmjs.org, github.com
Пакет Количество загрузок Количество звезд
1 connect 4 373 963 9 100
2 express 16 492 569 52 900
3 koa 844 877 31 100
4 nestjs 624 603 36 700
5 hapi 389 530 13 200
6 fastify 216 240 18 600
7 restify 93 665 10 100
8 polka 71 394 4 700


Express.js по-прежнему работает в более чем в 2/3 веб-приложений для node.js. Более того, 2/3 наиболее популярных веб-фреймворков для node.js используют подходы Express.js. (Точнее было бы сказать, подходы библиотеки Connect.js, на которой до версии 4 базировался Express.js).

В предлагаемом сообщении обсуждаются особенности основных веб-фреймворков для node.js, и что делает Fastify.js фреймворком другого уровня, что позволяет выбрать его как фреймворк для разработки Вашего следующего проекта.


Критика фреймворков, основаных на синхронных middleware



Что же плохого может быть в таком коде?

app.get('/', (req, res) => {  res.send('Hello World!')})


1. Функция, которая обрабатывает роут, не возвращает значение. Вместо этого необходимо вызвать один из методов объекта response (res). Если это метод не будет вызван явно, даже после возврата из функции клиент и сервер останутся в состоянии ожидания ответа сервера пока для каждого из них не истечет таймаут. Это только прямые убытки, но есть еще и упущенная выгода. То что эта функция не возвращает значения, делает невозможным просто реализовать востребованную функциональность, например валидацию или логирование возвращаемых клиенту ответов.

2. Обработка ошибок всегда синхронная. Редкий роут обходится без вызовов асинхронных операций. Так как Express.js создавался в допромисовую эру, стандартный обработчик ошибок не сработает и нужно ошибки обрабатывать так:

app.get('/', async (req, res, next) => {   try {      ...   } catch (ex) {      next(ex);   }})


или так:

app.get('/', (req, res, next) => {   doAcync().catch(next)})


3. Сложность асинхронной инициализации сервисов. Например, приложение работает с базой данных и обращается к базе данных как к сервису, сохранив ссылку в переменной. Инициализация роутов в Express.js всегда синхронная. Это означает, что когда на роуты начнут приходить первые запросы клиентов, асинхронная инициализация сервиса, вероятно еще не успеет отработать, так что придется тащить в роуты асинхронный код с получением ссылки на этот сервис. Все это, конечно, реализуемо. Но слишком далеко уходит от наивной простоты изначального кода:

app.get('/', (req, res) => {  res.send('Hello World!')})


4. Ну и наконец, последнее но немаловажное. В большинстве Express.js приложений работет примерно такой код:

app.use(someFuction);app.use(anotherFunction());app.use((req, res, nexn) => ..., next());app.get('/', (req, res) => {  res.send('Hello World!')})


Когда Вы разрабатываете свою часть приложения, то можете быть уверенным что до вашего кода уже успели отработать 10-20 middleware, которые вешают на объект req всевозможные свойства, и, даже, могут модифицировать исходный запрос, ровно как и в том что столько же если не больше middleware может бтоь добавлено после того, как вы разработаете свою часть приложения. Хотя, к слову сказать, в документации Express.js для навешивания дополнительных свойств неоднозначно рекомендуется объект res.locals:

// из документации Express.jsapp.use(function (req, res, next) {  res.locals.user = req.user  res.locals.authenticated = !req.user.anonymous  next()})


Исторические попытки преодоления недостатков Express.js



Не удивительно, что основной автор Express.js и Connect.js TJ Holowaychuk оставил проект, чтобы начать разработку нового фреймворка Koa.js. Koa.js добавляет асинхронность в Express.js. Например, такой код избавляет от необходимости перехватывать асинхронные ошибки в коде каждого роута и выносит обработчик в один middleware:

app.use(async (ctx, next) => {  try {    await next();  } catch (err) {    // will only respond with JSON    ctx.status = err.statusCode || err.status || 500;    ctx.body = {      message: err.message    };  }})


Первые версии Koa.js имели замысел внедрить генераторы для обработки асинхронных вызовов:

// from http://blog.stevensanderson.com/2013/12/21/experiments-with-koa-and-javascript-generators/var request = Q.denodeify(require('request')); // Example of calling library code that returns a promisefunction doHttpRequest(url) {    return request(url).then(function(resultParams) {        // Extract just the response object        return resultParams[];    });}app.use(function *() {    // Example with a return value    var response = yield doHttpRequest('http://example.com/');    this.body = "Response length is " + response.body.length;});


Внедрение async/await свело на нет полезность этой части Koa.js, и сейчас подобных примеров нет даже в документации фреймворка.

Почти ровесник Express.js фреймворк Hapi.js. Контроллеры в Hapi.js уже возвращают значение, что является шагом вперед, по сравнению с Express.js. Не получив популярность сравнимую с Express.js, мега-успешной стала составная часть проекта Hapi.js библиотека Joi, которая имеет количество загрузок с npmjs.org 3 388 762, и сейчас используется как на бэкенде, так и на фронтенде. Поняв, что валидация входящих объектов это не какой-то особый случай, а необходимый атрибут каждого приложения валидация в Hapi.js была включена как составляющая часть фреймворка, и как параметр в определении роута:

server.route({    method: 'GET',    path: '/hello/{name}',    handler: function (request, h) {        return `Hello ${request.params.name}!`;    },    options: {        validate: {            params: Joi.object({                name: Joi.string().min(3).max(10)            })        }    }});


В настоящее время, библиотека Joi выделена в самостоятельный проект.

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

На сегодняшний день, одно из лучших решений в документации API swagger/openAPI. Было бы очень удачно, если бы схема, описания с учетом требований swagger/openAPI, могла быть использована и для валидации и для формирования документации.

Fastify.js



Подитожу те требования, который мне кажутся существенными при выборе веб-фреймворка:

1. Наличие полноценных контроллеров (возвращаемое значение функции возвращется клиенту в теле ответа).
2. Удобная обработка синхронных и асинхронных ошибок.
3. Валидация входных параметров.
4. Самодокуметирование на основании определений роутов и схем валидации входных/выходных параметров.
5. Инстанциирование асинхронных сервисов.
6. Расширяемость.

Всем этим пунктам соответствует Nest.js, с которым я сейчас работаю на нескольких проектах. Особенностью Next.js является широкое применение декораторов, что может быть в ряде случаев ограничением, если в технических требованиях указано использовать стандартный JavaScript (а как известно со стандартизацией декораторов в JavaScript сиутация застопорилась несколько лет назад, и, похоже, не скоро найдет свое разрешение).

Поэтому альтернативой может стать фреймворк Fastify.js, особенности применения которого я сейчас разберу.

Fastify.js поддерживает и привычный для разработчиков на Express.js стиль формирования ответа сервера, и более перспективный в форме возвращаемого значения функции, при этом оставляя возможность гибко манипулировать другими параметрами ответа (статусом, заголовками):

// Require the framework and instantiate itconst fastify = require('fastify')({  logger: true})// Declare a routefastify.get('/', (request, reply) => {  reply.send({ hello: 'world' })})// Run the server!fastify.listen(3000, (err, address) => {  if (err) throw err  // Server is now listening on ${address}})


const fastify = require('fastify')({  logger: true})fastify.get('/',  (request, reply) => {  reply.type('application/json').code(200)  return { hello: 'world' }})fastify.listen(3000, (err, address) => {  if (err) throw err  // Server is now listening on ${address}})


Обработка ошибок может быть встроенной (из коробки) и кастомной.

const createError = require('fastify-error');const CustomError = createError('403_ERROR', 'Message: ', 403);function raiseAsyncError() {  return new Promise((resolve, reject) => {    setTimeout(() => reject(new CustomError('Async Error')), 5000);  });}async function routes(fastify) {  fastify.get('/sync-error', async () => {    if (true) {      throw new CustomError('Sync Error');    }    return { hello: 'world' };  });  fastify.get('/async-error', async () => {    await raiseAsyncError();    return { hello: 'world' };  });}


Оба варианта и синхронный и асинхронный отрабатывают одинаково встроенным обработчиком ошибок. Конечно, встроенных возможностей всегда мало. Кастомизируем обработчик ошибок:

fastify.setErrorHandler((error, request, reply) => {  console.log(error);  reply.status(error.status || 500).send(error);});  fastify.get('/custom-error', () => {    if (true) {      throw { status: 419, data: { a: 1, b: 2} };    }    return { hello: 'world' };  });


Эта часть кода упрощена (ошибка выбрасывает литерал). Аналогично можно выбрасывать и кастомную ошибку. (Определение кастомных сериализуемых ошибок это отдельная тема, поэтому пример не приводится).

Для валидации Fastify.js использует библиотеку Ajv.js, которая реализует интерфенйс swagger/openAPI. Этот факт делает возможным интеграцию Fastify.js со swagger/openAPI и самодокументирвоание API.

По умолчанию валидация не самая строгая (поля опциональоные и допускаются отсутствующие в схеме поля). Для того чтобы сделать валдиацию строгой необходимо определить параметры в конфигурации Ajv, и в схеме валидации:

const fastify = require('fastify')({  logger: true,  ajv: {    customOptions: {      removeAdditional: false,      useDefaults: true,      coerceTypes: true,      allErrors: true,      strictTypes: true,      nullable: true,      strictRequired: true,    },    plugins: [],  },});  const opts = {    httpStatus: 201,    schema: {      description: 'post some data',      tags: ['test'],      summary: 'qwerty',      additionalProperties: false,      body: {        additionalProperties: false,        type: 'object',        required: ['someKey'],        properties: {          someKey: { type: 'string' },          someOtherKey: { type: 'number', minimum: 10 },        },      },      response: {        200: {          type: 'object',          additionalProperties: false,          required: ['hello'],          properties: {            value: { type: 'string' },            otherValue: { type: 'boolean' },            hello: { type: 'string' },          },        },        201: {          type: 'object',          additionalProperties: false,          required: ['hello-test'],          properties: {            value: { type: 'string' },            otherValue: { type: 'boolean' },            'hello-test': { type: 'string' },          },        },      },    },  };  fastify.post('/test', opts, async (req, res) => {    res.status(201);    return { hello: 'world' };  });}


Поскольку схема входящих объектов уже определена, генерация документации swagger/openAPI сводится к инсталляции плагина:

fastify.register(require('fastify-swagger'), {  routePrefix: '/api-doc',  swagger: {    info: {      title: 'Test swagger',      description: 'testing the fastify swagger api',      version: '0.1.0',    },    securityDefinitions: {      apiKey: {        type: 'apiKey',        name: 'apiKey',        in: 'header',      },    },    host: 'localhost:3000',    schemes: ['http'],    consumes: ['application/json'],    produces: ['application/json'],  },  hideUntagged: true,  exposeRoute: true,});


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

fastify.register(require('fastify-response-validation'));


Валидация достаточно гибкая. Например ответ каждого статуса будет проверяться по своей схеме валидации.

Код связанный с написание статьи можно найти здесь.

Дополнительные источники информации

1. blog.stevensanderson.com/2013/12/21/experiments-with-koa-and-javascript-generators
2. habr.com/ru/company/dataart/blog/312638

apapacy@gmail.com
4 мая 2021 года
Подробнее..

Модуль для работы с sqlite3

07.05.2021 16:08:17 | Автор: admin

Сегодня Я хотел бы поделиться своей не большой разработкой, которая помогает мне уже более чем пол года: "Модуль для работы с sqlite3".

Концепция

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

Представим модуль в виде класса.

Всего будет 4 метода:

  1. getData() - для получения данных из таблицы.

  2. insertData() - для добавления данных в таблицу.

  3. updateData() - для обновления данных в таблице.

  4. deleteData() - для удаления данных из таблицы.

Конечно же с помощью 4 методов приведенных выше мы не сможем исключить все виды запросов, но в моем случаи эти запросы самые частые.

Кодим

Для начало создадим сам класс, настроем экспорт и подключимся к БД и создадим метод, который будет добавлять кавычки у элемента, если тип элемента соответствует типу строки.

class DataBase {    /**     *      * @readonly     */    static sqlite3 = require('sqlite3').verbose();        /**    *     * @readonly    */   static database = new this.sqlite3.Database('./database/database.db');    static ToString(value) {        return typeof(value) === 'string' ? '\'' + value + '\'' : value;    }}module.exports = {    database: DataBase};

Для начало напишем метод, который будет возвращать данные из таблицы, не обязательно, но мы будем учитывать, кол-во строк, которые нам будут возвращаться(одна или несколько).

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

class DataBase {    /**     *      * @readonly     */    static sqlite3 = require('sqlite3').verbose();        /**    *     * @readonly    */   static database = new this.sqlite3.Database('./database/database.db');        /**     *      * @param {String[]} keys      * @param {String} table      * @param {String} condition      * @param {Boolean} some      * @param {Function()} callback      */    static getData(keys, table, condition = '', some = true, callback = () => {}) {        let sql = 'SELECT ';        for (let i = 0; i < keys.length; i++) {            sql += keys[i] === '*' ? keys[i] : '`' + keys[i] + '`';            if (keys.length > i + 1)                sql += ', ';        }        sql += ' FROM `' + table + '` ' + condition;                if (some)            this.database.all(sql, (err, rows) => {                callback(err, rows);            });        else            this.database.get(sql, (err, row) => {                callback(err, row);            });    };    static ToString(value) {        return typeof(value) === 'string' ? '\'' + value + '\'' : value;    }}module.exports = {    database: DataBase};

Напишем метод отвечающий за обновление данных.

Начинаем с указания таблицы, установкой ключей и значений, а завершаем добавлением условия.

class DataBase {    /**     *      * @readonly     */    static sqlite3 = require('sqlite3').verbose();        /**    *     * @readonly    */   static database = new this.sqlite3.Database('./database/database.db');        /**     *      * @param {String[]} keys      * @param {String} table      * @param {String} condition      * @param {Boolean} some      * @param {Function()} callback      */    static getData(keys, table, condition = '', some = true, callback = () => {}) {        let sql = 'SELECT ';        for (let i = 0; i < keys.length; i++) {            sql += keys[i] === '*' ? keys[i] : '`' + keys[i] + '`';            if (keys.length > i + 1)                sql += ', ';        }        sql += ' FROM `' + table + '` ' + condition;                if (some)            this.database.all(sql, (err, rows) => {                callback(err, rows);            });        else            this.database.get(sql, (err, row) => {                callback(err, row);            });    };        /**     *      * @param {String[]} keys      * @param {Values[]} values      * @param {String} table      * @param {String} condition      * @param {Function()} callback      */    static updateData(keys, values, table, condition, callback = () => {}) {        let sql = 'UPDATE `' + table + '` SET ';        for (let i = 0; i < keys.length; i++) {            sql += '`' + keys[i] + '` = ' + this.ToString(values[i]);            if (keys.length > i + 1)                sql += ', ';        }        sql += ' ' + condition;                this.database.run(sql, (err) => {            callback(err);        });    }    static ToString(value) {        return typeof(value) === 'string' ? '\'' + value + '\'' : value;    }}module.exports = {    database: DataBase};

Остается совсем чуть-чуть, напишем метод для удаления данных(она максимально простой) и метод для добавления данных.

Добавления данных начнем с указания таблицы, потом передадим все ключи, которые мы хотим установить и заканчиваем установкой всех значений(значение должно иметь такой же индекс как и ключ, которому необходимо установить это значение).

class DataBase {    /**     *      * @readonly     */    static sqlite3 = require('sqlite3').verbose();        /**    *     * @readonly    */   static database = new this.sqlite3.Database('./database/database.db');        /**     *      * @param {String[]} keys      * @param {String} table      * @param {String} condition      * @param {Boolean} some      * @param {Function()} callback      */    static getData(keys, table, condition = '', some = true, callback = () => {}) {        let sql = 'SELECT ';        for (let i = 0; i < keys.length; i++) {            sql += keys[i] === '*' ? keys[i] : '`' + keys[i] + '`';            if (keys.length > i + 1)                sql += ', ';        }        sql += ' FROM `' + table + '` ' + condition;                if (some)            this.database.all(sql, (err, rows) => {                callback(err, rows);            });        else            this.database.get(sql, (err, row) => {                callback(err, row);            });    };        /**     *      * @param {String[]} keys      * @param {Values[]} values      * @param {String} table      * @param {String} condition      * @param {Function()} callback      */    static updateData(keys, values, table, condition, callback = () => {}) {        let sql = 'UPDATE `' + table + '` SET ';        for (let i = 0; i < keys.length; i++) {            sql += '`' + keys[i] + '` = ' + this.ToString(values[i]);            if (keys.length > i + 1)                sql += ', ';        }        sql += ' ' + condition;                this.database.run(sql, (err) => {            callback(err);        });    }        /**     * @param {String[]} keys     * @param {String[]} values     * @param {String} table      * @param {Function()} callback      */    static insertData(keys, values, table, callback = () => {}) {        let sql = 'INSERT INTO `' + table + '` (';        for (let i = 0; i < keys.length; i++) {            sql += '`' + keys[i] + '`';            if (keys.length > i + 1)                sql += ', ';        }        sql += ') VALUES (';        for (let i = 0; i < values.length; i++) {            sql += this.ToString(values[i]);            if (values.length > i + 1)                sql += ', ';        }        sql += ')';        this.database.run(sql, (err) => {            callback(err);        });    };    /**     *      * @param {String} table      * @param {String} condition      * @param {Function()} callback      */    static deleteData(table, condition = '', callback = () => {}) {        this.database.run('DELETE FROM `' + table + '` ' + condition, (err) => {            callback(err);        });    }    static ToString(value) {        return typeof(value) === 'string' ? '\'' + value + '\'' : value;    }}module.exports = {    database: DataBase};

На этом все, спасибо за внимание!
Проект на GitHub

Подробнее..

Использование приватных свойств класса для усиления типизации в typescript

10.05.2021 20:18:52 | Автор: admin

Вот за что я люблю typescript, так это за то что он не даёт мне пороть ерунду. Померять длину числового значения и проч. Поначалу я конечно плевался, возмущался что ко мне пристают со всякими глупыми формальностями. Но потом втянулся, полюбил пожёстче. Ну в смысле a little bit more strict. Включил в проекте опцию strictNullCheck и три дня потратил на устранение возникших ошибок. А потом с удовлетворением радовался, отмечая как легко и непринуждённо проходит теперь рефакторинг.

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

Пример 1

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

А тут мы можем нормально типизировать пропсы, передаваемые в компонент, и получать соответствующие подсказки IDE при редактировании шаблона. Но это внутри компонента. А теперь давайте проконтролируем, что не передали в этот компонент какую-нибудь левоту.


import { createElement, FunctionComponent, ComponentClass } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';

export class Rendered<P> extends String {
constructor(component: FunctionComponent<P> | ComponentClass<P>, props: P)
{
super('<!DOCTYPE html>' + renderToStaticMarkup(
createElement(component, props),
));
}
}

Теперь если мы попытаемся передать в компонент пользователя пропсы от заказа - нам незамедлительно укажут на это недоразумение. Круто? Круто.

Но это в момент генерации html. Как же дела обстоят с дальнейшим его использованием? Т.к. результатом инстацирования Rendered является просто строка, то typescript не будет ругаться например на такую конструкцию:

const html: Rendered<SomeProps> = 'Typescript cannot into space';

Соответственно, если мы напишем примерно такой контроллер:

@Get()
public index(): Rendered<IHelloWorld> {
return new Rendered(HelloWorldComponent, helloWorldProps);
}

это никак не гарантирует, что из этого метода будет возвращен именно результат компиляции компонента HelloWorldComponent.

Ну так давайте сделаем, чтобы это была не просто строка :)

export class Rendered<P> extends String {
_props: P;
constructor(component: FunctionComponent<P> | ComponentClass<P>, props: P)
...

Тут уже 'cannot into space' не прокатит. Уже есть прогресс. Вероятность нечаянно вернуть что-то не то сильно уменьшается. Что так же мне нравится - мы никак не используем свойство _props, и соответственно оно не попадает в скомпиллированный js код и не перегружает ответ сервера, т.е. остаётся "виртуальным" усилителем проверки типов.

Но всё еще прокатит вариант

Object.assign('cannot into space', {_props: 42})

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

export class Rendered1<P> extends String {
// @ts-ignore - не случай если у вас включен noUnusedParameters
private readonly _props: P;
constructor(component: FunctionComponent<P> | ComponentClass<P>, props: P)
...

Теперь даже результат вызова Object.assign нам не дадут вернуть из контроллера, т.к. в классе Rendered поле _props приватное, а в самодельном объекте публичное.

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

Пример 2

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

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

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

Давайте рассмотрим такой подход на примере класса ApiResponse. Это конечно не очень похоже на какую-то модель доменной области, но поможет проиллюстрировать некорректные сочетания параметров.

export interface IApiResponse {
readonly scenarioSuccess: boolean;
readonly systemSuccess: boolean;
readonly result: string | null;
readonly error: string | null;
readonly payload: string | null;
}

export class ApiResponse implements IApiResponse {
constructor(
public readonly scenarioSuccess: boolean,
public readonly systemSuccess: boolean,
public readonly result: string | null = null,
public readonly error: string | null = null,
public readonly payload: string | null = null,
) {}
}

При успешном выполнении операции будем ставить scenarioSuccess в true. Если наша логика отработала корректно, но пользователю нужно отказать (например введённый пароль неверен) - будем ставить scenarioSuccess в false. А если мы не смогли проверить пароль например потому что база отвалилась - будем ставить systemSuccess в false. Сообщения об успехе/неудаче будем отдавать в полях result/error. Интерфейс конечно подраздут. Зато хорошо видно, что можно например выставить scenarioSuccess true и непустое значение error.

Чтобы не допускать некорректных сочетаний свойств, давайте пометим класс ApiResponse как абстрактный, и наплодим для него наследников:

export class ScenarioSuccessResponse extends ApiResponse {
constructor(result: string, payload: string | null = null) {
super(true, true, result, null, payload);
}
}

и так далее.

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

const SECRET_SYMBOL = Symbol('SECRET_SYMBOL');

expoet abstract class ApiResponse implements IApiResponse {
// @ts-ignore
private readonly [SECRET_SYMBOL]: unknown;


constructor(
public readonly scenarioSuccess: boolean,
public readonly systemSuccess: boolean,
public readonly result: string | null = null,
public readonly error: string | null = null,
public readonly payload: string | null = null,
) {}
}

Если для обхода класса Rendered можно было создать новый класс с приватным полем _props, то теперь приватное свойство является вычисляемым, а обратиться к нему можно через символ, который мы не экспортируем из модуля. И соответственно "повторить" его не получится. По крайней мере в другом файле. (Вам тоже кажется, что это попахивает паранойей?)

Ну что ж. Такое обойти можно, пожалуй, только через any. Но против лома нет приёма.

Тут ещё можно заметить, что общение между компонентами системы должно быть развязано через интерфейсы. Но принимающей стороне вполне можно прописать, что она ожидает IApiResponse, а вот сервис из слоя доменной логики так уж и быть, пусть возвращает конкретную реализацию ApiResponse.

Well...

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

Благодарю за уделённое время. Буду рад конструктивной критике.

Подробнее..
Категории: Javascript , Typescript , Node.js , Nodejs , Типизация

ReactRedoor IPC мониторинг

12.05.2021 18:04:50 | Автор: admin

В одном из наших проектов, мы использовали IPC (inter-process communication) на сокетах. Довольно большой проект, торгового бота, где были множество модулей которые взаимодействовали друг с другом. По мере роста сложности стал вопрос о мониторинге, что происходит в микросервисах. Мы решили создать свое приложение для отслеживания, потока данных на всего двух библиотеках react и redoor. Я хотел бы поделиться с вами нашим подходом.

Микросервисы обмениваются между собой JSON объектами, с двумя полями: имя и данные. Имя - это идентификатор какому сервису предназначается объект и поле данные - полезная нагрузка. Пример:

{ name:'ticket_delete', data:{id:1} }

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

И так начнем.Для примера сделаем простейшее приложение и веб сервер. Приложение будет состоять из трех модулей. На картинке они обозначены пунктирными линиями. Таймер, статистика и кнопки управления статистикой.

Создадим простой Web Socket сервер.

/** src/ws_server/echo_server.js */const WebSocket = require('ws');const wss = new WebSocket.Server({ port: 8888 });function sendToAll( data) {  let str = JSON.stringify(data);  wss.clients.forEach(function each(client) {    client.send(str);  });}// Отправляем данные каждую секундуsetInterval(e=>{  let d = new Date();  let H = d.getHours();  let m = ('0'+d.getMinutes()).substr(-2);  let s = ('0'+d.getSeconds()).substr(-2);  let time_str = `${H}:${m}:${s}`;  sendToAll({name:'timer', data:{time_str}});},1000);

Сервер каждую секунду формирует строку с датой и отправляет всем подключившимся клиентам. Открываем консоль и запускаем сервер:

node src/ws_server/echo_server.js

Теперь перейдем к проекту приложения. Для сборки и отладки будем использовать rollup конфигурация ниже.

rollup.config.js
import serve from 'rollup-plugin-serve';import babel from '@rollup/plugin-babel';import { nodeResolve } from '@rollup/plugin-node-resolve';import commonjs from '@rollup/plugin-commonjs';import hmr from 'rollup-plugin-hot'import postcss from 'rollup-plugin-postcss';import autoprefixer from 'autoprefixer'import replace from '@rollup/plugin-replace';const browsers = [  "last 2 years",  "> 0.1%",  "not dead"]let is_production = process.env.BUILD === 'production';const replace_cfg = {  'process.env.NODE_ENV': JSON.stringify( is_production ? 'production' : 'development' ),  preventAssignment:false,}const babel_cfg = {    babelrc: false,    presets: [      [        "@babel/preset-env",        {          targets: {            browsers: browsers          },        }      ],      "@babel/preset-react"    ],    exclude: 'node_modules/**',    plugins: [      "@babel/plugin-proposal-class-properties",      ["@babel/plugin-transform-runtime", {         "regenerator": true      }],      [ "transform-react-jsx" ]    ],    babelHelpers: 'runtime'}const cfg = {  input: [    'src/main.js',  ],  output: {    dir:'dist',    format: 'iife',    sourcemap: true,    exports: 'named',  },  inlineDynamicImports: true,  plugins: [    replace(replace_cfg),    babel(babel_cfg),    postcss({      plugins: [        autoprefixer({          overrideBrowserslist: browsers        }),      ]    }),    commonjs({        sourceMap: true,    }),    nodeResolve({        browser: true,        jsnext: true,        module: false,    }),    serve({      open: false,      host: 'localhost',      port: 3000,    }),  ],} ;export default cfg;

Точка входа нашего проекта main.js создадим его.

/** src/main.js */import React, { createElement, Component, createContext } from 'react';import ReactDOM from 'react-dom';import {Connect, Provider} from './store'import Timer from './Timer/Timer'const Main = () => (  <Provider>    <h1>ws stats</h1>    <Timer/>  </Provider>);const root = document.body.appendChild(document.createElement("DIV"));ReactDOM.render(<Main />, root);

Теперь создадим стор для нашего проекта

/** src/store.js */import React, { createElement, Component, createContext } from 'react';import createStoreFactory from 'redoor';import * as actionsWS from './actionsWS'import * as actionsTimer from './Timer/actionsTimer'const createStore = createStoreFactory({Component, createContext, createElement});const { Provider, Connect } = createStore(  [    actionsWS,     // websocket actions    actionsTimer,  // Timer actions  ]);export { Provider, Connect };

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

/** src/actionsWS.js */export const  __module_name = 'actionsWS'let __emit;// получаем функцию emit от redoorexport const bindStateMethods = (getState, setState, emit) => {  __emit = emit};// подключаемся к серверуlet wss = new WebSocket('ws://localhost:8888')// получаем все сообщения от сервера и отправляем их в поток redoorwss.onmessage = (msg) => {  let d = JSON.parse(msg.data);  __emit(d.name, d.data);} 

Здесь надо остановиться поподробнее. Наши сервисы отправляют данные в виде объекта с полями: имя и данные. В библиотеке redoor можно так же создавать потоки событий в которые мы просто передаем данные и имя. Выглядит это примерно так:

   +------+    | emit | --- events --+--------------+----- ... ------+------------->+------+              |              |                |                      v              v                v                 +----------+   +----------+     +----------+                 | actions1 |   | actions2 | ... | actionsN |                 +----------+   +----------+     +----------+

Таким образом каждый модуль имеет возможность "слушать" свои события и по надобности и чужие тоже.

Теперь создадим собственно сам модуль таймера. В папке Timer создадим два файла Timer.js и actionsTimer.js

/** src/Timer/Timer.js */import React from 'react';import {Connect} from '../store'import s from './Timer.module.css'const Timer = ({timer_str}) => <div className={s.root}>  {timer_str}</div>export default Connect(Timer);

Здесь все просто, таймер берет из глобального стейта timer_str который обновляется в actionsTimer.js. Функция Connect подключает модуль к redoor.

/** src/Timer/actionsTimer.js */export const  __module_name = 'actionsTimer'let __setState;// получаем метод для обновления стейтаexport const bindStateMethods = (getState, setState) => {  __setState = setState;};// инициализируем переменную таймераexport const initState = {  timer_str:''}// "слушаем" поток событий нам нужен "timer"export const listen = (name,data) =>{  name === 'timer' && updateTimer(data);}// обновляем стейт function updateTimer(data) {  __setState({timer_str:data.time_str})}

В акшес файле, мы "слушаем" событие timer таймера (функция listen) и как только оно будет получено обновляем стейт и выводим строку с данными.

Подробнее о функциях redoor:

__module_name - зарезервированная переменная нужна просто для отладки она сообщает в какой модуль входят акшенсы.

bindStateMethods - функция для получения setState, поскольку данные приходят асинхронно нам надо получить в локальных переменных функцию обновления стейта.

initState - функция или объект инициализации данных модуля в нашем случае это timer_str

listen- функция в которую приходят все события сгенерированные redoor.

Готово. Запускаем компиляцию и открываем браузер по адресу http://localhost:3000

npx rollup -c rollup.config.js --watch

Должны появиться часики с временем. Перейдём к более сложному. По аналогии с таймером добавим еще модуль статистики. Для начала добавим новый генератор данных в echo_server.js

/** src/ws_server/echo_server.js */...let g_interval = 1;// Данные статистикиsetInterval(e=>{  let stats_array = [];  for(let i=0;i<30;i++) {    stats_array.push((Math.random()*(i*g_interval))|0);  }  let data  = {    stats_array  }  sendToAll({name:'stats', data});},500);...

И добавим модуль в проект. Для этого создадим папку Stats в которой создадим Stats.js и actionsStats.js

/** src/Stats/Stats.js */import React from 'react';import {Connect} from '../store'import s from './Stats.module.css'const Bar = ({h})=><div className={s.bar} style={{height:`${h}`px}}>  {h}</div>const Stats = ({stats_array})=><div className={s.root}>  <div className={s.bars}>    {stats_array.map((it,v)=><Bar key={v} h={it} />)}  </div></div>export default Connect(Stats);
/** src/Stats/actionsStats.js */export const  __module_name = 'actionsStats'let __setState = null;export const bindStateMethods = (getState, setState, emit) => {  __setState = setState;}export const initState = {  stats_array:[],}export const listen = (name,data) =>{  name === 'stats' && updateStats(data);}function updateStats(data) {  __setState({    stats_array:data.stats_array,  })}

и подключаем новый модуль к стору

/** src/store.js */...import * as actionsStats from './Stats/actionsStats'const { Provider, Connect } = createStore(  [    actionsWS,    actionsTimer,    actionsStats //<-- модуль Stats  ]);...

В итоге мы должны получить это:

Как видите модуль Stats принципиально не отличается от модуля Timer, только отображение не строки, а массива данных. Что если мы хотим не только получать данные, но и отправлять их на сервер? Добавим управление статистикой.

В нашем примере переменная g_interval это угловой коэффициент наклона нормировки случайной величины. Попробуем ей управлять с нашего приложения.

Добавим пару кнопок к графику статистики. Плюс будет увеличвать значение interval минус уменьшать.

/** src/Stats/Stats.js */...import Buttons from './Buttons' // импортируем модуль...const Stats = ({cxRun, stats_array})=><div className={s.root}>  <div className={s.bars}>    {stats_array.map((it,v)=><Bar key={v} h={it} />)}  </div>  <Buttons/> {/*Модуль кнопочки*/}</div>...

И сам модуль с кнопочками

/** src/Stats/Buttons.js */import React from 'react';import {Connect} from '../store'import s from './Stats.module.css'const DATA_INTERVAL_PLUS = {  name:'change_interval',  interval:1}const DATA_INTERVAL_MINUS = {  name:'change_interval',  interval:-1}const Buttons = ({cxEmit, interval})=><div className={s.root}>  <div className={s.btns}>      <button onClick={e=>cxEmit('ws_send',DATA_INTERVAL_PLUS)}>        plus      </button>      <div className={s.len}>interval:{interval}</div>      <button onClick={e=>cxEmit('ws_send',DATA_INTERVAL_MINUS)}>        minus      </button>  </div></div>export default Connect(Buttons);

Получаем панель с кнопочками:

И модифицируем actionsWS.js

/** src/actionsWS.js */...let wss = new WebSocket('ws://localhost:8888')wss.onmessage = (msg) => {  let d = JSON.parse(msg.data);  __emit(d.name, d.data);}// "слушаем" событие отправить данные на серверexport const listen = (name,data) => {  name === 'ws_send' && sendMsg(data);}// отправляем данныеfunction sendMsg(msg) {  wss.send(JSON.stringify(msg))}

Здесь мы в модуле Buttons.js воспользовались встроенной функции (cxEmit) создания события в библиотеке redoor. Событие ws_send "слушает" модуль actionsWS.js. Полезная нагрузка data - это два объекта: DATA_INTERVAL_PLUS и DATA_INTERVAL_MINUS. Таким образам если нажать кнопку плюс на сервер будет отправлен объект { name:'change_interval', interval:1 }

На сервере добавляем

/** src/ws_server/echo_server.js */...wss.on('connection', function onConnect(ws) {  // "слушаем" приложение на событие "change_interval"  // от модуля Buttons.js  ws.on('message', function incoming(data) {    let d = JSON.parse(data);    d.name === 'change_interval' && change_interval(d);  });});let g_interval = 1;// меняем интервалfunction change_interval(data) {  g_interval += data.interval;  // создаем событие, что интервал изменен  sendToAll({name:'interval_changed', data:{interval:g_interval}});}...

И последний штрих необходимо отразить изменение интервала в модуле Buttons.js. Для этого в actionsStats.js начнём слушать событие "interval_changed" и обновлять переменную interval

/** src/Stats/actionsStats.js */...export const initState = {  stats_array:[],  interval:1 // добавляем переменную интервал}export const listen = (name,data) =>{  name === 'stats' && updateStats(data);    // "слушаем" событие обновления интервала  name === 'interval_changed' && updateInterval(data);}// обнавляем интервалfunction updateInterval(data) {  __setState({    interval:data.interval,  })}function updateStats(data) {  __setState({    stats_array:data.stats_array,  })}

Итак, мы получили три независимых модуля, где каждый модуль следит только за своим событием и отображает только его. Что довольно удобно когда еще не ясна до конца структура и протоколы на этапе прототипирования. Надо только добавить, что поскольку все события имеют сквозную структуру то надо четко придерживаться шаблона создания события мы для себя выбрали такую: (MODULEN AME)_(FUNCTION NAME)_(VAR NAME).

Надеюсь было полезно. Исходные коды проекта, как обычно, на гитхабе.

Подробнее..
Категории: Javascript , React , Node.js , Reactjs , Nodejs , Ipc , Webscoket

Хочу middleware, но не хочу ExpressJS

14.05.2021 06:11:23 | Автор: admin
Middleware в случае с HTTP-сервером в Node.JS это промежуточный код, который выполняется до того, как начнёт выполняться ваш основной код. Это, чаще всего, нужно для того, чтобы сделать какой-то дополнительный тюнинг или проверку входящего запроса. Например, чтобы превратить данные из POST-запроса в формате JSON-строки в обычный объект, или получить доступ к кукам в виде объета, и т.п.

Стандартный модуль http из Node.JS не поддерживает такие вещи. Самый очевидный путь: установить ExpressJS и не париться. Но, на мой взгляд, если есть возможность самому написать немного кода и не добавлять ещё 50 пакетов-зависимостей в проект, архитектура станет проще, скорость работы будет выше, будет меньше точек отказа, и ещё не будет нужно постоянно пастись на гитхабе и уговаривать разработчиков обновить версии зависимостей в package.json (или просто принять пулл-реквест, где другой человек за него это сделал), чтобы код был постоянно свежим и актуальным. Я пару раз так делал, и мне не очень нравится тратить время на такие вещи. Очень часто, если ты самостоятельно воспроизводишь какую-то технологию, времени на поддержку тратится меньше, чем если ты устанавливаешь сторонний модуль с такой технологией как раз из-за таких моментов, когда ты тратишь время на то, чтобы напоминать другим разработчикам, что нужно следить за обновлениями зависимостей и реагировать на них своевременно.

Суть middleware довольно-таки проста: это функция, которая принимает три параметра: request, response и next:



Middleware делает все нужные телодвижения с request и response, после чего вызывает функцию next это сигнал, что оно закончило работу и можно работать дальше (например, запустить в обработку следующее middleware, или просто перейти к основному коду). Если next вызывается без параметров, то всё нормально. Если в вызов передать ошибку, то обработка списка middleware останавливается.

Пример простейшего middleware:

function myMiddleware(request, response, next) {    if (typeof next !== 'function') {        next = () => {};    }    console.log('Incoming request');    next();}


Если честно, я даже не смотрел, как это реализовано в ExpressJS, но, навскидку, я понимаю этот процесс так: когда вызывается server.use(myMiddleware), моя функция myMiddleware добавляется в какой-то массив, а при каждом входящем запросе вызываются все функции из этого массиа в порядке очерёдности их добавления, после чего начинает работать остальной код. Очевидно, раз используется функция next, то подразумевается асинхронность кода: middleware-функции не просто выполняются одна за другой перед тем как выполнить следующую функцию из списка, нужно дождаться окончания работы предыдущей.

Получается, вначале мне нужно создать функцию server.use, которая будет регистрировать все middleware.

MyHttpServer.js:

const http = require('http');const middlewares = [];/** * Основной обработчик HTTP-запросов *  * Пока что тут только заглушка *  * @param {IncomingMessage} request * @param {ServerResponse} response * @return {Promise<void>} */async function requestListener(request, response) {    throw new Error('Not implemented');}/* * Функция-регистратор middleware-кода */function registerMiddleware(callback) {    if (typeof callback !== 'function') {        return;    }    middlewares.push(callback);}// Создаётся сервер и регистрируется осноной обработчикconst server = http.createServer(requestListener);// К серверу добавляется регистратор middleware-функцийserver.use = registerMiddleware;


Осталась самая малость: нужно каким-то образом выполнять все эти middleware в асинхронном режиме. Лично я, если мне нужно обойти массив в асинхронном режиме, пользуюсь функцией Array.prototype.reduce(). Она, в определённых условиях, может делать как раз то, что мне нужно. Самое время доработать функцию requestListener.

/* * Это просто служебная функция  вставлена здесь для примера  * и сама по себе обычно находится в другом модуле */function isError(error) {    switch (Object.prototype.toString.call(error)) {        case '[object Error]':            return true;        case '[object Object]':            return (                error.message                && typeof error.message === 'string'                && error.stack                && typeof error.stack === 'string'            );    }    return false;}/** * Основной обработчик HTTP-запросов *  * @param {IncomingMessage} request * @param {ServerResponse} response * @return {Promise<void>} */async function requestListener(request, response) {    response.isFinished = false;    response.on('finish', () => response.isFinished = true);    response.on('close', () => response.isFinished = true);    let result;    try {        result = await middlewares.reduce(            // Редусер. Первый параметр  предыдущее значение,             // второй  текущее. Чтобы обеспечить асинхронность,             // всё оборачивается в Promise.            (/**Promise*/promise, middleware) => promise.then(result => {                // Если в предыдущем middleware был вызов                 // next(new Error('Some message')), текущий middleware                 // игнорируется и сразу возвращается ошибка                 // из предыдущего кода                if (isError(result)) {                    return Promise.reject(result);                }                // Возвращается новый Promise, который, кроме прочего,                 // реагирует на какую-то ошибку в рамках не только                 // вызова next, но и в рамках всего кода.                // То есть, если middleware вызывает внутри JSON.parse                 // без try-catch, то, в случае ошибки парсинга, реакция                 // будет такая же, как и при вызове next с передачей                 // ошибки в качестве параметра                return new Promise((next, reject) => {                    Promise.resolve(middleware(request, response, next)).catch(reject);                });            }),            Promise.resolve()        );        if (isError(result)) {            throw result;        }    } catch (error) {        response.statusCode = 500;        result = 'Error';    }    if (response.isFinished) {        return;    }    response.end(result);}


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

const cookieParser = require('cookie-parser');server.use(cookieParser());// Мой основной кодserver.use((request, response, next) => {    if (request.cookies['SSID']) {        response.end('Your session id is ' + request.cookies['SSID']);    } else {        response.end('No session detected');    }    next();});


Под спойлером простейший пример стандартного HTTP-сервера Node.JS с поддержкой экспрессовких middleware для тех, кто предпочитает copy/paste.

MyHttpServer.js
const http = require('http');const middlewares = [];function isError(error) {    switch (Object.prototype.toString.call(error)) {        case '[object Error]':            return true;        case '[object Object]':            return (                error.message                && typeof error.message === 'string'                && error.stack                && typeof error.stack === 'string'            );    }    return false;}/** * Основной обработчик HTTP-запросов *  * @param {IncomingMessage} request * @param {ServerResponse} response * @return {Promise<void>} */async function requestListener(request, response) {    response.isFinished = false;    response.on('finish', () => response.isFinished = true);    response.on('close', () => response.isFinished = true);    let result;    try {        result = await middlewares.reduce(            (/**Promise*/promise, middleware) => promise.then(result => {                if (isError(result)) {                    return Promise.reject(result);                }                return new Promise((next, reject) => {                    Promise.resolve(middleware(request, response, next)).catch(reject);                });            }),            Promise.resolve()        );        if (isError(result)) {            throw result;        }    } catch (e) {        response.statusCode = 500;        result = 'Error';    }    if (response.isFinished) {        return;    }    response.end(result);}/* * Функция-регистратор middleware-кода */function registerMiddleware(callback) {    if (typeof callback !== 'function') {        return;    }    middlewares.push(callback);}const server = http.createServer(requestListener);server.use = registerMiddleware;const cookieParser = require('cookie-parser');server.use(cookieParser());server.use((request, response) => {    if (request.cookies['SSID']) {        return 'Your session id is ' + request.cookies['SSID'];    }    return 'No session detected';});server.listen(12345, 'localhost', () => {    console.log('Started http');});



Нашли ошибку в тексте? Выделите текст, содержащий ошибку и нажмите Alt-F4 (если у вас мак, то -Q). Шутка, конечно же. Если нашли ошибку, пишите в личные сообщения или в комментарии постараюсь исправить.
Подробнее..
Категории: Javascript , Node.js , Nodejs , Middleware

Перевод Как мы потерпели неудачу, а затем преуспели в переходе на TypeScript

02.06.2021 18:06:54 | Автор: admin

К старту курса о Fullstack-разработке на Python, где также рассматривается TypeScript, мы перевели статью о миграции в Heap.io компании, которая предоставляет платформу аналитики продуктов, c языка CoffeeScript на TypeScript; TS в Heap.io начали использовать более 4 лет назад. Несмотря на широкое предпочтение TypeScript среди инженеров, миграция была медленной, а чёткого пути к 100 % кода TS не было.


На самом деле, если цель состояла в том, чтобы полностью переключиться на TS, мы двигались в неправильном направлении. Да, код на TS добавлялся, но количество кода на CoffeeScript росло быстрее. TypeScript и CoffeeScript нацелены на один и тот же рантайм Node.js, который, как ожидалось, должен был облегчить переход, но, несмотря на желание большого числа разработчиков перейти на TypeScript, мы не набрали большого импульса и не направлялись к будущему без CoffeeScript.

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

Количество строк кода в разработкеКоличество строк кода в разработке

Миграция стека в равной степени касается и технологий, и людей

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

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

Новый опыт разработки должен предлагать очевидное улучшение

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

Мы начали с определения областей кодовой базы, которые в случае преобразования приведут к существенному повышению производительности, зная, что стратегическое преобразование файлов будет привлекательнее произвольного преобразования. Например, наш слой доступа к данным (ORM) покрывает всё, и большинство файлов каким-то образом работают с ORM. Введение типов на уровне доступа к данным было множителем всей работы; почти каждый преобразованный в будущем файл выиграет от хорошо типизированных моделей баз данных и утилит.

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

Наконец, мы сошлись на наборе согласованных правил линтинга, которые позволили нам писать код в едином стиле по всей организации и сделать переход более удобным.

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

Технические барьеры нужно ломать

Когда мы начали анализировать шаблоны внедрения TypeScript, стало ясно, что использование TypeScript для наших инженеров не было простым, им часто приходилось импортировать специальные утилиты (ts-node/register) или создавать промежуточные файлы CoffeeScript, которые не делали ничего, кроме импорта их эквивалентов TypeScript. Короче говоря, история взаимодействия языков существовала, но требовала много бойлерплейта и слишком много проб и ошибок.

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

Чтобы добиться этого, мы отдавали приоритет усилиям, которые позволили бы разработчикам писать на TS в любом компоненте или сервисе. Будь то бэкенд, фронтенд, скрипты или задачи devops, мы хотели, чтобы наши инженеры могли писать код в TypeScript и чтобы он просто работал. В итоге мы прописали переменную среды NODE_OPTIONSс -r ts-node/register, чтобы существующие (использующие команду coffee для запуска файлов CoffeeScript) рабочие процессы также продолжали работать.

Преобразование должно быть простым, безопасным и автоматизированным

Миграция на другой язык может быть рискованной: то, что может показаться эквивалентным синтаксисом между CoffeeScript и ES6/TypeScript, на самом деле может вообще не быть эквивалентным. И разработчики могут рассматривать преобразование как хорошую возможность для рефакторинга, а это ещё хуже; переписывание делает рискованную миграцию ещё более рискованной.

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

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

Для первоначального преобразования мы использовали скрипт, преобразующий файл .coffee в файл .ts. В целях перехода от CoffeeScript к JavaScript ES6 Под капотом работал decaffeinate. Поскольку весь JavaScript ES6 является синтаксически правильным TypeScript, на выходе получался рабочий файл. (Мы обнаружили, что decaffeinate очень зрелый и надёжный инструмент.) В истории Git шаг преобразования представлен одним отдельным коммитом.

Однако работа ещё не была закончена. Мы используем TypeScript в строгом режиме, поэтому была отключена такая функция, как "implicit any". Мы использовали это окно преобразования как возможность создавать аннотации типов для элементов, где вывод типов был невозможен. Также мы избегали использования any в этой фазе, вместо этого выбрав более строгий неизвестный. Цель на этом этапе состояла в том, чтобы внести изменения, которые не приведут к изменению поведения во время выполнения. Мы не занимались никаким рефакторингом, а просто выполняли минимальный объём работы, чтобы привести код в состояние, в котором он компилировался, линтовался и проходил тесты.

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

Этот второй шаг также прошёл отдельным коммитом; такой подход сильно упростил ревью: ревьюер мог легко увидеть, какие изменения были внесены после шага c decafeinate.

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

#typescript: канал дискуссий и вопросов

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

Для этого мы создали канал #typescript в Slack и гарантировали, что застрявшие разработчики могут выйти из положения. Разработчики, управляющие миграцией, стали отвечать на вопросы и остановились на поиске общих проблем и камней преткновения. Если возникала одна и та же проблема, они знали, что нужно сделать лучше.

Разработчики должны знать, что на любые вопросы о языке и инструментах, которые у них есть, будут даны быстрые ответы. Мы решили, что чемпионы TypeScript должны отдать приоритет ответам, а не собственной работе. Хотя такой подход замедлил их работу, он также устранил ряд потенциально серьёзных препятствий в миграции.

Отслеживание прогресса

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

Отслеживание усилий во времени оказалось полезным. Таким образом возможно было понять, продолжается ли прогресс в миграции. Для визуализации количества строк мы воспользовались Grafana. Вот ещё одна визуализация, показывающая количество файлов во времени:

Уважающее инженеров руководство

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

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

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

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

Ситуации могут быть разными, и если вам не хочется ограничивать себя только одним подходом, владеть языками с разными системами типов, то вы можете обратить внимание на наш курс Fullstack-разработчик на Python, где, кроме Python и TS, студенты также изучают чистый JavaScript.

Узнайте, как прокачаться и в других специальностях или освоить их с нуля:

Другие профессии и курсы
Подробнее..

Lerna CI ? Или как не запутаться в трёх соснах

02.01.2021 22:16:45 | Автор: admin

Вместо предисловия

Доброго времени суток! Меня зовут Сергей, и я тимлид в компании Медпоинт24-Лаб. Я занимаюсь разработкой на nodejs чуть больше полутора лет - до этого был C#, ну а ещё до того, всякое разное и не очень серьёзно. Ну то есть, опыта у меня не так чтобы вагон, и иногда приходится серьёзно поломать голову при решении возникающих проблем. Решив такую, всегда хочется поделиться находками с товарищами по команде.

И вот несколько дней назад, они посоветовали мне завести блог... а я подумал, может тогда просто написать на Хабр?

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

Рассказывать постараюсь без погружения в теорию, но со ссылками на оную.

О чём пойдёт речь?

Пилот будет посвящён интересной проблеме с которой мы столкнулись при попытке организовать CI/CD для монорепозитория с lerna. Сразу скажу, что этот пост:

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

  • не про инструменты для управления монорепозиториями. Монорепу можно реализовать при помощи Nx, rush, даже просто yarn workspaces. Но так получилось, что мы выбрали lerna и поживём с ней какое то время.

  • не про пакетные менеджеры. Могу порекомендовать хороший видос со сравнением npm, yarn и pnpm и офигенную серию постов в которой работа c npm объясняется с самых азов и очень тщательно. А у нас npm (пока)...

  • не про nestjs. Но он классный!

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

Тогда о чём?

Дано:

Имеется маленький монорепозиторий, в котором лежит сервер на несте и npm-пакет, который содержит всё необходимое для клиентского приложения, которое будет этот сервер вызывать.

packages+-- @contract|+-- src|+-- package.json|   ...|+-- application|   +-- src|   +-- package.json|   ...|+-- package.json+-- lerna.json...
Зачем пакет?

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

То есть, вызывающий код делает не просто axios.post(....) передавая туда никак не проверяемые статически параметры (any), а вызывает метод с типизированным входом и выходом.

import { Client } from '@contract/some-service';const client = new Client(options);const filters: StronglyTypedObject = ...const data = await client.getSomeData(filters)/** И результат у нас тоже типизированный.* А ещё из getSomeData() вылетают типизированные ошибки известного формата,* чем в нас обычно кидается, axios.*/

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

Кроме того, недавно мы пошли чуть дальше и сделали так:

const query = new SomeQuery({ ... });const data = await client.call(query);/** Теперь клиент у нас общий на все сервисы, а вот запросы и команды -* волшебные и сами знают, что делать. Это мы используем с rabbitMQ.*/

Такой подход мы используем не только для http-сервисов, но и для сервисов, которые обрабатывают сообщения из RabbitMQ, а также при помощи одного из самописных средств транспорта на основе redis. Но усложнять статью этими деталями не будем.

Так вот, у нас есть монорепа, что она нам даёт? В первую очередь, удобство разработки. Базовая фича для всех релевантных инструментов - это то, что в lerna называется bootstrap.

lerna bootstrap --hoist

Флаг --hoist - самая приятная часть. Он говорит лерне, что все зависимости, если можно, надо ставить в node_modules в корне проекта. Мы на этом экономим место + получаем ещё бонус, который нам пригодится дальше.

Помимо установки пакетов lerna bootstrap создаёт симлинки на пакеты имеющиеся в репозитории. То есть, хотя в application/package.json указано

"dependencies": {"@contract/core": "^1.0.0"}

в действительности, этот пакет не будет установлен из npm-реестра, а просто прилинкуется в node_modules из папки packages. Таким образом, мы его меняем, собираем и сразу используем новую версию в нашем приложении.

Задача

Мы строим систему CI/CD. И нам нужно научиться красиво вписать наш монорепозиторий в конвеер. Казалось бы, задача должна была уже 1000 раз быть решена - настолько она очевидна.

И действительно, есть куча issues на github, посты на Stackoverflow и др. ресурсах. Но нет рецептов.. костыли есть, и то не все рабочие, а нормального "штатного" решения я не нашёл (искал, чесслово).

Так вот, когда наш сервис готов к релизу:

  1. Мы хотим смержить PR и, таким образом, запустить пайплайн.

  2. Мы хотим собрать проект, прогнать линтер, unit-тесты.

  3. Мы хотим поднять версию приложения и пакета (а в чём не было изменений - там не поднимать).

  4. Мы хотим опубликовать пакет @contract в npm registry (в нашем случае, приватном).

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

  6. Ну и мы хотим, чтобы наш артефакт был разумного размера и содержал только то, что ему реально нужно для работы. node_modules по ГБ - не совсем то, что нам нужно.

Поехали!

Первый пункт берут на себя CI/CD системы.

Со вторым проблем быть не должно:

Для третьего lerna нам предлагает две прекрасные команды: lerna version и lerna publish (последняя включает в себя первую и мы будем использовать её). Примерно так:

lerna publish --conventional-commits --yes# На заметку: команда publish принимает все флаги команды version.# В доке это есть, но я с первого раза пропустил.
Чуть подробнее про conventional commits.

Если делать команду lerna publish без указанных ключей, то поднятие версии будет интерактивным,что для CIонвейра не годится. На помощь приходит спецификация Conventional Commits. Соблюдение этого простого и понятного соглашения по структуре commit-сообщений, позволяет lerna автоматически определить, какую как правильно по semver поднять (минор, мажор или патч). Самое милое, что мы можем вынудить наших разработчиков писать правильные коммиты (что само по себе хорошо)! Вот достаточно подробная инструкция.

С пунктом 4 у нас тоже нет проблем.lerna publish нам это уже сделала, а если нас это почему-то не устраивает (ну, к примеру, мы не хотим ставить теги или ещё что), то используем lerna version в сочетании с npm publish из директории пакета. Забавно, что npm publish не имеет ключа --registry, чтобы указать, куда пушить пакет. В случае lerna publish, нас выручит lerna.json (стр. 7):

{  "version": "1.2.2",  "npmClient": "npm",  "command": {    "publish": {      "message": "chore(release): publish",      "registry": ....    }  },  "packages": [    "packages/@contract",    "packages/application"  ]}

Иначе нам понадобится файл .npmrc (файл с настройками npm) в директории пакета.

Первые сложности

Итак, наша CI-машина должна делать примерно следующее (без привязки к конкретной системе CI/CD):

# Pull и checkoutlerna bootsrap --hoist lerna run build # Запустит команду npm run build в каждом пакете.lerna publish --conventional-commits --yescp packages/application/build /tmp/place/for/artifact...

Но для работы нашему приложению нужны ещё node_modules.

Попытка 1. Можно конечно взять да и скопировать папку node_modules из корня проекта в наш /tmp/place/for/artifact. Но тогда:

  • Мы точно получим лишние зависимости (всякие jest, typescript и кучу ещё всего ненужного в рантайме). А если у нас в репозитории не 2 пакета, а 22, то размер node_modules может быть неприличным.

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

Попытка 2. Не вопрос. У нас же есть package.json внутри packages/application. Там ведь перечисленно всё, что надо! Копируем package.json в папку с артефактом, запускаем npm i - профит! Но:

Дело в том, что для обеспечения повторяемости билда, в CI среде вместо команды npm install принято использовать npm ci. Основное отличие от npm install в том, что пакеты ставятся не из package.json, а из package-lock.json или shrinkwrap.json (смысл тот же). Подробнее о lock-файлах можно почитать в хорошем переводе от Андрея Мелихова.

Для моего рассказа важно понимать следующее:

  • Обойтись без lock-файла никак нельзя. Даже если указать в dependencies точные версии зависимостей без всяких "~" и "^" - это не поможет, т. к. транзитивные зависимости (то есть зависимости ваших зависимостей) вы не контролируете.

  • lock-файл должен быть синхронизирован с package.json. Это значит, что если у нас в package.json появилась новая зависимость (или наоборот), а в package-lock.json её нет, то npm ci будет ругаться:

Приятно, что текст ошибки содержит прямое указание на то, что нам нужно сделать - npm install.

Давайте вспомним, с чего мы начинали нашу сборку: lerna bootstrap --hoist Эта команда на самом деле уже создала нам package-lock.json в корне проекта. Однако, это нам мало помогает.

Можете убедиться в этом сами, скопировав в артефакт package.json из packages/application и lock-файл из корня - получите ошибку. Конечно, ведь там никакого намёка не будет на синхронизацию! А в application lock-файла у нас нет. Поэтому:

Попытка 3. Давайте попробуем обойтись без "всплытия". Да, не супер удобно, зато lock-файл будет там где надо. Делаем просто:

lerna bootstrap

Это действительно даст нам по lock-файлу на каждый проект. Но и тут всё не слава Богу! Потому что при попытке с этим файлом сделать npm ci, нам опять скажут нехорошие слова про синхронизацию. Как так?

Изучаем файл package-lock.json и видим.. что там не хватает пакета @contract/core!Ну и правильно, мы его не устанавливали, а делали симлинк...

Попытка 4. Ок, делаем просто npm install внутри каждого пакета. Тут нам поможет:

lerna exec -- npm i

Ура, теперь lock-файл и манифест внутри пакета синхронизированы! npm ci работает! Победа!

Запускаем наше приложение и при первом же запросе...

Оно падает

Говорит, что с модулем @contractчто-то не то. Конечно не то! Ведь npm i поставил этот модуль из npm registry. Ну тогда понятно - это только формально та же версия. А по факту, мы локально могли внести изменения, в том числе и ломающие, но версию ещё не поднимали и пакет не пушили (напоминаю, что build у нас до publish). Если же никаких изменений в пакете не было, то всё сработает.. и это скорее плохо, чем хорошо. Лучше пусть всегда не работает, чем то так, то сяк.

Думали гадали насчёт того, как бы делать publish сначала. Но это не логично - код надо собрать, потом протестировать, потом только паблишить - а он, зараза, не собирается.

Попытка 4. Ну ок, нам нужен симлинк, давайте его сделаем...

Выполняем:

lerna exec -- npm i      # Создаётся lock-файлы в пакетах.lerna link               # Создаются симлинки.lerna run buildlerna publish --conventional-commits ...cp packages/application/build /path/to/artifact# Можно ещё вместо копирования сделать production сборку# - без sourceMaps и деклараций.cp packages/application/package*.json /path/to/artifact(cd /path/to/artifact && npm ci --production)

И оно даже работает! Только мы кое-что забыли.. Добавим вызов jest где-нибудь между 3-й и 4-й строкой...

И внезапно падают уже тесты

Могут конечно и не упасть... особенно, если их нет. Но приложение может работать некорректно. А точнее, не так, как на машине разработчика, где он делает lerna bootstrap --hoist а потом билд.

До этого момента все проблемы были в общем-то тривиальны. Времени много ушло потому, что не было некоторых базовых знаний платформы и инструментов. Сейчас - после нескольких часов проведённых в гугле (хабре, медиуме, гитхабе...) - уже кажется, что всё просто. А вот новая проблема прямо мистическая. И возможно, вы с ней не столкнётесь. Но в чём тут суть, ИМХО понимать полезно.

Итак, lerna bootstrap --hoist и lerna exec -- npm i && lerna link - в чём может быть разница? Ведь второе - это по сути lerna bootstrap, но без --hoist. Пробуем на машине разработчика убрать флаг hoist... тесты падают. Добавляем - проходят.

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

packages+-- @contract|   +-- node_modules|       +-- class-transformer|+-- src|+-- package.json|   ...|+-- application|   +-- node_modules|       +-- class-transformer|       +-- @contract -> символическая ссылка|   +-- src|   +-- package.json|   ...|+-- package.json+-- lerna.json...

На схеме подсказка. И application и contract зависят от пакета class-transformer. Вообще-то, там есть и другие общие зависимости, но, к счастью, не все зависимости ломаются, когда в структуре node_modules они присутствуют в двойном экземпляре.

class-transformer - из тех, что ломается.

Подробнее о том, почему

class-transformer - удобная библиотека для преобразования объектов основанная на декораторах. В nestjs она встроена в дефолтную систему валидации (ValidationPipe). Самый простой пример её использования может быть такой:

import { Type } from 'class-transformer';import { IsInt, IsPositive } from 'class-validator';export class Query {  @IsInt()  @IsPositive()  @Type(() => Number)  id: number;}

При этом мы это посылаем в GET запросе (?id=100500) и получается, что nest на вход получит строчку, а не число. И валидатор IsInt() на это ругнётся (может и нет, но IsPositive() ругнётся 100%).

Поэтому мы говорим несту: преобразуй пожалуйста в число. Декоратор @Type() - самый простой способ. Если я не ошибаюсь, то он сделает просто return Number(id) Для более сложных случаев можно использовать декоратор @Transform() в который можно передать функцию преобразования.

Всё это вы можете найти в доке по class-validator и class-transformer.

Но вот только будте осторожны - функция трансформации НИ В КОЕМ СЛУЧАЕ не должна бросить ошибку. Это положит поток (на горьком опыте - потерянные 3 часа жизни)

Так вот:

Что произойдёт в нашем случае. Когда отработает декоратор @Type(), он он запишет в специальный объект в недрах class-transformer метаданные: "у вот этого класса надо преобразовывать входящее значение в число". Потом, когда объект придёт в ваше приложение nest вызовет функцию plainToClass из той же самой библиотеки, передав туда данные и конструктор Query. Она из того же объекта считает метаданные и проведёт преобразование.

В этом "том же" вся проблема. Если копий библиотеки у нас две, то это может быть два разных объекта и когда plainToClass будет работать, метаданных установленных в декораторе @Type() там не окажется!

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


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

А мы как раз поместили наш класс Query в один пакет, а приложение в другой и если внимательно посмотреть на структуру проекта выше, но становится понятно, что у @contractнет никаких шансов найти нужную копию class-transformer.

Забавно, что у class-validator такой проблемы нет. Возможно, они хранят метаданные иначе (в global?). Как именно ещё не успел посмотреть.

Вот собственно ответ и найден. Получается, из-за того, как работает резолв зависимостей в ноде (ищем в node_modules, потом поднимаемся выше, ищем в node_modules... и так до рута) в случае симлинков нам очень выгоден --hoist. В случае установки из registry, пакетный менеджер сделает примерно (меня пугает это примерно...) то же - поднимет всё, что сможет поднять.

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

Что в итоге?

Чуток устаканив в голове новое понимание (и вопросы), я родил примерно следующее:

  • Когда программист начинает работу над репозиторием (только что слонировав его), он действует по плану:

lerna bootstrap --hoist # Не npm i в корне! Это сломает ваш lock-file!lerna run buildjest# ну и работаем...
  • В CI делаем то же самое, потом lerna publish, а затем:

# Makefile# Получаем версию приложения.BUILD:=build.$(shell jq .version packages/application/package.json | sed 's/"//g')artifact:  # Скрипт build/prod убирает sourceMap'ы и декларации, которые не нужны в продакшене(cd packages/application && npm run build:prod -- --outDir ../../deploy/$(BUILD))cp -r packages/application/package*.json deploy/$(BUILD)  # Ставим только рантайм-зависимости из package-lock.json(cd deploy/$(BUILD) && npm ci --production)    # Я вырезал кое-какие несущественные подробности, типа удаления package*.json и  # создания tar.gz архива.rm deploy/$(BUILD)/package*.jsosdf

Мы используем make, но это не суть. В итоге, это всё планируется перенести в Dockerfile, когда наша инфраструктура будет к этому готова.

  • Как создать корректные lock-файлы, если их нет?

lerna exec -- npm ilerna clean --yes# Именно так. Ставим модули, а потом удаляем. Но это единственный способ получить# lock-файлыlerna bootstrap -- hoist
  • Ну и последнее и, пожалуй, самое неприятное. Как установить новый пакет в наш application (или @contract)таким образом, чтобы сохранить консистентность lock-файлов:

# Makefileadd:# (ОЧЕНЬ ВАЖНО) Здесь обновится только package.jsonlerna add --scope=$(scope) $(package) --no-bootstrap# Обновится package-lock.json внутри пакетаlerna exec --scope=$(scope) -- npm i# node_modules внутри units/application нам не нужны!lerna clean --yes# вернёт нам все зависимости в корень и обновит рутовый package-lock.jsonlerna bootstrap --hoist  # Запускаем так (в scope надо передавать имя пакета из package.json):$ make add scope=app_name package left-pad

Почему так сложно? Потому что команда lerna add не обновляет package-lock.json, а только сам манифест. Не понятно почему. Может быть я не нашёл чего-то. Подскажите...

Выводы:

  • Зависимости в ноде - это сложно.

  • Управление зависимостями в монорепозиториях в условиях CI/CD - это ещё сложнее.

  • Но самое главное, свет в конце всегда туннеля есть! И пока решаешь такого рода заморочки, частенько удаётся поднять хороший пласт новых знаний.

Уверен, что это не последняя итерация. Меня не покидает ощущение, что всё можно сделать проще, чище - буду рад мнениям и идеям в комментариях.

Надо ещё поиграться с командой npm shrinkwrap, например...

Большое спасибо тем, кто дочитал до конца... Если здесь ещё кто-нибудь есть?

Если такой формат "история из практики" интересен, напишите пожалуйста, что "так", что "не так". Потому что историй... их есть у меня.

Спасибо за внимание!

Подробнее..
Категории: Node.js , Ci/cd , Nodejs , Nestjs , Monorepo , Lerna

ArangoDB в реальном проекте

15.03.2021 04:23:51 | Автор: admin

ArangoDB гибридная (документная и графовая) база данных. К ее положительным сторонам относятся:


  • мощный и удобный язык запросов AQL
  • JOIN (даже более мощный чем в реляционных базах данных)
  • репликация и шардинг
  • ACID (в кластере работает только в платной версии)

Из менее существенных, но не менее удобных возможностей:


  • нечеткий поиск
  • встроенный в базу данных движок микросервисов Foxx
  • работа в режиме подписки на изменения в базе данных

Справедливости ради отмечу и недостатки:


  • отсутствие ODM
  • низкая популярность (в сравнении например с MongoDB)

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


Возможности AQL (ArangoDB Query Language)


Один из главных вопросов, который меня волновал, будет ли выразительность AQL достаточной для выполнения всего спектра запросов в реальном приложении. И будет ли работа без ORM/ODM достаточно комфортной.


В ArangoDB есть несколько способов сделать запрос к данным. Есть привычный для тех, кто работает с MongoDB, объектно-ориентированный API, но такой способ в ArangoDB считается устаревшим и основной упор делается на запросы AQL.


Простейший запрос к одной коллекции выглядит так:


db.query({  query: `for doc in managers    filter doc.role == @role    sort doc.@field @order    limit @page * @perPage, @perPage    return doc`,  bindVars: { role, page, perPage, field, order },});

Такой вот интересный язык запросов, построенный на ключевом слове FOR, которое в данном случае не означает перебор всех документов коллекции, если, конечно, по полю role создан индекс.


В большинстве случаев, для работы приложения нужно выбрать связанные объекты из нескольких коллекций. В библиотеке mongoose (MongoDB) для этого используют метод populate(). В ArangoDB это можно сделать одним запросом AQL:


db.query({   query: `      for mall in malls        for city in cities          filter mall.cityId == city._key      return merge(mall, { city })  `,  bindVars: { },});

Это типичный INNER JOIN. Только немного удобнее, так как объект city будет присутствовать в виде вложенного объекта, а не сольётся в список полей, как это происходит в стандартном SQL.


Что касается LEFT JOIN для его реализации нужно использовать подзапросы и ключевое слово LET:


db.query({  query: `    for city in cities      let malls=(        for mall in malls          filter mall.cityId==city._key          return mall      )    return merge(city, {malls})`,  bindVars: { },});

Результирующий объект будет содержать поле malls типа array или значение null. Как Вы можете заметить, есть отличие от LEFT JOIN в стандартном SQL это то, что количество объектов в результирующей коллекции будет равно количеству объектов в коллекции city, и не будет повторяться для каждого значение mall. Вместо этого mall представлено массивом. Я бы сказал, что такой вариант даже более удобен для работы. Получить же "классический" результат, как в SQL, также можно, но запрос будет более сложный.


Я привел самые простые запросы из тех, что есть в реальном приложении. Но, как выяснилось, на базе вышеперечисленных средств можно строить самые сложные запросы, которые не менее, а может быть и более выразительны, чем запросы SQL. При этом умолчу о других документо-ориентированных NoSQL базах данных, где аналогичные запросы просто невозможны.


Графы


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


Я планировал реализовать на граф-ориентированных возможностях дерево категорий товаров. Однако, реализация операций update оказалась неожиданно сложной. Поэтому я отказался от этой идеи. Возможно, просто не нашел еще ключ к этим возможностям.


Нечеткий поиск


Есть такая часто встречающаяся задача: искать текст, если в исходной строке есть опечатки или ошибки. Как правило, для этого используется база данных Elacticsearch. У такого решения есть два недостатка. Во-первых, нужно согласовывать в режиме реального времени значения в основной базе данных и в Elasticsearch. Это непросто, часто эти значения расходятся, и тогда приходится принудительно переиндексировать базу данных. И, во-вторых, Elasticsearch требовательна по ресурсам, что также не всегда приемлемо из соображений финансового порядка.


В последних версиях ArangoDB можно создать SEARCH VIEW в котором можно искать значения с неполным совпадением:


  await db.createAnalyzer('fuzzy_brand_search_bigram', {    type: 'ngram',    properties: { min: 2, max: 2, preserveOriginal: true },    features: ['position', 'frequency', 'norm'],  });  await db.createView('brandSearch', {    links: {      brands: {        includeAllFields: true,        analyzers: ['fuzzy_brand_search_bigram'],      },    },  });

Сам запрос выглядит так:


db.query({    query: `       for brand in brandSearch          search NGRAM_MATCH(              brand.name,               @brandName,               0.4,               'fuzzy_brand_search_bigram'          )          filter brand.mallId == @mallId        return brand `,    bindVars: { mallId, brandName },});

Без ODM?


В своей статье я показал, что по статистике, MongoDB в половине случаев используется без ODM. То есть, это достаточно распространенная практика.


Действительно, сделать запрос, как это было показано выше, гораздо проще средствами AQL, чем определять схему с разными видами связей. Во всяком случае, не было еще ни одного проекта на Sequelize (ORM для реляционных баз данных), где не пришлось бы сделать один-два RAW запроса.


Однако, я, тем не менее, сторонник использования ODM. В своей статье я описал, что я хотел бы от ODM для ArangoDB. ODM не обязательно должна заниматься генерацией запросов в базу данных. Я бы хотел, чтобы ODM обеспечивала сохранение в базу данных только нужных полей, и следила за наличием обязательных полей. А при получении объекта из базы данных типизировала его, добавляла вычислимые поля, фильтровала набор полей для разных групп запросов, и обеспечивала локализацию значений полей.


В настоящее время я нашел всего один фреймвёрк, который очень близок к тому, что я хочу получить: https://github.com/rawmodel/framework. Но мне в нем не хватает двух возможностей. Во-первых для методов типа PATCH входной объект, как правило, содержит не все, а только изменяемые поля. Для таких запросов нужно отключать полные правила валидации. И, во-вторых, там невозможно сделать локализацию значений. Я незамедлительно создал два issue в этом репозитарии. К чести автора, он ответил почти мгновенно, но ответ меня далеко не устроил. По первому вопросу он рекомендовал сначала забирать полный объект из базы данных, а затем мерджить его с объектом с неполным набором полей. По второму порекомендовал локализацию делать на фронтенде.


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


apapacy@gmail.com
15 марта 2021 года

Подробнее..

Монстрация-онлайнстрация

01.05.2021 12:14:42 | Автор: admin

Дело было вечером, делать было нечего.

Я вспомнил, что завтра Первое мая и обычно на него мы идём на Монстрацию - творческий митинг, родом из Новосибирска, куда каждый желающий может прийти со своим плакатом любого содержания.

По известным всем обстоятельствам в этом году Монстрацию не разрешили, о чём я немного взгрустнул и подумал: "А что я могу сделать, как разработчик?".

Короче, развернул сервер, сделал небольшое API для обработки запросов на NodeJS+Express+MongoDB, а затем в Unity 3D сварганил небольшое приложение, в котором создал простенькое окружение, логику взаимодействия с API и пару игровых персонажей: участников митинга и полицию, которая их "охраняет". С контентом мне помогли несколько ребят, сделав модели полицейского УАЗика, Новосибирского оперного театра, основу персонажей.

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

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

Что получилось в итоге можете поглядеть прямо сейчаспо этой ссылке. Работает только для ПК и ноутов, мобильные устройства не поддерживаются.

Сейчас у нас уже 40 участников, думаю какие ещё игровые сценарии туда запихнуть. Мне кажется неплохо получилось для одного вечера, а?

Подробнее..

Спасибо хабравчанам за участие в Онлайнстрации

02.05.2021 20:23:47 | Автор: admin

Ребята, спасибо всем, ктопоучаствовал в мероприятии. Зарегилось 424 человека, получился настоящий творческий первомайский митинг в онлайне.

В честь этого создал специального персонажа!

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

Подробнее..

Screeps, есть ли жизнь после туториала?

15.05.2021 10:13:08 | Автор: admin

Screeps это ММО для програмистов (платное). сделан хаброчанином @artch

Что у вас есть после туториала?

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

четвертый урок туториала, даёт вам все инструменты для апгрейда контроллера. для этого вам достаточно добавить этот код в класс main.js в 21 строку

else {        var upgraders = _.filter(Game.creeps, (creep) => creep.memory.role == 'upgrader');        if(upgraders.length < 2){            var newName = 'Upgrader' + Game.time;            console.log('Spawning new upgrader: ' + newName);            Game.spawns['Spawn1'].spawnCreep([WORK,CARRY,MOVE], newName,                {memory: {role: 'upgrader'}});        }    }

для визуальной картинки, добавляем в main.js (в самый конец функции, она там всего одна)

    var controller = Game.spawns['Spawn1'].room.controller;    controller.room.visual.text('Tick '+Game.time+'\nLevel'+(controller.level+1)+' '+(controller.progress*100/controller.progressTotal)+'% Complete',            controller.pos.x + 1,            controller.pos.y,            {align: 'left', opacity: 0.8});

Видео, как запустить код туториала на локальном сервере.

Видео, добавляем правки

В результате, если не произойдет проблемы очередей, ваши скриперы/апгрейдеры доведут ваш контроллер до второго уровня, как в этом ролике

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

Подробнее..
Категории: Node.js , Nodejs , Tutorial , Screeps

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru