本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
図書館の蔵書を横断検索できるサービス「カーリル」を使っていると、複数の図書館やデータベースをまとめて検索しているわりに、結果が速く出てくることに気づきます。横断検索が裏側で非同期に動いていること自体はなんとなく想像していましたが、具体的にどう実装されているのかは知りませんでした。
大阪府立図書館が提供する文献探索型の横断検索「ひろがりサーチ」をブラウザの開発者ツールで眺めていると、search と polling という2つのリクエストが続けて飛んでいます。この記事は、その2つが何をしているのかを、カーリルが公開しているソースコードを読んで確かめた学習記録です。
⚠️ この記事は公開APIの使い方を説明するものではありません。
観察に使ったエンドポイント(
unitrac-*.calil.jp)は、各図書館がカーリルと契約して運用している業務用の API「Unitrad API」のインスタンスで、現時点では第三者向けに公開されている API ではありません。利用条件も公開されていないため、規約の趣旨を尊重して、確認は自分が利用者であるサービスに対する手動・少量のアクセスに留めています。横断検索を API として正規に利用したい場合は、ドキュメントが公開されている図書館API(アプリケーションキー申請制・無償)か、カーリルへの問い合わせが入口になります。
読んだクライアント側のコードは、カーリルが OSS(オープンソースソフトウェア)として公開している CALIL/unitrad-ui(MIT License, Copyright (c) CALIL Inc.)です。挙動を追えたのは、ソースが公開されているおかげです。
/search はジョブの受付だけを返す
ひろがりサーチで検索すると、最初にこのリクエストが飛びます。
GET /search?free=<キーワード>®ion=<地域ID>
レスポンスを見ると、検索結果はほとんど入っていませんでした。代わりに入っているのは、次のような内容です。
{
"uuid": "unitrac-tokyo-1-xxxxxxxx-...",
"version": 1,
"running": true,
"books": [],
"remains": [ ... ],
"errors": []
}
調べた限り、/search は「検索を受け付けた」という応答を返すだけのようです。uuid が検索ジョブの識別子(UUID、universally unique identifier)、running: true が「まだ収集中」を表すフラグです。蔵書データそのものはここには含まれていません。
横断検索は対象館すべての応答を待ってから結果を返す、と漠然と考えていましたが、少なくともこの実装はそうではなく、まず受付だけ済ませてすぐ応答を返す作りになっていました。
/polling で差分を受け取る
結果は別のリクエストで取得します。uuid を持って、次のリクエストを繰り返し送ります。
GET /polling?uuid=<uuid>&version=<N>&diff=1&timeout=10
パラメータはそれぞれ役割を持っています。
| パラメータ | 役割 |
|---|---|
uuid | どの検索ジョブの結果が欲しいか |
version | どこまで受け取ったかを示すカーソル |
diff=1 | 全件ではなく差分だけ返してほしい |
timeout=10 | サーバは最大10秒まで接続を保持してよい |
timeout=10 があるため、サーバは新しい結果が出るまで最大10秒接続を保持し、何か出た時点で返します。空振りのリクエストを減らしながら、近い間隔で結果を受け取れます。ロングポーリングと呼ばれる方式です。
レスポンスは差分の形式でした。
{
"version": 5,
"running": true,
"books_diff": {
"insert": [ { "title": "...", "author": "...", "holdings": [ ... ] } ],
"update": [ { "_idx": 12, "holdings": [ ... ] } ]
}
}
insert が新たに見つかった本、update がすでに表示済みの本(_idx 番目)への追記です。クライアントはこの差分を手元のリストにマージし、running が false になるまで /polling を繰り返します。応答の速い館の結果が先に出て、遅い館の結果は後から流れ込む構造です。
ソースを読んで確かめる
ここまではブラウザの開発者ツールでの観察です。より正確に確かめたかったので、unitrad-ui を clone してクライアント実装を読みました。中心になるのは src/js/api.js の api クラスで、要点を抜き出すと次のようになっています(MIT License, CALIL Inc.)。
search(query) {
// /search でジョブ起動。失敗したら 1000ms 後にリトライ
_request('search').query(stripQuery(query)).end((err, res) => {
if (!err) this.receive(res.body);
else setTimeout(() => this.search(query), 1000);
});
}
polling() {
// version をカーソルに、diff=1 + timeout=10 でロングポーリング
_request('polling')
.query({ uuid: this.data.uuid, version: this.data.version, diff: 1, timeout: 10 })
.end((err, res) => {
if (res.body === null) setTimeout(() => this.polling(), 100); // まだ何も無い
else this.receive(res.body);
});
}
receive(data) {
if (data.books_diff) {
// insert を追記し、update を _idx 指定でその場パッチ
Array.prototype.push.apply(this.data.books, data.books_diff.insert);
// ...(version/running などの上書き、update のマージ)...
} else {
this.data = data; // 初回(/search の全文応答)
}
this.callback(this.data);
if (data.running === true) {
if (data.version === 1 && this.data.books.length === 0) {
setTimeout(() => this.polling(), 20); // 初動
} else {
setTimeout(() => this.polling(), 500); // 結果が出てから
}
}
}
観察だけでは分からなかった点がいくつか見えました。
ポーリングの間隔は一定ではない
receive() の末尾です。running が続くあいだポーリングを繰り返しますが、間隔が状況で変わります。
- 検索直後(
versionが1かつ件数がゼロ)は 20ms 後に再ポーリングします - 結果が出始めたら 500ms 後に再ポーリングします
最初の結果が出るまでは短い間隔で、出始めたら長い間隔に切り替えています。体感の速さは、この初動の間隔にも支えられているようです。
エラー処理
/search が失敗したら 1000ms 後にリトライ、/polling のレスポンスが null(まだ結果が無い)なら 100ms 後に再送、という作りになっていました。横断検索は対象とする館やデータベースが多く、一部の応答が失敗することもあるため、リトライがクライアント側に組み込まれています。
推定の所蔵を先に見せる
型定義(flow/declare.js)を見ると、本のデータに次の2つのフィールドがありました。
holdings: Array<number> // 確定した所蔵館のID
estimated_holdings: Array<number> // 推定の所蔵館のID
estimated_holdings、つまり推定の所蔵です。所蔵館数を計算する sort.js の holdingsFromBook() は、確定(holdings)と推定(estimated_holdings)を合算して館数を出していました。
let _holdings = book.holdings.concat();
if (book.estimated_holdings) {
_holdings = [...new Set(_holdings.concat(book.estimated_holdings))];
}
return countHoldings(_holdings, includes);
応答の遅い館を待つあいだも、推定の所蔵を含めて館数を表示してしまう、という作りのようです。収集が完了していなくても画面が埋まるため、速く感じられる一因になっていると考えられます。
ソートやフィルタも、調べた限りクライアント側(sort.js)で完結していました。和暦を西暦に正規化する normalizePubdate()(「令和」「平成」「元年」などをパースする処理)や、桁数の違う ISBN(International Standard Book Number、書籍の識別番号)を揃える normalizeIsbn() も含まれています。サーバは生データを流し、整形はブラウザが担当する分担になっていました。
開いた本だけ精度を上げる
view/book.jsx には doDeepSearch() という処理があります。再検索を起動する関数で、その結果を受け取る doUpdate() には「高精度化実験」というコメントが添えられていました。
doDeepSearch() {
if (this.props.opened && !this.api) {
this.api = new api({ isbn: this.props.book.isbn, region: this.props.region },
this.doUpdate.bind(this));
}
}
本の詳細を開いたときに、その ISBN をキーにして横断検索をもう一度走らせています。一覧の検索はフリーワードで広く拾うぶん、個々の本の所蔵が推定のまま残ることがあります。そこで、利用者が「この本を見たい」と操作した時点で、ISBN を指定して再検索し、所蔵を推定から確定に置き換える、という流れになっているようです。再検索の発火は setTimeout で1秒遅らせてあり、誤操作ですぐに検索が走るのを避けるための猶予だと思われます。
一覧の段階ではすべてを確定させず、推定で表示し、詳細を開いた本だけ後から再検索する、という構成になっています。
全体の分担
ソースを読んで、自分なりに整理した分担は次のとおりです。
| レイヤー | 担当している処理 |
|---|---|
| サーバ(Unitrad) | 各館を収集し、未完了でも推定でも、出せるものから差分で流す |
api.js | search と polling で差分を受け取り、1つのデータにマージ |
sort.js | 確定と推定を合算した館数の計算、和暦・ISBN の正規化、ソート |
book.jsx | 推定で先に表示し、開かれた本だけ ISBN 再検索で確定に置き換え |
横断検索が速く感じられる理由は、調べた限り次の3点に整理できそうです。
- 対象館すべての完了を待たず、
searchとpollingで差分を順次受け取っています estimated_holdings(推定所蔵)によって、収集の完了前から館数を表示しています- ソートやフィルタがクライアント側で完結しています
正確さのほうは、詳細を開いた本に対する ISBN 再検索(Deep検索)が後から担保する構造になっていました。サーバの即応性とクライアントの後追いの精緻化を、役割で分けている設計だと理解しました。
持ち帰れそうな点
図書館の横断検索という処理には、応答性のための実装がいくつか含まれていました。汎用的に応用できそうな点をメモしておきます。
- 重い処理は、結果を待たせるよりも、先にジョブの受付だけを返す形にできます
- ロングポーリングとバージョンカーソルと差分の組み合わせは、準リアルタイムな結果配信の選択肢になります
- ポーリングの間隔は一定にせず、初動だけ短くするだけでも体感が変わります
- 推定値を先に出して後から確定に置き換えると、速さと正確さを時間軸でずらして両立できます
横断検索の遅さは図書館システムそのものではなく横断検索システムの設計に起因する、という説明がカーリル側のブログ記事にあります。ソースを読んだあとだと、その説明の意味がつかみやすくなりました。
この記事の範囲
繰り返しになりますが、この記事は公開APIの解説ではなく、公開されているソースコード(unitrad-ui)を読み、自分が利用者であるサービスの挙動を手動で少量だけ確認した学習記録です。MIT License で公開されているのはクライアント側の UI コードであり、バックエンドの API を自由に利用してよいという意味ではありません。地域IDを総当たりするような探索もしていません。仕組みを把握するには、公開されているソースを読むだけで足りました。



コメント
…