Java >> Java tutorial >  >> Tag >> HTTP

Asynkron HTTP-programmering med Play Framework

1. Oversigt

Ofte skal vores webtjenester bruge andre webtjenester for at kunne udføre deres arbejde. Det kan være svært at betjene brugeranmodninger, samtidig med at man holder en lav svartid. En langsom ekstern service kan øge vores responstid og få vores system til at hobe sig op for anmodninger ved at bruge flere ressourcer. Det er her en ikke-blokerende tilgang kan være meget nyttig

I denne vejledning affyrer vi flere asynkrone anmodninger til en tjeneste fra en Play Framework-applikation. Ved at udnytte Javas 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 vil vi udforske Play WebService-biblioteket.

2. Play WebService (WS) biblioteket

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

Ved at bruge 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 Forbrugeren grænseflade.

Dette mønster deler nogle ligheder med JavaScripts implementering af tilbagekald, Promises, og async/await mønster.

Lad os bygge en simpel Forbruger der logger nogle af svardataene:

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

Vores Forbruger er blot at logge ind i dette eksempel. Forbrugeren kan dog gøre alt, hvad vi skal gøre med resultatet, som at gemme resultatet i en database.

Hvis vi ser dybere ind i bibliotekets implementering, kan vi observere, at WS ombryder og konfigurerer Javas AsyncHttpClient , som er en del af standard JDK og ikke afhænger af Play.

3. Forbered et eksempelprojekt

For at eksperimentere med rammen, lad os oprette nogle enhedstests for at starte anmodninger. Vi opretter en skelet-webapplikation til at besvare dem og bruger WS-rammen til at lave HTTP-anmodninger.

3.1. The Skeleton Web Application

Først og fremmest opretter vi det indledende projekt ved at bruge sbt new kommando:

sbt new playframework/play-java-seed.g8

I den nye mappe redigerer vi derefter build.sbt fil og tilføj WS-biblioteksafhængigheden:

libraryDependencies += javaWs

Nu kan vi starte serveren med sbt run kommando:

$ sbt run
...
--- (Running the application, auto-reloading is enabled) ---

[info] p.c.s.AkkaHttpServer - Listening for HTTP on /0:0:0:0:0:0:0:0:9000

Når applikationen er startet, kan vi kontrollere, at alt er i orden ved at browse http://localhost:9000 , som åbner Plays velkomstside.

3.2. Testmiljøet

For at teste vores applikation bruger vi enhedstestklassen HomeControllerTest .

Først skal vi udvide WithServer som vil give serverens livscyklus:

public class HomeControllerTest extends WithServer {

Takket være sin forælder, denne klasse starter nu vores skelet-webserver i testtilstand og på en tilfældig port , før du kører testene. WithServer klasse stopper også applikationen, når testen er færdig.

Dernæst skal vi levere et program til at køre.

Vi kan oprette det med Guice 's GuiceApplicationBuilder :

@Override
protected Application provideApplication() {
    return new GuiceApplicationBuilder().build();
}

Og til sidst sætter vi server-URL'en op, som skal bruges i vores tests, ved hjælp af portnummeret fra testserveren:

@Override
@Before
public void setup() {
    OptionalInt optHttpsPort = testServer.getRunningHttpsPort();
    if (optHttpsPort.isPresent()) {
        port = optHttpsPort.getAsInt();
        url = "https://localhost:" + port;
    } else {
        port = testServer.getRunningHttpPort()
          .getAsInt();
        url = "http://localhost:" + port;
    }
}

Nu er vi klar til at skrive prøver. Den omfattende testramme giver os mulighed for at koncentrere os om at kode vores testanmodninger.

4. Forbered en WSRequest

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

4.1. Initialiser WSRequest Objekt

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

I en virkelig applikation kan vi få en klient, automatisk konfigureret med standardindstillinger, via afhængighedsinjektion:

@Autowired
WSClient ws;

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

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)

webadressen metode gør nok til at give os mulighed for at affyre en anmodning. Vi kan dog tilpasse det yderligere ved at tilføje nogle brugerdefinerede indstillinger:

ws.url(url)
  .addHeader("key", "value")
  .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 blot kalde get 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å næste linje i vores funktion.

Objektet returneret af get er et CompletionStage forekomst , som er en del af CompletableFuture API.

Når HTTP-kaldet er afsluttet, udfører dette trin kun nogle få instruktioner. Den omslutter svaret i et WSResponse objekt.

Normalt ville dette resultat blive videregivet til næste fase af udførelseskæden. I dette eksempel har vi ikke leveret nogen forbrugende funktion, så resultatet er tabt.

Af denne grund er denne anmodning af typen "fire-and-forget".

4.3. Indsend en formular

Indsendelse af en formular er ikke meget forskellig fra get eksempel.

For at udløse anmodningen kalder vi bare opslaget metode:

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

I dette scenarie skal vi videregive en krop som en parameter. Dette kan være en simpel streng som en fil, et json- eller xml-dokument, en BodyWritable eller en 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 indlægget metode med en Kilde .

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

Source<ByteString, ?> file = FileIO.fromPath(Paths.get("hello.txt"));
FilePart<Source<ByteString, ?>> file = 
  new FilePart<>("fileParam", "myfile.txt", "text/plain", file);
DataPart data = new DataPart("key", "value");

ws.url(url)
...
  .post(Source.from(Arrays.asList(file, data)));

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

5. Behandle det asynkrone svar

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

Lad os nu udforske to teknikker til at behandle et asynkront svar.

Vi kan enten blokere hovedtråden og vente på en CompletableFuture, eller forbruge asynkront med en Forbruger .

5.1. Processvar ved at blokere med CompletableFuture

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

Brug af CompletableFuture API, vi mangler blot et par ændringer i vores kode for at implementere dette scenario:

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

Dette kan for eksempel være nyttigt for at give en stærk datakonsistens, som vi ikke kan opnå på andre måder.

5.2. Processvar asynkront

For at behandle et asynkront svar uden at blokere, vi leverer en Forbruger eller Funktion 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("key", "value")
  .addQueryParameter("num", "" + 1)
  .get()
  .thenAccept(r -> 
    log.debug("Thread#" + Thread.currentThread().getId() 
      + " Request complete: Response code = " + r.getStatus() 
      + " | Response: " + r.getBody() 
      + " | Current Time:" + System.currentTimeMillis()));

Vi ser så svaret i loggene:

[debug] c.HomeControllerTest - Thread#30 Request complete: Response code = 200 | Response: {
  "Result" : "ok",
  "Params" : {
    "num" : [ "1" ]
  },
  "Headers" : {
    "accept" : [ "*/*" ],
    "host" : [ "localhost:19001" ],
    "key" : [ "value" ],
    "user-agent" : [ "AHC/2.1" ]
  }
} | Current Time:1579303109613

Det er værd at bemærke, at vi brugte thenAccept , hvilket kræver en Forbruger funktion, da vi ikke behøver at returnere noget efter logning.

Når vi ønsker, at det nuværende trin skal returnere noget, så vi kan bruge det i næste trin, skal vi såAnvend i stedet, som tager en Funktion .

Disse bruger konventionerne for standard Java Functional Interfaces.

5.3. Stort svarlegeme

Den kode, vi har implementeret indtil videre, er en god løsning til små svar og de fleste use cases. Men hvis vi skal behandle nogle få hundrede megabyte data, har vi brug for en bedre strategi.

Vi bør bemærke:Anmodningsmetoder som get og indlæg indlæs 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(
    response -> {
        try {
            OutputStream outputStream = Files.newOutputStream(path);
            Sink<ByteString, CompletionStage<Done>> outputWriter =
              Sink.foreach(bytes -> outputStream.write(bytes.toArray()));
            response.getBodyAsSource().runWith(outputWriter, materializer);
        } catch (IOException e) {
            log.error("An error happened while opening the output stream", e);
        }
    });

