Come costruire un'API REST veloce e robusta con Scala

"C'è più di un modo per scuoiare un gatto".

Questo è un detto popolare e sebbene l'immagine mentale possa essere inquietante, è una verità universale, in particolare per l'informatica.

Ciò che segue è quindi un modo per costruire l'API REST in Scala e non il modo per costruirle.

Per tutti gli scopi pratici, facciamo finta di costruire un paio di API per un'applicazione come Reddit in cui gli utenti possono accedere al proprio profilo e inviare aggiornamenti. Per basarci sulla metafora di Reddit, immagina che stiamo (ri) implementando api / v1 / me e api / submit

Qualche lavoro di base

In breve:

  1. Scala è un linguaggio di programmazione orientato agli oggetti basato sul calcolo lambda che gira su una Java Virtual Machine e si integra perfettamente con Java.
  2. AKKA è una biblioteca costruita in cima a Scala che offre attori (oggetti sicuri multi-thread) e altro ancora.
  3. Spray.io è una libreria HTTP costruita su AKKA che fornisce un'implementazione del protocollo HTTP semplice e flessibile in modo che tu possa implementare il tuo servizio cloud.

La sfida

L'API REST dovrebbe fornire:

  1. autenticazione a livello di chiamata rapida e sicura e controllo delle autorizzazioni;
  2. calcolo e I / O della logica aziendale veloce;
  3. tutto quanto sopra in alta concorrenza;
  4. ho parlato velocemente?

Passaggio 1, autenticazione e autorizzazione

L'autenticazione deve essere implementata in OAUTH o OAUTH 2 o in qualche modo dell'autenticazione con chiave pubblica / privata.

