设计中转服务的最佳做法

遵循 StreamJsonRpc 的适用于 RPC 接口的通用指南和记录的限制

此外,以下准则适用于中转服务。

方法签名

所有方法都应采用 CancellationToken 参数作为其最后一个参数。 此参数通常应 为可选参数,因此调用方不太可能意外省略参数。 即使方法的实现被认为是简单的,提供 CancellationToken 允许客户端在请求传输到服务器之前取消请求。 它还允许服务器的实现演变成更昂贵的内容,而无需更新方法以在以后添加取消作为选项。

请考虑避免在 RPC 接口上包含同一方法的多个重载。 虽然重载解析通常有效(并且应编写测试来验证它是否有效),但它依赖于基于每个重载的参数类型来尝试反序列化参数,因此会导致在选取重载的常规部分引发第一次异常。 我们希望最大程度减少成功道路中引发的第一次异常的数量,因此最好只使用一个具有给定名称的方法。

参数和返回类型

请记住,通过 RPC 交换的所有参数和返回值都只是数据。 它们都是通过网络序列化和发送的。 在这些数据类型上定义的任何方法仅对数据的本地副本进行操作,并且无法与生成数据的 RPC 服务进行通信。 此序列化行为唯一的例外是 StreamJsonRpc 专门支持的 特殊类型

请考虑使用 ValueTask<T> 而非 Task<T> 作为方法的返回类型,因为 ValueTask<T> 会产生更少的分配。 使用非泛型品种(例如,TaskValueTask)时重要性较低,但仍可能更偏好使用 ValueTask。 请注意有关 ValueTask<T> 的使用限制,如该 API 中所述。 此 博客文章视频 也有助于确定要使用的类型。

自定义数据类型

考虑将所有数据类型都定义为不可变,这样就可以在不复制的情况下更安全地跨进程共享数据,并有助于增强使用者的想法,即在响应查询时无法更改他们收到的数据,而无需放置另一个 RPC。

使用 ServiceJsonRpcDescriptor.Formatters.UTF8时,请将数据类型定义为 class 而不是 struct,从而避免使用 Newtonsoft.Json 时的装箱成本(可能重复)。 使用 ServiceJsonRpcDescriptor.Formatters.MessagePack 时,不会发生装箱,因此,如果提交到该格式化程序,结构可能是一个合适的选项

请考虑在您的数据类型上实现 IEquatable<T> 方法,并重写 GetHashCode()Equals(Object) 方法,这样客户端就可以根据数据是否等于在另一时间接收到的数据来有效地存储、比较和重用数据。

使用 DiscriminatedTypeJsonConverter<TBase> 支持使用 JSON 序列化多态类型。

集合

在 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);

上面使用的 IDisposableIObserver<T> 类型是 StreamJsonRpc 中的异常类型中的两种,因此它们有特别的封送行为,而不是仅是被序列化为普通数据。

事件

由于多种原因,事件在 RPC 上可能会有问题,我们建议改用上述观察者模式。

请记住,当服务和客户端处于单独的进程中时,服务无法查看客户端附加的事件处理程序数。 JsonRpc 始终只附加一个负责将事件传播到客户端的处理程序。 客户端可能在远端处附加零个或多个处理程序。

大多数 RPC 客户端在首次连接时不会连接事件处理程序。 请避免引发第一个事件,直到客户端在接口上调用了“Subscribe*”方法,以指示接收事件的兴趣和准备情况。

如果事件状态中指示存在增量(例如,集合中添加了新项),请考虑引发所有过去的事件或描述所有当前数据,就像客户端订阅以帮助其仅“同步”事件处理代码时事件参数中的新数据一样。

如果客户端可能希望表达对数据或通知子集的兴趣,以减少转发这些通知所需的网络流量和 CPU,请考虑接受上述“Subscribe*”方法的额外参数。

如果同时公开事件以接收更改通知,或者主动阻止客户端与事件结合使用,请考虑不提供返回当前值的方法。 订阅事件以获取数据以及调用方法来获取当前值的客户端,可能会与该值的更改发生竞争,导致错过更改事件,或者不知道如何协调一个线程上的更改事件和另一个线程上获取的值。 对于任何接口而言,这都是普遍性问题,而不仅仅是在使用 RPC 时存在。

命名约定

  • 在 RPC 接口上使用 Service 后缀和简单的 I 前缀。
  • 不要将 Service 后缀用于 SDK 中的类。 库或 RPC 封装器应使用一个确切描述其用途的名称,避免使用术语“service”。
  • 请避免在接口或成员名称中使用“remote”一词。 请记住,中转服务在本地场景中应和在远程场景中一样适用。

