IK.AM

@making's tech note


Spring WebFlux.fnハンズオン - 2. 簡易家計簿Moneygerプロジェクトの作成


本ハンズオンで、次の図のような簡易家計簿のAPIサーバーをSpring WebFlux.fnを使って実装します。 あえてSpring BootもDependency Injectionも使わないシンプルなWebアプリとして実装します。

ハンズオンコンテンツ

  1. 簡易家計簿Moneygerプロジェクトの作成 👈
  2. YAVIによるValidationの実装
  3. R2DBCによるデータベースアクセス
  4. Web UIの追加
  5. 例外ハンドリングの改善
  6. 収入APIの実装
  7. Spring Bootアプリに変換
  8. GraalVMのSubstrateVMでNative Imageにコンパイル

目次

簡易家計簿Moneygerプロジェクトの作成

簡易家計簿Moneygerのプロジェクトを作成します。

プロジェクトの雛形はMaven Archetypeのvanilla-spring-webflux-fn-blankを使用します。

次のコマンドで雛形プロジェクトを作成してください。Windowsの場合はGit BashなどのBash実行環境を使用してください。

mvn archetype:generate\
 -DarchetypeGroupId=am.ik.archetype\
 -DarchetypeArtifactId=vanilla-spring-webflux-fn-blank-archetype\
 -DarchetypeVersion=0.2.15\
 -DgroupId=com.example\
 -DartifactId=moneyger\
 -Dversion=1.0.0-SNAPSHOT\
 -B

2019-09-05時点で、Spring Frameworkの最新の変更を取り込むため、pom.xml<properties>タグ内の次の値を

        <spring-boot.version>2.2.0.M5</spring-boot.version>
        <r2dbc-releasetrain.version>Arabba-M8</r2dbc-releasetrain.version>

次の値に変更してください。

        <spring-boot.version>2.2.0.BUILD-SNAPSHOT</spring-boot.version>
        <r2dbc-releasetrain.version>Arabba-BUILD-SNAPSHOT</r2dbc-releasetrain.version>

同梱のサンプルコードの動作を確認します。

cd moneyger
chmod +x mvnw
./mvnw clean package

java -jar target/moneyger-1.0.0-SNAPSHOT.jar
$ curl localhost:8080
Hello World!

$ curl localhost:8080/messages -d "{\"text\":\"Hello\"}" -H "Content-Type: application/json"
{"text":"Hello"}

$ curl localhost:8080/messages
[{"text":"Hello"}]

Expenditureモデルの作成

家計簿のExpenditure(支出)モデルを作成します。

com.example.expenditureパッケージを作って以下のExpenditure.javaExpenditureBuilder.javaを作成してください。

package com.example.expenditure;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

import java.time.LocalDate;

@JsonDeserialize(builder = ExpenditureBuilder.class)
public class Expenditure {

    private final Integer expenditureId;

    private final String expenditureName;

    private final int unitPrice;

    private final int quantity;

    private final LocalDate expenditureDate;

    Expenditure(Integer expenditureId, String expenditureName, int unitPrice, int quantity, LocalDate expenditureDate) {
        this.expenditureId = expenditureId;
        this.expenditureName = expenditureName;
        this.unitPrice = unitPrice;
        this.quantity = quantity;
        this.expenditureDate = expenditureDate;
    }

    public Integer getExpenditureId() {
        return expenditureId;
    }


    public String getExpenditureName() {
        return expenditureName;
    }


    public int getUnitPrice() {
        return unitPrice;
    }


    public int getQuantity() {
        return quantity;
    }


    public LocalDate getExpenditureDate() {
        return expenditureDate;
    }


    @Override
    public String toString() {
        return "Expenditure{" +
            "expenditureId=" + expenditureId +
            ", expenditureName='" + expenditureName + '\'' +
            ", unitPrice=" + unitPrice +
            ", quantity=" + quantity +
            ", expenditureDate=" + expenditureDate +
            '}';
    }
}
package com.example.expenditure;

import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;

import java.time.LocalDate;

@JsonPOJOBuilder
public class ExpenditureBuilder {

    private int unitPrice;

    private LocalDate expenditureDate;

    private Integer expenditureId;

    private String expenditureName;

    private int quantity;

    public ExpenditureBuilder() {
    }

    public ExpenditureBuilder(Expenditure expenditure) {
        this.unitPrice = expenditure.getUnitPrice();
        this.expenditureDate = expenditure.getExpenditureDate();
        this.expenditureId = expenditure.getExpenditureId();
        this.expenditureName = expenditure.getExpenditureName();
        this.quantity = expenditure.getQuantity();
    }

    public Expenditure build() {
        return new Expenditure(expenditureId, expenditureName, unitPrice, quantity, expenditureDate);
    }

    public ExpenditureBuilder withUnitPrice(int unitPrice) {
        this.unitPrice = unitPrice;
        return this;
    }

    public ExpenditureBuilder withExpenditureDate(LocalDate expenditureDate) {
        this.expenditureDate = expenditureDate;
        return this;
    }

    public ExpenditureBuilder withExpenditureId(Integer expenditureId) {
        this.expenditureId = expenditureId;
        return this;
    }

    public ExpenditureBuilder withExpenditureName(String expenditureName) {
        this.expenditureName = expenditureName;
        return this;
    }

    public ExpenditureBuilder withQuantity(int quantity) {
        this.quantity = quantity;
        return this;
    }
}

ExpenditureRepositoryの作成

com.example.expenditureパッケージにExpenditureRepositoryインタフェースを作成してください。

package com.example.expenditure;

import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

public interface ExpenditureRepository {

    Flux<Expenditure> findAll();

    Mono<Expenditure> findById(Integer expenditureId);

    Mono<Expenditure> save(Expenditure expenditure);

    Mono<Void> deleteById(Integer expenditureId);
}

まずはExpenditureRepositoryインタフェースのインメモリ実装であるInMemoryExpenditureRepositoryを作成します。

package com.example.expenditure;

import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.List;
import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;

public class InMemoryExpenditureRepository implements ExpenditureRepository {

    final List<Expenditure> expenditures = new CopyOnWriteArrayList<>();

