Procedimientos recomendados para diseñar un servicio intermediado
Siga las instrucciones y restricciones generales documentadas para las interfaces RPC para StreamJsonRpc.
Además, se aplican las siguientes directrices a los servicios intermediados.
Firmas de método
Todos los métodos deben tomar un parámetro CancellationToken como último parámetro. Este parámetro normalmente no debe ser un parámetro opcional, por lo que es menos probable que los autores de llamadas omitan accidentalmente el argumento. Incluso si se espera que la implementación del método sea trivial, proporcionar un CancellationToken permite al cliente cancelar su propia solicitud antes de que se transmita al servidor. También permite que la implementación del servidor evolucione en algo más caro sin tener que actualizar el método para agregar la cancelación como una opción más adelante.
Considere evitar sobrecargas múltiples del mismo método en su interfaz RPC. Aunque la resolución de sobrecarga normalmente funciona (y las pruebas deben escribirse para comprobar que sí), se basa en intentar deserializar argumentos en función de los tipos de parámetro de cada sobrecarga, lo que da lugar a que se produzcan excepciones de primera oportunidad como parte normal de la selección de una sobrecarga. Como queremos minimizar el número de excepciones de primera oportunidad producidas en caminos de éxito, es preferible simplemente tener un método con un nombre determinado.
Tipos de parámetro y valor devuelto
Recuerde que todos los argumentos y valores devueltos intercambiados a través de RPC son solo datos. Todos se serializan y envían a través de la conexión. Los métodos que defina en estos tipos de datos solo operan en esa copia local de los datos y no tienen forma de comunicarse con el servicio RPC que lo generó. Las únicas excepciones a este comportamiento de serialización son los tipos exóticos para los que StreamJsonRpc tiene compatibilidad especial.
Considere usar ValueTask<T>
en lugar de Task<T>
como tipo de retorno de métodos, ya que ValueTask<T>
incurre en menos asignaciones.
Al usar la variedad no genérica (por ejemplo, Task y ValueTask), es menos importante, pero puede que ValueTask aún sea preferible.
Tenga en cuenta las restricciones de uso de ValueTask<T>
tal como se documenta en esa API. Esta entrada de blog y este vídeo pueden resultar útiles para decidir qué tipo usar también.
Tipos de datos personalizados
Considere la posibilidad de definir todos los tipos de datos para que sean inmutables, lo que permite un uso compartido más seguro de los datos en un proceso sin copiar y ayuda a reforzar la idea a los consumidores de que no pueden cambiar los datos que reciben en respuesta a una consulta sin colocar otro RPC.
Defina los tipos de datos como class
en lugar de struct
al usar ServiceJsonRpcDescriptor.Formatters.UTF8, lo que le evitará el costo de conversión boxing (potencialmente repetido) al usar Newtonsoft.Json.
La conversión boxing no se produce al usar ServiceJsonRpcDescriptor.Formatters.MessagePack, por lo que las estructuras pueden ser una opción adecuada si está comprometido con ese formateador.
Considere la posibilidad de implementar IEquatable<T> e invalidar GetHashCode() y métodos de Equals(Object) en los tipos de datos, lo que permite al cliente almacenar, comparar y reutilizar de forma eficaz los datos recibidos en función de si es igual a los datos recibidos en otro momento.
Use el DiscriminatedTypeJsonConverter<TBase> para admitir la serialización de tipos polimórficos mediante JSON.
Colecciones
Utilice interfaces de colecciones de solo lectura en firmas de método RPC (por ejemplo, IReadOnlyList<T>) en lugar de tipos concretos (por ejemplo, List<T> o T[]
), lo que permite una deserialización potencialmente más eficiente.
Evite IEnumerable<T>.
Su falta de una propiedad Count
conduce a código ineficaz e implica la posible generación tardía de datos, que no se aplica en un escenario RPC.
Use IReadOnlyCollection<T> para colecciones no ordenadas o IReadOnlyList<T> para colecciones ordenadas en su lugar.
Fíjese en IAsyncEnumerable<T>. Cualquier otro tipo de colección o IEnumerable<T> dará lugar a que toda la colección se envíe en un mensaje. El uso de IAsyncEnumerable<T> permite un mensaje inicial pequeño y proporciona al receptor los medios para obtener tantos elementos de la colección como desee, enumerarlo de forma asincrónica. Obtenga más información sobre este patrón novel.
Patrón de observador
Considere la posibilidad de usar el patrón de diseño de observador en la interfaz. Esta es una manera sencilla de que el cliente se suscriba a los datos sin los muchos problemas que se aplican al modelo de eventos tradicional descrito en la sección siguiente.
El patrón de observador puede ser tan sencillo como este:
Task<IDisposable> SubscribeAsync(IObserver<YourDataType> observer);
Los tipos IDisposable y IObserver<T> usados anteriormente son dos de los tipos exóticos de StreamJsonRpc, por lo que reciben un tratamiento especial en lugar de ser simplemente serializados como datos.
Eventos
Los eventos pueden ser problemáticos a través de RPC por varias razones y recomendamos el patrón de observador que hemos descrito anteriormente en su lugar.
Tenga en cuenta que el servicio no tiene visibilidad sobre cuántos controladores de eventos ha asociado el cliente cuando el servicio y el cliente están en procesos independientes. JsonRpc siempre asociará exactamente un controlador responsable de propagar el evento al cliente. El cliente puede tener cero o más controladores asociados en el lado lejano.
La mayoría de los clientes RPC no tendrán controladores de eventos conectados cuando estén conectados por primera vez. Evite generar el primer evento hasta después de que el cliente haya invocado un método "Subscribe*" en la interfaz para indicar interés y preparación para recibir eventos.
Si su evento indica un valor delta en el estado (por ejemplo, un nuevo elemento agregado a una colección), considere la opción de generar todos los eventos pasados o describir todos los datos actuales como si fueran nuevos en el argumento del evento cuando un cliente se suscriba para ayudarle a "sincronizarse" con nada más que el código de gestión de eventos.
Considere la posibilidad de aceptar argumentos adicionales en el método "Subscribe*" mencionado anteriormente si el cliente podría querer expresar interés en un subconjunto de datos o notificaciones, para reducir el tráfico de red y la CPU necesarios para reenviar estas notificaciones.
Considere la posibilidad de no ofrecer un método que devuelva el valor actual si también expone un evento para recibir notificaciones de cambio o desaconseja activamente que los clientes lo usen en combinación con el evento. Un cliente que se suscribe a un evento para obtener datos y llama a un método para obtener el valor actual se enfrenta a una carrera contra los cambios en ese valor y a la posibilidad de perderse un evento de cambio o de no saber cómo conciliar un evento de cambio en un subproceso con el valor obtenido en otro subproceso. Esta preocupación es general para cualquier interfaz, no solo cuando es sobre RPC.
Convenciones de nomenclatura
- Utiliza el sufijo
Service
en las interfaces RPC y un prefijo simpleI
. - No use el sufijo
Service
para las clases del SDK. La biblioteca o el contenedor RPC deben usar un nombre que describa exactamente lo que hace, evitando el término "servicio". - Evite el término "remoto" en los nombres de interfaz o miembros. Recuerde que los servicios intermediados se aplican de manera ideal tanto en escenarios locales como en los remotos.
Problemas de compatibilidad de versiones
Queremos que cualquier servicio intermediado en particular que se exponga a otras extensiones o a través de Live Share sea compatible con versiones anteriores y posteriores, lo que significa que debemos asumir que un cliente puede ser más antiguo o más reciente que el servicio y que la funcionalidad debe ser aproximadamente igual a la de la versión menor aplicable de las dos.
En primer lugar, revisemos la terminología de los cambios importantes:
cambio de ruptura binaria: un cambio en la API que provocaría que otro código administrado, compilado con una versión anterior del ensamblado, falle al intentar enlazarse en tiempo de ejecución con la nueva versión. Algunos ejemplos son:
- Cambiar la firma de un miembro público existente.
- Cambiar el nombre de un miembro público.
- Quitar un tipo público.
- Agregar un miembro abstracto a un tipo o cualquier miembro a una interfaz.
Pero los siguientes no son cambios binarios importantes:
- Agregar un miembro no abstracto a una clase o estructura.
- Agregar una implementación de interfaz completa (no abstracta) a un tipo existente.
cambio importante del protocolo: un cambio en la forma serializada de algún tipo de datos o llamada al método RPC de forma que la entidad remota no pueda deserializar y procesarla correctamente. Algunos ejemplos son:
- Adición de parámetros necesarios a un método RPC.
- Eliminar un miembro de un tipo de datos que previamente se garantizaba que no era nulo.
- La adición de un requisito que indique que una llamada a un método debe realizarse antes que otras operaciones preexistentes.
- Agregar, quitar o cambiar un atributo en un campo o propiedad que controla el nombre serializado de los datos de ese miembro.
- (MessagePack): el cambio de la propiedad DataMemberAttribute.Order o el entero
KeyAttribute
de un miembro existente.
No obstante, los siguientes no son cambios de protocolo importantes:
- Agregar un miembro opcional a un tipo de datos.
- La adición de miembros a interfaces RPC.
- Agregar parámetros opcionales a los métodos existentes.
- Cambiar un tipo de parámetro que representa un entero o float a uno con mayor longitud o precisión (por ejemplo,
int
along
ofloat
adouble
). - Cambiar el nombre de un parámetro. Técnicamente, esto puede causar problemas a los clientes que usan argumentos nombrados JSON-RPC, pero los clientes que usan ServiceJsonRpcDescriptor utilizan argumentos posicionales de forma predeterminada y no se verían afectados por un cambio en el nombre del parámetro. Esto no tiene nada que ver con si el código fuente del cliente usa una sintaxis de argumentos con nombre, en cuyo caso un cambio de nombre de parámetro sería un cambio importante en el código fuente.
Cambio importante de comportamiento: un cambio en la implementación de un servicio de intermediación que agrega o cambia el comportamiento de manera que los clientes más antiguos puedan funcionar incorrectamente. Algunos ejemplos son:
- Dejar de inicializar un miembro de un tipo de datos que siempre se inicializaba anteriormente.
- Lanzar una excepción con una condición que anteriormente podía completarse de forma correcta.
- Devolver un error con un código de error diferente al que se devolvió anteriormente.
No obstante, los siguientes no son cambios de comportamiento importantes:
- Lanzar un nuevo tipo de excepción (porque todas las excepciones se encapsulan en RemoteInvocationException de todas formas).
Cuando se requieren cambios importantes, se pueden realizar de forma segura registrando y proponiendo un nuevo identificador de servicio. Este moniker puede compartir el mismo nombre, pero con un número de versión superior. La interfaz RPC original podría ser reutilizable si no hay ningún cambio que rompa la compatibilidad binaria. De lo contrario, defina una nueva interfaz para la nueva versión del servicio. Evitar perjudicar a clientes antiguos al continuar registrando, ofreciendo y brindando soporte también a la versión anterior.
Queremos evitar todos estos cambios importantes, excepto la adición de miembros a las interfaces de RPC.
Adición de miembros a interfaces RPC
No agregue miembros a una interfaz de devolución de llamada de cliente de RPC, ya que muchos clientes pueden implementar esa interfaz y agregar miembros provocaría que CLR iniciara TypeLoadException cuando se cargaran esos tipos sin implementar los nuevos miembros de la interfaz. Si debe agregar miembros para invocar en un destino de devolución de llamada de cliente RPC, defina una nueva interfaz (que puede derivar de la original) y luego siga el proceso estándar para ofrecer su servicio de intermediación con un número de versión incrementado y ofrezca un descriptor con el tipo de interfaz de cliente actualizado especificado.
Puede agregar miembros a interfaces RPC que definan un servicio de intermediación. Este no es un cambio de protocolo importante; es solo un cambio binario importante para aquellos que implementan el servicio, pero supuestamente también estaría actualizando el servicio para implementar al nuevo miembro. Dado que nuestra guía es que nadie debe implementar la interfaz RPC excepto el propio servicio de intermediación (y las pruebas deben usar marcos de trabajo de simulación), agregar un miembro a una interfaz RPC no debe interrumpir a nadie.
Estos nuevos miembros deben tener comentarios de documento xml que identifiquen la versión del servicio que primero agregó ese miembro. Si un cliente más reciente llama al método en un servicio anterior que no implementa el método , ese cliente puede detectar RemoteMethodNotFoundException. Pero ese cliente puede (y probablemente debería) predecir el error y evitar la llamada desde el principio. Entre los procedimientos recomendados para agregar miembros a los servicios existentes se incluyen:
- Si este es el primer cambio de una versión del servicio, aumente la versión secundaria en el identificador de su servicio cuando agregue el miembro y declare el nuevo descriptor.
- Actualice su servicio para registrar y ofrecer la nueva versión además de la versión anterior.
- Si tiene un cliente de su servicio intermediado, actualice su cliente para solicitar la versión más reciente, y use una alternativa para solicitar la versión anterior si la más reciente regresa como null.