Partager via


Proposer un service réparti

Un service réparti se compose des éléments suivants :

Chacun des éléments de la liste précédente est décrit en détail dans les sections suivantes.

Pour tout le code de cet article, il est fortement recommandé d’activer la fonctionnalité des types de référence nullables de C#.

L’interface de service

L’interface du service peut être une interface .NET standard (souvent écrite en C#), mais doit se conformer aux directives établies par le type dérivé de ServiceRpcDescriptor que votre service utilisera pour s’assurer que l’interface peut être utilisée par RPC lorsque le client et le service s’exécutent dans des processus différents. Ces restrictions incluent généralement le fait que les propriétés et les indexeurs ne sont pas autorisés, et que la plupart ou toutes les méthodes renvoient Task ou un autre type de retour compatible avec l’asynchronisme.

Le ServiceJsonRpcDescriptor est le type dérivé recommandé pour les services répartis. Cette classe utilise la bibliothèque StreamJsonRpc lorsque le client et le service ont besoin de RPC pour communiquer. StreamJsonRpc applique certaines restrictions à l’interface de service, comme décrit ici.

L’interface peut dériver de IDisposable, System.IAsyncDisposable ou même Microsoft.VisualStudio.Threading.IAsyncDisposable, mais le système ne l’exige pas. Les proxies clients générés mettront en œuvre IDisposable dans les deux cas.

Une interface de service de calculatrice simple peut être déclarée comme suit :

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

Bien que l’implémentation des méthodes de cette interface puisse ne pas justifier une méthode asynchrone, nous utilisons toujours des signatures de méthodes asynchrones sur cette interface parce que cette interface est utilisée pour générer le proxy client qui peut invoquer ce service à distance, ce qui justifie certainement une signature de méthode asynchrone.

Une interface peut déclarer des événements qui peuvent être utilisés pour notifier à leurs clients les événements qui se produisent au niveau du service.

Au-delà des événements ou du modèle de conception « observateur », un service réparti qui doit « rappeler » le client peut définir une deuxième interface qui sert de contrat que le client doit mettre en œuvre et fournir via la propriété ServiceActivationOptions.ClientRpcTarget lors de la requête du service. Une telle interface devrait se conformer aux mêmes modèles de conception et aux mêmes restrictions que l’interface du service réparti, mais avec des restrictions complémentaires sur la gestion des versions.

Consultez les meilleures pratiques de conception d’un service réparti pour obtenir des conseils sur la conception d’une interface RPC performante et à l’épreuve du temps.

Il peut être utile de déclarer cette interface dans un assembly distinct de l’assembly qui implémente le service afin que ses clients puissent faire référence à l’interface sans que le service n’ait à exposer davantage de détails de son implémentation. Il peut également être utile de livrer l’assembly de l’interface en tant que package NuGet pour que d’autres extensions puissent y faire référence tout en réservant votre propre extension pour livrer l’implémentation du service.

Envisagez de cibler l’assembly qui déclare votre interface de service sur netstandard2.0 pour vous assurer que votre service peut être facilement invoqué à partir de n’importe quel processus .NET, qu’il exécute .NET Framework, .NET Core, .NET 5 ou une version ultérieure.

Test

Des tests automatisés doivent être rédigés parallèlement à votre interface de service afin de vérifier que l’interface est prête pour la RPC.

Les tests doivent vérifier que toutes les données passées par l’interface sont sérialisables.

La classe BrokeredServiceContractTestBase<TInterface,TServiceMock> du package Microsoft.VisualStudio.Sdk.TestFramework.Xunit peut vous être utile pour dériver votre classe de test d’interface. Cette classe comprend des tests de convention de base pour votre interface, des méthodes pour aider avec des assertions communes comme les tests d’événements, et plus encore.

Méthodes

Affirmez que chaque argument et la valeur de retour ont été entièrement sérialisés. Si vous utilisez la classe de base de test mentionnée ci-dessus, votre code pourrait ressembler à ceci :

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

Envisagez de tester la résolution des surcharges si vous déclarez plusieurs méthodes portant le même nom. Vous pouvez ajouter un champ internal à votre service fictif pour chaque méthode qui stocke les arguments de cette méthode afin que la méthode de test puisse appeler la méthode et vérifier que la bonne méthode a été invoquée avec les bons arguments.

Événements

Tous les événements déclarés sur votre interface doivent également être testés pour la préparation RPC. Les événements générés par un service réparti ne provoquent pas l’échec du test s’ils échouent lors de la sérialisation RPC, car les événements sont de type « tir et oubli ».

Si vous utilisez la classe de base de test mentionnée ci-dessus, ce comportement est déjà intégré dans certaines méthodes d’aide et pourrait ressembler à ceci (avec les parties inchangées omises pour des raisons de brièveté) :

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

Implémentation du service

La classe de service doit implémenter l’interface RPC déclarée à l’étape précédente. Un service peut mettre en œuvre IDisposable ou toute autre interface en plus de celle utilisée pour RPC. Le proxy généré sur le client n’implémente que l’interface de service, IDisposable, et éventuellement quelques autres interfaces sélectionnées pour soutenir le système, de sorte qu’un cast vers d’autres interfaces implémentées par le service échouera sur le client.

Considérez l’exemple de la calculatrice utilisé ci-dessus, que nous implémentons ici :

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

Étant donné que les corps de méthode eux-mêmes ne doivent pas être asynchrones, nous enveloppons explicitement la valeur de retour dans un type de retour construit ValueTask<TResult> pour nous conformer à l’interface de service.

Implémentation du modèle de conception observable

Si vous proposez un abonnement à un observateur sur votre interface de service, cela pourrait ressembler à ceci :

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

L’argument IObserver<T> devra généralement dépasser la durée de vie de cet appel de méthode afin que le client puisse continuer à recevoir des mises à jour après la fin de l’appel de méthode jusqu’à ce que le client dispose de la valeur IDisposable renvoyée. Pour ce faire, votre classe de service peut inclure une collection d’abonnements IObserver<T> que toute mise à jour de votre état énumérera pour mettre à jour tous les abonnés. Assurez-vous que l’énumération de votre collection est thread-safe les uns par rapport aux autres et surtout par rapport aux mutations de cette collection qui peuvent se produire par le biais d’abonnements supplémentaires ou de l’élimination de ces abonnements.

Veillez à ce que toutes les mises à jour publiées via OnNext conservent l’ordre dans lequel les changements d’état ont été introduits dans votre service.

Tous les abonnements doivent se terminer par un appel à OnCompleted ou OnError afin d’éviter les fuites de ressources sur le client et les systèmes RPC. Cela inclut l’élimination des services où tous les abonnements restants doivent être explicitement complétés.

Apprenez-en plus sur le modèle de conception de l’observateur, sur la manière d’implémenter un fournisseur de données observable, en particulier en gardant à l’esprit la notion de RPC.

Services jetables

Votre classe de service n’est pas obligée d’être jetable, mais les services qui le sont seront éliminés lorsque le client éliminera son proxy vers votre service ou lorsque la connexion entre le client et le service sera perdue. Les interfaces jetables sont testées dans l’ordre suivant : System.IAsyncDisposable, Microsoft.VisualStudio.Threading.IAsyncDisposable et IDisposable. Seule la première interface de cette liste que votre classe de service implémente sera utilisée pour se débarrasser du service.

Gardez à l’esprit la threading-safety lorsque vous envisagez l’élimination. Votre méthode Dispose peut être appelée sur n’importe quel thread alors que d’autres codes de votre service sont en cours d’exécution (par exemple, en cas d’abandon d’une connexion).

Levée des exceptions

Lorsque vous lancez des exceptions, envisagez de lancer des LocalRpcException avec un ErrorCode spécifique afin de contrôler le code d’erreur reçu par le client dans le RemoteInvocationException. Fournir aux clients un code d’erreur peut leur permettre d’effectuer une branche basée sur la nature de l’erreur, mieux que d’analyser les messages d’exception ou les types.

Selon la spécification JSON-RPC, les codes d’erreur DOIVENT être supérieurs à -32000, y compris les nombres positifs.

Consommation d’autres services répartis

Lorsqu’un service réparti nécessite lui-même l’accès à un autre service réparti, nous recommandons l’utilisation du IServiceBroker qui est fourni à sa fabrique de services, mais c’est particulièrement important lorsque l’enregistrement du service réparti définit l’indicateur AllowTransitiveGuestClients.

Pour se conformer à cette directive, si notre service de calculatrice avait besoin d’autres services répartis pour mettre en œuvre son comportement, nous modifierions le constructeur pour qu’il accepte 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;
    }

    // ...
}