    final AtomicInteger counter = new AtomicInteger(1);

    @Override
    public Flux<Expenditure> findAll() {
        return Flux.fromIterable(this.expenditures);
    }

    @Override
    public Mono<Expenditure> findById(Integer expenditureId) {
        return Mono.justOrEmpty(this.expenditures.stream()
            .filter(x -> Objects.equals(x.getExpenditureId(), expenditureId))
            .findFirst());
    }

    @Override
    public Mono<Expenditure> save(Expenditure expenditure) {
        return Mono.fromCallable(() -> {
            Expenditure created = new ExpenditureBuilder(expenditure)
                .withExpenditureId(this.counter.getAndIncrement())
                .build();
            this.expenditures.add(created);
            return created;
        });
    }

    @Override
    public Mono<Void> deleteById(Integer expenditureId) {
        return Mono.defer(() -> {
            this.expenditures.removeIf(x -> Objects.equals(x.getExpenditureId(), expenditureId));
            return Mono.empty();
        });
    }
}

this.expenditures.add(created);Mono.fromCallableの外で行う場合と中で行う場合の違いを考えて見てください。

ExpenditureHandlerの作成

次にcom.example.expenditureパッケージにExpenditureHandlerクラスを作成し、WebFlux.fnのRouterFunctionを実装します。

次のTODO(3箇所)は実装しないといけない部分です。動作を確認するためのテストコードは以下に続きます。TODOを実装する前にテストを実行してくだい。

package com.example.expenditure;

import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;

import java.util.LinkedHashMap;

import static org.springframework.http.HttpStatus.NOT_FOUND;

public class ExpenditureHandler {

    private final ExpenditureRepository expenditureRepository;

    public ExpenditureHandler(ExpenditureRepository expenditureRepository) {
        this.expenditureRepository = expenditureRepository;
    }

    public RouterFunction<ServerResponse> routes() {
        return RouterFunctions.route()
            // TODO Routingの定義
            // GET /expenditures
            // POST /expenditures
            // GET /expenditures/{expenditureId}
            .DELETE("/expenditures/{expenditureId}", this::delete)
            .build();
    }

    Mono<ServerResponse> list(ServerRequest req) {
        return ServerResponse.ok().body(this.expenditureRepository.findAll(), Expenditure.class);
    }

    Mono<ServerResponse> post(ServerRequest req) {
        return req.bodyToMono(Expenditure.class)
            // TODO
            // ExpenditureRepositoryでExpenditureを保存
            // Hint: ExpenditureRepository.saveを使ってください。
            .flatMap(created -> ServerResponse
                .created(UriComponentsBuilder.fromUri(req.uri()).path("/{expenditureId}").build(created.getExpenditureId()))
                .bodyValue(created));
    }

    Mono<ServerResponse> get(ServerRequest req) {
        return this.expenditureRepository.findById(Integer.valueOf(req.pathVariable("expenditureId")))
            .flatMap(expenditure -> ServerResponse.ok().bodyValue(expenditure))
            // TODO
            // expenditureが存在しない場合は404を返す。エラーレスポンスは{"status":404,"error":"Not Found","message":"The given expenditure is not found."}
            // Hint: switchIfEmptyおよびServerResponse.statusを使ってください。
            ;
    }

    Mono<ServerResponse> delete(ServerRequest req) {
        return ServerResponse.noContent()
            .build(this.expenditureRepository.deleteById(Integer.valueOf(req.pathVariable("expenditureId"))));
    }
}

src/main/java/com/example/App.javaroutesメソッドを下のコードに変更してください。

    static RouterFunction<ServerResponse> routes() {
        return new ExpenditureHandler(new InMemoryExpenditureRepository()).routes();
    }

src/test/java/com/example/expenditureExpenditureHandlerTestを作成して、次のテストコードを記述してください。TODO部分はそのままにしてください。

package com.example.expenditure;

import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.reactive.server.WebTestClient;

import java.net.URI;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

class ExpenditureHandlerTest {

    private WebTestClient testClient;

    private InMemoryExpenditureRepository expenditureRepository = new InMemoryExpenditureRepository();

    private ExpenditureHandler expenditureHandler = new ExpenditureHandler(this.expenditureRepository);

    private List<Expenditure> fixtures = Arrays.asList(
        new ExpenditureBuilder()
            .withExpenditureId(1)
            .withExpenditureName("本")
            .withUnitPrice(2000)
            .withQuantity(1)
            .withExpenditureDate(LocalDate.of(2019, 4, 1))
            .build(),
        new ExpenditureBuilder()
            .withExpenditureId(2)
            .withExpenditureName("コーヒー")
            .withUnitPrice(300)
            .withQuantity(2)
            .withExpenditureDate(LocalDate.of(2019, 4, 2))
            .build());

    @BeforeAll
    void before() {
        this.testClient = WebTestClient.bindToRouterFunction(this.expenditureHandler.routes())
            .build();
    }

    @BeforeEach
    void reset() {
        this.expenditureRepository.expenditures.clear();
        this.expenditureRepository.expenditures.addAll(this.fixtures);
        this.expenditureRepository.counter.set(100);
    }

    @Test
    void list() {
        this.testClient.get()
            .uri("/expenditures")
            .exchange()
            .expectStatus().isOk()
            .expectBody(JsonNode.class)
            .consumeWith(result -> {
                JsonNode body = result.getResponseBody();
                assertThat(body).isNotNull();
                assertThat(body.size()).isEqualTo(2);

                assertThat(body.get(0).get("expenditureId").asInt()).isEqualTo(1);
                assertThat(body.get(0).get("expenditureName").asText()).isEqualTo("本");
                assertThat(body.get(0).get("unitPrice").asInt()).isEqualTo(2000);
                assertThat(body.get(0).get("quantity").asInt()).isEqualTo(1);
                // TODO 後で実装します
                //assertThat(body.get(0).get("expenditureDate").asText()).isEqualTo("2019-04-01");

                assertThat(body.get(1).get("expenditureId").asInt()).isEqualTo(2);
                assertThat(body.get(1).get("expenditureName").asText()).isEqualTo("コーヒー");
                assertThat(body.get(1).get("unitPrice").asInt()).isEqualTo(300);
                assertThat(body.get(1).get("quantity").asInt()).isEqualTo(2);
                // TODO 後で実装します
                //assertThat(body.get(1).get("expenditureDate").asText()).isEqualTo("2019-04-02");
            });
    }

