בנה שרת צ'אט פשוט עם gRPC ב-.Net Core

במאמר זה, ניצור יישום שיחת gRPC מקבילית פשוטה. נשתמש ב-.NET Core, רשת חופשי פלטפורמה, פתוח, ומודולרי, לבניית יישום שיחת השרת שלנו. נכסה את הנושאים הבאים:

  • A brief introduction to gRPC
  • הקמת הסביבה של gRPC והגדרת חוזה השירות
  • יישום שירות השיחה וטיפול בבקשות הלקוח
  • טיפול במספר רב של לקוחות במקביל באמצעות תכנות אסימכרוני
  • שידור הודעות שיחה לכל הלקוחות המחוברים באותו חדר

בסוף הדרכה זו, תהיה הבנה של איך להשתמש בgRPC לבניית שרת שיחה.

מה זה gRPC?

gRPC הוא ראשי תיבות המסמל קריאות פונקציה רחוקות של Google. זה פותח במקור על ידי Google וכיום מתוחם על ידי קרן החינוך הענן העולמי (CNCF). gRPC מאפשר לך להתחבר, לקרוא, לפעול ולסדר יישומים שונים ושונות במשולב כמו קריאת פונקציה מקומית.

gRPC משתמש ב-HTTP/2 להספק, גישה ראשונה לפיתוח API, פרוטוקול בלוקים (Protobuf) כשפת ההגדרה הבינארית כמו גם צורת ההחלפה הבסיסית של הודעות. זה יכול לתמוך בארבעה סוגים של API (Unary RPC, Server Streaming RPC, Client Streaming 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 בשם content עם מספר אפשרויות. שמות השדות הם "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