Seleccionar página
La aparición de gRPC de Google ha hecho que vuelva a ponerse de moda los sistemas de Remote Procedure Calls (RPC), que en el pasado tuvieron una amplia difusión. Uno de los problemas que, en nuestra humilde opinión, han tenido todas las implementaciones RPC es que se han ajustado muy poco a las características idiomáticas de Javascript, y han estado basados en lenguajes como Java con características muy específicas, por ejemplo, el tipado, de las que carecemos en nuestro entorno.

En este artículo vamos a explicar cómo utilizar y como se ha desarrollado una solución jsRPC reutilizable, basada en estándares, completamente adaptada a Javascript, que podemos utilizar tanto para ejecutar funciones en el servidor desde el navegador, como entre microservicios.

Introducción al RPC

¿Qué es?

La Wikipedia (traducido del inglés) define Remote Procedure Call (RPC) como: «En computación distribuida, una llamada a procedimiento remoto (RPC) es cuando un programa de ordenador hace que un procedimiento (subrutina) se ejecute en un espacio de direcciones diferente (comúnmente en otro ordenador en una red compartida), que se codifica como si fuera una normal (llamada a procedimiento local), sin que el programador codifique explícitamente los detalles para la interacción remota. Es decir, el programador escribe esencialmente el mismo código si la subrutina es local al programa en ejecución, o remota. »

Quizás pueda parecer un poco confuso, pero básicamente RCP es un sistema para poder invocar una función de forma remota como si esta función se estuviera ejecutando localmente, por lo tanto, sin que tengamos que preocuparnos de cómo se conectan entre ellos. Esto es muy flexible, ya que no tenemos que modificar nuestro código para cambiar una llamada local que se ejecuta en nuestro mismo entorno por una llamada que se ejecutará en otra máquina, sin que tengamos que modificar nuestro código original.

Componentes de un sistema RPC

Para que entendamos un poco mejor lo que estamos comentando vamos a describir todas las piezas que intervienen habitualmente en un sistema RPC:

  • En primer lugar, cambiamos la función original en local por un Stub, que podemos considerar un sustituto de la función original. Lo ideal es que el programa original no sea consciente de este cambio.
  • Los parámetros deberán ser serializados en un proceso de marshalling para poder ser transmitidos por medio de la red, ya que no podremos enviar objetos u otros artefactos sin esta serialización.
  • Se utilizará un cliente de red que enviará los datos por medio de algún protocolo y que recibirá el servidor de red.
  • Los parámetros serán des serializados en un proceso unmarshalling, inverso al que hemos realizado en el cliente.
  • Se llama al skeleton o stub server que es el encargado de llamar a la función original y capturar el retorno que devuelve.
  • El retorno debe ser serializado en un proceso de marshalling, normalmente de igual naturaleza que el utilizado anteriormente para los parámetros.
  • Este valor de retorno es pasado al servidor de red para que lo envíe al cliente por medio del protocolo utilizado.
  • El retorno es des serializado en un proceso de unmarshalling
  • El stub devuelve al programa que retorno que ha recibido, como si se hubiera ejecutado localmente.

Es necesario algún mecanismo para parar la ejecución del programa mientras se resuelve remotamente la función. Esto depende mucho del entorno concreto donde nos estemos ejecutando.

Aunque no es estrictamente necesario, en los sistemas RPC suele utilizar un IDL (Interfaz Definition Language) para que el cliente stub tenga información sobre los parámetros que recibe y los valores que retorna la función remota y poder ajustarse a lo que el cliente está esperando.

Limitaciones de las funciones remotas

Bueno, esto es muy interesante, pero vamos a ser sinceros, en un sistema RPC existen algunas limitaciones de las funciones que podemos invocar de forma remota. Algunas de estas limitaciones son:

  • En primer lugar, los parámetros deben ser pasados por valor. Los valores no pueden ser pasados por referencia o por puntero, ya que no podemos compartir un espacio de direcciones de memoria. En esta línea, por ejemplo, los parámetros del tipo función no tienen mucho sentido, ya que es necesario serializarlas.
  • No es posible compartir variables globales, ya que no estarán disponibles en el entre ambos entornos.
  • Es conveniente gestionar de alguna forma la asincronía que se produce entre la llamada y la respuesta del servidor, y es posible que esto no estuviera planteado en la función original, pero es necesario.
  • Aunque no debemos preocuparnos de las comunicaciones, lo cierto es que la velocidad de respuesta y los errores que se puedan producir en las comunicaciones pueden producir algunas alteraciones al comportamiento original de las funciones que no podemos ignorar completamente.