    @Test
    void get_200() {
        this.testClient.get()
            .uri("/expenditures/{expenditureId}", 1)
            .exchange()
            .expectStatus().isOk()
            .expectBody(JsonNode.class)
            .consumeWith(result -> {
                JsonNode body = result.getResponseBody();
                assertThat(body).isNotNull();

                assertThat(body.get("expenditureId").asInt()).isEqualTo(1);
                assertThat(body.get("expenditureName").asText()).isEqualTo("本");
                assertThat(body.get("unitPrice").asInt()).isEqualTo(2000);
                assertThat(body.get("quantity").asInt()).isEqualTo(1);
                // TODO 後で実装します
                //assertThat(body.get("expenditureDate").asText()).isEqualTo("2019-04-01");
            });
    }

    @Test
    void get_404() {
        this.testClient.get()
            .uri("/expenditures/{expenditureId}", 10000)
            .exchange()
            .expectStatus().isNotFound()
            .expectBody(JsonNode.class)
            .consumeWith(result -> {
                JsonNode body = result.getResponseBody();
                assertThat(body).isNotNull();

                assertThat(body.get("status").asInt()).isEqualTo(404);
                assertThat(body.get("error").asText()).isEqualTo("Not Found");
                assertThat(body.get("message").asText()).isEqualTo("The given expenditure is not found.");
            });
    }

    @Test
    void post_201() {
        Map<String, Object> expenditure = new LinkedHashMap<String, Object>() {

            {
                put("expenditureName", "ビール");
                put("unitPrice", 250);
                put("quantity", 1);
                put("expenditureDate", "2019-04-03");
            }
        };

        this.testClient.post()
            .uri("/expenditures")
            .bodyValue(expenditure)
            .exchange()
            .expectStatus().isCreated()
            .expectBody(JsonNode.class)
            .consumeWith(result -> {
                URI location = result.getResponseHeaders().getLocation();
                assertThat(location.toString()).isEqualTo("/expenditures/100");
                JsonNode body = result.getResponseBody();
                assertThat(body).isNotNull();

                assertThat(body.get("expenditureId").asInt()).isEqualTo(100);
                assertThat(body.get("expenditureName").asText()).isEqualTo("ビール");
                assertThat(body.get("unitPrice").asInt()).isEqualTo(250);
                assertThat(body.get("quantity").asInt()).isEqualTo(1);
                // TODO 後で実装します
                //assertThat(body.get("expenditureDate").asText()).isEqualTo("2019-04-03");
            });

        this.testClient.get()
            .uri("/expenditures/{expenditureId}", 100)
            .exchange()
            .expectStatus().isOk()
            .expectBody(JsonNode.class)
            .consumeWith(result -> {
                JsonNode body = result.getResponseBody();
                assertThat(body).isNotNull();

                assertThat(body.get("expenditureId").asInt()).isEqualTo(100);
                assertThat(body.get("expenditureName").asText()).isEqualTo("ビール");
                assertThat(body.get("unitPrice").asInt()).isEqualTo(250);
                assertThat(body.get("quantity").asInt()).isEqualTo(1);
                // TODO 後で実装します
                //assertThat(body.get("expenditureDate").asText()).isEqualTo("2019-04-03");
            });
    }

    // TODO 後で実装します
    //@Test
    void post_400() {
        Map<String, Object> expenditure = new LinkedHashMap<String, Object>() {

            {
                put("expenditureId", 1000);
                put("expenditureName", "");
                put("unitPrice", -1);
                put("quantity", -1);
            }
        };

        this.testClient.post()
            .uri("/expenditures")
            .bodyValue(expenditure)
            .exchange()
            .expectStatus().isBadRequest()
            .expectBody(JsonNode.class)
            .consumeWith(result -> {
                JsonNode body = result.getResponseBody();
                assertThat(body).isNotNull();

                assertThat(body.get("status").asInt()).isEqualTo(400);
                assertThat(body.get("error").asText()).isEqualTo("Bad Request");
                assertThat(body.get("details").size()).isEqualTo(5);
                assertThat(body.get("details").get("expenditureId").get(0).asText()).isEqualTo("\"expenditureId\" must be null");
                assertThat(body.get("details").get("expenditureName").get(0).asText()).isEqualTo("\"expenditureName\" must not be empty");
                assertThat(body.get("details").get("unitPrice").get(0).asText()).isEqualTo("\"unitPrice\" must be greater than 0");
                assertThat(body.get("details").get("quantity").get(0).asText()).isEqualTo("\"quantity\" must be greater than 0");
                assertThat(body.get("details").get("expenditureDate").get(0).asText()).isEqualTo("\"expenditureDate\" must not be null");
            });
    }

    @Test
    void delete() {
        this.testClient.delete()
            .uri("/expenditures/{expenditureId}", 1)
            .exchange()
            .expectStatus().isNoContent();

        this.testClient.get()
            .uri("/expenditures/{expenditureId}", 1)
            .exchange()
            .expectStatus().isNotFound();
    }
}

TODOを実装しないでテストを実行すると次のようにdelete以外のテストが失敗します。

image

TODOを実装して、全てのテストが成功したら、Appクラスのmainメソッドを実行して、次のリクエストを送り、正しくレスポンスが返ることを確認してください。

$ curl localhost:8080/expenditures -d "{\"expenditureName\":\"コーヒー\",\"unitPrice\":300,\"quantity\":1,\"expenditureDate\":[2019,6,3]}" -H "Content-Type: application/json"
{"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":[2019,6,3]}
$ curl localhost:8080/expenditures
[{"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":[2019,6,3]}]
$ curl localhost:8080/expenditures/1
{"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":[2019,6,3]}
$ curl -XDELETE localhost:8080/expenditures/1
$ curl localhost:8080/expenditures
[]

