gRPC و SignalR
برای تماس سریع بین سرویسها از gRPC استفاده میکنیم. قرارداد دقیق دارد، پیامها باینری هستند و روی HTTP/2 کار میکند. برای فرستادن خبر زنده از سرور به کاربر از SignalR استفاده میکنیم. هر دو یک اتصال طولانی نگه میدارند، پس توزیع بار و چند سرور برایشان نکتههای خاص دارد.
نویسنده: bezzad
دو مشکل متفاوت
فروشگاه ما دو نیاز تازه دارد:
- سرویس سفارش، مدام از سرویس انبار موجودی میپرسد. هزاران بار در دقیقه. با JSON و REST کار میکند، ولی پیامها بزرگاند و قرارداد بین دو تیم فقط در یک فایل مستند است. یک تیم اسم یک فیلد را عوض میکند و سرویس دیگر بیصدا خراب میشود.
- مشتری میخواهد وضعیت سفارش را زنده ببیند. «بستهبندی شد»، «ارسال شد». الان صفحه هر ۵ ثانیه از سرور میپرسد «خبر جدید داری؟». بیشتر جوابها «نه» است.
برای مشکل اول gRPC را داریم. برای مشکل دوم SignalR را. این درس هر دو را توضیح میدهد، چون هر دو یک ویژگی مشترک دارند: اتصال طولانی.
بخش اول: gRPC برای تماس بین سرویسها
ایده
در gRPC، اول قرارداد را مینویسیم. یک فایل proto که میگوید سرویس چه متدهایی دارد و هر پیام چه فیلدهایی. از روی این فایل، کد سرور و کد کلاینت خودکار ساخته میشوند.
سه چیز gRPC را سریع و امن میکند:
- پیام باینری (Protobuf). پیامها کوچکتر از JSON هستند و خواندنشان سریعتر است.
- پروتکل HTTP/2. چند تماس همزمان روی یک اتصال میروند. لازم نیست برای هر تماس اتصال جدید باز شود.
- قرارداد سخت. اگر یک طرف قرارداد را بشکند، کد طرف دیگر کامپایل نمیشود، نه اینکه در 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);
چهار نوع تماس
- تماس ساده (Unary). یک درخواست، یک جواب. مثل بالا.
- جریان از سرور (Server streaming). یک درخواست، چند جواب پشت سر هم. مثلاً «تغییرهای قیمت را برایم بفرست».
- جریان از کلاینت (Client streaming). چند پیام از کلاینت، یک جواب در آخر. مثلاً آپلود دستهای موجودی.
- جریان دوطرفه (Bidirectional). هر دو طرف هر وقت خواستند پیام میفرستند.
دام توزیع بار
این نکته در مصاحبه زیاد پرسیده میشود. سرویس انبار را روی ۵ pod اجرا میکنیم. ولی میبینیم یک pod زیر بار است و بقیه بیکار.
چرا؟ قدم به قدم:
- کلاینت gRPC یک اتصال HTTP/2 باز میکند و آن را نگه میدارد.
- همه تماسها روی همان یک اتصال میروند.
- یک Load Balancer لایه ۴ (مثل Service معمولی در Kubernetes) فقط اتصال را میبیند، نه تماسها را. پس اتصال را یک بار به یک pod میدهد.
- نتیجه: همه تماسهای این کلاینت به همان یک pod میرسند.
راه حلها:
- پراکسی لایه ۷ که HTTP/2 را میفهمد. مثل Envoy یا یک Service Mesh مثل Linkerd. این پراکسی تکتک تماسها را پخش میکند.
- توزیع بار سمت کلاینت. کلاینت gRPC در .NET میتواند آدرس همه pod ها را بگیرد (مثلاً از DNS یک Headless Service) و خودش تماسها را بین آنها پخش کند.
بخش دوم: SignalR برای خبر زنده به کاربر
ایده
به جای اینکه مرورگر هر چند ثانیه بپرسد، یک اتصال باز میماند. هر وقت خبری شد، سرور خودش آن را میفرستد.
کتابخانه SignalR کارهای سخت را انجام میدهد:
- بهترین راه ارتباط را انتخاب میکند. اول WebSocket. اگر نشد، Server-Sent Events. اگر آن هم نشد، Long Polling.
- اتصال قطعشده را دوباره وصل میکند. اگر در کلاینت فعالش کنی.
- فرستادن به گروه را ساده میکند. به یک کاربر، به یک گروه یا به همه.
کد: 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 دیگری است. پس خبر هیچ وقت نمیرسد.
راه حلها:
- یک Backplane مثل Redis. هر pod پیام را در Redis منتشر میکند. همه pod ها آن را میگیرند و هر کدام که اتصال کاربر را دارد، میفرستد. با متد AddStackExchangeRedis بعد از AddSignalR فعال میشود.
- سرویس مدیریتشده Azure SignalR Service. اتصالها را خود این سرویس نگه میدارد. سرورهای ما فقط پیام میفرستند.
کدام را کجا استفاده کنیم؟
| REST و JSON | gRPC | SignalR | |
|---|---|---|---|
| بهترین کاربرد | API عمومی، مرورگر، همکارها | تماس داخلی بین سرویسها | خبر زنده از سرور به کاربر |
| قالب پیام | متن JSON | باینری Protobuf | متن JSON یا باینری MessagePack |
| قرارداد | جدا از کد (مثلاً OpenAPI) | فایل proto و کد ساختهشده | متدهای Hub |
| جهت ارتباط | کلاینت میپرسد | هر چهار نوع، با جریان | سرور هم میتواند شروع کند |
| از مرورگر | بله | فقط با gRPC-Web | بله |
قانونهای مهم
- در gRPC همیشه مهلت بگذار. و توکن لغو را پاس بده.
- شماره فیلدهای proto را عوض نکن. فیلد جدید با شماره جدید اضافه کن.
- برای gRPC توزیع بار در سطح تماس داشته باش. پراکسی لایه ۷ یا توزیع سمت کلاینت.
- در SignalR به کاربر یا گروه بفرست، نه به شناسه اتصال. شناسه اتصال با هر وصل شدن دوباره عوض میشود.
- برای SignalR روی چند سرور، Backplane و نشست چسبنده لازم است. یا یک سرویس مدیریتشده.
- پیام SignalR ممکن است نرسد. اگر کاربر آفلاین بود، پیام گم میشود. پس وضعیت اصلی را در دیتابیس نگه دار و بعد از وصل شدن دوباره، آن را بخوان.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| تماس gRPC بدون مهلت | با گیر کردن یک سرویس، سرویسهای دیگر هم گیر میکنند. | مهلت کوتاه برای هر تماس. |
| توزیع بار لایه ۴ برای gRPC | یک pod همه بار را میگیرد. | پراکسی لایه ۷ یا توزیع سمت کلاینت. |
| عوض کردن شماره فیلد در proto | سرویسهای قدیمی داده را غلط میخوانند. | فقط فیلد با شماره جدید اضافه کن. |
| SignalR روی چند pod بدون Backplane | خبرها به بعضی کاربرها نمیرسند. | Redis یا Azure SignalR Service. |
| تکیه کامل به پیام SignalR | کاربری که آفلاین بود، وضعیت را هرگز نمیبیند. | منبع اصلی دیتابیس است. پیام فقط خبر میدهد. |
| استفاده از gRPC برای API عمومی مرورگر | کلاینتها نمیتوانند راحت صدا بزنند. | REST و JSON برای بیرون، gRPC برای داخل. |
خلاصه در شش خط
- در gRPC قرارداد در فایل proto است و کد هر دو طرف از آن ساخته میشود.
- پیام باینری و HTTP/2 تماس بین سرویسها را سریع میکنند.
- همه تماسهای gRPC روی یک اتصال میروند. توزیع بار لایه ۴ کافی نیست.
- کتابخانه SignalR یک اتصال باز نگه میدارد تا سرور خودش خبر بفرستد.
- روی چند سرور، SignalR به Backplane و نشست چسبنده نیاز دارد.
- پیام زنده فقط خبر است. وضعیت اصلی در دیتابیس میماند.