Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Publish/Subscribe gRPC Relay Server with Separation of Concerns

5.00/5 (9 votes)
28 Jan 2023MIT10 min read 8.1K  
The article gives examples of Relay Server usage and the separation of concerns between different topics.

Introduction

Relay Server

Relay server passes messages between the publishing and subscribed clients. The message itself is not changed by the server - only channeled from a publishing client to a set of subscribed clients.

A simple relay server (and clients) are described in Simple Relay gRPC Examples. The article was a gRPC refresher and was only of learning value, so, the server described in that article was too simple to be used in practical application - all the published messages were being sent to every subscribed client - there is no separation, e.g., by topics.

The Relay Server described here allows separating the messages by topics - only clients subscribed to a topic will get messages published to that topic. This server can be used for communications between different processes on the same or different machines.

Separation of Concerns

Separating concerns, means splitting your product/project into a set of almost independent parts, each of which can be developed, debugged, maintained and extended almost independently of the rest of them, so that fixing or extending one part will not result in problems or changes within the rest of the product.

One of the most important tasks of the project's architect is to figure out the way to achieve the best separation of concerns so that the individual developers would not be constantly stepping on each others toes.

I often wrote about using IoC and plugin architecture for separating concerns. The main idea behind plugin architecture is that the individual plugins are independent of each other, though they might depend on common projects containing shared interfaces and functionality.

This article continues talking about the separation of concerns, this time applied to gRPC Relay Server and Clients.

We shall show a gRPC Relay server that can be easily extended to pass new message types (independent of the other message types) without any modifications to the server or the existing messages.

Code Location

The code for the sample is located under GrpcRelayServer folder of NP.Samples repository. The solution NP.Grpc.RelayServerRunner.sln containing both Relay Clients and Server can be found under GrpcRelayServer/NP.Grpc.RelayServerRunner folder within the same repository.

Code Language

At this point, both the server and the clients are written in C#. I plan to add samples of Python and JavaScript clients later.

Relay Client/Server Code Samples

Code Overview

Open the solution and take a look at the Solution Explorer:

Image 1

There are 5 console projects within the solution - one for running the Relay Server and 4 for running the client - 2 publishing clients and 2 subscribing clients.

The project that runs the server is NP.Grpc.RelayServerRunner. It depends only on one library project within the solution - NP.Grpc.RelayServerConfig which contains only one class GrpcServerConfig whose purpose is to share the server port and host between the clients and the server:

C#
public class GrpcServerConfig : IGrpcConfig
{
    public string ServerName => "localhost";

    public int Port => 5555;
} 

As was mentioned above, the RelayServer allows adding various topics so that the clients can publish and subscribe to them.

Our sample has two topics - Person and Org (Organization).

Person related projects are located under Topics/Person solution folder while Org related projects are locted under Topics/Organization.

The arrangement and dependency of Person related projects are exactly the same as those of Org related projects so, we talk shall only explain Person related projects but keep in mind that everything is the same also for Organization topic.

There are two console projects for Person:

SubscribePersonClient - Receives all published objects of type Person from Topic.PersonTopic topic enumeration value.

  1. PublishPersonClient - publishes objects of type Person to the Topic.PersonTopic topic enumeration value.

Both Person client projects depend on:

  1. PersonData project that defines both PersonType and Topic enumeration within Person.proto protobuf file. Note that Person.proto exists within PersonData project as a link. The file itself is defined as Content file within PersonProtos project. This is done, so that we could create client projects in different languages (JavaScript or Python) that would read the Person.proto file from PersonProtos project and built their own stubs differently than PersonData project does it in C#.
  2. NP.Grpc.ClientBuilder project containing information about connecting to the server.
  3. NP.Grpc.ClientBuilder project that absorbs some functionality for building the client that can be reused across every client within the solution.

Here is the project dependency diagram:

Image 2

Note that we skipped Org related projects for clarity sake, but they have exactly the same dependencies as the Person related projects.

Regarding the separation of concerns, the Org and Person client projects do not depend on each other at all and no server modifications are necessary when adding another Topic and its projects. We shall talk more about it when discussing the code.

Running the Server and the Clients

There are five console projects in the solution - one server project, two subscribing clients (one for each topic) and two publishing clients (also one for each topic):

Image 3

To run a project, simply right click on it within the Solution Explorer and then choose Debug->Start Without Debugging menu option.

Image 4

First start the server project - NP.Grpc.RelayServerRunner.