TODOの実装例は次の通りです。

ExpenditureHandlerの正解例
package com.example.expenditure;

import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;

import java.util.LinkedHashMap;

import static org.springframework.http.HttpStatus.NOT_FOUND;

public class ExpenditureHandler {

    private final ExpenditureRepository expenditureRepository;

    public ExpenditureHandler(ExpenditureRepository expenditureRepository) {
        this.expenditureRepository = expenditureRepository;
    }

    public RouterFunction<ServerResponse> routes() {
        return RouterFunctions.route()
            .GET("/expenditures", this::list)
            .POST("/expenditures", this::post)
            .GET("/expenditures/{expenditureId}", this::get)
            .DELETE("/expenditures/{expenditureId}", this::delete)
            .build();
    }

    Mono<ServerResponse> list(ServerRequest req) {
        return ServerResponse.ok().body(this.expenditureRepository.findAll(), Expenditure.class);
    }

    Mono<ServerResponse> post(ServerRequest req) {
        return req.bodyToMono(Expenditure.class)
            .flatMap(this.expenditureRepository::save)
            .flatMap(created -> ServerResponse
                .created(UriComponentsBuilder.fromUri(req.uri()).path("/{expenditureId}").build(created.getExpenditureId()))
                .bodyValue(created));
    }

    Mono<ServerResponse> get(ServerRequest req) {
        return this.expenditureRepository.findById(Integer.valueOf(req.pathVariable("expenditureId")))
            .flatMap(expenditure -> ServerResponse.ok().bodyValue(expenditure))
            .switchIfEmpty(Mono.defer(() -> ServerResponse.status(NOT_FOUND).bodyValue(new LinkedHashMap<String, Object>() {

                {
                    put("status", 404);
                    put("error", "Not Found");
                    put("message", "The given expenditure is not found.");
                }
            })));
    }

    Mono<ServerResponse> delete(ServerRequest req) {
        return ServerResponse.noContent()
            .build(this.expenditureRepository.deleteById(Integer.valueOf(req.pathVariable("expenditureId"))));
    }
}

日付フィールドの変更

expenditureDate[year, month, day]のリスト形式で返却されているので、"year-month-day"の文字列形式に変更しましょう。

App.javaに以下のメソッドを追加して、

    public static HandlerStrategies handlerStrategies() {
        return HandlerStrategies.empty()
            .codecs(configure -> {
                configure.registerDefaults(true);
                ServerCodecConfigurer.ServerDefaultCodecs defaults = configure
                    .defaultCodecs();
                ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
                    .dateFormat(new StdDateFormat())
                    .build();
                defaults.jackson2JsonEncoder(new Jackson2JsonEncoder(objectMapper));
                defaults.jackson2JsonDecoder(new Jackson2JsonDecoder(objectMapper));
            })
            .build();
    }

mainメソッド中の次のコード

        HttpHandler httpHandler = RouterFunctions.toHttpHandler(App.routes(),
            HandlerStrategies.builder().build());

        HttpHandler httpHandler = RouterFunctions.toHttpHandler(App.routes(),
            App.handlerStrategies());

に変更してください。

これに合わせてExpenditureHandlerTestbeforeメソッドも以下のように修正してください。

    @BeforeAll
    void before() {
        this.testClient = WebTestClient.bindToRouterFunction(this.expenditureHandler.routes())
            .handlerStrategies(App.handlerStrategies()) // 追加
            .build();
    }

ExpenditureHandlerTestコード内のTODO部分、次のような箇所に対して

                // TODO 後で実装します
                //assertThat(body.get(0).get("expenditureDate").asText()).isEqualTo("2019-04-01");

次のようにコメントを削除してください。

                assertThat(body.get(0).get("expenditureDate").asText()).isEqualTo("2019-04-01");

5箇所あります。

変更後の全てのテストが成功したら、Appクラスのmainメソッドを実行して、次のリクエストを送り、正しくレスポンスが返ることを確認してください。

$ curl localhost:8080/expenditures -d "{\"expenditureName\":\"コーヒー\",\"unitPrice\":300,\"quantity\":1,\"expenditureDate\":\"2019-06-03\"}" -H "Content-Type: application/json"
{"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":"2019-06-03"}
$ curl localhost:8080/expenditures
[{"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":"2019-06-03"}]
$ curl localhost:8080/expenditures/1
{"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":"2019-06-03"}
$ curl -XDELETE localhost:8080/expenditures/1
$ curl localhost:8080/expenditures
[]

Cloud Foundryへのデプロイ

作成したアプリケーションをCloud Foundry(Pivotal Web Services)にデプロイします。

cf loginでログインします。

cf login -a api.run.pivotal.io

プロジェクト直下でmanifest.ymlを作成し、次の内容を記述してください。

applications:
- name: moneyger
  path: target/moneyger-1.0.0-SNAPSHOT.jar
  memory: 128m
  env:
    JAVA_OPTS: '-XX:ReservedCodeCacheSize=22M -XX:MaxDirectMemorySize=22M -XX:MaxMetaspaceSize=54M -Xss512K'
    JBP_CONFIG_OPEN_JDK_JRE: '[memory_calculator: {stack_threads: 30}]'

manifest.ymlはかなり小さなメモリを使用するようにチューニングされています。コンテナのメモリサイズは128MBで、そのうち ReservedCodeCacheSizeが22MB、MaxDirectMemorySizeが22MB、MaxMetaspaceSizeが54MB、スレッドスタックが512KB * 30 = 15MB 残る15MBがヒープサイズに使用されます。 このメモリサイズをローカル環境で実行する場合は次のように実行してください。

JAVA_OPTS="-Xmx15m -XX:ReservedCodeCacheSize=22M -XX:MaxDirectMemorySize=22M -XX:MaxMetaspaceSize=54M -Xss512K"
java $JAVA_OPTS -jar target/moneyger-1.0.0-SNAPSHOT.jar

ビルドして、cf pushコマンドでデプロイします。

$ ./mvnw clean package -DskipTests=true
$ cf push --random-route

次のようなログが出力されてデプロイが完了します。

