Исходный код, разобранный в этой статье, опубликован в этом репозитории
Микросервисная архитектура, понятная ООП-разработчикам
Крупные приложения пишутся в 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
должен логировать имя сервиса, имя метода и аргументы.
Спасибо за внимание!
ссылка на оригинал статьи https://habr.com/ru/articles/858186/
Добавить комментарий