Aprovisionamiento de un servicio de intermediación

Un servicio de intermediación consta de los siguientes elementos:

Cada uno de los elementos de la lista anterior se describe al detalle en las secciones siguientes.

Con todo el código de este artículo, se recomienda activar la característica de tipos de referencia que aceptan valores NULL de C#.

La interfaz de servicio

La interfaz de servicio puede ser una interfaz de .NET estándar (a menudo escrita en C#), pero debe cumplir las directrices establecidas por el tipo derivado de ServiceRpcDescriptor que el servicio usará para asegurarse de que la interfaz se puede usar a través de RPC cuando el cliente y el servicio se ejecutan en distintos procesos. Estas restricciones suelen incluir que no se permiten propiedades e indexadores, y la mayoría o todos los métodos devuelven Task u otro tipo de devolución compatible con asincrónico.

ServiceJsonRpcDescriptor es el tipo derivado recomendado para los servicios de intermediación. Esta clase utiliza la biblioteca StreamJsonRpc cuando el cliente y el servicio requieren que RPC se comunique. StreamJsonRpc aplica ciertas restricciones en la interfaz de servicio como se describe aquí.

La interfaz puede derivar de IDisposable, System.IAsyncDisposable o incluso de Microsoft.VisualStudio.Threading.IAsyncDisposable, pero el sistema no lo requiere. Los servidores proxy de cliente generados implementarán IDisposable de cualquier manera.

Una interfaz de servicio de calculadora simple se puede declarar de la siguiente manera:

public interface ICalculator
{
    ValueTask<double> AddAsync(double a, double b, CancellationToken cancellationToken);
    ValueTask<double> SubtractAsync(double a, double b, CancellationToken cancellationToken);
}

Aunque la implementación de los métodos en esta interfaz puede no garantizar un método asincrónico, siempre usamos firmas de método asincrónico en esta interfaz porque esta interfaz se usa para generar el proxy de cliente que puede invocar este servicio de forma remota, lo que ciertamente garantiza una firma de método asincrónico.

Una interfaz puede declarar eventos que se pueden usar para notificar a sus clientes los eventos que se producen en el servicio.

Más allá de los eventos o el patrón de diseño de observador, un servicio de intermediación que necesita "devolver la llamada" al cliente puede definir una segunda interfaz que actúe como el contrato que un cliente debe implementar y proporcionar a través de la propiedad ServiceActivationOptions.ClientRpcTarget al solicitar el servicio. Esta interfaz debe ajustarse a todos los mismos patrones de diseño y restricciones que la interfaz de servicio de intermediación, pero con restricciones agregadas en el control de versiones.

Revise los Procedimientos recomendados para diseñar un servicio de intermediación para obtener sugerencias sobre cómo diseñar una interfaz RPC eficaz y preparada para el futuro.

Puede ser útil declarar esta interfaz en un ensamblado distinto del ensamblado que implementa el servicio para que sus clientes puedan hacer referencia a la interfaz sin que el servicio tenga que exponer más detalles de su implementación. También puede ser útil enviar el ensamblado de interfaz como un paquete NuGet para que otras extensiones hagan referencia mientras reserva su propia extensión para enviar la implementación del servicio.

Considere la posibilidad de tener como destino el ensamblado que declara la interfaz de servicio para netstandard2.0 para asegurarse de que el servicio se pueda invocar fácilmente desde cualquier proceso de .NET, ya sea que ejecute .NET Framework, .NET Core, .NET 5 o posterior.

Prueba

Las pruebas automatizadas deben escribirse junto con la interfaz de servicio para comprobar la preparación de RPC de la interfaz.

Las pruebas deben comprobar que todos los datos que se pasan a través de la interfaz son serializables.

Puede que la clase BrokeredServiceContractTestBase<TInterface,TServiceMock> del paquete Microsoft.VisualStudio.Sdk.TestFramework.Xunit le resulte útil para derivar su clase de prueba de interfaz. Esta clase incluye algunas pruebas de convención básicas para la interfaz, métodos para ayudar con aserciones comunes, como pruebas de eventos, etc.

Métodos

Compruebe que todos los argumentos y el valor devuelto se han serializado completamente. Si va a usar la clase base de prueba mencionada anteriormente, el código podría verse así:

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

Considere la posibilidad de probar la resolución de sobrecargas si declara varios métodos con el mismo nombre. Podría agregar un campo internal al servicio ficticio para cada método en él que almacene argumentos para ese método de modo que el método de prueba pueda llamar al método y luego verificar que el método correcto se invocó con los argumentos correctos.

Eventos

Los eventos declarados en la interfaz también deben probarse para la preparación de RPC. Los eventos generados a través de un servicio de intermediación no provocan un error de prueba si fallan durante la serialización de la RPC porque los eventos son de tipo "enviar y olvidar".

Si va a usar la clase base de prueba mencionada anteriormente, este procedimiento ya está integrado en algunos métodos auxiliares y podría tener aparecer así (con partes sin cambios omitidas para mayor brevedad):

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

Implementación del servicio

La clase de servicio debe implementar la interfaz RPC declarada en el paso anterior. Un servicio puede implementar IDisposable o cualquier otra interfaz además de la usada para RPC. El proxy generado en el cliente solo implementa la interfaz de servicio, IDisposable, y posiblemente algunas otras interfaces determinadas compatibles con el sistema, por lo que una conversión a otras interfaces implementadas por el servicio producirá un error en el cliente.

Considere el ejemplo de calculadora usado anteriormente, que implementamos aquí:

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

Dado que los propios cuerpos del método no necesitan ser asincrónicos, encapsulamos explícitamente el valor devuelto en un tipo de devolución ValueTask<TResult> construido para ajustarse a la interfaz de servicio.

Implementación del patrón de diseño observable

Si ofrece una suscripción de observador en la interfaz de servicio, podría tener este aspecto:

Task<IDisposable> SubscribeAsync(IObserver<YourDataType> observer);

El argumento IObserver<T> normalmente tendrá que durar más que esta llamada de método para que el cliente pueda seguir recibiendo actualizaciones una vez completada la llamada al método hasta que el cliente elimine el valor devuelto IDisposable. Para facilitar esta clase de servicio, puede incluir una colección de suscripciones IObserver<T> que las actualizaciones realizadas en su estado enumerarían para actualizar todos los suscriptores. Asegúrese de que la enumeración de la colección sea segura para subprocesos entre sí y, especialmente, con las mutaciones de esa colección que pueden producirse a través de suscripciones o eliminaciones adicionales de esas suscripciones.

Tenga cuidado de que todas las actualizaciones publicadas a través de OnNext conserven el orden en el que se introdujeron los cambios de estado en el servicio.

Todas las suscripciones deben finalizar en última instancia con una llamada a OnCompleted o OnError para evitar pérdidas de recursos en el cliente y los sistemas RPC. Esto incluye la eliminación del servicio donde se deben completar explícitamente todas las suscripciones restantes.

Obtenga más información sobre el patrón de diseño de observador, cómo implementar un proveedor de datos observable y, especialmente, teniendo en cuenta RPC.

Servicios descartables

No es necesario que la clase de servicio sea descartable, pero los servicios que lo sean se eliminarán cuando el cliente elimine su proxy al servicio o se pierda la conexión entre el cliente y el servicio. Las interfaces descartables se prueban en este orden: System.IAsyncDisposable, Microsoft.VisualStudio.Threading.IAsyncDisposable, IDisposable. Solo la primera interfaz de esta lista que implementa la clase de servicio se usará para eliminar el servicio.

Tenga en cuenta la seguridad de los subprocesos al considerar la eliminación. Se puede llamar al método Dispose en cualquier subproceso mientras se ejecuta otro código del servicio (por ejemplo, si se quita una conexión).

Iniciar excepciones

Al iniciar excepciones, considere la posibilidad de iniciar LocalRpcException con un ErrorCode específico para controlar el código de error recibido por el cliente en RemoteInvocationException. Proporcionar a los clientes un código de error puede permitirles bifurcarse en función de la naturaleza del error mejor que analizar los tipos o mensajes de excepción.

Según la especificación JSON-RPC, los códigos de error DEBEN ser superiores a -32000, incluidos los números positivos.

Consumo de otros servicios de intermediación

Cuando un servicio de intermediación requiere acceso a otro servicio de intermediación, se recomienda usar el IServiceBroker que se proporciona a su generador de servicios, pero es especialmente importante cuando el registro del servicio de intermediación establece la marca AllowTransitiveGuestClients.

Para cumplir esta norma si nuestro servicio de calculadora tuviera necesidad de otros servicios de intermediación para implementar su comportamiento, modificaríamos el constructor para aceptar un 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;
    }

    // ...
}

