本ハンズオンで、次の図のような簡易家計簿のAPIサーバーをSpring WebFlux.fnを使って実装します。 あえてSpring BootもDependency Injectionも使わないシンプルなWebアプリとして実装します。
ハンズオンコンテンツ
- 簡易家計簿Moneygerプロジェクトの作成 👈
- YAVIによるValidationの実装
- R2DBCによるデータベースアクセス
- Web UIの追加
- 例外ハンドリングの改善
- 収入APIの実装
- Spring Bootアプリに変換
- 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.java
とExpenditureBuilder.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.java
のroutes
メソッドを下のコードに変更してください。
static RouterFunction<ServerResponse> routes() {
return new ExpenditureHandler(new InMemoryExpenditureRepository()).routes();
}
src/test/java/com/example/expenditure
にExpenditureHandlerTest
を作成して、次のテストコードを記述してください。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
以外のテストが失敗します。
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());
に変更してください。
これに合わせてExpenditureHandlerTest
のbefore
メソッドも以下のように修正してください。
@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のSTATUS
がRunning
になっていることを確認してください。
また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();
}