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.
Step 4: Configure gRPC
In program.cs file:
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> _chatRooms = new ConcurrentDictionary>(); /// /// Read a single message from the client. /// /// /// 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) { // 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; } /// /// Broad client joined the room message. /// /// /// /// 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]) { //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 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.
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.
Opinions expressed by MaximusDevs contributors are their own.