Programmation HTTP asynchrone avec Play Framework

Haut Java

Je viens d'annoncer le nouveau cours Learn Spring , axé sur les principes de base de Spring 5 et Spring Boot 2:

>> VOIR LE COURS

1. Vue d'ensemble

Souvent, nos services Web doivent utiliser d'autres services Web pour faire leur travail. Il peut être difficile de répondre aux demandes des utilisateurs tout en conservant un temps de réponse réduit. Un service externe lent peut augmenter notre temps de réponse et amener notre système à accumuler les demandes, en utilisant plus de ressources. C'est là qu'une approche non bloquante peut être très utile

Dans ce didacticiel, nous allons lancer plusieurs requêtes asynchrones vers un service à partir d'une application Play Framework. En tirant parti de la capacité HTTP non bloquante de Java, nous pourrons interroger en douceur les ressources externes sans affecter notre propre logique principale.

Dans notre exemple, nous allons explorer la bibliothèque Play WebService.

2. La bibliothèque Play WebService (WS)

WS est une bibliothèque puissante fournissant des appels HTTP asynchrones à l'aide de Java Action .

En utilisant cette bibliothèque, notre code envoie ces requêtes et continue sans blocage. Pour traiter le résultat de la demande, nous fournissons une fonction consommatrice, c'est-à-dire une implémentation de l' interface Consumer .

Ce modèle partage certaines similitudes avec l'implémentation par JavaScript des rappels, des promesses et du modèle async / await .

Construisons un consommateur simple qui enregistre certaines des données de réponse:

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

Notre consommateur se connecte simplement à cet exemple. Le consommateur peut cependant faire tout ce que nous devons faire avec le résultat, comme stocker le résultat dans une base de données.

Si nous examinons plus en détail l'implémentation de la bibliothèque, nous pouvons observer que WS enveloppe et configure AsyncHttpClient de Java , qui fait partie du JDK standard et ne dépend pas de Play.

3. Préparez un exemple de projet

Pour expérimenter le framework, créons des tests unitaires pour lancer des requêtes. Nous allons créer une application Web squelette pour y répondre et utiliser le framework WS pour effectuer des requêtes HTTP.

3.1. L'application Web Skeleton

Tout d'abord, nous créons le projet initial en utilisant la commande sbt new :

sbt new playframework/play-java-seed.g8

Dans le nouveau dossier, nous éditons ensuite le fichier build.sbt et ajoutons la dépendance de la bibliothèque WS:

libraryDependencies += javaWs

Nous pouvons maintenant démarrer le serveur avec la commande sbt run :

$ 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

Une fois l'application lancée, nous pouvons vérifier que tout va bien en naviguant sur // localhost: 9000 , ce qui ouvrira la page d'accueil de Play.

3.2. L'environnement de test

Pour tester notre application, nous utiliserons la classe de test unitaire HomeControllerTest .

Tout d'abord, nous devons étendre WithServer qui fournira le cycle de vie du serveur:

public class HomeControllerTest extends WithServer { 

Grâce à son parent, cette classe démarre maintenant notre serveur web squelette en mode test et sur un port aléatoire , avant d'exécuter les tests. La classe WithServer arrête également l'application lorsque le test est terminé.

Ensuite, nous devons fournir une application à exécuter.

Nous pouvons créer avec Guice de GuiceApplicationBuilder :

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

Et enfin, nous avons configuré l'URL du serveur à utiliser dans nos tests, en utilisant le numéro de port fourni par le serveur de test:

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

Nous sommes maintenant prêts à écrire des tests. Le cadre de test complet nous permet de nous concentrer sur le codage de nos demandes de test.

4. Préparez une WSRequest

Voyons comment nous pouvons lancer des types de requêtes de base, tels que GET ou POST, et des requêtes en plusieurs parties pour le téléchargement de fichiers.

4.1. Initialiser l' objet WSRequest

Tout d'abord, nous devons obtenir une instance WSClient pour configurer et initialiser nos requêtes.

Dans une application réelle, nous pouvons obtenir un client, configuré automatiquement avec les paramètres par défaut, via l'injection de dépendances:

@Autowired WSClient ws;

Dans notre classe de test, cependant, nous utilisons WSTestClient , disponible à partir du framework Play Test:

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

Une fois que nous avons notre client, nous pouvons initialiser un objet WSRequest en appelant la méthode url :

ws.url(url)

La méthode url en fait assez pour nous permettre de lancer une requête. Cependant, nous pouvons le personnaliser davantage en ajoutant des paramètres personnalisés:

ws.url(url) .addHeader("key", "value") .addQueryParameter("num", "" + num);

As we can see, it's pretty easy to add headers and query parameters.

After we've fully configured our request, we can call the method to initiate it.

4.2. Generic GET Request

To trigger a GET request we just have to call the get method on our WSRequest object:

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

As this is a non-blocking code, it starts the request and then continues execution at the next line of our function.

The object returned by get is a CompletionStage instance, which is part of the CompletableFuture API.

Once the HTTP call has completed, this stage executes just a few instructions. It wraps the response in a WSResponse object.

Normally, this result would be passed on to the next stage of the execution chain. In this example, we have not provided any consuming function, so the result is lost.

For this reason, this request is of type “fire-and-forget”.

4.3. Submit a Form

Submitting a form is not very different from the get example.

To trigger the request we just call the post method:

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

In this scenario, we need to pass a body as a parameter. This can be a simple string like a file, a json or xml document, a BodyWritable or a Source.

4.4. Submit a Multipart/Form Data

A multipart form requires us to send both input fields and data from an attached file or stream.

To implement this in the framework, we use the post method with a Source.

Inside the source, we can wrap all the different data types needed by our form:

Source file = FileIO.fromPath(Paths.get("hello.txt")); FilePart 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)));