demo@example.com としてマニフェストから組織 APJ / スペース production にプッシュしています...
マニフェスト・ファイル /tmp/moneyger/manifest.yml を使用しています
アプリ情報を取得しています...
これらの属性でアプリを作成しています...
+ 名前:       moneyger
  パス:       /private/tmp/moneyger/target/moneyger-1.0.0-SNAPSHOT.jar
+ メモリー:   128M
  環境:
+   JAVA_OPTS
+   JBP_CONFIG_OPEN_JDK_JRE
  経路:
+   moneyger-chatty-zebra.cfapps.io

アプリ moneyger を作成しています...
経路をマップしています...
ローカル・ファイルをリモート・キャッシュと比較しています...
Packaging files to upload...
ファイルをアップロードしています...
 248.00 KiB / 248.00 KiB [=======================================================================================================================================================================================================================================] 100.00% 1s

API がファイルの処理を完了するのを待機しています...

アプリをステージングし、ログをトレースしています...
   Downloading dotnet_core_buildpack_beta...
   Downloading staticfile_buildpack...
   Downloading nodejs_buildpack...
   Downloading dotnet_core_buildpack...
   Downloading java_buildpack...
   Downloaded staticfile_buildpack
   Downloading ruby_buildpack...
   Downloaded nodejs_buildpack
   Downloading php_buildpack...
   Downloaded dotnet_core_buildpack_beta
   Downloading go_buildpack...
   Downloaded dotnet_core_buildpack
   Downloading python_buildpack...
   Downloaded java_buildpack
   Downloading binary_buildpack...
   Downloaded ruby_buildpack
   Downloaded php_buildpack
   Downloaded go_buildpack
   Downloaded python_buildpack
   Downloaded binary_buildpack
   Cell f237c36d-0a62-4579-a51b-b11ee1d58145 creating container for instance b1036536-3a31-4d47-8039-83952134de33
   Cell f237c36d-0a62-4579-a51b-b11ee1d58145 successfully created container for instance b1036536-3a31-4d47-8039-83952134de33
   Downloading app package...
   Downloaded app package (12.4M)
   -----> Java Buildpack v4.19 (offline) | https://github.com/cloudfoundry/java-buildpack.git#3f4eee2
   -----> Downloading Jvmkill Agent 1.16.0_RELEASE from https://java-buildpack.cloudfoundry.org/jvmkill/bionic/x86_64/jvmkill-1.16.0_RELEASE.so (found in cache)
   -----> Downloading Open Jdk JRE 1.8.0_202 from https://java-buildpack.cloudfoundry.org/openjdk/bionic/x86_64/openjdk-jre-1.8.0_202-bionic.tar.gz (found in cache)
          Expanding Open Jdk JRE to .java-buildpack/open_jdk_jre (1.8s)
          JVM DNS caching disabled in lieu of BOSH DNS caching
   -----> Downloading Open JDK Like Memory Calculator 3.13.0_RELEASE from https://java-buildpack.cloudfoundry.org/memory-calculator/bionic/x86_64/memory-calculator-3.13.0_RELEASE.tar.gz (found in cache)
          Loaded Classes: 11266, Threads: 30
   -----> Downloading Client Certificate Mapper 1.8.0_RELEASE from https://java-buildpack.cloudfoundry.org/client-certificate-mapper/client-certificate-mapper-1.8.0_RELEASE.jar (found in cache)
   -----> Downloading Container Security Provider 1.16.0_RELEASE from https://java-buildpack.cloudfoundry.org/container-security-provider/container-security-provider-1.16.0_RELEASE.jar (found in cache)
   -----> Downloading Spring Auto Reconfiguration 2.7.0_RELEASE from https://java-buildpack.cloudfoundry.org/auto-reconfiguration/auto-reconfiguration-2.7.0_RELEASE.jar (found in cache)
   Exit status 0
   Uploading droplet, build artifacts cache...
   Uploading droplet...
   Uploading build artifacts cache...
   Uploaded build artifacts cache (128B)
   Uploaded droplet (55.9M)
   Uploading complete
   Cell f237c36d-0a62-4579-a51b-b11ee1d58145 stopping instance b1036536-3a31-4d47-8039-83952134de33
   Cell f237c36d-0a62-4579-a51b-b11ee1d58145 destroying container for instance b1036536-3a31-4d47-8039-83952134de33

アプリが開始するのを待機しています...

名前:                   moneyger
要求された状態:         started
経路:                   moneyger-chatty-zebra.cfapps.io
最終アップロード日時:   Mon 27 May 16:16:10 JST 2019
スタック:               cflinuxfs3
ビルドパック:           client-certificate-mapper=1.8.0_RELEASE container-security-provider=1.16.0_RELEASE java-buildpack=v4.19-offline-https://github.com/cloudfoundry/java-buildpack.git#3f4eee2 java-main java-opts java-security jvmkill-agent=1.16.0_RELEASE
                        open-jdk-...

タイプ:           web
インスタンス:     1/1
メモリー使用量:   128M
開始コマンド:     JAVA_OPTS="-agentpath:$PWD/.java-buildpack/open_jdk_jre/bin/jvmkill-1.16.0_RELEASE=printHeapHistogram=1 -Djava.io.tmpdir=$TMPDIR -XX:ActiveProcessorCount=$(nproc)
                  -Djava.ext.dirs=$PWD/.java-buildpack/container_security_provider:$PWD/.java-buildpack/open_jdk_jre/lib/ext -Djava.security.properties=$PWD/.java-buildpack/java_security/java.security $JAVA_OPTS" &&
                  CALCULATED_MEMORY=$($PWD/.java-buildpack/open_jdk_jre/bin/java-buildpack-memory-calculator-3.13.0_RELEASE -totMemory=$MEMORY_LIMIT -loadedClasses=12567 -poolType=metaspace -stackThreads=30 -vmOptions="$JAVA_OPTS") && echo JVM Memory Configuration:
                  $CALCULATED_MEMORY && JAVA_OPTS="$JAVA_OPTS $CALCULATED_MEMORY" && MALLOC_ARENA_MAX=2 SERVER_PORT=$PORT eval exec $PWD/.java-buildpack/open_jdk_jre/bin/java $JAVA_OPTS -cp $PWD/. org.springframework.boot.loader.JarLauncher
     状態   開始日時               cpu    メモリー            ディスク         詳細
