TypeScript

چطور در Node.js با Inversify و gRPC یک ارتباط تمیز ساختم؟

یک راهنمای عملی برای اضافه کردن gRPC به پروژه Node.js با TypeScript، Inversify و Clean Architecture؛ از تعریف قرارداد Protobuf و ts-proto تا DI، مدیریت خطا و تست با Bruno.

چطور در Node.js با Inversify و gRPC یک ارتباط تمیز ساختم؟
خلاصه:
مدتی بود می‌خواستم یک نمونه واقعی از ارتباط میکروسرویسی با 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 فعلاً دو ورودی داریم:

  1. REST / Express برای ارتباط با کلاینت‌های عمومی
  2. 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 پشتیبانی می‌کند:

  1. در Bruno یک درخواست جدید از نوع gRPC بسازید.
  2. آدرس سرور را localhost:50051 وارد کنید (بدون SSL یا Insecure).
  3. در تب Proto File، فایل contracts/user.proto پروژه را انتخاب کنید.
  4. از لیست متدها، گزینه 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ها و سیستم‌های احراز هویت در ارتباطات میکروسرویسی دوباره به آن بازخواهم گشت.