Поиск  
Always will be ready notify the world about expectations as easy as possible: job change page
Jun 8, 2023

.NET gRPC — Simple chat application with gRPC

Автор:
Behdad Kardgar
Источник:
Просмотров:
8178

.NET gRPC

This is my first article about gRPC on ASP.NET 6. In this article, I will give a short introduction to what gRPC is and the different types of communication in gRPC. In the end, I will share a simple console chat application and briefly explain how it works. The link to the codebase can be found here in my Github Repo.

What is gRPC?

In gRPC, a client application can directly call a method on a server application on a different machine as if it were a local object, making it easier for you to create distributed applications and services. As in many RPC systems, gRPC is based around the idea of defining a service, specifying the methods that can be called remotely with their parameters and return types. On the server side, the server implements this interface and runs a gRPC server to handle client calls. On the client side, the client has a stub (referred to as just a client in some languages) that provides the same methods as the server.

gRPC clients and servers can run and talk to each other in a variety of environments — from servers inside Google to your own desktop — and can be written in any of gRPC’s supported languages. So, for example, you can easily create a gRPC server in Java with clients in Go, Python, or Ruby. In addition, the latest Google APIs will have gRPC versions of their interfaces, letting you easily build Google functionality into your applications.

It is focused on high performance and uses the HTTP/2 protocol to transport binary messages. It also relies on the Protocol Buffers language to define service contracts. Protocol Buffers, also known as Protobuf, allow you to define the interface to be used in service to service communication independently from the programming language. Many tools for the most common programming languages are available to translate these Protobuf interfaces into code.

There are 4 types of service communications in gRPC which I will explain down below.

Unary RPC

This is the simplest type of RPC where the client sends a single request and gets back a single response.

  1. Once the client calls a stub method, the server is notified that the RPC has been invoked with the client’s metadatafor this call, the method name, and the specified deadline if applicable.
  2. The server can then either send back its own initial metadata (which must be sent before any response) straight away, or wait for the client’s request message. Which happens first, is application-specific.
  3. Once the server has the client’s request message, it does whatever work is necessary to create and populate a response. The response is then returned (if successful) to the client together with status details (status code and optional status message) and optional trailing metadata.
  4. If the response status is OK, then the client gets the response, which completes the call on the client side.

Server streaming RPC

A server-streaming RPC is similar to a unary RPC, except that the server returns a stream of messages in response to a client’s request. After sending all its messages, the server’s status details (status code and optional status message) and optional trailing metadata are sent to the client. This completes processing on the server side. The client completes once it has all the server’s messages.

Client streaming RPC

A client-streaming RPC is similar to a unary RPC, except that the client sends a stream of messages to the server instead of a single message. The server responds with a single message (along with its status details and optional trailing metadata), typically but not necessarily after it has received all the client’s messages.

Bidirectional streaming RPC

In a bidirectional streaming RPC, the call is initiated by the client invoking the method and the server receiving the client metadata, method name, and deadline. The server can choose to send back its initial metadata or wait for the client to start streaming messages.

Client- and server-side stream processing is application specific. Since the two streams are independent, the client and server can read and write messages in any order. For example, a server can wait until it has received all of a client’s messages before writing its messages, or the server and client can play “ping-pong” — the server gets a request, then sends back a response, then the client sends another request based on the response, and so on.

Bidirectional streaming RPC

The gRPC framework allows developers to create services that can communicate with each other efficiently and independently from their preferred programming language. Once you define a contract with Protobuf, this contract can be used by each service to automatically generate the code that sets up the communication infrastructure. This aspect simplifies the creation of services interaction and, together with the high performance, makes gRPC the ideal framework to create microservices.

Chat application with gRPC

In my solution, all the services are made by .NET and the server is responsible for broadcasting the messages between support and client. If I have to summarize the entire project in one word I would just say STREAMING. As I explained in the text above Bi-directional streaming is in gRPC and what it does, it can give us a huge advantage when it comes to sending data between client and server in any order. The server can easily broadcast the message via the stream while the connection is open. What I tried to do in this application was to switch the streams between User A and User B in the server to let the server broadcast the User A messages to User B and vice versa! This sounds really easy! right?