Obtenga más información sobre cómo proteger un servicio de intermediación y consumir servicios de intermediación.

Servicios con estado

Estado por cliente

Se creará una nueva instancia de esta clase para cada cliente que solicite el servicio. Un campo de la clase Calculator anterior almacenaría un valor que podría ser único para cada cliente. Supongamos que agregamos un contador que se incrementa cada vez que se realiza una operación:

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

El servicio de intermediación debe escribirse para seguir los procedimientos seguros para subprocesos. Cuando se usa el ServiceJsonRpcDescriptor recomendado, las conexiones remotas con clientes pueden incluir la ejecución simultánea de los métodos del servicio, tal como se describe en este documento. Cuando el cliente comparte un proceso y AppDomain con el servicio, el cliente podría llamar al servicio simultáneamente desde varios subprocesos. Una implementación segura para subprocesos del ejemplo anterior podría usar Interlocked.Increment(Int32) para incrementar el campo operationCounter.

Estado compartido

Si hay un estado en el que el servicio tendrá que compartir en todos sus clientes, este estado debe definirse en una clase distinta creada por el paquete de VS y pasada como argumento al constructor del servicio.

Supongamos que queremos que el operationCounter definido anteriormente cuente todas las operaciones en todos los clientes del servicio. Tendríamos que elevar el campo a esta nueva clase de estado:

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

