Comments 64
Возможно, кому-то понравятся высказанные в статье подходы, но в тех приложениях, которые я писал, это либо неактуально, либо избыточно.
- То, что можно делать SSR на ноде, создавая сервер-прослойку (BFF) — тема избитая и в корпоративных приложениях используется уже несколько лет.
- Проблема необходимости разного набора данных разным приложениям решается версионированием АПИ, тут ничем BFF не поможет
- API Gateway как маппер, в какой микросервис пойти в зависимости от урла запроса (
https://api.domain.com/ms1/users
|https://api.domain.com/ms2/auth
) и по какому протоколу, действительно можно вынести в BFF, но фронтендерам придется следить за изменением инфраструктуры бэковых сервисов и синхронизироваться. Удобнее все же отдать реализацию этого маппера бэкендерам, в BFF же просто проксируя запросы в единую точку. - То, что бизнес-логика в виде нормализации данных, стейт-машин, синхронизации между различными источниками (офлайн режим, сторонние сервисы), проверка прав есть и на фронтенде — это логично. Делать запрос на бэк "юзер нажал кнопку Восстановить пароль, есть ли у него права для отображения этой страницы?" или "можно ли ему показать кнопку редактирования?" действительно нет смысла, если можно проверить эти права во фронтовом стейте с пермишенами, полученными заранее. Хотя это и выглядит "размазыванием ответственности", в основном этот код обслуживает нужды интерфейса.
- Пример кода на Express, в котором "все слои перемешаны" — дело рук не этого простого, гибкого и удобного инструмента, а разработчика. Конечно, можно написать любую дичь и сказать "поддерживать такой код не хочется", но вот нападки на Express этим не обоснованы. Валидации, обработка ошибок, логирование, метод отправки на фронт (стримами в данном случае), миддлвары обработки запроса, схемы — все это выносится в удобные слои, которые хоть и склеиваются внутри миддлвар в итоге, но позволяют абстрагироваться.
- "протащить созданный экземпляр логгера через всё приложение" можно и через global (ничего страшного в таких микроприложениях не будет) или более удобным декоратором
@useLogger class UserEntity extends EntityClass {}
, для чего дополнительные невидимые контейнеры — не понятно. Это же BFF, простой сервер-рендерер-прокси, а не сложная система с кучей сервисов. - Миддлвары — отличный паттерн, явно определяющий порядок обработки запроса и позволяющий удобно логировать этапы и в любой момент прервать этот поток, выбросив исключение, либо отправив response. При этом структурировать обработку можно как угодно — через мапперы, классы, функциональные контроллеры, схемы обработки, описанные в json, — все зависит от видения разработчика и применяемых подходов. Nest переусложнен и жестковато структурирован для BFF, в котором основной функционал — по схеме роутинга отдать рендер фронтендового фреймворка + проксировать запросы на бэк с соответствующими валидациями и нормализацией + логирование + работа с сессиями. Express идеален для подобных задач благодаря своей простоте и развитой экосистеме.
Ну и как-то я не уловил момент перехода с BFF с простым примером на всяческие UserEntity, домены, сервисы, пайпы, гарды… Это, видимо, когда фронтендеру после создания сервера-прокси пришла мысль "а почему бы весь бэк не переделать на ноду? Я могу!") Ну максимум там будут сервисы хранения сессии пользователей и кэширования. Или BFF был просто вступлением, а суть — в рекламе ролика с гексагональным паттерном для высчитывания остатка денег на счету пользователя? Критикую не ролик, а несоответствие теме статьи и в целом не совсем логичное повествование.
Проблема необходимости разного набора данных разным приложениям решается версионированием АПИ, тут ничем BFF не поможет
В данном случае речь не о поддержке нескольких версий, а об оптимальном наборе данных для каждой страницы/приложения и уменьшении количества запросов, необходимых для получения этих данных. Для примера, на одной странице нам можем понадобится только имя пользователя, на другой нам нужно получить имя, ссылку на аватар и счётчик комментариев. Используя один универсальный эндпоинт мы будем либо вытягивать недостаточно данных и делать дополнительные запросы, либо тащить избыточный набор и нагружать канал.
API Gateway как маппер,, в какой микросервис пойти в зависимости от урла запроса
В нашем случае всегда есть оркестрация, недостаточно иметь простой маппер. Например, авторизация пользователя — это отдельный микросервис. Да, мы стараемся минимально зашивать бизнес-логику, но коммуникаций между микросервисами бэкенда у нас в избытке.
Конечно, можно написать любую дичь и сказать «поддерживать такой код не хочется», но вот нападки на Express этим не обоснованы
Пока мой опыт показывает, что качество кода с переходом на Nest в командах выросло. Абсолютно согласен, что можно писать хорошо на Express, но, к сожалению, чем более низкоуровневое решение, тем больше свободы у разработчика.
Однако я к этому и хотел подвести, что Nest не даст архитектуры, он всего лишь улучшит отдельные моменты. Например, нам удалось построить очень удобную систему распределённого рендеринга (часть страницы рендерится отдельно в отдельном микросервисе), удобно подключаемую через декораторы к контроллерам, что сильно снизило когнитивную нагрузку на разработчика. Код контроллеров остался максимально чистым.
Это же BFF, простой сервер-рендерер-прокси, а не сложная система с кучей сервисов.
Как только бизнес-логика начинает проникать в BFF — исчезает «простой рендер прокси», к сожалению. А в больших проектах она будет проникать.
Миддлвары — отличный паттерн
Но работающий в Express на неконтролируемом мутировании нетипиризованных Request и Response. От этого бывает очень больно.
Nest переусложнен и жестковато структурирован для BFF
Именно этой жесткости нам и не хватало в Express.
Или BFF был просто вступлением, а суть — в рекламе ролика с гексагональным паттерном для высчитывания остатка денег на счету пользователя?
Ролик — просто ответ на возможный вопрос «а как же писать бизнес-логику, не завязанную на фреймворк»? Просто, чтобы не быть голословным.
Хорошие ответы, только не хватает этих оговорок в самом тексте статьи) Нужен контекст, что примененные подходы работают именно в вашем большом проекте, где:
- в BFF и API Gateway проникла бизнес-логика и запутанные взаимосвязи (да, в статье об этом говорится, но как о возможности, а не об опорных исходных данных)
- каждый байт на счету при передаче данных с бэка по апи (очень редкий кейс, характерный только для хайлоада, так как в остальных корп продуктах заботятся о перфомансе выполнения операций, стабильности и унифицированности, но не о наличии некоторых дополнительных данных)
- архитектурные практики недостаточно контролируются, поэтому востребована система с жестким паттерном
Нетипизированные req & res в Express действительно проблема при интенсивном использовании, но никто не заставляет использовать их как хранилища. Так, сессии и кэш лучше держать в Redis, как и дополнительные данные типа traceId запросов, а работа с базой отлично типизируется и стандартизируется, что исключит "неконтролируемые мутации". В Nest же, как вижу, предлагается использовать неявный слой IoC, а про дополнительные данные, привязанные к сессии пользователя, не говорится, так что сравнить не с чем. В целом, не думаю, что это значительный недостаток.
В создании системы распределенного рендеринга участвовал, по сути она состоит из "определить микрофронтенды, которые ответственны за рендеринг страницы" + "ответы от их bff склеить в единый html-документ", эти задачи можно решить массой способов. Наверняка решение "подключить декоратором к контроллеру" продиктовано архитектурой Nest, то есть и в этом случае задействована специфика вашего проекта, в котором жесткие подходы выгоднее свободного архитектурирования. Для максимальной прозрачности удобнее было бы иметь сервис-детектор, который достаточно просто покрывается тестами, а сбор данных произвести через const requiredBffs = detector(currentRoute); const htmlParts = Promise.all(requiredBffs.map(fetch))
через слой кэширования, а затем провести склейку по маркерам. В идеале система должна работать автоматически, а информация о частях, из которых состоит конкретная страница, содержаться в конфиге роутов, что не потребует от разработчиков вообще задумываться об этом механизме и проставлять декораторы. То есть я к тому, что, возможно, жесткие паттерны в данном случае ухудшили экспириенс, а не улучшили)
И "как же писать бизнес-логику" в BFF — тоже контекстуальная тема, которая не должна затрагивать большинство корп проектов. Когда фронтовый бэк из рендерера для seo и ускорения первой отрисовки превращается в толстый бэк с кучей наворотов, пора бы задуматься, туда ли все повернуло, и не стоит ли остальную логику вынести на "хардкорный" бэк. Так что логичнее было бы вынести это в отдельную статью, сравнив несколько паттернов организации сущностей и взаимодействия между ними, ибо в итоге раздел "архитектурные слои" получился очень немногословным и смятым
В Nest же, как вижу, предлагается использовать неявный слой IoC, а про дополнительные данные, привязанные к сессии пользователя, не говорится, так что сравнить не с чем.
Да, в Nest и правда очень сильно не хватает возможности привязать данные к сессии пользователя. Это решается созданием сервиса в скоупе реквеста (IoC даёт это из коробки), но это довольно дурнопахнущее решение. Другой вариант — использовать CLS (например cls-hooked, либо недавно приземлённые в ноду AsyncLocalStorage)
Так что логичнее было бы вынести это в отдельную статью, сравнив несколько паттернов организации сущностей и взаимодействия между ними, ибо в итоге раздел «архитектурные слои» получился очень немногословным и смятым
Ваша правда. Для того и пишем, чтобы учиться доносить мысли лучше. Теперь сам вижу, что скомкано вышло :)
Мидлвары — сломанная версия паттерна "цепочка ответственности" развращающая не знакомых с SOLID, GRASP и GoF при помощи шаренного состояния и сайдэффектов.
Исключения не так страшны (в конце концов, если не упираться в идеологическую чистоту, это дело вкуса), как сложности с их отловом в Js/ts по конкретному типу. У меня костыль с этаким функциональным аналогом try-catch (+async) с поиском ближайшего прототипа и типизацией на дженериках.
Но, конечно, из обработчика команды выкидывать исключение совсем не айс, тут или Either или result object по типу dto.
Вот что действительно печалит — так это отсутствие нормальной ORM, позволяющей работать с rich models и DDD aggregate roots. Typeorm вообще написан с анемичными моделями как единственным подходом в голове разработчика, mikroorm — более верное направление, но гадит в прототип сущности вместо использования прокси и не умеет ембеддаблы. А на самом деле нужен в основе прежде всего хороший маппер, по типу автомапперов в c#. Хоть сам пиши.
Исключения не так страшны (в конце концов, если не упираться в идеологическую чистоту, это дело вкуса), как сложности с их отловом в Js/ts по конкретному типу
Да, если бы мы могли как в Java описывать все возвращаемые исключения по типам — жизнь была бы намного лучше.
Вот что действительно печалит — так это отсутствие нормальной ORM, позволяющей работать с rich models и DDD aggregate roots.
К счастью, BFF перекладывает всю ответственность по работе с БД на плечи бэкенда. Боль очень понимаю, но сам не испытываю.
Бэкенд-то тоже на чем-то писать надо. :)
BFF у меня как бы есть (спасибо, теперь я знаю это слово, называл это просто SSR backend), но настолько микроскопический, что вообще все очевидно, NestJS прекрасно подходит, если думать не инструкцией, а головой (завязывать все слои на фреймворк — очевидная глупость, а для http application слоя там все ок).
GraalVM и прочие экзотические решения проигрывают V8 в производительности и слишком специфичны.
Привет! А можно немного по подробнее, в чем GraalVM проигрывает, и на каких тестах?
Интересно, что там за год ребята сделали, и какой у них прогресс. Завтра как раз можно будет это спросить :)
www.youtube.com/watch?v=mYg3f-117Zc
По поводу исключений вопрос: промисовые режекты — в некотором смысле аналоги исключений. Является ли "искусственный" режект плохой практикой?
Промис не нарушает контракт. Мы говорим, что наша функция возвращает промис и она его возвращает. Конечно, мы всё ещё сталкиваемся с проблемой статического анализа обработки хорошего и плохого пути (потребитель может не обработать catch), но отловить это намного проще. Но async/await ломает всю эту красоту и мы снова вынуждены писать try/catch. Тут уже вступает в игру result-контейнер.
Одна из базовых характеристик отказоустойчивых систем — явно определенные потоки ошибок, которые попадают в соответствующие перехватчики. То есть, к примеру, при работе с апи бэка можно использовать такой флоу:
function authUser({ store, api, actions }, { login, password }) {
return Promise.resolve()
.then(() => api.postUserData({ login, password }))
.then(({ userInfo }) => (store.user = userInfo))
.catch(error => {
if (error.name === "SOLVED") return;
if (error.name === "INVALID_LOGIN") {
return actions.pushFormValidators({
password: { value => value === login, message: 'Пользователя не существует' }
})
}
return actions.showNotification('Неизвестная ошибка')
})
}
При этом обработка HTTP-ошибок будет производиться в api-слое, то есть имеются следующие сценарии:
- при 404/403 метод
api.postUserData
сам выдаст нотификацию и вернетPromise.reject(Err.SOLVED)
, таким образом обработчик выше не будет предпринимать действий. - при 402 (ошибка валидации или формата данных, присланная бэком) отдаст
Promise.reject(Err[response.errorName])
, обработчик подсветит эту ошибку в форме и не даст пользователю второй раз ввести некорректные данные - при неизвестной ошибке в самом экшене authUser (например, опечатка
stAre.user = userInfo
) либо при багах кода в процессе запроса, ошибка попадет в последнюю строчку кэтчера. Этот же сценарий в случае, если бэк вернет неизвестную константу на фронт вместо "INVALID_LOGIN", то есть при ошибке в контракте общения.
Таким образом явно определяются потоки всех возможных ошибок и стратегии их обработки (можно и дополнительные слои выносить, я привел базовый пример), четко ограничивается скоуп их распространения и исключается влияние на остальной функционал приложения. В целом, хотя при throw вместо Promise.reject будет работать так же, для типизации+семантичности все же лучше использовать Promise.reject, тут я с автором согласен, для предсказуемых "ошибок", которые по сути являются перенаправлением в другой блок обработки промиса (catch вместо then). Но учитывать возможность возникновения неожиданных исключений всегда необходимо, то есть, если заменить reject на throw, должно работать так же, иначе может возникнуть ситуация, что при выполнении действия ломается вся страница или большая часть функционала (очень часто встречаю это при работе).
Насколько это оправданные опасения и если речь о действительно больших приложениях и большом количестве запросов, то справится ли BFF слой с нагрузкой?
Т.е. с большим количеством запросов года справится, если на эти запросы не будет навешено тяжёлой работы, особенно синхронной.
, если на эти запросы не будет навешено тяжёлой работы, особенно синхронной.
На самом деле сложно представить какую либо длительную синхронную операцию. В теории она существует, но найти реальный кейс вряд ли возможно. Какая синхронная задача может занять более 5-10мс, которая бы повлияла на отзывчивость для других клиентов? Даже если это какие то тяжелые вычисления (которые почему то написаны на JS) всегда можно протащить через ивентлуп, вместо рекурсии например. С асинхронными функциями теперь это весьма просто, только пару строк чтобы следить за тиканьем ивентлупа. Ну а дальше балансировщик сделает свое дело.
Разбивать тяжёлые операции на чанки хорошее решение, про которое зачастую забывают.
Даже как то удивительно, что ее так мало юзают другие, я в чужих реальных проектах этого не видел, а ведь банальный подход.
мы всё равно будем вынуждены делать все операции в том же потоке.
Да, но с точки зрения клиентов им есть ли разница в каком потоке на сервере был обработан их запрос? Им интересна только задержка ответа. Если речь о делегировании работы другому потоку (основной получил, передал данные воркеру ожидает ответа, попутно обрабатывая другие запросы в евентлупе) то это ничем не отличается от того, если бы запрос пришел другому инстансу через балансир, только еще одно лишнее звено (балансир->инстанс1->воркер1). Если речь что один запрос можно параллельно обработать- половину задачи одним потоком обработать, пока второй решает вторую половину, то тут уже вылазит проблемы синхронизации потоков и зависимостей данных, т.е задачи могут плохо распараллеливается если следующие вычисления базируются на предыдущих. Но к этому надо добавить оверхед на передачу и получения данных воркерам. А ведь даже тяжелые запросы это всего миллисекунды. Суть ведь равномерно загрузить ядра/цп, чтобы потоки не гуляли, при этом обеспечить допустимую задержку отклика для каждого запроса. Какие то феншуи делать для размазывания по воркерам запросы в миллисекунды как бы бессмысленно для большинства задач в вебе.
само по себе дробление это не бесплатная операция, мы забиваем очереди event loop.
В чем разница? 10 инстансов и 1 инстанса + 9(10) воркеров? Воркеры тоже не бесконечны- не на каждый ведь запрос можно выделить свой воркер, так что будем иметь тоже самое узкое горлышко, только в другом потоке. Дробить задачи так чтобы каждый чанк не исполнялся более пары мс не забьет очередь, чтобы существенно отразилось на производительности. Трансфер данных воркерам тоже не бесплатно, и вероятно дороже.
Трансфер данных воркерам тоже не бесплатно, и вероятно дороже.
По IPC да, но, к счастью, теперь у нас есть worker threads с более дешёвой коммуникацией через shared memory.
и лекции:
Сравнение некорректное. Java это язык а нода это рантайм (конкретная реализация взаимодействия с IO операционной системы). И скорость ноды и этой хваленый event loop и т.д — это все заслуга системного IO операционной системы — например на линуксе это системный вызов epoll_wait(..) — который позволяет одним потоком обслуживать много сокетов. Это значит что аналогичную эффективность NodeJS (скорости работы с io и обслуживание одним потоком многих tcp/http запросов) можно получить на всех популярных языках — не только на java но и например на php — достаточно всего лишь прокинуть системные вызовы epoll_wait/epoll_create/… в интерпретатор или компилятор языка.
Но уточнение резонное, хорошая поддержка асинхронного I/O была сильной стороной node.js только в момент появления на рынке, сейчас все остальные решения так же подтянулись и могут предложить свой вариант async I/O.
Но как фронтендер я не могу тут спорить — опыта не хватит.
Одобряю! Почти полностью совпадает с моими 3 весенними вебинарами на jsfwdays: https://fwdays.com/en/event/node-js-in-2020
Кроме одного: декораторы — зло, nest.js сделал большую работу по уничтожению центра говнокода (express) и но он умрет из-за TypeScript.
Но что делать, все реализации IoC-контейнеров для ts построены на легаси-декораторах.
А сейчас я делаю возможность для impress писать не только js методы, но ts и AssemblyScript. При этом, все метаданные про методы описываются декларативно, пример:
({
access: 'public',
method: async ({ login, password }) => {
const user = await application.auth.getUser(login);
const hash = user ? user.password : undefined;
const valid = await application.security.validatePassword(password, hash);
if (!user || !valid) throw new Error('Incorrect login or password');
console.log(`Logged user: ${login}`);
return { result: 'success', userId: user.id };
}
});
а код методов может выглядеть очень кратко:
async ({ countryId }) => {
const fields = ['Id', 'Name'];
const where = { countryId };
const data = await application.db.select('City', fields, where);
return { result: 'success', data };
};
или даже
({ countryId, minArea, maxArea }) => application.db
.select(['cityId', 'cityName', 'population'])
.from('locality')
.where({
countryId,
lacalityType: 'city',
population: { '>': 1000000 },
area: { between: [minArea, maxArea] },
})
.timeout(5000)
.cache(2000)
.paging(100);
Уже сейчас есть: подгрузка изменений с hd без перезагрузки процессов, утилизация CPU через потоки, очередь запросов на вход в API, авто балансировка и куча всего. В стартер ките все это поместилось в 25кб без зависимостей (для ознакомительных с внутренностями целей), а в impress все так же, но с оптимизациями и распилено на npm-модули. Можно посмотреть архитектуру в стартер-ките, но платформа будет готова только к концу года, с прозрачным масштабированием стейтфул приложений.
({
Category: { category: 'Category', index: true },
StorageKind: { domain: 'StorageKind', required: true, index: true },
Status: { domain: 'IdStatus', required: true, index: true },
Creation: { domain: 'DateTime', required: true },
Change: { domain: 'DateTime', required: true },
Lock: { domain: 'Logical', required: true, default: false },
Version: { domain: 'Version', required: true, default: 0 },
Checksum: { domain: 'SHA2', required: true },
CheckCategory: Validate(record => {
if (record.Status === 'Actual' || record.Status === 'Historical') {
return !!record.Category;
} else {
return !record.Category;
}
})
});
Еще примеры: github.com/metarhia/globalstorage/tree/master/schemas/system
И, второй вопрос, как поступать в случае контейнеризации? Положить один «большой» процесс с кучей worker_threads в контейнер и поставить балансировщик? Мы хотели двигаться в несколько иную сторону, выделяя каждый процесс (воркер) в отдельный контейнер и масштабируясь контейнерами. Схожий подход описан в Node.js Design Patterns, даже в самой свежей её редакции.
Я предлагаю масштабироваться при помощи тредов открывая по 1 порту в каждом, а балансировать в отдельном Auth-сервисе, при входе или первом контакте клиент получает токен и несколько точек подключения host:port, подключается к первой по токену и попадает в свой родной тред. При чем, я преимущественно во всех проектах использую вебсокеты для веба и TLS для мобильных (дальше планирую на http3 переходить). Если связь пропадает, то параллельно пробуем переподключиться к основному и запасным точкам подключения, если все открылись, то берем предпочтительную (основную) если основной сокет не подключается, то переходим на запасные. Таким образом не нужно весь трафик пропускать через один балансировщик, а распределение происходит только при создании сессиий.
Контейнеры: на моих проектах мы делаем 1 контейнер на машину, внутри контейнера масштабирование тредами. Машины у нас железные, но и для виртуалок я бы такую же схему использовал.
Готового решения я сейчас не могу предоставить, но целая группа в которой много контрибьютеров ноды, работает над технологическим стеком Метархия, который будет готов для продакшена к концу года, в нем масштабирование будет решено из коробки. А пока можно смотреть прототип в моем Статрет ките и помогать нам все это быстрее привести в порядок.
Немного уточню по graphql. Я не воспринимаю его как монолит который общается с базой данных. Как раз resolver в graphql может брать данные откуда угодно. В тосхм числе из базы данных напрямую, из других микросервисов, такой себе роутер.
Если фронтенда разработчики пишут запросы на axios к микросервисов. То почему они не могут писать такие же запросы на том же axios к тем же микросервисов но только из резольверов. Это же не более мудрено чем тот же redux
Почему вы так говорите? Где вы видите реализацию?
В импорте класса реализации. Мы прошиваем высокоуровневые политики знанием о низкоуровневых. Да, внутри одного слоя мы можем пользоваться прямыми импортами классов, но между слоями такого быть не должно.
Где AbstractFooService — это класс, который используется как интерфейс.
Я же привожу в статье точно такой же пример решения через абстрактный класс.
Это похоже на отсебятину, не проверенную ни временем, ни обсуждениями хотя бы на хабре.
Я привожу ссылку на литературу, ту же самую, на которую ссылается и Фаулер. Речь не идёт о Java, проблема именно в выбрасывании исключения как средстве управления поведением кода.
Не представляю о чем идет речь, когда вы говорите «ни JavaScript, ни TypeScript не дают гарантий, что исключение будет обработано.»
В отличие от Java мы не можем гарантировать, что потребитель нашего кода напишет обработчик наших исключений. Мы никак не можем декларировать, что наш код выбрасывает исключения определённого типа и они должны быть декларированы.
Например, почему бы сервисы, интерсепторы или гарды не отнести к Domain (то есть к бизнес логике)?
Потому что в этом случае фреймворк проникнет в домен. Это усложнит написание тестов, это прошьёт код фреймворком и мы не сможем относительно дёшево сбежать с Nest в случае проблем с теми же декораторами.
почему вы считаете, что задача разработчиков фреймворков «навсегда привязать вас к себе»?
Достаточно послушать доклады разработчиков. Для них выгодно, чтобы мы оставались потребителем фреймворка, пусть несознательно, но всегда будут гнуть в эту сторону.
По-вашему, как можно «удобно организовать логику уровня приложения» не используя инструментов фреймворка?
Я привожу пример с реализацией гексагонального паттерна, когда бизнес-логика полностью отделена от фреймворка.
В целом не я придумал этот подход, о нём постоянно говорит тот же Роберт Мартин. Фреймворк должен быть всего лишь заменяемой деталью вашей архитектуры.
Архитектура современных корпоративных Node.js-приложений