Chat application with gRPC

This application is a customer support console app where a customer connects to a support engineer to ask for help. As you can see in the above image the project has 2 clients (Customer and Support). The customer uses the support id to ask the chat server to connect the customer to the support. This is where the stream switching happens. The server switches the streams between support and customer and saves them into in-memory storage. After this step, the customer and support can easily send messages to each other by writing into the stream saved in the in-memory storage by their ids.

Please keep in mind that the purpose of this project is to show how gRPC streaming works that’s why error handling and https were not covert fully in this project.

Chat.proto

In chat.proto file we define the chat messages including the rpc service. ChatMessage has information about the sender_id, sender_name, and the message. Another important message in this file is ConnetChannelRequest. In this message, we need to send the customer_id and support_id and we use this message in the ConnectToChannel method to connect the support and customer. I will get to it in more detail when I am explaining the Client service.

syntax = "proto3";
import "google/protobuf/empty.proto";

package chat;

message ChatMessage {
 string sender_id = 1;
 string sender_name = 2;
 string message = 3;
}

message ChatMessageRequest{
 ChatMessage chatMessage = 1;
}

message ChatMessageResponse {
 ChatMessage chatMessage = 1;
}

message ConnetChannelRequest {
 string customer_id = 1;
 string support_id = 2;
}

message DisconnetChannelRequest {
 string customer_id = 1;
 string support_id = 2;
}

service ChatService {
 rpc SendChatMessage(stream ChatMessageRequest) returns (stream ChatMessageResponse) {}
 rpc ConnectToChannel(ConnetChannelRequest) returns (google.protobuf.Empty) {}
 rpc DisconnectToChannel (DisconnetChannelRequest) returns (google.protobuf.Empty) {}
}

Support.proto

In Support.proto we have messages that can pass the support information. In this service, we should be able to add a support engineer, get the available support engineer, and change the status of the support.

syntax = "proto3";
import "google/protobuf/empty.proto";
package support;

enum AvailabiltyStatus {
 none = 0;
 available = 1;
 busy = 2;
}

message SupportDetail {
 string id = 1;
 string firstname = 2;
 string lastname = 3;
 string title = 4;
 string department = 5;
 AvailabiltyStatus status = 6;
}

message AddSupportEngineerRequest {
 SupportDetail detail = 1;
}

message AddSupportEngineerResponse {
 string support_id = 1;
}

message GetAvailableSupportEngineerRequest {

}

message GetAvailableSupportEngineerResponse {
 SupportDetail SupportDetail = 1;
}
message SetSupportEngineerStatusToAvailableResponse {
 string support_id = 1;
}

service SupportService {
 rpc AddSupportEngineer (AddSupportEngineerRequest) returns (AddSupportEngineerResponse) {}
 rpc GetAvailableSupportEngineer (GetAvailableSupportEngineerRequest) returns (GetAvailableSupportEngineerResponse) {}
 rpc SetSupportEngineerStatusToAvailable(SetSupportEngineerStatusToAvailableResponse) returns (google.protobuf.Empty) {}
}

Support Service

In support service (Program.cs) we first need to create a channel to our server. The SetupChannelAsync method is responsible to create a channel and connect it to the server.

private static async Task<Channel?> SetupChannelAsync()
{
    var channel = new Channel("localhost:5001", ChannelCredentials.Insecure);

    await channel.ConnectAsync().ContinueWith((t) =>
    {
        if (t.Status == TaskStatus.RanToCompletion)
        {
           Console.WriteLine("The client connected successfully.");
        }
    });

     return channel;
}

After connecting to the server we need to connect the channel to the services we need.

var suppportClient = new SupportService.SupportServiceClient(channel);
var chatClient = new ChatService.ChatServiceClient(channel);

Now it's time to ask the support engineers to enter their information.