Ahora tenemos una manera elegante y comprobable de administrar el estado compartido en varias instancias de nuestro servicio Calculator. Más adelante al escribir el código para ofrecer el servicio veremos cómo se crea esta clase State una vez y se comparte con cada instancia del servicio Calculator.

Es especialmente importante que cuando se trabaja con el estado compartido sea seguro para subprocesos, ya que no se puede realizar ninguna suposición en torno a varios clientes que programan sus llamadas de forma que nunca se realicen simultáneamente.

Si la clase de estado compartido necesita tener acceso a otros servicios de intermediación, debe usar el agente de servicio global en lugar de uno de los contextuales asignados a una instancia individual del servicio de intermediación. El uso del agente de servicio global dentro de un servicio de intermediación conlleva implicaciones de seguridad cuando se establece la marca ProvideBrokeredServiceAttribute.AllowTransitiveGuestClients.

Problemas de seguridad

La seguridad es una consideración para el servicio de intermediación si está registrado con la marca ProvideBrokeredServiceAttribute.AllowTransitiveGuestClients, que expone a otros usuarios el acceso posible en otras máquinas que participan en una sesión compartida de Live Share.

Consulte Cómo proteger un servicio de intermediación y tome las mitigaciones de seguridad necesarias antes de establecer la marca AllowTransitiveGuestClients.

El moniker de servicio

Un servicio de intermediación debe tener un nombre serializable y una versión opcional mediante los cuales un cliente pueda solicitar el servicio. Un ServiceMoniker es un contenedor cómodo para estos dos fragmentos de información.

Un moniker de servicio es análogo al nombre completo del ensamblado de un tipo CLR (Common Language Runtime). Debe ser único globalmente y, por tanto, debe incluir el nombre de la empresa y quizás el nombre de la extensión como prefijos para el propio nombre del servicio.

Puede ser útil definir este moniker en un campo static readonly para usarlo en otro lugar:

public static readonly ServiceMoniker Moniker = new ServiceMoniker("YourCompany.Extension.Calculator", new Version("1.0"));

Aunque la mayoría de los usos del servicio pueden no usar el moniker directamente, un cliente que se comunica a través de canalizaciones en lugar de un proxy requerirá el moniker.

Aunque una versión es opcional en un moniker, se recomienda facilitar una versión, ya que da a los autores de servicios más opciones para conservar la compatibilidad con los clientes en todos los cambios operativos.

El descriptor del servicio

El descriptor de servicio combina el moniker de servicio con los comportamientos necesarios para ejecutar una conexión RPC y crear un proxy local o remoto. El descriptor es responsable de convertir eficazmente la interfaz RPC en un protocolo de conexión. Este descriptor de servicio es una instancia de un tipo derivado de ServiceRpcDescriptor. El descriptor debe estar disponible para todos los clientes que usarán un proxy para acceder a este servicio. Para ofrecer el servicio también se necesita este descriptor.