Apprenez-en plus sur la manière d’obtenir un service réparti et sur la consommation de services répartis.

Services avec état

État par client

Une nouvelle instance de cette classe sera créée pour chaque client qui demande le service. Un champ de la classe Calculator ci-dessus stockerait une valeur qui pourrait être unique pour chaque client. Supposons que nous ajoutions un compteur qui s’incrémente à chaque fois qu’une opération est effectuée :

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

Votre service réparti doit être écrit de manière à respecter les pratiques thread-safe. Lorsque vous utilisez la méthode recommandée ServiceJsonRpcDescriptor, les connexions à distance avec les clients peuvent inclure l’exécution simultanée des méthodes de votre service, comme décrit dans le présent document. Lorsque le client partage un processus et un AppDomain avec le service, il peut appeler votre service simultanément à partir de plusieurs threads. Une implémentation thread-safe de l’exemple ci-dessus pourrait utiliser Interlocked.Increment(Int32) pour incrémenter le champ operationCounter.

État partagé

S’il y a un état que votre service doit partager avec tous ses clients, cet état doit être défini dans une classe distincte qui est instanciée par votre package VS et passée en argument au constructeur de votre service.

Supposons que nous voulions que le operationCounter défini ci-dessus compte toutes les opérations effectuées par tous les clients du service. Nous devrions transférer le champ dans cette nouvelle classe d’état :

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

