Fournir un service intermédié
Un service intermédiaire se compose des éléments suivants :
- Une interface qui déclare la fonctionnalité du service et sert de contrat entre le service et ses clients.
- Implémentation de cette interface.
- un moniker de service pour attribuer un nom et une version au service.
- un descripteur qui combine le moniker de service avec le comportement de gestion du RPC (appel de procédure distante) si nécessaire.
- Soit offrez l'usine de services et enregistrez votre service réparti avec un package Visual Studio, soit faites les deux avec le MEF (le cadre d'extensibilité managée).
Chacun des éléments de la liste précédente est décrit en détail dans les sections suivantes.
Avec tout le code de cet article, l’activation des types de référence nullables C# fonctionnalité est vivement recommandée.
Interface de service
L’interface de service peut être une interface .NET standard (souvent écrite en C#), mais doit être conforme aux instructions définies par le type dérivé de ServiceRpcDescriptorque votre service utilisera pour vous assurer que l’interface peut être utilisée via RPC lorsque le client et le service s’exécutent dans différents processus.
Ces restrictions incluent généralement que les propriétés et les indexeurs ne sont pas autorisés, et la plupart ou toutes les méthodes retournent Task
ou un autre type de retour compatible asynchrone.
Le ServiceJsonRpcDescriptor est le type dérivé recommandé pour les services répartits. Cette classe utilise la bibliothèque StreamJsonRpc lorsque le client et le service nécessitent la communication RPC. StreamJsonRpc applique certaines restrictions à l’interface de service comme décrit ici.
L’interface peut dériver de IDisposable, System.IAsyncDisposableou même Microsoft.VisualStudio.Threading.IAsyncDisposable, mais cela n’est pas requis par le système. Les proxys clients générés implémentent IDisposable de l’une ou l’autre manière.
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 sur cette interface ne justifie pas une méthode asynchrone, nous utilisons toujours des signatures de méthode asynchrones sur cette interface, car cette interface est utilisée pour générer le proxy client qui peut appeler ce service à distance, ce qui certainement ne justifie une signature de méthode asynchrone.
Une interface peut déclarer des événements qui peuvent être utilisés pour informer leurs clients des événements qui se produisent sur le service.
Au-delà des événements ou du modèle de conception d’observateur, un service intermédiaire qui doit appeler en retour le client peut définir une deuxième interface servant de contrat qu'un client doit implémenter et fournir via la propriété ServiceActivationOptions.ClientRpcTarget lors de la demande du service. Une telle interface doit être conforme à tous les mêmes modèles de conception et restrictions que l’interface de service réparti, mais avec des restrictions supplémentaires sur le contrôle de version.
Passez en revue les meilleures pratiques pour la conception d’un service de courtage pour obtenir des conseils sur la conception d’une interface RPC performante et pérenne.
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 référencer l’interface sans que le service ait à exposer davantage de détails d’implémentation. Il peut également être utile d’expédier l’assembly d’interface en tant que package NuGet pour d’autres extensions à référencer tout en réservant votre propre extension pour expédier l’implémentation du service.
Envisagez de cibler l’assembly qui déclare votre interface de service à netstandard2.0
pour vous assurer que votre service peut être facilement appelé à partir de n’importe quel processus .NET, qu’il exécute .NET Framework, .NET Core, .NET 5 ou version ultérieure.
Test
Les tests automatisés doivent être écrits en même temps que votre service interface pour vérifier la préparation RPC de l’interface.
Les tests doivent vérifier que toutes les données transmises via l’interface sont sérialisables.
Vous trouverez peut-être la classe BrokeredServiceContractTestBase<TInterface,TServiceMock> à partir du package Microsoft.VisualStudio.Sdk.TestFramework.Xunit utile pour dériver votre classe de test d’interface. Cette classe inclut des tests de convention de base pour votre interface, des méthodes pour faciliter les assertions courantes telles que les tests d’événements, etc.
Méthode
Affirmez que chaque argument et la valeur de retour ont été sérialisés complètement. Si vous utilisez la classe de base de test mentionnée ci-dessus, votre code peut 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 de surcharge 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 sur celle-ci qui stocke les arguments de cette méthode afin que la méthode de test puisse appeler la méthode, puis vérifier que la méthode appropriée a été appelée avec les arguments appropriés.
Événements
Tous les événements déclarés sur votre interface doivent également être testés pour garantir leur disponibilité RPC. Les événements déclenchés à partir d’un service réparti ne pas provoquent un échec de test s’ils échouent lors de la sérialisation RPC, car les événements sont « fire and forget ».
Si vous utilisez la classe de base de test mentionnée ci-dessus, ce comportement est déjà intégré à certaines méthodes d’assistance et peut ressembler à ceci (avec des parties inchangées omises pour la concision) :
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 implémenter IDisposable ou toute autre interface au-delà de celle utilisée pour RPC. Le proxy généré sur le client implémente uniquement l’interface de service, IDisposable, et éventuellement quelques autres interfaces sélectionnées pour prendre en charge le système, de sorte qu’un cast vers d’autres interfaces implémentées par le service échoue sur le client.
Prenons l’exemple de 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 n’ont pas besoin d’être asynchrones, nous encapsulons explicitement la valeur de retour dans un type de retour ValueTask<TResult> construit pour se conformer à l’interface de service.
Implémentation du modèle de conception observable
Si vous proposez un abonnement observateur sur votre interface de service, il peut ressembler à ceci :
Task<IDisposable> SubscribeAsync(IObserver<YourDataType> observer);
L’argument IObserver<T> doit généralement survivre à la durée de vie de cet appel de méthode afin que le client puisse continuer à recevoir des mises à jour une fois l’appel de méthode terminé jusqu’à ce que le client supprime la valeur de IDisposable retournée. Pour faciliter cette classe de service, vous pouvez inclure une collection d’abonnements IObserver<T> que toutes les mises à jour apportées à votre état énumèrent ensuite pour mettre à jour tous les abonnés. Assurez-vous que l’énumération de votre collection est thread-safe par rapport à l’autre et en particulier avec les mutations sur cette collection qui peuvent se produire via des abonnements ou des suppressions supplémentaires de ces abonnements.
Veillez à ce que toutes les mises à jour publiées via OnNext conservent l’ordre dans lequel les modifications d’état ont été introduites dans votre service.
Tous les abonnements doivent finalement être arrêtés avec un appel à OnCompleted ou OnError pour éviter les fuites de ressources sur les systèmes client et RPC. Cela inclut l’élimination des services où tous les abonnements restants doivent être explicitement terminés.
En savoir plus sur le modèle de conception d’observateur, comment implémenter un fournisseur de données observable et en particulier avec RPC à l’esprit.
Services jetables
Votre classe de service ne doit pas nécessairement être supprimable, mais les services qui le sont seront supprimés lorsque le client supprimera son proxy vers votre service ou que la connexion entre le client et le service sera perdue. Les interfaces jetables sont testées dans cet ordre : System.IAsyncDisposable, Microsoft.VisualStudio.Threading.IAsyncDisposable, IDisposable. Seule la première interface de cette liste que votre classe de service implémente sera utilisée pour supprimer le service.
Gardez à l’esprit la sécurité des threads lorsque vous envisagez l'élimination. Votre méthode de Dispose peut être appelée sur n’importe quel thread tandis que d’autres codes de votre service s’exécutent (par exemple, si une connexion est supprimée).
Lancer des exceptions
Lors de la levée d’exceptions, envisagez de lever 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'agir en fonction de la nature de l'erreur plus efficacement que d'analyser les messages ou types d'exceptions.
Selon la spécification JSON-RPC, les codes d’erreur DOIVENT être supérieurs à -32000, y compris les nombres positifs.
Consommation d’autres services de courtage
Lorsqu’un service réparti nécessite lui-même l’accès à un autre service réparti, nous vous recommandons d’utiliser le IServiceBroker fourni à sa fabrique de services. Cela est particulièrement important lorsque l’enregistrement du service réparti définit l’indicateur AllowTransitiveGuestClients.
Pour respecter cette directive, si notre service de calculatrice nécessitait d’autres services répartiteurs pour implémenter son comportement, nous modifierions le constructeur pour accepter 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;
}
// ...
}
En savoir plus sur comment sécuriser un service intermédié et consommer des services intermédiés.
Services avec état
État par client
Une nouvelle instance de cette classe est créée pour chaque client qui demande le service.
Un champ de la classe Calculator
ci-dessus stocke une valeur qui peut être unique pour chaque client.
Supposons que nous ajoutons un compteur qui 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 courtier doit être écrit pour suivre les pratiques thread-safe.
Lorsque vous utilisez le ServiceJsonRpcDescriptorrecommandé, les connexions à distance avec les clients peuvent inclure l’exécution simultanée des méthodes de votre service, comme décrit dans ce document .
Lorsque le client partage un processus et AppDomain avec le service, le client peut appeler votre service simultanément à partir de plusieurs threads.
Une implémentation thread-safe de l’exemple ci-dessus peut utiliser Interlocked.Increment(Int32) pour incrémenter le champ operationCounter
.
État partagé
S’il existe un état indiquant que votre service doit partager entre tous ses clients, cet état doit être défini dans une classe distincte instanciée par votre package VS et transmise en tant qu’argument au constructeur de votre service.
Supposons que le operationCounter
défini ci-dessus soit conçu pour compter toutes les opérations effectuées par tous les clients du service.
Nous aurions besoin de 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);
}
}
Maintenant, nous avons un moyen élégant et testable de gérer l’état partagé entre plusieurs instances de notre service Calculator
.
Plus tard, lors de l’écriture du code pour proffer le service, nous verrons comment cette classe State
est créée une fois et partagée avec chaque instance du service Calculator
.
Il est particulièrement important d’être thread-safe lors de la gestion de l’état partagé, car aucune hypothèse ne peut être effectuée autour de plusieurs clients qui planifient leurs appels afin qu’ils ne soient jamais effectués simultanément.
Si votre classe d’état partagée doit accéder à d’autres services répartiteurs, elle doit utiliser le service broker global plutôt que l’un des services contextuels affectés à une instance individuelle de votre service réparti. L’utilisation du service broker global au sein d’un service réparti porte avec elle implications en matière de sécurité lorsque l’indicateur de ProvideBrokeredServiceAttribute.AllowTransitiveGuestClients est défini.
Problèmes de sécurité
La sécurité est une considération pour votre service intermédiaire s’il est enregistré avec le drapeau ProvideBrokeredServiceAttribute.AllowTransitiveGuestClients, ce qui l’expose à un accès possible par d’autres utilisateurs sur d’autres ordinateurs participant à une session Live Share partagée.
Examiner Comment sécuriser un service intermédiaire et prendre les mesures nécessaires pour atténuer les risques de sécurité avant de configurer le paramètre AllowTransitiveGuestClients.
Surnom du service
Un service réparti doit avoir un nom sérialisable et une version facultative par laquelle un client peut demander le service. Un ServiceMoniker est une enveloppe pratique pour ces deux éléments d'information.
Un moniker de service est analogue au nom complet qualifié d’assembly d’un type CLR (Common Language Runtime). Il doit être globalement unique et doit donc inclure le nom de votre entreprise et peut-être votre nom d’extension comme préfixes du nom de service lui-même.
Il peut être utile de définir ce moniker dans un champ static readonly
à 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 identifiant, un client qui communique via des tuyaux au lieu d’un proxy aura besoin de l’identifiant.
Bien qu’une version soit facultative sur un moniker, la fourniture d’une version est recommandée, car elle offre aux auteurs de services davantage d’options pour maintenir la compatibilité entre services et clients au fil des changements comportementaux.
Descripteur de service
Le descripteur de service combine le moniker de service avec les comportements requis pour exécuter une connexion RPC et créer un proxy local ou distant. Le descripteur est chargé de convertir efficacement votre interface RPC en 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. Offrir le service nécessite également ce descripteur.
Visual Studio définit un type dérivé de ce type et recommande son utilisation pour tous les services : ServiceJsonRpcDescriptor. Ce descripteur utilise StreamJsonRpc pour ses connexions RPC et crée un proxy local performant pour les services locaux qui émule certains comportements distants, tels que le fait d'encapsuler les exceptions levées par le service dans RemoteInvocationException.
Le ServiceJsonRpcDescriptor prend en charge la configuration de la classe JsonRpc pour l’encodage JSON ou MessagePack du protocole JSON-RPC. Nous recommandons l’encodage MessagePack, car il est plus compact et peut être 10X 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 formatteur et de délimiteur est disponible. Comme toutes les combinaisons ne sont pas valides, 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’un seul canal sur un MultiplexingStream, qui est partagé avec la connexion JSON-RPC à permettre un transfert efficace de données binaires volumineuses via leJSON-RPC.
La stratégie ExceptionProcessing.ISerializable entraîne la sérialisation et la conservation des exceptions levées par votre service, qui sont ensuite préservées en tant que Exception.InnerException des RemoteInvocationException levées sur le client. Sans ce paramètre, des informations d’exception moins détaillées sont disponibles sur le client.
Conseil : exposez votre descripteur en tant que ServiceRpcDescriptor plutôt que tout type dérivé que vous utilisez comme détail d’implémentation. Cela vous donne plus de flexibilité pour modifier ultérieurement les détails de l'implémentation sans changements perturbant l'API.
Incluez une référence à votre interface de service dans le commentaire de document xml sur votre descripteur pour faciliter l’utilisation de votre service par les utilisateurs. Référencez également l’interface que votre service accepte comme cible RPC cliente, le cas échéant.
Certains services plus avancés peuvent également accepter ou exiger un objet cible RPC du client conforme à une 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.
Gestion des versions du descripteur
Au fil du temps, vous pouvez incrémenter la version de votre service. Dans ce cas, vous devez définir un descripteur pour chaque version que vous souhaitez prendre en charge, à l’aide d’un ServiceMoniker spécifique à une version unique pour chacun d’eux. La prise en charge simultanée de plusieurs versions peut être adaptée à la compatibilité descendante et peut généralement être effectuée avec une seule interface RPC.
Visual Studio suit ce modèle avec sa classe VisualStudioServices en définissant l’original ServiceRpcDescriptor comme une propriété virtual
de la classe imbriquée qui représente la première version ayant ajouté ce service courtier.
Lorsque nous devons modifier le protocole filaire ou ajouter/modifier des fonctionnalités du service, Visual Studio déclare une propriété override
dans une classe imbriquée versionnée ultérieure qui retourne une nouvelle ServiceRpcDescriptor.
Pour un service défini et profferé par une extension Visual Studio, il peut suffire de déclarer une autre propriété descripteur en regard de l’original. Par exemple, supposons que votre service 1.0 a utilisé le formateur UTF8 (JSON) et que vous réalisez que le passage à MessagePack offre un avantage significatif en matière de performances. Comme la modification du formateur est une modification par rupture de protocole filaire, elle nécessite d’incrémenter le numéro de version du service réparti et un deuxième descripteur. Les deux descripteurs ensemble peuvent 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 plus tard, nous devrons offrir et inscrire deux services), nous pouvons faire cela avec une seule interface et implémentation de service, en maintenant la surcharge pour prendre en charge plusieurs versions de service assez faible.
Offrir le service
Votre service intermédiaire doit être créé lorsqu'une demande survient, ce qui se fait par une étape appelée l'offre du service.
Fabrique de services
Utilisez GlobalProvider.GetServiceAsync pour demander le SVsBrokeredServiceContainer. Appelez ensuite IBrokeredServiceContainer.Proffer sur ce conteneur pour proffer votre service.
Dans l’exemple ci-dessous, nous offrons un service à l’aide du champ CalculatorService
déclaré précédemment, qui est défini comme une instance d’un ServiceRpcDescriptor.
Nous passons notre usine 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 intermédiaire est généralement instancié une fois par client. Cela diffère des autres services VS (Visual Studio), qui sont généralement instanciés une fois et partagés entre l'ensemble des clients. La création d’une instance du service par client permet une meilleure sécurité, car chaque service et/ou sa connexion peuvent conserver l’état par client sur le niveau d’autorisation auquel le client opère, ce que leur CultureInfo préféré est, etc. Comme nous le verrons ensuite, il permet également d’obtenir des services plus intéressants qui acceptent des arguments spécifiques à cette demande.
Important
Une fabrique de services qui s’écarte de cette directive et retourne une instance de service partagé au lieu d’une nouvelle instance de service à chaque client doit jamais mettre en œuvre son service IDisposable, car le premier client à supprimer son proxy conduit à la suppression de l’instance de service partagé avant que d’autres clients ne l’utilisent.
Dans le cas plus avancé où le constructeur de CalculatorService
nécessite un objet d’état partagé et un IServiceBroker, nous pouvons 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
est en dehors de la fabrique de services et n’est donc créée qu’une seule fois et partagée entre tous les services instanciés.
Encore plus avancé, si le service a besoin d’un accès au ServiceActivationOptions (par exemple, pour appeler des méthodes sur l’objet cible RPC client) qui peut également être passé :
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 se présenter ainsi, en supposant que le ServiceJsonRpcDescriptor aurait été créé avec typeof(IClientCallbackInterface)
en tant qu'argument de 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 appelé chaque fois que le service souhaite appeler le client, jusqu’à ce que la connexion soit supprimée.
Le délégué BrokeredServiceFactory prend un ServiceMoniker en tant que paramètre si la fabrique de service est une méthode partagée qui crée plusieurs services ou versions distinctes du service en fonction du moniker. Ce moniker provient du client et inclut la version du service attendu. En transférant ce moniker au constructeur de service, le service peut émuler le comportement bizarre des versions de service particulières pour correspondre à ce que le client peut attendre.
Évitez d’utiliser le délégué AuthorizingBrokeredServiceFactory avec la méthode IBrokeredServiceContainer.Proffer, sauf si vous utiliserez le IAuthorizationService à l’intérieur de votre classe de service réparti. Cette IAuthorizationServicedoit être éliminée avec votre classe de service intermédiaire pour éviter une fuite de mémoire.
Prise en charge de plusieurs versions de votre service
Lorsque vous incrémentez la version sur votre ServiceMoniker, vous devez proposer chaque version de votre service réparti que vous souhaitez utiliser pour répondre aux demandes des clients. Pour ce faire, appelez la méthode IBrokeredServiceContainer.Proffer avec chaque ServiceRpcDescriptor que vous prenez encore en charge.
Offrir votre service avec une version null
servira de « solution universelle » qui s'alignera sur toute requête d'un client pour laquelle il n'existe pas de correspondance précise avec un service enregistré.
Par exemple, vous pouvez proffer votre service 1.0 et 1.1 avec des versions spécifiques, et inscrire votre service avec une version null
.
Dans de tels cas, les clients qui demandent votre service avec la version 1.0 ou 1.1 appellent la fabrique de service que vous avez offerte pour ces versions exactes, tandis qu’un client demandant la version 8.0 entraîne l’appel de votre fabrique de service avec version nulle.
Étant donné que la version demandée par le client est fournie à la fabrique de services, la fabrique peut alors prendre une décision sur la façon de configurer le service pour ce client particulier ou de retourner null
pour signer une version non prise en charge.
Une requête de client pour un service avec une version null
uniquement correspond à un service enregistré et proposé avec une version null
.
Considérez un cas où vous avez publié de nombreuses versions de votre service, dont plusieurs sont rétrocompatibles et peuvent donc partager une implémentation de service. Nous pouvons utiliser l’option fourre-tout pour éviter d’avoir à présenter à plusieurs reprises chaque version 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 médiatisé au conteneur global de services médiatisés provoquera une erreur, sauf si le service a été enregistré au préalable. L'inscription permet au conteneur de connaître à l'avance quels services intermédiaires peuvent être disponibles, et quel package VS charger lorsqu'il est requis dans le but d'exécuter le code d'offre. Cela permet à Visual Studio de démarrer rapidement, sans charger toutes les extensions à l’avance, mais de pouvoir charger l’extension requise lorsqu’elle est demandée par un client de son service réparti.
L’inscription peut être effectuée en appliquant la ProvideBrokeredServiceAttribute à votre classe dérivée de AsyncPackage. Il s’agit du seul endroit où le ServiceAudience peut être défini.
[ProvideBrokeredService("YourCompany.Extension.Calculator", "1.0", Audience = ServiceAudience.Local)]
La Audience par défaut est ServiceAudience.Process, qui expose votre service intermédié uniquement à d'autres morceaux de code au sein du même processus. En définissant ServiceAudience.Local, vous choisissez d’exposer votre service réparti à d’autres processus appartenant à la même session Visual Studio.
Si votre service intermédiaire doit être accessible aux invités de Live Share, le Audience doit inclure ServiceAudience.LiveShareGuest et la propriété ProvideBrokeredServiceAttribute.AllowTransitiveGuestClients définie sur true
.
Définir ces indicateurs peut introduire de graves vulnérabilités de sécurité et ne doit pas être fait sans d’abord se conformer aux instructions de Comment sécuriser un service réparti.
Lorsque vous incrémentez la version sur votre ServiceMoniker, vous devez inscrire chaque version de votre service intermédiaire destinée à répondre aux demandes des clients. En prenant en charge plus que la version la plus récente de votre service réparti, vous pouvez maintenir la compatibilité descendante pour les clients de votre version de service réparti plus ancienne, ce qui peut être particulièrement utile lorsque vous envisagez le scénario Live Share où chaque version de Visual Studio qui partage la session peut être une version différente.
L'enregistrement de votre service avec une version null
servira de « solution par défaut » qui correspondra à toute requête 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 MEF pour proposer et enregistrer votre service
Cela nécessite Visual Studio 2022 Update 2 ou 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. Cela a des compromis à prendre en compte :
Compromis | Offre de package | Exportation MEF |
---|---|---|
Disponibilité | ✅ Le service intermédiaire est disponible immédiatement au démarrage de VS. | ️ ⚠Le service intermédiaire peut être retardé dans sa disponibilité jusqu'à ce que MEF ait été initialisé au cours du processus. Cela est généralement rapide, mais peut prendre plusieurs secondes lorsque le cache MEF est obsolète. |
Préparation multiplateforme | ️ ⚠Code spécifique à Visual Studio pour Windows doit être créé. | ✅ Le service intermédiaire dans votre assembly est accessible dans Visual Studio pour Windows et Visual Studio pour Mac. |
Pour exporter votre service réparti via MEF au lieu d’utiliser des packages VS :
- Vérifiez que vous n’avez pas de code lié aux deux dernières sections. En particulier, vous ne devez pas avoir de code qui appelle IBrokeredServiceContainer.Proffer, et vous ne devez pas appliquer le ProvideBrokeredServiceAttribute à votre package (le cas échéant).
- Implémentez l’interface
IExportedBrokeredService
sur votre classe de service réparti. - Évitez les dépendances au thread principal dans votre constructeur ou dans vos setters de propriétés importés. Utilisez la méthode
IExportedBrokeredService.InitializeAsync
pour initialiser votre service réparti, où les dépendances de thread principales sont autorisées. - Appliquez le
ExportBrokeredServiceAttribute
à votre classe de service intermédiaire, en spécifiant les informations relatives à votre moniker de service, à l’audience et à toutes les autres informations relatives à l'enregistrement requises. - Si votre classe nécessite une suppression, implémentez IDisposable plutôt que IAsyncDisposable, car MEF possède la durée de vie de votre service et prend uniquement en charge l’élimination synchrone.
- Vérifiez que votre fichier
source.extension.vsixmanifest
répertorie le projet contenant votre service intermédiaire en tant qu'assembly MEF.
En tant que composant MEF, votre service intermédiaire peut importer tout autre composant MEF dans le périmètre par défaut.
Dans ce cas, veillez à utiliser System.ComponentModel.Composition.ImportAttribute plutôt que System.Composition.ImportAttribute.
Cela est dû au fait que le ExportBrokeredServiceAttribute
dérive de System.ComponentModel.Composition.ExportAttribute et l’utilisation du même espace de noms MEF dans un type est nécessaire.
Un service intermédiaire est unique en sa capacité à importer quelques exportations spéciales :
- IServiceBroker, qui devrait être utilisé pour acquérir d’autres services d'intermédiation.
- ServiceMoniker, ce qui peut être utile lorsque vous exportez plusieurs versions de votre service réparti et devez détecter la version demandée par le client.
- ServiceActivationOptions, ce qui peut être utile lorsque vous avez besoin que vos clients fournissent des paramètres spéciaux ou une cible de rappel client.
- AuthorizationServiceClient, qui peut être utile lorsque vous devez effectuer des vérifications de sécurité comme décrit dans Comment sécuriser un service réparti. Cet objet n'a pas besoin d'être supprimé par votre classe, car il sera supprimé automatiquement lorsque votre service courtier sera supprimé.
Votre service courtier ne doit pas utiliser ImportAttribute de MEF pour acquérir d’autres services courtier.
Au lieu de cela, il peut [Import]
IServiceBroker et demander des services intermédiaires de la manière traditionnelle.
En savoir plus dans Comment consommer un service intermédiaire.
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);
}
}
Exportation de plusieurs versions de votre service réparti
Le ExportBrokeredServiceAttribute
peut être appliqué à votre service intermédiaire plusieurs fois pour offrir plusieurs versions de votre service intermédiaire.
Votre implémentation de la propriété IExportedBrokeredService.Descriptor
doit retourner un descripteur avec un moniker qui correspond à celui demandé par le client.
Prenons cet exemple, où le service de calculatrice a exporté la version 1.0 avec la mise en forme UTF8, puis ajoute ultérieurement une exportation 1.1 pour profiter des gains de performances de l’utilisation de la mise en forme 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
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 retourner null
de la propriété Descriptor
afin de rejeter une demande cliente lorsqu’il n’offre pas d’implémentation de la version demandée par le client.
Rejet d’une demande de service
Un service intermédiaire peut rejeter la requête d'activation d'un client en générant une exception dans la méthode InitializeAsync. L’envoi entraîne le renvoi d’un ServiceActivationFailedException au client.