Visual Studio define un tipo derivado de este tipo y recomienda su uso para todos los servicios: ServiceJsonRpcDescriptor. Este descriptor utiliza StreamJsonRpc para sus conexiones RPC y crea un proxy local de alto rendimiento para los servicios locales que emula algunos de los comportamientos remotos, como ajustar excepciones producidas por el servicio en RemoteInvocationException.

El ServiceJsonRpcDescriptor admite la configuración de la clase JsonRpc para la codificación JSON o MessagePack del protocolo JSON-RPC. Se recomienda codificar MessagePack porque es más compacto y puede ser 10 veces más eficaz.

Podemos definir un descriptor para nuestro servicio de calculadora como este:

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

Como puede ver arriba, hay disponible una opción de formateador y delimitador. Como no todas las combinaciones son válidas, se recomienda cualquiera de estas combinaciones:

ServiceJsonRpcDescriptor.Formatters ServiceJsonRpcDescriptor.MessageDelimiters Más adecuado para
MessagePack BigEndianInt32LengthHeader Alto rendimiento
UTF8 (JSON) HttpLikeHeaders Interoperabilidad con otros sistemas JSON-RPC

Al especificar el objeto MultiplexingStream.Options como parámetro final, la conexión RPC compartida entre el cliente y el servicio es solo un canal en MultiplexingStream, que se comparte con la conexión JSON-RPC para permitir la transferencia eficaz de datos binarios grandes a través de JSON-RPC.

La estrategia ExceptionProcessing.ISerializable hace que las excepciones producidas desde el servicio se serialicen y conserven como Exception.InnerException para RemoteInvocationException que se inicia en el cliente. Sin esta configuración, la información de excepción menos detallada está disponible en el cliente.

Sugerencia: exponga el descriptor como ServiceRpcDescriptor en lugar de cualquier tipo derivado que use como detalle de implementación. Esto proporciona más flexibilidad para cambiar los detalles de implementación más adelante sin cambios importantes en la API.

Incluya una referencia a la interfaz de servicio en el comentario del documento xml en el descriptor para facilitar a los usuarios el consumo del servicio. También haga referencia a la interfaz que el servicio acepta como destino RPC del cliente, si procede.

Algunos servicios más avanzados también pueden aceptar o requerir un objeto de destino RPC del cliente que se ajuste a alguna interfaz. Para este caso, use un constructor ServiceJsonRpcDescriptor con un parámetro Type clientInterface para especificar la interfaz de la que el cliente debe proporcionar una instancia.

Control de versiones del descriptor

Con el tiempo, es posible que quiera incrementar la versión del servicio. En tal caso, debe definir un descriptor para cada versión que desee admitir, utilizando un único ServiceMoniker específico de la versión para cada una. Admitir varias versiones simultáneamente puede ser bueno para la compatibilidad con versiones anteriores y normalmente se puede hacer con una sola interfaz RPC.

Visual Studio sigue este patrón con su clase VisualStudioServices definiendo el ServiceRpcDescriptor original como una propiedad virtual bajo la clase anidada que representa la primera versión que agregó ese servicio de intermediación. Cuando es necesario cambiar el protocolo de conexión o agregar o cambiar la funcionalidad del servicio, Visual Studio declara una propiedad override en una clase anidada con versiones posteriores que devuelve un nuevo ServiceRpcDescriptor.

Para un servicio definido y ofrecido por una extensión de Visual Studio, puede ser suficiente declarar otra propiedad del descriptor junto al original. Por ejemplo, supongamos que el servicio 1.0 usó el formateador UTF8 (JSON) y se da cuenta de que cambiar a MessagePack proporcionaría una ventaja significativa de rendimiento. Como cambiar el formateador es un cambio importante en el protocolo de conexión, requiere incrementar el número de versión del servicio de intermediación y un segundo descriptor. Los dos descriptores juntos podrían tener este aspecto:

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

Aunque declaramos dos descriptores (y luego tendremos que ofrecer y registrar dos servicios), podemos hacerlo con una sola interfaz de servicio e implementación, o que mantiene bastante baja la sobrecarga para admitir varias versiones de servicio.