Nous disposons désormais d’un moyen élégant et testable de gérer l’état partagé entre plusieurs instances de notre service Calculator. Plus tard, lorsque nous écrirons le code pour proposer le service, nous verrons comment cette classe State est créée une seule fois et partagée avec chaque instance du service Calculator.

Il est particulièrement important d’être thread-safe lorsqu’il s’agit d’un état partagé, car il n’est pas possible de supposer que plusieurs clients planifient leurs appels de manière à ce qu’ils ne soient jamais effectués en même temps.

Si votre classe d’état partagé doit accéder à d’autres services répartis, elle doit utiliser le courtier de service global plutôt que l’un des courtiers contextuels assignés à une instance individuelle de votre service réparti. L’utilisation du courtier de services global dans le cadre d’un service réparti a des implications en matière de sécurité lorsque l’indicateur ProvideBrokeredServiceAttribute.AllowTransitiveGuestClients est activé.

Considérations sur la sécurité

La sécurité est à prendre en compte pour votre service réparti s’il est enregistré avec l’indicateur ProvideBrokeredServiceAttribute.AllowTransitiveGuestClients, ce qui l’expose à un accès possible par d’autres utilisateurs sur d’autres machines qui participent à une session Live Share partagée.

Examinez comment sécuriser un service réparti et prenez les mesures de sécurité nécessaires avant d’activer l’indicateur AllowTransitiveGuestClients.

Le moniker du service

Un service réparti doit avoir un nom et une version en option sérialisables par lesquels un client peut requérir le service. Un ServiceMoniker est un wrapper pratique pour ces deux informations.

Le moniker d’un service est analogue au nom complet d’un type CLR (Common Language Runtime) qualifié par l’assembleur. Il doit être unique au niveau mondial et doit donc inclure le nom de votre entreprise et éventuellement le nom de votre extension en tant que préfixe du nom du service lui-même.

Il peut être utile de définir ce moniker dans un champ static readonly pour l’utiliser ailleurs :

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

Bien que la plupart des utilisations de votre service n’utilisent pas directement votre moniker, un client qui communique par l’intermédiaire de tuyaux au lieu d’un proxy aura besoin du moniker.

Bien que la version soit facultative sur un moniker, il est recommandé de la fournir, car elle offre aux auteurs de services davantage d’options pour maintenir la compatibilité avec les clients en cas de changement de comportement.

Le descripteur de service