Then start the two subscribing client projects: SubscribePersonClient and SubscribeOrgClient. Pull the started console windows into different corners of your screen so that you could easily see which is which.

Now you can repeatedly start and restart the two publishing projects PublishPersonClient and PublishOrgClient in any order your choose. The Published person info (string "Joe Doe") will be printed line by line on the SubscribePersonClient console. The Published Org info (string "Google, Inc") will be printed line by line on the SubscribeOrgClient console.

And now off to the code!

RelayServerRunner Code

The server and the clients code is amazingly simple - most of the complexity is being absorbed by the referenced projects (some of the most important projects are referenced only as plugins as will be explained below and as was described in Creating and Installing Plugins as Nuget Packages).

Here is the code for starting the server:

C#
using NP.Grpc.CommonRelayInterfaces;
using NP.Grpc.RelayServerConfig;
using NP.IoCy;

// create container builder with Enum keys
var containerBuilder = new ContainerBuilder<Enum>();

// Register IGrpcConfig type to be resolved to GrpcServerConfig objectp 
containerBuilder.RegisterSingletonType<IGrpcConfig, GrpcServerConfig>();

// Dynamically load and inject all the plugins from the subfolders of
// Plugins/Services folder under TargetFolder of the project
// TargetFolder is where the executable of the project is located
// e.g. folder bin/Debug/net6.0 under the projects directory. 
containerBuilder.RegisterPluginsFromSubFolders("Plugins/Services");

// build the IoC container from container builder
var container = containerBuilder.Build();

// get the reference to the relay server from the plugin
// The server will start running the moment it is created. 
IRelayServer relayServer = container.Resolve<IRelayServer>();

// prevent the program from exiting
Console.ReadLine();  

I am using NP.IoCy IoC container described in Generic Minimal Inversion-of-Control/Dependency Injection Interfaces implemented by the Refactored NP.IoCy and AutofacAdapter Containers for plugins.

IRelayServer type is defined in NP.Grpc.CommonRelayInterfaces nuget package referenced by the server and client projects.

Note that the main plugin comes from NP.Grpc.RelayServer nuget package and is copied by the NP.Grpc.RelayServerRunner.csproj code to the Plugin/Services directory as was described in Creating and Installing Plugins as Nuget Packages article.

Here is the NP.Grpc.RelayServerRunner.csproj code that does it:

XML
<ItemGroup>
    ...
    <PackageReference Include="NP.Grpc.RelayServer" Version="1.0.7" 
     GeneratePathProperty="true">
        <!-- Do not reference the assets of the NP.Grpc.RelayServer package
             (since we are using it as a plugin instead -->
        <ExcludeAssets>All</ExcludeAssets>
    </PackageReference>
</ItemGroup>

<ItemGroup>
    <RelayServerFiles Include="$(PkgNP_Grpc_RelayServer)\lib\net6.0\**\*.*" />
</ItemGroup>

<Target Name="CopyServerPluginFromNugetPackage" AfterTargets="Build">
    <PropertyGroup>
        <!-- Set the output folder for the relay server plugin -->
        <ServerPluginFolder>$(TargetDir)\Plugins\Services\NP.Grpc.RelayServer
        </ServerPluginFolder>
    </PropertyGroup>

    <!-- remove the old plugin directory -->
    <RemoveDir Directories="$(ServerPluginFolder)" />

    <!-- Copy the contents of NP.Grpc.RelayServer.nupkg to the
         $(TargetDir)\Plugins\Services\NP.Grpc.RelayServer plugin folder -->
    <Copy SourceFiles="@(RelayServerFiles)" 
     DestinationFolder="$(ServerPluginFolder)\%(RecursiveDir)" />
</Target>  

There are two more plugin folders under Plugins/Services directory - OrgData and PersonData - they are copied by the Post-Build events of the same named projects:

Image 5

These two plugins, OrgData and PersonData are needed to be there so that the container could inject an array or allowed topics into the server which (in our sample) consists of two topics coming from two different enumerations: {NP.OrgClient.Topic.OrgTopic, NP.PersonClient.Topic.PersonTopic}. Note that one of the topics comes from NP.OrgClient.Topic enumeration and the other from NP.PersonClient.Topic enumeration and the two enumerations are defined correspondingly in two different projects - one on OrgData and the other in PersonData.

Combining them into a single injectable collection of Enum values is achieved by the MultiCell capability of NP.IoCy framework described, e.g. in Multiple Plugins with Multi-Cells.

We shall talk more about combinding the two values (one from each plugin) into a multi-cell when we talk about the client projects' code.

The resulting collection specifies which topics can be sent to the server. Trying to send another topic, not within the collection of allowed topics will result in an error.

The plugins are injected into the NP.Grpc.RelayServer object based on their IoC attributes.

There is one more object that needs to be injected into the server - an implementation of IGrpcConfig, which in our case comes from the type GrpcServerConfig defined in NP.GrpcRelayServerConfig dependent project.

Here is how this type registered with the IoC container:

C#
// Register IGrpcConfig type to be resolved to GrpcServerConfig objectp 
containerBuilder.RegisterSingletonType<IGrpcConfig, GrpcServerConfig>(); 

And here is the very simple implementation of GrpcServerConfig class:

C#
public class GrpcServerConfig : IGrpcConfig
{
    public string ServerName => "localhost";

    public int Port => 5555;
}  

We simply assign the server name to be "localhost" and the port to be 5555.

Note that the plugin architecture would easily allow us to swap the simple implementation of IGrpcConfig for something more complex, e.g., an implementation that assigns the server and port names based on a config file values.

PersonData and PersonProto Projects

As was mentioned above, PersonProtos project defines Person.proto file as a Content file while PersonData project creates includes a link to Person.proto file treated as a protobuf file:

XML
<ItemGroup>
    <Protobuf Include="..\PersonProtos\Person.proto" 
     Link="Person.proto" GrpcServices="Client" ProtoRoot=".."/>
</ItemGroup>  

Above is the line from PersonData.csproj project file that forces automatic C# client stub generation.

Such division between a PersonProto project that defines the Person.proto file and PersonData project that links to it is needed in case we want to use other languages than C#. Later (in a future article), it will be shown how to create Python and JavaScript clients - as different projects also referencing Person.proto as links.

Here is the content of Person.proto file:

protobuf
syntax = "proto3";

package NP.PersonClient;

enum Topic
{
    None = 0;
    PersonTopic = 10;
}

message Person
{
    string Name = 1;
    int32 Age = 2;
}  

It defines Person type to have two properties - string Name and Int32 Age. It also defines the topic enumeration with one non-trivial value PersonTopic = 10;. Note that the integer value of the corresponding PersonTopic C# enum will be 10.

Note also that since we want to distinguish between Org and Person topics, the integer value of the OrgTopic is 20 as in OrgProtos/Org.proto file:

protobuf
enum Topic
{
    None = 0;
    OrgTopic = 20;
}  

Another important file within PersonData project is TopicsGetter.cs. It defines a method to return PersonTopic enum value as part of the MultiCell Topics collection:

C#
[HasRegisterMethods]
public static class TopicsGetter
{        
    /// Returns the PersonTopic value as part of the MultiCell Topics collection
    [RegisterMultiCellMethod(cellType: typeof(Enum), resolutionKey: IoCKeys.Topics)] 
    public static Enum GetTopics() { return NP.PersonClient.Topic.PersonTopic; } 
}

NP.IoCy container, based on the RegisterMultiCellMethod attribute creates a single collection containing Enum values from enumerations within different Topics, so that the server will have the list of all allowed Topics.

PersonData project has a post build event that copies its compiled content under the Plugins/Services/PersonData folder of the RelayServer in order for the Topics collection to be created and populated by the server's IoC container:

XML
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
    <!-- copy to the server to register the topic -->
    <Exec Command="xcopy "$(OutDir)" 
    "$(SolutionDir)\bin\$(Configuration)\net6.0\Plugins\Services\$(ProjectName)\" 
    /S /R /Y /I" />
</Target>  

SubcribePersonClient Project

SubcribePersonClient creates a subscribing client listening to the Topic.PersonTopic for objects of type Person to arrive. After every arrival, it prints the Person.Name property value to the console.

C#
// create relay client
IRelayClient relayClient = ClientBuilder.GetClient();

// observe Topic PersonTopic and define the action on arrived Person object 
// by calling subscribe
IDisposable disposable = 
    relayClient
        .ObserveTopicStream<Person>(Topic.PersonTopic)
        .Subscribe(OnPersonDataArrived);

void OnPersonDataArrived(Person person)
{
    // print Person.Name for every new person
    // coming from the server
    Console.WriteLine(person.Name);
}

// prevent from exiting
Console.ReadLine(); 

Note that common code for creating any of the four clients is located within class ClientBuilder of the shared project NP.Grpc.ClientBuilder:

C#
public static class ClientBuilder
{
    private static IRelayClient? _relayClient;

    public static IRelayClient GetClient()
    {
        if (_relayClient == null)
        {
            // create container builder with keys limited to Enum (enumeration values)
            var containerBuilder = new ContainerBuilder<System.Enum>();

            // Register GrpcServerConfig containing server Name as "localhost"
            // and server port - 5555 to be retuned by the container 
            // for the IGrpcConfig type.
            containerBuilder.RegisterType<IGrpcConfig, GrpcServerConfig>();

            // register multicell of cell type Enum and resolution key IoCKeys.Topics
            containerBuilder.RegisterMultiCell(typeof(System.Enum), IoCKeys.Topics);

            // get the plugins from Plugins/Services folder under
            // the folder containing client executable
            containerBuilder.RegisterPluginsFromSubFolders("Plugins/Services");
            var container = containerBuilder.Build();

            // create the relay client
            _relayClient = container.Resolve<IRelayClient>();
        }

        // return relay client
        return _relayClient;
    }
} 

SubscribePersonClient (as every other client within the solution) uses NP.Grpc.RelayClient nuget package as a plugin (as was described in Creating and Installing Plugins as Nuget Packages).

Here is the NP.Grpc.SubscribePersonClient.csproj code that copies the NP.Grpc.RelayClient nuget package contents into the Plugin/Services folder under NP.Grpc.SubscribePersonClient executable directory:

XML
<ItemGroup>
    ...
    <!-- GeneratePathProperty set to true, 
         generates PkgNP_Grpc_RelayClient as the root folder
         for the package contents -->
    <PackageReference Include="NP.Grpc.RelayClient" 
     Version="1.0.6" GeneratePathProperty="true">
        <ExcludeAssets>All</ExcludeAssets>
    </PackageReference>
</ItemGroup>

<ItemGroup>
    <RelayClientFiles Include="$(PkgNP_Grpc_RelayClient)\lib\net6.0\**\*.*" />
</ItemGroup>

<Target Name="CopyClientPluginFromNugetPackage" AfterTargets="Build">
    <PropertyGroup>
        <!-- path for client plugin folder -->
        <ClientPluginFolder>$(TargetDir)\Plugins\Services\NP.Grpc.RelayClient
        </ClientPluginFolder>
    </PropertyGroup>
    <!-- remove the old folder with plugin folder (if exists) -->
    <RemoveDir Directories="$(ClientPluginFolder)" />

    <!-- copy the the contents of the nuget package into the client plugin folder -->
    <Copy SourceFiles="@(RelayClientFiles)" 
     DestinationFolder="$(ClientPluginFolder)\%(RecursiveDir)" />
</Target>  

PublishPersonClient Project

PublishPersonClient project creates a Person object with name "Joe Doe" and publishes it into the topic PersonTopic. Here is the very simple content of its Program.cs file:

C#
// get the client from ClientBuilder
IRelayClient relayClient = ClientBuilder.GetClient();

// create person 30 years old, named Joe Doe
Person person = new Person { Age = 30, Name = "Joe Doe"};

// publish the person to Topic.PersonTopic
await relayClient.Publish(Topic.PersonTopic, person);

Same as SubscribePersonClient project, it depends on NP.Grpc.ClientBuilder and uses NP.Grpc.RelayClient nuget package as a plugin.

Org Topic Projects

Projects under Organization folder are almost the same as projects under Person folder, only they deal with Org objects:

protobuf
message Org
{
    string Name = 1;
    int32 NumberPeople = 2;
} 

Also as was mentioned above, Topic.OrgTopic enum value has a different value of 20 instead of 10 for Topic.PersonTopic:

C#
enum Topic 
{
    None = 0; 
    OrgTopic = 20; 
} 

Note that Org and Person projects do not depend on each other and the server does not depend on them either. So as long as we make sure that the topic enumerations have different integer values and names, we can churn as many independent client projects as we want without any modifications to the other client projects or the server.

Conclusion

The article gives example of usage for a Relay Server which allows publishing methods to different Topics and subscribing to them. The Relay Server does not change the messages on channels them to the appropriate topics.

The article also shows how to extend the topics and the messages without any dependencies between them and with full separation of concerns between different topics.

History

  • 29th January, 2023: Initial version

License

This article, along with any associated source code and files, is licensed under The MIT License