Вступление

Зачастую возникает необходимость начать новый микросервис. Вот и у меня совсем недавно возникла такая потребность. А ведь хочется еще и чего-то новенького попробовать.

Сперва был определен стек и хотя процесс для меня не новый, но я столкнулся с множеством подводных камней. В результате родилась идея написать этот туториал.

В конце будет представлена ссылка на репозиторий с кодом.

Шаг 1. Инициализация проекта на TS

Для начала необходимо инициализировать сам typescript и произвести первоначальные настройки.

Запуск проекта на тайпскрипте с нуля - задача достаточно тривиальна, но почему-то постоянно возникают сложности с настройкой. В этом плане очень удобен nest, так как там идет все из коробки, но он достаточно тяжелый и сам по себе уже как язык программирования, поэтому это не наш путь.

Выполним следующие команды в терминале

npm init -y
yarn add typescript tsconfig-paths ts-node @types/node nodemon concurrently --dev
mkdir src && touch src/index.ts
npx tsc --init --rootDir src --outDir build --esModuleInterop --resolveJsonModule --module commonjs --allowJs true --noImplicitAny true --target esnext --moduleResolution node
touch .gitignore && echo "node_modules \n.vscode\nbuild" >> .gitignore
touch nodemon.json
echo "console.log('Hello world')" >> src/index.ts

Далее дополним конфигурационный файл typescript.json, первоначальную настройку трогать не будем, ее вполне достаточно для наших целей

{
  "compilerOptions": {...},
  "include": ["src"],
  "exclude": [
    "node_modules",
    "dist",
    "examples",
  ],
}

Заполним секцию scripts в package.json

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "tsc",
    "dev": "export NODE_ENV=development TS_NODE_BASEURL=./dist && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"tsc -w\" \"nodemon\""
  },

Для dev режима будет использоваться nodemon, поэтому его тоже необходимо настроить, в nodemon.json

{
  "ignore": [
    "**/*.test.ts",
    "**/*.spec.ts",
    ".git",
    "node_modules"
  ],
  "watch": [
    "src"
  ],
  "exec": "tsc && node -r tsconfig-paths/register -r ts-node/register build/index.js",
  "ext": "ts, js"
}

Финальная проверка на этом шаге, в консоли после запуска мы должны увидеть сообщение

yarn dev

[App] [nodemon] starting `tsc && node -r tsconfig-paths/register -r ts-node/register build/index.js`
[App] Hello world
[App] [nodemon] clean exit - waiting for changes before rest

На этом первоначальная, базовая, настройка готова.

Шаг 2. Добавление Fastify

Далее нам необходимо добавить сам сервер. Сперва отвечу на вопрос: "Почему fastify ? "

Fastify достаточно легкий и на нем быстро и просто писать REST. У него удобная система плагинов, собственно почему бы и нет.

Добавим пакет и создадим файл для нашего сервера.

yarn add fastify
touch src/app.ts

На данном этапе наш сервер достаточно прост, создание самого приложения вынесем в отдельный файл app.ts.

import Fastify, { FastifyServerOptions } from 'fastify'

export type AppOptions = Partial<FastifyServerOptions>;

async function buildApp(options: AppOptions = {}) {
  const fastify = Fastify(options);

  return fastify;
}

export { buildApp }

Опишем запуск сервера в index.ts

import { buildApp, AppOptions } from './app';

const options: AppOptions = {
  logger: true,
};

const start = async () => {
  const app = await buildApp(options);

  try {
    await app.listen({
      port: 3000,
      host: 'localhost',
    });

  } catch (err) {
    app.log.error(err);
    process.exit(1);
  }
};

start();

Для проверки запускаем сервер в dev режиме, в консоли должно появиться следующее сообщение.

yarn dev

[App] {"level":30,"time":1676434938643,"pid":39600,"hostname":"Anatolys-MacBook-Pro.local","msg":"Server listening at http://[::1]:3000"}

Шаг 3. Добавление prisma

В качестве базы данных будет использоваться Mongodb. И собственно почему Prisma ?
Впервые, не так давно, попробовал prisma и был в восторге - описываешь схему, а она сама уже генерит тонны логики, что сильно упрощает взаимодействие с БД.

Вам нужно поднять локально монгу или взять в докер хабе https://hub.docker.com/_/mongo.