Aunque pueden parecer muchas limitaciones, lo cierto es que muchas funciones que utilizamos cada día cumplen estos requisitos y, en el caso que tengamos que desarrollarlas para nuestro proyecto, cumplir estos requerimientos no es demasiado complicado.

jsRPC

Para dar respuesta ligera y eficiente a un modelo RPC en Javascript hemos creado jsRPC, un sencillo paquete con dos funciones que permite ejecutar una función remota de forma transparente.

El código se puede obtener en https://github.com/todojs/jsrpc y se puede instalar desde NPM con:

npm install @todojs/jsrpc --save

Vamos a utilizar un pequeño ejemplo, sin sentido práctico alguno, que nos va a servir para comprender cómo funciona el sistema. Es una librería de cálculo matemático muy simple:

module.exports = {
  addition       : async (x, y) => await x + y,
  subtraction    : async (x, y) => await x - y,
  multiplication : async (x, y) => await x * y,
  division       : async (x, y) => await x / y
};

Este sería un ejemplo de cómo se utilizaría esta librería en forma de llamada local:

const Arithmetic = require ('./arithmetic');

(async () => {
  console.assert (await Arithmetic.addition (2, 3) === 5);
  console.assert (await Arithmetic.subtraction (2, 3) === -1);
  console.assert (await Arithmetic.multiplication (2, 3) === 6);
  console.assert (await Arithmetic.division (2, 3) === 2 / 3);
}) ();

El objetivo es cambiar el fichero arithmetic.js por su stub y que estas operaciones se realicen en otra máquina. Como os hemos dicho, no es algo muy práctico, sólo un ejemplo de lo que podemos hacer.

Para crear el stub vamos a utilizar una función de apoyo que hemos llamado stubify() (mostraremos su código más adelante) que permite devolver un objeto que capturará todas las llamadas y las enviará al servidor. Para ello sólo tenemos que crear un código de este tipo y ponerlo en el fichero arithmetic.js en sustitución del fichero original:

const stubify  = require ('@todojs/jsrpc/stubify');
module.exports = stubify (
  "http://localhost:9000",
  'arithmetic',
  [
    'addition',
    'subtraction',
    'multiplication',
    'division'
  ]);
 

Como se puede observar, la función stubify() recibe una URL, el nombre del objeto, y opcionalmente, una lista de métodos de los que dispone el objeto que ahora vamos a invocar remotamente.

Para crear el skeleton vamos a utilizar una función de apoyo que hemos llamado skeletonify() (mostraremos su código más adelante) que permite crear un servidor http que recibirá las solicitudes del cliente y llamará al módulo original. Para ello tenemos que crear un código de este tipo y ejecutarlo en Node para arrancar el servidor:

const Arithmetic  = require ('./arithmetic');
const skeletonify = require ('@todojs/jsrpc/skeletonify');
skeletonify ('arithmetic', Arithmetic);

Como se puede observar, la función skeletonify() recibe el nombre del objeto que contiene los métodos, el objeto como tal.

Ahora, si ejecutamos el servidor con node arithmetic-skeleton.js y volvemos a ejecutar el ejemplo inicial, pero con el stub veremos que las funciones se ejecutan en el servidor, en vez de localmente.

const Arithmetic = require ('./arithmetic-stub');

(async () => {
  console.assert (await Arithmetic.addition (2, 3) === 5);
  console.assert (await Arithmetic.subtraction (2, 3) === -1);
  console.assert (await Arithmetic.multiplication (2, 3) === 6);
  console.assert (await Arithmetic.division (2, 3) === 2 / 3);
  try {
    await Arithmetic.other (2, 3)
  } catch(err) {
    console.assert(err.message === 'Method not found')
  }
}) ();

Implementación de jsRPC

