Cómo construir API REST rápida y robusta con Scala

"Hay más de una forma de pelar un gato".

Este es un dicho popular y, aunque la imagen mental puede ser inquietante, es una verdad universal, particularmente para la informática.

Lo que sigue es, por lo tanto, una forma de construir API REST en Scala y no la forma de construirlos.

Para todos los fines prácticos, supongamos que estamos creando un par de API para una aplicación similar a Reddit donde los usuarios pueden acceder a su perfil y enviar actualizaciones. Para construir sobre la metáfora de Reddit, imagine que estamos (re) implementando api / v1 / me y api / submit

Algo de trabajo de tierra

En una palabra:

  1. Scala es un lenguaje de programación orientado a objetos basado en cálculo lambda que se ejecuta en una máquina virtual Java y se integra a la perfección con Java.
  2. AKKA es una biblioteca construida sobre Scala que proporciona actores (objetos seguros de subprocesos múltiples) y más.
  3. Spray.io es una biblioteca HTTP construida sobre AKKA que proporciona una implementación de protocolo HTTP simple y flexible para que pueda implementar su propio servicio en la nube.

El reto

Se espera que REST API proporcione:

  1. autenticación de nivel de llamada rápida y segura y control de permisos;
  2. cómputo lógico de negocios rápido y E / S;
  3. todo lo anterior bajo alta concurrencia;
  4. ¿mencioné rápido?

Paso 1, autenticación y permiso

La autenticación debe implementarse en OAUTH u OAUTH 2 o en algún tipo de autenticación de clave privada / pública.

El beneficio de un enfoque OAUTH2 es que obtienes un token de sesión (que puedes usar para buscar la cuenta de usuario y sesión correspondientes) y un token de firma, más sobre eso en un momento.

Continuaremos aquí asumiendo que esto es lo que usamos.

El token de firma es normalmente un token encriptado obtenido al firmar la carga útil completa de la solicitud con una clave secreta compartida utilizando SHA1. La ficha de firma mata a dos pájaros de un tiro:

  1. le dice si la persona que llama conoce el secreto compartido correcto;
  2. evita la inyección de datos y el hombre en el medio de los ataques;

Hay un par de precios que pagar por lo anterior: primero debe extraer los datos de su capa de E / S y luego debe calcular un cifrado relativamente costoso (es decir, SHA1) antes de poder comparar el token de firma de la persona que llama y el que construye el servidor, que se considera el correcto ya que el back-end lo sabe todo (casi).

Para ayudar con las E / S, se puede agregar un caché (Memcache? Redis?) Y eliminar la necesidad de un costoso viaje a la pila persistente (Mongo? Postgres?).

AKKA y Spray.io son muy efectivos para abordar lo anterior. Spray.io encapsula los pasos necesarios para extraer la información del encabezado HTTP y la carga útil. Los actores de AKKA permiten que las tareas asincrónicas se realicen independientemente del análisis API. Esta combinación reduce la carga en el manejador de solicitudes y se puede marcar de forma comparativa para que la mayoría de las API tengan un tiempo de procesamiento inferior a 100 ms. Nota: Dije tiempo de procesamiento, no tiempo de respuesta, no incluyo latencia de red.

Nota: con los actores de AKKA, es posible activar dos procesos concurrentes, uno para el permiso / autenticación y otro para la lógica de negocios. Entonces uno se registraría para sus devoluciones de llamadas y fusionaría los resultados. Esto paraleliza la implementación de la API a nivel de llamada, adoptando el enfoque optimista de que la autenticación tendrá éxito. Este enfoque requiere una mínima repetición de datos, ya que el cliente debe enviar todo lo que la lógica empresarial necesita, como la identificación del usuario y cualquier cosa que normalmente extraiga de la sesión. En mi experiencia, la ganancia de este enfoque produce una reducción de alrededor del 10% en el tiempo de ejecución y es costosa tanto en tiempo de diseño como en tiempo de ejecución, ya que utiliza más CPU y más memoria. Sin embargo, puede haber escenarios en los que la ganancia relativamente pequeña se vincula con el resultado final de procesar millones de llamadas por minuto, lo que aumenta los ahorros / beneficios. Sin embargo, en la mayoría de los casos no lo recomendaría.

Una vez que el token de sesión se resuelve a un usuario, se puede almacenar en caché el perfil de usuario que incluye los niveles de permiso y simplemente compararlos con el nivel de permiso requerido para realizar la llamada API.

Para obtener el nivel de permiso de una API, uno analiza el URI y extrae el recurso REST y el identificador (si corresponde) y uno usa el encabezado HTTP para extraer el tipo.

Digamos, por ejemplo, que desea permitir que los usuarios registrados obtengan su perfil a través de HTTP GET

/ api / v1 / me

entonces así es como se vería un documento de configuración de permisos en dicho sistema:

{
 "V1 / me": [{
 "Admin": ["obtener", "poner", "publicar", "eliminar"]
 }, {
 "Registrado": ["obtener", "poner", "publicar", "eliminar"]
 }, {
 "Read_only": ["get"]
 }, {
 "Bloqueado": []
 }],
 "enviar": [{
 "Admin": ["poner", "publicar", "eliminar"]
 }, {
 "Registrado": ["publicación", "eliminar"]
 }, {
 "solo lectura": []
 }, {
 "Bloqueado": []
 }]
}

