Comment créer une API REST rapide et robuste avec Scala

"Il y a plus d'une façon de peler un chat."

C'est un dicton populaire et bien que l'image mentale puisse être troublante, c'est une vérité universelle, en particulier pour l'informatique.

Ce qui suit est donc un moyen de créer une API REST dans Scala et non la manière de les construire.

Pour des raisons pratiques, supposons que nous construisons quelques API pour une application similaire à Reddit, où les utilisateurs peuvent accéder à leur profil et soumettre des mises à jour. Pour construire sur la métaphore Reddit, imaginons que nous (re) mettons en œuvre api / v1 / me et api / submit

Quelques travaux au sol

En un mot:

  1. Scala est un langage de programmation orienté objet basé sur le lambda calcul qui s’exécute sur une machine virtuelle Java et s’intègre de manière transparente à Java.
  2. AKKA est une bibliothèque construite au sommet de Scala qui fournit aux acteurs (objets sécurisés multithreads) et plus encore.
  3. Spray.io est une bibliothèque HTTP construite sur AKKA qui fournit une implémentation simple et flexible du protocole HTTP afin que vous puissiez déployer votre propre service cloud.

Le défi

Les API REST sont censées fournir:

  1. authentification rapide et sécurisée au niveau des appels et contrôle des autorisations;
  2. calcul rapide de la logique métier et des E / S;
  3. tout ce qui précède est soumis à une simultanéité élevée;
  4. Ai-je mentionné rapide?

Étape 1, authentification et autorisation

L'authentification doit être implémentée dans OAUTH ou OAUTH 2 ou dans un autre type d'authentification par clé privée / publique.