版本兼容性问题

我们希望向其他扩展或通过 Live Share 公开的任何给定中介服务具有前向兼容和后向兼容性,这意味着我们应该假设客户端可能比服务更新或更旧,并且其功能应大致等同于两个适用版本中较低的版本。

首先,让我们回顾一下中断性变更术语:

  • 二进制不兼容变更:API 更改会导致针对以前版本的程序集编译的其他托管代码无法在运行时绑定到新版本。 示例包括:

    • 更改现有公共成员的签名。
    • 重命名公共成员。
    • 删除公共类型。
    • 向类型添加抽象成员,或向接口添加任何成员。

    但下方不是二进制中断性变更

    • 将非抽象成员添加到类或结构。
    • 将完整的(非抽象)接口实现添加到现有类型。
  • 协议中断性变更:更改某些数据类型或 RPC 方法调用的序列化形式,以便远程方无法正确反序列化和处理它。 示例包括:

    • 将所需的参数添加到 RPC 方法。
    • 从先前被保证为非 null 的数据类型中移除一个成员。
    • 添加必须在其他预先存在的操作之前放置方法调用的要求。
    • 在成员中控制数据的序列化名称的字段或属性上添加、删除或更改属性。
    • (MessagePack):更改现有成员的 DataMemberAttribute.Order 属性或 KeyAttribute 整数。

    但下方不是破坏协议的变更

    • 将可选成员添加到数据类型。
    • 将成员添加到 RPC 接口。
    • 向现有方法添加可选参数。
    • 将表示整数或浮点数的参数类型更改为长度或精度更高的参数类型(例如,将 intlongfloat 更改为 double)。
    • 重命名参数。 从技术上说,这会导致使用 JSON-RPC 命名参数的客户端发生中断,但使用 ServiceJsonRpcDescriptor 的客户端默认使用位置参数,不会受到参数名称更改的影响。 这与客户端源代码是否使用命名参数语法无关,对此情况,参数重命名会导致源代码中断性变更
  • 行为中断性变更:对代理服务的实现的更改,用于添加或更改行为,可能导致旧客户端出现故障。 示例包括:

    • 不再初始化以前始终初始化的数据类型的成员。
    • 在以前可以成功完成的条件下引发异常。
    • 返回的错误的错误代码与之前返回的错误代码不同。

    但下方不是行为中断性变更

需要重大更改时,可以通过注册和提供新的服务标识符来安全地进行这些更改。 此名字对象可以使用相同的名字,但版本号更高。 如果没有二进制中断性变更,则原始 RPC 接口可能可重用。 否则,请为新服务版本定义新接口。 通过继续注册、提供和同时继续支持旧版本来避免中断旧客户端。

我们希望避免所有这些中断性变更,将成员添加到 RPC 接口除外。

将成员添加到 RPC 接口

请勿向 RPC 客户端回调接口添加成员,因为许多客户端可能实现该接口,当这些类型被加载但未实现新接口成员时,添加成员会导致 CLR 引发 TypeLoadException。 如果必须添加成员以在 RPC 客户端回调目标上调用,请定义一个新接口(可能派生自原始接口),然后按照标准过程使用递增版本号来提供中转服务,并提供具有指定更新的客户端接口类型的描述符。

可以向定义中转服务的 RPC 接口添加成员。 这不是协议中断性变更,仅对实现服务方是二进制中断性变更,不过,可以假设你也会更新服务以实现新成员。 由于我们的指导方针是,除了中转服务本身之外(测试应使用模拟框架),任何人都不应实现 RPC 接口,因此向 RPC 接口中添加成员不应导致任何中断。

这些新成员应具有 XML 文档注释,用于标识哪个服务版本首先添加了该成员。 如果较新的客户端在未实现该方法的较旧服务上调用该方法,则客户端可以捕获 RemoteMethodNotFoundException。 但是,该客户端可以(并且可能应该)预测失败,并避免进行调用。 将成员添加到现有服务的最佳做法包括:

  • 如果这是服务版本中的第一个更改:在添加成员并声明新的描述符时,请在服务名字对象上提升次要版本。
  • 除旧版本外,请更新服务以注册和提供新版本
  • 如果你有代理服务的客户端,请更新客户端以请求最新版本,如果最新版本返回为 null,则回退请求旧版本。