Ein einfacher Chat-Server mit gRPC in .Net Core erstellen

In diesem Artikel werden wir eine einfache parallele gRPC-Chat-Server-Anwendung erstellen. Wir werden .NET Core, eine plattformübergreifende, quelloffene und modulare Framework, verwenden, um unsere Chat-Server-Anwendung zu erstellen. Wir werden die folgenden Themen behandeln:

  • A brief introduction to gRPC
  • Einrichten der gRPC-Umgebung und Definieren des Dienstvertrags
  • Implementieren des Chat-Dienstes und Behandeln von Clientanfragen
  • Behandeln mehrerer Clients parallel mithilfe asynchroner Programmierung
  • Übertragen von Chatnachrichten an alle verbundenen Clients im gleichen Raum

Am Ende dieses Tutorials werden Sie ein Verständnis dafür haben, wie man gRPC verwendet, um einen Chat-Server zu erstellen.

Was ist gRPC?

gRPC ist ein Akronym, das für Google Remote Procedure Calls steht. Es wurde ursprünglich von Google entwickelt und wird nun von der Cloud Native Computing Foundation (CNCF) gepflegt. gRPC ermöglicht es Ihnen, verteilte, heterogene Anwendungen zu verbinden, aufzurufen, zu betreiben und zu debuggen, so einfach wie einen lokalen Funktionsaufruf zu machen. 

gRPC verwendet HTTP/2 für den Transport, einen vertragsspezifisch vorrangigen Ansatz für die API-Entwicklung, Protocol Buffers (Protobuf) als Schnittstellendefinitionssprache sowie als zugrunde liegende Nachrichtenaustauschformat. Es kann vier Arten von API unterstützen (Unary RPC, Server Streaming RPC, Client Streaming RPC und Bidirektional  Streaming RPC). Weitere Informationen zu gRPC finden Sie hier.

Erste Schritte

Bevor wir mit dem Schreiben von Code beginnen, muss eine Installation von .NET Core durchgeführt werden, und stellen Sie sicher, dass die folgenden Voraussetzungen erfüllt sind:

  • Visual Studio Code, Visual Studio oder JetBrains Rider IDE
  • .NET Core
  • gRPC .NET
  • Protobuf

Schritt 1: Erstellen eines gRPC-Projekts aus Visual Studio oder der Kommandozeile

  • Sie können den folgenden Befehl verwenden, um ein neues Projekt zu erstellen. Wenn erfolgreich, sollte es im von Ihnen angegebenen Verzeichnis mit dem Namen ‚ChatServer‚ erstellt worden sein.
PowerShell

 

dotnet new grpc -n ChatServerApp

  • Öffnen Sie das Projekt mit Ihrem ausgewählten Editor. Ich verwende Visual Studio für Mac.

Schritt 2: Definieren der Protobuf-Nachrichten in einer Proto-Datei

Protobuf-Vertrag:

  1. Erstellen Sie eine .proto-Datei namens server.proto im protos-Ordner. Die Proto-Datei wird verwendet, um die Struktur des Dienstes zu definieren, einschließlich der Nachrichtentypen und der vom Dienst unterstützten Methoden. 
ProtoBuf

 

syntax = "proto3";

option csharp_namespace = "ChatServerApp.Protos";

package chat;

service ChatServer {
  // Bidirektionale Kommunikationsströme zwischen Client und Server
  rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);

}

//Client-Nachrichten:
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;
}

//Server-Nachrichten
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 definiert den Hauptservice unserer Chat-Anwendung, der eine einzige RPC-Methode namens HandleCommunication umfasst. Diese Methode dient zur bidirektionalen Streaming-Kommunikation zwischen dem Client und dem Server. Sie nimmt einen Stream von ClientMessage als Eingabe und gibt einen Stream von ServerMessage als Ausgabe zurück.
ProtoBuf

 

service ChatServer {
  // Bidirektionale Kommunikationsstream zwischen Client und Server
  rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);

}

  • ClientMessageLogin, welcher vom Client gesendet wird, hat zwei Felder namens chat_room_id und user_name. Diese Nachrichtstyp wird verwendet, um Anmeldeinformationen vom Client zum Server zu senden. Das chat_room_id Feld gibt den Chat-Raum an, den der Client beitreten möchte, während das user_name Feld den Benutzernamen angibt, den der Client im Chat-Raum verwenden möchte
ProtoBuf

 

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


  • ClientMessageChat, welcher verwendet wird, um Chat-Nachrichten vom Client zum Server zu senden. Es enthält ein einzelnes Feld text.
