Asynkron HTTP-programmering med Play Framework

Java Top

Jeg har lige annonceret det nye Lær foråret kursus med fokus på det grundlæggende i Spring 5 og Spring Boot 2:

>> KONTROLLER KURSEN

1. Oversigt

Ofte er vores webtjenester nødt til at bruge andre webtjenester for at udføre deres arbejde. Det kan være svært at betjene brugeranmodninger, mens man holder en lav responstid. En langsom ekstern service kan øge vores svartid og få vores system til at bunke anmodninger ved hjælp af flere ressourcer. Det er her, en ikke-blokerende tilgang kan være meget nyttigt

I denne vejledning affyrer vi flere asynkrone anmodninger til en tjeneste fra en Play Framework-applikation. Ved at udnytte Java's ikke-blokerende HTTP-kapacitet, vil vi være i stand til problemfrit at forespørge eksterne ressourcer uden at påvirke vores egen hovedlogik.

I vores eksempel udforsker vi Play WebService Library.

2. Play WebService (WS) -biblioteket

WS er ​​et kraftfuldt bibliotek, der leverer asynkrone HTTP-opkald ved hjælp af Java Handling.

Ved hjælp af dette bibliotek sender vores kode disse anmodninger og fortsætter uden at blokere. For at behandle resultatet af anmodningen leverer vi en forbrugende funktion, det vil sige en implementering af Forbruger interface.

Dette mønster deler nogle ligheder med JavaScript's implementering af tilbagekald, Løfter, og asynkroniser / afventer mønster.

Lad os bygge et simpelt Forbruger der logger nogle af svardataene:

ws.url (url) .thenAccept (r -> log.debug ("Thread #" + Thread.currentThread (). getId () + "Request complete: Response code =" + r.getStatus () + "| Svar: "+ r.getBody () +" | Aktuel tid: "+ System.currentTimeMillis ()))

Vores Forbruger logger kun på dette eksempel. Forbrugeren kunne gøre alt, hvad vi har brug for at gøre med resultatet, dog som at gemme resultatet i en database.

Hvis vi ser dybere på bibliotekets implementering, kan vi se, at WS indpakker og konfigurerer Java'er AsyncHttpClient, som er en del af standard JDK og ikke afhænger af Play.

3. Forbered et eksempel på et projekt

For at eksperimentere med rammen, lad os oprette nogle enhedstest for at starte anmodninger. Vi opretter et skeletwebapplikation til at besvare dem og bruge WS-rammen til at fremsætte HTTP-anmodninger.

3.1. Skeletonapplikationen

Først og fremmest opretter vi det oprindelige projekt ved hjælp af sbt nyt kommando:

sbt nyt playframework / play-java-seed.g8

I den nye mappe, vi derefter rediger build.sbt fil og tilføj WS-bibliotekets afhængighed:

libraryDependencies + = javaWs

Nu kan vi starte serveren med sbt løb kommando:

$ sbt-kørsel ... --- (Kørsel af applikationen, automatisk genindlæsning er aktiveret) --- [info] pcsAkkaHttpServer - Lytning til HTTP på / 0: 0: 0: 0: 0: 0: 0: 0: 9000

Når applikationen er startet, kan vi kontrollere, at alt er ok ved at gennemse // localhost: 9000, som åbner Play's velkomstside.

3.2. Testmiljøet

For at teste vores ansøgning bruger vi enhedstestklassen HomeControllerTest.

Først skal vi udvide WithServer som giver serverens livscyklus:

offentlig klasse HomeControllerTest udvides WithServer { 

Tak til sin forælder denne klasse starter nu vores skelet webserver i testtilstand og i en tilfældig port, før du kører testene. Det WithServer klasse stopper også applikationen, når testen er afsluttet.

Dernæst skal vi levere en applikation, der skal køres.

Vi kan skabe det med Guice'S GuiceApplicationBuilder:

@ Override-beskyttet applikation supplyApplication () {returner ny GuiceApplicationBuilder (). Build (); } 

Og endelig konfigurerede vi server-URL'en, der skal bruges i vores tests ved hjælp af det portnummer, der er leveret af testserveren:

@ Override @ Før offentlig opsætning af ugyldigt () {OptionalInt optHttpsPort = testServer.getRunningHttpsPort (); hvis (optHttpsPort.isPresent ()) {port = optHttpsPort.getAsInt (); url = "// localhost:" + port; } andet {port = testServer.getRunningHttpPort () .getAsInt (); url = "// localhost:" + port; }}

Nu er vi klar til at skrive prøver. Den omfattende testramme lader os koncentrere os om kodning af vores testanmodninger.

4. Forbered en WSRequest

Lad os se, hvordan vi kan affyre grundlæggende typer anmodninger, såsom GET eller POST, og anmodninger om flerdelt om filupload.

4.1. Initialiser WSRequest Objekt

Først og fremmest skal vi få en WSClient instans for at konfigurere og initialisere vores anmodninger.

I et virkeligt program kan vi få en klient, der automatisk konfigureres med standardindstillinger via afhængighedsinjektion:

@Autowired WSClient ws;

I vores testklasse bruger vi dog WSTestClient, tilgængelig fra Play Test-rammen:

WSClient ws = play.test.WSTestClient.newClient (port);

Når vi har vores klient, kan vi initialisere en WSRequest objekt ved at kalde url metode:

ws.url (url)

Det url metoden gør nok til, at vi kan affyre en anmodning. Vi kan dog tilpasse det yderligere ved at tilføje nogle brugerdefinerede indstillinger:

ws.url (url) .addHeader ("nøgle", "værdi") .addQueryParameter ("num", "" + num);

Som vi kan se, er det ret nemt at tilføje overskrifter og forespørgselsparametre.

Når vi har konfigureret vores anmodning fuldt ud, kan vi kalde metoden for at starte den.

4.2. Generisk GET-anmodning

For at udløse en GET-anmodning skal vi bare ringe til metode på vores WSRequest objekt:

ws.url (url) ... .get ();

Da dette er en ikke-blokerende kode, starter den anmodningen og fortsætter derefter udførelsen på den næste linje i vores funktion.

Objektet vendte tilbage er en FuldførelseStage eksempel, som er en del af Fuldført API.

Når HTTP-opkaldet er afsluttet, udfører denne fase kun et par instruktioner. Det indpakker svaret i en WSResponse objekt.

Normalt vil dette resultat blive videregivet til næste trin i henrettelseskæden. I dette eksempel har vi ikke leveret nogen forbrugende funktion, så resultatet går tabt.

Af denne grund er denne anmodning af typen "fyr-og-glem".

4.3. Indsend en formular

Indsendelse af en formular er ikke særlig forskellig fra eksempel.

For at udløse anmodningen kalder vi bare på stolpe metode:

ws.url (url) ... .setContentType ("application / x-www-form-urlencoded") .post ("key1 = value1 & key2 = value2");

I dette scenarie er vi nødt til at videregive et organ som en parameter. Dette kan være en simpel streng som en fil, et json- eller xml-dokument, en BodyWritable eller a Kilde.

4.4. Indsend en multipart- / formulardata

En formular med flere dele kræver, at vi sender både inputfelter og data fra en vedhæftet fil eller stream.

For at implementere dette i rammen bruger vi stolpe metode med en Kilde.

Inde i kilden kan vi pakke alle de forskellige datatyper, der er nødvendige i vores formular:

Kildefil = FileIO.fromPath (Paths.get ("hello.txt")); FilePart fil = ny FilePart ("fileParam", "myfile.txt", "text / plain", fil); DataPart data = ny DataPart ("nøgle", "værdi"); ws.url (url) ... .post (Source.from (Arrays.asList (fil, data)));

Selvom denne tilgang tilføjer noget mere konfiguration, ligner den stadig meget de andre typer anmodninger.

5. Behandl Async-svaret

Indtil dette tidspunkt har vi kun udløst brand-og-glem-anmodninger, hvor vores kode ikke gør noget med svardataene.

Lad os nu undersøge to teknikker til behandling af et asynkront svar.

Vi kan enten blokere hovedtråden og vente på en Fuldførende fremtid eller forbruge asynkront med en Forbruger.

5.1. Processvar ved at blokere med Fuldført

Selv når vi bruger en asynkron ramme, kan vi vælge at blokere vores kodes udførelse og vente på svaret.

Bruger Fuldført API, vi har kun brug for et par ændringer i vores kode for at implementere dette scenario:

WSResponse respons = ws.url (url) .get () .toCompletableFuture () .get ();

Dette kan f.eks. Være nyttigt at give en stærk datakonsistens, som vi ikke kan opnå på andre måder.

5.2. Behandl svar asynkront

For at behandle et asynkront svar uden at blokere, vi leverer en Forbruger eller Fungere der køres af den asynkrone ramme, når svaret er tilgængeligt.

Lad os f.eks. Tilføje en Forbruger til vores tidligere eksempel for at logge svaret:

ws.url (url) .addHeader ("nøgle", "værdi") .addQueryParameter ("num", "" + 1). get () .thenAccept (r -> log.debug ("Tråd #" + tråd. currentThread (). getId () + "Request complete: Response code =" + r.getStatus () + "| Response:" + r.getBody () + "| Aktuel tid:" + System.currentTimeMillis ()));

Vi ser derefter svaret i logfilerne:

[debug] c.HomeControllerTest - Tråd # 30 Anmodning udført: Svarskode = 200 | Svar: {"Resultat": "ok", "Params": {"num": ["1"]}, "Headers": {"accept": ["* / *"], "host": [" localhost: 19001 "]," key ": [" value "]," user-agent ": [" AHC / 2.1 "]}} | Aktuel tid: 1579303109613

Det er værd at bemærke, at vi brugte derefter accepterer, som kræver en Forbruger funktion, da vi ikke har brug for at returnere noget efter logning.

Når vi ønsker, at den nuværende fase skal returnere noget, så vi kan bruge det i næste trin, har vi brug for derefterAnvend i stedet, hvilket tager en Fungere.

Disse bruger konventionerne i standard Java Functional Interfaces.

5.3. Stor reaktionskrop

Den kode, vi hidtil har implementeret, er en god løsning til små svar og de fleste brugssager. Men hvis vi har brug for at behandle et par hundrede megabyte data, har vi brug for en bedre strategi.

Vi skal bemærke: Anmod om metoder som og stolpe indlæse hele svaret i hukommelsen.

For at undgå en mulig OutOfMemoryError, kan vi bruge Akka Streams til at behandle svaret uden at lade det fylde vores hukommelse.

For eksempel kan vi skrive dens krop i en fil:

ws.url (url) .stream () .thenAccept (svar -> {prøv {OutputStream outputStream = Files.newOutputStream (sti); Sink outputWriter = Sink.foreach (bytes -> outputStream.write (bytes.toArray ())); respons.getBodyAsSource (). runWith (outputWriter, materializer); } catch (IOException e) {log.error ("Der opstod en fejl under åbning af outputstrømmen", e); }});

Det strøm metode returnerer a FuldførelseStage hvor er WSResponse har en getBodyAsStream metode, der giver en Kilde.

Vi kan fortælle koden, hvordan man behandler denne type krop ved hjælp af Akkas Håndvask, som i vores eksempel simpelthen skriver data, der passerer igennem i OutputStream.

5.4. Timeouts

Når vi bygger en anmodning, kan vi også indstille en bestemt timeout, så anmodningen afbrydes, hvis vi ikke modtager det komplette svar i tide.

Dette er en særlig nyttig funktion, når vi ser, at en tjeneste, vi forespørger om, er særlig langsom og kan forårsage en ophobning af åbne forbindelser, der sidder fast og venter på svaret.

Vi kan indstille en global timeout for alle vores anmodninger ved hjælp af tuningparametre. For en anmodningsspecifik timeout kan vi føje til en anmodning ved hjælp af setRequestTimeout:

ws.url (url) .setRequestTimeout (Duration.of (1, SECONDS));

Der er dog stadig en sag at håndtere: Vi har muligvis modtaget alle data, men vores Forbruger kan behandle det meget langsomt. Dette kan ske, hvis der er masser af dataknusing, databaseopkald osv.

I systemer med lav kapacitet kan vi simpelthen lade koden køre, indtil den er færdig. Vi ønsker dog muligvis at afbryde langvarige aktiviteter.

For at opnå det er vi nødt til at pakke vores kode med nogle futures håndtering.

Lad os simulere en meget lang proces i vores kode:

ws.url (url) .get () .thenApply (result -> {try {Thread.sleep (10000L); return Results.ok ();} catch (InterruptedException e) {return Results.status (SERVICE_UNAVAILABLE);}} );

Dette vil returnere en Okay svar efter 10 sekunder, men vi vil ikke vente så længe.

I stedet for med tiden er gået indpakning, vi instruerer vores kode om at vente ikke mere end 1 sekund:

CompletionStage f = futures.timeout (ws.url (url) .get () .thenApply (result -> {try {Thread.sleep (10000L); return Results.ok ();} catch (InterruptedException e) {return Results. status (SERVICE_UNAVAILABLE);}}), 1L, TimeUnit.SECONDS); 

Nu vil vores fremtid returnere et resultat på begge måder: beregningsresultatet, hvis det Forbruger færdig i tide eller undtagelsen på grund af futures tiden er gået.

5.5. Håndtering af undtagelser

I det foregående eksempel oprettede vi en funktion, der enten returnerer et resultat eller mislykkes med en undtagelse. Så nu skal vi håndtere begge scenarier.

Vi kan håndtere både succes og fiasko scenarier med handleAsync metode.

Lad os sige, at vi vil returnere resultatet, hvis vi har det, eller logge fejlen og returnere undtagelsen til videre håndtering:

CompletionStage res = f.handleAsync ((resultat, e) -> {if (e! = Null) {log.error ("Undtagelse kastet", e); returner e.getCause ();} ellers {returresultat;}} ); 

Koden skal nu returnere a FuldførelseStage indeholdende TimeoutException kastet.

Vi kan bekræfte det ved blot at ringe til en hævderLige på klassen for det returnerede undtagelsesobjekt:

Class clazz = res.toCompletableFuture (). Get (). GetClass (); assertEquals (TimeoutException.class, clazz);

Når du kører testen, logger den også den undtagelse, vi modtog:

[fejl] c.HomeControllerTest - Undtagelse kastet java.util.concurrent.TimeoutException: Timeout efter 1 sekund ...

6. Anmod om filtre

Nogle gange er vi nødt til at køre nogle logik, før en anmodning udløses.

Vi kunne manipulere WSRequest objekt en gang initialiseret, men en mere elegant teknik er at indstille en WSRequestFilter.

Et filter kan indstilles under initialiseringen, inden der kaldes på udløsermetoden, og er knyttet til anmodningslogikken.

Vi kan definere vores eget filter ved at implementere WSRequestFilter interface, eller vi kan tilføje en færdiglavet.

Et almindeligt scenario er at logge, hvordan anmodningen ser ud, før den udføres.

I dette tilfælde skal vi bare indstille AhcCurlRequestLogger:

ws.url (url) ... .setRequestFilter (ny AhcCurlRequestLogger ()) ... .get ();

Den resulterende log har en krølle-lignende format:

[info] p.l.w.a.AhcCurlRequestLogger - curl \ --verbose \ --quest GET \ --header 'key: value' \ '// localhost: 19001'

Vi kan indstille det ønskede logniveau ved at ændre vores logback.xml konfiguration.

7. Caching-svar

WSClient understøtter også caching af svar.

Denne funktion er især nyttig, når den samme anmodning udløses flere gange, og vi ikke har brug for de friskeste data hver gang.

Det hjælper også, når den service, vi ringer til, midlertidigt er nede.

7.1. Tilføj Caching-afhængigheder

For at konfigurere caching skal vi først tilføje afhængigheden i vores build.sbt:

biblioteksafhængighed + = ehcache

Dette konfigurerer Ehcache som vores cachelag.

Hvis vi ikke ønsker Ehcache specifikt, kan vi bruge enhver anden JSR-107-cacheimplementering.

7.2. Force Caching Heuristic

Play WS cachelager som standard ikke HTTP-svar, hvis serveren ikke returnerer nogen caching-konfiguration.

For at omgå dette kan vi tvinge den heuristiske caching ved at tilføje en indstilling til vores application.conf:

play.ws.cache.heuristics.enabled = sand

Dette konfigurerer systemet til at beslutte, hvornår det er nyttigt at cache et HTTP-svar, uanset fjerntjenestens annoncerede caching.

8. Yderligere indstilling

Forespørgsler til en ekstern tjeneste kan kræve en vis klientkonfiguration. Vi bliver muligvis nødt til at håndtere omdirigeringer, en langsom server eller en eller anden filtrering afhængigt af brugeragentens overskrift.

For at løse dette kan vi tune vores WS-klient ved hjælp af egenskaber i vores application.conf:

play.ws.followRedirects = falsk play.ws.useragent = MyPlayApplication play.ws.compressionEnabled = true # tid til at vente på, at forbindelsen oprettes play.ws.timeout.connection = 30 # tid til at vente på data, efter at forbindelsen er åben play.ws.timeout.idle = 30 # maks. tilgængelig tid til at fuldføre anmodningen play.ws.timeout.request = 300

Det er også muligt at konfigurere det underliggende AsyncHttpClient direkte.

Den fulde liste over tilgængelige ejendomme kan kontrolleres i kildekoden til AhcConfig.

9. Konklusion

I denne artikel udforskede vi Play WS-biblioteket og dets vigtigste funktioner. Vi konfigurerede vores projekt, lærte at affyre almindelige anmodninger og behandle deres svar, både synkront og asynkront.

Vi arbejdede med store data-downloads og så, hvordan vi kunne kortlægge langvarige aktiviteter.

Endelig kiggede vi på caching for at forbedre ydeevnen, og hvordan man indstiller klienten.

Som altid er kildekoden til denne tutorial tilgængelig på GitHub.

Java bund

Jeg har lige annonceret det nye Lær foråret kursus med fokus på det grundlæggende i Spring 5 og Spring Boot 2:

>> KONTROLLER KURSEN