在.Net Core中使用gRPC构建簡單聊天伺服器

在本文中,我們將創建一個簡單的並發gRPC聊天服務器應用程序。我們將使用.NET Core,一個跨平台、開源且模塊化的框架,來構建我們的聊天服務器應用程序。我們將涵蓋以下主題:

  • A brief introduction to gRPC
  • 設置gRPC環境並定義服務契約
  • 實現聊天服務並處理客戶端請求
  • 使用異步編程處理多個客戶端並發
  • 向同一房間內所有連接的客戶端廣播聊天消息

通過本教程結束時,您將了解如何使用gRPC來構建聊天服務器。

什麼是gRPC?

gRPC是代表Google遠程過程調用的縮寫。它最初由Google開發,現在由Cloud Native Computing Foundation(CNCF)維護。gRPC允許您像調用本地函數一樣輕鬆地連接、調用、操作和調試分佈式異構應用程序。

gRPC使用HTTP/2進行傳輸,採用契約優先的API開發方法,使用Protocol Buffers(Protobuf)作為接口定義語言及其底層消息交換格式。它可以支持四種類型的API(單向RPC、服務器流式RPC、客戶端流式RPC和雙向 流式RPC)。您可以在此處了解更多關於gRPC的信息

開始

在開始編寫程式碼之前,需要完成.NET Core的安裝,並確保已具備以下先決條件:

  • Visual Studio Code、Visual Studio或JetBrains Rider IDE
  • .NET Core
  • gRPC .NET
  • Protobuf

步驟1:從Visual Studio或命令行創建gRPC專案

  • 您可以使用以下命令來創建一個新專案。如果成功,應該會在您指定的目錄中以其名稱’ChatServer‘創建。
PowerShell

 

dotnet new grpc -n ChatServerApp

  • 使用您選擇的編輯器打開專案。我正在使用Mac版的Visual Studio。

步驟2:在Proto文件中定義Protobuf消息

Protobuf合約:

  1. 在protos文件夾中創建名為server.proto的.proto文件。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 定義了我們聊天應用程式的主要服務,其中包括一個名為 HandleCommunication 的單一 RPC 方法。此方法用於客戶端與伺服器之間的雙向串流。它接收一個 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“,分別對應於 ClientMessageLoginClientMessageChat 訊息
ProtoBuf

 

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

  • ServerMessageLoginFailure 定義了伺服器發送給客戶端的消息,用以指示客戶端登入聊天室失敗。其中的 reason 欄位指明了失敗的原因。
ProtoBuf

 

message ServerMessageLoginFailure {
  string reason = 1;
}

  • ServerMessageLoginSuccess 定義了 伺服器發送給客戶端的消息,用以指示客戶端已成功登入聊天室。此消息不含任何欄位,僅表示登入成功。當客戶端發送 ClientMessageLogin 消息時,伺服器將根據登入是否成功,回應 ServerMessageLoginSuccessServerMessageLoginFailure 消息。若登入成功,客戶端便可開始發送 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;
  }
}

第三步:新增 ChatService 類別

新增一個繼承自 ChatServerBase(由 server.proto 文件透過 gRPC 代碼生成工具 protoc 生成)的 ChatService 類別。接著覆寫 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);
    }
}

第四步:配置 gRPC

在 program.cs 文件中:

C#

 

using ChatServer.Services;
using Microsoft.AspNetCore.Server.Kestrel.Core;

var builder = WebApplication.CreateBuilder(args);


/*
// 在 macOS 上成功運行 gRPC 需要額外配置。
// 有關如何在 macOS 上配置 Kestrel 和 gRPC 客戶端的說明,
// 請訪問 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 =>
{
    // 設置無 TLS 的 HTTP/2 端點。
    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();

注意:ASP.NET Core gRPC 範本和範例默認使用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])
                {
                    // 此發送者名稱可以是每個用戶的唯一ID。
                    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:最後,實現步驟3中的gRPC HandleCommunication方法

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;
                        // 從clientMessage中取得用戶名稱及chatRoom Id。
                        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;
                }
            }
        }
    }
}

完整專案目錄:

至此,第一部分的介紹完畢。在接下來的第二部分,我將創建 一個客戶端專案,包含客戶端實作,以完成此聊天應用程式。

Source:
https://dzone.com/articles/create-a-concurrent-grpc-chat-messaging-in-net-7