Publish/Subscribe gRPC Relay Server with Separation of Concerns





5.00/5 (9 votes)
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:
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:
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.
PublishPersonClient
- publishes objects of typePerson
to theTopic.PersonTopic
topic enumeration value.
Both Person
client projects depend on:
PersonData
project that defines bothPersonType
andTopic
enumeration within Person.proto protobuf file. Note that Person.proto exists withinPersonData
project as a link. The file itself is defined as Content file withinPersonProtos
project. This is done, so that we could create client projects in different languages (JavaScript or Python) that would read the Person.proto file fromPersonProtos
project and built their own stubs differently thanPersonData
project does it in C#.NP.Grpc.ClientBuilder
project containing information about connecting to the server.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:
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):
To run a project, simply right click on it within the Solution Explorer and then choose Debug->Start Without Debugging menu option.
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:
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:
<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:
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:
// Register IGrpcConfig type to be resolved to GrpcServerConfig objectp
containerBuilder.RegisterSingletonType<IGrpcConfig, GrpcServerConfig>();
And here is the very simple implementation of GrpcServerConfig
class:
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:
<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:
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:
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:
[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:
<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.
// 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
:
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:
<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:
// 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:
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:
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