Levelwise
فارسی
ASP.NET Core

gRPC و SignalR

برای تماس سریع بین سرویس‌ها از gRPC استفاده می‌کنیم. قرارداد دقیق دارد، پیام‌ها باینری هستند و روی HTTP/2 کار می‌کند. برای فرستادن خبر زنده از سرور به کاربر از SignalR استفاده می‌کنیم. هر دو یک اتصال طولانی نگه می‌دارند، پس توزیع بار و چند سرور برایشان نکته‌های خاص دارد.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۶ دقیقهمثال سفارش و انبار فروشگاهکد C# و .NET 10

نویسنده: bezzad

دو مشکل متفاوت

فروشگاه ما دو نیاز تازه دارد:

  1. سرویس سفارش، مدام از سرویس انبار موجودی می‌پرسد. هزاران بار در دقیقه. با JSON و REST کار می‌کند، ولی پیام‌ها بزرگ‌اند و قرارداد بین دو تیم فقط در یک فایل مستند است. یک تیم اسم یک فیلد را عوض می‌کند و سرویس دیگر بی‌صدا خراب می‌شود.
  2. مشتری می‌خواهد وضعیت سفارش را زنده ببیند. «بسته‌بندی شد»، «ارسال شد». الان صفحه هر ۵ ثانیه از سرور می‌پرسد «خبر جدید داری؟». بیشتر جواب‌ها «نه» است.

برای مشکل اول gRPC را داریم. برای مشکل دوم SignalR را. این درس هر دو را توضیح می‌دهد، چون هر دو یک ویژگی مشترک دارند: اتصال طولانی.

بخش اول: gRPC برای تماس بین سرویس‌ها

ایده

در gRPC، اول قرارداد را می‌نویسیم. یک فایل proto که می‌گوید سرویس چه متدهایی دارد و هر پیام چه فیلدهایی. از روی این فایل، کد سرور و کد کلاینت خودکار ساخته می‌شوند.

RESTمستند جدا، شاید قدیمیسرویس سفارشسرویس انبار{ "productId": 7, "available": 12 }اسم هر فیلد در هر پیام تکرار می‌شودgRPCinventory.protoسرویس سفارشسرویس انبارکد کلاینتکد سرور08 07 10 0Cفقط شماره فیلد و مقدار
در REST قرارداد جدا از کد است. در gRPC کد هر دو طرف از یک فایل قرارداد ساخته می‌شود.

سه چیز gRPC را سریع و امن می‌کند:

  1. پیام باینری (Protobuf). پیام‌ها کوچک‌تر از JSON هستند و خواندنشان سریع‌تر است.
  2. پروتکل HTTP/2. چند تماس همزمان روی یک اتصال می‌روند. لازم نیست برای هر تماس اتصال جدید باز شود.
  3. قرارداد سخت. اگر یک طرف قرارداد را بشکند، کد طرف دیگر کامپایل نمی‌شود، نه اینکه در Production بی‌صدا خراب شود.

کد: قرارداد

syntax = "proto3";
option csharp_namespace = "Shop.Inventory";

service Inventory {
  rpc GetStock (StockRequest) returns (StockReply);
}

message StockRequest {
  int32 product_id = 1;
}

message StockReply {
  int32 product_id = 1;
  int32 available = 2;
}

عدد جلوی هر فیلد، شماره آن در پیام باینری است. اسم فیلد در پیام نمی‌رود. پس هیچ وقت شماره یک فیلد را عوض نکن و شماره فیلد حذف‌شده را دوباره استفاده نکن.

کد: سرور و کلاینت

// Inventory service: implement the generated base class.
public sealed class InventoryService(ShopDb db) : Inventory.InventoryBase
{
    public override async Task<StockReply> GetStock(StockRequest request, ServerCallContext context)
    {
        var available = await db.Stock
            .Where(s => s.ProductId == request.ProductId)
            .Select(s => s.Available)
            .FirstOrDefaultAsync(context.CancellationToken);

        return new StockReply { ProductId = request.ProductId, Available = available };
    }
}

// Program.cs of the inventory service
builder.Services.AddGrpc();
app.MapGrpcService<InventoryService>();
// Order service: register the generated client.
builder.Services.AddGrpcClient<Inventory.InventoryClient>(o =>
    o.Address = new Uri("https://inventory"));

// Use it with a deadline. Never wait forever for another service.
var reply = await inventory.GetStockAsync(
    new StockRequest { ProductId = 7 },
    deadline: DateTime.UtcNow.AddSeconds(2),
    cancellationToken: ct);
مهلت (Deadline) را فراموش نکن. اگر سرویس انبار گیر کند و مهلت نگذاشته باشی، تماس‌های سرویس سفارش جمع می‌شوند تا آن هم از کار بیفتد. با مهلت، بعد از ۲ ثانیه تماس با خطا تمام می‌شود و سرور هم می‌فهمد که دیگر نباید ادامه دهد.

