В этой статье мы создадим простой консоуррентный чат-сервер на 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.’
dotnet new grpc -n ChatServerApp
- Откройте проект в выбранном вами редакторе. Я использую Visual Studio для Mac.
Шаг 2: Определение сообщений Protobuf в файле Proto
Контракт Protobuf:
- Создайте файл .proto с именем server.proto в папке protos. Файл 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
определяет основную службу нашего чат-приложения, которая включает единственный RPC-метод под названиемHandleCommunication
. Метод используется для двунаправленного потокового обмена данными между клиентом и сервером. Он принимает поток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;
}
}
Шаг 3: Добавьте класс ChatService
Добавьте класс ChatService
, который наследует от ChatServerBase
(сгенерирован из файла server.proto с использованием генератора gRPC protoc). Затем переопределите метод 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);
}
}
Шаг 4: Настройка gRPC
В файле program.cs:
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 для идентификации клиента.
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
действительными, и соответственно отправляет сообщение ответа клиенту. Если вход успешен, клиент добавляется в чат-комнату, и отправляется широковещательное сообщение всем клиентам в чат-комнате. - В случае чата, метод транслирует сообщение всем клиентам в чат-комнате.
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