提供中转服务
中转服务由以下元素组成:
- 一个接口,用于声明服务的功能,并充当服务与其客户端之间的协定。
- 该接口的实现。
- 为服务分配名称和版本的服务名字对象。
- 一个描述符,它将服务名字对象与必要时处理 RPC(远程过程调用)的行为相结合。
- 要么提供服务工厂和用 VS 包注册中转服务,要么用 MEF (Managed Extensibility Framework) 同时提供。
以下各节将详细介绍上述列表中的每个项目。
对于本文中的所有代码,强烈建议激活 C# 的可为空引用类型功能。
服务接口
服务接口可以是标准 .NET 接口(通常用 C# 编写),但应该符合由 ServiceRpcDescriptor 派生类型设置的指南,以确保当客户端和服务在不同进程中运行时,该接口可以通过 RPC 使用。
这些限制通常包括不允许使用属性和索引器,并且大多数或所有方法返回 Task
或另一个异步兼容的返回类型。
ServiceJsonRpcDescriptor 是推荐的中转服务派生类型。 当客户端和服务需要 RPC 进行通信时,此类使用 StreamJsonRpc 库。 StreamJsonRpc 对服务接口应用某些限制,如此处所述。
接口可能派生自 IDisposable、System.IAsyncDisposable,甚至 Microsoft.VisualStudio.Threading.IAsyncDisposable,但系统不需要此接口。 生成的客户端代理将以任何一种方式实现 IDisposable。
一个简单的计算器服务接口可以这样声明:
public interface ICalculator
{
ValueTask<double> AddAsync(double a, double b, CancellationToken cancellationToken);
ValueTask<double> SubtractAsync(double a, double b, CancellationToken cancellationToken);
}
尽管该接口上方法的实现可能不保证异步方法,但我们总是在该接口上使用异步方法签名,因为该接口用于生成可以远程调用该服务的客户端代理,这当然确实保证异步方法签名。
接口可以声明事件,这些事件可用于将服务中发生的事件通知其客户端。
除了事件或观察者设计模式之外,需要“回调”客户端的中转服务可以定义第二个接口,该接口充当客户端在请求服务时必须通过属性实现并提供的 ServiceActivationOptions.ClientRpcTarget 协定。 此类接口符合与中转服务接口相同的设计模式和限制,但增加了对版本控制的限制。
查看设计中转服务的最佳做法,获取有关设计高性能、经得起未来考验的 RPC 接口的提示。
在与实现服务的程序集不同的程序集中声明此接口非常有用,这样服务的客户端就可以引用该接口,而服务不必公开其更多的实现详细信息。 将接口程序集作为 NuGet 包发布,以供其他扩展引用,同时保留自己的扩展以提供服务实现,这也可能很有用。
请考虑将声明服务接口的程序集定位到 netstandard2.0
,以确保无论服务是运行 .NET Framework、.NET Core、.NET 5 或更高版本,都可以从任何 .NET 进程轻松调用服务。
测试
自动化测试应与服务接口一起编写,以验证接口的 RPC 就绪情况。
测试应验证通过接口传递的所有数据是否可序列化。
可以从 Microsoft.VisualStudio.Sdk.TestFramework.Xunit 包中找到 BrokeredServiceContractTestBase<TInterface,TServiceMock> 类,以便从中派生接口测试类。 此类包括接口的一些基本约定测试、辅助常见断言(如事件测试)的方法等。
方法
断言每个参数和返回值都已完全序列化。 如果使用的是上面提到的测试基类,则代码如下所示:
public interface IYourService
{
Task<bool> SomeOperationAsync(YourStruct arg1);
}
public static class Descriptors
{
public static readonly ServiceRpcDescriptor YourService = new ServiceJsonRpcDescriptor(
new ServiceMoniker("YourCompany.YourExtension.YourService", new Version(1, 0)),
clientInterface: null,
ServiceJsonRpcDescriptor.Formatters.MessagePack,
ServiceJsonRpcDescriptor.MessageDelimiters.BigEndianInt32LengthHeader,
new MultiplexingStream.Options { ProtocolMajorVersion = 3 })
.WithExceptionStrategy(StreamJsonRpc.ExceptionProcessing.ISerializable);
}
public class YourServiceMock : IYourService
{
internal YourStruct? SomeOperationArg1 { get; set; }
public Task<bool> SomeOperationAsync(YourStruct arg1, CancellationToken cancellationToken)
{
this.SomeOperationArg1 = arg1;
return true;
}
}
public class BrokeredServiceTests : BrokeredServiceContractTestBase<IYourService, YourServiceMock>
{
public BrokeredServiceTests(ITestOutputHelper logger)
: base(logger, Descriptors.YourService)
{
}
[Fact]
public async Task SomeOperation()
{
var arg1 = new YourStruct
{
Field1 = "Something",
};
Assert.True(await this.ClientProxy.SomeOperationAsync(arg1, this.TimeoutToken));
Assert.Equal(arg1.Field1, this.Service.SomeOperationArg1.Value.Field1);
}
}
如果声明多个具有相同名称的方法,请考虑测试重载解析。
可以为模拟服务中存储该方法参数的每个方法添加 internal
字段,以便测试方法可以调用该方法,然后验证是否使用正确的参数调用了正确的方法。
事件
在接口上声明的任何事件也应针对 RPC 就绪情况进行测试。 如果从中转服务引发的事件在 RPC 序列化过程中失败,则不会导致测试失败,因为事件是“即发即弃”。
如果使用的是上面提到的测试基类,那么这种行为已经内置到一些帮助程序方法中,并且可能如下所示(为了简洁起见,省略了未更改的部分):
public interface IYourService
{
event EventHandler<int> NewTotal;
}
public class YourServiceMock : IYourService
{
public event EventHandler<int>? NewTotal;
internal void RaiseNewTotal(int arg) => this.NewTotal?.Invoke(this, arg);
}
public class BrokeredServiceTests : BrokeredServiceContractTestBase<IYourService, YourServiceMock>
{
[Fact]
public async Task NewTotal()
{
await this.AssertEventRaisedAsync<int>(
(p, h) => p.NewTotal += h,
(p, h) => p.NewTotal -= h,
s => s.RaiseNewTotal(50),
a => Assert.Equal(50, a));
}
}
实现服务
服务类应实现在上一步中声明的 RPC 接口。 服务可以实现 IDisposable 或除用于 RPC 的接口之外的任何其他接口。 客户端上生成的代理将仅实现服务接口、IDisposable 以及可能的其他一些支持系统的选择接口,因此在客户端上转换到服务实现的其他接口将失败。
请考虑上面使用的计算器示例,我们在此处实现该示例:
internal class Calculator : ICalculator
{
public ValueTask<double> AddAsync(double a, double b, CancellationToken cancellationToken)
{
return new ValueTask<double>(a + b);
}
public ValueTask<double> SubtractAsync(double a, double b, CancellationToken cancellationToken)
{
return new ValueTask<double>(a - b);
}
}
由于方法体本身不需要是异步的,因此我们显式地将返回值包装在构造的 ValueTask<TResult> 返回类型中,以符合服务接口。
实现可观测的设计模式
如果在服务接口上提供察程序订阅,它可能如下所示:
Task<IDisposable> SubscribeAsync(IObserver<YourDataType> observer);
IObserver<T> 参数通常需要超过此方法调用的生存期过期,以便客户端可以在方法调用完成后继续接收更新,直到客户端释放返回的 IDisposable 值为止。 为了方便执行此操作,服务类可能包含 IObserver<T> 订阅的集合,然后对状态所做的任何更新都将枚举该集合以更新所有订阅者。 请确保集合的枚举相对于其他枚举是线程安全的,尤其是在该集合上可能通过额外订阅或释放这些订阅而发生突变的情况下。
请注意,通过 OnNext 发布的所有更新都保留了状态更改引入服务的顺序。
所有订阅最终都应该通过调用 OnCompleted 或 OnError 来终止,以避免客户端和 RPC 系统上的资源泄漏。 这包括应显式完成所有剩余订阅的服务释放。
详细了解观察程序设计模式、如何实现可观测数据提供程序,特别是 在考虑 RPC 的情况下。
可释放服务
服务类不需要是可释放的,但当客户端将其代理释放到服务或客户端和服务之间的连接断开时,将释放这些服务。 可释放接口的测试顺序如下:System.IAsyncDisposable、Microsoft.VisualStudio.Threading.IAsyncDisposable、IDisposable。 只有服务类实现的该列表中的第一个接口将用于释放服务。
在考虑释放时,请牢记线程安全。 当服务中的其他代码正在运行时(例如,如果断开连接),可以在任何线程上调用 Dispose 方法。
引发异常
引发异常时,请考虑使用特定的 ErrorCode 引发 LocalRpcException,以控制客户端在 RemoteInvocationException 中接收到的错误代码。 为客户端提供错误代码可以使它们更好地根据错误的性质进行分支,而不是解析异常消息或类型。
根据 JSON-RPC 规范,错误代码必须大于 -32000,包括正数。
使用其他中转服务
当中转服务本身需要访问另一个中转服务时,我们建议使用提供给其服务工厂的 IServiceBroker,但当中转服务注册设置 AllowTransitiveGuestClients 标志时,这一点尤为重要。
为了遵守这一准则,如果我们的计算器服务需要其他中转服务来实现其行为,我们将修改构造函数以接受 IServiceBroker:
internal class Calculator : ICalculator
{
private readonly State state;
private readonly IServiceBroker serviceBroker;
internal class Calculator(State state, IServiceBroker serviceBroker)
{
this.state = state;
this.serviceBroker = serviceBroker;
}
// ...
}
有状态服务
按客户端状态
将为每个请求服务的客户端创建一个此类的新实例。
上述 Calculator
类上的字段将存储一个值,该值可能对每个客户端都是唯一的。
假设我们添加一个计数器,该计数器在每次执行操作时递增:
internal class Calculator : ICalculator
{
int operationCounter;
public ValueTask<double> AddAsync(double a, double b, CancellationToken cancellationToken)
{
this.operationCounter++;
return new ValueTask<double>(a + b);
}
public ValueTask<double> SubtractAsync(double a, double b, CancellationToken cancellationToken)
{
this.operationCounter++;
return new ValueTask<double>(a - b);
}
}
应按照线程安全实践编写中转服务。
当使用推荐的 ServiceJsonRpcDescriptor 时,与客户端的远程连接可能包括并发执行服务的方法,如本文档中所述。
当客户端与服务共享进程和 AppDomain 时,客户端可能会从多个线程并发调用服务。
上述示例的线程安全实现可能使用 Interlocked.Increment(Int32) 来增加 operationCounter
字段。
共享状态
如果服务需要在其所有客户端之间共享某个状态,则应在一个由 VS 包实例化的不同类中定义该状态,并将其作为参数传递给服务的构造函数。
假设我们希望上面定义的 operationCounter
对服务的所有客户端的所有操作进行计数。
我们需要将字段提升到此新的状态类:
internal class Calculator : ICalculator
{
private readonly State state;
internal Calculator(State state)
{
this.state = state;
}
public ValueTask<double> AddAsync(double a, double b, CancellationToken cancellationToken)
{
this.state.IncrementCounter();
return new ValueTask<double>(a + b);
}
public ValueTask<double> SubtractAsync(double a, double b, CancellationToken cancellationToken)
{
this.state.IncrementCounter();
return new ValueTask<double>(a - b);
}
internal class State
{
private int operationCounter;
internal int OperationCounter => this.operationCounter;
internal void IncrementCounter() => Interlocked.Increment(ref this.operationCounter);
}
}
现在,我们有了一种巧妙、可测试的方式来管理 Calculator
服务的多个实例之间的共享状态。
稍后,在编写提供服务的代码时,我们将看到如何一次性创建这个 State
类并与 Calculator
服务的每个实例共享。
在处理共享状态时,线程安全尤为重要,因为不能假设多个客户端计划它们的调用,从而使它们永远不会并发进行。
如果共享状态类需要访问其他中转服务,则应使用全局服务代理,而不是分配给中转服务的单个实例的上下文代理之一。 当设置了 ProvideBrokeredServiceAttribute.AllowTransitiveGuestClients 标志时,在中转服务中使用全局服务代理会带来安全隐患 。
安全注意事项
如果中转服务是使用 ProvideBrokeredServiceAttribute.AllowTransitiveGuestClients 标志注册的,则需要考虑安全性问题,这会使参与共享 Live Share 会话的其他计算机上的其他用户可能访问该服务。
查看如何保护中转服务,并在设置 AllowTransitiveGuestClients 标志之前采取必要的安全缓解措施。
服务标记
中转服务必须具有可序列化的名称和版本,客户端可以通过该名称和可选版本请求该服务。 ServiceMoniker 是这两条信息的便捷包装器。
服务名字对象类似于 CLR(公共语言运行时)类型的程序集限定全名。 它必须是全局唯一的,因此应该包括你的公司名称,也许还有你的扩展名作为服务名称本身的前缀。
在 static readonly
字段中定义此名字对象可能很有用,以便在其他地方使用:
public static readonly ServiceMoniker Moniker = new ServiceMoniker("YourCompany.Extension.Calculator", new Version("1.0"));
虽然大多数服务的使用可能不会直接使用名字对象,但通过管道而不是代理进行通信的客户端将需要这个名字对象。
虽然某个版本在名字对象上是可选的,但建议提供版本,因为它为服务作者提供了更多选项,以便在行为变化时保持与客户端的兼容性。
服务描述符
服务描述符将服务名字对象与运行 RPC 连接和创建本地或远程代理所需的行为相结合。 描述符负责有效地将 RPC 接口转换为线路协议。 此服务描述符是 ServiceRpcDescriptor 派生类型的实例。 描述符必须可用于所有将使用代理访问此服务的客户端。 提供服务也需要此描述符。
Visual Studio 定义了一种这样的派生类型,并建议将其用于所有服务:ServiceJsonRpcDescriptor。 此描述符将 StreamJsonRpc 用于其 RPC 连接,并为本地服务创建一个高性能的本地代理,该代理模拟一些远程行为,如包装 RemoteInvocationException 中服务抛出的异常。
ServiceJsonRpcDescriptor 支持为 JSON-RPC 协议的 JSON 或 MessagePack 编码配置 JsonRpc 类。 建议使用 MessagePack 编码,因为它更紧凑,性能可以提高 10 倍。
我们可以为计算器服务定义描述符,如下所示:
/// <summary>
/// The descriptor for the calculator brokered service.
/// Use the <see cref="ICalculator"/> interface for the client proxy for this service.
/// </summary>
public static readonly ServiceRpcDescriptor CalculatorService = new ServiceJsonRpcDescriptor(
Moniker,
Formatters.MessagePack,
MessageDelimiters.BigEndianInt32LengthHeader,
new MultiplexingStream.Options { ProtocolMajorVersion = 3 })
.WithExceptionStrategy(StreamJsonRpc.ExceptionProcessing.ISerializable);
如上所示,可以选择格式化程序和分隔符。 由于并非所有组合都有效,我们建议使用以下任一组合:
ServiceJsonRpcDescriptor.Formatters | ServiceJsonRpcDescriptor.MessageDelimiters | 最适用于 |
---|---|---|
MessagePack | BigEndianInt32LengthHeader | 高性能 |
UTF8 (JSON) | HttpLikeHeaders | 与其他 JSON-RPC 系统互操作 |
通过将 MultiplexingStream.Options
对象指定为最终参数,客户端和服务之间共享的 RPC 连接只是 MultiplexingStream 上的一个通道,该通道与 JSON-RPC 连接共享,以便通过 JSON-RPC 高效传输大型二进制数据。
ExceptionProcessing.ISerializable 策略导致从服务抛出的异常被序列化,并保留为客户端上抛出的 Exception.InnerException 到 RemoteInvocationException 的异常。 如果不进行此设置,则客户端上可用的异常信息不太详细。
提示:将描述符公开为 ServiceRpcDescriptor,而不是用作实现详细信息的任何派生类型。 这样,可以更灵活地在以后更改实现详细信息,而无需对 API 重大更改。
在描述符的 xml 文档注释中包含对服务接口的引用,以便用户更容易地使用你的服务。 如果适用,还可以引用你的服务作为客户端 RPC 目标接受的接口。
一些更高级的服务也可能接受或要求来自客户端的符合某些接口的 RPC 目标对象。
对于这种情况,请使用带有 Type clientInterface
参数的 ServiceJsonRpcDescriptor 构造函数来指定客户端应提供的实例的接口。
对描述符进行版本控制
随着时间的推移,你可能希望增加服务的版本。 在这种情况下,你应该为希望支持的每个版本定义一个描述符,并为每个版本使用唯一的特定于版本的 ServiceMoniker。 同时支持多个版本有利于向后兼容性,通常只需一个 RPC 接口即可实现。
Visual Studio 在其 VisualStudioServices 类中遵循此模式,将原始 ServiceRpcDescriptor 定义为嵌套类下的 virtual
属性,该嵌套类表示添加该中转服务的第一个版本。
我们需要更改线路协议或添加/更改服务的功能时,Visual Studio 将在更高版本的嵌套类中声明一个 override
属性,该属性将返回一个新的 ServiceRpcDescriptor。
对于由 Visual Studio 扩展定义和提供的服务,在原始服务旁边声明另一个描述符属性就足够了。 例如,假设 1.0 服务使用了 UTF8 (JSON) 格式化程序,并且你明白了切换到 MessagePack 将带来显著的性能优势。 由于更改格式化程序是线路协议的重大更改,因此需要递增中转服务版本号和第二个描述符。 这两个描述符在一起可能如下所示:
public static readonly ServiceJsonRpcDescriptor CalculatorService = new ServiceJsonRpcDescriptor(
new ServiceMoniker("YourCompany.Extension.Calculator", new Version("1.0")),
Formatters.UTF8,
MessageDelimiters.HttpLikeHeaders,
new MultiplexingStream.Options { ProtocolMajorVersion = 3 })
);
public static readonly ServiceJsonRpcDescriptor CalculatorService1_1 = new ServiceJsonRpcDescriptor(
new ServiceMoniker("YourCompany.Extension.Calculator", new Version("1.1")),
Formatters.MessagePack,
MessageDelimiters.BigEndianInt32LengthHeader,
new MultiplexingStream.Options { ProtocolMajorVersion = 3 });
尽管我们声明了两个描述符(稍后我们将不得不提供和注册两个服务),但我们只需一个服务接口和实现就可以做到这一点,从而使支持多个服务版本的开销非常低。
提供服务
当请求传入时,必须创建中转服务,该请求通过称为“提供服务”的步骤进行安排。
服务工厂
使用 GlobalProvider.GetServiceAsync 请求 SVsBrokeredServiceContainer。 然后,调用该容器的 IBrokeredServiceContainer.Proffer 来提供服务。
在下面的示例中,我们使用前面声明的 CalculatorService
字段(设置为 ServiceRpcDescriptor 的实例)来提供服务。
我们将它传递给服务工厂,这是一个 BrokeredServiceFactory 委托。
IBrokeredServiceContainer container = await AsyncServiceProvider.GlobalProvider.GetServiceAsync<SVsBrokeredServiceContainer, IBrokeredServiceContainer>();
container.Proffer(
CalculatorService,
(moniker, options, serviceBroker, cancellationToken) => new ValueTask<object?>(new CalculatorService()));
中转服务通常为每个客户端实例化一次。 这与其他 VS (Visual Studio) 服务不同,后者通常实例化一次并在所有客户端之间共享。 为每个客户端创建一个服务实例可以实现更好的安全性,因为每个服务和/或其连接都可以为每个客户端保留关于客户端操作的授权级别、它们的首选 CultureInfo 是什么等的状态。正如我们接下来将看到的,它还允许接受特定于此请求的参数的更有趣的服务。
重要
如果服务工厂偏离了这一准则,并向每个客户端返回一个共享服务实例而不是一个新实例,则永远不应让其服务实现IDisposable,因为第一个处理其代理的客户端将导致在其他客户端使用共享服务实例之前处理该共享服务实例。
在更高级的情况下,CalculatorService
构造函数需要一个共享状态对象和 IServiceBroker,我们可以提供这样的工厂:
var state = new CalculatorService.State();
container.Proffer(
CalculatorService,
(moniker, options, serviceBroker, cancellationToken) => new ValueTask<object?>(new CalculatorService(state, serviceBroker)));
state
局部变量位于服务工厂外部,因此仅创建一次,并在所有实例化服务之间共享。
更高级的是,如果服务需要访问 ServiceActivationOptions(例如,调用客户端 RPC 目标对象上的方法),也可以传入:
var state = new CalculatorService.State();
container.Proffer(CalculatorService, (moniker, options, serviceBroker, cancellationToken) =>
new ValueTask<object?>(new CalculatorService(state, serviceBroker, options)));
在这种情况下,服务构造函数可能如下所示,假设 ServiceJsonRpcDescriptor 是用 typeof(IClientCallbackInterface)
作为其构造函数参数之一创建的:
internal class Calculator(State state, IServiceBroker serviceBroker, ServiceActivationOptions options)
{
this.state = state;
this.serviceBroker = serviceBroker;
this.options = options;
this.clientCallback = (IClientCallbackInterface)options.ClientRpcTarget;
}
此 clientCallback
字段现在可以在服务需要调用客户端的任何时候调用,直到释放连接。
如果服务工厂是基于名字对象创建多个服务或服务的不同版本的共享方法,则 BrokeredServiceFactory 委托接受 ServiceMoniker 作为参数。 此名字对象来自客户端,并包含他们期望的服务版本。 通过将此名字对象转发给服务构造函数,服务可以模拟特定服务版本的怪异行为,以匹配客户端可能期望的行为。
避免将 AuthorizingBrokeredServiceFactory 委托与 IBrokeredServiceContainer.Proffer 方法一起使用,除非你将在中转服务类中使用 IAuthorizationService。 此 IAuthorizationService 必须与中转服务类一起处理,以避免内存泄漏。
支持服务的多个版本
当在 ServiceMoniker 上增加版本时,必须提供你打算响应客户请求的中转服务的每个版本。 这是通过对你仍然支持的每个 ServiceRpcDescriptor 调用 IBrokeredServiceContainer.Proffer 方法来完成的。
使用 null
版本提供服务将充当“catch all”,它将匹配任何客户端请求,其中精确的版本匹配与注册的服务不存在。
例如,可以提供具有特定版本的 1.0 和 1.1 服务,也可以使用 null
版本注册服务。
在这种情况下,使用 1.0 或 1.1 请求服务的客户端会调用你为这些确切版本提供的服务工厂,而请求版本 8.0 的客户端会导致调用提供的 null 版本的服务工厂。
由于客户端请求的版本提供给服务工厂,因此工厂可以决定如何为此特定客户端配置服务,或者是否返回 null
表示不受支持的版本。
客户端对具有 null
版本的服务的请求仅与已注册并用 null
版本提供的服务匹配。
考虑这样一种情况,你发布了许多版本的服务,其中几个版本向后兼容,因此可能共享一个服务实现。 我们可以利用 catch-all 选项,避免重复提供每个单独的版本,如下所示:
const string ServiceName = "YourCompany.Extension.Calculator";
ServiceRpcDescriptor CreateDescriptor(Version? version) =>
new ServiceJsonRpcDescriptor(
new ServiceMoniker(ServiceName, version),
ServiceJsonRpcDescriptor.Formatters.MessagePack,
ServiceJsonRpcDescriptor.MessageDelimiters.BigEndianInt32LengthHeader);
IBrokeredServiceContainer container = await AsyncServiceProvider.GlobalProvider.GetServiceAsync<SVsBrokeredServiceContainer, IBrokeredServiceContainer>();
container.Proffer(
CreateDescriptor(new Version(2, 0)),
(moniker, options, serviceBroker, cancellationToken) => new ValueTask<object?>(new CalculatorServiceV2()));
container.Proffer(
CreateDescriptor(null), // proffer a catch-all
(moniker, options, serviceBroker, cancellationToken) => new ValueTask<object?>(moniker.Version switch {
{ Major: 1 } => new CalculatorService(), // match any v1.x request with our v1 service.
null => null, // We don't support clients that do not specify a version.
_ => null, // The client requested some other version we don't recognize.
}));
注册服务
向全局中转服务容器提供中转服务将引发异常,除非该服务已首先注册。 注册为容器提供了一种方法,可以提前知道哪些中转服务可用,以及当请求哪些中转服务以执行提供代码时应该加载哪些 VS 包。 这使 Visual Studio 能够快速启动,而无需提前加载所有扩展,但能够在其中转服务的客户端请求时加载所需的扩展。
注册可以通过将 ProvideBrokeredServiceAttribute 应用到 AsyncPackage 派生类来完成。 这是唯一可以设置 ServiceAudience 的位置。
[ProvideBrokeredService("YourCompany.Extension.Calculator", "1.0", Audience = ServiceAudience.Local)]
默认 Audience 为 ServiceAudience.Process,仅向同一进程中的其他代码公开中转服务。 通过设置 ServiceAudience.Local,可以选择将中转服务公开给属于同一 Visual Studio 会话的其他进程。
如果中转服务必须向 Live Share 来宾公开,则 Audience 必须包含 ServiceAudience.LiveShareGuest 属性,并且 ProvideBrokeredServiceAttribute.AllowTransitiveGuestClients 属性设置为 true
。
设置这些标志可能会引入严重的安全漏洞,在没有首先遵循如何保护中转服务中的指导之前,则不应该这样做。
当在 ServiceMoniker 上增加版本时,必须注册你打算响应客户请求的中转服务的每个版本。 通过支持中转服务的最新版本以上版本,可以帮助维护旧中转服务版本的客户端的向后兼容性,这在考虑 Live Share 方案时可能特别有用,因为每个版本的 Visual Studio 共享会话可能是不同的版本。
使用 null
版本注册服务将充当“catch all”,它将匹配任何客户端请求,其中精确的版本匹配与注册的服务不存在。
例如,可以注册具有特定版本的 1.0 和 2.0 服务,也可以使用 null
版本注册服务。
使用 MEF 提供和注册服务
这需要 Visual Studio 2022 Update 2 或更高版本。
中转服务可以通过 MEF 导出,而不是使用前面两节中介绍的 Visual Studio 包。 这需要考虑权衡:
权衡 | 包提供 | MEF 导出 |
---|---|---|
可用性 | ✅ VS 启动时立即提供中转服务。 | ⚠️ 在 MEF 在流程中初始化之前,中转服务的可用性可能会延迟。 这通常很快,但是当 MEF 缓存过期时可能需要几秒钟的时间。 |
跨平台就绪情况 | ⚠️ 必须编写特定于 Windows 的 Visual Studio 代码。 | ✅ 程序集中的中转服务在 Visual Studio for Windows 和 Visual Studio for Mac 中均可加载。 |
若要通过 MEF 而不是使用 VS 包导出中转服务,请执行以下操作:
- 确认没有与最后两个部分相关的代码。 具体而言,你不应有调用 IBrokeredServiceContainer.Proffer 的代码,也不应将 ProvideBrokeredServiceAttribute 应用于包(如果有的话)。
- 在
IExportedBrokeredService
中转服务类上实现接口。 - 避免在构造函数或导入属性资源库中使用任何主线程依赖项。 使用
IExportedBrokeredService.InitializeAsync
方法初始化中转服务,其中允许主线程依赖项。 - 将
ExportBrokeredServiceAttribute
应用于中转服务类,指定有关名字对象、受众的信息以及所需的任何其他注册相关信息。 - 如果类需要处置,则实现 IDisposable 而不是 IAsyncDisposable,因为 MEF 拥有服务的生存期,并且仅支持同步处置。
- 确保
source.extension.vsixmanifest
文件将包含中转服务的项目作为 MEF 程序集列出。
作为 MEF 部分,中转服务可能会在默认范围内导入任何其他 MEF 部分。
执行此操作时,请确保使用 System.ComponentModel.Composition.ImportAttribute 而不是 System.Composition.ImportAttribute。
这是因为 ExportBrokeredServiceAttribute
派生自 System.ComponentModel.Composition.ExportAttribute,并且需要在整个类型中使用相同的 MEF 命名空间。
中转服务的独特之处在于能够导入一些特殊导出:
- IServiceBroker,用于获取其他中转服务。
- ServiceMoniker,导出中转服务的多个版本并需要检测客户端请求的版本时,这会非常有用。
- ServiceActivationOptions,当需要客户端提供特殊参数或客户端回调目标时,这会非常有用。
- AuthorizationServiceClient,当需要执行如何保护中转服务中所述的安全检查时,这会非常有用。 此对象不需要由你的类释放,因为它将在处理中转服务时自动释放。
中转服务不得使用 MEF 的 ImportAttribute 获取其他中转服务。
相反,它可以 [Import]
IServiceBroker 并以传统方式查询中转服务。
详细了解如何使用中转服务。
示例如下:
using System;
using System.ComponentModel.Composition;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.ServiceHub.Framework;
using Microsoft.ServiceHub.Framework.Services;
using Microsoft.VisualStudio.Shell.ServiceBroker;
[ExportBrokeredService("Calc", "1.0")]
internal class MefBrokeredService : IExportedBrokeredService, ICalculator
{
internal static ServiceRpcDescriptor SharedDescriptor { get; } = new ServiceJsonRpcDescriptor(
new ServiceMoniker("Calc", new Version("1.0")),
clientInterface: null,
ServiceJsonRpcDescriptor.Formatters.MessagePack,
ServiceJsonRpcDescriptor.MessageDelimiters.BigEndianInt32LengthHeader,
new MultiplexingStream.Options { ProtocolMajorVersion = 3 });
// IExportedBrokeredService
public ServiceRpcDescriptor Descriptor => SharedDescriptor;
[Import]
IServiceBroker ServiceBroker { get; set; } = null!;
[Import]
ServiceMoniker ServiceMoniker { get; set; } = null!;
[Import]
ServiceActivationOptions Options { get; set; }
// IExportedBrokeredService
public Task InitializeAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public ValueTask<int> AddAsync(int a, int b, CancellationToken cancellationToken = default)
{
return new(a + b);
}
public ValueTask<int> SubtractAsync(int a, int b, CancellationToken cancellationToken = default)
{
return new(a - b);
}
}
导出中转服务的多个版本
ExportBrokeredServiceAttribute
可以多次应用于中转服务,以提供多个版本的中转服务。
属性 IExportedBrokeredService.Descriptor
的实现应返回一个描述符,其名字对象与客户端请求的名字对象匹配。
请考虑此示例,计算器服务使用 UTF8 格式导出 1.0,然后稍后添加 1.1 导出,以享受使用 MessagePack 格式带来的性能优势。
[ExportBrokeredService("Calc", "1.0")]
[ExportBrokeredService("Calc", "1.1")]
internal class MefBrokeredService : IExportedBrokeredService, ICalculator
{
internal static ServiceRpcDescriptor SharedDescriptor1_0 { get; } = new ServiceJsonRpcDescriptor(
new ServiceMoniker("Calc", new Version("1.0")),
clientInterface: null,
ServiceJsonRpcDescriptor.Formatters.UTF8,
ServiceJsonRpcDescriptor.MessageDelimiters.HttpLikeHeaders,
new MultiplexingStream.Options { ProtocolMajorVersion = 3 });
internal static ServiceRpcDescriptor SharedDescriptor1_1 { get; } = new ServiceJsonRpcDescriptor(
new ServiceMoniker("Calc", new Version("1.1")),
clientInterface: null,
ServiceJsonRpcDescriptor.Formatters.MessagePack,
ServiceJsonRpcDescriptor.MessageDelimiters.BigEndianInt32LengthHeader,
new MultiplexingStream.Options { ProtocolMajorVersion = 3 });
// IExportedBrokeredService
public ServiceRpcDescriptor Descriptor =>
this.ServiceMoniker.Version == SharedDescriptor1_0.Moniker.Version ? SharedDescriptor1_0 :
this.ServiceMoniker.Version == SharedDescriptor1_1.Moniker.Version ? SharedDescriptor1_1 :
throw new NotSupportedException();
[Import]
ServiceMoniker ServiceMoniker { get; set; } = null!;
}
从 Visual Studio 2022 Update 12 (17.12) 开始,可以导出 null
版本控制服务,以匹配服务的任何客户端请求,而不考虑版本,包括具有 null
版本的请求。
此类服务可以从 Descriptor
属性返回 null
,以便在不提供客户端请求的版本实现时拒绝客户端请求。
拒绝服务请求
中转服务可以通过从 InitializeAsync 方法抛出来拒绝客户端的激活请求。 抛出会导致 ServiceActivationFailedException 被抛出回客户端。