strømmen metoden returnerer en CompletionStage hvor WSResponse har en getBodyAsStream metode, der giver en Source .

Vi kan fortælle koden, hvordan man behandler denne type krop ved at bruge Akkas Sink , som i vores eksempel blot vil skrive alle data, der passerer igennem i OutputStream .

5.4. Timeouts

Når vi opbygger en anmodning, kan vi også indstille en specifik 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 på, 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 tuning-parametre. For en anmodningsspecifik timeout kan vi tilføje til en anmodning ved hjælp af setRequestTimeout :

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

Der er dog stadig én sag at behandle:Vi har muligvis modtaget alle data, men vores Forbruger kan være meget langsom at behandle det. Dette kan ske, hvis der er masser af dataknas, databasekald osv.

I systemer med lav gennemstrømning kan vi blot lade koden køre, indtil den er færdig. Vi kan dog ønske at afbryde langvarige aktiviteter.

For at opnå det er vi nødt til at pakke vores kode ind 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 returnerer et OK svar efter 10 sekunder, men vi ønsker ikke at vente så længe.

I stedet med timeout wrapper, instruerer vi vores kode til ikke at vente i mere end 1 sekund:

CompletionStage<Result> 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 Forbrugeren færdig i tide, eller undtagelsen på grund af fremtiden timeout.

5.5. Håndtering af undtagelser

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

Vi kan håndtere både succes- og fiaskoscenarier med handleAsync metode.

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

CompletionStage<Object> res = f.handleAsync((result, e) -> {
    if (e != null) {
        log.error("Exception thrown", e);
        return e.getCause();
    } else {
        return result;
    }
});

Koden skulle nu returnere en CompletionStage indeholdende TimeoutException smidt.

Vi kan bekræfte det ved blot at kalde en assertEquals på klassen af ​​undtagelsesobjektet returnerede:

Class<?> clazz = res.toCompletableFuture().get().getClass();
assertEquals(TimeoutException.class, clazz);

Når testen kører, vil den også logge undtagelsen, vi modtog:

[error] c.HomeControllerTest - Exception thrown
java.util.concurrent.TimeoutException: Timeout after 1 second
...

6. Anmod om filtre

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

Vi kunne manipulere WSRequest objektet er først initialiseret, men en mere elegant teknik er at indstille et WSRequestFilter .

Et filter kan indstilles under initialisering, før udløsningsmetoden kaldes, og det 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 scenarie er at logge, hvordan anmodningen ser ud, før den udføres.

I dette tilfælde skal vi blot indstille AhcCurlRequestLogger :

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

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

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

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

7. Caching af svar

WSClient understøtter også cachelagring af svar.

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

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

7.1. Tilføj cacheafhængigheder

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

libraryDependencies += ehcache

Dette konfigurerer Ehcache som vores cachinglag.

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

7.2. Force Caching Heuristic

Som standard cacher Play WS ikke HTTP-svar, hvis serveren ikke returnerer nogen cachekonfiguration.

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

play.ws.cache.heuristics.enabled=true

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

8. Yderligere tuning

Forespørgsler til en ekstern tjeneste kan kræve en vis klientkonfiguration. Vi skal muligvis håndtere omdirigeringer, en langsom server eller noget filtrering afhængigt af bruger-agent-headeren.

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

play.ws.followRedirects=false
play.ws.useragent=MyPlayApplication
play.ws.compressionEnabled=true
# time to wait for the connection to be established
play.ws.timeout.connection=30
# time to wait for data after the connection is open
play.ws.timeout.idle=30
# max time available to complete the request
play.ws.timeout.request=300

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

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

9. Konklusion

I denne artikel udforskede vi Play WS-biblioteket og dets hovedfunktioner. Vi konfigurerede vores projekt, lærte, hvordan man affyrer almindelige anmodninger og behandler deres svar, både synkront og asynkront.

Vi arbejdede med store datadownloads og så, hvordan man kunne afbryde kortvarige aktiviteter.

Til sidst så vi på caching for at forbedre ydeevnen, og hvordan man tuner klienten.

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


Java tag