Ofrecimiento del servicio

El servicio de intermediación debe crearse cuando entra una solicitud, que se organiza a través de un paso denominado ofrecimiento del servicio.

El generador de servicios

Use GlobalProvider.GetServiceAsync para solicitar el SVsBrokeredServiceContainer. A continuación, llame a IBrokeredServiceContainer.Proffer en ese contenedor para ofrecer el servicio.

En el ejemplo siguiente, ofrecemos un servicio mediante el campo CalculatorService declarado anteriormente, que se establece en una instancia de ServiceRpcDescriptor. Lo pasamos a nuestro generador de servicios, que es un delegado de BrokeredServiceFactory.

IBrokeredServiceContainer container = await AsyncServiceProvider.GlobalProvider.GetServiceAsync<SVsBrokeredServiceContainer, IBrokeredServiceContainer>();
container.Proffer(
    CalculatorService,
    (moniker, options, serviceBroker, cancellationToken) => new ValueTask<object?>(new CalculatorService()));

Normalmente, se crea una instancia de un servicio de intermediación por cliente. Se trata de una salida de otros servicios de VS (Visual Studio), que normalmente se crean con una instancia una vez y se comparten entre todos los clientes. La creación de una instancia del servicio por cliente mejora la seguridad, ya que cada servicio o su conexión pueden conservar el estado por cliente con respecto al nivel de autorización en el que opera el cliente, su preferencia de CultureInfo, etc. Como veremos a continuación, también permite servicios más interesantes que aceptan argumentos específicos de esta solicitud.

Importante

Un generador de servicios que se desvía de esta guía y devuelve una instancia de servicio compartida en lugar de una nueva a cada cliente nunca debe permitir que su servicio implemente IDisposable, ya que el primer cliente que elimine su proxy dará lugar a la eliminación de la instancia de servicio compartido antes de que otros clientes lo usen.

En el caso más avanzado en el que el constructor CalculatorService requiere un objeto de estado compartido y un IServiceBroker, podríamos ofrecer el generador de la siguiente manera:

var state = new CalculatorService.State();
container.Proffer(
    CalculatorService,
    (moniker, options, serviceBroker, cancellationToken) => new ValueTask<object?>(new CalculatorService(state, serviceBroker)));

La variable local state está fuera del generador de servicios y, por ello, solo se crea una vez y se comparte en todos los servicios creados por instancias.

Aún más avanzado, si el servicio necesita acceder a ServiceActivationOptions (por ejemplo, para invocar métodos en el objeto de destino RPC del cliente) también se podría pasar:

var state = new CalculatorService.State();
container.Proffer(CalculatorService, (moniker, options, serviceBroker, cancellationToken) =>
    new ValueTask<object?>(new CalculatorService(state, serviceBroker, options)));

En este caso, el constructor de servicio podría tener este aspecto, suponiendo que ServiceJsonRpcDescriptor se hubiera creado con typeof(IClientCallbackInterface) como uno de sus argumentos de constructor:

internal class Calculator(State state, IServiceBroker serviceBroker, ServiceActivationOptions options)
{
    this.state = state;
    this.serviceBroker = serviceBroker;
    this.options = options;
    this.clientCallback = (IClientCallbackInterface)options.ClientRpcTarget;
}

Este campo clientCallback ahora se puede invocar en cualquier momento en que el servicio quiera invocar al cliente, hasta que se elimine la conexión.

El delegado de BrokeredServiceFactory toma un ServiceMoniker como parámetro en caso de que el generador de servicios sea un método compartido que cree varios servicios o versiones diferentes del servicio según el moniker. Este moniker procede del cliente e incluye la versión del servicio que espera. Al reenviar este moniker al constructor de servicio, el servicio puede emular el comportamiento peculiar de determinadas versiones de servicio para que coincidan con lo que puede esperar el cliente.

Evite usar el delegado de AuthorizingBrokeredServiceFactory con el método IBrokeredServiceContainer.Proffer a menos que vaya a usar el IAuthorizationService dentro de la clase del servicio de intermediación. Este IAuthorizationService se debe eliminar con la clase del servicio de intermediación para evitar una pérdida de memoria.

Compatibilidad con varias versiones del servicio

