إنشاء خادم دردشة بسيط باستخدام gRPC في .Net Core

في هذا المقال، سنقوم بإنشاء تطبيق خادم نقاش 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.’
PowerShell

 

dotnet new grpc -n ChatServerApp

  • افتح المشروع باستخدام محررك المفضل. أنا أستخدم Visual Studio لـ Mac.

الخطوة 2: تحديد رسوم Protobuf في ملف Proto

عقد Protobuf:

  1. إنشاء ملف .proto باسم server.proto داخل مجلد protos. يُستخدم ملف proto لتحديد بنية الخدمة، بما في ذلك أنواع الرسائل والأساليب التي تدعمها الخدمة. 
ProtoBuf

 

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 كمخرجات. 
ProtoBuf

 

service ChatServer {
  // تيار التواصل الثنائي بين العميل والخادم
  rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);

}

  • ClientMessageLogin, الذي سيتم إرساله من قبل العميل، يحتوي على حقلين يسميان chat_room_id و user_name. يستخدم هذا النوع من الرسائل لإرسال معلومات الدخول من العميل إلى الخادم. يحدد حقل chat_room_id غرفة الدردشة التي يرغب العميل في الانضمام إليها، بينما يحدد حقل user_name اسم المستخدم الذي يرغب العميل في استخدامه في غرفة الدردشة
ProtoBuf

 

message ClientMessageLogin {
  string chat_room_id = 1;
  string user_name = 2;
}


  • ClientMessageChat الذي سيُستخدم لإرسال رسائل الدردشة من العميل إلى الخادم. يحتوي على حقل واحد يسمى text.
ProtoBuf

 

message ClientMessageChat {
  string text = 1;
}


  • ClientMessage يعرّف أنواع الرسائل المختلفة التي يمكن للعميل إرسالها إلى الخادم. يحتوي على حقل oneof، مما يعني أنه يمكن تعيين حقل واحد فقط في الوقت. إذا كنت تستخدم oneof، ستحتوي رمز C# المولّد على تعداد يشير إلى أي الحقول قد تم تعيينها. اسماء الحقول هي “login” و “chat” والتي تتوافق مع رسائل ClientMessageLogin و ClientMessageChat على التوالي
ProtoBuf

 

message ClientMessage {
  oneof content {
	ClientMessageLogin login = 1;
	ClientMessageChat chat = 2;
  }
}

  • ServerMessageLoginFailure يعرّف الرسالة التي يرسلها الخادم للإشارة إلى أن العميل فشل في تسجيل الدخول إلى غرفة المحادثة. يحدد حقل السبب السبب للفشل.
ProtoBuf

 

message ServerMessageLoginFailure {
  string reason = 1;
}

  • ServerMessageLoginSuccess يعرّف الرسالة التي يرسلها الخادم للإشارة إلى أن العميل نجح في تسجيل الدخول إلى غرفة المحادثة. لا تحتوي على حقول ويشير ببساطة إلى أن تسجيل الدخول كان ناجحًا. عندما يرسل العميل رسالة ClientMessageLogin، سيستجيب الخادم إما برسالة ServerMessageLoginSuccess أو رسالة ServerMessageLoginFailure، حسب نجاح أو فشل التسجيل. إذا نجح التسجيل، يمكن للعميل بعد ذلك بدء إرسال رسائل ClientMessageChat لبدء رسائل المحادثة.
ProtoBuf

 

message ServerMessageLoginSuccess {
}

  • رسالة ServerMessageUserJoined تعرّف الرسالة التي يرسلها الخادم إلى العميل عندما ينضم مستخدم جديد إلى غرفة المحادثة.
ProtoBuf

 

message ServerMessageUserJoined {
  string user_name = 1;
}

  • رسالة ServerMessageChat تعرّف الرسالة التي يرسلها الخادم للإشارة إلى أن رسالة محادثة جديدة قد تم الإستلام. يحدد حقل text محتوى رسالة المحادثة، ويحدد حقل user_name اسم المستخدم الذي أرسل الرسالة.
ProtoBuf

 

message ServerMessageChat {
  string text = 1;
  string user_name = 2;
}

  • رسالة ServerMessage تعرّف أنواع الرسائل المختلفة التي يمكن إرسالها من الخادم إلى العميل. تحتوي على حقل oneof يسمى المحتوى مع عدة اختيارات. اسماء الحقول هي “login_success,” “login_failure,” “user_joined,” و “chat“، والتي تتوافق مع رسائل ServerMessageLoginSuccess, ServerMessageLoginFailure, ServerMessageUserJoined, و ServerMessageChat على التوالي.
ProtoBuf

 

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 مسؤولًا عن معالجة الاتصال بين العميل والخادم.

C#

 

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:

C#

 

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 لتحديد العميل.

C#

 

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 صالحين ويرسل رسالة رد فعل إلى العميل وفقًا لذلك. إذا نجح الدخول، يتم إضافة العميل إلى غرفة المحادثة، ويتم إرسال رسالة بث مباشر إلى جميع العملاء في غرفة المحادثة. 
  • في حالة المحادثة، يتم بث الرسالة إلى جميع العملاء في غرفة المحادثة. 
C#

 

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