在本文中,我們將創建一個簡單的並發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‘創建。
dotnet new grpc -n ChatServerApp
- 使用您選擇的編輯器打開專案。我正在使用Mac版的Visual Studio。
步驟2:在Proto文件中定義Protobuf消息
Protobuf合約:
- 在protos文件夾中創建名為server.proto的.proto文件。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
定義了我們聊天應用程式的主要服務,其中包括一個名為HandleCommunication
的單一 RPC 方法。此方法用於客戶端與伺服器之間的雙向串流。它接收一個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
定義了伺服器發送給客戶端的消息,用以指示客戶端登入聊天室失敗。其中的reason
欄位指明了失敗的原因。
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
欄位名為 content,具有多個選項。欄位名稱包括 “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;
}
}
第三步:新增 ChatService
類別
新增一個繼承自 ChatServerBase
(由 server.proto 文件透過 gRPC 代碼生成工具 protoc 生成)的 ChatService
類別。接著覆寫 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);
}
}
第四步:配置 gRPC
在 program.cs 文件中:
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屬性。
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
是否有效,並依此向客戶端發送相應的回應訊息。若登入成功,客戶端將被加入至聊天室,並向該聊天室內所有客戶端廣播訊息。 - 至於聊天情境,該方法則會向聊天室內所有客戶端廣播訊息。
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