Al incrementar la versión en ServiceMoniker, debe ofrecer cada versión del servicio de intermediación para el que pretende responder a las solicitudes de cliente. Para ello, llame al método IBrokeredServiceContainer.Proffer con cada ServiceRpcDescriptor que siga admitiendo.

Si ofrece su servicio con una versión null, esto le servirá como "comodín" que servirá para cualquier solicitud de cliente en la que no exista una coincidencia de versión exacta con un servicio registrado. Por ejemplo, puede ofrecer su servicio 1.0 y 1.1 con versiones específicas y también registrar el servicio con una versión null. En tales casos, los clientes que solicitan el servicio con la versión 1.0 o 1.1 invocan al generador de servicios que ofrecía esas versiones exactas, mientras que un cliente que solicita la versión 8.0 irá al generador de servicios ofrecido con versión NULL que se vaya a invocar. Dado que la versión solicitada por el cliente le llega al generador de servicios, el generador puede tomar una decisión sobre cómo configurar el servicio de este cliente en particular o si desea devolver null para indicar una versión no admitida.

La solicitud del cliente de un servicio con una versión null solo coincide con un servicio registrado y ofrecido con una versión null.

Pongamos el caso el que ha publicado muchas versiones del servicio, varias de las cuales son compatibles con versiones anteriores y, por tanto, pueden tener la misma implementación de servicio. Podemos usar la opción comodín para evitar tener que ofrecer repetidamente cada versión tal como se indica a continuación:

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.
    }));

Registrar el servicio

Ofrecer un servicio de intermediación al contenedor global de servicios de intermediación se iniciará a menos que el servicio se haya registrado previamente. El registro proporciona un medio para que el contenedor sepa con antelación qué servicios de intermediación pueden estar disponibles y qué paquete de VS se carga cuando se solicitan para ejecutar el código de búfer. Esto permite que Visual Studio se inicie rápidamente, sin cargar todas las extensiones de antemano, pero puede cargar la extensión necesaria cuando lo solicite un cliente de su servicio de intermediación.

El registro se puede realizar aplicando el ProvideBrokeredServiceAttribute a la clase derivada de AsyncPackage. Este es el único lugar donde ServiceAudience se puede establecer.

[ProvideBrokeredService("YourCompany.Extension.Calculator", "1.0", Audience = ServiceAudience.Local)]

El valor predeterminado de Audience es ServiceAudience.Process, que expone el servicio de intermediación solo a otro código dentro del mismo proceso. Al establecer ServiceAudience.Local, puede optar por exponer el servicio de intermediación a otros procesos que pertenecen a la misma sesión de Visual Studio.

Si el servicio de intermediación debe exponerse a invitados de Live Share, Audience debe incluir ServiceAudience.LiveShareGuest y la propiedad ProvideBrokeredServiceAttribute.AllowTransitiveGuestClients establecida en true. Si se crean estas flags, pueden surgir vulnerabilidades de seguridad graves y esto no debe realizarse sin cumplir primero las instrucciones sobre Cómo proteger un servicio de intermediación.

Al incrementar la versión en ServiceMoniker, debe registrar cada versión del servicio de intermediación para el que pretende responder a las solicitudes de cliente. Al admitir más de la versión más reciente de su servicio de intermediación, ayuda a mantener la compatibilidad con versiones anteriores para los clientes de su versión de servicio de intermediación más antigua, lo que puede resultar especialmente útil cuando se considera el escenario de Live Share, en el que cada versión de Visual Studio que comparte la sesión puede ser una versión diferente.

Si se registra el servicio con una versión null, esto servirá como "comodín" que servirá para cualquier solicitud de cliente en la que no exista una coincidencia de versión exacta con un servicio registrado. Por ejemplo, puede registrar su servicio 1.0 y 2.0 con versiones específicas y también registrar el servicio con una versión null.

Uso de MEF para ofrecer y registrar el servicio

Requiere Visual Studio 2022 Update 2 o posterior.

Un servicio de intermediación se puede exportar a través de MEF en lugar de usar un paquete de Visual Studio como se describe en las dos secciones anteriores. Esto tiene inconvenientes que debe tener en cuenta:

