Cet article fait partie d’une série traitant de Spring Cloud Function:
- Introduction à Spring Cloud Function
- Tests avec Spring Cloud Function
- Spring Cloud Function et Déploiement Web
- Spring Cloud Function et Spring Cloud Stream
Introduction
Dans cet article, nous allons voir comment déployer des fonctions sur un serveur web à l’aide de Spring Cloud Function.
Dépendance
Pour déployer l’application, nous devons ajouter une dépendance à spring-cloud-starter-function-web
dans notre fichier de configuration Maven:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-function-web</artifactId>
</dependency>
Supplier
Commençons avec une fonction de type Supplier
:
@Bean
public Supplier<String> supplier() {
return () -> "hello";
}
spring-cloud-function-web
va automatiquement créer un endpoint /supplier
qui appellera notre fonction. Démarrons l’application et vérifions cela:
http :8080/supplier
J’utilise httpie comme client http mais vous pouvez utiliser curl
ou tout autre outil qui vous convienne à la place.
La réponse obtenue est la suivante:
HTTP/1.1 200
Content-Length: 5
Content-Type: text/plain;charset=UTF-8
Date: Sat, 13 Nov 2021 09:42:01 GMT
Keep-Alive: timeout=60
accept-encoding: gzip, deflate
connection: keep-alive, keep-alive
uri: /supplier
user-agent: HTTPie/2.6.0
hello from Supplier
On voit que notre fonction a été appelée sans avoir à créer de controller.
Que se passe-t-il si on exécute un POST
plutôt qu’un GET
?
http post :8080/supplier
Il semble que cela fonctionne également (bien que ce ne soit pas documenté):
HTTP/1.1 200
Content-Length: 5
Content-Type: text/plain;charset=UTF-8
Date: Sat, 13 Nov 2021 09:45:31 GMT
Keep-Alive: timeout=60
accept-encoding: gzip, deflate
connection: keep-alive, keep-alive
uri: /supplier
user-agent: HTTPie/2.6.0
hello from Supplier
Le statut de la réponse est identique.
Et si on retournait un POJO ?
Ajoutons un autre supplier:
@Bean
public Supplier<Welcome> pojoSupplier() {
return () -> new Welcome("hello from POJO Supplier");
}
@AllArgsConstructor
@Getter
static class Welcome {
String message;
}
Lorsqu’on exécute la commande:
http :8080/pojoSupplier
on obtient (sans surprise tellement on est gâté :-)):
HTTP/1.1 200
Content-Type: application/json
Date: Sat, 13 Nov 2021 10:12:01 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked
accept-encoding: gzip, deflate
connection: keep-alive, keep-alive
uri: /pojoSupplier
user-agent: HTTPie/2.6.0
{
"message": "hello from POJO Supplier"
}
Notez que si on oublie d’ajouter les getters à notre POJO, l’appel retournera une erreur avec un code statut 406 (Not Acceptable
) et les logs indiquent le message suivant:
2021-11-13 11:14:50.188 WARN 95289 --- [nio-8080-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpMediaTypeNotAcceptableException: Could not find acceptable representation]
Consumer
Voyons ce qu’il se passe si nous écrivons une fonction de type Consumer
plutôt que Supplier
:
@Bean
public Consumer<String> consumer() {
return log::info;
}
Comme le consumer ne retourne pas de réponse, nous loggons juste l’argument fourni en entrée.
Relançons l’application et effectuons quelques tests.
http :8080/consumer/hello
Notez comment j’ajoute l’argument de la fonction au chemin de l’url.
La sortie suivante est obtenue:
HTTP/1.1 202
Content-Length: 0
Date: Sat, 13 Nov 2021 09:51:06 GMT
Keep-Alive: timeout=60
accept-encoding: gzip, deflate
connection: keep-alive, keep-alive
uri: /consumer/hello
user-agent: HTTPie/2.6.0
On notera que le statut de la réponse est ici 202 (Accepted
) et que la méthode GET
fonctionne (ce cas n’est également pas documenté).
Si on regarde les logs, l’argument est bien tracé:
2021-11-13 10:51:05.996 INFO 92337 --- [nio-8080-exec-1] com.fouadhamdi.scf.Application : hello
La documentation recommande plutôt d’utiliser la méthode POST
avec les Consumer
. Essayons:
http post :8080/consumer/hello
La réponse obtenue est similaire:
HTTP/1.1 202
Content-Length: 0
Date: Sat, 13 Nov 2021 09:56:25 GMT
Keep-Alive: timeout=60
accept-encoding: gzip, deflate
connection: keep-alive, keep-alive
uri: /consumer/hello
user-agent: HTTPie/2.6.0
Mais si on jette un oeil aux logs, l’argument n’a pas été récupéré !
2021-11-13 10:56:25.373 INFO 92337 --- [nio-8080-exec-3] com.fouadhamdi.scf.Application :
Dans la doc, il est effectivement précisé qu’il faut transmettre les arguments de la requête dans le body:
Mirrors input and pushes request body into consumer
Enfin, c’est ce que je crois que ça dit, ce n’est pas très clair pour moi. Il est également précisé que la requête doit être un:
JSON object or text
Dans notre cas, on veut juste passer du texte pour le moment:
echo 'hello' | http post :8080/consumer
La sortie est similaire au cas précédente et si on regarde la log:
2021-11-13 11:06:21.810 INFO 92337 --- [nio-8080-exec-1] com.fouadhamdi.scf.Application : hello
ça a marché !
Et avec un POJO ?
Essayons:
@Bean
public Consumer<User> pojoConsumer() {
return user -> log.info(user.toString());
}
@ToString
@Setter
static class User {
String name;
}
Avec la commande suivante:
http post :8080/pojoConsumer name=john
On obtient la sortie suivante:
HTTP/1.1 202
Content-Length: 0
Date: Sat, 13 Nov 2021 10:21:55 GMT
Keep-Alive: timeout=60
accept-encoding: gzip, deflate
connection: keep-alive, keep-alive
uri: /pojoConsumer
user-agent: HTTPie/2.6.0
et dans les logs:
2021-11-13 11:21:55.189 INFO 96195 --- [nio-8080-exec-1] com.fouadhamdi.scf.Application : Application.User(name=john)
Tout a l’air ok !
Notez que si on ne met pas les setters sur notre POJO, la propriété name
de l’objet User
vaudra null
.
Passons aux fonctions de type Function
à présent.
Function
Avec ce que nous avons appris pour les Supplier
et les Consumer
, cela ne devrait pas être trop difficile:
@Bean
public Function<String, String> hello() {
return input -> "Hello, " + input;
}
Et si on exécute:
http :8080/function/world
ou bien:
echo world | http post :8080/function
on obtiendra la même réponse:
HTTP/1.1 200
Content-Length: 13
Content-Type: application/json
Date: Sat, 13 Nov 2021 10:29:19 GMT
Keep-Alive: timeout=60
accept-encoding: gzip, deflate
connection: keep-alive, keep-alive
uri: /function
user-agent: HTTPie/2.6.0
Hello, world
Avec des POJO
Essayons:
@Bean
public Function<User, Welcome> pojoFunction() {
return user -> new Welcome("Hello, " + user.name);
}
Avec la commande:
http post :8080/pojoFunction name=John
On a la sortie:
HTTP/1.1 200
Content-Type: application/json
Date: Sat, 13 Nov 2021 10:32:16 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked
accept-encoding: gzip, deflate
connection: keep-alive, keep-alive
uri: /pojoFunction
user-agent: HTTPie/2.6.0
{
"message": "Hello, John"
}
Eh bien, on dirait que ça marche également.
Comment on combine des fonctions avec ce type de déploiement ?
Tout simplement en utilisant |
ou ,
pour séparer les fonctions que l’on souhaite combiner lors de l’appel. Essayons avec notre Supplier
et Consumer
écrits précédemment:
http post :8080/supplier,consumer
Si on jette un oeil aux logs, on voit apparaître comme attendu la ligne suivante:
2021-11-13 11:42:21.251 INFO 97222 --- [nio-8080-exec-3] com.fouadhamdi.scf.Application : hello from Supplier
Et ça marche aussi en Reactive ?
Ahhhh, bonne question. Essayons tout de suite:
@Bean
public Supplier<Flux<String>> reactiveSupplier() {
return () -> Flux.interval(Duration.ofSeconds(1))
.map(Object::toString);
}
Notre fonction réactive retourne toutes les secondes un entier incrémenté (je n’ai pas beaucoup d’imagination, je sais).
http :8080/reactiveSupplier Accept:text/event-stream --stream
On passe un header Accept
avec la valeur text/event-stream
pour indiquer que l’on souhaite récupérer les résultats sous forme de stream.
Et là…on obtient une erreur de timeout ! On dirait que ça ne fonctionne pas. Pourtant, les logs montrent bien que la fonction est exécutée:
2021-11-14 10:28:06.252 INFO 17596 --- [nio-8080-exec-3] reactor.Flux.Interval.2 : onSubscribe(FluxInterval.IntervalRunnable)
2021-11-14 10:28:06.252 INFO 17596 --- [nio-8080-exec-3] reactor.Flux.Interval.2 : request(unbounded)
2021-11-14 10:28:07.253 INFO 17596 --- [ parallel-2] reactor.Flux.Interval.2 : onNext(0)
2021-11-14 10:28:08.253 INFO 17596 --- [ parallel-2] reactor.Flux.Interval.2 : onNext(1)
2021-11-14 10:28:09.253 INFO 17596 --- [ parallel-2] reactor.Flux.Interval.2 : onNext(2)
...
2021-11-14 10:28:36.253 INFO 17596 --- [ parallel-2] reactor.Flux.Interval.2 : onNext(29)
2021-11-14 10:28:37.147 INFO 17596 --- [nio-8080-exec-4] reactor.Flux.Interval.2 : cancel()
2021-11-14 10:28:37.150 WARN 17596 --- [nio-8080-exec-4] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.context.request.async.AsyncRequestTimeoutException]
Et au bout de 30 secondes, on a le timeout.
En débuggant un peu le framework, le code qui appelle notre fonction se trouve dans la classe FunctionWebRequestProcessingHelper
et la méthode processRequest
. J’ai isolé le code concerné pour faciliter la description:
Object result = function.apply(input);
// ...
if (result instanceof Publisher) {
pResult = (Publisher) result;
if (eventStream) {
return Flux.from(pResult).then(Mono.fromSupplier(() -> responseOkBuilder.body(result)));
}
// ...
}
result
représente le résultat de l’appel à notre fonction et eventStream
est un booléen qui vaut true
dans notre cas car on souhaite récupérer les résultats sous forme de stream.
L’origine de notre problème vient de cet appel à la fonction then
qui n’exécutera son contenu seulement lorsque le flux pResult
sera terminé. Or, dans notre cas, cela n’arrivera jamais car nous émettons un flux infini d’objets.
Vérifions cette hypothèse en créant un Supplier
reactive qui se termine:
@Bean
public Supplier<Flux<String>> finiteReactiveSupplier() {
return () -> Flux.just("hello", "world");
}
Effectuons un test:
http :8080/finiteReactiveSupplier Accept:text/event-stream --stream
Et là, ça fonctionne en effet:
HTTP/1.1 200
Content-Type: text/event-stream
Date: Sat, 14 Nov 2021 09:39:14 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked
accept-encoding: gzip, deflate
connection: keep-alive, keep-alive
uri: /finiteReactiveSupplier
user-agent: HTTPie/2.6.0
data:hello
data:world
On récupère bien les résultats sous forme de stream. Si on omet le header Accept
, voici ce que l’on obtient:
HTTP/1.1 200
Content-Type: application/json
Date: Sat, 14 Nov 2021 09:41:38 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked
accept-encoding: gzip, deflate
connection: keep-alive, keep-alive
uri: /finiteReactiveSupplier
user-agent: HTTPie/2.6.0
[
"hello",
"world"
]
Les résultats sont retournés sous forme de tableau cette fois-ci.
Concernant le problème du flux infini, j’ai ouvert un ticket auprès de l’équipe Spring Cloud Function: il est donc possible qu’il soit résolu au moment où vous lirez cet article et expérimenterez avec une version plus récente.
Limiter les fonctions exposées
Il est possible d’indiquer la ou les fonctions que l’on souhaite exposer sur le web à l’aide de la propriété spring.cloud.function.definition
.
Si par exemple, on souhaite n’exposer que la combinaison de nos fonctions supplier
et consumer
ainsi que la fonction function
, nous pouvons configurer l’application de la façon suivante:
spring.cloud.function.definition=supplier|consumer;function
Notez le ;
pour séparer les fonctions.
Conclusion
Ici s’arrête notre petite expérimentation avec le déploiement web de nos fonctions. On a vu qu’il était assez simple d’exposer celles-ci sans avoir à créer de controllers.
Il y néanmoins encore quelques limitations avec le modèle reactive
pour lequel les streams infinis ne semblent pas encore supportés pour le moment.