private static (string firstname, string lastname) GetEngineerData()
{
    Console.WriteLine("Please enter your first name:");
    string firstname = Console.ReadLine() ?? string.Empty;
    while (string.IsNullOrEmpty(firstname))
    {
         Console.WriteLine("Please enter a valid firstname. Firstname cannot be null or empty.");
         firstname = Console.ReadLine() ?? string.Empty;
    }
    Console.WriteLine("Please enter your last name:");
    string lastname = Console.ReadLine() ?? string.Empty;
    while (string.IsNullOrEmpty(lastname))
    {
         Console.WriteLine("Please enter a valid lastname. Lastname cannot be null or empty.");
         lastname = Console.ReadLine() ?? string.Empty;
    }
    Console.Clear();
    Console.Title = $"{firstname} {lastname}";

    return (firstname, lastname);
}

After having the support engineer’s data it's time to send it to the server to save it in our in-memory database. For simplicity, I decided to have an in-memory database as gRPC bi-directional functionality is what I wanted to explore in this demo.

var addSupportEngineerRequest = GetAddSupportEngineerRequestObject(firstname, lastname);
var addedSupportEngineerResponse = suppportClient.AddSupportEngineer(addSupportEngineerRequest);

Now our support engineer is ready to connect to our chat service. As we just saw in our chat.proto, SendChatMessage sends a stream and returns another stream object. The send stream is responsible to send data to the server and the response stream is what the server uses to send data to the support service. Keep the response stream in mind because this is really important in our application.

We need to create a task to read all the responses coming from the server and write them to our console app.

var chatResponse = Task.Run(async () =>
{
    while (await sendChatMessageStream.ResponseStream.MoveNext())
    {
       ShowReceivedMessage(sendChatMessageStream.ResponseStream.Current.ChatMessage);
    }
});

Now we need to have a look at our send SendMessageAsync method. As you can see in this method you only write in streamWriter which is a RequestStream and gRPC will deliver the message to our servers.

private static async Task SendMessageAsync(IClientStreamWriter<ChatMessageRequest> streamWriter, string senderId, string firstname, string lastname, string message)
{
    await streamWriter.WriteAsync(new ChatMessageRequest
    {
        ChatMessage = new ChatMessage
        {
            SenderId = senderId,
            Message = message,
            SenderName = $"{firstname} {lastname}"
        }
    });
}

Now let's use our SendMessageAsync method in our Support service. We send an empty message to the server only to save the stream to an in-memory dataset. I will get into this part when I am explaining Server service.

await SendMessageAsync(sendChatMessageStream.RequestStream, addedSupportEngineerResponse.SupportId, firstname, lastname, string.Empty);

Now we need to have an infinite loop to keep sending messages to our server as long as we are trying.

var message = Console.ReadLine();
PreviewSentMessage(message);
while (!string.Equals(message, "qw!", StringComparison.OrdinalIgnoreCase))
{
    await SendMessageAsync(sendChatMessageStream.RequestStream, addedSupportEngineerResponse.SupportId, firstname, lastname, message);
    message = Console.ReadLine();
    PreviewSentMessage(message);
}
await sendChatMessageStream.RequestStream.CompleteAsync();

This is pretty much all we have in our support service.

Customer Service

Our Customer service is 90% the same as our support service so it makes more sense to only cover the part which is not in the support service to keep this article as small as possible. After connecting to the server channel and Getting the customer information we need to request the server to get the available Support engineer.

var availableEnigneer = await supportClient.GetAvailableSupportEngineerAsync(new GetAvailableSupportEngineerRequest());

After getting the support engineer's information we need to connect the Customer and Support. The below method will do the job

private static async Task ConnectCustomerToSupportAsync(ChatService.ChatServiceClient chatClient, string customerId, string supportId)
{
    await chatClient.ConnectToChannelAsync(new ConnetChannelRequest
    {
        CustomerId = customerId,
        SupportId = supportId
    });
}

Now let's jump to the Server service to show what happens in the ConnectToChannelAsync method.

Server Service

In ChatServiceImpl.cs we have the implementation of the ChatService. I also create a data provider to handle the in-memory data storage. As I just explained in Customer service we try to connect the customer and support in the ConnectToChannel method.