En esta implementación de RPC en Javascript hemos tomado algunas decisiones de diseño para utilizar una perspectiva idiomática y ajustarnos lo más posible a las características de este lenguaje:

  • El stub nos recuerda mucho a las funcionalidades del Proxy, por lo que hemos utilizado uno para capturar las llamadas a los métodos.
  • Como procesos de marshalling y unmarshalling hemos utilizado JSON.stringify y JSON.parser con el estándar de mensaje jsonRPC, de esta forma podemos interactuar con otros sistemas que soporten este estándar
  • A nivel de red vamos a utilizar http con fetch en el lado cliente y el servidor http estándar de Node, aunque también podríamos utilizar Expresss o Fastify. También podríamos haber usado WebSocket, pero lo dejamos para una próxima versión.
  • El skeleton están implementado como el manejador de una ruta en ese servidor http, e invocará la función original y obtendrá el resultado para volverlo a enviar de vuelta.
  • Para gestionar la asincronía de la llamada remota hemos optado por utilizar promesas, que son una solución bastante elegante. Si las funciones originales no están basadas en promesas, este es un cambio que se va a producir cuando se pase a funciones remotas.
  • No es necesario utilizar un IDL ya que estamos en un lenguaje levemente tipado y no tenemos que predefinir el número y tipos de datos de los parámetros y del retorno de la función. No obstante, como vamos a agrupar las funciones en objetos, implementaremos unos manejadores ownKeys y getOwnPropertyDescriptor en el Proxy para que si consultamos los métodos del objeto con métodos del tipo Object.keys() o Object.getOwnPropertetyDescriptor(), obtengamos la información de las funciones remotas.

Veamos como se ha escrito esta libería:

Código de jsRPC

const fetch = require ('node-fetch');

module.exports = function (server, objName, methods = []) {
  let id      = 1;
  const proxy = new Proxy ({}, {
    ownKeys                  : () => methods,
    getOwnPropertyDescriptor : (target, prop) => ({
      value : proxy[ prop ], writable : true, enumerable : true, configurable : true
    }),
    get                      : (() => {
      const cache = [];
      return (target, method) => {
        return cache[method] || (cache[method] = async (...params) => {
          const res  = await fetch (`${ server }/${ objName }/`, {
            method  : 'POST',
            headers : {'Content-Type' : 'application/json'},
            body    : JSON.stringify ({
              jsonrpc : "2.0",
              method  : method,
              params  : params,
              id      : id++
            })  // jsonRPC 2.0 standard format
          });
          const data = await res.json ();
          if (res.status === 200) {
            return data.result;
          }
          throw new Error (data.error.message);
        });
      };
    }) ()
  });
  return proxy;
};

Esta función stubify() devuelve un objeto stub con todas sus funciones preparadas para ser llamadas de forma remota. Para ello hemos utilizado un Proxy que nos permite interceptar todas las llamadas a métodos del objeto. Utilizamos JSON.stringify() con el formato estándar jsonRPC para el proceso de marshalling y fetch para enviar y recibir lo datos, lo cual nos facilita la recepción de los datos y el proceso de unmarshalling.

Al mostrar este código estamos desvelando algunas características de nuestro modelo de comunicación, ya que vamos a llamar a un servidor concreto, con una URL concreta. Estas características son opacas a nuestro programa cliente, pero son importantes para poder gestionar nuestra infraestructura de forma eficiente.

const app = require ('http').createServer ();

const routes = new Map ();
app.listen (process.env.PORT || 9000);
app.on ('request', (request, response) => {
  const bodyChunks = [];
  request.on ('data', (chunk) => bodyChunks.push (chunk));
  request.on ('end', async () => {
    try {
      request.bodyRaw = Buffer.concat (bodyChunks);
      request.body    = request.bodyRaw.length ? JSON.parse (request.bodyRaw.toString ()) : null;
    } catch (err) {
      return sendError (400, -37600, "Parse error");
    }
    try {
      for (let route of routes) {
        if (request.url.match (route[ 0 ]) && route[ 1 ][ request.method.toLowerCase () ]) {
          await route[ 1 ][ request.method.toLowerCase () ] (request, response);
          if (response.finished) {
            return log ();
          }
        }
      }
      return sendError (401, -32601, "Method not found", request.body.id);
    } catch (err) {
      if (err.message === "Cannot read property 'apply' of undefined") {
        return sendError (401, -32601, "Method not found", request.body.id);
      } else {
        return sendError (500, -32000, err.message, request.body.id);
      }
    }
  });

  function sendError (status, code, message, id = null) {
    response.statusCode = status;
    response.setHeader ('Content-Type', 'application/json');
    response.end (JSON.stringify ({
      "jsonrpc" : "2.0",
      "error"   : {code, message},
      "id"      : id
    }));
    log ()
  }

  function log () {
    console.log (new Date ().toISOString (), request.connection.remoteAddress,
      response.statusCode, request.url, request.body && request.body.method);
  }

});

module.exports = function addRoute (path, methods) {
  routes.set (path, methods);
  return app;
};

