Проще всего начать новый сервис копией kafkastream: в нём минимум кода и нет ничего, кроме каркаса и одного модуля. Ниже — что в этом каркасе и зачем.
my-service/
├── config/
│ └── 02_config.yml # конфигурация, все *.yml в папке читаются рекурсивно
├── src/
│ ├── index.ts # точка входа: импортирует и экспортирует appServer
│ ├── AppServer.ts # сборка зависимостей, подписка на шину, setup()
│ ├── constants.ts # имена каналов и методов
│ ├── types.ts # ModuleConfig — типы своих секций конфигурации
│ └── kafka/ # модуль с логикой сервиса
├── @types/ # декларации для пакетов без типов (можно пустую папку с .keep)
├── .env # переменные окружения для локального запуска
├── .env.local # локальные переопределения, не коммитятся
├── Dockerfile
├── makefile # сборка и публикация образа
├── package.json
└── tsconfig.json
Из зависимостей обязательны сам фреймворк, reflect-metadata (импортируется первым в AppServer.ts)
и source-map-support для читаемых стеков в production:
{
"name": "@rockstat/my-service",
"version": "1.0.0",
"scripts": {
"start:dev": "LOG_LEVEL=debug ts-node-dev -r 'tsconfig-paths/register' -r 'source-map-support/register' ./src/index.ts | pino-pretty -L debug",
"start:prod": "TS_NODE_BASEURL=./dist node -r 'tsconfig-paths/register' -r 'source-map-support/register' dist/index",
"build": "rimraf dist && tsc -p tsconfig.json",
"clean": "rimraf dist",
"watch": "tsc -w"
},
"dependencies": {
"@rockstat/rock-me-ts": "file:../rockmets",
"js-yaml": "^4.1.0",
"reflect-metadata": "^0.2.1",
"source-map-support": "^0.5.21"
},
"devDependencies": {
"@types/node": "^20.11.26",
"pino-pretty": "^11.0.0",
"rimraf": "^5.0.5",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.4.2"
}
}
Фреймворк подключается одним из двух способов:
"@rockstat/rock-me-ts": "file:../rockmets", если репозиторий
rockme-framework склонирован рядом (так сделано в kafkastream).npm link @rockstat/rock-me-ts. Базовый образ rockstat/band-base-ts уже содержит
собранный и слинкованный фреймворк, см. Сборка и запуск.pino-pretty нужен только в dev-режиме. В production логи пишутся в stdout как JSON,
по строке на запись, и их разбирает система сбора логов.
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"strictNullChecks": true,
"noImplicitAny": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"outDir": "./dist/",
"typeRoots": [
"node_modules/@types",
"node_modules/@rockstat/rock-me-ts/@types",
"@types"
],
"baseUrl": "./src/",
"paths": {
"@app/*": ["*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Что здесь важно:
baseUrl и paths задают алиас @app/* на src/*. Во время разработки его разрешает
tsconfig-paths/register, в production — он же с TS_NODE_BASEURL=./dist.typeRoots включает @types фреймворка: там лежат декларации для redis-fast-driver,
flake-idgen, xxhash и merge-options, у которых нет собственных типов.emitDecoratorMetadata и experimentalDecorators нужны reflect-metadata.src/index.ts ничего не делает сам — экземпляр сервера создаётся при импорте модуля AppServer:
import { appServer } from '@app/AppServer';
export default appServer;
Если в package.json нет module-alias, строка import 'module-alias/register' не нужна:
алиасы разрешает tsconfig-paths.
Каркас одинаков во всех сервисах. Контейнер Deps передаётся в модули, чтобы те брали из него
логгер, метрики, конфигурацию и RPC, не зная ничего о порядке их создания:
import 'reflect-metadata';
import {
RPCAdapterRedis, RPCAgnostic, AgnosticRPCOptions, Meter, RedisFactory,
TheIds, Logger, AppConfig, RPCAdapter, BROADCAST
} from '@rockstat/rock-me-ts';
import { ModuleConfig } from '@app/types';
import { KafkaManager } from '@app/kafka';
export class Deps {
log: Logger;
id: TheIds;
config: AppConfig<ModuleConfig>;
meter: Meter;
rpc: RPCAgnostic;
rpcAdaptor: RPCAdapter;
redisFactory: RedisFactory;
constructor(obj: { [k: string]: any } = {}) {
Object.assign(this, obj);
}
}
export class AppServer {
log: Logger;
deps: Deps;
name: string;
rpc: RPCAgnostic;
kafkamanager: KafkaManager;
constructor() {
const config = new AppConfig<ModuleConfig>();
const log = new Logger(config.log);
const meter = new Meter(config.meter);
this.name = config.rpc.name;
this.deps = new Deps({ id: new TheIds(), log, config, meter });
this.log = log.for(this);
this.log.info('Starting service');
try {
const redisFactory = this.deps.redisFactory = new RedisFactory({ log, meter, ...config.redis });
const channels = [config.rpc.name, BROADCAST];
const rpcOptions: AgnosticRPCOptions = { channels, redisFactory, log, meter, ...config.rpc };
this.deps.rpcAdaptor = new RPCAdapterRedis(rpcOptions);
this.rpc = this.deps.rpc = new RPCAgnostic(rpcOptions);
this.rpc.setup(this.deps.rpcAdaptor);
this.kafkamanager = new KafkaManager(this.deps);
} catch (exc) {
this.log.error(exc);
this.stop();
}
this.setup()
.then(() => this.log.info('Setup completed'))
.catch(exc => { this.log.error(exc); this.stop(); });
}
/** Подключения к внешним системам и регистрация методов шины */
async setup() {
await this.kafkamanager.init();
this.rpc.register(BROADCAST, this.kafkamanager.write);
}
private stop() {
this.log.info('Stopping...');
process.exit(0);
}
}
export const appServer = new AppServer();
Порядок в конструкторе важен: сначала конфигурация, потом логгер и метрики (им нужна конфигурация),
потом Redis и RPC. Всё асинхронное — подключение к Kafka или ClickHouse, проверка схемы — живёт
в setup(), а регистрация методов шины идёт после того, как сервис готов принимать данные.
Логику сервиса держите в отдельном классе, который получает Deps в конструкторе и берёт
из него нужное. Так модуль можно тестировать, подменив зависимости:
import { Deps } from '@app/AppServer';
import { Logger, Meter } from '@rockstat/rock-me-ts';
import { KafkaConfig } from '@app/types';
export class KafkaManager {
log: Logger;
meter: Meter;
options: KafkaConfig;
constructor(deps: Deps) {
const { log, meter, config } = deps;
this.log = log.for(this);
this.meter = meter;
this.options = config.get('kafkastream');
}
async init() { /* подключение */ }
write = async (msg: { [k: string]: any }) => { /* обработка события */ return {}; }
}
Методы, которые регистрируются в шине (write), объявляйте стрелочными свойствами класса:
RPC вызывает их без привязки this.