javascript

Почему мы выбрали gRPC вместо tRPC?

  • четверг, 14 ноября 2024 г. в 00:00:08
https://habr.com/ru/articles/858186/

Исходный код, разобранный в этой статье, опубликован в этом репозитории

Микросервисная архитектура, понятная ООП-разработчикам

Крупные приложения пишутся в Domain Driven Design. Частный случай этой архитектуры — Model View Controller в монолите. Этому учат в университетах, и найти специалистов просто. Однако для обработки высоких нагрузок нужны микросервисы. Найти хороших специалистов, которые могут поддерживать ООП-код в микросервисах, а не процедурный код, сложно.

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

Почему не tRPC

  1. Необходимость сохранить возможность писать сервисы на Golang

В будущем должна быть возможность переписать высоконагруженные участки кода на компилируемом языке вроде golang, чего tRPC не позволяет

  1. Роутер как анти-паттерн в микросервисной архитектуре

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

  1. Бесполезные yum валидации

Аналогично prop-types в React, предпочтительный способ объявить контракт — через interface аргументов, так как декларативно описанная статическая проверка типов во время компиляции значительно проще портируется на другой язык программирования

  1. Частичный перезапуск серверного приложения

Подход gRPC децентрализован. Поскольку нет единой точки входа, это позволяет избежать узкого места в производительности. Например, если основной сервер tRPC упал, придётся перезапускать все микросервисы. В gRPC хост-приложение и все сервисы могут перезапускаться отдельно. Также можно использовать YAML Engineer для декларативного описания стратегии проксирования запросов, например политики повторных попыток

  1. Маппинг методов класса вместо удалённых процедур

При работе с tRPC вы будете использовать switch-case в удалённой процедуре для маппинга метода класса, используя табличную функцию с типом действия. Это лишний шаблонный код, проще предоставить экземпляр класса для маппинга методов и сделать процесс автоматическим

Решённые проблемы

  1. Работа с gRPC через TypeScript

По состоянию на 2016 год не было разделения между модулями commonjs и esm и TypeScript, поэтому proto-файлы предлагалось конвертировать в js с сомнительным содержимым. В этом starter kit архитектура предполагает доступ через sdk объект с поддержкой IntelliSense, проблема генерации d.ts из proto решена js-скриптом без нативного бинарника. Любое взаимодействие между микросервисами осуществляется через вызов метода интерфейса целевого класса и класса-обёртки.

  1. Запуск бэкенда без docker через npm start

Иногда нужен доступ к js-файлам без изоляции, чтобы проинспектировать их отладчиком или добавить console.log в уже транспилированный бандл. Для запуска микросервисов используется PM2, что упрощает доступ к коду программистом.

  1. Единый источник ответственности за операции с базой данных

Для операций с базой данных лучше использовать луковичную архитектуру Model View Presenter, где слой представления организует маппинг и логирование данных, а слой сервисов базы данных предоставляет абстракцию от СУБД. Проблема масштабируемости этого паттерна решается перемещением кода в общий модуль; упрощённо, каждый микросервис хостит копию монолита.

  1. Выполнение методов микросервиса без Postman

Хост-приложения, взаимодействующие с сервисами через gRPC, находятся в папке apps. Было создано два приложения: apps/host-main и apps/host-test, первое с веб-сервером, во втором можно писать произвольный код и запускать его командой npm run test. Также в apps/host-test можно писать модульные тесты, если нужно вести разработку тестированием.

  1. Автоматическое обнаружение не-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 в сервисах и общем коде. В результате:

  1. Код в классах

  2. Есть внедрение зависимостей

Файлы 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 должен логировать имя сервиса, имя метода и аргументы.

Спасибо за внимание!