#0   実行   2019-05-27T07:16:32Z   0.0%   128M の中の 12.3M   1G の中の 124M  

アプリケーションのURLはhttps://moneyger-<ランダムな文字列>.cfapps.ioです。

次のリクエストを送り、正しくレスポンスが返ることを確認してください。

$ curl https://moneyger-<CHANGE ME>.cfapps.io/expenditures -d "{\"expenditureName\":\"コーヒー\",\"unitPrice\":300,\"quantity\":1,\"expenditureDate\":\"2019-06-03\"}" -H "Content-Type: application/json"
{"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":"2019-06-03"}
$ curl https://moneyger-<CHANGE ME>.cfapps.io/expenditures
[{"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":"2019-06-03"}]
$ curl https://moneyger-<CHANGE ME>.cfapps.io/expenditures/1
{"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":"2019-06-03"}
$ curl -XDELETE https://moneyger-<CHANGE ME>.cfapps.io/expenditures/1
$ curl https://moneyger-<CHANGE ME>.cfapps.io/expenditures
[]

Pivotal Web Servicesはメモリ課金であり、128MB使用の場合にかかる費用は常時起動した状態で、$2.70 (約300円) /月です。

Kubernetesへのデプロイ

次にKubernetesにデプロイする方法を説明します。Cloud Foundryを使う人はこのセクションはスキップしてください。

Dockerイメージの作成

まずはSpring BootアプリのDockerイメージを作成するために、packコマンドを使用します。 packのインストール方法はこちらを参照にしてください。

packでJavaアプリからDockerイメージに変換するにはビルドからpackで行う方法とビルド済みのjarから行う方法があります。 それぞれやり方を示すので好きな方を選択してください。

packコマンドでDocker Registryへのpushまで行うので、事前にdocker loginを済ませておいてください。

ソースのビルドから行う方法

$ pack build <image-name> --builder cloudfoundry/cnb:bionic --publish

# 例: pack build making/moneyger --builder cloudfoundry/cnb:bionic --publish

ソースコードからビルドを行う場合、初回はキャッシュがないためMaven Buildに時間がかかります。

いずれの場合でも二回目以降は--no-pullオプションをつけるどビルド時間を短縮できます。

二回目以降は次のようなログになります。

