提供中转服务
中转服务由以下元素组成:
- 声明服务功能并充当服务与其客户端之间的协定的接口。
- 该接口的实现。
- 服务别名 为服务指定名称和版本。
- 一个描述符,该描述符将服务名称与处理 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 支持配置 JsonRpc 类,以用于 JSON-RPC 协议的 JSON 或 MessagePack 编码。 建议使用 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 连接只是多路复用流 上的一个通道,该通道与 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
版本的服务将作为一个“总括”,可以匹配任何与注册服务不存在精确版本匹配的客户端请求。
例如,可以提供特定版本的 1.0 和 1.1 服务,也可以注册 null
版本的服务。
在这种情况下,请求 1.0 或 1.1 服务的客户端会调用你为这些精确版本提供的服务工厂,而请求 8.0 版本的客户端则会调用你提供的 null 版本数据工厂。
由于客户端请求的版本提供给服务工厂,因此工厂可以决定如何为此特定客户端配置服务,或者是否返回 null
来表示不受支持的版本。
客户端对 null
版本服务的请求只能与注册并提供 null
版本的服务相匹配。
假设你发布了许多版本的服务,其中几个版本向后兼容,因此可能会共享服务实现。 我们可以利用通用选项,避免重复提供每个单独的版本,如下所示:
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 和设置为 true
的 ProvideBrokeredServiceAttribute.AllowTransitiveGuestClients 属性。
设置这些标志可能会引入严重的安全漏洞,如果不首先符合 如何保护中转服务中的指南,则不应这样做。
当你增加 ServiceMoniker 上的版本时,必须注册你打算响应客户端请求的每个中转服务版本。 通过支持不仅限于最新版本的中转服务,你帮助维护旧中转服务版本的客户端的后向兼容性,这在考虑 Live Share 场景(共享会话的每个 Visual Studio 版本可能是不同的版本)时可能特别有用。
注册 null
版本的服务将作为一个“总括”,可以匹配任何与注册服务不存在精确版本匹配的客户端请求。
例如,可以将 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 导出代理服务,而不是使用 Visual Studio 包,
- 确认你没有与最后两个部分相关的代码。 具体而言,你不应有调用 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 更新 12(17.12)开始,可以导出一个标记为 null
版本的服务,以便响应任何有关服务的客户端请求,无论请求使用的是哪个版本,包括标注为 null
版本的请求。
此类服务可以从 Descriptor
属性返回 null
,以便在它不提供客户端请求的版本实现时拒绝客户端请求。
拒绝服务请求
中转服务可以通过从 InitializeAsync 方法引发来拒绝客户端的激活请求。 引发会导致将 ServiceActivationFailedException 抛回客户端。