چهار نوع تماس

  1. تماس ساده (Unary). یک درخواست، یک جواب. مثل بالا.
  2. جریان از سرور (Server streaming). یک درخواست، چند جواب پشت سر هم. مثلاً «تغییرهای قیمت را برایم بفرست».
  3. جریان از کلاینت (Client streaming). چند پیام از کلاینت، یک جواب در آخر. مثلاً آپلود دسته‌ای موجودی.
  4. جریان دوطرفه (Bidirectional). هر دو طرف هر وقت خواستند پیام می‌فرستند.

دام توزیع بار

این نکته در مصاحبه زیاد پرسیده می‌شود. سرویس انبار را روی ۵ pod اجرا می‌کنیم. ولی می‌بینیم یک pod زیر بار است و بقیه بیکار.

سرویس سفارشهزار تماس در ثانیهتوزیع بار لایه ۴فقط اتصال را می‌بیندیک اتصالpod 1pod 2pod 3pod 4همه باربیکار
یک Load Balancer لایه ۴ فقط اتصال‌ها را پخش می‌کند. چون همه تماس‌ها روی یک اتصال HTTP/2 هستند، همه به یک pod می‌رسند.

چرا؟ قدم به قدم:

  1. کلاینت gRPC یک اتصال HTTP/2 باز می‌کند و آن را نگه می‌دارد.
  2. همه تماس‌ها روی همان یک اتصال می‌روند.
  3. یک Load Balancer لایه ۴ (مثل Service معمولی در Kubernetes) فقط اتصال را می‌بیند، نه تماس‌ها را. پس اتصال را یک بار به یک pod می‌دهد.
  4. نتیجه: همه تماس‌های این کلاینت به همان یک pod می‌رسند.

راه حل‌ها:

  • پراکسی لایه ۷ که HTTP/2 را می‌فهمد. مثل Envoy یا یک Service Mesh مثل Linkerd. این پراکسی تک‌تک تماس‌ها را پخش می‌کند.
  • توزیع بار سمت کلاینت. کلاینت gRPC در .NET می‌تواند آدرس همه pod ها را بگیرد (مثلاً از DNS یک Headless Service) و خودش تماس‌ها را بین آن‌ها پخش کند.
مرورگر مستقیم gRPC نمی‌فهمد. مرورگرها کنترل کافی روی HTTP/2 به کد JavaScript نمی‌دهند. برای صدا زدن gRPC از مرورگر، نسخه gRPC-Web لازم است. برای API عمومی و مرورگر، REST و JSON معمولاً ساده‌تر است.

بخش دوم: SignalR برای خبر زنده به کاربر

ایده

به جای اینکه مرورگر هر چند ثانیه بپرسد، یک اتصال باز می‌ماند. هر وقت خبری شد، سرور خودش آن را می‌فرستد.

پرسیدن هر ۵ ثانیهمرورگرسرورنهنهنهارسال شدسه درخواست بی‌فایده و خبر تا ۵ ثانیه دیراتصال بازمرورگرسروریک بار وصل شد و باز ماندارسال شد، همان لحظههیچ درخواست اضافه‌ای نیست
با پرسیدن مداوم، بیشتر درخواست‌ها بی‌فایده‌اند و خبر دیر می‌رسد. با اتصال باز، خبر همان لحظه می‌رسد.

کتابخانه SignalR کارهای سخت را انجام می‌دهد:

  1. بهترین راه ارتباط را انتخاب می‌کند. اول WebSocket. اگر نشد، Server-Sent Events. اگر آن هم نشد، Long Polling.
  2. اتصال قطع‌شده را دوباره وصل می‌کند. اگر در کلاینت فعالش کنی.
  3. فرستادن به گروه را ساده می‌کند. به یک کاربر، به یک گروه یا به همه.

کد: Hub و فرستادن خبر

مرکز ارتباط در SignalR یک Hub است. با یک رابط، متدهای سمت کلاینت را با نوع دقیق تعریف می‌کنیم:

public interface IOrderClient
{
    Task OrderStatusChanged(Guid orderId, string status);
}

[Authorize]
public sealed class OrderHub : Hub<IOrderClient>;

// Program.cs
builder.Services.AddSignalR();
app.MapHub<OrderHub>("/hubs/orders");

خبر معمولاً از داخل Hub شروع نمی‌شود. از جای دیگری می‌آید، مثلاً وقتی رویداد «ارسال شد» از سرویس ارسال می‌رسد. برای این کار از IHubContext استفاده می‌کنیم:

public sealed class OrderShippedHandler(IHubContext<OrderHub, IOrderClient> hub)
{
    public Task HandleAsync(OrderShipped e) =>
        hub.Clients.User(e.CustomerId.ToString())
           .OrderStatusChanged(e.OrderId, "Shipped");
}

و در مرورگر:

const connection = new signalR.HubConnectionBuilder()
  .withUrl("/hubs/orders")
  .withAutomaticReconnect()
  .build();

connection.on("OrderStatusChanged", (orderId, status) => showStatus(orderId, status));
await connection.start();
فرستادن به کاربر، نه به اتصال. یک مشتری ممکن است سایت را در دو تب و یک گوشی باز کرده باشد. فرستادن به کاربر، خبر را به همه اتصال‌های او می‌رساند. شناسه کاربر از احراز هویت می‌آید.

