Стартуем микросервис на Node.js + fastify + Typescript + prisma + mongodb + grpc
Вступление
Зачастую возникает необходимость начать новый микросервис. Вот и у меня совсем недавно возникла такая потребность. А ведь хочется еще и чего-то новенького попробовать.
Сперва был определен стек и хотя процесс для меня не новый, но я столкнулся с множеством подводных камней. В результате родилась идея написать этот туториал.
В конце будет представлена ссылка на репозиторий с кодом.
Шаг 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