CLOVER🍀

That was when it all began.

Spring Cloud Functionを試してみる

これは、なにをしたくて書いたもの?

Spring Cloud Functionを、ちょっと試してみようかな、と。

Spring Cloud Function

Spring Cloud Functionとは、関数(Function)を使ってロジックを実行する仕組みです。特定の実行環境に依存せず、
同じコードでWebのエンドポイント、ストリーム処理、タスクを実行できるようにできます。

Spring Cloud Function

DIなどの、Springの機能も使えます。

機能としては、こんな感じのようです。

  • リアクティブ、命令型、およびそのハイブリッドのサポート
  • @FunctionalInterfaceなセマンティクスなものであれば関数として扱う(POJO function)
  • 入出力の変換
  • 関数の合成
  • RESTサポート
  • Spring Cloud Streamを介したストリーミング(Apache Kafka、RabbitMQなどを使って)
  • JARへのパッケージング
  • ターゲットとするプラットフォーム(AWS Lambdaなど)向けにパッケージする
  • 各種FaaS向けのアダプター
  • リアクティブな関数のサポートによる複数の入出力のマージ、結合、その他の操作

ドキュメントは、こちらです。

Spring Cloud Function Reference Documentation

Spring Cloud Function

今回は、簡単に関数を作ってREST APIとしてアクセスしてみます。プログラミングスタイルは、リアクティブ(Reactorを使う)で
いきます。

環境

今回の環境は、こちらです。

$ java --version
openjdk 11.0.10 2021-01-19
OpenJDK Runtime Environment (build 11.0.10+9-Ubuntu-0ubuntu1.20.04)
OpenJDK 64-Bit Server VM (build 11.0.10+9-Ubuntu-0ubuntu1.20.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 11.0.10, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-67-generic", arch: "amd64", family: "unix"

プロジェクトを作成する

まずは、Spring Bootプロジェクトを作成します。依存関係にSpring Cloud Functionと、WebFluxを選んでプロジェクトを作成。

$ curl -s https://start.spring.io/starter.tgz \
  -d dependencies=cloud-function,webflux \
  -d groupId=org.littlewings \
  -d artifactId=getting-started \
  -d packageName=org.littlewings.spring.cloudfuntion \
  -d bootVersion=2.4.4 \
  -d javaVersion=11 \
  -d type=maven-project \
  -d baseDir=getting-started | tar zxvf -
$ cd getting-started

最初から入っているソースコードは削除しておきます。

$ rm src/main/java/org/littlewings/spring/cloudfuntion/DemoApplication.java src/test/java/org/littlewings/spring/cloudfuntion/DemoApplicationTests.java 

生成されたpom.xmlは、こんな感じです。

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.4.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>org.littlewings</groupId>
    <artifactId>getting-started</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>11</java.version>
        <spring-cloud.version>2020.0.1</spring-cloud.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-function-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

spring-cloud-starter-function-webfluxというものを使うのでは?と思ったのですが、中身を見るとspring-boot-starter-webfluxと
spring-cloud-function-webを足したもののようなので、意味は同じですね。

https://github.com/spring-cloud/spring-cloud-function/tree/v3.1.1/spring-cloud-starter-function-webflux

関数を作成する

では、関数を作成していきましょう。Java 8の関数インターフェースを使っていきます。

Spring Cloud Function / Java 8 function support

そして、Webアプリケーションとして実行します。

Spring Cloud Function / Standalone Web Applications

今回は扱いませんが、ストリーム処理もできます、と。

Spring Cloud Function / Standalone Streaming Applications

作成したmainクラス+関数は、こちら。

src/main/java/org/littlewings/spring/cloudfuntion/App.java

package org.littlewings.spring.cloudfuntion;

import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@SpringBootApplication
public class App {
    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }

    @Bean
    public Supplier<Mono<String>> hello() {
        return () -> Mono.just("Hello Spring Cloud Function!!");
    }

    @Bean
    public Function<Mono<String>, Mono<String>> decorate() {
        return message -> message.map(m -> String.format("★★★%s★★★", m));
    }

    @Bean
    public Consumer<Flux<String>> consume() {
        return message -> message.subscribe(m -> System.out.printf("Received: %s%n", m));
    }
}