$ pack build making/moneyger --builder cloudfoundry/cnb:bionic --publish --no-pull
Selected run image cloudfoundry/run:base-cnb
Using build cache volume pack-cache-37598e8529a1.build
Executing lifecycle version 0.3.0
===> DETECTING  
[detector] Trying group 1 out of 6 with 14 buildpacks...
[detector] ======== Results ========
[detector] skip: Cloud Foundry Archive Expanding Buildpack
[detector] pass: Cloud Foundry OpenJDK Buildpack
[detector] pass: Cloud Foundry Build System Buildpack
[detector] pass: Cloud Foundry JVM Application Buildpack
[detector] pass: Cloud Foundry Apache Tomcat Buildpack
[detector] pass: Cloud Foundry Spring Boot Buildpack
[detector] pass: Cloud Foundry DistZip Buildpack
[detector] skip: Cloud Foundry Procfile Buildpack
[detector] skip: Cloud Foundry Azure Application Insights Buildpack
[detector] skip: Cloud Foundry Debug Buildpack
[detector] skip: Cloud Foundry Google Stackdriver Buildpack
[detector] skip: Cloud Foundry JDBC Buildpack
[detector] skip: Cloud Foundry JMX Buildpack
[detector] pass: Cloud Foundry Spring Auto-reconfiguration Buildpack
===> RESTORING  
[restorer] Restoring cached layer 'org.cloudfoundry.openjdk:23cded2b43261016f0f246c85c8948d4a9b7f2d44988f75dad69723a7a526094'
[restorer] Restoring cached layer 'org.cloudfoundry.openjdk:d2df8bc799b09c8375f79bf646747afac3d933bb1f65de71d6c78e7466ff8fe4'
[restorer] Restoring cached layer 'org.cloudfoundry.openjdk:openjdk-jdk'
[restorer] Restoring cached layer 'org.cloudfoundry.buildsystem:build-system-cache'
[restorer] Restoring cached layer 'org.cloudfoundry.jvmapplication:executable-jar'
[restorer] Restoring cached layer 'org.cloudfoundry.springboot:spring-boot'
[restorer] Restoring cached layer 'org.cloudfoundry.springautoreconfiguration:0d524877db7344ec34620f7e46254053568292f5ce514f74e3a0e9b2dbfc338b'
===> ANALYZING  
[analyzer] Analyzing image 'index.docker.io/making/moneyger@sha256:9491786594cdda87cacd89b2ca8dce641236b1f3b18cea26c14410eb71f9c23f'
[analyzer] Using cached layer 'org.cloudfoundry.openjdk:23cded2b43261016f0f246c85c8948d4a9b7f2d44988f75dad69723a7a526094'
[analyzer] Using cached layer 'org.cloudfoundry.openjdk:d2df8bc799b09c8375f79bf646747afac3d933bb1f65de71d6c78e7466ff8fe4'
[analyzer] Using cached layer 'org.cloudfoundry.openjdk:openjdk-jdk'
[analyzer] Writing metadata for uncached layer 'org.cloudfoundry.openjdk:openjdk-jre'
[analyzer] Using cached layer 'org.cloudfoundry.buildsystem:build-system-cache'
[analyzer] Using cached launch layer 'org.cloudfoundry.jvmapplication:executable-jar'
[analyzer] Rewriting metadata for layer 'org.cloudfoundry.jvmapplication:executable-jar'
[analyzer] Using cached launch layer 'org.cloudfoundry.springboot:spring-boot'
[analyzer] Rewriting metadata for layer 'org.cloudfoundry.springboot:spring-boot'
[analyzer] Using cached layer 'org.cloudfoundry.springautoreconfiguration:0d524877db7344ec34620f7e46254053568292f5ce514f74e3a0e9b2dbfc338b'
[analyzer] Writing metadata for uncached layer 'org.cloudfoundry.springautoreconfiguration:auto-reconfiguration'
===> BUILDING   
[builder] 
[builder] Cloud Foundry OpenJDK Buildpack 1.0.0-M9
[builder]   OpenJDK JDK 11.0.3: Reusing cached layer
[builder]   OpenJDK JRE 11.0.3: Reusing cached layer
[builder] 
[builder] Cloud Foundry Build System Buildpack 1.0.0-M9
[builder]     Using wrapper
[builder]     Linking Cache to /home/cnb/.m2
[builder]   Compiled Application: Contributing to layer
[builder] /workspace
[builder] [INFO] Scanning for projects...
[builder] [INFO] 
[builder] [INFO] ------------------------< com.example:moneyger >------------------------
[builder] [INFO] Building moneyger 1.0.0-SNAPSHOT
[builder] [INFO] --------------------------------[ jar ]---------------------------------
[builder] [INFO] 
[builder] [INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ moneyger ---
[builder] [INFO] Using 'UTF-8' encoding to copy filtered resources.
[builder] [INFO] Copying 6 resources
[builder] [INFO] 
[builder] [INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ moneyger ---
[builder] [INFO] Changes detected - recompiling the module!
[builder] [INFO] Compiling 9 source files to /workspace/target/classes
[builder] [WARNING] /workspace/src/main/java/com/example/MessageHandler.java: /workspace/src/main/java/com/example/MessageHandler.java uses or overrides a deprecated API.
[builder] [WARNING] /workspace/src/main/java/com/example/MessageHandler.java: Recompile with -Xlint:deprecation for details.
[builder] [INFO] 
[builder] [INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ moneyger ---
[builder] [INFO] Not copying test resources
[builder] [INFO] 
[builder] [INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ moneyger ---
[builder] [INFO] Not compiling test sources
[builder] [INFO] 
[builder] [INFO] --- maven-surefire-plugin:2.22.0:test (default-test) @ moneyger ---
[builder] [INFO] Tests are skipped.
[builder] [INFO] 
[builder] [INFO] --- maven-jar-plugin:2.4:jar (default-jar) @ moneyger ---
[builder] [INFO] Building jar: /workspace/target/moneyger-1.0.0-SNAPSHOT.jar
[builder] [INFO] 
[builder] [INFO] --- spring-boot-maven-plugin:2.2.0.BUILD-SNAPSHOT:repackage (default) @ moneyger ---
[builder] [INFO] Replacing main artifact with repackaged archive
[builder] [INFO] ------------------------------------------------------------------------
[builder] [INFO] BUILD SUCCESS
[builder] [INFO] ------------------------------------------------------------------------
[builder] [INFO] Total time:  3.504 s
[builder] [INFO] Finished at: 2019-08-26T07:38:51Z
[builder] [INFO] ------------------------------------------------------------------------
[builder]   Removing source code
[builder] 
[builder] Cloud Foundry JVM Application Buildpack 1.0.0-M9
[builder]   Executable JAR: Reusing cached layer
[builder]   Process types:
[builder]     executable-jar: java -cp $CLASSPATH $JAVA_OPTS org.springframework.boot.loader.JarLauncher
[builder]     task:           java -cp $CLASSPATH $JAVA_OPTS org.springframework.boot.loader.JarLauncher
[builder]     web:            java -cp $CLASSPATH $JAVA_OPTS org.springframework.boot.loader.JarLauncher
[builder] 
[builder] Cloud Foundry Spring Boot Buildpack 1.0.0-M9
[builder]   Spring Boot 2.2.0.BUILD-SNAPSHOT: Reusing cached layer
[builder]   Process types:
[builder]     spring-boot: java -cp $CLASSPATH $JAVA_OPTS com.example.App
[builder]     task:        java -cp $CLASSPATH $JAVA_OPTS com.example.App
[builder]     web:         java -cp $CLASSPATH $JAVA_OPTS com.example.App
[builder] 
[builder] Cloud Foundry Spring Auto-reconfiguration Buildpack 1.0.0-M9
[builder]   Spring Auto-reconfiguration 2.7.0: Reusing cached layer
===> EXPORTING  
[exporter] Reusing layers from image 'index.docker.io/making/moneyger@sha256:9491786594cdda87cacd89b2ca8dce641236b1f3b18cea26c14410eb71f9c23f'
[exporter] Exporting layer 'app' with SHA sha256:c95d6559b8658f56939bcbe6d4c41150a363210a6810e4eb31bf7cee45f3aed3
[exporter] Reusing layer 'config' with SHA sha256:e26b93feb778917d0b68ee79a499d45ecd4a49ea52042192200c77df580c4169
[exporter] Reusing layer 'launcher' with SHA sha256:2187c4179a3ddaae0e4ad2612c576b3b594927ba15dd610bbf720197209ceaa6
[exporter] Reusing layer 'org.cloudfoundry.openjdk:openjdk-jre' with SHA sha256:9c84525dcbc758ce1754cce9b8f4d59f5ea6cf103a6c47043d900cad838052da
[exporter] Reusing layer 'org.cloudfoundry.jvmapplication:executable-jar' with SHA sha256:3d9310c8403c8710b6adcd40999547d6dc790513c64bba6abc7a338b429c35d2
[exporter] Reusing layer 'org.cloudfoundry.springboot:spring-boot' with SHA sha256:d776470042bc5bbb0270dcf1678f4eea7900f1a76caa15f35f9d4b25dbf7021e
[exporter] Reusing layer 'org.cloudfoundry.springautoreconfiguration:auto-reconfiguration' with SHA sha256:f61d2b65c75f9f5f2f2185fccb0be37ec39535bf89975c1632291f5116720479
[exporter] *** Images:
[exporter]       index.docker.io/making/moneyger:latest - succeeded
[exporter] 
[exporter] *** Digest: sha256:2d1025e87b7e8036c0bf6a7f9b357e2031d0932c03f56f9a595a1a62c710d02f
===> CACHING    
[cacher] Reusing layer 'org.cloudfoundry.openjdk:23cded2b43261016f0f246c85c8948d4a9b7f2d44988f75dad69723a7a526094' with SHA sha256:f0d412ec133fe252e7e1218ba97fc8a275bed7a65e931ed083c2c9b5bf63607b
[cacher] Reusing layer 'org.cloudfoundry.openjdk:d2df8bc799b09c8375f79bf646747afac3d933bb1f65de71d6c78e7466ff8fe4' with SHA sha256:636cde73aeca34a1e8730cdb74c4566fbf6ac7646fbbb2370b137ace1b4facf2
[cacher] Reusing layer 'org.cloudfoundry.openjdk:openjdk-jdk' with SHA sha256:d0551ffa6a58d07f92201ebcd6ff649fe9afb266f580fcd6eb67acf5bb8fc8c3
[cacher] Reusing layer 'org.cloudfoundry.buildsystem:build-system-cache' with SHA sha256:fe2ac9cb542d9dfa296a55c67136786dd18a47615dc27d57a9d68d9625f06c64
[cacher] Reusing layer 'org.cloudfoundry.jvmapplication:executable-jar' with SHA sha256:3d9310c8403c8710b6adcd40999547d6dc790513c64bba6abc7a338b429c35d2
[cacher] Reusing layer 'org.cloudfoundry.springboot:spring-boot' with SHA sha256:d776470042bc5bbb0270dcf1678f4eea7900f1a76caa15f35f9d4b25dbf7021e
[cacher] Reusing layer 'org.cloudfoundry.springautoreconfiguration:0d524877db7344ec34620f7e46254053568292f5ce514f74e3a0e9b2dbfc338b' with SHA sha256:8768e331517cabc14ab245a654e48e01a0a46922955704ad80b1385d3f033c28
Successfully built image making/moneyger

デフォルトではJava 11が使用されます。Java 8で使用したい場合は--env BP_JAVA_VERSION=8.*をつけてください。

ビルド済みのjarから行う方法

$ ./mvnw clean package -DskipTests=true
$ pack build <image-name> -p target/moneyger-1.0.0-SNAPSHOT.jar --builder cloudfoundry/cnb:bionic --publish

# 例: pack build making/moneyger -p target/moneyger-1.0.0-SNAPSHOT.jar --builder cloudfoundry/cnb:bionic --publish

Dockerイメージをローカルで起動して動作確認しましょう。

docker run --rm -p 8080:8080 <image-name>
# 例: docker run --rm -p 8080:8080 making/moneyger
Kubernetesへデプロイ

moneyger.ymlを作成して次の内容を記述してください。

apiVersion: v1
kind: Namespace
metadata:
  name: moneyger
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: moneyger
  namespace: moneyger
spec:
  replicas: 1
  selector:
    matchLabels:
      app: moneyger
  template:
    metadata:
      labels:
        app: moneyger
    spec:
      containers:
      - image: <image-name>:latest
        # 例: 
        # image: making/moneyger:latest
        name: moneyger
        ports:
        - containerPort: 8080
        env:
        - name: _JAVA_OPTIONS
          value: "-Xmx15m -XX:ReservedCodeCacheSize=22M -XX:MaxDirectMemorySize=22M -XX:MaxMetaspaceSize=54M -Xss512K"
        resources:
          limits:
            memory: "128Mi"
          requests:
            memory: "128Mi"
        readinessProbe:
          httpGet:
            path: /expenditures
            port: 8080
            scheme: HTTP
          initialDelaySeconds: 5
          timeoutSeconds: 3
          failureThreshold: 3
          periodSeconds: 5
---
kind: Service
apiVersion: v1
metadata:
  name: moneyger
  namespace: moneyger
spec:
  type: LoadBalancer
  # 環境によってはNodePort
  selector:
    app: moneyger
  ports:
  - protocol: TCP
    port: 8080

次のコマンドでデプロイしてください。

kubectl apply -f moneyger.yml

次のコマンドでPodのSTATUSRunningになっていることを確認してください。 またServiceのEXTERNAL-IPにDNS名またはIPアドレスが表示されていることを確認してください。

$ kubectl get all -n moneyger
NAME                            READY   STATUS    RESTARTS   AGE
pod/moneyger-7d75db74cb-wv2fh   1/1     Running   0          35s

NAME               TYPE           CLUSTER-IP     EXTERNAL-IP                                                                    PORT(S)          AGE
service/moneyger   LoadBalancer   10.100.200.6   a6f010cd7c7d911e99cb7066f53a5a1a-1422899677.ap-northeast-1.elb.amazonaws.com   8080:32340/TCP   35s

NAME                       READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/moneyger   1/1     1            1           35s

NAME                                  DESIRED   CURRENT   READY   AGE
replicaset.apps/moneyger-7d75db74cb   1         1         1       35s

アプリケーションのURLはhttp://<EXTERNAL-IP>:8080です。

次のリクエストを送り、正しくレスポンスが返ることを確認してください。

$ curl http://<EXTERNAL-IP>:8080/expenditures -d "{\"expenditureName\":\"コーヒー\",\"unitPrice\":300,\"quantity\":1,\"expenditureDate\":\"2019-06-03\"}" -H "Content-Type: application/json"
{"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":"2019-06-03"}
$ curl http://<EXTERNAL-IP>:8080/expenditures
[{"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":"2019-06-03"}]
$ curl http://<EXTERNAL-IP>:8080/expenditures/1
{"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":"2019-06-03"}
$ curl -XDELETE http://<EXTERNAL-IP>:8080/expenditures/1
$ curl http://<EXTERNAL-IP>:8080/expenditures
[]

補足

routes()の定義は次のようにネストして記述することもできます。

    public RouterFunction<ServerResponse> routes() {
        return RouterFunctions.route()
            .path("/expenditures", b -> b
                .GET("/", this::list)
                .POST("/", this::post)
                .GET("/{expenditureId}", this::get)
                .DELETE("/{expenditureId}", this::delete))
            .build();
    }

✒️️ Edit  ⏰ History  🗑 Delete