Le descripteur de service combine le moniker du service avec les communications à distance nécessaires pour établir une connexion RPC et créer un proxy local ou distant. Le descripteur est chargé de convertir efficacement votre interface RPC en un protocole filaire. Ce descripteur de service est une instance d’un type dérivé de ServiceRpcDescriptor Le descripteur doit être mis à la disposition de tous les clients qui utiliseront un proxy pour accéder à ce service. L’offre du service nécessite également ce descripteur.

Visual Studio définit un tel type dérivé et recommande son utilisation pour tous les services : ServiceJsonRpcDescriptor. Ce descripteur utilise StreamJsonRpc pour ses connexions RPC et crée un proxy local très performant pour les services locaux qui émule certains des comportements distants tels que l’encapsulation des exceptions lancées par le service dans RemoteInvocationException.

Le ServiceJsonRpcDescriptor permet de configurer la classe JsonRpc pour le codage JSON ou MessagePack du protocole JSON-RPC. Nous recommandons l’encodage MessagePack, car il est plus compact et peut être 10 fois plus performant.

Nous pouvons définir un descripteur pour notre service de calculatrice comme suit :

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

Comme vous pouvez le voir ci-dessus, un choix de formateur et de délimiteur est disponible. Comme toutes les combinaisons ne sont pas valables, nous vous recommandons l’une ou l’autre de ces combinaisons :

ServiceJsonRpcDescriptor.Formatters ServiceJsonRpcDescriptor.MessageDelimiters Idéal pour
MessagePack BigEndianInt32LengthHeader Hautes performances
UTF8 (JSON) HttpLikeHeaders Interopérabilité avec d’autres systèmes JSON-RPC

En spécifiant l’objet MultiplexingStream.Options comme paramètre final, la connexion RPC partagée entre le client et le service n’est qu’une chaîne sur un MultiplexingStream, qui est partagé avec la connexion JSON-RPC pour permettre un transfert efficace de données binaires volumineuses via JSON-RPC.

La stratégie ExceptionProcessing.ISerializable permet de sérialiser les exceptions lancées par votre service et de les préserver en tant que Exception.InnerException des RemoteInvocationException lancées sur le client. Sans ce paramètre, des informations moins détaillées sur les exceptions sont disponibles sur le client.

Conseil : Exposez votre descripteur en tant que ServiceRpcDescriptor plutôt que tout type dérivé que vous utilisez en tant que détail d’implémentation. Vous disposez ainsi d’une plus grande souplesse pour modifier ultérieurement les détails de l’implémentation sans rompre l’API.

Incluez une référence à l’interface de votre service dans le commentaire xml doc de votre descripteur pour faciliter l’utilisation de votre service par les utilisateurs. Faites également référence à l’interface que votre service accepte comme cible RPC du client, le cas échéant.

Certains services plus avancés peuvent également accepter ou exiger du client un objet cible RPC conforme à une certaine interface. Dans ce cas, utilisez un constructeur ServiceJsonRpcDescriptor avec un paramètre Type clientInterface pour spécifier l’interface dont le client doit fournir une instance.

Versionner le descripteur

Au fil du temps, vous pouvez vouloir augmenter la version de votre service. Dans ce cas, vous devez définir un descripteur pour chaque version que vous souhaitez prendre en charge, en utilisant un ServiceMoniker unique pour chaque version. La prise en charge simultanée de plusieurs versions peut être bénéfique pour la compatibilité ascendante et peut généralement être réalisée avec une seule interface RPC.

Visual Studio suit ce modèle avec sa classe VisualStudioServices en définissant la ServiceRpcDescriptor d’origine comme une propriété virtual sous la classe imbriquée qui représente la première version qui a ajouté ce service réparti. Lorsque nous devons modifier le protocole filaire ou ajouter ou modifier une fonctionnalité du service, Visual Studio déclare une propriété override dans une classe imbriquée de version ultérieure qui renvoie une nouvelle ServiceRpcDescriptor.

