はじめに
プリザンターを使っていて、こんな経験はありませんか。
- 更新したはずのレコードが検索に引っかからない
- ヘッダの検索ボックスでは見つかるのに、一覧の検索ボックスでは見つからない
- データが増えてきたら一覧の検索が急に遅くなった
これらはすべて「プリザンターの検索が内部でどう動いているか」を知ると説明がつきます。
この連載では、検索ボックスに入れた文字が最終的にどんな SQL になって返ってくるのかを、
入口から出口まで通しで追っていきます。全7回の予定です。
| 回 | 内容 |
|---|---|
| 第1回(本記事) | 全体像とデータフロー |
| 第2回 |
Items.FullText 列がどう作られるか |
| 第3回 | RDBMS 別の全文検索 SQL |
| 第4回 | 一覧の検索と絞り込み |
| 第5回 | 横断検索とその他の検索 |
| 第6回 | 添付ファイル全文検索の仕組みと限界 |
| 第7回 | 内部実装から導く実践編 |
初回は地図を描くところから始めましょう。
バージョン 1.5.7.0 を対象にしています
検索の入口は4つある
まず押さえておきたいのは、プリザンターの「検索」はひとつではないということです。
UI 上で検索と呼ばれているものは、実装としては4系統に分かれています。
| 入口 | UI 上の場所 | 実装の入口 | 最終的に見ている列 |
|---|---|---|---|
| 横断検索 | ヘッダの検索ボックス |
Indexes.Search / SearchJson
|
Items.FullText |
| 一覧の検索 | 一覧上部の検索ボックス |
View.Search → SetSearchWhere
|
Items.FullText / Items.Title
|
| 項目ごとの絞り込み | フィルタ列 | View.ColumnFilterHash |
各テーブルの実カラム |
| 選択肢の検索 | リンク項目のドロップダウン | SearchDropDown |
リンク先の Items.Title
|
「ヘッダの検索では出るのに一覧の検索では出ない」という現象は、
そもそも見ている列も条件式も違うために起こります。
この連載では第4回で一覧の検索、第5回で横断検索とドロップダウン検索を扱います。
検索の土台は Items.FullText 列
上の表を見ると、横断検索と一覧の検索はどちらも Items.FullText という列を見ています。
プリザンターの検索を理解する鍵はここにあります。
プリザンターのレコード実体は、テーブルの種類によって
Issues・Results・Wikis・Sites・Dashboards に分かれて格納されています。
ところが検索は、それらのテーブルを直接見にいきません。
かわりに Items テーブルの FullText 列へ、検索用のテキストを1本の文字列として展開して持たせています。
いわゆる非正規化です。なぜこうしているのかというと、理由がいくつも重なっています。
- 拡張項目が横に長い(
ClassA〜ClassZ、NumA〜NumZなど)ため、
実テーブルを対象にすると検索条件が列数分だけ膨れ上がる
(Enterprise Edition の項目拡張を使っている環境ではさらに増える) - 参照タイプが5種類あるため、テーブルごとに別の SQL を書くことになる
- 横断検索では、異種テーブルのレコードを1回のクエリでまとめて舐めたい
Items に文字列を1本持たせておけば、テーブルの種類を問わず同じ条件式で検索できます。
横断検索が「テーブルをまたいで一気に探せる」のは、この設計のおかげです。
Items テーブルの検索関連列
| 列 | 役割 |
|---|---|
ReferenceId |
レコードの ID(IssueId / ResultId など) |
ReferenceType |
参照タイプ(Issues / Results / Wikis / Sites / Dashboards) |
SiteId |
所属サイト |
Title |
レコードのタイトル |
FullText |
検索用に展開したテキスト |
SearchIndexCreatedTime |
FullText を生成した時刻 |
SearchIndexCreatedTime は地味ですが重要な列です。
これを UpdatedTime と比較することで「インデックスが古いレコード」を判定しています。
インデックスが作られる3つの経路
Items.FullText は自動で最新になるわけではなく、3つの経路で生成されます。
| 経路 | 契機 | 制御するパラメータ |
|---|---|---|
| 同期生成 | レコードの作成・更新時 |
Search.json の CreateIndexes(既定 true) |
| バックグラウンド | 生成時刻が未設定・不一致のものを拾う |
BackgroundTask.json の CreateSearchIndexLot(既定 100) |
| 手動再構築 | サイトの設定の「検索インデックスの再構築」 | なし |
実際に Items.FullText を書き込んでいるのは、以下のメソッドです。
private static void CreateFullText(Context context, long id, string fullText)
{
if (fullText != null)
{
Repository.ExecuteNonQuery(
context: context,
statements: Rds.UpdateItems(
where: Rds.ItemsWhere().ReferenceId(id),
param: Rds.ItemsParam()
.FullText(fullText)
.SearchIndexCreatedTime(DateTime.Now),
addUpdatorParam: false,
addUpdatedTimeParam: false));
}
}
addUpdatorParam: false と addUpdatedTimeParam: false が付いているところがポイントです。
インデックスを作り直しても更新者と更新日時は変わらないようになっています。
CreateIndexes を false にすると何が起きるか
Search.json の CreateIndexes を false にすると、同期生成が止まります。
各モデルの FullText() メソッドの先頭に、こういう判定が入っています。
if (!Parameters.Search.CreateIndexes && !backgroundTask) return null;
null が返ると、先ほどの CreateFullText() は何も書き込みません。
ただし backgroundTask: true で呼ばれた場合は素通りするので、
バックグラウンド生成と手動再構築は CreateIndexes の設定に関係なく動きます。
大量データを一気に投入するときだけ CreateIndexes を false にして、
あとからまとめて再構築するという運用が可能なのは、この作りのおかげです。
Search.json のパラメータ一覧
検索の挙動は App_Data/Parameters/Search.json でまとめて制御されています。
既定値は以下のとおりです。
{
"SearchDocuments": false,
"CreateIndexes": true,
"PageSize": 20,
"DisableCrossSearch": false,
"DisableCrossSearchSites": false,
"FullTextIncludeBreadcrumb": false,
"FullTextIncludeSiteId": false,
"FullTextIncludeSiteTitle": true,
"FullTextNumberOfMails": 10,
"FullTextMaxNumberOfMails": 100
}
それぞれの意味と、この連載でどこで詳しく扱うかを整理しました。
| パラメータ | 既定 | 内容 | 詳しく扱う回 |
|---|---|---|---|
SearchDocuments |
false |
添付ファイルの中身も検索対象にするか | 第6回 |
CreateIndexes |
true |
作成・更新時にインデックスを生成するか | 本記事 |
PageSize |
20 |
横断検索の1ページあたり件数 | 第5回 |
DisableCrossSearch |
false |
横断検索そのものを無効化するか | 第5回 |
DisableCrossSearchSites |
false |
テーブル定義自体を結果から外すか | 第5回 |
FullTextIncludeBreadcrumb |
false |
パンくずをインデックスに含めるか | 第2回 |
FullTextIncludeSiteId |
false |
サイト ID を含めるか | 第2回 |
FullTextIncludeSiteTitle |
true |
サイトのタイトルを含めるか | 第2回 |
FullTextNumberOfMails |
10 |
インデックスに含める送信メール数 | 第2回 |
FullTextMaxNumberOfMails |
100 |
送信メール数の上限 | 第2回 |
パラメータクラスの定義もシンプルで、JSON のキーと1対1で対応しています。
public class Search
{
public bool SearchDocuments;
public bool CreateIndexes;
public int PageSize;
public bool DisableCrossSearch;
public bool DisableCrossSearchSites;
public bool FullTextIncludeBreadcrumb;
public bool FullTextIncludeSiteId;
public bool FullTextIncludeSiteTitle;
public int FullTextNumberOfMails;
public int FullTextMaxNumberOfMails;
}
ソースコードの地図
最後に、この連載で読んでいくファイルを一覧にしておきます。
| ファイル | 役割 |
|---|---|
Libraries/Search/Indexes.cs |
検索の中核。1112 行 |
Libraries/Extensions/FullTextExtensions.cs |
型ごとの FullText 生成 |
Libraries/Extensions/SearchIndexExtensions.cs |
検索語の分割 |
Libraries/Settings/View.cs |
一覧の検索・列フィルタ |
Libraries/Settings/SiteSettings.cs |
サイト単位の検索方式 |
Libraries/Settings/Column.cs |
項目単位の検索方式 |
Rds/Implem.SqlServer/SqlServerCommandText.cs |
SQL Server 用の条件式 |
Rds/Implem.PostgreSql/PostgreSqlCommandText.cs |
PostgreSQL 用の条件式 |
Rds/Implem.MySql/MySqlCommandText.cs |
MySQL 用の条件式 |
検索処理の大半は Indexes.cs に集まっています。
第2回以降はこのファイルを軸に読み進めていきます。
診断してみましょう
初回の締めに、自環境の状態を確認する SQL を紹介します。
インデックスが古いままのレコードが何件あるかを数えるものです。
select count(*) from "Items"
where "SearchIndexCreatedTime" is null
or "SearchIndexCreatedTime" <> "UpdatedTime";
この件数がゼロでないなら、バックグラウンド生成が追いついていないか、
CreateIndexes が false になっているか、どちらかです。
CreateSearchIndexLot の既定は 100 件なので、
大量に投入した直後はしばらくゼロにならないのが正常な動きです。
まとめ
初回の要点を振り返ります。
- プリザンターの検索は入口が4系統あり、見ている列も条件式も異なります
- 横断検索と一覧の検索は、どちらも
Items.FullTextという非正規化された1本の文字列を見ています -
Items.FullTextは自動で最新になるわけではなく、同期生成・バックグラウンド・手動再構築の3経路で作られます -
SearchIndexCreatedTimeとUpdatedTimeの比較が、インデックスの鮮度判定に使われています - 検索の挙動は
Search.jsonの10個のパラメータでまとめて制御されています
次回は、その Items.FullText が具体的に何をどう連結して作られているのかを読んでいきます。
「更新したのに検索に引っかからない」の答えの多くは、次回の内容にあります。