Предположим что у вас уже есть монга по адресу mongodb://localhost:27017

Сперва необходимо установить необходимые пакеты

yarn add prisma @prisma/client fastify-plugin
npx prisma init --datasource-provider mongodb

Далее определить схему базы данных prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mongodb"
  url      = "mongodb://localhost:27017/example"
}

model Post {
  id       String    @id @default(auto()) @map("_id") @db.ObjectId
  slug     String    @unique
  title    String
  body     String
  author   User      @relation(fields: [authorId], references: [id])
  authorId String    @db.ObjectId
}

model User {
  id      String   @id @default(auto()) @map("_id") @db.ObjectId
  email   String   @unique
  name    String?
  address Address?
  posts   Post[]
}

Запускаем генерацию бизнес логики работы с БД и накатываем изменения в саму базу данных. Это действие необходимо делать каждый раз после изменения схемы, поэтому их можно вынести в секцию scrips - package.json

yarn prisma generate
npx prisma db push

Подключать призму мы будем через плагины fastify touch src/prisma.plugin.ts

import { PrismaClient } from '@prisma/client';
import fp from 'fastify-plugin';

async function initDatabaseConnection(): Promise<PrismaClient> {
  const db = new PrismaClient();
  await db.$connect();
  return db;
}

// Use TypeScript module augmentation to declare the type of server.prisma to be PrismaClient
declare module 'fastify' {
  interface FastifyInstance {
    prisma: PrismaClient
  }
}

const prismaPlugin = fp(async (server) => {
  const prisma = await initDatabaseConnection();

  // Make Prisma Client available through the fastify server instance: server.prisma
  server.decorate('prisma', prisma);
  server.addHook('onClose', async () =>  {
    await server.prisma.$disconnect();
  });
});

export default prismaPlugin;

Далее необходимо подключить плагин в app.ts

import Fastify, { FastifyServerOptions } from 'fastify'
import prismaPlugin from './prisma.plugin';

export type AppOptions = Partial<FastifyServerOptions>;

async function buildApp(options: AppOptions = {}) {
  const fastify = Fastify(options);
  fastify.register(prismaPlugin);
  
  return fastify;
}

export { buildApp }

Сделаем простенький обработчик для проверки работoспособности touch src/services.ts

import { PrismaClient } from '@prisma/client';

async function main(prisma: PrismaClient) {
  await prisma.user.deleteMany();
  await prisma.user.create({ data: { email: 'test@email.com' } });
  const usersCount = await prisma.user.count();
  console.log({ users_count: usersCount });

}

export { main };

И наконец, внесем изменения в index.js

import { buildApp, AppOptions } from './app';
import { main } from './services';

const options: AppOptions = {
  logger: true,
};

const start = async () => {
  const app = await buildApp(options);

  try {
    await app.listen({
      port: 3000,
      host: 'localhost',
    });

    await main(app.prisma);
  } catch (err) {
    app.log.error(err);
    process.exit(1);
  }
};

start();

Финальная проверка на шаге 3, в консоли должно появиться следующее сообщение

yarn dev

[App] { users_count: 1 }

Шаг 4. Добавление grpc

И вот мы подошли к финишу, осталось добавить сервер для grpc, а собственно для чего нам grpc?

Grpc хорош для взаимодействий бек-бек, он шустрее реста, но имплементируется сложнее.

Сперва необходимо установить необходимые пакеты.

yarn add @grpc/grpc-js @grpc/proto-loader
mkdir proto
touch proto/example.proto

Затем опишем наш сервер со стороны proto файла example.proto, для примера достаточно описать один сервис, который будет отдавать массив пользователей:

syntax = "proto3";

package example;

message User {
  string id = 1;
  string email = 2;
};

message GetUsersResponse {
  repeated User users = 1;
}

message GetUsersRequest {}

service UserService {
  rpc GetUsers(GetUsersRequest) returns (GetUsersResponse);
}

После того как мы описали proto файл, необходимо сгенерить типы для typescript, для этого воспользуемся встроенным в proto-loader генератором proto-loader-gen-types. Сразу добавим нужную команду в секцию scrips в файле package.json

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "tsc",
    "dev": "export NODE_ENV=development TS_NODE_BASEURL=./dist && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"tsc -w\" \"nodemon\"",
    "gen-proto": "$(npm bin)/proto-loader-gen-types --longs=String --enums=String  --oneofs --grpcLib=@grpc/grpc-js --outDir=src/proto/interfaces proto/*.proto"
  },