ProtoBuf

 

message ClientMessageChat {
  string text = 1;
}


  • ClientMessage definiert die verschiedenen Arten von Nachrichten, die ein Client an den Server senden kann. Es enthält ein oneof Feld, was bedeutet, dass jeweils nur eines der Felder gesetzt werden kann. Wenn Sie oneof verwenden, wird der generierte C#-Code eine Enumeration enthalten, die angibt, welche Felder gesetzt wurden. Die Feldnamen sind „login“ und „chat„, was den ClientMessageLogin und ClientMessageChat Nachrichten entspricht.
ProtoBuf

 

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

  • ServerMessageLoginFailure definiert die Nachricht, die der Server anzeigt, um zu signalisieren, dass ein Client beim Versuch, sich in den Chatraum einzuloggen, gescheitert ist. Der Grundfeld gibt den Grund für das Scheitern an.
ProtoBuf

 

message ServerMessageLoginFailure {
  string reason = 1;
}

  • ServerMessageLoginSuccess definiert die Nachricht, die der Server sendet, um anzuzeigen, dass ein Client erfolgreich in den Chatraum eingeloggt wurde. Sie enthält keine Felder und signalisiert lediglich, dass die Anmeldung erfolgreich war. Wenn ein Client eine ClientMessageLogin Nachricht sendet, wird der Server mit einer ServerMessageLoginSuccess Nachricht oder einer ServerMessageLoginFailure Nachricht antworten, abhängig davon, ob die Anmeldung erfolgreich war oder nicht. Wenn die Anmeldung erfolgreich war, kann der Client dann mit dem Senden von ClientMessageChat Nachrichten beginnen, um Chatnachrichten zu starten.
ProtoBuf

 

message ServerMessageLoginSuccess {
}

  • Nachricht ServerMessageUserJoined definiert die Nachricht, die der Server an den Client sendet, wenn ein neuer Benutzer dem Chatraum beitritt.
ProtoBuf

 

message ServerMessageUserJoined {
  string user_name = 1;
}

  • Nachricht ServerMessageChat definiert die Nachricht, die der Server sendet, um anzuzeigen, dass eine neue Chatnachricht empfangen wurde. Das text Feld gibt den Inhalt der Chatnachricht an, und das user_name Feld gibt den Benutzernamen des Benutzers an, der die Nachricht gesendet hat.
ProtoBuf

 

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

  • Nachricht ServerMessage definiert die verschiedenen Arten von Nachrichten, die vom Server an den Client gesendet werden können. Sie enthält ein oneof Feld namens content mit mehreren Optionen. Die Feldnamen sind „login_success,“ „login_failure,“ „user_joined,“ und „chat,“ die den ServerMessageLoginSuccess, ServerMessageLoginFailure, ServerMessageUserJoined, und ServerMessageChat Nachrichten entsprechen, jeweils.
ProtoBuf

 

message ServerMessage {
  oneof content {
	ServerMessageLoginSuccess login_success = 1;
	ServerMessageLoginFailure login_failure = 2;
	ServerMessageUserJoined user_joined = 3;
	ServerMessageChat chat = 4;
  }
}

Schritt 3: Fügen Sie eine ChatService Klasse hinzu

Fügen Sie eine ChatService Klasse hinzu, die von ChatServerBase (generiert aus der server.proto Datei unter Verwendung des gRPC Codegen protoc) abgeleitet ist. Wir überschreiben dann die HandleCommunication Methode. Die Implementierung der HandleCommunication Methode ist dafür verantwortlich, die Kommunikation zwischen dem Client und dem Server zu behandeln.

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);
    }
}

Schritt 4: Konfigurieren von gRPC

In der Datei program.cs:

C#

 

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

var builder = WebApplication.CreateBuilder(args);