Pour un service défini et proposé par une extension Visual Studio, il peut suffire de déclarer une autre propriété de descripteur à côté de l’originale. Supposons par exemple que votre service 1.0 utilise le format UTF8 (JSON) et que vous vous rendiez compte que le passage à MessagePack apporterait un avantage significatif en termes de performances. Comme le changement de format est une rupture de protocole, il faut incrémenter le numéro de version du service réparti et un deuxième descripteur. L’ensemble des deux descripteurs pourrait ressembler à ceci :

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

Bien que nous déclarions deux descripteurs (et que nous devions ultérieurement proposer et enregistrer deux services), nous pouvons le faire avec une seule interface de service et une seule implémentation, ce qui réduit considérablement les frais généraux liés à la prise en charge de plusieurs versions de services.

Proposer le service

Votre service réparti doit être créé lorsqu’une requête arrive, ce qui se fait par le biais d’une étape appelée « proposer le service ».

La fabrique de service

Utilisez GlobalProvider.GetServiceAsync pour demander le SVsBrokeredServiceContainer. Appelez ensuite IBrokeredServiceContainer.Proffer sur ce conteneur pour proposer vos services.

Dans l’exemple ci-dessous, nous proposons un service en utilisant le champ CalculatorService déclaré précédemment, qui est défini comme une instance de ServiceRpcDescriptor. Nous lui transmettons notre fabrique de service, qui est un délégué BrokeredServiceFactory.

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

Un service réparti est généralement instancié une fois par client. Il s’agit d’une différence par rapport aux autres services VS (Visual Studio), qui sont généralement instanciés une seule fois et partagés par tous les clients. La création d’une instance du service par client permet d’améliorer la sécurité, car chaque service et/ou sa connexion peut conserver des informations sur le niveau d’autorisation du client, sur son CultureInfo préféré, etc. Comme nous le verrons plus loin, cela permet également de créer des services plus intéressants qui acceptent des arguments spécifiques à cette requête.

Important

Une fabrique de service qui s’écarte de cette ligne directrice et renvoie une instance de service partagée au lieu d’une nouvelle instance à chaque client ne devrait jamais avoir son service qui implémente IDisposable, puisque le premier client à se débarrasser de son proxy conduira à l’élimination de l’instance de service partagée avant que les autres clients n’aient fini de l’utiliser.

Dans le cas plus avancé où le constructeur CalculatorService nécessite un objet d’état partagé et un IServiceBroker, nous pourrions proposer la fabrique comme suit :

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

La variable locale state se trouve en dehors de la fabrique de service et n’est donc créée qu’une seule fois et partagée par tous les services instanciés.

De manière encore plus avancée, si le service a besoin d’accéder au ServiceActivationOptions (par exemple, pour invoquer des méthodes sur l’objet cible RPC du client), celui-ci peut également être transmis :

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

Dans ce cas, le constructeur de service pourrait ressembler à ceci, en supposant que les ServiceJsonRpcDescriptor ont été créés avec typeof(IClientCallbackInterface) comme l’un des arguments du constructeur :

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

Ce champ clientCallback peut maintenant être invoqué à chaque fois que le service veut invoquer le client, jusqu’à ce que la connexion soit supprimée.

Le délégué BrokeredServiceFactory prend un ServiceMoniker en tant que paramètre dans le cas où la fabrique de service est une méthode partagée qui crée plusieurs services ou plusieurs versions du service sur la base du moniker. Ce moniker provient du client et inclut la version du service qu’il attend. En redirigeant ce moniker vers le constructeur du service, ce dernier peut émuler le comportement particulier de certaines versions du service afin de répondre aux attentes du client.

Évitez d’utiliser le délégué AuthorizingBrokeredServiceFactory avec la méthode IBrokeredServiceContainer.Proffer, à moins que vous n’utilisiez le IAuthorizationService à l’intérieur de votre classe de service réparti. Ce IAuthorizationService doit être éliminé avec votre classe de service réparti pour éviter une fuite de mémoire.

Prise en charge de plusieurs versions de votre service

Lorsque vous augmentez la version de votre ServiceMoniker, vous devez proposer chaque version de votre service réparti pour laquelle vous avez l’intention de répondre aux requêtes des clients. Pour ce faire, vous devez appeler la méthode IBrokeredServiceContainer.Proffer pour chaque ServiceRpcDescriptor que vous continuez à prendre en charge.

