これは、なにをしたくて書いたもの?
Quarkusで、ReactiveなMySQLクライアントを使えるというので、試してみようかなと。
Quakus Reactive SQL Clients
Quarkusでデータベースアクセスを行う際にまず挙がってくるのはJPA(Hibernate)かなと思いますが、ReactiveなSQLクライアントも
備えています。
Quarkus - Reactive SQL Clients
1.8.1の時点だと、以下のデータベースに対応しているようです。
ドキュメントは、PostgreSQLを使って書かれています。
この機能は、SmallRye MunityとVert.xのReactive SQL Clientを使用して作成されています。
https://github.com/smallrye/smallrye-reactive-utils/tree/1.1.0/vertx-mutiny-clients
Vert.xのReactive SQL Clientが、SmallRye Munityでラップされている感じですね。
今回は、この中のReactive MySQL Clientを使用します。JDBCドライバを使っているわけではないんですよねぇ…。
Reactive MySQL Client - Vert.x
データベースごとに、Extensionおよび使用するクラスが異なります。
では、試してみましょう。
環境
今回の環境は、こちら。
$ java --version openjdk 11.0.8 2020-07-14 OpenJDK Runtime Environment (build 11.0.8+10-post-Ubuntu-0ubuntu120.04) OpenJDK 64-Bit Server VM (build 11.0.8+10-post-Ubuntu-0ubuntu120.04, mixed mode, sharing) $ mvn --version Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 11.0.8, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "4.15.0-112-generic", arch: "amd64", family: "unix"
MySQLは8.0.21を使用し、172.17.0.2
で動作しているものとします。
'mysql Ver 8.0.21 for Linux on x86_64 (MySQL Community Server - GPL)' started.
プロジェクトの作成
最初に、プロジェクトを作りましょう。
書籍をお題に、Reactive MySQL Clientを使ってテーブルにアクセスし、JAX-RSで操作可能なアプリケーションを作成しましょう。
$ mvn io.quarkus:quarkus-maven-plugin:1.8.1.Final:create \ -DprojectGroupId=org.littlewings \ -DprojectArtifactId=resteasy-reactive-mysql \ -Dextensions="resteasy-mutiny, resteasy-jsonb, reactive-mysql-client"
今回、特にポイントとなるのはreactive-mysql-client
ですね。PostgreSQLやDB2の場合は、mysql
の部分を変更することになります。
作成したプロジェクトのディレクトリ内に移動。
$ cd resteasy-reactive-mysql
雛形
お題を書籍にするので、まずはテーブルを作成します。
mysql> create table book( -> isbn varchar(14), -> title varchar(255), -> price int, -> primary key(isbn) -> ); Query OK, 0 rows affected (0.10 sec)
Reactive SQL Clientにもスキーマ定義や初期データを登録するための方法はあるのですが、今回はパス。
プロダクション環境ではFlywayを使った方が良いと書いていますし、このための処理を書くのもなぁ、と。
書籍クラス。JAX-RSリソースクラスで扱うものになります。
src/main/java/org/littlewings/quarkus/reactive/mysql/Book.java
package org.littlewings.quarkus.reactive.mysql; public class Book { String isbn; String title; int price; public static Book create(String isbn, String title, int price) { Book book = new Book(); book.setIsbn(isbn); book.setTitle(title); book.setPrice(price); return book; } // getter/setterは省略 }
続いて、JAX-RSリソースクラス。
src/main/java/org/littlewings/quarkus/reactive/mysql/BookResource.java
package org.littlewings.quarkus.reactive.mysql; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import javax.inject.Inject; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.core.MediaType; import io.smallrye.mutiny.Multi; import io.smallrye.mutiny.Uni; import io.vertx.mutiny.mysqlclient.MySQLPool; import io.vertx.mutiny.sqlclient.Row; import io.vertx.mutiny.sqlclient.RowIterator; import io.vertx.mutiny.sqlclient.SqlClientHelper; import io.vertx.mutiny.sqlclient.Tuple; @Path("book") public class BookResource { @Inject MySQLPool client; // ここに処理を書く }
主要な処理は、この後で記載していきます。
MySQLを操作するのは、こちらのMySQLPooL
クラスを使用して行います。
@Inject
MySQLPool client;
クラスパス上に同じような名前のクラスがいくつかあるのですが、io.vertx.mutiny.mysqlclient.MySQLPool
を使用してください。
他のデータベースの場合は、利用するクラスも変更になります。
データベースごとにクラスがあるわけですが、いずれもio.vertx.mutiny.sqlclient.Pool
クラスを継承しているので…JDBCのように、
同じ型で扱えないものでしょうか…?
今回、ここの確認はパスします。
ちなみに、ドキュメントはPostgreSQLの例で記述されているので、MySQLの場合は以下あたりを参考にしました。
https://github.com/quarkusio/quarkus/tree/1.8.1.Final/integration-tests/reactive-mysql-client
設定
Reactive MySQL Clientの設定を行います。
今回は、このように設定。
src/main/resources/application.properties
# Configuration file # key = value quarkus.datasource.db-kind=mysql quarkus.datasource.username=kazuhira quarkus.datasource.password=password quarkus.datasource.reactive.url=mysql://172.17.0.2:3306/practice quarkus.datasource.reactive.mysql.charset=utf8mb4
quarkus.datasource.db-kind
というのはデータベースの種類に応じた値を設定する必要があるので、今回はmysql
を指定。
MySQL以外のデータベースの場合の値は、こちらを参照。
その他の設定項目については、ドキュメントを参照してください。
ソースコードだと、このあたりを見るとよいでしょう。
データアクセスを行う処理を書く
では、データアクセスを行う処理を書いていきましょう。
このあたりを見ながら。
MunityのUni
やMulti
を使って、処理を書いていくことになります。
検索系クエリ
まずは、シンプルなクエリを使った例を見つつ
PreparedQuery
(PreparedStatement
)を使った例を見ていきます。
?
で、パラメーターをバインドするようです。
ドキュメントと違うのでは?と思うのですが、これは利用するSQL Clientによって差があるみたいですね。
Reactive MySQL Client / Prepared queries
Reactive PostgreSQL Client / Prepared queries
Quarkusのドキュメントは、PostgreSQLを使った例なので。
では、続けます。
パラメーターなしで、全件取得。
@GET @Produces(MediaType.APPLICATION_JSON) public Multi<Book> findAll() { return client .preparedQuery("select isbn, title, price from book order by price desc") .execute() .onItem() .transformToMulti(Multi.createFrom()::iterable) .onItem() .transform(row -> Book.create(row.getString("isbn"), row.getString("title"), row.getInteger("price"))); }
主キー検索。
@GET @Path("{isbn}") @Produces(MediaType.APPLICATION_JSON) public Uni<Book> find(@PathParam("isbn") String isbn) { return client .preparedQuery("select isbn, title, price from book where isbn = ?") .execute(Tuple.of(isbn)) .onItem() .transform(rows -> { RowIterator<Row> iterator = rows.iterator(); Row row = iterator.next(); return Book.create(row.getString("isbn"), row.getString("title"), row.getInteger("price")); }); }
MySQLPool#preparedQuery
でクエリを作成し、PreparedQuery#execute
にTuple
を渡すことでパラメーターをバインドします。
return client .preparedQuery("select isbn, title, price from book where isbn = ?") .execute(Tuple.of(isbn))
結果はRowSet
になり、RowIterator
から結果を取得することができます。
.transform(rows -> { RowIterator<Row> iterator = rows.iterator(); Row row = iterator.next(); return Book.create(row.getString("isbn"), row.getString("title"), row.getInteger("price")); });
更新系クエリ
続いて、更新系のクエリを書いていきます。
トランザクションも使ってみましょう。
SqlClientHelper
を使った簡単なトランザクション管理と、Transaction
を使った手動のトランザクション管理の2つの方法が
あります。
1件insert。
@PUT @Path("{isbn}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Uni<Book> put(Book book) { return SqlClientHelper .inTransactionUni(client, tx -> tx .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)") .execute(Tuple.of(book.getIsbn(), book.getTitle(), book.getPrice())) .onItem() .transform(rows -> book) ); }
SqlClientHelper#inTransactionUni
やSqlClientHelper#inTransactionMulti
内で、トランザクションをコントロールします。
バッチ更新。
@POST @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Multi<Book> register(List<Book> books) { return SqlClientHelper .inTransactionMulti(client, tx -> { List<Tuple> tuples = books.stream().map(b -> Tuple.of(b.getIsbn(), b.getTitle(), b.getPrice())).collect(Collectors.toList()); return tx .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)") .executeBatch(tuples) .onItem() .transformToMulti(rows -> Multi.createFrom().items(books.stream())); }); }
PreparedQuery#executeBatch
で、List<Tuple>
を使ってバッチ更新を行うことができます。
List<Tuple> tuples = books.stream().map(b -> Tuple.of(b.getIsbn(), b.getTitle(), b.getPrice())).collect(Collectors.toList()); return tx .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)") .executeBatch(tuples)
トランザクション管理は、こちらはSqlClientHelper#inTransactionMulti
を使用しています。
return SqlClientHelper
.inTransactionMulti(client, tx -> {
begin
、commit
、rollback
を使う方法も試してみましょう。
こちらは、ロールバックするようにしてあります。
@POST @Path("manual") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Multi<Book> manualTransactionRegister(List<Book> books) { List<Tuple> tuples = books.stream().map(b -> Tuple.of(b.getIsbn(), b.getTitle(), b.getPrice())).collect(Collectors.toList()); return client .begin() .onItem() .transformToMulti(tx -> tx .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)") .executeBatch(tuples) .onItem() .<Book>transformToMulti(v -> { throw new RuntimeException("oops!"); // -> rollback }) // .transformToMulti(v1 -> tx.commit().onItem().transformToMulti(v2 -> Multi.createFrom().items(books.stream()))) // -> commit .onFailure() .recoverWithMulti(th -> tx.rollback().onItem().transformToMulti(v -> Multi.createFrom().failure(th)) ) ); }
こうすると、コミットするコードになります。
/* .<Book>transformToMulti(v -> { throw new RuntimeException("oops!"); // -> rollback }) */ .transformToMulti(v1 -> tx.commit().onItem().transformToMulti(v2 -> Multi.createFrom().items(books.stream()))) // -> commit
このAPIを使ったサンプルがなくて困ったのですが、SqlClientHelper#inTransaction〜
の中で使われていたので、こちらを
参考にしました。
最後に、truncateも。
@DELETE @Produces(MediaType.APPLICATION_JSON) public Uni<Map<String, String>> truncate() { return client .preparedQuery("truncate table book") .execute() .onItem() .transform(rows -> Map.of("message", "OK")); }
こんな感じで。
確認
では、確認していきましょう。
ビルドして、起動。
$ mvn package $ java -jar target/resteasy-reactive-mysql-1.0-SNAPSHOT-runner.jar
データを1件登録。
$ curl -i -XPUT -H 'Content-Type: application/json' localhost:8080/book/978-4798161488 -d '{ > "isbn": "978-4798161488", > "title": "MySQL徹底入門 第4版 MySQL 8.0対応", > "price": 4180 > }' HTTP/1.1 200 OK Content-Length: 90 Content-Type: application/json {"isbn":"978-4798161488","price":4180,"title":"MySQL徹底入門 第4版 MySQL 8.0対応"}
複数件登録。
$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/book -d '[ > { > "isbn": "978-4621303252", > "title": "Effective Java 第3版", > "price": 4400 > }, > { > "isbn": "978-4295008477", > "title": "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", > "price": 2860 > }, > { > "isbn": "978-4798151120", > "title": "独習Java 新版", > "price": 3278 > } > ]' HTTP/1.1 200 OK Content-Length: 287 Content-Type: application/json [{"isbn":"978-4621303252","price":4400,"title":"Effective Java 第3版"},{"isbn":"978-4295008477","price":2860,"title":"新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]"},{"isbn":"978-4798151120","price":3278,"title":"独習Java 新版"}]
1件取得。
$ curl -i localhost:8080/book/978-4798161488 HTTP/1.1 200 OK Content-Length: 90 Content-Type: application/json {"isbn":"978-4798161488","price":4180,"title":"MySQL徹底入門 第4版 MySQL 8.0対応"}
全件取得。
$ curl -s localhost:8080/book | jq [ { "isbn": "978-4621303252", "price": 4400, "title": "Effective Java 第3版" }, { "isbn": "978-4798161488", "price": 4180, "title": "MySQL徹底入門 第4版 MySQL 8.0対応" }, { "isbn": "978-4798151120", "price": 3278, "title": "独習Java 新版" }, { "isbn": "978-4295008477", "price": 2860, "title": "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]" } ]
ロールバックする例。
$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/book/manual -d '[ > { > "isbn": "978-4798147406", > "title": "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド", > "price": 3960 > }, > { > "isbn": "978-4873116389", > "title": "実践ハイパフォーマンスMySQL 第3版", > "price": 5280 > } > ]' HTTP/1.1 500 Internal Server Error content-length: 0
データは増えていません。
$ curl -s localhost:8080/book | jq [ { "isbn": "978-4621303252", "price": 4400, "title": "Effective Java 第3版" }, { "isbn": "978-4798161488", "price": 4180, "title": "MySQL徹底入門 第4版 MySQL 8.0対応" }, { "isbn": "978-4798151120", "price": 3278, "title": "独習Java 新版" }, { "isbn": "978-4295008477", "price": 2860, "title": "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]" } ]
ログはこんな感じになります。
2020-09-26 03:48:16,822 ERROR [org.jbo.res.res.i18n] (vert.x-eventloop-thread-14) RESTEASY002020: Unhandled asynchronous exception, sending back 500: java.lang.RuntimeException: oops! at org.littlewings.quarkus.reactive.mysql.BookResource.lambda$manualTransactionRegister$8(BookResource.java:107) at io.smallrye.mutiny.operators.UniOnItemTransformToMulti$FlatMapPublisherSubscriber.onItem(UniOnItemTransformToMulti.java:117) at io.smallrye.mutiny.context.ContextPropagationUniInterceptor$1.lambda$onItem$1(ContextPropagationUniInterceptor.java:35) at io.smallrye.context.SmallRyeThreadContext.lambda$withContext$0(SmallRyeThreadContext.java:217) at io.smallrye.mutiny.context.ContextPropagationUniInterceptor$1.onItem(ContextPropagationUniInterceptor.java:35) at io.smallrye.mutiny.operators.UniSerializedSubscriber.onItem(UniSerializedSubscriber.java:72) at io.smallrye.mutiny.vertx.AsyncResultUni.lambda$subscribing$1(AsyncResultUni.java:34) at io.vertx.mutiny.sqlclient.PreparedQuery$3.handle(PreparedQuery.java:161) at io.vertx.mutiny.sqlclient.PreparedQuery$3.handle(PreparedQuery.java:158) at io.vertx.sqlclient.impl.SqlResultHandler.complete(SqlResultHandler.java:97) at io.vertx.sqlclient.impl.SqlResultHandler.handle(SqlResultHandler.java:86) at io.vertx.sqlclient.impl.SqlResultHandler.handle(SqlResultHandler.java:33) at io.vertx.sqlclient.impl.TransactionImpl.lambda$wrap$2(TransactionImpl.java:139)
最後にtruncate。
$ curl -i -XDELETE localhost:8080/book HTTP/1.1 200 OK Content-Length: 16 Content-Type: application/json {"message":"OK"}
0件になりました。
$ curl -s localhost:8080/book | jq []
まとめ
QuarkusのReactive MySQL Clientを試してみました。
リアクティブなSQLクライアントを使うのは初めてだったので、だいぶてこずりましたが、なんとかなりました…。
練習を繰り返さないと使いこなせない気がとてもとてもするのですが、Munity含め、少しずつ頑張ってみましょう。
最後に、作成したJAX-RSリソースクラス全体を載せておきます。
src/main/java/org/littlewings/quarkus/reactive/mysql/BookResource.java
package org.littlewings.quarkus.reactive.mysql; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import javax.inject.Inject; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.core.MediaType; import io.smallrye.mutiny.Multi; import io.smallrye.mutiny.Uni; import io.vertx.mutiny.mysqlclient.MySQLPool; import io.vertx.mutiny.sqlclient.Row; import io.vertx.mutiny.sqlclient.RowIterator; import io.vertx.mutiny.sqlclient.SqlClientHelper; import io.vertx.mutiny.sqlclient.Tuple; @Path("book") public class BookResource { @Inject MySQLPool client; @GET @Produces(MediaType.APPLICATION_JSON) public Multi<Book> findAll() { return client .preparedQuery("select isbn, title, price from book order by price desc") .execute() .onItem() .transformToMulti(Multi.createFrom()::iterable) .onItem() .transform(row -> Book.create(row.getString("isbn"), row.getString("title"), row.getInteger("price"))); } @GET @Path("{isbn}") @Produces(MediaType.APPLICATION_JSON) public Uni<Book> find(@PathParam("isbn") String isbn) { return client .preparedQuery("select isbn, title, price from book where isbn = ?") .execute(Tuple.of(isbn)) .onItem() .transform(rows -> { RowIterator<Row> iterator = rows.iterator(); Row row = iterator.next(); return Book.create(row.getString("isbn"), row.getString("title"), row.getInteger("price")); }); } @PUT @Path("{isbn}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Uni<Book> put(Book book) { return SqlClientHelper .inTransactionUni(client, tx -> tx .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)") .execute(Tuple.of(book.getIsbn(), book.getTitle(), book.getPrice())) .onItem() .transform(rows -> book) ); } @POST @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Multi<Book> register(List<Book> books) { return SqlClientHelper .inTransactionMulti(client, tx -> { List<Tuple> tuples = books.stream().map(b -> Tuple.of(b.getIsbn(), b.getTitle(), b.getPrice())).collect(Collectors.toList()); return tx .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)") .executeBatch(tuples) .onItem() .transformToMulti(rows -> Multi.createFrom().items(books.stream())); }); } @POST @Path("manual") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Multi<Book> manualTransactionRegister(List<Book> books) { List<Tuple> tuples = books.stream().map(b -> Tuple.of(b.getIsbn(), b.getTitle(), b.getPrice())).collect(Collectors.toList()); return client .begin() .onItem() .transformToMulti(tx -> tx .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)") .executeBatch(tuples) .onItem() .<Book>transformToMulti(v -> { throw new RuntimeException("oops!"); // -> rollback }) // .transformToMulti(v1 -> tx.commit().onItem().transformToMulti(v2 -> Multi.createFrom().items(books.stream()))) // -> commit .onFailure() .recoverWithMulti(th -> tx.rollback().onItem().transformToMulti(v -> Multi.createFrom().failure(th)) ) ); } @DELETE @Produces(MediaType.APPLICATION_JSON) public Uni<Map<String, String>> truncate() { return client .preparedQuery("truncate table book") .execute() .onItem() .transform(rows -> Map.of("message", "OK")); } }