L'avantage d'une approche OAUTH2 est que vous obtenez un jeton de session (que vous pouvez utiliser pour rechercher le compte d'utilisateur et la session correspondants) et un jeton de signature, pour en savoir plus dans un instant.

Nous allons continuer ici en supposant que c'est ce que nous utilisons.

Le jeton de signature est normalement un jeton chiffré obtenu en signant la totalité de la charge utile de la demande avec une clé secrète partagée à l'aide de SHA1. Le jeton de signature tue ainsi deux oiseaux d'une pierre:

  1. il vous indique si l'appelant connaît le secret partagé approprié;
  2. il empêche l'injection de données et les attaques de l'homme du milieu;

Il y a un ou deux prix à payer pour ce qui précède: d'abord, vous devez extraire les données de votre couche d'E / S et, deuxièmement, vous devez calculer un cryptage relativement coûteux (SHA1) avant de pouvoir comparer le jeton de signature de l'appelant. et celui que le serveur construit, ce qui est considéré comme le bon, car le serveur final sait tout (ou presque).

Pour vous aider avec les E / S, vous pouvez ajouter un cache (Memcache? Redis?) Et supprimer la nécessité d’un voyage coûteux vers la pile persistante (Mongo? Postgres?).

AKKA et Spray.io sont très efficaces pour résoudre ce problème. Spray.io encapsule les étapes nécessaires à l'extraction des informations d'en-tête HTTP et de la charge utile. Les acteurs AKKA permettent aux tâches asynchrones d'être effectuées indépendamment de l'analyse de l'API. Cette combinaison réduit la charge sur le gestionnaire de demandes et peut être comparé afin que la plupart des API aient un temps de traitement inférieur à 100 ms. Remarque: j'ai dit temps de traitement et non temps de réponse, je n'inclus pas la latence du réseau.

Remarque: en utilisant les acteurs AKKA, il est possible de déclencher deux processus simultanés, l'un pour l'autorisation / authentification et l'autre pour la logique applicative. On s’enregistrait alors pour leurs rappels et fusionnait les résultats. Cela met en parallèle l'implémentation de l'API au niveau des appels en adoptant l'approche optimiste voulant que l'authentification réussisse. Cette approche nécessite une répétition minimale des données, car le client doit envoyer tout ce dont la logique métier a besoin, comme l'ID utilisateur et tout ce que vous extrayez normalement de la session. D'après mon expérience, le gain de cette approche se traduit par une réduction d'environ 10% du temps d'exécution. Elle est coûteuse aussi bien au moment de la conception que de l'exécution car elle utilise plus de processeur et de mémoire. Cependant, il peut arriver que le gain relativement faible corresponde au traitement de millions d'appels par minute, augmentant ainsi les économies / les avantages. Dans la plupart des cas cependant, je ne le recommanderais pas.

Une fois le jeton de session résolu pour un utilisateur, il est possible de mettre en cache le profil de l'utilisateur, qui inclut les niveaux d'autorisation, et de simplement les comparer au niveau d'autorisation requis pour effectuer l'appel d'API.

Pour obtenir le niveau d'autorisation d'une API, on analyse l'URI et extrait la ressource REST et l'identificateur (le cas échéant), et on utilise l'en-tête HTTP pour extraire le type.

Disons par exemple que vous souhaitez autoriser les utilisateurs enregistrés à obtenir leur profil via un HTTP GET

/ api / v1 / me

alors voici à quoi ressemblerait un document de configuration des permissions dans un tel système:

{
 “V1 / moi”: [{
 “Admin”: [“get”, “put”, “post”, “delete”]
 }, {
 “Enregistré”: [“get”, “put”, “post”, “delete”]
 }, {
 "Read_only": ["get"]
 }, {
 “Bloqué”: []
 }],
 "soumettre": [{
 “Admin”: [“put”, “post”, “delete”]
 }, {
 “Enregistré”: [“post”, “supprimer”]
 }, {
 "lecture seulement": []
 }, {
 “Bloqué”: []
 }]
}

Le lecteur doit noter qu'il s'agit d'une condition nécessaire mais non suffisante pour obtenir l'autorisation d'accès aux données. Jusqu'à présent, nous avons établi que le client appelant est autorisé à effectuer l'appel et que l'utilisateur est autorisé à accéder à l'API. Cependant, dans de nombreux cas, nous devons également nous assurer que l’utilisateur A ne peut pas voir (ou éditer) les données B de l’utilisateur. Alors étendons la notation avec “get_owner”, ce qui signifie que les utilisateurs authentifiés ont le droit d’exécuter un GET seulement s’ils possèdent la ressource. Voyons à quoi ressemblerait la configuration:

{
 “V1 / moi”: [{
 “Admin”: [“get”, “put”, “post”, “delete”]
 }, {
 “Inscrit”: [“get_owner”, “put”, “post”, “delete”]
 }, {
 "Read_only": ["get_owner"]
 }, {
 “Bloqué”: []
 }],
 "soumettre": [{
 “Admin”: [“put”, “post”, “delete”]
 }, {
 “Inscrit”: ["put_owner", "post", "delete"]
 }, {
 "lecture seulement": []
 }, {
 “Bloqué”: []
 }]
}

Désormais, un utilisateur enregistré peut accéder à son propre profil, le lire, le modifier mais personne d'autre ne le peut (à l'exception d'un administrateur). De même, seul le propriétaire peut mettre à jour une soumission avec:

/ api / submit / 

La puissance de cette approche réside dans le fait qu'il est possible de modifier radicalement ce que les utilisateurs peuvent et ne peuvent pas faire avec les données en modifiant simplement la configuration des autorisations. Aucun changement de code n'est nécessaire. Ainsi, au cours du cycle de vie du produit, l’arrière-plan peut correspondre aux changements d’exigences sur-le-champ.

L'application peut être encapsulée dans deux fonctions qui peuvent être agnostiques de la logique métier de l'API et simplement implémenter et appliquer l'authentification et l'autorisation:

def validateSessionToken (sessionToken: String) UserProfile = {
...
}
def checkPermission (
  méthode: chaîne,
  ressource: String,
  utilisateur: UserProfile
) {
...
// lève une exception en cas d'échec
}

Ceux-ci seraient appelés au début de la gestion par Spray.io des appels API:

// NOTE: profileReader et sumbissionWriter sont omis ici, supposons qu'ils prolongent un acteur AKKA.
route def =
{
pathPrefix ("api") {
  // extrait les en-têtes et les informations HTTP
  ...
  var user: UserProfile = null
  essayer {
    validatedSessionToken (sessionToken)
  } catch (e: Exception) {
    complete (completeWithError (e.getMessage))
  }
  essayer {
    checkPermission (méthode, ressource, utilisateur)
  } catch (e: Exception) {
    complete (completeWithError (e.getMessage))
  }
  pathPrefix ("v1") {
    chemin ("moi") {
      obtenir {
        complete (profileReader? getUserProfile (user.id))
      }
    }
  } ~
  chemin ("submit") {
    post {
      entité (comme [String]) {=> jsonstr
        val payload = read [SubmitPayload] (jsonstr)
        complete (submissionWriter? sumbit (payload))
      }
    }
  }
  ...
}

Comme nous pouvons le constater, cette approche permet au gestionnaire Spray.io de rester lisible et facile à gérer, car elle sépare l’authentification / la permission de la logique applicative individuelle de chaque API. L'application de la propriété des données, qui n'est pas illustrée ici, peut être réalisée en passant un booléen à la couche d'E / S, qui appliquerait ensuite la propriété des données utilisateur au niveau de persistance.

Étape 2, logique métier

La logique applicative peut être encapsulée dans des acteurs d'E / S, tels que le submitWriter mentionné dans l'extrait de code ci-dessus. Cet acteur implémenterait une opération d'E / S asynchrone qui effectue des écritures d'abord sur une couche de cache, par exemple Elasticsearch, et ensuite sur une base de données de choix. Les écritures de base de données peuvent être davantage découplées dans une logique d'incendie et d'oubli qui utiliserait une récupération basée sur les journaux afin que le client n'ait pas à attendre que ces opérations coûteuses soient terminées.

Notez qu'il s'agit d'une approche optimiste non verrouillable et que le seul moyen pour le client de s'assurer que les données ont été écrites serait de faire un suivi avec une lecture. En attendant, un client mobile devrait fonctionner en supposant que les données en cache respectives sont sales.

C'est un paradigme de conception très puissant, mais le lecteur devrait être averti qu'avec AKKA + Spary.io, vous ne pouvez pas aller plus de trois niveaux au plus profond de la pile d'appels d'acteurs. Par exemple, s'il s'agit des acteurs du système:

  1. S pour le routeur de pulvérisation.
  2. A pour le gestionnaire d'API.
  3. B pour le gestionnaire d'E / S.

en utilisant la notation x? y pour signifier que x appelle y pour demander un rappel et x! y pour signifier que x se déclenche et oublie y, les opérations suivantes fonctionnent:

S? UNE ! B

Cependant, ceux-ci ne:

S! UNE ! B

S? UNE ! B! B

Dans ces deux cas, toutes les occurrences de B sont détruites dès que A complète si efficacement que vous n'avez qu'une seule chance de mettre tous vos calculs hors charge sur un feu et d'oublier l'acteur. Je crois que c’est une limitation de Spray et non d’AKKA et il aurait peut-être été résolu au moment de la publication de ce message.

Enfin, I / O et la persistance

Comme indiqué ci-dessus, nous pouvons appliquer des opérations d'écriture lente aux threads asynchrones afin de maintenir les performances POST / PUT de l'API dans des délais acceptables. Celles-ci sont généralement comprises entre quelques dizaines de secondes ou une centaine de millisecondes, en fonction du profil du serveur et de la quantité de logique pouvant être différée à l’aide de cette approche.

Cependant, il arrive souvent que les écritures soient plus nombreuses que les écritures d'un ou plusieurs ordres de grandeur. Une bonne approche de mise en cache est donc essentielle pour obtenir un débit global élevé.

Remarque: l'inverse est vrai pour les paysages IOT où les données sensorielles écrites provenant de nœuds seront plus nombreuses que les lectures de plusieurs ordres de grandeur. Dans ce cas, le paysage peut être configuré pour avoir un groupe de serveurs configuré pour effectuer uniquement des écritures à partir de périphériques IOT, en dédiant un autre groupe de serveurs avec des spécifications différentes aux appels d'API provenant de clients (front-end). La plupart sinon la totalité de la base de code pourrait être partagée entre ces deux classes de serveurs et des fonctionnalités pourraient simplement être désactivées via une configuration afin de prévenir les vulnérabilités de sécurité.

Une approche populaire consiste à utiliser un cache de mémoire tel que Redis. Redis fonctionne bien lorsqu'il est utilisé pour stocker les autorisations utilisateur pour l'authentification, c'est-à-dire des données qui ne changent pas souvent. Un seul nœud Redis peut stocker jusqu'à 250 mi de paires.

Pour les lectures ayant besoin d'interroger le cache, nous avons besoin d'une solution différente. Elasticsearch, un index en mémoire, fonctionne exceptionnellement bien pour les données géographiques ou pour les données pouvant être partitionnées en types. Par exemple, un index nommé soumissions avec des types chiens et motos peut être facilement interrogé pour obtenir la dernière soumission (sous-titres?) Pour certains sujets.

Par exemple, en utilisant la notation http API d’Elasticsearch:

curl -XPOST 'localhost: 9200 / soumissions / chiens / _search? pretty' -d '
{
  "requête": {
    "filtré": {
      "query": {"match_all": {}},
      "filtre": {
        "intervalle": {
          "créé": {
            "gte": 1464913588000
          }
        }
      }
    }
  }
} '

renverrait tous les documents après la date spécifiée dans / chiens. De même, nous pourrions rechercher tous les articles dans / soumission / motos dont les documents contiennent l’œuvre «Ducati».

curl -XPOST 'localhost: 9200 / submission / motos / _search? pretty' -d '
{
  "query": {"match": {"text": "Ducati"}}
} '
Elasticsearch fonctionne très bien pour les lectures lorsque l'index est soigneusement conçu et créé avant la saisie des données. Cela pourrait en décourager certains, car l’un des avantages d’Elasticsearch est la possibilité de créer un index simplement en publiant un document et de laisser le moteur déterminer les types et les structures de données. Cependant, les avantages de la définition de la structure dépassent les coûts et il convient de noter que la migration vers un nouvel index est simple, même dans les environnements de production lors de l'utilisation d'alias.

Remarque: les index Elasticsearch sont implémentés sous forme d'arborescence équilibrée. Par conséquent, l'opération d'insertion et de suppression peut s'avérer coûteuse lorsque l'arborescence devient volumineuse. L'insertion dans un index contenant des dizaines de millions de documents peut prendre plusieurs dizaines de secondes, en fonction des spécifications du serveur. Cela peut faire en sorte que votre Elasticsearch écrit l’un des processus les plus lents de votre cloud (en dehors des écritures DB, bien sûr). Cependant pousser l’écriture dans un feu et oublier AKKA acteur peut améliorer sinon résoudre le problème.

Conclusions

Scala + AKKA + Spray.io constituent une pile technologique très efficace pour la création d’API REST hautes performances lorsqu’elles sont associées à la mise en cache et / ou à l’indexation de la mémoire.

J'ai travaillé sur une implémentation pas très éloignée des concepts décrits ici, dans lesquels 2 000 hits par minute par nœud déplaçaient à peine la charge du processeur au-dessus de 1%.

Bonus round: apprentissage automatique et plus

L'ajout d'Elasticsearch à la pile ouvre la porte à l'apprentissage automatique en ligne et hors ligne, car Elasticsearch s'intègre à Apache Spark. La même couche de persistance utilisée pour servir l'API peut être réutilisée par les modules d'apprentissage automatique, réduisant ainsi le codage, les coûts de maintenance et la complexité de la pile. Enfin, Scala nous permet d’utiliser toute librairie Scala ou Java ouvrant la porte à un traitement de données plus sophistiqué, en s’appuyant sur des technologies telles que le PNL de base de Stanford, OpenCV, Spark Mlib, etc.

Liens vers les technologies mentionnées dans cet article

  1. http://www.scala-lang.org
  2. http://spray.io
  3. et pour (2) donner un sens, jetez un oeil à http://akka.io