Le fait de proposer votre service avec une version null servira de « fourre-tout » qui répondra à toute demande de client pour laquelle il n’existe pas de version précise correspondant à un service enregistré. Par exemple, vous pouvez proposer votre service 1.0 et 1.1 avec des versions spécifiques, et également inscrire votre service avec une version null. Dans ce cas, les clients qui demandent votre service avec la version 1.0 ou 1.1 invoquent la fabrique de service que vous avez proposée pour ces versions exactes, tandis qu’un client qui demande la version 8.0 invoque la fabrique de service que vous avez proposée pour la version nulle. Étant donné que la version demandée par le client est fournie à la fabrique de service, cette dernière peut alors prendre une décision sur la façon de configurer le service pour ce client particulier ou de renvoyer null pour indiquer une version non prise en charge.

Une demande de client pour un service avec une version null correspond uniquement à un service inscrit et proposé avec une version null.

Imaginez que vous avez publié de nombreuses versions de votre service, dont plusieurs sont rétrocompatibles et peuvent donc partager une implémentation du service. Nous pouvons utiliser l’option fourre-tout pour éviter d’avoir à proposer à plusieurs reprises chaque version individuelle comme suit :

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

Inscription du service

Le fait de proposer un service réparti au conteneur de services réparti global entraînera un rejet, à moins que le service n’ait été enregistré au préalable. L’enregistrement permet au conteneur de savoir à l’avance quels services répartis sont disponibles et quel package VS doit être chargé lorsqu’ils sont requis afin d’exécuter le code proposé. Cela permet à Visual Studio de démarrer rapidement, sans charger toutes les extensions à l’avance, tout en étant capable de charger l’extension requise lorsqu’elle est demandée par un client de son service réparti.

L’enregistrement peut se faire en appliquant le ProvideBrokeredServiceAttribute à votre classe dérivée de AsyncPackage. C’est le seul endroit où le ServiceAudience peut être défini.

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

Par défaut la valeur de Audience est ServiceAudience.Process, ce qui expose votre service réparti uniquement à d’autres codes au sein du même processus. En définissant ServiceAudience.Local, vous acceptez d’exposer votre service réparti à d’autres processus appartenant à la même session Visual Studio.

Si votre service réparti doit être exposé aux invités de Live Share, le Audience doit inclure ServiceAudience.LiveShareGuest et la propriété ProvideBrokeredServiceAttribute.AllowTransitiveGuestClients doit être fixée à true. L’activation de ces indicateurs peut entraîner de graves failles de sécurité et ne doit pas être effectuée sans se conformer d’abord aux conseils donnés dans la section Comment sécuriser un service réparti.

Lorsque vous augmentez la version de votre ServiceMoniker, vous devez enregistrer chaque version de votre service réparti pour laquelle vous avez l’intention de répondre aux requêtes des clients. En supportant plus que la version la plus récente de votre service réparti, vous aidez à maintenir la compatibilité ascendante pour les clients de votre ancienne version de service réparti, ce qui peut être particulièrement utile dans le cas du scénario Live Share où chaque version de Visual Studio qui partage la session peut être une version différente.

Le fait d’inscrire votre service avec une version null servira de « fourre-tout » qui répondra à toute demande de client pour laquelle il n’existe pas de version précise avec un service enregistré. Par exemple, vous pouvez inscrire votre service 1.0 et 2.0 avec des versions spécifiques, et également inscrire votre service avec une version null.

Utilisez le MEF pour proposer et enregistrer votre service

Vous devez pour cela disposer de Visual Studio 2022 Update 2 ou d’une version ultérieure.

Un service réparti peut être exporté via MEF au lieu d’utiliser un package Visual Studio comme décrit dans les deux sections précédentes. Il y a des compromis à prendre en compte :

Compromis Package proffer. Exportation au format MEF
Disponibilité ✅ Le service réparti est disponible dès le démarrage du VS. ⚠️ La disponibilité du service réparti peut être retardée jusqu’à ce que le MEF ait été initialisé dans le processus. Cette opération est généralement rapide, mais peut prendre plusieurs secondes lorsque le cache du MEF est périmé.
Préparation multiplateforme ⚠️ Le code spécifique de Visual Studio pour Windows doit être rédigé. ✅ Le service réparti dans votre assembly peut être chargé dans Visual Studio pour Windows ainsi que dans Visual Studio pour Mac.

