使用中转服务
本文档介绍与任何中转服务的获取、常规用途和处置相关的所有代码、模式和注意事项。 若要了解如何在获取后使用特定中转服务,请查找该中转服务的特定文档。
对于本文档中的所有代码,强烈建议激活 C# 的可为空引用类型功能。
检索 IServiceBroker
若要获取中转服务,必须先有一个 IServiceBroker 的实例。 当代码在 MEF (Managed Extensibility Framework) 或 VSPackage 的上下文中运行时,通常需要全局服务代理。
中转服务本身应该使用在调用其服务工厂时分配给它们的 IServiceBroker。
全局服务代理
Visual Studio 提供了两种获取全局服务代理的方法。
使用 GlobalProvider.GetServiceAsync 请求 SVsBrokeredServiceContainer:
IBrokeredServiceContainer container = await AsyncServiceProvider.GlobalProvider.GetServiceAsync<SVsBrokeredServiceContainer, IBrokeredServiceContainer>();
IServiceBroker serviceBroker = container.GetFullAccessServiceBroker();
从 Visual Studio 2022 开始,在 MEF 激活的扩展中运行的代码可以导入全局服务代理:
[Import(typeof(SVsFullAccessServiceBroker))]
IServiceBroker ServiceBroker { get; set; }
请注意 Import 属性的 typeof
参数,这是必需的。
对全局 IServiceBroker 的每个请求都会生成一个对象的新实例,该实例充当全局中转服务容器的视图。 此 Service Broker 的唯一实例允许客户端接收该客户端使用的唯一 AvailabilityChanged 事件。 我们建议扩展中的每个客户端/类都使用上述任一方法获取其自己的服务代理,而不是获取一个实例并在整个扩展中共享。 此模式还鼓励安全编码模式,其中中转服务不应使用全局服务代理。
重要
IServiceBroker 的实现通常不实现 IDisposable;但当存在 AvailabilityChanged 处理程序时,这些对象无法被收集。 请务必平衡事件处理程序的添加/删除,特别是当代码可能在流程的生命周期内丢弃 Service Broker 时。
特定于上下文的 Service Broker
使用适当的 Service Broker 是中转服务的安全模型的重要要求,尤其是在 Live Share 会话的上下文中。
中转服务是使用自己的 IServiceBroker 激活的,应将此实例用于任何自己的中转服务需求,包括用 Proffer 提供的服务。 这样的代码提供了一个 BrokeredServiceFactory,它接收一个 Service Broker,供实例化的中转服务使用。
检索中转服务代理
中转服务的检索通常使用 GetProxyAsync 方法完成。
GetProxyAsync 方法需要一个 ServiceRpcDescriptor 和服务接口作为泛型类型参数。 所请求的中转服务的文档应说明从哪里获取描述符以及使用哪个接口。 对于 Visual Studio 附带的中转服务,要使用的接口应显示在描述符的 IntelliSense 文档中。 了解如何在发现可用的中转服务中查找 Visual Studio 中转服务的描述符。
IServiceBroker broker; // Acquired as described earlier in this topic
IMyService? myService = await broker.GetProxyAsync<IMyService>(serviceDescriptor, cancellationToken);
using (myService as IDisposable)
{
Assumes.Present(myService); // Throw if service was not available
await myService.SayHelloAsync();
}
与所有中转服务请求一样,上述代码将激活中转服务的新实例。
使用服务后,当执行退出 using
块时,前面的代码将处置代理。
重要
检索到的每个代理都必须被处置,即使服务接口不是从 IDisposable 派生的。
处置非常重要,因为代理通常具有支持它的 I/O 资源,以防止被当成垃圾回收。
处置会终止 I/O,允许对代理进行垃圾回收。
使用条件强制转换进行 IDisposable 处置,并为强制转换失败做好准备,以避免 null
代理或未实际实现 IDisposable 的代理出现异常。
请务必安装最新的 Microsoft.ServiceHub.Analyzers NuGet 包,并保留启用 ISBxxxx 分析器规则,以帮助防止此类泄漏。
代理的处置会导致处置专用于该客户端的中转服务。
如果代码需要中转服务,并且当服务不可用时无法完成其工作,则如果代码拥有用户体验,可能会向用户显示错误对话框,而不是引发异常。
客户端 RPC 目标
某些中转服务接受或要求客户端 RPC(远程过程调用)目标进行“回调”。此类选项或要求应位于该特定中转服务的文档中。 对于 Visual Studio 中转服务,此信息应包含在描述符的 IntelliSense 文档中。
在这种情况下,客户端可以像这样使用 ServiceActivationOptions.ClientRpcTarget 提供一个:
IMyService? myService = await broker.GetProxyAsync<IMyService>(
serviceDescriptor,
new ServiceActivationOptions
{
ClientRpcTarget = new MyCallbackObject(),
},
cancellationToken);
调用客户端代理
请求中转服务的结果是由代理实现的服务接口的实例。 该代理在每个方向上转发调用和事件,与直接调用服务时的预期行为有一些重要差异。
观察者模式
如果服务协定采用类型 IObserver<T> 的参数,可以在如何实现观察程序中了解关于如何构造这种类型的详细信息。
ActionBlock<TInput> 可以使用 AsObserver 扩展方法来实现 IObserver<T>。 反应框架中的 System.Reactive.Observer 类是自行实现接口的另一种替代方法。
从代理引发的异常
- 对于从中转服务引发的任何异常,预期 RemoteInvocationException 会引发此异常。 原始异常可以在 InnerException 中找到。
这是远程托管服务的自然行为,因为它是来自 JsonRpc 的行为。
当服务是本地服务时,本地代理以相同的方式包装所有异常,这样客户端代码就可以只有一个适用于本地服务和远程服务的例外路径。
- 如果服务文档建议根据可以分支的特定条件设置特定代码,请检查 ErrorCode 属性。
- 通过捕获 RemoteRpcException 来传递更广泛的错误集,这是 RemoteInvocationException 的基类型。
- 当与远程服务的连接断开或承载服务的进程崩溃时,预计从任何调用中抛出 ConnectionLostException。 当可以远程获取服务时,这是主要关注的问题。
缓存代理
激活中转服务和关联的代理需要花费一些费用,特别是当服务来自远程进程时。
当频繁使用中转服务需要在类的许多调用中缓存代理时,可以将代理存储在该类的字段中。
包含类应该是可处置的,并在其 Dispose
方法中处置代理。
请看以下示例:
class MyExtension : IDisposable
{
readonly IServiceBroker serviceBroker;
IMyService? serviceProxy;
internal MyExtension(IServiceBroker serviceBroker)
{
this.serviceBroker = serviceBroker;
}
async Task SayHiAsync(CancellationToken cancellationToken)
{
if (this.serviceProxy is null)
{
this.serviceProxy = await this.serviceBroker.GetProxyAsync<IMyService>(serviceDescriptor, cancellationToken);
Assumes.Present(this.serviceProxy);
}
await this.serviceProxy.SayHelloAsync();
}
public void Dispose()
{
(this.serviceProxy as IDisposable)?.Dispose();
}
}
前面的代码大致正确,但是它没有考虑到 Dispose
和 SayHiAsync
之间的争用条件。
代码也没有考虑到 AvailabilityChanged 事件,这些事件会导致处置先前获取的代理,并在下次需要时重新获取代理。
ServiceBrokerClient 类旨在处理这些争用和失效条件,以帮助保持自己的代码简单。 请考虑使用此帮助程序类缓存代理的此更新示例:
class MyExtension : IDisposable
{
readonly ServiceBrokerClient serviceBrokerClient;
internal MyExtension(IServiceBroker serviceBroker)
{
this.serviceBrokerClient = new ServiceBrokerClient(serviceBroker);
}
async Task SayHiAsync(CancellationToken cancellationToken)
{
using var rental = await this.serviceBrokerClient.GetProxyAsync<IMyService>(descriptor, cancellationToken);
Assumes.Present(rental.Proxy); // Throw if service is not available
IMyService myService = rental.Proxy;
await myService.SayHelloAsync();
}
public void Dispose()
{
// Disposing the ServiceBrokerClient will dispose of all proxies
// when their rentals are released.
this.serviceBrokerClient.Dispose();
}
}
前面的代码仍然负责处置代理的 ServiceBrokerClient 和每次租赁。 代理的处置和使用之间的争用条件由 ServiceBrokerClient 对象处理,该对象将在每个缓存的代理自行处置时或在该代理的最后一次租赁被释放时(以较晚者为准)处置该代理。
有关 ServiceBrokerClient
的重要注意事项
ServiceBrokerClient 仅基于 ServiceMoniker 对缓存的代理进行索引。 如果传入 ServiceActivationOptions 而缓存的代理已可用,则缓存的代理将在不使用 ServiceActivationOptions 的情况下返回,从而导致服务出现意外行为。 在这种情况下,考虑直接使用 IServiceBroker。
不要将从 ServiceBrokerClient.GetProxyAsync 获得的 ServiceBrokerClient.Rental<T> 存储在字段中。 代理已被 ServiceBrokerClient 缓存在一个方法的作用域之外。 如果需要更好地控制代理的生命周期,特别是在由于 AvailabilityChanged 事件而重新获取时,请直接使用 IServiceBroker 并将服务代理存储在字段中。
创建 ServiceBrokerClient 并将其存储到字段而不是局部变量中。 如果你在方法中创建并使用它作为局部变量,它不会比直接使用 IServiceBroker 增加任何值,但现在你必须处理两个对象(客户端和租赁)而不是一个(服务)。
在 IServiceBroker 和 ServiceBrokerClient 之间进行选择
这两者都是用户友好的,默认值可能是 IServiceBroker。
类别 | IServiceBroker | ServiceBrokerClient |
---|---|---|
用户友好 | 是 | 是 |
需要处置 | 否 | 是 |
管理代理的生命周期 | 否。 使用完代理后,所有者必须处置代理。 | 是的,只要它们有效,它们就会保持活动状态并被重复使用。 |
适用于无状态服务 | 是 | 是 |
适用于有状态服务 | 是 | 否 |
将事件处理程序添加到代理时适用 | 是 | 否 |
旧代理失效时通知的事件 | AvailabilityChanged | Invalidated |
ServiceBrokerClient 为你提供了一种方便的方法,可以快速频繁地重用代理,在这种情况下,你不在乎基础服务是否在顶层操作之间从你手下更改。 但是,如果你确实关心这些事项,并希望自己管理代理的生命周期,或者你需要事件处理程序(这意味着需要管理代理的生命周期),则应该使用 IServiceBroker。
对服务中断的复原能力
中转服务可能会发生几种服务中断:
- 服务不可用。
- 断开了与以前获取的中转服务的连接。
- 如果将来对该服务提出请求,服务可用性将发生变化。
中转服务激活失败
如果某个可用的服务可以满足中转服务请求,但服务工厂引发未经处理的异常,则会将一个 ServiceActivationFailedException 异常抛回到客户端,以便他们可以了解故障并向用户报告故障。
当中转服务请求无法与任何可用服务匹配时,将向客户端返回 null
。
在这种情况下,当该服务稍后可用时,将引发 AvailabilityChanged。
服务请求可能会被拒绝,不是因为服务不存在,而是因为所提供的版本低于所请求的版本。 回退计划可能包括使用客户端知道存在并能够与之交互的较低版本重新尝试服务请求。
如果/当所有失败版本检查的延迟变得明显时,客户端可以请求 VisualStudioServices.VS2019_4Services.RemoteBrokeredServiceManifest,以便全面了解远程源中可用的服务和版本。
处理断开的连接
成功获取的中转服务代理可能会由于连接断开或托管代理的进程崩溃而失败。 发生此类中断后,对该代理所做的任何调用都将导致引发 ConnectionLostException。
中转服务客户端可以通过处理 Disconnected 事件来主动检测此类连接中断并做出反应。 若要访问此事件,必须将代理强制转换为 IJsonRpcClientProxy 以获取 JsonRpc 对象。 这种强制转换应该有条件地进行,以便在服务是本地时正常地失败。
if (this.myService is IJsonRpcClientProxy clientProxy)
{
clientProxy.JsonRpc.Disconnected += JsonRpc_Disconnected;
}
void JsonRpc_Disconnected(object? sender, JsonRpcDisconnectedEventArgs args)
{
if (args.Reason == DisconnectedReason.RemotePartyTerminated)
{
// consider reacquisition of the service.
}
}
处理服务可用性更改
通过处理 AvailabilityChanged 事件,中转服务客户端可以收到通知,告知它们何时应该查询以前查询过的中转服务。 应在请求中转服务之前添加此事件的处理程序,以确保在发出服务请求后不久引发的事件不会因争用条件而丢失。
如果仅在一个异步方法执行期间请求中转服务,则不建议处理此事件。 该事件与长时间存储代理的客户端最相关,这样他们就需要补偿服务更改并能够刷新代理。
此事件可以在任何线程上引发,可能并发地引发正在使用该事件所描述的服务的代码。
多个状态更改可能会导致引发此事件,包括:
- 正在打开或关闭的解决方案或文件夹。
- Live Share 会话启动。
- 刚刚发现的动态注册的中转服务。
受影响的中转服务只会向以前请求过该服务的客户端引发此事件,无论该请求是否得到满足。
该事件在每个服务的每个请求之后最多引发一次。 例如,如果客户端请求服务 A,而服务 B 发生可用性更改,则不会向该客户端引发任何事件。 稍后,当服务 A 发生可用性更改时,客户端将收到该事件。 如果客户端未重新请求服务 A,则 A 的后续可用性更改不会导致向该客户端发出任何进一步的通知。 一旦客户端再次请求 A,它就有资格接收有关该服务的下一条通知。
当服务变得可用、不再可用或经历需要所有先前服务客户端重新查询服务的实现更改时,会引发此事件。
ServiceBrokerClient 通过在返回任何租用时处置旧代理,并在其所有者请求时请求服务的新实例,自动处理与缓存代理相关的可用性更改事件。 当服务是无状态的,并且不需要代码将事件处理程序附加到代理时,此类可以大大简化代码。
检索中转服务管道
虽然通过代理访问中转服务是最常见和最方便的技术,但在高级方案中,可能更可取或有必要请求一个到该服务的管道,以便客户端可以直接控制 RPC 或直接传输任何其他数据类型。
可以通过 GetPipeAsync 方法获取到中转服务的管道。 此方法采用 ServiceMoniker 而不是 ServiceRpcDescriptor,因为不需要描述符提供的 RPC 行为。 当有一个描述符时,可以通过 ServiceRpcDescriptor.Moniker 属性从中获取名字对象。
虽然管道绑定到 I/O,但它们不符合垃圾回收的条件。 在不再使用这些管道时,始终完成这些管道,以避免内存泄漏。
在以下代码片段中,一个中转服务被激活,并且客户端具有指向它的直接管道。 然后,客户端将文件内容发送到服务并断开连接。
async Task SendMovieAsync(string movieFilePath, CancellationToken cancellationToken)
{
IServiceBroker serviceBroker;
IDuplexPipe? pipe = await serviceBroker.GetPipeAsync(serviceMoniker, cancellationToken);
if (pipe is null)
{
throw new InvalidOperationException($"The brokered service '{serviceMoniker}' is not available.");
}
try
{
// Open the file optimized for async I/O
using FileStream fs = new FileStream(movieFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true);
await fs.CopyToAsync(pipe.Output.AsStream(), cancellationToken);
}
catch (Exception ex)
{
// Complete the pipe, passing through the exception so the remote side understands what went wrong.
await pipe.Input.CompleteAsync(ex);
await pipe.Output.CompleteAsync(ex);
throw;
}
finally
{
// Always complete the pipe after successfully using the service.
await pipe.Input.CompleteAsync();
await pipe.Output.CompleteAsync();
}
}
测试中转服务客户端
在测试扩展时,中转服务是一个合理的模仿依赖项。 模拟中转服务时,建议使用代表你实现接口的模拟框架,并将所需的代码注入到客户端将调用的特定成员中。 这允许测试在成员添加到中转服务接口时继续编译和运行,而不会中断。
使用 Microsoft.VisualStudio.Sdk.TestFramework 测试扩展时,测试可以包含标准代码来提供模拟服务,客户端代码可以查询并运行该模拟服务。 例如,假设你想要在测试中模拟 VisualStudioServices.VS2022.FileSystem 中转服务。 可以使用以下代码来提供模拟:
IBrokeredServiceContainer sbc = await AsyncServiceProvider.GlobalProvider.GetServiceAsync<SVsBrokeredServiceContainer, IBrokeredServiceContainer>();
Mock<IFileSystem> mockFileSystem = new Mock<IFileSystem>();
sbc.Proffer(VisualStudioServices.VS2022.FileSystem, (ServiceMoniker moniker, ServiceActivationOptions options, IServiceBroker serviceBroker, CancellationToken cancellationToken) => new ValueTask<object?>(mockFileSystem.Object));
模拟的中转服务容器不需要像 Visual Studio 本身那样首先注册提供的服务。
测试中的代码可以像平常一样获取中转服务,不同之处在于,在测试中,它会获得模拟,而不是在 Visual Studio 下运行时获得的真实服务:
IBrokeredServiceContainer sbc = await AsyncServiceProvider.GlobalProvider.GetServiceAsync<SVsBrokeredServiceContainer, IBrokeredServiceContainer>();
IServiceBroker serviceBroker = sbc.GetFullAccessServiceBroker();
IFileSystem? proxy = await serviceBroker.GetProxyAsync<IFileSystem>(VisualStudioServices.VS2022.FileSystem);
using (proxy as IDisposable)
{
Assumes.Present(proxy);
await proxy.DeleteAsync(new Uri("file://some/file"), recursive: false, null, this.TimeoutToken);
}