In this article, we will create a simple concurrent gRPC chat server application. We will use .NET Core, a cross-platform, open-source, and modular framework, to build our chat server application. We will cover the following topics:
- A brief introduction to gRPC.
- Setting up the gRPC environment and defining the service contract.
- Implementing the chat service and handling client requests.
- Handling multiple clients concurrently using asynchronous programming
- Broadcasting chat messages to all connected clients in the same room.
By the end of this tutorial, you will have an understanding of how to use gRPC to build a chat server.
What Is gRPC?
gRPC is an acronym that stands for Google Remote Procedure Calls. It was initially developed by Google and is now maintained by the Cloud Native Computing Foundation (CNCF). gRPC allows you to connect, invoke, operate, and debug distributed heterogeneous applications as easily as making a local function call.
gRPC uses HTTP/2 for transport, a contract-first approach to API development, protocol Buffers (protobuf) as the interface definition language as well as its underlying message interchange format. It can support four types of API (Unary RPC, Server streaming RPC, Client streaming RPC, and Bidirectional streaming RPC). You can read more about gRPC here.
Getting Started:
Before we start to write code, an installation of .NET core needs to be done, and make sure you have the following prerequisites in place:
- Visual Studio Code, Visual Studio, or JetBrains Rider IDE.
- .NET Core.
- gRPC .NET
- Protobuf
Step 1: Create a gRPC Project From the Visual Studio or Command Line
- You can use the following command to create a new project. If successful, you should have it created in the directory you specify with the name ‘ChatServer.’
dotnet new grpc -n ChatServerApp
- Open the project with your chosen editor. I am using visual studio for Mac.
Step 2: Define the Protobuf Messages in a Proto File
Protobuf Contract:
- Create .proto file named server.proto within the protos folder. The proto file is used to define the structure of the service, including the message types and the methods that the service supports.
syntax = "proto3";
option csharp_namespace = "ChatServerApp.Protos";
package chat;
service ChatServer {
// Bidirectional communication stream between client and server
rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);
}
//Client Messages:
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 Messages
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
defines the main service of our chat application, which includes a single RPC method calledHandleCommunication
. The method is used for bidirectional streaming between the client and the server. It takes a stream ofClientMessage
as input and returns a stream ofServerMessage
as output.
service ChatServer {
// Bidirectional communication stream between client and server
rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);
}
ClientMessageLogin
, which will be sent by the client, has two fields called chat_room_id and user_name. This message type is used to send login information from the client to the server. Thechat_room_id
field specifies the chat room that the client wants to join, while theuser_name
field specifies the username that the client wants to use in the chat room
message ClientMessageLogin {
string chat_room_id = 1;
string user_name = 2;
}
ClientMessageChat
which will be used to send chat messages from the client to the server. It contains a single fieldtext
.
message ClientMessageChat {
string text = 1;
}
ClientMessage
defines the different types of messages that a client can send to the server. It contains a oneof field, which means that only one of the fields can be set at a time. if you useoneof
, the generated C# code will contain an enumeration indicating which fields have been set. The field names are “login
” and “chat
“which corresponds to theClientMessageLogin
andClientMessageChat
messages respectively
message ClientMessage {
oneof content {
ClientMessageLogin login = 1;
ClientMessageChat chat = 2;
}
}
ServerMessageLoginFailure
defines the message sent by the server to indicate that a client failed to log in to the chat room. The reason field specifies the reason for the failure.
message ServerMessageLoginFailure {
string reason = 1;
}
-
ServerMessageLoginSuccess
defines the message sent by the server to indicate that a client has successfully logged in to the chat room. It contains no fields and simply signals that the login was successful. When a client sends aClientMessageLogin
message, the server will respond with either aServerMessageLoginSuccess
message or aServerMessageLoginFailure
message, depending on whether the login was successful or not. If the login was successful, the client can then start to sendClientMessageChat
messages to start chat messages.
message ServerMessageLoginSuccess {
}
- Message
ServerMessageUserJoined
defines the message sent by the server to the client when a new user joins the chat room.
message ServerMessageUserJoined {
string user_name = 1;
}
- Message
ServerMessageChat
defines the message sent by the server to indicate that a new chat message has been received. Thetext
field specifies the content of the chat message, and theuser_name
field specifies the username of the user who sent the message.
message ServerMessageChat {
string text = 1;
string user_name = 2;
}
- Message
ServerMessage
defines the different types of messages that can be sent from the server to the client. It contains aoneof
field named content with multiple options. The field names are “login_success
,” “login_failure
,” “user_joined
,” and “chat
,” which correspond to theServerMessageLoginSuccess
,ServerMessageLoginFailure
,ServerMessageUserJoined
, andServerMessageChat
messages, respectively.
message ServerMessage {
oneof content {
ServerMessageLoginSuccess login_success = 1;
ServerMessageLoginFailure login_failure = 2;
ServerMessageUserJoined user_joined = 3;
ServerMessageChat chat = 4;
}
}
Step 3: Add a ChatService
Class
Add a ChatService
class that is derived from ChatServerBase
(generated from the server.proto file using the gRPC codegen protoc). We then override the HandleCommunication
method. The implementation of the HandleCommunication
method will be responsible for handling the communication between the client and the server.
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);
}
}
Step 4: Configure gRPC
In program.cs file:
using ChatServer.Services;
using Microsoft.AspNetCore.Server.Kestrel.Core;
var builder = WebApplication.CreateBuilder(args);
/*
// Additional configuration is required to successfully run gRPC on macOS.
// For instructions on how to configure Kestrel and gRPC clients on macOS,
// visit 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 =>
{
// Setup a HTTP/2 endpoint without TLS.
options.ListenLocalhost(50051, o => o.Protocols =
HttpProtocols.Http2);
});
// Add services to the container.
builder.Services.AddGrpc();
builder.Services.AddSingleton<ChatRoomService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
app.MapGrpcService<ChatService>();
app.MapGet("https://dzone.com/", () => "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();
Note: ASP.NET Core gRPC template and samples use TLS by default. But for development purposes, we configure Kestrel and the gRPC client to use HTTP/2 without TLS.
Step 5: Create a ChatRoomService
and Implement Various Methods Needed in HandleCommunication
The ChatRoomService
class is responsible for managing chat rooms and clients, as well as handling messages sent between clients. It uses a ConcurrentDictionary
to store chat rooms and a list of ChatClient
objects for each room. The AddClientToChatRoom
method adds a new client to a chat room, and the BroadcastClientJoinedRoomMessage
method sends a message to all clients in the room when a new client joins. The BroadcastMessageToChatRoom
method sends a message to all clients in a room except for the sender of the message.
The ChatClient
class contains a StreamWriter
object for writing messages to the client, as well as a UserName property for identifying the client.
using System;
using ChatServer;
using Grpc.Core;
using System.Collections.Concurrent;
namespace ChatServer.Services
{
public class ChatRoomService
{
private static readonly ConcurrentDictionary<string, List<ChatClient>> _chatRooms = new ConcurrentDictionary<string, List<ChatClient>>();
/// <summary>
/// Read a single message from the client.
/// </summary>
/// <exception cref="ConnectionLostException"></exception>
/// <exception cref="TimeoutException"></exception>
public async Task<ClientMessage> ReadMessageWithTimeoutAsync(IAsyncStreamReader<ClientMessage> 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();
}
}
/// <summary>
/// <summary>
/// </summary>
/// <param name="chatRoomId"></param>
/// <param name="user"></param>
/// <returns></returns>
public async Task AddClientToChatRoom(string chatRoomId, ChatClient chatClient)
{
if (!_chatRooms.ContainsKey(chatRoomId))
{
_chatRooms[chatRoomId] = new List<ChatClient> { chatClient };
}
else
{
var existingUser = _chatRooms[chatRoomId].FirstOrDefault(c => c.UserName == chatClient.UserName);
if (existingUser != null)
{
// A user with the same user name already exists in the chat room
throw new InvalidOperationException("User with the same name already exists in the chat room");
}
_chatRooms[chatRoomId].Add(chatClient);
}
await Task.CompletedTask;
}
/// <summary>
/// Broad client joined the room message.
/// </summary>
/// <param name="userName"></param>
/// <param name="chatRoomId"></param>
/// <returns></returns>
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<Task>();
foreach (var stream in _chatRooms[chatRoomId])
{
if (stream != null && stream != default)
{
tasks.Add(stream.StreamWriter.WriteAsync(message));
}
}
await Task.WhenAll(tasks);
}
}
/// <summary>
/// </summary>
/// <param name="chatRoomId"></param>
/// <param name="senderName"></param>
/// <param name="text"></param>
/// <returns></returns>
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<Task>();
var streamList = _chatRooms[chatRoomId];
foreach (var stream in _chatRooms[chatRoomId])
{
//This senderName can be something of unique Id for each user.
if (stream != null && stream != default && stream.UserName != senderName)
{
tasks.Add(stream.StreamWriter.WriteAsync(message));
}
}
await Task.WhenAll(tasks);
}
}
}
public class ChatClient
{
public IServerStreamWriter<ServerMessage> StreamWriter { get; set; }
public string UserName { get; set; }
}
}
Step 6: Finally, Implement the gRPC HandleCommunication
Method in Step 3
The HandleCommunication
receives a requestStream
from the client and sends a responseStream
back to the client. The method reads a message from the client, extracts the username and chatRoomId
, and handles two cases: a login case and a chat case.
- In the login case, the method checks if the username and
chatRoomId
are valid and sends a response message to the client accordingly. If the login is successful, the client is added to the chat room, and a broadcast message is sent to all clients in the chat room. - In the chat case, the method broadcasts the message to all clients in the chat room.
using System;
using ChatServer;
using Grpc.Core;
namespace ChatServer.Services
{
public class ChatService : ChatServer.ChatServerBase
{
private readonly ILogger<ChatService> _logger;
private readonly ChatRoomService _chatRoomService;
public ChatService(ChatRoomService chatRoomService, ILogger<ChatService> logger)
{
_chatRoomService = chatRoomService;
_logger = logger;
}
public override async Task HandleCommunication(IAsyncStreamReader<ClientMessage> requestStream, IServerStreamWriter<ServerMessage> responseStream, ServerCallContext context)
{
var userName = string.Empty;
var chatRoomId = string.Empty;
while (true)
{
//Read a message from the client.
var clientMessage = await _chatRoomService.ReadMessageWithTimeoutAsync(requestStream, Timeout.InfiniteTimeSpan);
switch (clientMessage.ContentCase)
{
case ClientMessage.ContentOneofCase.Login:
var loginMessage = clientMessage.Login;
//get username and chatRoom Id from clientMessage.
chatRoomId = loginMessage.ChatRoomId;
userName = loginMessage.UserName;
if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(chatRoomId))
{
//Send a login Failure message.
var failureMessage = new ServerMessage
{
LoginFailure = new ServerMessageLoginFailure { Reason = "Invalid username" }
};
await responseStream.WriteAsync(failureMessage);
return;
}
//Send login succes message to client
var successMessage = new ServerMessage { LoginSuccess = new ServerMessageLoginSuccess() };
await responseStream.WriteAsync(successMessage);
//Add client to chat room.
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)
{
//broad cast the message to the room
await _chatRoomService.BroadcastMessageToChatRoom(chatRoomId, userName, chatMessage.Text);
}
break;
}
}
}
}
}
Complete project directory:
That is all for part 1. In the next part 2, I will create a client project with the client implementation to complete this chat application.