خلاصه:
مدتی بود میخواستم یک نمونه واقعی از ارتباط میکروسرویسی با gRPC در Node.js داشته باشم؛ نه یک Hello World ساده، بلکه چیزی که واقعاً بتوانم بعداً در پروژههای دیگر هم از آن استفاده کنم. در این پست، با TypeScript، Inversify، gRPC و Clean Architecture یک سرویس جمعوجور میسازیم، با گزینههای کاربردی ts-proto کدها را جنریت میکنیم و در نهایت همهچیز را با Bruno تست میکنیم.
مدتی بود حس میکردم جای یک پیادهسازی تمیز از gRPC در جعبهابزار شخصیام خالی است.
در پروژههای مختلف، از ایدههایی مثل PXLAds گرفته تا سرویسهای بزرگتر، بالاخره به نقطهای میرسیم که چند سرویس باید با یکدیگر ارتباط داشته باشند.
REST در خیلی از سناریوها کاملاً کافی است، اما وقتی ارتباط بین سرویسها پررنگتر میشود، چیزهایی مثل قرارداد مشخص (Contract)، ایمنی تایپ (Type Safety)، سربار کمتر و سرعت بالاتر در مقیاس باینری اهمیت بیشتری پیدا میکنند.
اینجا بود که تصمیم گرفتم یک سناریوی واقعی برای خودم پیاده کنم:
یک اپلیکیشن Node.js با TypeScript و Inversify که بر اساس Clean Architecture ساخته شده، REST API خودش را دارد و در کنار آن یک gRPC Server هم برای ارتباط با سایر سرویسها ارائه میدهد.
هدفم این نبود که فقط gRPC را بالا بیاورم؛ میخواستم ساختار طوری باشد که Domain و Use Caseهای برنامه هیچ وابستگی مستقیمی به Express یا gRPC نداشته باشند.
این پست هم نتیجه همان تمرین است و هم یک Cheat Sheet برای خودم؛ چیزی که احتمالاً چند ماه دیگر دوباره سراغش میآیم.
تصویر کلی معماری
ساختار پروژه را به چهار لایه اصلی تقسیم کردم:
- Domain: موجودیتها و منطق اصلی بیزینس، مثل
User. - Application: یوزکیسها و اینترفیسهایی که رفتار برنامه را تعریف میکنند؛ مثل
RegisterUseCaseوGetUserIdByUsernameUseCase. - Infrastructure: جزئیات فنی مثل اتصال به MongoDB و پیادهسازی مخزنها، مثل
MongoUserRepository. - Presentation: ورودیهای برنامه؛ یعنی هر چیزی که درخواست را از دنیای بیرون دریافت میکند.
در لایه Presentation فعلاً دو ورودی داریم:
- REST / Express برای ارتباط با کلاینتهای عمومی
- gRPC برای ارتباط سریع بین سرویسها
[ Client / Microservices ]
│ │
(HTTP / REST) (gRPC)
│ │
▼ ▼
Controllers gRPC Server ◄── Presentation Layer
\ /
▼ ▼
Use Cases ◄── Application Layer (Inversify DI)
│
▼
Repositories ◄── Infrastructure Layer (MongoDB)نکته مهم اینجاست که هم Controllerهای REST و هم Handlerهای gRPC فقط ورودی برنامه هستند. منطق اصلی همچنان دستنخورده داخل Use Caseها باقی میماند.
گام اول: تعریف قرارداد با Protobuf
در gRPC همهچیز از یک Contract شروع میشود.
قرارداد مشخص میکند چه سرویسی داریم، چه متدی ارائه میدهد، چه دادهای دریافت میکند و خروجی آن چیست.
برای این سناریو، فایل زیر را داخل پوشه contracts/ ساختم:
📄 contracts/user.proto
syntax = "proto3";
package com.playground;
service GetUserId {
rpc getUserId(GetUserIdRequest) returns (GetUserIdResponse) {}
}
message GetUserIdRequest {
string username = 1;
}
message GetUserIdResponse {
string id = 1;
}
هدف سرویس ساده و سرراست است: یک username دریافت میکنیم و در صورت وجود، id مربوطه را برمیگردانیم.
تبدیل فایل Proto به TypeScript با گزینههای کاربردی
برای تولید تایپها از ابزار مدرن ts-proto استفاده کردم. برای اینکه خروجی دقیقاً برای محیط Node و پکیج @grpc/grpc-js آماده باشد، دستور زیر را اجرا کردم:
protoc \
--plugin=./node_modules/.bin/protoc-gen-ts_proto \
--ts_proto_out=./generated \
--ts_proto_opt=outputServices=grpc-js,env=node \
-I ./contracts \
./contracts/*.proto
چرا این فلگها مهم هستند؟
- گزینه
--ts_proto_opt=outputServices=grpc-js,env=node: بهts-protoمیگوید کدها را طوری کامپایل کند که تعاریف سرویس و کلاینت مستقیماً با@grpc/grpc-jsهماهنگ باشند. - گزینه
-I ./contracts: دایرکتوری مرجع را مشخص میکند تا ایمپورتها درست حل شوند.
یک نکته حیاتی درباره خروجی ts-proto
یکی از چیزهایی که اول کار میتواند گیجکننده باشد، نحوه برخورد با Messageها در ts-proto است.
در ابزارهای سنتی مثل google-protobuf، پیامها کلاس بودند:
// اشتباه در ts-proto:
const response = new GetUserIdResponse();
response.setId(user.id);
اما در ts-proto خروجیها Plain TypeScript Interfaces هستند؛ یعنی با آبجکتهای ساده جاوااسکریپت طرفیم و به راحتی آبجکت معمولی پاس میدهیم:
return callback(null, { id: result.id });
گام دوم: ساخت Use Case واقعی
نمیخواستم سرور فقط یک مقدار هاردکدشده برگرداند؛ پس یک Use Case واقعی ساختم که بر اساس نامکاربری داخل ریپازیتوری جستوجو کند و شناسه کاربر را تحویل دهد:
📄 src/application/use-cases/get-user-id-by-username.use-case.ts
import { inject, injectable } from "inversify";
import {
IUserRepository,
USER_REPOSITORY,
} from "../interfaces/user.repository";
type GetUserIdInput = {
username: string;
};
type GetUserIdOutput = {
id: string;
};
@injectable()
export class GetUserIdByUsernameUseCase {
constructor(
@inject(USER_REPOSITORY)
private readonly userRepo: IUserRepository,
) {}
async execute(data: GetUserIdInput): Promise<GetUserIdOutput> {
const user = await this.userRepo.findByUsername(data.username);
if (!user || !user.id) {
throw new Error("User not found");
}
return { id: user.id };
}
}
این Use Case هیچ دانشی درباره Express یا gRPC ندارد. تنها چیزی که میشناسد، اینترفیس IUserRepository است.
ثبت Use Case در کانتینر Inversify
در فایل src/module.ts یوزکیس جدید را به ماژول کانتینر اضافه کردم:
bind(GetUserIdByUsernameUseCase).toSelf().inSingletonScope();
گام سوم: پیادهسازی سرور gRPC
حالا در لایه Presentation سرور gRPC را پیادهسازی میکنیم تا ورودی را اعتبارسنجی کند، یوزکیس را از DI بخواند و روی پورت 50051 اصطلاحاً Bind شود:
📄 src/presentation/grpc/server.ts
import {
sendUnaryData,
Server,
ServerCredentials,
ServerUnaryCall,
status,
} from "@grpc/grpc-js";
import {
GetUserIdService,
GetUserIdRequest,
GetUserIdResponse,
GetUserIdServer,
} from "../../../generated/user";
import { getContainer } from "../../container";
import { GetUserIdByUsernameUseCase } from "../../application/use-cases/get-user-id-by-username.use-case";
export function createGrpcServer(): Server {
const server = new Server();
const container = getContainer();
const getUserIdByUsernameUseCase = container.get(GetUserIdByUsernameUseCase);
const getUserIdHandler: GetUserIdServer["GetUserId"] = async (
call: ServerUnaryCall<GetUserIdRequest, GetUserIdResponse>,
callback: sendUnaryData<GetUserIdResponse>,
) => {
try {
const username = call.request.username;
if (!username || username.trim() === "") {
return callback({
code: status.INVALID_ARGUMENT,
message: "Username is required",
});
}
console.log(
`[gRPC] Received getUserId request for username: "${username}"`,
);
const result = await getUserIdByUsernameUseCase.execute({ username });
return callback(null, { id: result.id });
} catch (error) {
if (error instanceof Error) {
return callback({
code: status.INTERNAL,
message: "An internal server error",
});
}
console.error("[GRPC error]", error);
return callback({
code: status.INTERNAL,
message: "An internal server error",
});
}
};
server.addService(GetUserIdService, {
getUserId: getUserIdHandler,
});
return server;
}
export function startGrpcServer(port: number = 50051): Promise<Server> {
return new Promise((resolve, reject) => {
const server = createGrpcServer();
const address = `0.0.0.0:${port}`;
server.bindAsync(
address,
ServerCredentials.createInsecure(),
(err, boundPort) => {
if (err) {
return reject(err);
}
console.log(`gRPC server is running on port ${boundPort}`);
resolve(server);
},
);
});
}
💡 نکتهای برای پروداکشن (Error Handling معنایی):
در کد فعلی، برای سادگی در بلوکcatchخطاهای عمومی به کدstatus.INTERNALمپ شدهاند. در یک پیادهسازی تکاملیافته، میتوان خطای پیدا نشدن کاربر (مثلاًUserNotFoundError) را به صورت مجزا بررسی کرد و با کد گویایstatus.NOT_FOUNDبه کلاینت پاسخ داد تا کلاینت بداند که این خطا ناشی از کرش سرور نبوده، بلکه کاربر وجود نداشته است.
گام چهارم: اجرای همزمان REST و gRPC در Bootstrap
پروژه از قبل سرور Express را روی پورت 2000 داشت. کافی بود در متد bootstrap فایل src/index.ts سرور gRPC را هم فراخوانی کنم:
📄 src/index.ts
async function bootstrap() {
try {
await mongoose.connect(MONGO_URI);
console.log("Connected to MongoDB successfully");
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
// راهاندازی سرور gRPC روی پورت 50051
await startGrpcServer(50051);
} catch (error) {
console.error("Failed to start application:", error);
process.exit(1);
}
}
bootstrap();
حالا یک پردازش داریم که دو پورت ورودی مجزا را همزمان ارائه میدهد:
- Express / REST:
localhost:2000 - gRPC Server:
localhost:50051
گام پنجم: تست بیدردسر gRPC با Bruno
برای تست متدها نیازی نیست وقت بگذاریم و کلاینت تستی بنویسیم. نرمافزار Bruno به صورت بومی از gRPC پشتیبانی میکند:
- در Bruno یک درخواست جدید از نوع gRPC بسازید.
- آدرس سرور را
localhost:50051وارد کنید (بدون SSL یا Insecure). - در تب Proto File، فایل
contracts/user.protoپروژه را انتخاب کنید. - از لیست متدها، گزینه
com.playground.GetUserId / getUserIdرا انتخاب کنید.
با زدن دکمه Send، پاسخ را در پنل روبرو مشاهده خواهید کرد:
{
"id": "6740b2a8d119bc30e..."
}
در تب Body پیام تستی را وارد کنید:
{
"username": "amir"
}
چیتشیت سریع (Quick Cheat Sheet)
| بخش | دستور یا نکته کلیدی |
|---|---|
| دستور جنریت تایپها | protoc --plugin=... --ts_proto_out=./generated --ts_proto_opt=outputServices=grpc-js,env=node -I ./contracts ./contracts/*.proto |
| نوع پاسخ در ts-proto | { id: result.id } (به عنوان POJO، بدون new) |
| کد پارامتر نامعتبر | status.INVALID_ARGUMENT |
| کد خطای داخلی | status.INTERNAL |
| اتصال لوکال بدون SSL | ServerCredentials.createInsecure() |
| تست مستقیم | Bruno با لود کردن فایل user.proto روی پورت 50051 |
جمعبندی
چیزی که در این پیادهسازی بیش از همه برایم ارزشمند بود، حفظ یکپارچگی معماری تمیز بود. Use Case ما اصلاً نمیداند که درخواست را یک مرورگر وب از درگاه REST فرستاده یا یک میکروسرویس از طریق کانال باینری gRPC. هسته برنامه در هر دو حالت ثابت مانده است.
این تمرین یکی از آن پایههایی است که بعداً برای افزودن امکاناتی مثل Streaming، Interceptorها و سیستمهای احراز هویت در ارتباطات میکروسرویسی دوباره به آن بازخواهم گشت.