Orchard CMS と Lucene.NET による検索機能の概要
Orchard CMS は、モジュール型のアーキテクチャを採用しており、站内搜索機能には Lucene.NET が利用されています。コンテンツが作成または更新される際、システム内のメッセージング機構を通じて非同期で索引が生成される仕組みになっています。本稿では、管理画面からの索引更新トリガーから、実際の Lucene ドキュメントへの書き込みまでの内部処理フローについて解説します。
索引更新のトリガー処理
管理画面の「Search Index」メニューから更新操作が行われると、まずコントローラー層で権限チェックが行われた後、索引サービスへ処理が委譲されます。以下のコードは、管理者による更新要求を受け付けるアクションの例です。
[HttpPost]
public ActionResult TriggerIndexUpdate() {
// 管理者権限の確認
var isAuthorized = Services.Authorizer.Authorize(
StandardPermissions.SiteOwner,
T("Permission to manage search index required.")
);
if (!isAuthorized) {
return new HttpUnauthorizedResult();
}
// 索引サービスへ更新を依頼
_indexingService.RefreshIndex(_defaultIndexName);
// 完了後、一覧画面へリダイレクト
return RedirectToAction("IndexList");
}
サービス層における通知機制
呼び出された IndexingService は、直接索引を作成するのではなく、登録されたハンドラーに対して更新通知を発行します。これにより、関心の分離が保たれています。
public void RefreshIndex(string targetIndexName) {
// 登録されたすべての通知ハンドラーを呼び出す
foreach (var handler in _indexNotifierHandlers) {
handler.UpdateIndex(targetIndexName);
}
// 処理完了をユーザーに通知
Services.Notifier.Information(T("Search index update process has been initiated."));
}
タスクスケジューリングへの移行
通知を受け取ったコンポーネントは、実際の索引生成タスクをバックグラウンドのスケジューラーへ登録します。これにより、重い処理がリクエストスレッドをブロックすることを防ぎます。
public void QueueIndexTask(string indexName) {
var shellDescriptor = _shellDescriptorManager.GetShellDescriptor();
// 処理エンジンへタスクを登録
_processingEngine.AddTask(
_shellSettings,
shellDescriptor,
"IIndexNotifierHandler.UpdateIndex",
new Dictionary<string, object> { { "indexName", indexName } }
);
}
public void UpdateIndex(string indexName) {
// バッチ処理 executor に委譲
if (_indexingTaskExecutor.Value.ExecuteBatchUpdate(indexName)) {
// 継続して処理すべき場合は再度スケジュール
QueueIndexTask(indexName);
}
}
索引タスクの実行ロジック
核心となる処理は IndexingTaskExecutor によって行われます。このクラスはメモリ上に常駐し、メッセージキューやタスクキューを監視しながら、コンテンツアイテムのバッチ処理を行います。処理モードには「再構築(Rebuild)」と「更新(Update)」の 2 種類があります。
private bool ProcessIndexBatch(string indexName, string configPath, IndexSettings config) {
var documentsToInsert = new List<IDocumentIndex>();
var idsToRemove = new List<int>();
// 索引の再構築モードの場合
if (config.Mode == IndexingMode.Rebuild) {
Logger.Information("Starting index rebuild process");
_indexingStatus = IndexingStatus.Rebuilding;
// 未処理のコンテンツアイテムを取得
var items = _contentRepository
.Fetch(v => v.Published && v.Id > config.LastContentId)
.OrderBy(v => v.Id)
.Take(MaxItemsPerBatch)
.Select(v => _contentManager.Get(v.ContentItemRecord.Id, VersionOptions.VersionRecord(v.Id)))
.Distinct()
.ToList();
if (items.Count == 0) {
config.Mode = IndexingMode.Update;
}
foreach (var item in items) {
try {
var docIndex = BuildDocumentIndex(item);
if (docIndex != null && docIndex.IsDirty) {
documentsToInsert.Add(docIndex);
}
config.LastContentId = item.VersionRecord.Id;
}
catch (Exception ex) {
Logger.Warning(ex, "Failed to index item #{0} during rebuild", item.Id);
}
}
}
// 通常更新モードの場合
if (config.Mode == IndexingMode.Update) {
Logger.Information("Processing incremental index update");
_indexingStatus = IndexingStatus.Updating;
// 未処理のタスクを取得し、コンテンツ ID でグループ化
var tasks = _taskRepository
.Fetch(t => t.Id > config.LastIndexedId)
.OrderBy(t => t.Id)
.Take(MaxItemsPerBatch)
.GroupBy(t => t.ContentItemRecord.Id)
.Select(g => new {
TaskId = g.Max(t => t.Id),
IsDelete = g.Last().Action == IndexingTaskRecord.Delete,
ContentId = g.Key,
Item = _contentManager.Get(g.Key, VersionOptions.Published)
})
.OrderBy(x => x.TaskId)
.ToArray();
foreach (var task in tasks) {
try {
var docIndex = BuildDocumentIndex(task.Item);
if (docIndex == null || task.IsDelete) {
idsToRemove.Add(task.ContentId);
}
else if (docIndex.IsDirty) {
documentsToInsert.Add(docIndex);
}
config.LastIndexedId = task.TaskId;
}
catch (Exception ex) {
Logger.Warning(ex, "Failed to index item #{0} during update", task.ContentId);
}
}
}
// 設定ファイルの更新
config.LastIndexedUtc = _clock.UtcNow;
_appDataFolder.CreateFile(configPath, config.ToXml());
if (idsToRemove.Count == 0 && documentsToInsert.Count == 0) {
_indexingStatus = IndexingStatus.Idle;
return false;
}
// 索引への書き込み実行
try {
if (documentsToInsert.Count > 0) {
_indexProvider.CommitDocuments(indexName, documentsToInsert);
Logger.Information("Indexed {0} new documents", documentsToInsert.Count);
}
}
catch (Exception ex) {
Logger.Warning(ex, "Error occurred while adding documents to index");
}
try {
if (idsToRemove.Count > 0) {
_indexProvider.RemoveDocuments(indexName, idsToRemove);
Logger.Information("Removed {0} documents from index", idsToRemove.Count);
}
}
catch (Exception ex) {
Logger.Warning(ex, "Error occurred while removing documents from index");
}
return true;
}
Lucene への永続化処理
最終的に、準備されたドキュメントは LuceneIndexProvider によって実際の Lucene 索引ファイルへ書き込まれます。ここでは、Orchard 固有のドキュメントオブジェクトが Lucene 用のドキュメントへ変換され、IndexWriter を介してディスクに保存されます。
public void CommitDocuments(string indexName, IEnumerable<LuceneDocumentIndex> documents) {
if (!documents.Any()) {
return;
}
// 既存のドキュメントを一旦削除
Delete(indexName, documents.Select(d => d.ContentItemId));
var writer = new IndexWriter(
GetDirectory(indexName),
_analyzer,
false,
IndexWriter.MaxFieldLength.UNLIMITED
);
LuceneDocumentIndex currentDoc = null;
try {
foreach (var docItem in documents) {
currentDoc = docItem;
// 内部表現を Lucene 形式へ変換
var luceneDoc = ConvertToLuceneDocument(docItem);
writer.AddDocument(luceneDoc);
Logger.Debug("Indexed document [{0}]", docItem.ContentItemId);
}
}
catch (Exception ex) {
Logger.Error(ex, "Unexpected error indexing document [{0}] in index [{1}]", currentDoc.ContentItemId, indexName);
}
finally {
writer.Optimize();
writer.Close();
}
}