El lector debe tener en cuenta que esta es una condición necesaria pero no suficiente para el permiso de acceso a los datos. Hasta ahora hemos establecido que el cliente que realiza la llamada está autorizado para realizar la llamada y que el usuario tiene permiso para acceder a la API. Sin embargo, en muchos casos también debemos asegurarnos de que el usuario A no pueda ver (o editar) los datos de B del usuario. Así que ampliemos la notación con "get_owner", lo que significa que los usuarios autenticados tienen permiso para ejecutar un GET solo si posee el recurso. Veamos cómo se vería la configuración entonces:

{
 "V1 / me": [{
 "Admin": ["obtener", "poner", "publicar", "eliminar"]
 }, {
 "Registrado": ["get_owner", "put", "post", "delete"]
 }, {
 "Read_only": ["get_owner"]
 }, {
 "Bloqueado": []
 }],
 "enviar": [{
 "Admin": ["poner", "publicar", "eliminar"]
 }, {
 "Registrado": ["put_owner", "post", "delete"]
 }, {
 "solo lectura": []
 }, {
 "Bloqueado": []
 }]
}

Ahora un usuario registrado puede acceder a su propio perfil, leerlo, modificarlo, pero nadie más puede (excepto un administrador). Del mismo modo, solo el propietario puede actualizar un envío con:

/ api / submit / 

El poder de este enfoque es que los cambios drásticos en lo que los usuarios pueden y no pueden hacer con los datos se pueden lograr simplemente cambiando la configuración de permisos, no se requieren cambios de código. Por lo tanto, durante el ciclo de vida del producto, el back-end puede igualar los cambios en los requisitos en cualquier momento.

La aplicación se puede encapsular en un par de funciones que pueden ser independientes de la lógica comercial de la API y solo implementar y aplicar la autenticación y el permiso:

def validateSessionToken (sessionToken: String) UserProfile = {
...
}
def checkPermission (
  Método: cadena,
  recurso: Cadena,
  usuario: UserProfile
) {
...
// lanza una excepción en caso de falla
}

Se llamarían al comienzo del manejo de Spray.io de las llamadas API:

// NOTA: profileReader y sumbissionWriter se omiten aquí, supongamos que están extendiendo un actor AKKA.
ruta de def =
{
pathPrefix ("api") {
  // extrae los encabezados y la información HTTP
  ...
  usuario var: UserProfile = null
  tratar {
    validatedSessionToken (sessionToken)
  } catch (e: Exception) {
    complete (completeWithError (e.getMessage))
  }
  tratar {
    checkPermission (método, recurso, usuario)
  } catch (e: Exception) {
    complete (completeWithError (e.getMessage))
  }
  pathPrefix ("v1") {
    ruta ("yo") {
      obtener {
        complete (profileReader? getUserProfile (user.id))
      }
    }
  } ~
  ruta ("enviar") {
    publicar {
      entidad (como [Cadena]) {=> jsonstr
        val payload = read [SubmitPayload] (jsonstr)
        complete (submitWriter? sumbit (carga útil))
      }
    }
  }
  ...
}

Como podemos ver, este enfoque mantiene el controlador Spray.io legible y fácil de mantener, ya que separa la autenticación / permiso de la lógica comercial individual de cada API. La aplicación de la propiedad de datos, que no se muestra aquí, se puede lograr pasando un Booleano a la capa de E / S que luego obligaría a la propiedad de los datos del usuario al nivel de persistencia.

Paso 2, lógica de negocios

La lógica empresarial se puede encapsular en actores de E / S como el submitWriter mencionado en el fragmento de código anterior. Este actor implementaría una operación de E / S asíncrona que realiza escrituras primero en una capa de caché, por ejemplo, Elasticsearch, y en segundo lugar en una base de datos de elección. Las escrituras de la base de datos se pueden desacoplar aún más en un incendio y olvidar la lógica que usaría la recuperación basada en registros para que el cliente no tenga que esperar a que se completen estas costosas operaciones.

Tenga en cuenta que este es un enfoque optimista sin bloqueo y que la única forma de que el cliente esté seguro de que los datos se escribieron sería hacer un seguimiento con una lectura. Hasta ese momento, un cliente móvil debe operar bajo el supuesto de que los datos en caché respectivos están sucios.

Este es un paradigma de diseño muy poderoso, sin embargo, se debe advertir al lector que con AKKA + Spary.io no puede ir más allá de tres niveles en la pila de llamadas de actores. Por ejemplo, si estos son los actores en el sistema:

  1. S para el enrutador Spray.
  2. A para el manejador de API.
  3. B para el manejador de E / S.

usando la notación x? y para indicar que x llama y solicita una devolución de llamada y x! y para indicar que x dispara y olvida y, lo siguiente funciona:

S? UNA ! segundo

Sin embargo, estos no:

S! UNA ! segundo

S? UNA ! B! segundo

En estos dos casos, todas las instancias de B se destruyen tan pronto como A se completa de manera tan efectiva que solo tiene una oportunidad de empacar todos sus cálculos descargados en un incendio y olvidar al actor. Creo que esto es una limitación de Spray y no AKKA y podría haber sido abordado para cuando se publique esta publicación.

Por último, E / S y persistencia.

Como se muestra arriba, podemos insertar operaciones de escritura lenta en subprocesos asincrónicos para mantener el rendimiento de API POST / PUT dentro de un tiempo de ejecución aceptable. Por lo general, estos varían en decenas de segundos o menos de cien milisegundos dependiendo del perfil del servidor y cuánta lógica se puede diferir usando el enfoque de disparo y olvido.

Sin embargo, a menudo es el caso que las lecturas superan a las escrituras en uno o más órdenes de magnitud. Por lo tanto, un buen enfoque de almacenamiento en caché es fundamental para ofrecer un alto rendimiento general.

Nota: lo contrario es cierto para los paisajes IOT donde las escrituras de datos sensoriales provenientes de los nodos superarán en número a la lectura en varios órdenes de magnitud. En este caso, el paisaje se puede configurar para tener un grupo de servidores configurado para realizar solo escrituras desde dispositivos IOT, dedicando otro grupo de servidores con diferentes especificaciones a las llamadas API de los clientes (front-end). La mayoría, si no todo, la base de código podría compartirse entre estas dos clases de servidores y las características podrían simplemente desactivarse mediante la configuración para evitar vulnerabilidades de seguridad.

Un enfoque popular es usar una memoria caché como Redis. Redis funciona bien cuando se usa para almacenar permisos de usuario para la autenticación, es decir, datos que no cambian con frecuencia. Un solo nodo Redis puede almacenar hasta 250 mi pares.

Para las lecturas que necesitan consultar el caché, necesitamos una solución diferente. Elasticsearch, un índice en memoria, funciona excepcionalmente bien para datos geográficos o para datos que se pueden dividir en tipos. Por ejemplo, se puede consultar fácilmente un índice denominado envíos con tipos perros y motocicletas para obtener el último envío (¿subreddits?) Para ciertos temas.

Por ejemplo, usando la notación de API http de Elasticsearch:

curl -XPOST 'localhost: 9200 / sumisiones / perros / _search? pretty' -d '
{
  "consulta": {
    "filtrado": {
      "query": {"match_all": {}},
      "filtro": {
        "distancia": {
          "creado": {
            "gte": 1464913588000
          }
        }
      }
    }
  }
} '

devolvería todos los documentos después de la fecha especificada en / dogs. Del mismo modo, podríamos buscar todas las publicaciones en / sumisiones / motocicletas cuyos documentos contengan el trabajo "Ducati".

curl -XPOST 'localhost: 9200 / submit / motorcycles / _search? pretty' -d '
{
  "query": {"match": {"text": "Ducati"}}
} '
Elasticsearch funciona muy bien para las lecturas cuando el índice está cuidadosamente diseñado y creado antes de ingresar los datos. Esto podría desalentar a algunos, ya que uno de los beneficios de Elasticsearch es la capacidad de crear un índice simplemente publicando un documento y dejar que el motor descubra tipos y estructuras de datos. Sin embargo, los beneficios de definir la estructura son mayores que los costos y debe tenerse en cuenta que migrar a un nuevo índice es sencillo incluso en entornos de producción cuando se usan alias.

Nota: los índices Elasticsearch se implementan como árboles equilibrados, por lo que la operación de inserción y eliminación puede ser costosa cuando el árbol crece. Insertar en un índice con decenas de millones de documentos puede llevar hasta decenas de segundos, dependiendo de las especificaciones del servidor. Esto puede hacer que su Elasticsearch escriba uno de los procesos de ejecución más lenta en su nube (aparte de las escrituras DB, por supuesto). Sin embargo, empujar la escritura al fuego y olvidar que el actor AKKA puede mejorar si no resuelve el problema.

Conclusiones

Scala + AKKA + Spray.io son una pila de tecnología muy efectiva para construir API REST de alto rendimiento cuando se casa con almacenamiento en caché de memoria y / o indexación de memoria.

Trabajé en una implementación no muy lejos de los conceptos descritos aquí, donde 2000 golpes por minuto por nodo apenas movieron la carga de la CPU por encima del 1%.

Ronda de bonificación: aprendizaje automático y más

Agregar Elasticsearch a la pila abre la puerta para el aprendizaje automático tanto en línea como fuera de línea, ya que Elasticsearch se integra con Apache Spark. Los módulos de aprendizaje automático pueden reutilizar la misma capa de persistencia utilizada para servir a la API, reduciendo la codificación, los costos de mantenimiento y la complejidad de la pila. Por último, Scala nos permite usar cualquier biblioteca Scala o Java que abra la puerta a un procesamiento de datos más sofisticado, aprovechando elementos tales como Stanford’s Core NLP, OpenCV, Spark Mlib y más.

Enlaces a tecnologías mencionadas en esta publicación

  1. http://www.scala-lang.org
  2. http://spray.io
  3. y para que (2) tenga sentido, eche un vistazo a http://akka.io