Il vantaggio di un approccio OAUTH2 è che ottieni un token di sessione (che puoi usare per cercare l'account utente e la sessione corrispondenti) e un token di firma, più su quello in un momento.

Continueremo qui partendo dal presupposto che questo è ciò che usiamo.

Il token di firma è normalmente un token crittografato ottenuto firmando l'intero payload della richiesta con una chiave segreta condivisa utilizzando SHA1. Il gettone firma quindi uccide due uccelli con una fava:

  1. ti dice se il chiamante conosce il segreto condiviso giusto;
  2. impedisce l'iniezione di dati e attacchi man in the middle;

Ci sono un paio di prezzi da pagare per quanto sopra: prima devi estrarre i dati dal tuo livello I / O e in secondo luogo devi calcolare una crittografia relativamente costosa (ad esempio SHA1) prima di poter confrontare il token della firma dal chiamante e quello che il server costruisce, che è considerato quello corretto poiché il back-end conosce tutto (quasi).

Per aiutare con l'I / O, si può aggiungere una cache (Memcache? Redis?) E rimuovere la necessità di un costoso viaggio nello stack persistente (Mongo? Postgres?).

AKKA e Spray.io sono molto efficaci nell'affrontare quanto sopra. Spray.io incapsula i passaggi necessari per estrarre le informazioni dell'intestazione HTTP e il payload. Gli attori di AKKA consentono di eseguire attività asincrone indipendentemente dall'analisi dell'API. Questa combinazione riduce il carico sul gestore delle richieste e può essere contrassegnata in modo che la maggior parte delle API abbia un tempo di elaborazione inferiore a 100 ms. Nota: ho detto che il tempo di elaborazione non è il tempo di risposta, non includo la latenza di rete.

Nota: utilizzando gli attori di AKKA, è possibile attivare due processi simultanei, uno per l'autorizzazione / autenticazione e uno per la logica aziendale. Uno si sarebbe quindi registrato per i loro richiami e unire i risultati. Ciò parallelizza l'implementazione dell'API a livello di chiamata adottando l'approccio ottimistico secondo cui l'autenticazione avrà esito positivo. Questo approccio richiede una ripetizione dei dati minima in quanto il client deve inviare tutto ciò di cui la logica aziendale ha bisogno come l'id utente e tutto ciò che normalmente si estrarrebbe dalla sessione. Nella mia esperienza, il guadagno di questo approccio comporta una riduzione del 10% circa dei tempi di esecuzione ed è costoso sia in fase di progettazione che in fase di esecuzione poiché utilizza più CPU e più memoria. Tuttavia, potrebbero esserci scenari in cui il guadagno relativamente piccolo si lega alla linea di fondo dell'elaborazione di milioni di chiamate al minuto, aumentando così i risparmi / benefici. Nella maggior parte dei casi, tuttavia, non lo consiglierei.

Una volta che il token di sessione è stato risolto per un utente, è possibile memorizzare nella cache il profilo utente che include i livelli di autorizzazione e confrontarli semplicemente con il livello di autorizzazione richiesto per eseguire la chiamata API.

Per ottenere il livello di autorizzazione di un'API, si analizza l'URI ed estrae la risorsa e l'identificatore REST (se applicabile) e si utilizza l'intestazione HTTP per estrarre il tipo.

Supponiamo ad esempio che tu voglia consentire agli utenti registrati di ottenere il loro profilo tramite un HTTP GET

/ Api / v1 me /

allora questo è come apparirebbe un documento di configurazione delle autorizzazioni in un tale sistema:

{
 "V1 / me": [{
 "Admin": ["get", "put", "post", "delete"]
 }, {
 "Registrati": ["ottieni", "inserisci", "pubblica", "elimina"]
 }, {
 “Read_only”: [“get”]
 }, {
 “Bloccato”: []
 }],
 "Sottoscrivi": [{
 "Admin": ["put", "post", "delete"]
 }, {
 "Registrato": ["post", "elimina"]
 }, {
 "sola lettura": []
 }, {
 “Bloccato”: []
 }]
}

Il lettore dovrebbe notare che questa è una condizione necessaria ma non sufficiente per l'autorizzazione di un accesso ai dati. Finora abbiamo stabilito che il client chiamante è autorizzato a effettuare la chiamata e che l'utente dispone dell'autorizzazione per accedere all'API. Tuttavia, in molti casi dobbiamo anche garantire che l'utente A non possa vedere (o modificare) i dati B dell'utente. Quindi estendiamo la notazione con "get_owner", il che significa che gli utenti autenticati hanno il permesso di eseguire un GET solo se possiede la risorsa. Vediamo come sarebbe la configurazione allora:

{
 "V1 / me": [{
 "Admin": ["get", "put", "post", "delete"]
 }, {
 "Registrati": ["get_owner", "put", "post", "delete"]
 }, {
 “Read_only”: [“get_owner”]
 }, {
 “Bloccato”: []
 }],
 "Sottoscrivi": [{
 "Admin": ["put", "post", "delete"]
 }, {
 “Registrati”: ["put_owner", "post", "delete"]
 }, {
 "sola lettura": []
 }, {
 “Bloccato”: []
 }]
}

Ora un utente registrato può accedere al proprio profilo, leggerlo, modificarlo ma nessun altro può (tranne un amministratore). Allo stesso modo solo il proprietario può aggiornare un invio con:

/ Api / inviare / 

Il potere di questo approccio è che i cambiamenti drastici a ciò che gli utenti possono e non possono fare con i dati possono essere realizzati semplicemente cambiando la configurazione delle autorizzazioni, non sono necessarie modifiche al codice. Pertanto, durante il ciclo di vita del prodotto, il back-end può abbinare le modifiche ai requisiti con un preavviso.

L'applicazione può essere incapsulata in un paio di funzioni che possono essere indipendenti dalla logica aziendale dell'API e implementare e applicare l'autenticazione e l'autorizzazione:

def validateSessionToken (sessionToken: String) UserProfile = {
...
}
def checkPermission (
  method: String,
  risorsa: String,
  utente: UserProfile
) {
...
// genera un'eccezione in caso di errore
}

Questi verrebbero chiamati all'inizio della gestione Spray.io delle chiamate API:

// NOTA: profiloReader e sumbissionWriter sono omessi qui, supponiamo che stiano estendendo un attore di AKKA.
def route =
{
Pathprefix ( "API") {
  // estrae le intestazioni e le informazioni HTTP
  ...
  var user: UserProfile = null
  provare {
    validatedSessionToken (sessionToken)
  } catch (e: Exception) {
    completa (completeWithError (e.getMessage))
  }
  provare {
    checkPermission (metodo, risorsa, utente)
  } catch (e: Exception) {
    completa (completeWithError (e.getMessage))
  }
  Pathprefix ( "v1") {
    percorso ( "me") {
      ottenere {
        completo (profileReader? getUserProfile (user.id))
      }
    }
  } ~
  percorso ( "submit") {
    pubblica {
      entity (as [String]) {=> jsonstr
        val payload = read [SubmitPayload] (jsonstr)
        completo (submissionWriter? sumbit (payload))
      }
    }
  }
  ...
}

Come possiamo vedere, questo approccio mantiene il gestore Spray.io leggibile e di facile manutenzione in quanto separa l'autenticazione / autorizzazione dalla logica di business individuale di ciascuna API. L'applicazione della proprietà dei dati, non mostrata qui, può essere ottenuta passando un valore booleano al livello I / O che imporrebbe quindi la proprietà dei dati dell'utente a livello di persistenza.

Passaggio 2, logica aziendale

La logica di business può essere incapsulata in attori I / O come il parametro submitmission menzionato nello snippet di codice sopra. Questo attore implementerebbe un'operazione di I / O asincrona che esegue prima le scritture su un livello cache, ad esempio Elasticsearch, e poi su un DB preferito. Le scritture DB possono essere ulteriormente disaccoppiate in un incendio e dimenticare la logica che userebbe il recupero basato su log in modo che il client non debba attendere il completamento di queste costose operazioni.

Si noti che questo è un approccio ottimistico senza blocco e l'unico modo per il client di essere sicuro che i dati siano stati scritti sarebbe quello di dare seguito a una lettura. Fino a quel momento, un client mobile dovrebbe operare supponendo che i rispettivi dati memorizzati nella cache siano sporchi.

Questo è un paradigma progettuale molto potente, tuttavia il lettore dovrebbe essere avvisato che con AKKA + Spary.io non è possibile approfondire più di tre livelli nello stack di chiamate degli attori. Ad esempio, se questi sono gli attori nel sistema:

  1. S per il router spray.
  2. A per il gestore API.
  3. B per il gestore I / O.

usando la notazione x? y per indicare che x chiama y richiedendo una richiamata e x! y per indicare che x spara e dimentica y, il seguente funziona:

S ? A! B

Tuttavia questi non:

S ! A! B

S ? A! B! B

In questi due casi tutte le istanze di B vengono distrutte non appena A viene completato in modo così efficace che hai solo una volta la possibilità di impacchettare tutto il tuo calcolo scaricato in un incendio e dimenticare l'attore. Credo che questa sia una limitazione di Spray e non di AKKA e potrebbe essere stata risolta al momento della pubblicazione di questo post.

Infine, I / O e persistenza

Come mostrato sopra, possiamo inserire operazioni di scrittura lente in thread asincroni per mantenere le prestazioni POST / PUT dell'API entro tempi di esecuzione accettabili. Questi di solito variano in decine di secondi o in un centinaio di millisecondi in base al profilo del server e alla quantità di logica che può essere rinviata usando l'approccio fire and forget.

Tuttavia è spesso il caso che legge un numero maggiore di scritture di uno o più ordini di grandezza. Pertanto, un buon approccio alla memorizzazione nella cache è fondamentale per offrire un throughput elevato in generale.

Nota: è vero il contrario per i paesaggi IOT in cui le scritture di dati sensoriali provenienti da nodi supereranno la lettura di diversi ordini di grandezza. In questo caso, il panorama può essere configurato per avere un gruppo di server configurato per eseguire solo scritture da dispositivi IOT, dedicando un altro gruppo di server con specifiche diverse alle chiamate API dai client (front-end). La maggior parte se non tutta la base di codice potrebbe essere condivisa tra queste due classi di server e le funzionalità potrebbero semplicemente essere disattivate tramite la configurazione per prevenire vulnerabilità della sicurezza.

Un approccio popolare consiste nell'utilizzare una cache di memoria come Redis. Redis funziona bene se usato per archiviare i permessi degli utenti per l'autenticazione, cioè dati che non cambiano spesso. Un singolo nodo Redis può memorizzare fino a 250 mi coppie.

Per le letture che devono interrogare la cache abbiamo bisogno di una soluzione diversa. Elasticsearch, un indice in memoria, funziona eccezionalmente bene per i dati geografici o per i dati che possono essere partizionati in tipi. Ad esempio un indice denominato invii con tipi di cani e motocicli può essere facilmente interrogato per ottenere l'ultimo invio (subreddits?) Per determinati argomenti.

Ad esempio, utilizzando la notazione API HTTP di Elasticsearch:

curl -XPOST 'localhost: 9200 / osservazioni / cani / _search? pretty' -d '
{
  "query": {
    "filtrato": {
      "query": {"match_all": {}},
      "filtro": {
        "gamma": {
          "creato": {
            "gte": 1464913588000
          }
        }
      }
    }
  }
}'

restituirebbe tutti i documenti dopo la data specificata in / dogs. Allo stesso modo potremmo cercare tutti i post in / presentazioni / motociclette i cui documenti contengono il lavoro "Ducati".

curl -XPOST 'localhost: 9200 / submission / motorcycles / _search? pretty' -d '
{
  "query": {"match": {"text": "Ducati"}}
}'
Elasticsearch si comporta molto bene per le letture quando l'indice è attentamente progettato e creato prima dell'immissione dei dati. Ciò potrebbe scoraggiarne alcuni poiché uno dei vantaggi di Elasticsearch è la capacità di creare un indice semplicemente pubblicando un documento e lasciando che il motore capisca tipi e strutture di dati. Tuttavia, i vantaggi della definizione della struttura sono superiori ai costi e va notato che la migrazione a un nuovo indice è semplice anche negli ambienti di produzione quando si utilizzano gli alias.

Nota: gli indici Elasticsearch sono implementati come alberi bilanciati, pertanto l'inserimento e l'eliminazione delle operazioni possono essere costose quando l'albero diventa grande. L'inserimento in un indice con decine di milioni di documenti può richiedere fino a decine di secondi, a seconda delle specifiche del server. Questo può rendere la tua scrittura Elasticsearch uno dei processi più lenti nel tuo cloud (a parte le scritture DB, ovviamente). Tuttavia, spingendo la scrittura nel fuoco e dimenticando l'attore di AKKA può migliorare se non risolvere il problema.

conclusioni

Scala + AKKA + Spray.io è uno stack tecnologico molto efficace per la creazione di API REST ad alte prestazioni se sposato con la memorizzazione nella cache della memoria e / o nell'indicizzazione della memoria.

Ho lavorato su un'implementazione non troppo lontana dai concetti descritti qui in cui 2000 hit al minuto per nodo hanno spostato a malapena il carico della CPU oltre l'1%.

Round bonus: apprendimento automatico e altro ancora

L'aggiunta di Elasticsearch allo stack apre le porte all'apprendimento automatico sia in linea che off line, poiché Elasticsearch si integra con Apache Spark. Lo stesso livello di persistenza utilizzato per servire l'API può essere riutilizzato dai moduli di apprendimento automatico, riducendo la codifica, i costi di manutenzione e la complessità dello stack. Infine, Scala ci consente di utilizzare qualsiasi libreria Scala o Java che apre le porte a elaborazioni di dati più sofisticate, sfruttando elementi come Core NLP di Stanford, OpenCV, Spark Mlib e altro.

Link alle tecnologie menzionate in questo post

  1. http://www.scala-lang.org
  2. http://spray.io
  3. e per (2) avere un senso, dai un'occhiata a http://akka.io