public override Task<Empty> ConnectToChannel(ConnetChannelRequest request, ServerCallContext context)
{
    var supportId = request.SupportId;
    var customerId = request.CustomerId;
    _chatDataProvider.ConnetUserToReceiverStream(customerId, supportId);

    return Task.FromResult(new Empty());
}

Let's have a look at what happens in the data provider. If you remember we saved the customer and support stream into an in-memory dataset. In SaveUserInfo we save the user info into a dictionary.

private readonly Dictionary<string, IServerStreamWriter<ChatMessageResponse>> _streamDic;

...

public void SaveUserInfo(string userId, IServerStreamWriter<ChatMessageResponse> CustomerResponseStream)
{
     if (!_userInfo.Any(x => x.UserId == userId))
     {
         _userInfo.Add(new UserInfo
          {
              UserId = userId,
              Stream = CustomerResponseStream
          });
      }
      else
      {
          var channel = _userInfo.FirstOrDefault(x => x.UserId == userId);
          if (channel == null)
               throw new RpcException(new Status(StatusCode.Internal, "Something went wrong while trying to connect to the channel"));

           channel.Stream = CustomerResponseStream;
      }
}

We are saving the user ChatMessageResponse stream into a dictionary so that later we can switch the Customer and Support streams when we are connecting them in the ConnetUserToReceiverStream method.

public void ConnetUserToReceiverStream(string customerId, string supportId)
{
     var customer = _userInfo.FirstOrDefault(x => x.UserId == customerId);
     var support = _userInfo.FirstOrDefault(x => x.UserId == supportId);

     if (customer == null)
          throw new RpcException(new Status(StatusCode.Internal, $"Something went wrong while trying to connect to the customer {customerId} stream"));

     if (support == null)
          throw new RpcException(new Status(StatusCode.Internal, $"Something went wrong while trying to connect to the suppport {supportId} stream"));

     _streamDic.Add(support.UserId, customer.Stream);
     _streamDic.Add(customer.UserId, support.Stream);
}

After exchanging the streams between customer and support they can send each other messages by writing into each other’s stream.

public async void SendAsync(string senderId, string message, string senderName)
{
    if (_streamDic.TryGetValue(senderId, out var stream))
    {
         await stream.WriteAsync(new ChatMessageResponse
         {
             ChatMessage = new ChatMessage
             {
                 Message = message,
                 SenderName = senderName,
                 SenderId = senderId
              }
          });
     }
     else
     {
          throw new RpcException(new Status(StatusCode.Internal, "Could not find user."));
     }
}

That's pretty much it. Please have a look at the Github repo to see the entire codebase.

How to run?

Step one is to run the server service. After the server service is up and running you need to run the support project. After running the support project the console asks you to enter the first and last name of the support engineer. When the server has the data of the support engineer it's time to run the customer project. Here you will be asked to enter your first and last name again. Then you will be connected to the Support engineer and it's ready to chat. See the short video below.

gRPC application

References

Похожее
Jul 26, 2023
Author: Jason Watmore
Built with .NET 7.0, Dapper 2.0 and PostgreSQL In this tutorial we'll show how to build a .NET 7.0 (ASP.NET Core) API with Dapper and PostgreSQL that supports CRUD operations. Tutorial contents Example API overview Tools required to run the...
Sep 10, 2023
Author: Sriram Kumar Mannava
In a situation where we need to modify our API’s structure or functionality while ensuring that existing API clients remain unaffected, the solution is versioning. We can designate our current APIs as the older version and introduce all intended changes...
Aug 19
Author: Mukesh Murugan
In this guide, we will learn how to implement Advanced Pagination in ASP.NET Core WebApi with ease. Pagination is one of the most important concepts while building RESTful APIs. You would have seen several public APIs implementing this feature for...
Oct 26, 2023
Author: Matt Bentley
How to implement CQRS in ASP.NET using MediatR. A guided example using CQRS with separate Read and Write models using Enity Framework Core for Commands and Dapper for Queries. When people think about CQRS they often think about complex, event-driven,...
Написать сообщение
Тип
Почта
Имя
*Сообщение