چند سرور: مشکل اصلی SignalR

سایت روی ۳ pod اجرا می‌شود. مرورگر مشتری به pod شماره ۱ وصل است. رویداد «ارسال شد» به pod شماره ۲ می‌رسد. pod شماره ۲ این مشتری را نمی‌شناسد، چون اتصال او روی pod دیگری است. پس خبر هیچ وقت نمی‌رسد.

رویداد ارسالOrderShippedpod 2pod 3pod 1Redis Backplaneپیام را به همه می‌دهد۱. منتشر کن۲. به همهمرورگر مشتریبه این نسخه وصل است۳. ارسال شد
با یک Backplane، هر pod پیام را منتشر می‌کند و pod ای که اتصال کاربر را دارد، آن را به مرورگر می‌رساند.

راه حل‌ها:

  1. یک Backplane مثل Redis. هر pod پیام را در Redis منتشر می‌کند. همه pod ها آن را می‌گیرند و هر کدام که اتصال کاربر را دارد، می‌فرستد. با متد AddStackExchangeRedis بعد از AddSignalR فعال می‌شود.
  2. سرویس مدیریت‌شده Azure SignalR Service. اتصال‌ها را خود این سرویس نگه می‌دارد. سرورهای ما فقط پیام می‌فرستند.
نشست چسبنده (Sticky Session) هم لازم است. مرحله اول اتصال SignalR (مذاکره) و روش‌های جایگزین مثل Long Polling چند درخواست جدا می‌فرستند. همه آن‌ها باید به همان pod برسند. پس در Load Balancer، نشست چسبنده را روشن کن. فقط اگر همه کلاینت‌ها فقط WebSocket استفاده کنند و مذاکره را رد کنند، یا از Azure SignalR Service استفاده کنی، به آن نیازی نیست.

کدام را کجا استفاده کنیم؟

REST و JSON gRPC SignalR
بهترین کاربرد API عمومی، مرورگر، همکارها تماس داخلی بین سرویس‌ها خبر زنده از سرور به کاربر
قالب پیام متن JSON باینری Protobuf متن JSON یا باینری MessagePack
قرارداد جدا از کد (مثلاً OpenAPI) فایل proto و کد ساخته‌شده متدهای Hub
جهت ارتباط کلاینت می‌پرسد هر چهار نوع، با جریان سرور هم می‌تواند شروع کند
از مرورگر بله فقط با gRPC-Web بله

قانون‌های مهم

  1. در gRPC همیشه مهلت بگذار. و توکن لغو را پاس بده.
  2. شماره فیلدهای proto را عوض نکن. فیلد جدید با شماره جدید اضافه کن.
  3. برای gRPC توزیع بار در سطح تماس داشته باش. پراکسی لایه ۷ یا توزیع سمت کلاینت.
  4. در SignalR به کاربر یا گروه بفرست، نه به شناسه اتصال. شناسه اتصال با هر وصل شدن دوباره عوض می‌شود.
  5. برای SignalR روی چند سرور، Backplane و نشست چسبنده لازم است. یا یک سرویس مدیریت‌شده.
  6. پیام SignalR ممکن است نرسد. اگر کاربر آفلاین بود، پیام گم می‌شود. پس وضعیت اصلی را در دیتابیس نگه دار و بعد از وصل شدن دوباره، آن را بخوان.

اشتباه‌های رایج

اشتباه نتیجه راه درست
تماس gRPC بدون مهلت با گیر کردن یک سرویس، سرویس‌های دیگر هم گیر می‌کنند. مهلت کوتاه برای هر تماس.
توزیع بار لایه ۴ برای gRPC یک pod همه بار را می‌گیرد. پراکسی لایه ۷ یا توزیع سمت کلاینت.
عوض کردن شماره فیلد در proto سرویس‌های قدیمی داده را غلط می‌خوانند. فقط فیلد با شماره جدید اضافه کن.
SignalR روی چند pod بدون Backplane خبرها به بعضی کاربرها نمی‌رسند. Redis یا Azure SignalR Service.
تکیه کامل به پیام SignalR کاربری که آفلاین بود، وضعیت را هرگز نمی‌بیند. منبع اصلی دیتابیس است. پیام فقط خبر می‌دهد.
استفاده از gRPC برای API عمومی مرورگر کلاینت‌ها نمی‌توانند راحت صدا بزنند. REST و JSON برای بیرون، gRPC برای داخل.

خلاصه در شش خط

  1. در gRPC قرارداد در فایل proto است و کد هر دو طرف از آن ساخته می‌شود.
  2. پیام باینری و HTTP/2 تماس بین سرویس‌ها را سریع می‌کنند.
  3. همه تماس‌های gRPC روی یک اتصال می‌روند. توزیع بار لایه ۴ کافی نیست.
  4. کتابخانه SignalR یک اتصال باز نگه می‌دارد تا سرور خودش خبر بفرستد.
  5. روی چند سرور، SignalR به Backplane و نشست چسبنده نیاز دارد.
  6. پیام زنده فقط خبر است. وضعیت اصلی در دیتابیس می‌ماند.