في هذا المقال، سنقوم بإنشاء تطبيق خادم نقاش gRPC موازٍ بسيط. سنستخدم .NET Core، وهي إطار عمل قابل للتنقل بين النظام الأساسي، مفتوح المصدر، ومدعوم الوحدات، لبناء تطبيق خادم النقاش. سنغطي المواضيع التالية:
- A brief introduction to gRPC
- إعداد بيئة gRPC وتحديد عقدة الخدمة
- تنفيذ خدمة النقاش والتعامل مع طلبات العميل
- التعامل مع عدة عملاء موازيين باستخدام البرمجة التوافقية
- بث الرسائل النقاشية إلى جميع العملاء المتصلين في نفس الغرفة
بنهاية هذا البرنامج التعليمي، ستكون لديك فهم لكيفية استخدام gRPC لبناء خادم نقاش.
ما هو gRPC؟
gRPC هو اختصار لـ Google Remote Procedure Calls. تم تطويره في الأصل من قبل Google وهو الآن يُحافظ عليه من قبل مؤسسة الحوسبة المحمولة السحابية (CNCF). يتيح لك gRPC التواصل والاستدعاء والتشغيل والتصحيح في التطبيقات المتنوعة الموزعة بسهولة مثل استدعاء دالة على مستوى المكتب.
يستخدم gRPC HTTP/2 للنقل، وتبني نهج أولي لتطوير API، وبروتوكول بافو (Protobuf) كلغة تحديد الواجهة التطبيقية بالإضافة إلى تبادل رسائلها الأساسية. يمكن أن تدعم أربعة أنواع من API (الـ RPC الأحادي، والـ RPC التي تتدفق من الخادم، والـ RPC التي تتدفق من العميل، والـ RPC التي تتدفق ثنائي الاتجاه). يمكنك قراءة المزيد حول gRPC هنا.
البدء في الاستخدام
قبل أن نبدأ في كتابة التعليمات البرمجية، يجب إجراء تثبيت لـ .NET core، والتأكد من وجود المتطلبات الأساسية التالية في مكانها:
- Visual Studio Code، Visual Studio، أو JetBrains Rider IDE
- .NET Core
- gRPC .NET
- Protobuf
الخطوة 1: إنشاء مشروع gRPC من Visual Studio أو سطر الأوامر
- يمكنك استخدام الأمر التالي لإنشاء مشروع جديد. إذا نجحت، يجب أن تكون قد أنشأته في الدليل الذي تحدده بالاسم ‘ChatServer.’
dotnet new grpc -n ChatServerApp
- افتح المشروع باستخدام محررك المفضل. أنا أستخدم Visual Studio لـ Mac.
الخطوة 2: تحديد رسوم Protobuf في ملف Proto
عقد Protobuf:
- إنشاء ملف .proto باسم server.proto داخل مجلد protos. يُستخدم ملف proto لتحديد بنية الخدمة، بما في ذلك أنواع الرسائل والأساليب التي تدعمها الخدمة.
syntax = "proto3";
option csharp_namespace = "ChatServerApp.Protos";
package chat;
service ChatServer {
// تيار الاتصال الثنائي بين العميل والخادم
rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);
}
//رسائل العميل:
message ClientMessage {
oneof content {
ClientMessageLogin login = 1;
ClientMessageChat chat = 2;
}
}
message ClientMessageLogin {
string chat_room_id = 1;
string user_name = 2;
}
message ClientMessageChat {
string text = 1;
}
//رسائل الخادم
message ServerMessage {
oneof content {
ServerMessageLoginSuccess login_success = 1;
ServerMessageLoginFailure login_failure = 2;
ServerMessageUserJoined user_joined = 3;
ServerMessageChat chat = 4;
}
}
message ServerMessageLoginFailure {
string reason = 1;
}
message ServerMessageLoginSuccess {
}
message ServerMessageUserJoined {
string user_name = 1;
}
message ServerMessageChat {
string text = 1;
string user_name = 2;
}
ChatServer
يعرّف الخدمة الرئيسية لتطبيق الدردشة لدينا، والتي تشمل طرفين متكلمين (RPC) واحد يسمىHandleCommunication
. تستخدم الطريقة للتدفق الثنائي بين العميل والخادم. تأخذ تيارًا منClientMessage
كمدخلات وتعيد تيارًا منServerMessage
كمخرجات.
service ChatServer {
// تيار التواصل الثنائي بين العميل والخادم
rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);
}
ClientMessageLogin
, الذي سيتم إرساله من قبل العميل، يحتوي على حقلين يسميان chat_room_id و user_name. يستخدم هذا النوع من الرسائل لإرسال معلومات الدخول من العميل إلى الخادم. يحدد حقلchat_room_id
غرفة الدردشة التي يرغب العميل في الانضمام إليها، بينما يحدد حقلuser_name
اسم المستخدم الذي يرغب العميل في استخدامه في غرفة الدردشة
message ClientMessageLogin {
string chat_room_id = 1;
string user_name = 2;
}
ClientMessageChat
الذي سيُستخدم لإرسال رسائل الدردشة من العميل إلى الخادم. يحتوي على حقل واحد يسمىtext
.
message ClientMessageChat {
string text = 1;
}
ClientMessage
يعرّف أنواع الرسائل المختلفة التي يمكن للعميل إرسالها إلى الخادم. يحتوي على حقل oneof، مما يعني أنه يمكن تعيين حقل واحد فقط في الوقت. إذا كنت تستخدمoneof
، ستحتوي رمز C# المولّد على تعداد يشير إلى أي الحقول قد تم تعيينها. اسماء الحقول هي “login
” و “chat
” والتي تتوافق مع رسائلClientMessageLogin
وClientMessageChat
على التوالي
message ClientMessage {
oneof content {
ClientMessageLogin login = 1;
ClientMessageChat chat = 2;
}
}
ServerMessageLoginFailure
يعرّف الرسالة التي يرسلها الخادم للإشارة إلى أن العميل فشل في تسجيل الدخول إلى غرفة المحادثة. يحدد حقل السبب السبب للفشل.
message ServerMessageLoginFailure {
string reason = 1;
}
-
ServerMessageLoginSuccess
يعرّف الرسالة التي يرسلها الخادم للإشارة إلى أن العميل نجح في تسجيل الدخول إلى غرفة المحادثة. لا تحتوي على حقول ويشير ببساطة إلى أن تسجيل الدخول كان ناجحًا. عندما يرسل العميل رسالةClientMessageLogin
، سيستجيب الخادم إما برسالةServerMessageLoginSuccess
أو رسالةServerMessageLoginFailure
، حسب نجاح أو فشل التسجيل. إذا نجح التسجيل، يمكن للعميل بعد ذلك بدء إرسال رسائلClientMessageChat
لبدء رسائل المحادثة.
message ServerMessageLoginSuccess {
}
- رسالة
ServerMessageUserJoined
تعرّف الرسالة التي يرسلها الخادم إلى العميل عندما ينضم مستخدم جديد إلى غرفة المحادثة.
message ServerMessageUserJoined {
string user_name = 1;
}
- رسالة
ServerMessageChat
تعرّف الرسالة التي يرسلها الخادم للإشارة إلى أن رسالة محادثة جديدة قد تم الإستلام. يحدد حقلtext
محتوى رسالة المحادثة، ويحدد حقلuser_name
اسم المستخدم الذي أرسل الرسالة.
message ServerMessageChat {
string text = 1;
string user_name = 2;
}
- رسالة
ServerMessage
تعرّف أنواع الرسائل المختلفة التي يمكن إرسالها من الخادم إلى العميل. تحتوي على حقلoneof
يسمى المحتوى مع عدة اختيارات. اسماء الحقول هي “login_success
,” “login_failure
,” “user_joined
,” و “chat
“، والتي تتوافق مع رسائلServerMessageLoginSuccess
,ServerMessageLoginFailure
,ServerMessageUserJoined
, وServerMessageChat
على التوالي.
message ServerMessage {
oneof content {
ServerMessageLoginSuccess login_success = 1;
ServerMessageLoginFailure login_failure = 2;
ServerMessageUserJoined user_joined = 3;
ServerMessageChat chat = 4;
}
}
الخطوة 3: إضافة فئة ChatService
أضف فئة ChatService
تنحدر من ChatServerBase
(المولدة من ملف server.proto باستخدام مولد gRPC protoc). ثم نكتب مكررة للطريقة HandleCommunication
. ستكون تنفيذ الطريقة HandleCommunication
مسؤولًا عن معالجة الاتصال بين العميل والخادم.
public class ChatService : ChatServerBase
{
private readonly ILogger<ChatService> _logger;
public ChatService(ILogger<ChatService> logger)
{
_logger = logger;
}
public override Task HandleCommunication(IAsyncStreamReader<ClientMessage> requestStream, IServerStreamWriter<ServerMessage> responseStream, ServerCallContext context)
{
return base.HandleCommunication(requestStream, responseStream, context);
}
}
الخطوة 4: تكوين gRPC
في ملف program.cs:
using ChatServer.Services;
using Microsoft.AspNetCore.Server.Kestrel.Core;
var builder = WebApplication.CreateBuilder(args);
/*
// تحتاج إلى تكوين إضافي لتشغيل gRPC بنجاح على macOS.
// لمعلومات حول كيفية تكوين Kestrel وعملاء gRPC على macOS،
// زيارة الرابط https://go.microsoft.com/fwlink/?linkid=2099682
To avoid missing ALPN support issue on Mac. To work around this issue, configure Kestrel and the gRPC client to use HTTP/2 without TLS.
You should only do this during development. Not using TLS will result in gRPC messages being sent without encryption.
https://learn.microsoft.com/en-us/aspnet/core/grpc/troubleshoot?view=aspnetcore-7.0
*/
builder.WebHost.ConfigureKestrel(options =>
{
// قم بإعداد نقطة HTTP/2 بدون TLS.
options.ListenLocalhost(50051, o => o.Protocols =
HttpProtocols.Http2);
});
// أضف الخدمات إلى الحاوية.
builder.Services.AddGrpc();
builder.Services.AddSingleton();
var app = builder.Build();
// قم بتكوين طريقة الطلب التفاعلي HTTP.
app.MapGrpcService();
app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
Console.WriteLine($"gRPC server about to listening on port:50051");
app.Run();
ملاحظة: قالب gRPC لـ ASP.NET Core والعينات تستخدم TLS افتراضيًا. ولكن لأغراض التطوير، نقوم بتكوين Kestrel والعميل gRPC لاستخدام HTTP/2 بدون TLS.
خطوة 5: إنشاء ChatRoomService
وتنفيذ طرق مختلفة مطلوبة في HandleCommunication
يكفل الطبقة ChatRoomService
إدارة الغرف الدردشة والعملاء، وكذلك معالجة الرسائل المرسلة بين العملاء. يستخدم ConcurrentDictionary
لتخزين الغرف الدردشة وقائمة من كائنات ChatClient
لكل غرفة. تضيف طريقة AddClientToChatRoom
عميلًا جديدًا إلى غرفة دردشة، وترسل طريقة BroadcastClientJoinedRoomMessage
رسالة إلى جميع العملاء في الغرفة عندما ينضم عميل جديد. ترسل طريقة BroadcastMessageToChatRoom
رسالة إلى جميع العملاء في غرفة ما باستثناء مرسل الرسالة.
تحتوي الطبقة ChatClient
على كائن StreamWriter
لكتابة الرسائل إلى العميل، بالإضافة إلى خاصية UserName لتحديد العميل.
using System;
using ChatServer;
using Grpc.Core;
using System.Collections.Concurrent;
namespace ChatServer.Services
{
public class ChatRoomService
{
private static readonly ConcurrentDictionary> _chatRooms = new ConcurrentDictionary>();
/// اقرأ رسالة واحدة من العميل.
///
///
///
public async Task ReadMessageWithTimeoutAsync(IAsyncStreamReader requestStream, TimeSpan timeout)
{
CancellationTokenSource cancellationTokenSource = new();
cancellationTokenSource.CancelAfter(timeout);
try
{
bool moveNext = await requestStream.MoveNext(cancellationTokenSource.Token);
if (moveNext == false)
{
throw new Exception("connection dropped exception");
}
return requestStream.Current;
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled)
{
throw new TimeoutException();
}
}
///
///
///
///
public async Task AddClientToChatRoom(string chatRoomId, ChatClient chatClient)
{
if (!_chatRooms.ContainsKey(chatRoomId))
{
_chatRooms[chatRoomId] = new List { chatClient };
}
else
{
var existingUser = _chatRooms[chatRoomId].FirstOrDefault(c => c.UserName == chatClient.UserName);
if (existingUser != null)
{
// هناك مستخدم بنفس اسم المستخدم موجود بالفعل في غرفة الحوار
throw new InvalidOperationException("User with the same name already exists in the chat room");
}
_chatRooms[chatRoomId].Add(chatClient);
}
await Task.CompletedTask;
}
/// نشر رسالة الانضمام إلى الغرفة للعميل الشامل.
///
///
///
///
public async Task BroadcastClientJoinedRoomMessage(string userName, string chatRoomId)
{
if (_chatRooms.ContainsKey(chatRoomId))
{
var message = new ServerMessage { UserJoined = new ServerMessageUserJoined { UserName = userName } };
var tasks = new List();
foreach (var stream in _chatRooms[chatRoomId])
{
if (stream != null && stream != default)
{
tasks.Add(stream.StreamWriter.WriteAsync(message));
}
}
await Task.WhenAll(tasks);
}
}
///
///
///
///
///
public async Task BroadcastMessageToChatRoom(string chatRoomId, string senderName, string text)
{
if (_chatRooms.ContainsKey(chatRoomId))
{
var message = new ServerMessage { Chat = new ServerMessageChat { UserName = senderName, Text = text } };
var tasks = new List();
var streamList = _chatRooms[chatRoomId];
foreach (var stream in _chatRooms[chatRoomId])
{
//هذا senderName يمكن أن يكون شيئًا من الهوية الفريدة لكل مستخدم.
if (stream != null && stream != default && stream.UserName != senderName)
{
tasks.Add(stream.StreamWriter.WriteAsync(message));
}
}
await Task.WhenAll(tasks);
}
}
}
public class ChatClient
{
public IServerStreamWriter StreamWriter { get; set; }
public string UserName { get; set; }
}
}
الخطوة 6: وأخيرًا، قم بتنفيذ طريقة gRPC HandleCommunication
في الخطوة 3
الطريقة HandleCommunication
تتلقى requestStream
من العميل وترسل responseStream
إلى العميل. الطريقة تقرأ رسالة من العميل، تستخرج اسم المستخدم و chatRoomId
، وتتعامل مع حالتين: حالة تسجيل الدخول وحالة الحوار.
- في حالة الدخول، يتحقق الطريقة مما إذا كان اسم المستخدم و
chatRoomId
صالحين ويرسل رسالة رد فعل إلى العميل وفقًا لذلك. إذا نجح الدخول، يتم إضافة العميل إلى غرفة المحادثة، ويتم إرسال رسالة بث مباشر إلى جميع العملاء في غرفة المحادثة. - في حالة المحادثة، يتم بث الرسالة إلى جميع العملاء في غرفة المحادثة.
using System;
using ChatServer;
using Grpc.Core;
namespace ChatServer.Services
{
public class ChatService : ChatServer.ChatServerBase
{
private readonly ILogger _logger;
private readonly ChatRoomService _chatRoomService;
public ChatService(ChatRoomService chatRoomService, ILogger logger)
{
_chatRoomService = chatRoomService;
_logger = logger;
}
public override async Task HandleCommunication(IAsyncStreamReader requestStream, IServerStreamWriter responseStream, ServerCallContext context)
{
var userName = string.Empty;
var chatRoomId = string.Empty;
while (true)
{
//قراءة رسالة من العميل.
var clientMessage = await _chatRoomService.ReadMessageWithTimeoutAsync(requestStream, Timeout.InfiniteTimeSpan);
switch (clientMessage.ContentCase)
{
case ClientMessage.ContentOneofCase.Login:
var loginMessage = clientMessage.Login;
//الحصول على اسم المستخدم ومعرف غرفة المحادثة من الرسالة المستلمة.
chatRoomId = loginMessage.ChatRoomId;
userName = loginMessage.UserName;
if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(chatRoomId))
{
//إرسال رسالة خطأ دخول.
var failureMessage = new ServerMessage
{
LoginFailure = new ServerMessageLoginFailure { Reason = "Invalid username" }
};
await responseStream.WriteAsync(failureMessage);
return;
}
//إرسال رسالة نجاح دخول إلى العميل
var successMessage = new ServerMessage { LoginSuccess = new ServerMessageLoginSuccess() };
await responseStream.WriteAsync(successMessage);
//إضافة العميل إلى غرفة المحادثة.
await _chatRoomService.AddClientToChatRoom(chatRoomId, new ChatClient
{
StreamWriter = responseStream,
UserName = userName
});
break;
case ClientMessage.ContentOneofCase.Chat:
var chatMessage = clientMessage.Chat;
if (userName is not null && chatRoomId is not null)
{
//بث الرسالة إلى الغرفة
await _chatRoomService.BroadcastMessageToChatRoom(chatRoomId, userName, chatMessage.Text);
}
break;
}
}
}
}
}
دليل المشروع المكتمل:
هذا كل شيء بالنسبة لـ الجزء 1. في الجزء التالي الجزء 2، سأقوم بإنشاء مشروع عميل بتنفيذ العميل لإكمال تطبيق محادثة هذه.
Source:
https://dzone.com/articles/create-a-concurrent-grpc-chat-messaging-in-net-7