Pour exporter votre service réparti via MEF au lieu d’utiliser les packages VS :

  1. Confirmez que vous n’avez pas de code lié aux deux dernières sections. En particulier, vous ne devez pas avoir de code faisant appel à IBrokeredServiceContainer.Proffer et ne devez pas appliquer le ProvideBrokeredServiceAttribute à votre package (s’il existe).
  2. Implémentez l’interface IExportedBrokeredService dans votre classe de service réparti.
  3. Évitez toute dépendance au threading principal dans votre constructeur ou dans l’importation de fixateurs de propriété. Utilisez la méthode IExportedBrokeredService.InitializeAsync pour initialiser votre service réparti, où les dépendances du thread principal sont autorisées.
  4. Appliquez le ExportBrokeredServiceAttribute à votre classe de service réparti, en spécifiant les informations relatives au moniker de votre service, à l’audience et à toute autre information nécessaire à l’enregistrement.
  5. Si votre classe nécessite une élimination, implémentez IDisposable plutôt que IAsyncDisposable, car le MEF possède la durée de vie de votre service et ne prend en charge que l’élimination synchrone.
  6. Assurez-vous que votre fichier source.extension.vsixmanifest répertorie le projet contenant votre service réparti en tant qu’assembly MEF.

En tant que partie du MEF, votre service réparti peut importer n’importe quelle autre partie du MEF dans le champ d’application par défaut. Dans ce cas, veillez à utiliser System.ComponentModel.Composition.ImportAttribute plutôt que System.Composition.ImportAttribute. En effet, le ExportBrokeredServiceAttribute dérive du System.ComponentModel.Composition.ExportAttribute et il est nécessaire d’utiliser le même espace de noms MEF pour l’ensemble d’un type.

Un service réparti est unique dans la mesure où il peut importer quelques exportations spéciales :

  • IServiceBroker, qui devrait être utilisé pour acquérir d’autres services répartis.
  • ServiceMoniker, qui peut être utile lorsque vous exportez plusieurs versions de votre service réparti et que vous devez détecter quelle version le client a requête.
  • ServiceActivationOptions, ce qui peut être utile lorsque vous exigez de vos clients qu’ils fournissent des paramètres spéciaux ou une cible de rappel client.
  • AuthorizationServiceClient, ce qui peut être utile lorsque vous devez effectuer des contrôles de sécurité comme décrit dans Comment sécuriser un service réparti. Cet objet n’a pas besoin d’être éliminé par votre classe, car il sera éliminé automatiquement lorsque votre service réparti sera éliminé.

Votre service réparti ne doit pas utiliser le ImportAttribute du MEF pour acquérir d’autres services répartis. Au lieu de cela, il peut [Import]IServiceBroker et demander des services répartis de manière traditionnelle. Pour en savoir plus, consultez la section Comment consommer un service réparti.

En voici un exemple :

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

Exporter plusieurs versions de votre service réparti

Le ExportBrokeredServiceAttribute peut être appliqué à votre service réparti plusieurs fois pour offrir plusieurs versions de votre service réparti.

Votre implémentation de la propriété IExportedBrokeredService.Descriptor doit renvoyer un descripteur dont le moniker correspond à celui que le client a requis.

Considérez cet exemple, où le service de calculatrice a exporté la version 1.0 avec un formatage UTF8, puis ajoute plus tard une exportation 1.1 afin de profiter des gains de performance liés à l’utilisation du formatage 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!;
}

À compter de Visual Studio 2022 Update 12 (17.12), un service null avec version peut être exporté pour correspondre à n’importe quelle demande de client pour le service, quelle que soit la version, y compris une demande avec une version null. Un tel service peut renvoyer null à partir de la propriété Descriptor pour rejeter une demande de client lorsqu’il n’offre pas d’implémentation de la version demandée par ce dernier.

Rejet d’une demande de service

Un service réparti peut rejeter la demande d’activation d’un client en levant à partir de la méthode InitializeAsync. La levée entraîne le renvoi d’un ServiceActivationFailedException au client.