Исходный код, разобранный в этой статье, опубликован в этом репозитории
Микросервисная архитектура, понятная ООП-разработчикам
Крупные приложения пишутся в Domain Driven Design. Частный случай этой архитектуры — Model View Controller в монолите. Этому учат в университетах, и найти специалистов просто. Однако для обработки высоких нагрузок нужны микросервисы. Найти хороших специалистов, которые могут поддерживать ООП-код в микросервисах, а не процедурный код, сложно.
Для решения проблемы процедурного кода в микросервисах был разработан стартовый набор для масштабируемого NodeJS микросервиса в монорепозитории.
Почему не tRPC
Необходимость сохранить возможность писать сервисы на Golang
В будущем должна быть возможность переписать высоконагруженные участки кода на компилируемом языке вроде golang
, чего tRPC не позволяет
Роутер как анти-паттерн в микросервисной архитектуре
Использование паттерна роутера для навигации по вызовам микросервисов приведёт к форку git-репозитория для создания групп микросервисов, где код некоторых сервисов будет перенесен копипастой
Бесполезные yum валидации
Аналогично prop-types
в React, предпочтительный способ объявить контракт — через interface
аргументов, так как декларативно описанная статическая проверка типов во время компиляции значительно проще портируется на другой язык программирования
Частичный перезапуск серверного приложения
Подход gRPC децентрализован. Поскольку нет единой точки входа, это позволяет избежать узкого места в производительности. Например, если основной сервер tRPC упал, придётся перезапускать все микросервисы. В gRPC хост-приложение и все сервисы могут перезапускаться отдельно. Также можно использовать YAML Engineer для декларативного описания стратегии проксирования запросов, например политики повторных попыток
Маппинг методов класса вместо удалённых процедур
При работе с tRPC вы будете использовать switch-case в удалённой процедуре для маппинга метода класса, используя табличную функцию с типом действия. Это лишний шаблонный код, проще предоставить экземпляр класса для маппинга методов и сделать процесс автоматическим
Решённые проблемы
Работа с gRPC через TypeScript
По состоянию на 2016 год не было разделения между модулями commonjs
и esm
и TypeScript, поэтому proto-файлы предлагалось конвертировать в js с сомнительным содержимым. В этом starter kit
архитектура предполагает доступ через sdk объект с поддержкой IntelliSense
, проблема генерации d.ts
из proto
решена js-скриптом без нативного бинарника. Любое взаимодействие между микросервисами осуществляется через вызов метода интерфейса целевого класса и класса-обёртки.
Запуск бэкенда без docker через
npm start
Иногда нужен доступ к js-файлам без изоляции, чтобы проинспектировать их отладчиком или добавить console.log
в уже транспилированный бандл. Для запуска микросервисов используется PM2, что упрощает доступ к коду программистом.
Единый источник ответственности за операции с базой данных
Для операций с базой данных лучше использовать луковичную архитектуру Model View Presenter, где слой представления организует маппинг и логирование данных, а слой сервисов базы данных предоставляет абстракцию от СУБД. Проблема масштабируемости этого паттерна решается перемещением кода в общий модуль; упрощённо, каждый микросервис хостит копию монолита.
Выполнение методов микросервиса без Postman
Хост-приложения, взаимодействующие с сервисами через gRPC, находятся в папке apps
. Было создано два приложения: apps/host-main
и apps/host-test
, первое с веб-сервером, во втором можно писать произвольный код и запускать его командой npm run test
. Также в apps/host-test
можно писать модульные тесты, если нужно вести разработку тестированием.
Автоматическое обнаружение не-SOLID кода с помощью языковых моделей
Если ненадёжный сотрудник пишет код, не соответствующий принципам SOLID, нейронная сеть может объективно оценить область ответственности класса. В этом стартовом наборе при транспиляции сервиса типы экспортируются в файлы types.d.ts
, которые используются для анализа назначения каждого класса в библиотеке или микросервисе и автоматического документирования его в человекочитаемой форме, по пара абзацев текста на класс с аудитом.
Упрощение взаимодействия микросервисов
1. Шаблонный код для работы gRPC громоздкий. Создание gRPC клиента и сервера вынесено в общий код, код приложения запускает микросервис в одну строку
syntax = "proto3";
message FooRequest {
string data = 1;
}
message FooResponse {
string data = 1;
}
service FooService {
rpc Execute (FooRequest) returns (FooResponse);
}
Есть proto
-файл, описывающий FooService
с методом Execute
, который принимает объект со строкой data
в качестве одного аргумента.
export class FooClientService implements GRPC.IFooService {
private readonly protoService = inject<ProtoService>(TYPES.protoService);
private readonly loggerService = inject<LoggerService>(TYPES.loggerService);
private _fooClient: GRPC.IFooService = null as never;
Execute = async (...args: any) => {
this.loggerService.log("remote-grpc fooClientService Execute", { args });
return await this._fooClient.Execute(...args);
};
protected init = () => {
this._fooClient = this.protoService.makeClient<GRPC.IFooService>("FooService")
}
}
Файлы *.proto
конвертируются в *.d.ts
скриптом scripts/generate-dts.mjs
(генерирует пространство имён GRPC
), затем пишется обёртка для уточнения типов на стороне TypeScript.
import { grpc } from "@modules/remote-grpc";
export class FooService {
Execute = (request: any) => {
if (request.data !== "foo") {
throw new Error("data !== foo")
}
return { data: "ok" }
}
}
grpc.protoService.makeServer("FooService", new FooService);
Затем gRPC сервис шарит методы класса в одну строку. Методы возвращают Promise
, мы можем использовать await
и выбрасывать исключения, кроме @grpc/grpc-js
, не нужно работать с callback hell.
import { grpc } from "@modules/remote-grpc";
import test from "tape";
test('Except fooClientService will return output', async (t) => {
const output = await grpc.fooClientService.Execute({ data: "bar" });
t.strictEqual(output.data, "ok");
})
2. Взаимодействие с базой данных (MVC) вынесено в общий код и доступно из хост-приложения, сервисов и других библиотек
export class TodoDbService {
private readonly appwriteService = inject<AppwriteService>(TYPES.appwriteService);
findAll = async () => {
return await resolveDocuments<ITodoRow>(listDocuments(CC_APPWRITE_TODO_COLLECTION_ID));
};
findById = async (id: string) => {
return await this.appwriteService.databases.getDocument<ITodoDocument>(
CC_APPWRITE_DATABASE_ID,
CC_APPWRITE_TODO_COLLECTION_ID,
id,
);
};
create = async (dto: ITodoDto) => {
return await this.appwriteService.databases.createDocument<ITodoDocument>(
CC_APPWRITE_DATABASE_ID,
CC_APPWRITE_TODO_COLLECTION_ID,
this.appwriteService.createId(),
dto,
);
};
update = async (id: string, dto: Partial<ITodoDto>) => {
return await this.appwriteService.databases.updateDocument<ITodoDocument>(
CC_APPWRITE_DATABASE_ID,
CC_APPWRITE_TODO_COLLECTION_ID,
id,
dto,
);
};
remove = async (id: string) => {
return await this.appwriteService.databases.deleteDocument(
CC_APPWRITE_DATABASE_ID,
CC_APPWRITE_TODO_COLLECTION_ID,
id,
);
};
};
...
import { db } from "@modules/remote-db";
await db.todoViewService.create({ title: "Hello world!" });
console.log(await db.todoRequestService.getTodoCount());
Используется сервер приложений Appwrite, обёртка над MariaDB, которая предоставляет метрики количества запросов, учёт дискового пространства, авторизации OAuth 2.0, резервное копирование и шину событий websocket.
Упрощение разработки
Критическая проблема микросервисной архитектуры — интегрируемость (IDE - Integrated development environment): программистам сложно подключить отладчик, обычно новички отлаживают через console.log
. Это особенно заметно, если код изначально работает только в docker.
В дополнение к основному хост-приложению apps/host-main
(веб-сервер REST API) сделана точка входа apps/host-test
для разработки через тестированием. Она не использует среду выполнения тестов, другими словами, мы можем напрямую вызвать обработчик микросервиса или метод контроллера базы данных без postman в условном public static void main()
. Уже добавлен ярлык npm run test
, который компилирует и запускает приложение. Также можно перейти в любую папку сервиса или хоста и запустить npm run start:debug
.
Упрощение развёртывания
Используя Lerna, компиляция и запуск проекта выполняются одной командой через npm start
(параллельная сборка). Хотите пересобрать — запустите команду снова. Хотите запустить вновь написанный код — выполните npm start && npm run test
. Среда для запуска проекта будет установлена автоматически после npm install
благодаря скрипту postinstall
.
{
"name": "node-grpc-monorepo",
"private": true,
"scripts": {
"test": "cd apps/host-test && npm start",
"start": "npm run pm2:stop && npm run build && npm run pm2:start",
"pm2:start": "pm2 start ./config/ecosystem.config.js",
"pm2:stop": "pm2 kill",
"build": "npm run build:modules && npm run build:services && npm run build:apps && npm run build:copy",
"build:modules": "dotenv -e .env -- lerna run build --scope=@modules/*",
"build:apps": "dotenv -e .env -- lerna run build --scope=@apps/*",
"build:services": "dotenv -e .env -- lerna run build --scope=@services/*",
"build:copy": "node ./scripts/copy-build.mjs",
"docs": "sh ./scripts/linux/docs.sh",
"docs:win": ".\\scripts\\win\\docs.bat",
"docs:gpt": "node ./scripts/gpt-docs.mjs",
"postinstall": "npm run postinstall:lerna && npm run postinstall:pm2",
"postinstall:lerna": "npm list -g lerna || npm install -g lerna",
"postinstall:pm2": "npm list -g pm2 || npm install -g pm2",
"proto:dts": "node ./scripts/generate-dts.mjs",
"proto:path": "node ./scripts/get-proto-path.mjs",
"translit:rus": "node ./scripts/rus-translit.cjs"
},
Для автоматического перезапуска микросервисов и хостов при ошибке используется менеджер процессов PM2. Он предоставляет crontab из коробки, что удобно, так как не нужно настраивать его со стороны ОС.
const dotenv = require('dotenv')
const fs = require("fs");
const readConfig = (path) => dotenv.parse(fs.readFileSync(path));
const appList = [
{
name: "host-main",
exec_mode: "fork",
instances: "1",
autorestart: true,
max_restarts: "5",
cron_restart: '0 0 * * *',
max_memory_restart: '1250M',
script: "./apps/host-main/build/index.mjs",
env: readConfig("./.env"),
},
];
const serviceList = [
{
name: "baz-service",
exec_mode: "fork",
instances: "1",
autorestart: true,
max_restarts: "5",
cron_restart: '0 0 * * *',
max_memory_restart: '1250M',
script: "./services/baz-service/build/index.mjs",
env: readConfig("./.env"),
},
{
name: "bar-service",
exec_mode: "fork",
instances: "1",
autorestart: true,
max_restarts: "5",
cron_restart: '0 0 * * *',
max_memory_restart: '1250M',
script: "./services/bar-service/build/index.mjs",
env: readConfig("./.env"),
},
{
name: "foo-service",
exec_mode: "fork",
instances: "1",
autorestart: true,
max_restarts: "5",
cron_restart: '0 0 * * *',
max_memory_restart: '1250M',
script: "./services/foo-service/build/index.mjs",
env: readConfig("./.env"),
},
];
module.exports = {
apps: [
...appList,
...serviceList,
],
};
Упрощение логирования
Как видно в ProtoService, все вызовы gRPC логируются, включая аргументы и результаты выполнения или ошибки.
{"level":30,"time":1731179018964,"pid":18336,"hostname":"DESKTOP-UDO3RQB","logLevel":"log","createdAt":"2024-11-09T19:03:38.964Z","createdBy":"remote-grpc.log","args":["remote-grpc fooClientService Execute",{"args":[{"data":"foo"}]}]}
{"level":30,"time":1731179018965,"pid":18336,"hostname":"DESKTOP-UDO3RQB","logLevel":"log","createdAt":"2024-11-09T19:03:38.965Z","createdBy":"remote-grpc.log","args":["remote-grpc protoService makeClient calling service=FooService method=Execute requestId=rbfl7l",{"request":{"data":"foo"}}]}
{"level":30,"time":1731179018984,"pid":18336,"hostname":"DESKTOP-UDO3RQB","logLevel":"log","createdAt":"2024-11-09T19:03:38.984Z","createdBy":"remote-grpc.log","args":["remote-grpc protoService makeClient succeed service=FooService method=Execute requestId=rbfl7l",{"request":{"data":"foo"},"result":{"data":"ok"}}]}
{"level":30,"time":1731179018977,"pid":22292,"hostname":"DESKTOP-UDO3RQB","logLevel":"log","createdAt":"2024-11-09T19:03:38.977Z","createdBy":"remote-grpc.log","args":["remote-grpc protoService makeServer executing method service=FooService method=Execute requestId=7x63h",{"request":{"data":"foo"}}]}
{"level":30,"time":1731179018978,"pid":22292,"hostname":"DESKTOP-UDO3RQB","logLevel":"log","createdAt":"2024-11-09T19:03:38.978Z","createdBy":"remote-grpc.log","args":["remote-grpc protoService makeServer method succeed requestId=7x63h",{"request":{"data":"foo"},"result":{"data":"ok"}}]}
Логи записываются с ротацией. Когда файл debug.log
достигает лимита в 100Мб, он будет сжат в 20241003-1132-01-debug.log.gz
. Дополнительно вы можете писать свои логи, используя pinolog.
Упрощение документации
Разработка предполагает использование функционального программирования в host
приложениях и объектно-ориентированного программирования по принципам SOLID в сервисах и общем коде. В результате:
Код в классах
Есть внедрение зависимостей
Файлы rollup.config.mjs
создают types.d.ts, содержащие объявления классов. Из них генерируется API Reference в формате markdown. Затем markdown-файлы обрабатываются нейронной сетью Nous-Hermes-2-Mistral-7B-DPO, которая возвращает результат в человекочитаемом виде.
# remote-grpc
## ProtoService
ProtoService is a TypeScript class that serves as an interface for managing gRPC services. It has a constructor, properties such as loggerService and _protoMap, and methods like loadProto, makeClient, and makeServer. The loggerService property is used for logging, while _protoMap stores the protobuf definitions. The loadProto method loads a specific protobuf definition based on the provided name. The makeClient method creates a client for the specified gRPC service, while makeServer creates a server for the specified gRPC service using a connector. The available services are "FooService", "BarService", and "BazService".
## LoggerService
The LoggerService is a TypeScript class that provides logging functionality. It has a constructor which initializes the `_logger` property, and two methods: `log()` and `setPrefix()`.
The `_logger` property is a variable that stores the logger instance, which will be used for logging messages. The `log()` method is used to log messages with optional arguments. The `setPrefix()` method is used to set a prefix for the log messages.
## FooClientService
The `FooClientService` is a TypeScript class that implements the `GRPC.IFooService` interface, which means it provides methods to interact with a gRPC service. The class has three properties: `protoService`, `loggerService`, and `_fooClient`.
The constructor of `FooClientService` does not take any arguments.
The `protoService` property is of type `any`, and it seems to hold the protobuf service definition.
The `loggerService` property is of type `any`, and it appears to be a logger service for logging messages.
The `_fooClient` property is of type `any`, and it seems to be a client for communicating with the gRPC service.
The `Execute` method is a generic function that takes any number of arguments and returns a Promise. It is used to execute the gRPC service methods.
The `init` method is a void function that initializes the `_fooClient` property.
Overall, `FooClientService` is a class that provides methods to interact with a gRPC service, using the protobuf service definition and a logger for logging messages. It initializes the gRPC client and provides a generic `Execute` method to execute the gRPC service methods.
Если изменить промпт, можно получить аудит, соответствует ли каждый класс в коде принципам SOLID
Как начать разработку
Настройте окружение
cp .env.example .env
npm install
npm start
Откройте файл modules/remote-grpc/src/config/params.ts. Добавьте микросервис, определив порт, который он будет использовать.
export const CC_GRPC_MAP = {
"FooService": {
grpcHost: "localhost:50051",
protoName: "foo_service",
methodList: [
"Execute",
],
},
// Добавляйте здесь
...
Затем, следуя паттерну Dependency Injection, добавьте тип сервиса в modules/remote-grpc/src/config/types.ts, экземпляр сервиса в modules/remote-grpc/src/config/provide.ts и внедрение в modules/remote-grpc/src/services/client.
const clientServices = {
fooClientService: inject<FooClientService>(TYPES.fooClientService),
barClientService: inject<BarClientService>(TYPES.barClientService),
bazClientService: inject<BazClientService>(TYPES.bazClientService),
// Добавляйте здесь
};
init();
export const grpc = {
...baseServices,
...clientServices,
};
Далее скопируйте папку services/foo-service и используйте её как основу для реализации вашей логики. Взаимодействия с базой данных должны быть перенесены в modules/remote-db, следуя тому же принципу. Не забывайте о логировании в LoggerService - каждый метод слоя view
должен логировать имя сервиса, имя метода и аргументы.
Спасибо за внимание!
Комментарии (12)
minaevmike
14.11.2024 07:40Если честно, то ощущение что у автора смешалось все в кучу, и он сам не особо понимает о чем пишет.
Подход gRPC децентрализован
В чем заключается децентрализация? Что такое высокая нагрузка? Выглядит как преждевременная оптимизация сайта личного блога.
tripolskypetr Автор
14.11.2024 07:40Децентрализация, в данном контексте, подразумевает декомпозицию кода без единой точки входа и способ взаимодействия сервисов между собой напрямую, а не через роутер между ними
1. Каждый сервис gRPC занимает свой обособленный порт. Как следствие, сервисы можно включать и выключать по отдельностиexport const CC_GRPC_MAP = {
"FooService": {
grpcHost: "localhost:50051",
protoName: "foo_service",
methodList: [
"Execute",
],
},
"BarService": {
grpcHost: "localhost:50052",
protoName: "bar_service",
methodList: [
"Execute",
],
},"BazService": {
grpcHost: "localhost:50053",
protoName: "baz_service",
methodList: [
"Execute",
],
},
} as const;
2. Proto файлы пишутся по отдельности. Нет единого файла, где на каждый новый добавленный разными разработчиками метод сервиса будет конфликт слияния веток
├───proto
│ bar_service.proto
│ baz_service.proto
│ foo_service.proto
tripolskypetr Автор
14.11.2024 07:40По поводу нагрузки, разумеется, что условный Яндекс будет использовать Kafka. Если выбор стоит между gRPC и tRPC, очевидно, что это некоторое коробочное решение, поставляемое среднем бизнесом на условный завод. Не особо крупный, но достаточно большой
tripolskypetr Автор
14.11.2024 07:40Во поводу внешнего вида, это деперсонализированное решение для не подпадания под NDA, уже используемое в коммерческом продукте
radist2s
14.11.2024 07:40Я не встречал ничего более неудобного для фронта, чем gRPC. Удачи писать моки на классах, и дебаг бинарных данных во вкладке Network браузера. OpenAPI - наше всё!
tripolskypetr Автор
14.11.2024 07:40Фронт не при чем, в host приложениях используется REST API. Берем NestJS, вот вам и Swagger. Это для взаимодействия между backend микросервисами
https://docs.nestjs.com/recipes/swaggerradist2s
14.11.2024 07:40Ну да, вот я сейчас пришел к своим бэкам и они взяли мне такие и затащили. Изначально нужно было что-то такое использовать - это верно.
tripolskypetr Автор
14.11.2024 07:40Поэтому и был сделан starter kit: чтобы исключить изобретение прорабом кастомного кирпича :-)
BruTO8000
А насколько вообще корректно сравнивать транспорт с библиотекой? Они хоть и похожи в названиях, но делают абсолютно разные вещи
tripolskypetr Автор
Говорим gRPC, подразумеваем официальный биндинг, который и есть библиотека