Введение
Архитектура
Обработка входящих запросов
Коммуникация сервисов
Руководство пользователя
Получение данных из сервисов
Использование Панели управления
Туториалы
Получение данных из других сервисов
Построение истории отдельного пользователя
Классические модели атрибуции
Вероятностное прохождение воронки
Атрибуция по индексу активности
Воронки
X
Выберите раздел

Организация сервиса

Проще всего начать новый сервис копией 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

package.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).
  • В Docker — npm link @rockstat/rock-me-ts. Базовый образ rockstat/band-base-ts уже содержит собранный и слинкованный фреймворк, см. Сборка и запуск.

pino-pretty нужен только в dev-режиме. В production логи пишутся в stdout как JSON, по строке на запись, и их разбирает система сбора логов.

tsconfig.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.

AppServer

Каркас одинаков во всех сервисах. Контейнер 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.