Though this approach adds some more configuration, it is still very similar to the other types of requests.

5. Process the Async Response

Up to this point, we have only triggered fire-and-forget requests, where our code doesn't do anything with the response data.

Let's now explore two techniques for processing an asynchronous response.

We can either block the main thread, waiting for a CompletableFuture, or consume asynchronously with a Consumer.

5.1. Process Response by Blocking With CompletableFuture

Even when using an asynchronous framework, we may choose to block our code's execution and wait for the response.

Using the CompletableFuture API, we just need a few changes in our code to implement this scenario:

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

This could be useful, for example, to provide a strong data consistency that we cannot achieve in other ways.

5.2. Process Response Asynchronously

To process an asynchronous response without blocking, we provide a Consumer or Function that is run by the asynchronous framework when the response is available.

For example, let's add a Consumer to our previous example to log the response:

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()));

We then see the response in the logs:

[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

It's worth noting that we used thenAccept, which requires a Consumer function since we don't need to return anything after logging.

When we want the current stage to return something, so that we can use it in the next stage, we need thenApply instead, which takes a Function.

These use the conventions of the standard Java Functional Interfaces.

5.3. Large Response Body

The code we've implemented so far is a good solution for small responses and most use cases. However, if we need to process a few hundreds of megabytes of data, we'll need a better strategy.

We should note: Request methods like get and post load the entire response in memory.

To avoid a possible OutOfMemoryError, we can use Akka Streams to process the response without letting it fill our memory.

For example, we can write its body in a file:

ws.url(url) .stream() .thenAccept( response -> { try { OutputStream outputStream = Files.newOutputStream(path); Sink
    
      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); } });
    

The stream method returns a CompletionStage where the WSResponse has a getBodyAsStream method that provides a Source.

We can tell the code how to process this type of body by using Akka's Sink, which in our example will simply write any data passing through in the OutputStream.

5.4. Timeouts

When building a request, we can also set a specific timeout, so the request is interrupted if we don't receive the complete response in time.

This is a particularly useful feature when we see that a service we're querying is particularly slow and could cause a pile-up of open connections stuck waiting for the response.

We can set a global timeout for all our requests using tuning parameters. For a request-specific timeout, we can add to a request using setRequestTimeout:

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

There's still one case to handle, though: We may have received all the data, but our Consumer may be very slow processing it. This might happen if there is lots of data crunching, database calls, etc.

In low throughput systems, we can simply let the code run until it completes. However, we may wish to abort long-running activities.

To achieve that, we have to wrap our code with some futures handling.

Let's simulate a very long process in our code:

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

This will return an OK response after 10 seconds, but we don't want to wait that long.

Instead, with the timeout wrapper, we instruct our code to wait for no more than 1 second:

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); 

Now our future will return a result either way: the computation result if the Consumer finished in time, or the exception due to the futures timeout.

5.5. Handling Exceptions

In the previous example, we created a function that either returns a result or fails with an exception. So, now we need to handle both scenarios.

We can handle both success and failure scenarios with the handleAsync method.

Let's say that we want to return the result, if we've got it, or log the error and return the exception for further handling:

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

The code should now return a CompletionStage containing the TimeoutException thrown.

We can verify it by simply calling an assertEquals on the class of the exception object returned:

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

When running the test, it will also log the exception we received:

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

6. Request Filters

Sometimes, we need to run some logic before a request is triggered.

We could manipulate the WSRequest object once initialized, but a more elegant technique is to set a WSRequestFilter.

A filter can be set during initialization, before calling the triggering method, and is attached to the request logic.

We can define our own filter by implementing the WSRequestFilter interface, or we can add a ready-made one.

A common scenario is logging what the request looks like before executing it.

In this case, we just need to set the AhcCurlRequestLogger:

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

The resulting log has a curl-like format:

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

We can set the desired log level, by changing our logback.xml configuration.

7. Caching Responses

WSClient also supports the caching of responses.

This feature is particularly useful when the same request is triggered multiple times and we don't need the freshest data every time.

It also helps when the service we're calling is temporarily down.

7.1. Add Caching Dependencies

To configure caching we need first to add the dependency in our build.sbt:

libraryDependencies += ehcache

This configures Ehcache as our caching layer.

If we don't want Ehcache specifically, we can use any other JSR-107 cache implementation.

7.2. Force Caching Heuristic

By default, Play WS won't cache HTTP responses if the server doesn't return any caching configuration.

To circumvent this, we can force the heuristic caching by adding a setting to our application.conf:

play.ws.cache.heuristics.enabled=true

This will configure the system to decide when it's useful to cache an HTTP response, regardless of the remote service's advertised caching.

8. Additional Tuning

Making requests to an external service may require some client configuration. We may need to handle redirects, a slow server, or some filtering depending on the user-agent header.

To address that, we can tune our WS client, using properties in our 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

It's also possible to configure the underlying AsyncHttpClient directly.

The full list of available properties can be checked in the source code of AhcConfig.

9. Conclusion

In this article, we explored the Play WS library and its main features. We configured our project, learned how to fire common requests and to process their response, both synchronously and asynchronously.

We worked with large data downloads and saw how to cut short long-running activities.

Enfin, nous avons examiné la mise en cache pour améliorer les performances et la manière de régler le client.

Comme toujours, le code source de ce didacticiel est disponible à l'adresse over sur GitHub.

Fond Java

Je viens d'annoncer le nouveau cours Learn Spring , axé sur les principes de base de Spring 5 et Spring Boot 2:

>> VOIR LE COURS