遅延読み込みと先読み#
一覧に「詳細も返せるようにしておきたいが、返さないことも多い」ものがあります。
AsyncData / AsyncList は、参照された時点ではじめて SQL を投げます。
protected List<Data> load () {
return BlogExample.db().selectList(
SQL.select()
.from(Comment.instance())
.where(Comment.post_id.eq(postId))
.orderBy(Comment.created_at)
);
}
Data post = ...;
post.put("comments", new CommentList(post.getLong(Post.id)));
// ここまでは SQL が飛ばない
post.getJsonString(); // ← ここで飛ぶ
実装するもの#
| メソッド | 何をするか |
|---|---|
load() |
1件ぶん引く |
setData(Data) |
引いたものを自分に反映する |
setRelationData(...) |
全件の setData が終わってから1回だけ。要らなければ書かない |
hashKey() |
同じものかどうかを決めるキー |
AsyncData は Data、AsyncList は List として振る舞うので、
読み込んだあとは普通のデータです。
読み込みを起こさずに見られるもの#
list.isLoaded() // 読み込み済みか
list.isLoadFailed() // 失敗したか
list.loadedValues() // すでに持っている値だけ
list.loadedSize() // すでに持っている件数
toString() も読み込みを起こしません。ログに1行出しただけで枝という枝に
SQL が飛ぶ、ということが起きないようにしてあります。
hashCode() / equals() も hashKey() しか見ません。比較しただけで
SQL が飛びません。
読み込みに失敗したとき#
例外は投げません。ログを出して先へ進みます。
失敗しても「読み込み済み」になります。 そうしないと、参照されるたびに
落ちるクエリを投げ続けます。失敗したことは isLoadFailed() で分かります。
N+1 になるとき#
一覧の全行にぶら下げて、全部を JSON にすると 1 + N 本飛びます。
記事を20件引く 1本
記事1のコメント 1本
記事2のコメント 1本
... ×20
これを2本にするのが先読みです。
先読み#
load() と同じことを、IN 句で書きます。
protected Map<Object, List<Data>> loadBatch (List<Object> ids) {
List<Data> rows = BlogExample.db().selectList(
SQL.select()
.from(Comment.instance())
.where(Comment.post_id.in(ids))
.orderBy(Comment.post_id, Comment.created_at)
);
if (rows == null) {
// 引けなかった。1つも読み込み済みにしないよう、例外にして個別読みへ落とす
throw new IllegalStateException("コメントを引けませんでした: " + BlogExample.db().getError());
}
Map<Object, List<Data>> byPost = new LinkedHashMap<>();
for (Data row : rows) {
byPost
.computeIfAbsent(row.getLong(Comment.post_id), key -> new ArrayList<>())
.add(row);
}
return byPost;
}
あわせて、まとめる単位を宣言します。
@Override
public String batchKey () { return "CommentList"; }
@Override
public Object batchId () { return postId; }
同じ batchKey を持つ未読み込みの枝が、loadBatch 1回にまとまります。
ids には、ほかの枝の batchId も入って渡ってきます。
load() と loadBatch() は同じファイルに並べて置いてください。
片方だけ直すと結果がずれます。置き場を分けない理由がこれです。
走らせる#
get("/posts/with-comments", context -> {
List<Data> posts = new ArrayList<>();
for (Data row : BlogApp.listPosts()) {
posts.add(BlogApp.withComments(row));
}
context.response().json("posts", posts);
/*
* ここまでで飛んだ SQL は記事の1本だけ。
* これを呼ばずに JSON にすると、記事の数だけコメントの SQL が飛ぶ。
*/
AsyncPrefetch.run(context.response());
});
AsyncPrefetch.run(...) がツリーを走査し、未読み込みで batchKey を持つ枝を
集めて埋めます。走査そのものは SQL を起こしません(未読み込みの枝の中は見ません)。
レスポンスを送る直前に自動で走らせることもできます。
async {
prefetch {
on_response = true
max_depth = 5
}
}
既定は false です。隠れた I/O を作らないためで、
入れたとたんに挙動が変わることはありません。
先読みしてもしなくても、結果は同じ#
変わるのはクエリの本数だけです。
loadBatchが返さなかったbatchIdは、個別に引いて0件だったのと同じ扱いになりますloadBatchが例外を投げたら、そのまとまりは1つも読み込み済みにしません。 それぞれが個別にload()されるので、結果は変わりません(速さだけが戻ります)loadBatchを書いていないクラスは、そのまま個別に読まれます
止まること#
埋めると新しい枝が生えるので、生えなくなるまで繰り返します。 無限に回らないよう、3つで止めています。
(batchKey, batchId) は1回だけ展開する |
A が B を、B が A を持っていても2周目で止まる |
| 周回の上限 | async.prefetch.max_depth(既定 5) |
| 同じオブジェクトを二度たどらない | 参照の同一性で判定する |
テーブルネストあり / なしを選んで JSON にする#
SELECT の結果はテーブル名でネストしています。
{"post": {"id": 1, "title": "こんにちは"}}
これを setData で平らにするか(flattenTable)、そのまま持つか(extractTableData)は、
これまでクラスを書いた時点で決まっていました。
つまり 1 つの AsyncData は 1 つの形しか出せません。
管理画面 API はネスト、ショップ API はフラットで返したい、となると 同じ内容のクラスを 2 つ書くことになります。片方だけ直したときに気づけません。
形は JSON にするときに選べます。
data.getJsonString(TableNest.ON); // {"post": {"id": 1}, "comments": [...]}
data.getJsonString(TableNest.OFF); // {"id": 1, "comments": [...]}
レスポンス単位でも指定できます。
path("/admin", () -> {
before(context -> context.response().tableNest(TableNest.ON));
get("/posts/{id}", context -> context.response().json("post", new PostData(id)));
});
path("/shop", () -> {
before(context -> context.response().tableNest(TableNest.OFF));
get("/posts/{id}", context -> context.response().json("post", new PostData(id)));
});
返しているのは同じクラスです。
どうやって形が分かるのか#
AsyncData / AsyncList は load() が返した生のデータを持ったままです。
その生データが「どのキーがどのテーブルの、どの列か」を知っています。
{"post": {"id": 1, "title": "..."}}
↑ テーブル名 ↑ 列名
だから、setData を flattenTable で書いても extractTableData で書いても、
どちらの形にも組み直せます。実装側に書き足すものはありません。
列でないものは動きません#
setRelationData で足した子(AsyncData / AsyncList)や計算値は、
生データの列に無いので常に最上位に残ります。
{"post": {"id": 1, "title": "..."}, "comments": [...]}
補足
既定は TableNest.AS_IS です。指定しなければ setData が作った形がそのまま出ます。
いまあるアプリの出力は 1 バイトも変わりません。
落とし穴
組み直せないものがあります。
自由 SQL や集約でテーブルネストしていない結果({"total": 12} など)は、
テーブル名が分からないのでそのまま出ます。
putData(null) を受けたノードも同じです。
JOIN していて同じ列名が 2 つのテーブルにあるとき(post.id と comment.id)は、
平らに持っている時点ですでに片方で上書きされていて、どちらの値かは分かりません。
ネストに戻すときは先に出てきたテーブルに入ります。