带关注点分离的发布/订阅 gRPC 代理服务器





5.00/5 (9投票s)
本文通过示例演示了代理服务器的使用以及不同主题之间的关注点分离。
引言
代理服务器
代理服务器在发布者和订阅者客户端之间传递消息。消息本身不会被服务器更改,只是从发布者客户端路由到一个或多个订阅者客户端。
一个简单的代理服务器(及其客户端)在 gRPC 简单代理示例 中进行了描述。该文章是对 gRPC 的回顾,仅具有学习价值,因此,该文章中描述的服务器过于简单,无法用于实际应用——所有发布的消息都会发送给每个订阅的客户端——没有分离,例如按主题分离。
本文描述的代理服务器允许按主题分离消息——只有订阅了某个主题的客户端才会收到发布到该主题的消息。此服务器可用于同一台或不同机器上不同进程之间的通信。
关注点分离
关注点分离意味着将您的产品/项目分割成一组几乎独立的组件,每个组件都可以独立于其他组件进行开发、调试、维护和扩展,从而修复或扩展一个组件不会导致产品其他部分出现问题或更改。
项目架构师最重要的任务之一是找到实现最佳关注点分离的方法,以避免个别开发人员之间相互干扰。
我经常撰写关于使用 IoC 和插件架构来实现关注点分离的文章。插件架构背后的主要思想是,各个插件是相互独立的,尽管它们可能依赖于包含共享接口和功能的通用项目。
本文继续讨论关注点分离,这次应用于 gRPC 代理服务器和客户端。
我们将展示一个 gRPC 代理服务器,它可以轻松扩展以传递新的消息类型(独立于其他消息类型),而无需修改服务器或现有消息。
代码位置
示例代码位于 NP.Samples 仓库的 GrpcRelayServer 文件夹下。包含代理客户端和服务器的解决方案 NP.Grpc.RelayServerRunner.sln 可以在同一仓库的 GrpcRelayServer/NP.Grpc.RelayServerRunner 文件夹下找到。
代码语言
目前,服务器和客户端都用 C# 编写。我计划稍后添加 Python 和 JavaScript 客户端的示例。
代理客户端/服务器代码示例
代码概述
打开解决方案,查看 **解决方案资源管理器**
解决方案中有 5 个控制台项目——一个用于运行代理服务器,4 个用于运行客户端——2 个发布客户端和 2 个订阅客户端。
运行服务器的项目是 NP.Grpc.RelayServerRunner
。它仅依赖于解决方案中的一个库项目——NP.Grpc.RelayServerConfig
,该项目仅包含一个名为 GrpcServerConfig
的类,其目的是在客户端和服务器之间共享服务器端口和主机。
public class GrpcServerConfig : IGrpcConfig
{
public string ServerName => "localhost";
public int Port => 5555;
}
如上所述,RelayServer
允许添加各种主题,以便客户端可以订阅和发布它们。
我们的示例有两个主题——Person
和 Org
(组织)。
与 Person
相关的项目位于 Topics/Person 解决方案文件夹下,而与 Org
相关的项目位于 Topics/Organization 下。
Person
相关项目的结构和依赖关系与 Org
相关项目的结构和依赖关系完全相同,因此,我们将只解释 Person
相关项目,但请记住,对于组织主题也是如此。
有两个控制台项目用于 Person
SubscribePersonClient
- 从 Topic.PersonTopic
主题枚举值接收所有发布的 Person
类型对象。
PublishPersonClient
- 将Person
类型对象发布到Topic.PersonTopic
主题枚举值。
这两个 Person
客户端项目都依赖于
PersonData
项目,该项目在 Person.proto protobuf 文件中定义了PersonType
和Topic
枚举。请注意,Person.proto 以链接的形式存在于PersonData
项目中。该文件本身在PersonProtos
项目中定义为 Content 文件。这样做是为了我们能够创建不同语言(JavaScript 或 Python)的客户端项目,这些项目将从PersonProtos
项目读取 Person.proto 文件,并以不同于PersonData
项目在 C# 中生成 stub 的方式构建自己的 stub。NP.Grpc.ClientBuilder
项目,包含有关连接到服务器的信息。NP.Grpc.ClientBuilder
项目,它封装了一些构建客户端的功能,这些功能可以在解决方案中的每个客户端中重用。
这是项目依赖关系图
请注意,为清晰起见,我们省略了与 Org
相关的项目,但它们与与 Person
相关的项目具有完全相同的依赖关系。
关于关注点分离,Org
和 Person
客户端项目之间完全没有依赖关系,在添加另一个 Topic
及其项目时,无需修改服务器。我们将在讨论代码时进一步讨论这一点。
运行服务器和客户端
解决方案中有五个控制台项目——一个服务器项目,两个订阅客户端(每个主题一个),以及两个发布客户端(每个主题一个)。
要运行项目,只需在解决方案资源管理器中右键单击它,然后选择 **调试**->**无调试启动** 菜单选项。
首先启动服务器项目——NP.Grpc.RelayServerRunner
。
然后启动两个订阅客户端项目:SubscribePersonClient
和 SubscribeOrgClient
。将启动的控制台窗口拖到屏幕的不同角落,以便您轻松区分它们。
现在,您可以按照您选择的任何顺序反复启动和重新启动两个发布项目 PublishPersonClient
和 PublishOrgClient
。发布的个人信息(字符串 "Joe Doe
")将逐行打印在 SubscribePersonClient
控制台上。发布的 Org
信息(字符串 "Google, Inc
")将逐行打印在 SubscribeOrgClient
控制台上。
现在让我们看看代码!
RelayServerRunner 代码
服务器和客户端的代码非常简单——大部分复杂性都由引用的项目承担(其中一些最重要的项目仅作为插件引用,如下所述,并且如 创建和安装插件作为 Nuget 包 中所述)。
这是启动服务器的代码
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();
我正在使用 通过重构的 NP.IoCy 和 AutofacAdapter 容器实现的通用最小控制反转/依赖注入接口 中描述的 NP.IoCy
IoC 容器用于插件。
IRelayServer
类型定义在服务器和客户端项目引用的 NP.Grpc.CommonRelayInterfaces
nuget 包中。
请注意,主插件来自 NP.Grpc.RelayServer
nuget 包,并如 创建和安装插件作为 Nuget 包 文章中所述,由 NP.Grpc.RelayServerRunner.csproj 代码复制到 Plugin/Services 目录。
这是执行此操作的 NP.Grpc.RelayServerRunner.csproj 代码
<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>
在 Plugins/Services 目录下的其他两个插件文件夹——OrgData 和 PersonData——它们由同名项目的生成后事件复制。
需要这两个插件 OrgData
和 PersonData
,以便容器可以将允许的主题数组注入到服务器中,而服务器(在我们这个示例中)由两个主题组成,来自两个不同的枚举:{NP.OrgClient.Topic.OrgTopic, NP.PersonClient.Topic.PersonTopic}
。请注意,其中一个主题来自 NP.OrgClient.Topic
枚举,另一个来自 NP.PersonClient.Topic
枚举,这两个枚举分别在两个不同的项目中定义——一个在 OrgData
中,另一个在 PersonData
中。
将它们组合成一个可注入的 Enum
值集合,是通过 NP.IoCy
框架的 MultiCell 功能实现的,该功能在例如 具有 Multi-Cells 的多个插件 中进行了描述。
我们将在讨论客户端项目代码时进一步讨论如何将这两个值(每个插件一个)组合成一个多单元格。
生成的集合指定了可以发送到服务器的主题。尝试发送集合中不允许的其他主题将导致错误。
插件根据其 IoC 属性注入到 NP.Grpc.RelayServer
对象中。
还有另一个对象需要注入到服务器中——IGrpcConfig
的实现,在我们的例子中,它来自 NP.GrpcRelayServerConfig
依赖项目中定义的 GrpcServerConfig
类型。
这是该类型如何与 IoC 容器注册
// Register IGrpcConfig type to be resolved to GrpcServerConfig objectp
containerBuilder.RegisterSingletonType<IGrpcConfig, GrpcServerConfig>();
这是 GrpcServerConfig
类的非常简单的实现
public class GrpcServerConfig : IGrpcConfig
{
public string ServerName => "localhost";
public int Port => 5555;
}
我们简单地将服务器名称设置为 "localhost"
,并将端口设置为 5555
。
请注意,插件架构可以让我们轻松地将 IGrpcConfig
的简单实现替换为更复杂的实现,例如,一个根据配置文件值分配服务器和端口名称的实现。
PersonData 和 PersonProto 项目
如上所述,PersonProtos
项目将 Person.proto 文件定义为 Content 文件,而 PersonData
项目则创建一个指向 Person.proto 文件的链接,该文件被视为 protobuf 文件。
<ItemGroup>
<Protobuf Include="..\PersonProtos\Person.proto"
Link="Person.proto" GrpcServices="Client" ProtoRoot=".."/>
</ItemGroup>
上面是 PersonData.csproj 项目文件中强制自动生成 C# 客户端 stub 的一行。
PersonProto
项目(定义 Person.proto 文件)和 PersonData
项目(链接到它)之间的这种划分是必要的,以防我们要使用 C# 以外的其他语言。稍后(在未来的文章中),将展示如何创建 Python 和 JavaScript 客户端——作为不同的项目,它们也将 Person.proto 作为链接引用。
这是 Person.proto 文件的内容
syntax = "proto3";
package NP.PersonClient;
enum Topic
{
None = 0;
PersonTopic = 10;
}
message Person
{
string Name = 1;
int32 Age = 2;
}
它将 Person
类型定义为具有两个属性——string Name
和 Int32 Age
。它还定义了主题枚举,其中一个非平凡值是 PersonTopic = 10;
。请注意,相应 C# enum
的 PersonTopic
的整数值将是 10
。
另请注意,由于我们希望区分 Org
和 Person
主题,因此 OrgTopic
的整数值为 20
,如 OrgProtos/Org.proto 文件所示。
enum Topic
{
None = 0;
OrgTopic = 20;
}
PersonData
项目中的另一个重要文件是 TopicsGetter.cs。它定义了一个方法,该方法将 PersonTopic enum
值作为 MultiCell
Topics 集合的一部分返回。
[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
容器基于 RegisterMultiCellMethod
属性,创建一个包含来自不同主题中枚举值的单个集合,以便服务器拥有所有允许主题的列表。
PersonData
项目有一个生成后事件,该事件将其编译的内容复制到 RelayServer
的 Plugins/Services/PersonData 文件夹下,以便服务器的 IoC 容器可以创建和填充 Topics
集合。
<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>
SubscribePersonClient 项目
SubcribePersonClient
创建一个订阅客户端,监听 Topic.PersonTopic
主题,等待 Person
类型对象到达。每次到达后,它会将 Person.Name
属性值打印到控制台。
// 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();
请注意,创建四个客户端之一的通用代码位于共享项目 NP.Grpc.ClientBuilder
的 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
(如解决方案中的其他客户端一样)使用 NP.Grpc.RelayClient
nuget 包作为插件(如 创建和安装插件作为 Nuget 包 中所述)。
这是 NP.Grpc.SubscribePersonClient.csproj
代码,它将 NP.Grpc.RelayClient
nuget 包的内容复制到 NP.Grpc.SubscribePersonClient
可执行目录下的 Plugin/Services 文件夹中。
<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 项目
PublishPersonClient
项目创建一个名为 "Joe Doe
" 的 Person
对象,并将其发布到 PersonTopic
主题。这是其 Program.cs 文件的非常简单的内容。
// 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);
与 SubscribePersonClient
项目相同,它依赖于 NP.Grpc.ClientBuilder
并使用 NP.Grpc.RelayClient
nuget 包作为插件。
Org Topic 项目
Organization 文件夹下的项目与 Person 文件夹下的项目几乎相同,只是它们处理 Org
对象。
message Org
{
string Name = 1;
int32 NumberPeople = 2;
}
此外,如上所述,Topic.OrgTopic
enum
值的值为 20
,而不是 Topic.PersonTopic
的 10
。
enum Topic
{
None = 0;
OrgTopic = 20;
}
请注意,Org
和 Person
项目之间没有依赖关系,服务器也没有它们之间的依赖关系。因此,只要我们确保主题枚举具有不同的整数值和名称,我们就可以创建任意数量的独立客户端项目,而无需修改其他客户端项目或服务器。
结论
本文给出了代理服务器的使用示例,该服务器允许将方法发布到不同的主题并订阅它们。代理服务器不更改消息,而是将它们路由到相应的主题。
本文还展示了如何在不依赖于它们之间的情况下扩展主题和消息,并在不同主题之间实现完全的关注点分离。
历史
- 2023 年 1 月 29 日:初始版本