DB を使う#
設定する#
application.conf にデータソースを書きます。
db {
blog_example {
main = true
driver = "org.mariadb.jdbc.Driver"
url = "jdbc:mariadb://127.0.0.1:3306/blog_example?useUnicode=true&characterEncoding=UTF-8"
url = ${?DB_URL}
username = "blog"
username = ${?DB_USER}
password = ""
password = ${?DB_PASSWORD}
maximum_pool_size = 10
connection_pool_type = "hikari"
}
}
codegen {
package = "db"
}
秘密はファイルに書かないでください。 ${?ENV_NAME} の形にしておくと、
環境変数があればそちらが勝ちます。無ければ前の行の値が残ります。
ドライバは jimble-db が MySQL / MariaDB と PostgreSQL の両方を持っているので、
ふつうは足す必要はありません。
接続の暗号化(TLS)#
2.6.0 から、MySQL / MariaDB のドライバ(MariaDB Connector/J 4.0)は既定で TLS を使い、サーバーの証明書を確かめます(sslMode=verify-full)。
2.5 までのドライバ(3.5)は、何も書かなければ暗号化せずに繋いでいました。
証明書を確かめられないと、**起動時に繋がらずに落ちます。**Docker の mysql:8.4 のような自己署名の証明書の MySQL では、こう出ます。
Cannot use authentication plugin caching_sha2_password with a Self signed certificates.
Either set sslMode=trust, use password with a MitM-Proof authentication plugin or provide server certificate to client
MariaDB 11.4 以降のサーバーは、自己署名の証明書でも書き足さずに確かめられます(手元の mariadb:11.4 は何も書かずに繋がりました)。
url に sslMode を書いて決めます。
| 書き方 | 使うところ |
|---|---|
書かない(verify-full) |
証明書が JDK の信頼する CA で署名されているとき。MariaDB 11.4 以降のサーバー |
sslMode=verify-full&serverSslCert=/path/to/ca.pem |
**本番。**サーバーの CA 証明書を渡す(Amazon RDS / Aurora なら AWS が配っている CA 証明書) |
sslMode=trust |
暗号化はするが、証明書を確かめない。盗み見は防ぐが、なりすましは防がない |
sslMode=disable |
暗号化しない。手元の DB だけ |
手元だけ暗号化しないなら、ローカル用の設定に書きます。
# application.local.conf
include "application.conf"
db {
blog_example {
url = "jdbc:mariadb://127.0.0.1:3306/blog_example?sslMode=disable"
}
}
補足
古い書き方の useSSL=false も sslMode=disable と同じに扱われます(jimble のテストの設定はこちらです)。
DB 製品を選ぶ#
product に製品を書くと、SQL ビルダーがその製品用の SQL を出します。
db {
blog_example {
main = true
product = "postgresql"
driver = "org.postgresql.Driver"
url = "jdbc:postgresql://127.0.0.1:5432/blog_example"
username = "blog"
password = "blog"
password = ${?DB_PASSWORD}
}
}
落とし穴
**${?ENV} の行だけを書かないでください。**環境変数が無いときにキーごと消えます
(上の書き方は「まず既定値、あれば環境変数で上書き」の2行組です)。
パスワードが消えたまま PostgreSQL に繋ぐと、返ってくるのは
The server requested SCRAM-based authentication, but no password was provided. で、
設定の話に見えません。
データベースがまだ無いとき#
create_database_sql を書いておくと、繋がらなかったときに1度だけ流します。
db {
blog_example {
...
create_database_sql = "CREATE DATABASE blog_example"
}
}
書かなければ何もしないので、手で作ってあるなら要りません。 本番では権限を持たない利用者で繋ぐほうが普通なので、開発と CI のためのものです。
| 書ける値 | 製品 |
|---|---|
mysql(既定)/ mariadb |
MySQL 8.x / MariaDB |
postgresql / postgres / pgsql |
PostgreSQL 16 以降 |
省略すると mysql です。知らない値を書くと起動時に落ちます。
アプリのコードは1行も変わりません。
// この1行が、product に応じて
// MySQL SELECT `post`.`id` AS `post__id` ... LIMIT ? OFFSET ?
// PostgreSQL SELECT "post"."id" AS "post__id" ... LIMIT ? OFFSET ?
// になります
SQL.select().from(Post.instance()).where(Post.id.eq(1L));
データソースごとに違う製品を書けます(メインは MySQL、集計用は PostgreSQL、など)。
補足
対応している製品は MySQL(MariaDB)と PostgreSQL の2つだけです。 知らない名前を書くと起動時に落ちます——黙って MySQL に倒すと、 PostgreSQL のつもりで書いたアプリにバッククォートの SQL が飛びます。
自前の方言を足す口はありません(Dialect は sealed です)。
方言には SQL 関数ごとのメソッドが 40 以上あり、
jimble が関数を1つ足すたびに、外の実装が壊れるためです。
対応してほしい製品があれば、枠組みに足す形で受け付けます。
その製品で書けないもの#
書けないものは、SQL を組み立てたところで DialectException になります。
実行してから製品の構文エラーで落ちる、ということはありません。
| ビルダー | PostgreSQL |
|---|---|
forceIndex / useIndex / ignoreIndex(インデックスのヒント) |
書けない。PostgreSQL にヒントは無く、黙って外すと遅いことに気づけないので断ります |
Dsl.match(...)(全文検索) |
書けない。to_tsvector は語彙の分割もスコアも別物なので、黙って置き換えません |
Dsl.dateFormat(col, "%Y-%m-%d") |
書けない。to_char は書式の言語が違うので、置き換えると例外にならずに違う文字列が返ります。Java 側で整えてください |
Dsl.jsonExtract(col, "$.a[0]") |
配列・ワイルドカード・引用符つきキーは書けません。$.a.b の形だけ |
逆に、名前だけ違うものは黙って揃えます(RAND / RANDOM、IFNULL / COALESCE、
TRUNCATE / TRUNC など)。Dsl.concat(...) は PostgreSQL では || になります
(PostgreSQL の concat() は NULL を空文字として飲み込むので、
名前だけ置き換えると MySQL と結果が変わります)。
どうしてもその製品の書き方を使いたいときは Dsl.freeSql(...) で逃げられます。
気をつけること#
- **空間関数の座標の順が違います。**MySQL 8 の SRID 4326 は緯度・経度、 PostGIS は経度・緯度です。jimble は吸収しないので、両方で使うなら自分で合わせてください
- マイグレーションの SQL(
conf/migration/...)は自分で書いたものがそのまま流れます。 両方の製品で動かすなら、001_xxx.mysql.sql/001_xxx.postgresql.sqlと ファイル名の接尾辞で分けます(接尾辞なしはどちらでも当たります)。 詳しくは 製品ごとに SQL を分ける
起動時に繋ぐ#
**設定を書いただけでは繋がりません。**アプリの main で明示的に呼びます。
public static void main (String[] args) {
Migration.install(); // 起動時マイグレーション(要るなら。DBUtil.load より前)
DBUtil.load(Conf.conf().config(), App.class); // 繋がらなければ例外。ここで止まります
JimbleServer.start(new App());
}
DBUtil.load の2つ目はクラスパスの起点です(マイグレーション SQL をここから探します)。
自分のアプリのクラスを渡してください。
繋がらなければ SqlExecuteException(DB_007)を投げ、起動はそこで止まります。
戻り値はありません(void)。受け止めずにそのまま main から投げてください。
補足
1.x の DBUtil.load は繋がらなくても false を返すだけで、捨てるとサーバーが起動してしまいました。
2.0 で例外になりました(2.0 への移行)。
終わるときは DBUtil.stop() です(Web はグレースフルシャットダウンが呼びます。
バッチやテストでは自分で呼びます)。
データソースが複数あるとき#
db {
main_db { main = true, ... }
log_db { ... }
}
DBUtil.getDB("log_db") で別のデータソースが取れます。
main = true のものは DBUtil.getMainDB() です(引数なしの getDB() はありません)。
トップレベルに並べたものは、1本ごとに独立した扱いを受けます。
conf/migration/<データソース名>/のマイグレーションが当たります- codegen が
db/<データソース名>/を作ります migration/db_lock/db_value/db_cacheもそちらに作られます
設定を書き写さずにもう1本ぶら下げたいだけなら、subs を使います。
db {
main_db {
main = true
...
subs {
archive_db { }
}
}
}
db.newSubDB("archive_db") で取ります。
落とし穴
接続もトランザクションも共有しません。subs は親とは別のプールです。
同じデータベースを指していても別の接続なので、親のトランザクションの中で
newSubDB に書いても一緒には戻りません。片方だけ残ります。
親から設定を引き継ぐ、それ以上のことはしていないと思ってください。
落とし穴
subs にはマイグレーションも codegen も当たりません。
データソースごとの処理は、トップレベルの db { } しか回らないためです。
だから subs に置けるのは「アプリが自分で作る作業用テーブル」までです。
表と型が要るもの(別 DB の監査ログなど)は、トップレベルにもう1本書いてください。
また subs が別のデータベースを指すなら、そのデータベースが先に在ること。
subs は親のデータソースを作る途中で繋ぎにいくので、あとに書いた
トップレベルの create_database_sql は間に合いません
(failed create subs datasource で起動が止まります)。
2本立てと subs を両方書いたものが examples/approval-data にあります。
テーブル定義を生成する#
./gradlew codegen
DB のスキーマから、テーブルごとのクラスを生成します。
Post.id // Column
Post.title
Post.instance() // Table
io.jimble.db プラグインを入れると、migrate → codegen → compileJava が繋がります。
DDL を書いて起動すれば、コードのほうが追いつきます。
plugins {
id("io.jimble.db")
}
生成されたクラスはリポジトリに入れてください。 生成物をコミットしないと、DB が無い環境でビルドできなくなります。
引く#
Data row = db.select(
SQL.select()
.from(Post.instance())
.where(Post.id.eq(1L))
).orElseThrow();
// SELECT の結果はテーブル名でネストする(要件 F-D-02)
String title = row.getData("post").getString("title");
// Column で引けば、途中の文字列が出てこない
String same = row.getString(Post.title);
**select は Optional<Data> を返します。**1件も無ければ空です
(上の .orElseThrow() は「必ずある」と決めて取り出す書き方です)。
読めなかったときは空ではなく例外です(下の「エラーの見方」)。
SELECT の結果はテーブル名でネストします。
post と comment を join したとき、両方に id があっても衝突しません。
Column で引けば、文字列のキーがコードに出てきません。
row.getData(Post.instance()) で、1テーブルぶんを平らにして取り出せます。
落とし穴
extractTableData は平らにしません。{post: {...}} をネストしたまま返すので、
そのまま JSON にすると入れ子が1段残ります。
平らにしたいときは getData(テーブル) か flattenTable(テーブル) です。
落とし穴
文字列の SQL(db.select("SELECT ..."))の結果はネストしません。ネストはビルダーが列に
post__title の別名を付けるから起きるので、自分で書いた SQL は平らな Data になります。
結合すると同じ名前の列(id など)はあとの値で黙って上書きされるので、別名を付けてください。
JSON の列#
MySQL の JSON、PostgreSQL の json / jsonb の列は、読んだ時点で Data(オブジェクト)か List(配列)になります。
row.getStringList("tags"); // ["a", "b"] 配列の列
row.getData("options"); // オブジェクトの列
row.getString("tags"); // "a" ← 配列の先頭の要素だけ。JSON の文字ではない
配列の列を getString すると先頭の要素だけが返ります(そうしたときは1度だけ WARN が出ます)。
JSON の文字のまま欲しいなら、SQL で文字にして読んでください——MySQL は CAST(列 AS CHAR)、PostgreSQL は 列::text です。
エラーの見方#
try (DB db = BlogExample.db()) {
/*
* 0件は空(空リスト・空の Optional・件数 0)、失敗は SqlExecuteException(2.0)。
* 書かなければ上まで飛んで 500。トランザクションの中なら巻き戻る。
*/
List<Data> rows = db.selectList(SQL.select().from(Post.instance()));
// 分岐したい失敗は一意制約くらい。それだけを受け止める
try {
db.insert(SQL.insert(Post.instance()).value(Post.title, "hello"));
} catch (DuplicateKeyException ex) {
Log.info("もうあります");
}
}
**DB の失敗は例外で返ります。**SQL が通らなかった・繋がらなかったときは SqlExecuteException(非検査)です。
受け止めなければ上まで飛んで 500 になり、トランザクションの中なら巻き戻ります。
コードは getCode()(DB_999 など)、元の JDBC の例外は getCause().getCause() にあります。
**0件は失敗ではありません。**空の Optional・空のリスト・件数 0 で返ります。
| メソッド | 返すもの | 0件のとき |
|---|---|---|
select / selectCached |
Optional<Data> |
空の Optional |
selectList / selectListCached / selectListPerformance |
List<Data>(null は返しません) |
空リスト |
insert |
なし(void) |
—— |
insertKey |
採番された値。採番されなければ SqlExecuteException |
—— |
update / delete |
当たった件数(int) |
0 |
execute |
当たった件数(int)。DDL と結果セットを返す文は 0 |
0 |
executeBatch / insertBatch |
文ごとの件数 / 採番値の一覧 | 空の入力は空リスト |
executeBatch / insertBatch は**SQL が全部同じでなければなりません。**違うものが混ざっていれば DB_998 の例外です。
補足
**PostgreSQL では、ドライバの設定で速さが変わります。**jimble は接続の URL にドライバの設定を足しません。
- まとめて入れる:URL に
reWriteBatchedInserts=trueを書くと、insertBatchの INSERT を複数行の1本にまとめて送ります - 大きな結果を少しずつ読む:
fetch_sizeはトランザクションの中でしか効きません(自動コミットのままだと、結果を全部ドライバが持ちます)。db.transaction(...)の中で読んでください
「1件も無かった」と「読めなかった」#
**別のものとして返ります。**空の Optional は「1件も無かった」だけで、読めなかったときは例外です。
Optional<Data> user = db.select(sql, id); // 読めなければ、ここで SqlExecuteException
if (user.isEmpty()) { return 誰でもない; } // 空は「1件も無かった」だけ
Data post = db.select(sql, id)
.orElseThrow(() -> new HttpException(404, "記事がありません"));
DB が落ちた日に「そんな利用者はいません」と答えることはありません。
補足
1.x の select は、0件も失敗も null でした(isError() を見ないと見分けられませんでした)。
2.0 で分かれました。selectOrThrow / selectListOrThrow は select / selectList と同じ意味になったので、
非推奨です(2.x で消します)。
一意制約だけを受け止める#
分岐したい失敗は、たいてい一意制約だけです。それは型で分かれます。
一意制約に当たると DuplicateKeyException(SqlExecuteException の子)が飛びます(insert / insertKey / update など、どれでも)。
try {
long id = db.insertKey(SQL.insert(User.instance()).value(User.email, email));
} catch (DuplicateKeyException e) {
return 「そのメールアドレスは使われています」;
}
落とし穴
トランザクションの中で受け止めて続けると、その Tx は確定できません。
tx.commit() が TransactionException(DB_004)で断り、全部巻き戻します(トランザクション)。
「あれば更新」に使うなら、トランザクションの外で受け止めるか、
SQL で書いてください(MySQL は INSERT ... ON DUPLICATE KEY UPDATE、PostgreSQL は INSERT ... ON CONFLICT)。
どこで catch するか#
**DB の例外は非検査なので、コンパイラは catch を求めません。**受けるところは、次の表で決めてください。表に無いものは受けずに、500 と巻き戻しに任せます。
| こう書くとき | 受ける例外 | 返すもの |
|---|---|---|
| 利用者の入力を、一意制約(UNIQUE・主キー)のある列に入れる・変える(メールアドレス・ログイン ID など) | DuplicateKeyException |
409 などの「もう使われています」 |
利用者の操作で RedisLock.lock(...) を取る |
RedisLockException |
409 などの「処理中です」(tryLock(...) の空で分けてもよい) |
それ以外(SqlExecuteException / TransactionException) |
受けない | 枠組みが 500 を返し、ログに出す |
- **先に
selectで「まだ無い」と確かめても、catch は省けません。**同時に2つ来れば、両方が確かめを通って片方が一意制約に当たります - 受けるのはトランザクションの外です(上の TRAP)
- 同じ値を2回入れるテストを1本書いておくと、catch を忘れたところが 500 で見つかります
採番値と件数#
**insert は何も返しません。**採番値が要るなら insertKey、件数が要る INSERT ... SELECT などは execute です。
db.insert(SQL.insert(Tag.instance()).value(Tag.name, name)); // 入れるだけ
long id = db.insertKey(SQL.insert(Post.instance()).value(...)); // 採番値
int count = db.execute("INSERT INTO archive SELECT * FROM post WHERE ..."); // 件数
補足
1.x の insert は、採番値が取れればその値、取れなければ入った件数を返していました
(採番列を1本足しただけで意味が変わりました)。2.0 で分けました。
insertNoReturnKey は非推奨です(2.x で消します)。insert か execute を使ってください。
マイグレーション#
conf/migration/<スキーマ名>/001_xxx.sql に置きます。
# --- !Ups
CREATE TABLE `note` (
`id` BIGINT UNSIGNED AUTO_INCREMENT COMMENT 'ID' PRIMARY KEY,
`title` VARCHAR(200) NOT NULL COMMENT 'タイトル',
`created_at` DATETIME NOT NULL COMMENT '作成日時'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='メモ';
# --- !Downs
DROP TABLE `note`;
./gradlew migrate
適用済みのものは記録されるので、二度は走りません。 複数のサーバーが同時に起動しても、ロックを取るので1つしか流れません。
いつ何が走るか、失敗したときにどうなるか、生成される型の対応は マイグレーションとコード生成 にまとめてあります。