Spring Cloud Function et Déploiement Web

Comment exposer ses Spring Cloud Functions sur le web...


Cet article fait partie d’une série traitant de Spring Cloud Function:

  1. Introduction à Spring Cloud Function
  2. Tests avec Spring Cloud Function
  3. Spring Cloud Function et Déploiement Web
  4. 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.

Liens

  1. Les exemples de l’article
  2. Le site du projet

© 2023 Du côté de chez Fouad. Tous droits réservés.