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 type Person
to the Topic.PersonTopic
topic enumeration value.
Both Person
client projects depend on:
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#. 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;
var containerBuilder = new ContainerBuilder<Enum>();
containerBuilder.RegisterSingletonType<IGrpcConfig, GrpcServerConfig>();
containerBuilder.RegisterPluginsFromSubFolders("Plugins/Services");
var container = containerBuilder.Build();
IRelayServer relayServer = container.Resolve<IRelayServer>();
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">
<ExcludeAssets>All</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<RelayServerFiles Include="$(PkgNP_Grpc_RelayServer)\lib\net6.0\**\*.*" />
</ItemGroup>
<Target Name="CopyServerPluginFromNugetPackage" AfterTargets="Build">
<PropertyGroup>
<ServerPluginFolder>$(TargetDir)\Plugins\Services\NP.Grpc.RelayServer
</ServerPluginFolder>
</PropertyGroup>
<RemoveDir Directories="$(ServerPluginFolder)" />
<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:
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
{
[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">
<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.
IRelayClient relayClient = ClientBuilder.GetClient();
IDisposable disposable =
relayClient
.ObserveTopicStream<Person>(Topic.PersonTopic)
.Subscribe(OnPersonDataArrived);
void OnPersonDataArrived(Person person)
{
Console.WriteLine(person.Name);
}
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)
{
var containerBuilder = new ContainerBuilder<System.Enum>();
containerBuilder.RegisterType<IGrpcConfig, GrpcServerConfig>();
containerBuilder.RegisterMultiCell(typeof(System.Enum), IoCKeys.Topics);
containerBuilder.RegisterPluginsFromSubFolders("Plugins/Services");
var container = containerBuilder.Build();
_relayClient = container.Resolve<IRelayClient>();
}
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>
...
<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>
<ClientPluginFolder>$(TargetDir)\Plugins\Services\NP.Grpc.RelayClient
</ClientPluginFolder>
</PropertyGroup>
<RemoveDir Directories="$(ClientPluginFolder)" />
<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:
IRelayClient relayClient = ClientBuilder.GetClient();
Person person = new Person { Age = 30, Name = "Joe Doe"};
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