FunctionやSupplier、ConsumerなどをSpringのBeanとして登録すればOKです。

    @Bean
    public Supplier<Mono<String>> hello() {
        return () -> Mono.just("Hello Spring Cloud Function!!");
    }

    @Bean
    public Function<Mono<String>, Mono<String>> decorate() {
        return message -> message.map(m -> String.format("★★★%s★★★", m));
    }

    @Bean
    public Consumer<Flux<String>> consume() {
        return message -> message.subscribe(m -> System.out.printf("Received: %s%n", m));
    }

パッケージングして起動。

$ mvn package
$ java -jar target/getting-started-0.0.1-SNAPSHOT.jar

URLのマッピングのルールはこちらに書いているので、これに沿ってアクセスしてみます。

Spring Cloud Function / Standalone Web Applications

Supplier。

    @Bean
    public Supplier<Mono<String>> hello() {
        return () -> Mono.just("Hello Spring Cloud Function!!");
    }

Beanの名前が、そのままアクセスするパスになります。

$ curl -i localhost:8080/hello
HTTP/1.1 200 OK
timestamp: 1616161539041
Content-Type: text/plain;charset=UTF-8
Content-Length: 29

Hello Spring Cloud Function!!

Function。

    @Bean
    public Function<Mono<String>, Mono<String>> decorate() {
        return message -> message.map(m -> String.format("★★★%s★★★", m));
    }

HTTPボディを入力として扱います。アクセスするパスは、こちらもBean名です。

$ curl -i -XPOST -H 'Content-Type: text/plain' localhost:8080/decorate -d 'Hello'
HTTP/1.1 200 OK
timestamp: 1616161556980
Content-Type: text/plain;charset=UTF-8
Content-Length: 23

★★★Hello★★★

Content-Typeは重要で、今回のアクセス方法でtext/plainを指定しない場合は、HTTPボディを認識できません。

$ curl -i -XPOST localhost:8080/decorate -d 'Hello'
HTTP/1.1 200 OK
timestamp: 1616161622371
Content-Type: text/plain;charset=UTF-8
Content-Length: 18

★★★★★★

パスの一部を入力として与えることも可能です。

$ curl -i  localhost:8080/decorate/hello
HTTP/1.1 200 OK
timestamp: 1616161636288
Content-Type: text/plain;charset=UTF-8
Content-Length: 23

★★★hello★★★

Consumerとリアクティブスタイルを組み合わせる場合は、入力はFluxにする必要があるようです。また、サブスクライブする
必要があることにも注意です。

    @Bean
    public Consumer<Flux<String>> consume() {
        return message -> message.subscribe(m -> System.out.printf("Received: %s%n", m));
    }

Spring Cloud Function / Consumer

$ curl -i -XPOST -H 'Content-Type: text/plain'  localhost:8080/consume -d 'Hello'
HTTP/1.1 202 Accepted
content-length: 0

サーバー側で出力される結果。サブスクライブしている部分ですね。

Received: Hello

ルーティングに関しては、他の方法もあるようですが、今回はパス。

Spring Cloud Function / Function Routing and Filtering

アクセスできることは確認できたので、ここまで書いた内容はコメントアウト。

@SpringBootApplication
public class App {
    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }

    /*
    @Bean
    public Supplier<Mono<String>> hello() {
        return () -> Mono.just("Hello Spring Cloud Function!!");
    }

    @Bean
    public Function<Mono<String>, Mono<String>> decorate() {
        return message -> message.map(m -> String.format("★★★%s★★★", m));
    }

    @Bean
    public Consumer<Flux<String>> consume() {
        return message -> message.subscribe(m -> System.out.printf("Received: %s%n", m));
    }
    */
}

入出力にJSONを使う

次は、入出力にJSONを使ってみましょう。お題は書籍で。

src/main/java/org/littlewings/spring/cloudfuntion/Book.java

package org.littlewings.spring.cloudfuntion;

public class Book {
    String isbn;
    String title;
    int price;

    // getter/setterは省略
}

書籍データの保存先は、メモリ上にしておきます。

src/main/java/org/littlewings/spring/cloudfuntion/InMemoryBookRepository.java

package org.littlewings.spring.cloudfuntion;