Compensación Ofrecimiento de paquetes Exportación de MEF
Disponibilidad ✅ El servicio de intermediación está disponible inmediatamente en el inicio de VS. ⚠️ La disponibilidad del servicio de intermediación puede retrasarse hasta que MEF se haya inicializado en el proceso. Esto suele ser rápido, pero puede tardar varios segundos cuando la caché MEF está obsoleta.
Preparación multiplataforma ⚠️ Debe crearse código específico de Visual Studio para Windows. ✅ El servicio de intermediación del ensamblado puede cargarse tanto en Visual Studio para Windows como en Visual Studio para Mac.

Para exportar el servicio de intermediación a través de MEF en lugar de usar paquetes de VS:

  1. Confirme que no tiene ningún código relacionado con las dos últimas secciones. En concreto, no debe tener ningún código que llame a IBrokeredServiceContainer.Proffer y no debe aplicar ProvideBrokeredServiceAttribute en el paquete (si existe).
  2. Implemente la interfaz IExportedBrokeredService en la clase del servicio de intermediación.
  3. Evite las dependencias principales del subproceso en el constructor o importe establecedores de propiedades. Use el método IExportedBrokeredService.InitializeAsync para inicializar el servicio de intermediación, donde se permiten las dependencias de subprocesos principales.
  4. Aplique ExportBrokeredServiceAttribute a la clase del servicio de intermediación, especificando la información sobre el moniker de servicio, la audiencia y cualquier otra información relacionada con el registro necesaria.
  5. Si la clase requiere eliminación, implemente IDisposable en lugar de IAsyncDisposable porque MEF posee la duración del servicio y solo admite la eliminación sincrónica.
  6. Asegúrese de que el archivo source.extension.vsixmanifest muestra el proyecto que contiene el servicio de intermediación como ensamblado MEF.

Como parte de MEF, el servicio de intermediación puede importar cualquier otro elemento MEF en el ámbito predeterminado. Al hacerlo, asegúrese de usar System.ComponentModel.Composition.ImportAttribute en lugar de System.Composition.ImportAttribute. Esto se debe a que ExportBrokeredServiceAttribute se deriva de System.ComponentModel.Composition.ExportAttribute y usa el mismo espacio de nombres MEF a lo largo de un tipo.

Un servicio de intermediación es único para poder importar algunas exportaciones especiales:

  • IServiceBroker, que se debe usar para adquirir otros servicios de intermediación.
  • ServiceMoniker, que puede ser útil cuando exporta varias versiones del servicio de intermediación y necesita detectar qué versión solicitó el cliente.
  • ServiceActivationOptions, que puede ser útil cuando se requiere que los clientes proporcionen parámetros especiales o un destino de devolución de llamada de cliente.
  • AuthorizationServiceClient, que puede ser útil cuando necesite realizar comprobaciones de seguridad como se describe en Cómo proteger un servicio de intermediación. Esta clase no necesita eliminar este objeto, ya que se eliminará automáticamente cuando se elimine el servicio de intermediación.

El servicio de intermediación no debe usar ImportAttribute de MEF para adquirir otros servicios de intermediación. En su lugar, puede [Import] IServiceBroker y consultar los servicios de intermediación de la manera tradicional. Obtenga más información en Cómo consumir un servicio de intermediación.

Este es un ejemplo:

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

Exportación de varias versiones del servicio de intermediación

El ExportBrokeredServiceAttribute puede aplicarse al servicio de intermediación varias veces para ofrecer varias versiones del servicio de intermediación.

La implementación de la propiedad IExportedBrokeredService.Descriptor debe devolver un descriptor con un moniker que coincida con el que solicitó el cliente.

Consideremos este ejemplo, en el que el servicio de calculadora exporta 1.0 con formato UTF8, y más tarde añade una exportación 1.1 para disfrutar de las ventajas de rendimiento que ofrece el uso del formato 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!;
}

A partir de la actualización 12 de Visual Studio 2022 (17.12), se puede exportar un servicio de versión null para que se corresponda con cualquier solicitud de cliente para el servicio, independientemente de la versión, así como una solicitud con una versión null. Este servicio puede devolver null en la propiedad Descriptor para rechazar una solicitud de cliente cuando no ofrece una implementación de la versión solicitada por el cliente.

Rechazar una solicitud de servicio

Un servicio de intermediación puede rechazar la solicitud de activación de un cliente sacándola del método InitializeAsync. Esta acción hace que se genere una ServiceActivationFailedException en el cliente.