Создание простого чат-сервера с использованием gRPC в .Net Core

В этой статье мы создадим простой консоуррентный чат-сервер на gRPC. Мы будем использовать .NET Core, кроссплатформенный, открытый и модульный фреймворк, для создания нашего чат-сервера. Мы рассмотрим следующие темы:

  • A brief introduction to gRPC
  • Настройка среды gRPC и определение контракта сервиса
  • Реализация чат-сервиса и обработка запросов клиентов
  • Обработка нескольких клиентов одновременно с использованием асинхронного программирования
  • Рассылка сообщений чата всем подключенным клиентам в одной комнате

К концу этого урока вы поймете, как использовать gRPC для создания чат-сервера.

Что такое gRPC?

gRPC это аббревиатура, которая обозначает Google Remote Procedure Calls. Изначально разработанный Google, сейчас он поддерживается Cloud Native Computing Foundation (CNCF). gRPC позволяет вам подключаться, вызывать, управлять и отлаживать распределенные гетерогенные приложения так же легко, как вызов локальной функции. 

gRPC использует HTTP/2 для транспорта, подход сначала к контракту для разработки API, Protocol Buffers (Protobuf) как язык определения интерфейса, а также формат обмена сообщениями в основе. Он может поддерживать четыре типа API (Unar RPC, Server streaming RPC, Client streaming RPC и Bidirectional  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 определяет сообщение, отправляемое сервером, чтобы указать, что клиент не смог войти в чат-комнату. Поле “reason” указывает причину неудачи.
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;
                        //Получить имя пользователя и chatRoom Id из клиентMessage.
                        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