de Miguel Lopez

O introducere la rutare HTTP Akka

O introducere la rutare HTTP Akka

DSL-ul de rutare al Akka HTTP ar putea părea complicat la început, dar odată ce îl veți obține, veți vedea cât de puternic este.

În acest tutorial ne vom concentra pe crearea de rute și structura acestora. Nu vom acoperi analiza către și de la JSON, avem alte tutoriale care acoperă subiectul respectiv.

Ce sunt directivele?

Unul dintre primele concepte pe care le vom găsi atunci când învățăm Akka HTTP pe partea de server (există și o bibliotecă pe partea de client) este directivelor.

Deci, ce sunt?

ad-banner

Vă puteți gândi la ele ca la blocuri, piese Lego, dacă doriți, pe care le puteți folosi pentru a vă construi traseele. Acestea sunt compozibile, ceea ce înseamnă că putem crea directive peste alte directive.

Dacă doriți o lectură mai aprofundată, nu ezitați să verificați Documentația oficială a Akka HTTP.

Înainte de a merge mai departe, să discutăm ce vom construi.

API de tip blog

Vom crea un eșantion de API orientat către public pentru un blog, unde le vom permite utilizatorilor să:

  • interogați o listă de tutoriale
  • interogați un singur tutorial după ID
  • interogați lista de comentarii dintr-un tutorial
  • adăugați comentarii la un tutorial

Obiectivele vor fi:

- List all tutorials GET /tutorials 
- Create a tutorial GET /tutorials/:id 
- Get all comments in a tutorial GET /tutorials/:id/comments 
- Add a comment to a tutorial POST /tutorials/:id/comments

Vom implementa doar punctele finale, fără logică în ele. În acest fel vom învăța cum să creăm această structură și capcanele comune atunci când începem cu Akka HTTP.

Configurare proiect

Am creat un repo pentru acest tutorial, în el veți găsi o ramură pe fiecare secțiune care necesită codificare. Simțiți-vă liber să o clonați și să o utilizați ca proiect de bază sau chiar să schimbați între ramuri pentru a privi diferențele.

În caz contrar, creați un nou proiect SBT, apoi adăugați dependențele în build.sbt fişier:

name := "akkahttp-routing-dsl" 
version := "0.1" 
scalaVersion := "2.12.7" 
val akkaVersion = "2.5.17" val akkaHttpVersion = "10.1.5" 
libraryDependencies ++= Seq(   "com.typesafe.akka" %% "akka-actor" % akkaVersion,   "com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test,  "com.typesafe.akka" %% "akka-stream" % akkaVersion,   "com.typesafe.akka" %% "akka-stream-testkit" % akkaVersion % Test,   "com.typesafe.akka" %% "akka-http" % akkaHttpVersion,   "com.typesafe.akka" %% "akka-http-testkit" % akkaHttpVersion % Test,      "org.scalatest" %% "scalatest" % "3.0.5" % Test )

Am adăugat Akka HTTP și dependențele sale, Akka Actor și Streams. Și vom folosi, de asemenea, Scalatest pentru testare.

Listarea tuturor tutorialelor

Vom adopta o abordare TDD pentru a ne construi ierarhia directivă, creând mai întâi testele pentru a ne asigura că atunci când nu ne rupem rutele atunci când adăugăm altele. Adoptarea acestei abordări este destul de utilă atunci când începeți cu Akka HTTP.

Să începem cu ruta noastră de listare a tuturor tutorialelor. Creați un fișier nou sub src/test/scala (dacă folderele nu există, creați-le) numite RouterSpec:

import akka.http.scaladsl.testkit.ScalatestRouteTest import org.scalatest.{Matchers, WordSpec} 
class RouterSpec extends WordSpec with Matchers with ScalatestRouteTest { 
}

WordSpec și Matchers sunt furnizate de Scalatest și le vom folosi pentru a ne structura testele și afirmațiile. ScalatestRouteTest este o trăsătură oferită de kit-ul de testare al Akka HTTP, ne va permite să ne testăm rutele într-un mod convenabil. Să vedem cum putem realiza asta.

Pentru că folosim Scalatest’s WordSpec, vom începe prin a crea un domeniu pentru Router obiect pe care îl vom crea în curând și primul test:

"A Router" should {   "list all tutorials" in {   } }

Apoi, vrem să ne asigurăm că putem trimite o cerere GET către cale /tutorials și să obținem răspunsul pe care îl așteptăm, să vedem cum putem realiza acest lucru:

Get("/tutorials") ~> Router.route ~> check {   status shouldBe StatusCodes.OK   responseAs[String] shouldBe "all tutorials" }

Nici măcar nu se va compila pentru că noi nu am creat-o Router obiect. Să facem asta acum.

Creați un nou obiect Scala sub src/main/scala numit Router. În el vom crea o metodă care va returna un Route:

import akka.http.scaladsl.server.Route 
object Router {
  def route: Route = ???
}

Nu vă faceți griji prea mult pentru ???, este doar un substituent pentru a evita temporar erorile de compilare. Cu toate acestea, dacă acel cod este executat, acesta va arunca un NotImplementedError după cum vom vedea în curând.

Acum că testele și proiectul nostru sunt compilate, haideți să rulăm testele (Faceți clic dreapta pe specificație și „Rulați„ RouterSpec ””).

Testul a eșuat cu excepția pe care o așteptam, nu ne-am implementat traseele. Sa incepem!

Crearea traseului listării

Privind în documentație oficială vedem că traseul începe cu path directivă. Să imităm ceea ce fac și să construim traseul nostru:

import akka.http.scaladsl.server.{Directives, Route}
object Router extends Directives {
  def route: Route = path("tutorials") {    get {      complete("all tutorials")    }  }}

Pare rezonabil, haideți să rulăm specificațiile noastre. Și trece, minunat!

Pentru referință, întregul nostru RouterSpec acum arată ca:

import akka.http.scaladsl.model.StatusCodesimport akka.http.scaladsl.testkit.ScalatestRouteTestimport org.scalatest.{Matchers, WordSpec}class RouterSpec extends WordSpec with Matchers with ScalatestRouteTest {  "A Router" should {    "list all tutorials" in {      Get("/tutorials") ~> Router.route ~> check {        status shouldBe StatusCodes.OK        responseAs[String] shouldBe "all tutorials"      }    }  }}

Obținerea unui singur tutorial după ID

În continuare, vom permite utilizatorilor noștri să preia un singur tutorial.

Să adăugăm un test pentru noul nostru traseu:

"return a single tutorial by id" in {  Get("/tutorials/hello-world") ~> Router.route ~> check {    status shouldBe StatusCodes.OK    responseAs[String] shouldBe "tutorial hello-world"  }}

Ne așteptăm să primim înapoi un mesaj care include ID-ul tutorialului.

Testul va eșua pentru că nu ne-am creat ruta, hai să facem asta acum.

Din același lucru resursă folosim mai devreme pentru a ne baza traseul, putem vedea cum putem plasa mai multe directive la același nivel în ierarhie folosind ~ directivă.

Va trebui să ne cuibărim path directive pentru că au nevoie de un alt segment după /tutorials ruta pentru ID-ul tutorialului. În documentația pe care o folosesc IntNumber pentru a extrage un număr din cale, dar vom folosi un șir și pentru asta vom folosi can Segment in schimb.

Traseul nostru arată ca:

def route: Route = path("tutorials") {  get {    complete("all tutorials")  } ~ path(Segment) { id =>    get {      complete(s"tutorial $id")    }  }}

Să rulăm testele. Și ar trebui să primiți o eroare similară:

Request was rejectedScalaTestFailureLocation: RouterSpec at (RouterSpec.scala:17)org.scalatest.exceptions.TestFailedException: Request was rejected

Ce se întâmplă?!

Ei bine, o cerere este respinsă atunci când nu corespunde ierarhiei noastre cu directive. Acesta este unul dintre lucrurile care m-au prins la început.

Acum este probabil un moment bun pentru a analiza modul în care aceste directive se potrivesc cu cererea primită pe măsură ce trece prin ierarhie.

Am văzut că diferite directive se vor potrivi cu diferite aspecte ale unei cereri primite path și get, una se potrivește cu adresa URL a cererii și cealaltă cu metoda. Dacă o cerere se potrivește cu o directivă, aceasta va intra în ea, dacă nu, va continua cu următoarea. Acest lucru ne spune, de asemenea, că ordinea contează. Dacă nu corespunde niciunei directive, cererea este respinsă.

Acum, că acum, când cererea noastră nu corespunde directivelor noastre, să începem să analizăm de ce.

Dacă căutăm documentația pentru path directivă (Cmd + Click pe Mac) vom găsi:

/** * Applies the given [[PathMatcher]] to the remaining unmatched path after consuming a leading slash. * The matcher has to match the remaining path completely. * If matched the value extracted by the [[PathMatcher]] is extracted on the directive level. * * @group path */

Asa ca path directiva trebuie să se potrivească exact cu calea, adică prima noastră path directiva se va potrivi doar /tutorials și niciodată /tutorials/:id.

În același PathDirectives trăsătură care conține path directivă putem vedea o altă directivă numită pathPrefix:

/** * Applies the given [[PathMatcher]] to a prefix of the remaining unmatched path after consuming a leading slash. * The matcher has to match a prefix of the remaining path. * If matched the value extracted by the PathMatcher is extracted on the directive level. * * @group path */

pathPrefix se potrivește doar cu un prefix și îl elimină. Se pare că acesta este ceea ce căutăm, să ne actualizăm traseele:

def route: Route = pathPrefix("tutorials") {  get {    complete("all tutorials")  } ~ path(Segment) { id =>    get {      complete(s"tutorial $id")    }  }}

Rulați testele și … primim o altă eroare. ?

"[all tutorials]" was not equal to "[tutorial hello-world]"ScalaTestFailureLocation: RouterSpec at (RouterSpec.scala:18)Expected :"[tutorial hello-world]"Actual   :"[all tutorials]"

Se pare că solicitarea noastră s-a potrivit cu prima get directivă. Acum se potrivește cu pathPrefix, și pentru că este, de asemenea, o solicitare GET, se va potrivi cu prima get directivă. Comanda contează.

Există câteva lucruri pe care le putem face. Cea mai simplă soluție ar fi mutarea primei get Cu toate acestea, ar trebui să ne amintim acest lucru sau să îl documentăm. Nu ideal.

Personal, prefer să evit astfel de soluții și, în schimb, să clarific intenția prin cod. Dacă ne uităm în PathDirectives trăsătură de mai devreme, vom găsi o directivă numită pathEnd:

/** * Rejects the request if the unmatchedPath of the [[RequestContext]] is non-empty, * or said differently: only passes on the request to its inner route if the request path * has been matched completely. * * @group path */

Exact asta vrem, așa că hai să încheiem primul nostru get directivă cu pathEnd:

def route: Route = pathPrefix("tutorials") {  pathEnd {    get {      complete("all tutorials")    }  } ~ path(Segment) { id =>    get {      complete(s"tutorial $id")    }  }}

Rulați din nou testele și … în cele din urmă, testele trec! ?

Listarea tuturor comentariilor într-un tutorial

Să punem în practică ceea ce am învățat despre traseele de cuibărit, ducându-l puțin mai departe.

Mai întâi testul:

"list all comments of a given tutorial" in {  Get("/tutorials/hello-world/comments") ~> Router.route ~> check {    status shouldBe StatusCodes.OK    responseAs[String] shouldBe "comments for the hello-world tutorial"  }}

Este un caz similar cu cel anterior: știm că va trebui să plasăm un traseu lângă altul, ceea ce înseamnă că trebuie:

  • schimba path(Segmenter) la pathPrefix(Segmenter)
  • înfășurați primul get cu pathEnd directivă
  • amplasați noul traseu lângă pathEnd

Traseele noastre ajung să arate ca:

def route: Route = pathPrefix("tutorials") {  pathEnd {    get {      complete("all tutorials")    }  } ~ pathPrefix(Segment) { id =>    pathEnd {      get {        complete(s"tutorial $id")      }    } ~ path("comments") {      get {        complete(s"comments for the $id tutorial")      }    }  }}

Rulați testele și ar trebui să treacă! ?

Adăugarea de comentarii la un tutorial

Ultimul nostru punct final este similar cu precedentul, dar se va potrivi cu solicitările POST. Vom folosi acest exemplu pentru a vedea diferența dintre implementarea și testarea unei cereri GET față de o solicitare POST.

Testul:

"add comments to a tutorial" in {  Post("/tutorials/hello-world/comments", "new comment") ~> Router.route ~> check {    status shouldBe StatusCodes.OK    responseAs[String] shouldBe "added the comment 'new comment' to the hello-world tutorial"  }}

Folosim Post metoda în loc de Get am folosit-o și îi oferim un parametru suplimentar, care este corpul cererii. Restul ne este familiar acum.

Pentru a implementa ultima noastră rută, ne putem referi la documentație și uită-te la cum se face de obicei.

Noi avem un post directivă la fel cum avem o get unu. Pentru a extrage corpul cererii avem nevoie de două directive, entity și as, la care furnizăm tipul pe care îl așteptăm. În cazul nostru este un șir.

Să încercăm:

post {  entity(as[String]) { comment =>    complete(s"added the comment '$comment' to the $id tutorial")  }}

Pare rezonabil. Extragem corpul cererii ca un șir și îl folosim în răspunsul nostru. Să o adăugăm la a noastră route metoda de lângă ruta anterioară la care am lucrat:

def route: Route = pathPrefix("tutorials") {  pathEnd {    get {      complete("all tutorials")    }  } ~ pathPrefix(Segment) { id =>    pathEnd {      get {        complete(s"tutorial $id")      }    } ~ path("comments") {      get {        complete(s"comments for the $id tutorial")      } ~ post {        entity(as[String]) { comment =>          complete(s"added the comment '$comment' to the $id tutorial")        }      }    }  }}

Dacă doriți să aflați cum să analizați cursurile Scala de la și de la JSON avem și tutoriale pentru asta.

Rulați testele și toate ar trebui să treacă.

Concluzie

DSL-ul de rutare al Akka HTTP ar putea părea confuz la început, dar după ce a depășit unele denivelări, face doar clic. După un timp va veni natural și poate fi foarte puternic.

Am învățat cum să ne structurăm traseele, dar mai important, am învățat cum să creăm acea structură ghidată de teste care ne vor asigura că nu le vom rupe la un moment dat în viitor.

Chiar dacă am lucrat doar la patru obiective finale, am ajuns la o structură oarecum complicată și profundă. Rămâneți la curent și vom explora diferite moduri de a ne simplifica traseele și de a le face mai ușor de gestionat!

Aflați cum să creați API-uri REST cu Scala și Akka HTTP cu acest curs gratuit pas cu pas!

Publicat inițial la www.codemunity.io.