После запускаем генерацию типов yarn gen-proto

Все сгенерированные типы будут лежать в src/proto/interfaces

Подключать grpc server будем так же через fasify плагины. Для этого создадим файл touch src/grpc.plugin.ts и запишем в него следующий код реализации сервера.

import { GetUsersResponse } from './proto/interfaces/example/GetUsersResponse';
import { GetUsersRequest__Output } from './proto/interfaces/example/GetUsersRequest';
import fp from 'fastify-plugin';
import { join } from 'path';
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import { ProtoGrpcType } from './proto/interfaces/example';

declare module 'fastify' {
  interface FastifyInstance {
    grpcServer: {
      start: () => void,
    },
  }
}

const grpcServerOptions = {
  keepCase: false,
  longs: String,
  enums: String,
  defaults: false,
  oneofs: true,
};

const grpcServerPlugin = fp(async (fastify) => {
  // load proto files from directory
  const packageDefinition = protoLoader.loadSync([join(__dirname, '../proto/example.proto')], grpcServerOptions);
  const proto = grpc.loadPackageDefinition(packageDefinition) as unknown as ProtoGrpcType;
  const grpcServer = new grpc.Server();

  // mapping between handlers and rpc services
  grpcServer.addService(proto.example.UserService.service, {
    GetUsers: async (
      req: grpc.ServerUnaryCall<GetUsersRequest__Output, GetUsersResponse>,
      res: grpc.sendUnaryData<GetUsersResponse>) => {
        return res(null, {
          users: [{
            id: 'test',
            email: 'test',
          }],
        })
    },
  });

  function start(opts: { port: number } = { port: 50501 }) {
    return grpcServer.bindAsync(
      `0.0.0.0:${opts.port}`,
      grpc.ServerCredentials.createInsecure(),
      (err, port) => {
        if (err) {
          console.error(err);
          return;
        }

        grpcServer.start();
        console.log(`GRPC Server listening on ${port}`);
      },
    );
  }

  fastify.decorate('grpcServer', { start });
});

export { grpcServerPlugin };

Ответ обработчика ендпоинта GetUsers здесь замокан в демонстрационных целях.

После того как плагин готов - необходимо его подключить в app.ts

import Fastify, { FastifyServerOptions } from 'fastify'
import { grpcServerPlugin } from './grpc.plugin';
import prismaPlugin from './prisma.plugin';

export type AppOptions = Partial<FastifyServerOptions>;

async function buildApp(options: AppOptions = {}) {
  const fastify = Fastify(options);
  fastify.register(prismaPlugin);
  fastify.register(grpcServerPlugin);

  return fastify;
}

export { buildApp }

Теперь плагин подключен и осталось только запустить сервер в index.ts

import { buildApp, AppOptions } from './app';
import { main } from './services';

const options: AppOptions = {
  logger: true,
};

const start = async () => {
  const app = await buildApp(options);

  try {
    await app.listen({
      port: 3000,
      host: 'localhost',
    });

    app.grpcServer.start();

    await main(app.prisma);
  } catch (err) {
    app.log.error(err);
    process.exit(1);
  }
};

start();

Для проверки запустим наш серверyarn dev, в консоли должна оторбазиться следующая информация

yarn dev

[App] {"level":30,"time":1676521988065,"pid":69311,"hostname":"Anatolys-MacBook-Pro.local","msg":"Server listening at http://127.0.0.1:3000"}
[App] GRPC Server listening on 50501
[App] { users_count: 1 }

Проверить ответ можно либо при помощи теста, либо через стороннее приложение, я использовал bloomRPC.

В ответе должен прийти наш замоканый ответ.

Заключение

Подведем итоги, болванка микросервиса может принимать запросы по grpc и по rest одновременно. Rest запущен на 3000 порту, grpc на 50501.

В качестве ORM используется prisma. Пожалуй не хватает только тестов, но это уже за рамками данного туториала!

Ссылка на репо с проектом: https://github.com/iseekyouu/habr-tfpg