设计中转服务的最佳做法
遵循针对 StreamJsonRpc 的 RPC 接口记录的一般指南和限制。
此外,以下准则适用于中转服务。
方法签名
所有方法都应采用参数作为其最后一个 CancellationToken 参数。 此参数通常 不应 是可选参数,因此调用方不太可能意外省略参数。 即使该方法的实现预期是微不足道的,提供 CancellationToken 允许客户端在传输到服务器之前取消其自己的请求。 它还允许服务器的实现演变成更昂贵的内容,而无需更新方法以在以后添加取消作为选项。
请考虑 避免 在 RPC 接口上使用相同方法的多个重载。 虽然重载解析通常有效(并且应编写测试来验证它是否确实有效),但它依赖于 尝试 基于每个重载的参数类型反序列化参数,导致第一次机会异常作为选取重载的常规部分引发。 我们希望最大程度地减少成功路径中引发的第一次机会异常的数量,最好只使用一个具有给定名称的方法。
参数和返回类型
请记住,通过 RPC 交换的所有参数和返回值只是 数据。 它们都是通过网络序列化和发送的。 在这些数据类型上定义的任何方法仅对数据的本地副本进行操作,并且无法与生成数据的 RPC 服务进行通信。 此序列化行为的唯一例外是 StreamJsonRpc 具有特殊支持的异国类型 。
请考虑使用 ValueTask<T>
over Task<T>
作为方法的返回类型,因为 ValueTask<T>
分配较少。
使用非泛型品种(例如 Task ,和 ValueTask)时,它不太重要,但仍 ValueTask 可能更好。
请注意有关该 API 的使用情况限制 ValueTask<T>
。 此 博客文章 和 视频 也有助于确定要使用的类型。
自定义数据类型
考虑将所有数据类型都定义为不可变,这样就可以在不复制的情况下更安全地跨进程共享数据,并有助于增强使用者的想法,即在响应查询时无法更改他们收到的数据,而无需放置另一个 RPC。
使用 Newtonsoft.Json 时,定义数据类型 class
而不是 struct
使用 ServiceJsonRpcDescriptor.Formatters.UTF8时,避免了(可能重复)装箱的成本。
如果提交到该格式化程序,则使用ServiceJsonRpcDescriptor.Formatters.MessagePack因此结构可能是一个合适的选项时,不会发生装箱。
请考虑在数据类型上实现 IEquatable<T> 和重写 GetHashCode() 和 Equals(Object) 方法,这样客户端就可以根据它是否等于另一时间接收的数据有效地存储、比较和重用收到的数据。
使用 JSON DiscriminatedTypeJsonConverter<TBase> 支持序列化多态类型。
集合
在 RPC 方法签名(例如) IReadOnlyList<T>中使用只读集合接口(例如),而不是具体类型(例如, List<T> 或 T[]
),这样可能会提高反序列化效率。
避免 IEnumerable<T>。
它缺少 Count
属性会导致代码效率低下,这意味着可能的后期生成数据,这不适用于 RPC 方案。
请 IReadOnlyCollection<T> 改为用于无序集合或 IReadOnlyList<T> 有序集合。
以 IAsyncEnumerable<T> 为例。 任何其他集合类型或 IEnumerable<T> 将导致整个集合在一条消息中发送。 使用 IAsyncEnumerable<T> 允许一条小型初始消息,并让接收方能够根据需要从集合中获取任意数量的项,以异步方式枚举它。 了解有关这种新模式的详细信息。
观察者模式
请考虑在界面中使用 观察程序设计模式 。 这是客户端订阅数据的一种简单方法,没有适用于下一部分所述的传统事件模型的许多陷阱。
观察程序模式可能如下所示:
Task<IDisposable> SubscribeAsync(IObserver<YourDataType> observer);
IDisposable上面使用的和IObserver<T>类型是 StreamJsonRpc 中的两种异国情调类型,因此它们得到特别封送的行为,而不是仅序列化为数据。
事件
由于多种原因,事件在 RPC 上可能会有问题,我们建议改用上述观察程序模式。
请记住,当服务和客户端处于单独的进程中时,服务无法查看客户端附加的事件处理程序数。 JsonRpc 将始终只附加一个负责将事件传播到客户端的处理程序。 客户端可能附加在远端的零个或多个处理程序。
大多数 RPC 客户端在首次连接时不会连接事件处理程序。 请避免引发第一个事件,直到客户端在接口上调用了“Subscribe*”方法,以指示接收事件的兴趣和准备情况。
如果事件指示状态中的增量(例如,添加到集合的新项),请考虑引发所有过去的事件或描述所有当前数据,就像客户端订阅帮助它们“同步”时事件参数中的新数据一样,只不过是事件处理代码。
如果客户端可能希望表达对数据或通知的子集感兴趣,以减少转发这些通知所需的网络流量和 CPU,请考虑接受上面提及的额外参数。
如果同时公开事件以接收更改通知,或者主动阻止客户端与事件结合使用,请考虑不提供返回当前值的方法。 订阅数据事件的客户端,并调用一种方法来获取当前值,以与该值的更改争用,或者缺少更改事件,或者不知道如何协调一个线程上的更改事件,以及另一个线程上获取的值。 对于任何接口而言,这一问题都是一般问题,而不仅仅是当它通过 RPC 时。
命名约定
- 在
Service
RPC 接口和简单I
前缀上使用后缀。 - 不要
Service
将后缀用于 SDK 中的类。 库或 RPC 包装器应使用描述其确切用途的名称,避免术语“service”。 - 避免在接口或成员名称中使用术语“remote”。 请记住,中转服务在本地方案中的应用量与远程服务一样多。
版本兼容性问题
我们希望向其他扩展公开或通过 Live Share 公开的任何给定中转服务向前和向后兼容,这意味着我们应该假设客户端可能比服务更旧或更新,并且该功能应大致等于两个适用版本的较小版本。
首先,让我们回顾一下重大变更术语:
二进制中断性变更:API 更改会导致针对以前版本的程序集编译的其他托管代码无法在运行时绑定到新代码。 示例包括:
- 更改现有公共成员的签名。
- 重命名公共成员。
- 删除公共类型。
- 向类型添加抽象成员,或向接口添加任何成员。
但以下不是二进制中断性变更:
- 将非抽象成员添加到类或结构。
- 将完整的(非抽象)接口实现添加到现有类型。
协议中断性变更:对某种数据类型或 RPC 方法调用的序列化形式的更改,使远程方无法正确反序列化和处理它。 示例包括:
- 将所需的参数添加到 RPC 方法。
- 从以前保证为非 null 的数据类型中删除成员。
- 添加必须在其他预先存在的操作之前放置方法调用的要求。
- 在该成员中控制数据的序列化名称的字段或属性上添加、删除或更改属性。
- (MessagePack):更改 DataMemberAttribute.Order 现有成员的属性或
KeyAttribute
整数。
但以下不是协议中断性变更:
- 将可选成员添加到数据类型。
- 将成员添加到 RPC 接口。
- 向现有方法添加可选参数。
- 将表示整数或浮点数的参数类型更改为长度或精度更高的参数类型(例如,
int
或long
float
更改为double
)。 - 重命名参数。 从技术上说,这与使用 JSON-RPC 命名参数的客户端中断,但默认情况下使用 ServiceJsonRpcDescriptor 位置参数的客户端不会受到参数名称更改的影响。 这与客户端 源代码 是否使用命名参数语法无关,参数重命名将是 源中断性 变更。
行为中断性变更:对中转服务的实现的更改,可添加或更改行为,使旧客户端出现故障。 示例包括:
- 不再初始化以前始终初始化的数据类型的成员。
- 在以前可以成功完成的条件下引发异常。
- 返回与之前返回的错误代码不同的错误。
但以下不是行为中断性变更:
- 引发新的异常类型(因为所有异常都包装在 RemoteInvocationException 内)。
需要重大更改时,可以通过注册和提供新的服务名字对象来安全地进行这些更改。 此名字对象可以共享相同的名称,但版本号更高。 如果没有二进制中断性变更,原始 RPC 接口 可能 可重复使用。 否则,请为新服务版本定义新接口。 通过继续注册、提供和支持旧版本,避免中断旧客户端。
我们希望避免所有这些中断性变更,但将成员添加到 RPC 接口除外。
将成员添加到 RPC 接口
不要将成员添加到 RPC 客户端回调接口,因为许多客户端可能实现该接口,并且添加成员将导致在加载这些类型但未实现新接口成员时引发 TypeLoadException CLR。 如果必须添加成员以在 RPC 客户端回调目标上调用,请定义一个新接口(可能派生自原始接口),然后按照标准过程使用递增版本号来提供中转服务,并提供具有指定更新的客户端接口类型的描述符。
可以将成员添加到定义中转服务的 RPC 接口。 这不是协议中断性变更,只是实现服务的二进制中断性变更,但大概还会更新服务以实现新成员。 由于 我们的指导 是,除了中转服务本身(测试应使用模拟框架)之外,任何人都不应实现 RPC 接口,因此向 RPC 接口添加成员不应中断任何人。
这些新成员应具有 XML 文档注释,用于标识哪个服务版本首先添加了该成员。 如果较新的客户端在未实现该方法的较旧服务上调用该方法,则客户端可以捕获 RemoteMethodNotFoundException。 但是,该客户端可以(并且可能应该)预测失败,并避免首先调用。 将成员添加到现有服务的最佳做法包括:
- 如果这是服务版本中的第一个更改:在添加成员并声明新的描述符时,在服务名字对象上颠簸次要版本。
- 除了旧版本之外,更新服务以注册和提供新版本。
- 如果你有中转服务的客户端,请更新客户端以请求较新版本,如果较新的版本恢复为 null,请回退以请求旧版本。