import java.util.Comparator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Collectors;

import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Component
public class InMemoryBookRepository {
    ConcurrentMap<String, Book> storage = new ConcurrentHashMap<>();

    public Mono<Void> insert(Book book) {
        return Mono.justOrEmpty(storage.put(book.getIsbn(), book)).then();
    }

    public Flux<Book> findAll() {
        return Flux
                .fromIterable(storage.values().stream().sorted(Comparator.<Book>comparingInt(book -> book.getPrice()).reversed()).collect(Collectors.toList()));
    }

    public Mono<Book> find(String isbn) {
        return Mono.justOrEmpty(storage.get(isbn));
    }
}

関数の実装方法は、今回は@Componentを使って試してみましょう。単に興味本位のやり方です。
ついでに、DIできることも確認しておこうかな、と。

書籍を保存するConsumer。@Componentに名前を指定することで、こちらの名前でアクセスできることも
確認してみようと思います。

src/main/java/org/littlewings/spring/cloudfuntion/BookInsertFunction.java

package org.littlewings.spring.cloudfuntion;

import java.util.function.Consumer;

import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;

@Component("insert")
public class BookInsertFunction implements Consumer<Flux<Book>> {
    InMemoryBookRepository bookRepository;

    public BookInsertFunction(InMemoryBookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    @Override
    public void accept(Flux<Book> books) {
        books.subscribe(b -> bookRepository.insert(b));
    }
}

先ほど作成したInMemoryBookRepositoryを、コンストラクタインジェクションしています。

書籍ひとつを取得するFunction。

src/main/java/org/littlewings/spring/cloudfuntion/BookFindFunction.java

package org.littlewings.spring.cloudfuntion;

import java.util.function.Function;

import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

@Component("find")
public class BookFindFunction implements Function<Mono<String>, Mono<Book>> {
    InMemoryBookRepository bookRepository;

    public BookFindFunction(InMemoryBookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    @Override
    public Mono<Book> apply(Mono<String> isbn) {
        return isbn.flatMap(i -> bookRepository.find(i));
    }
}

全書籍を取得するSupplier。

src/main/java/org/littlewings/spring/cloudfuntion/BookListFunction.java

package org.littlewings.spring.cloudfuntion;

import java.util.function.Supplier;

import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;

@Component("list")
public class BookListFunction implements Supplier<Flux<Book>> {
    InMemoryBookRepository bookRepository;

    public BookListFunction(InMemoryBookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    @Override
    public Flux<Book> get() {
        return bookRepository.findAll();
    }
}

パッケージングして起動。

$ mvn package
$ java -jar target/getting-started-0.0.1-SNAPSHOT.jar

確認してみます。まずは、データの登録。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/insert -d '{"isbn": "978-4798142470", "title": "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", "price": 4400}'
HTTP/1.1 202 Accepted
content-length: 0


$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/insert -d '{"isbn": "978-4774182179", "title": "[改訂新版]Spring入門 - Javaフレームワーク・より良い設計とアーキテクチャ", "price": 4180}'
HTTP/1.1 202 Accepted
content-length: 0

1件取得。

$ curl -i localhost:8080/find/978-4798142470
HTTP/1.1 200 OK
timestamp: 1616162572285
Content-Type: application/json
Content-Length: 127

{"isbn":"978-4798142470","title":"Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発","price":4400}

OKですね。

$ curl -i localhost:8080/list
HTTP/1.1 200 OK
timestamp: 1616162591835
Content-Type: application/json
Content-Length: 278

[{"isbn":"978-4798142470","title":"Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発","price":4400},{"isbn":"978-4774182179","title":"[改訂新版]Spring入門 - Javaフレームワーク・より良い設計とアーキテクチャ","price":4180}]

とりあえず、今回やってみたいことは確認できたかな、と。

まとめ

Spring Cloud Functionを試してみました。Function、Supplier、Consumerインターフェースで、簡単に関数を
公開できることが確認できました。

最初は、Spring Data系のものと組み合わせて試してみようかなと思ったのですが、関数へのルーティングまわりに
戸惑ったところもあって、単純にアクセスするだけにとどめておきました。

次からは、もうちょっといろいろできるかな、と思います。