Este es un servicio http basado en el servidor estándar de Node al que hemos añadido un par de funcionalidades interesantes como son la captura completa del body con versión del mismo en un objeto por medio de JSON.stringify() y la posibilidad de definir múltiples rutas por medio de la función que se retorna al llamar a la función addRoute.

Hemos implementado la gestión de errores tal y como especifica el estándar jsonRPC, que es agnóstico al método utilizado para la comunicación, pero que sí establece una serie de valores para la gestión de excepciones.

Este programa se podría haber basado en Express o Fastify que son servidores muy completos y probados. Lo que hemos querido desarrollar de la forma más simple posible para mostrar que no necesitamos mucho para la funcionalidad que estamos implementando.

const addRoute = require ('./server');

module.exports = function skeletonify (objName, obj) {
  return addRoute (new RegExp (`^\/${ objName }\/`), {
    post : async (request, response) => {
      response.setHeader ('Content-Type', 'application/json');
      response.end (JSON.stringify ({
        jsonrpc : "2.0",
        result  : await obj[ request.body.method ].apply (null, request.body.params),
        id      : request.body.id
      }));
    }
  });
};

Este función skeletonify() permite crear un skeleton de forma muy sencilla y crea la ruta necesaria en el servidor http. También llama a las funciones originales pasando los parámetros y capturando la respuesta para enviarla al cliente en formato jsonRPC.

Conclusiones

En general nos hemos preocupado en exceso en nuestras aplicaciones por las comunicaciones entre el cliente y el servidor, o entre servidores, cuando perfectamente podemos delegar esta labor en librerías de RPC bien construidas que nos permiten usar nuestras funciones remotas de forma transparente en nuestro código. La flexibilidad que ofrecen estos modelos es muy interesante y nos permite mantener un código centrado en la funcionalidad y no en la distribución de los elementos entre el cliente y el servidor.

Muchas soluciones RPC están pensadas desde una perspectiva multilenguaje o se alinean con un lenguaje concreto, y en general están bastante alejadas de una solución Javascript nativa, adaptada a las características singulares de este lenguaje. jsRPC intenta realizar una implementación idiomática del RPC sin renunciar a la utilización de estándares que le permitan trabajar en un entorno heterogéneo.

No son necesarias miles de líneas de código y cientos de librerías para implementar una solución RPC en Javascript, las características del lenguaje nos facilitan mucho el trabajo. Como hemos podido ver, con unas 100 líneas es posible implementar una solución bastante completa. Es posible que queramos añadirle alguna funcionalidad adicional, como la identificación de usuarios, manejo de token o uso de certificados SSL. La simplicidad del código hace que incluir estas funcionalidades no sea un gran problema.

Os animamos a explorar las posibilidades del RPC en general y de la pequeña librería jsRPC en particular. Estamos seguros qué será muy interesante y esperamos que incorporéis una herramienta de este tipo a vuestros desarrollos ganando productividad y legibilidad de vuestras soluciones.

Novedades

Datos inmutables en Javascript

Datos inmutables en Javascript

En Javascript todo parece mutable, es decir, que se puede cambiar, pero lo cierto es que también nos ofrece varios mecanismos para conseguir que los datos que manejamos, especialmente los objetos, sean inmutables. Te invitamos a descubrir cómo…

Copiar objetos en Javascript

Copiar objetos en Javascript

Copiar objetos no es algo sencillo, incluso se podría decir que en si mismo no es posible, ya que el concepto «copiar» no entra dentro del paradigma de los objetos. No obstante, por medio de instrucciones como Object.assign() hemos aprendido como obtener objetos con las mismas propiedades, pero está técnica no se puede aplicar a todos los tipos de objetos disponibles en Javascript. Vamos a ver cómo podemos copiar cualquier tipo de objeto…

Descubre los Javascript Array Internals

Descubre los Javascript Array Internals

El Array es una de las estructuras más utilizadas en Javascript y no siempre bien comprendida. Hoy os invitamos a analizar el comportamiento interno de este objeto y descubrir cómo Javascript implementa las diferente acciones con los Array y que operaciones internas se realizan en cada caso.

Web Components: pasado, presente y futuro

Web Components: pasado, presente y futuro

Los Web Components aparecieron en el panorama de desarrollo hace ya bastante tiempo. Desde su presentación se les ha prestado mucha atención, pero lo cierto es que no han sido acogidos de forma generalizada, quizás por la difusión de nuevos y potentes frameworks. Nos preguntamos qué ha pasado con este estándar y, sobre todo, que puede pasar de aquí en adelante con el uso práctico de los componentes web.