/*
// Zusätzliche Konfiguration ist erforderlich, um gRPC erfolgreich unter macOS auszuführen.
// Für Anweisungen zur Konfiguration von Kestrel und gRPC-Clients unter macOS,
// besuchen Sie 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 =>
{
    // Stellen Sie eine HTTP/2-Endpunkt ohne TLS ein.
    options.ListenLocalhost(50051, o => o.Protocols =
        HttpProtocols.Http2);
});


// Fügen Sie Dienste zum Container hinzu.
builder.Services.AddGrpc();
builder.Services.AddSingleton();

var app = builder.Build();

// Konfigurieren Sie den HTTP-Anforderungs-Pipeline.
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();

Hinweis: Das ASP.NET Core gRPC-Vorlage und -Beispiele verwenden standardmäßig TLS. Für Entwicklungszwecke konfigurieren wir jedoch Kestrel und den gRPC-Client, um HTTP/2 ohne TLS zu verwenden.

Schritt 5: Erstellen Sie eine ChatRoomService und Implementieren Sie verschiedene Methoden, die in HandleCommunication benötigt werden

Die ChatRoomService-Klasse ist dafür zuständig, Chat-Räume und Clients zu verwalten sowie Nachrichten zwischen den Clients auszutauschen. Sie verwendet ein ConcurrentDictionary zur Speicherung von Chat-Räumen und eine Liste von ChatClient-Objekten für jedes Zimmer. Die AddClientToChatRoom-Methode fügt einem Chat-Raum einen neuen Client hinzu, und die BroadcastClientJoinedRoomMessage-Methode sendet eine Nachricht an alle Clients im Raum, wenn ein neuer Client beitritt. Die BroadcastMessageToChatRoom-Methode sendet eine Nachricht an alle Clients in einem Raum mit Ausnahme des Absenders der Nachricht. 

Die ChatClient-Klasse enthält ein StreamWriter-Objekt zum Schreiben von Nachrichten an den Client sowie eine UserName-Eigenschaft zur Identifizierung des Clients.

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>();

        
        /// Eine einzelne Nachricht vom Client lesen.
        /// 
        /// 
        /// 
        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)
                {
                    // Ein Benutzer mit demselben Benutzernamen existiert bereits im Chatraum
                    throw new InvalidOperationException("User with the same name already exists in the chat room");
                }
                _chatRooms[chatRoomId].Add(chatClient);
            }

            await Task.CompletedTask;
        }
        
        /// Breite Nachricht, dass ein Client dem Raum beigetreten ist.
        /// 
        /// 
        /// 
        /// 
        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])
                {
                    //Dieser senderName kann eine eindeutige ID für jeden Benutzer sein.
                    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; }
    }
}

Schritt 6: Implementieren Sie schließlich den gRPC-HandleCommunication-Methode aus Schritt 3

Die HandleCommunication-Methode empfängt einen requestStream vom Client und sendet einen responseStream zurück zum Client. Die Methode liest eine Nachricht vom Client, extrahiert den Benutzernamen und die chatRoomId und behandelt zwei Fälle: einen Anmeldefall und einen Chatfall. 

  • Im Login-Fall überprüft die Methode, ob der Benutzername und chatRoomId gültig sind, und sendet entsprechend eine Antwortnachricht an den Client. Wenn die Anmeldung erfolgreich ist, wird der Client dem Chatraum hinzugefügt und eine Broadcast-Nachricht an alle Clients im Chatraum gesendet. 
  • Im Chat-Fall sendet die Methode die Nachricht an alle Clients im Chatraum. 
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)
             {
                //Eine Nachricht vom Client lesen.
                var clientMessage = await _chatRoomService.ReadMessageWithTimeoutAsync(requestStream, Timeout.InfiniteTimeSpan);

                switch (clientMessage.ContentCase)
                {
                    case ClientMessage.ContentOneofCase.Login:

                        var loginMessage = clientMessage.Login;
                        //Benutzername und ChatRoom-ID aus der ClientNachricht abrufen.
                        chatRoomId = loginMessage.ChatRoomId;
                        userName = loginMessage.UserName;

                        if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(chatRoomId))
                        {
                            //Eine Anmeldefehler-Nachricht senden.
                            var failureMessage = new ServerMessage
                            {
                                LoginFailure = new ServerMessageLoginFailure { Reason = "Invalid username" }
                            };

                            await responseStream.WriteAsync(failureMessage);

                            return;
                        }

                        //Anmelderfolg-Nachricht an den Client senden
                        var successMessage = new ServerMessage { LoginSuccess = new ServerMessageLoginSuccess() };
                        await responseStream.WriteAsync(successMessage);

                        //Client zum Chatraum hinzufügen.
                        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)
                        {
                            //Die Nachricht im Raum verbreiten
                            await _chatRoomService.BroadcastMessageToChatRoom(chatRoomId, userName, chatMessage.Text);
                        }

                        break;
                }
            }
        }
    }
}

Kompletter Projektordner:

Das war’s für Teil 1. Im nächsten Teil 2 werde ich ein Clientprojekt mit der Clientimplementierung erstellen, um diese Chat-Anwendung zu vervollständigen.

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