前言:上一篇我們學習了Lucene.Net的基本概念、分詞以及實現了一個最簡單的搜索引擎,這一篇我們開始開發一個初具規模的站內搜索項目,通過開發站內搜索模塊,我們可以方便地在項目中集成站內搜索功能。本次示例Demo麻雀雖小,五臟俱全,值得學習。
一、項目初窺
1.1 項目背景
本項目模擬一個BBS論壇的文章內容管理系統,當用戶發帖之后首先將內容存到數據庫,然后對內容進行分詞后存入索引庫。因此,當用戶在論壇站內搜索模塊進行搜索時,會直接從索引庫中進行匹配並獲取查詢結果。站內搜索界面的效果如下圖所示:
所以,本Demo的重點就在於如何搭建這樣的一個站內搜索模塊,其他例如文章帖子的CRUD不會多做介紹,請自行下載源碼查看。
首先,來看看本Demo的項目結構,雖然只是做一個小Demo,還是使用了簡單地三層結構來進行開發:
(1)Manulife.SearchEngine.Dao
顧名思義,數據訪問層,與數據庫進行交互,各種SQL!
(2)Manulife.SearchEngine.Service
業務邏輯層,對數據訪問接口進行簡單的封裝,為UI層提供服務接口。
(3)Manulife.SearchEngine.Model
公共的實體對象,為各個層次提供Entity。
(4)Manulife.SearchEngine.Web
一個ASP.NET WebForm的網站,主要提供Admin管理操作(文章帖子的CRUD)以及站內搜索(我們的關注點就在這兒)。
1.2 數據訪問層
(1)本次數據庫只涉及到三張表:
其中,Article是文章表,SearchLog是搜索日志表,SearchLogStastics則是搜索日志統計表(例如:什么關鍵詞搜索了多少次之類的統計)。
(2)為操作這些表提供數據訪問對象類
這些代碼都很簡單,由代碼生成器生成,不用care。
1.3 業務邏輯層
本次Demo的業務邏輯層僅僅是對數據訪問層方法的簡單封裝,同樣,也是由代碼生成器生成,不用care。
其中,對於獲取搜索熱詞考慮到每個用戶都會看到熱詞,為了減輕數據庫訪問的壓力,使用了ASP.NET自帶的Cache進行優化,該方法會首先從Cache中查找是否已有了搜索熱詞,沒有才會去數據庫中獲取,並且設置緩存失效時間為1小時。也就是說,在1小時以內,所有用戶看到的搜索熱詞都是相同的。
public DataTable GetHotKeyword() { // 首先判斷緩存中是否有記錄 var cacheData = HttpRuntime.Cache["HotKeywords"]; if (cacheData == null) { var hotKeywords = new SearchLogStasticsDao().GetHotKeyword(); // 將結果放入緩存,並設定1小時替換一次緩存 HttpRuntime.Cache.Insert("HotKeywords",hotKeywords,null, DateTime.Now.AddHours(1), TimeSpan.Zero); return hotKeywords; } else { return cacheData as DataTable; } }
1.4 UI界面層
界面層是本次Demo的重點,因為關於站內搜索的所有功能都寫在這一層的邏輯代碼中。首先,我們來看看Web層的項目結構:
(1)assets
這個不用多說,里面就存放一些css,js與image文件,都是Demo需要使用的。
(2)Common
這個folder下主要是對一些常用功能的封裝,以便盡可能實現代碼復用。當然,也對Lucene.Net的一些例如創建索引的操作進行了封裝,保證代碼的單一職責。
(3)Dict與Index
這兩個folder下主要是存放Lucene.Net必須要用到的詞庫與索引文件,如果你還不熟悉,請瀏覽上一篇進行學習。這里需要注意的是,Dict文件夾下的詞庫文件需要設置為:如果較新則復制,這樣才可以在編譯時自動同步到Bin目錄下。
(4)Log
這個folder下主要是存放系統一些關鍵操作的日志記錄,以及用戶搜索的日志記錄。按照年月日進行區分,使用log4net組件進行日志的讀寫。
(5)Views
這個folder下就是一些我們熟悉的頁面了,其中:Admin目錄下是后台管理操作,對文章的CRUD操作;Article目錄下則是針對前台用戶的站內搜索和文章瀏覽的頁面。Shared目錄下是一些公用的模板頁。這里為了快速開發原型系統所以主要采用ASP.Net WebForms技術進行實現,沒有采用ASP.Net MVC。
二、核心代碼
2.1 文章索引的創建與更新
(1)設計IndexManager
考慮到文章的發布和修改都需要更新到索引庫,因此我們將更新索引庫的操作提取出來封裝一個class命名為IndexManager。
①首先,索引庫的更新是一個耗時的操作,並且IO資源是很珍貴的,所以我們將IndexManager設置為一個單例:
public class IndexManager { public static readonly IndexManager Instance = new IndexManager(); private IndexManager() { } static IndexManager() { } }
這里采用了.NET中獨有的靜態構造函數方法保證實例的唯一,CLR已經為我們考慮了線程安全的問題了。
C#的語法中有一個函數能夠確保只調用一次,那就是靜態構造函數。由於C#是在調用靜態構造函數時初始化靜態變量,.NET運行時(CLR)能夠確保只調用一次靜態構造函數,這樣我們就能夠保證只初始化一次instance。
②其次,借助生產者消費者的思想,通過消息隊列的方式將原來同步的創建索引操作變為任務隊列的異步操作。由此用戶在發布文章時,不用等待索引創建完成后才得到提示,只需要等到保存到數據庫之后就可以退出進行其他操作。
關鍵代碼如下所示:
public class IndexManager { ...... public void Start() { Thread thread = new Thread(WatchIndexTask); thread.IsBackground = true; thread.Start(); log.Debug("IndexManager has been lunched successfully!"); } private Queue<IndexTask> indexQueue = new Queue<IndexTask>(); private void WatchIndexTask() { while (true) { if (indexQueue.Count > 0) { // 索引文檔保存位置 FSDirectory directory = FSDirectory.Open(new DirectoryInfo(IndexPath), new NativeFSLockFactory()); bool isUpdate = IndexReader.IndexExists(directory); //判斷索引庫是否存在 log.Debug(string.Format("The status of index : {0}", isUpdate)); if (isUpdate) { // 如果索引目錄被鎖定(比如索引過程中程序異常退出),則首先解鎖 // Lucene.Net在寫索引庫之前會自動加鎖,在close的時候會自動解鎖 // 不能多線程執行,只能處理意外被永遠鎖定的情況 if (IndexWriter.IsLocked(directory)) { log.Debug("The index is existed, need to unlock."); IndexWriter.Unlock(directory); //unlock:強制解鎖,待優化 } } // 創建向索引庫寫操作對象 IndexWriter(索引目錄,指定使用盤古分詞進行切詞,最大寫入長度限制) // 補充:使用IndexWriter打開directory時會自動對索引庫文件上鎖 IndexWriter writer = new IndexWriter(directory, new PanGuAnalyzer(), !isUpdate, IndexWriter.MaxFieldLength.UNLIMITED); log.Debug(string.Format("Total number of task : {0}", indexQueue.Count)); while (indexQueue.Count > 0) { IndexTask task = indexQueue.Dequeue(); long id = task.TaskId; ArticleService articleService = new ArticleService(); Article article = articleService.GetById(id); if (article == null) { continue; } // 一條Document相當於一條記錄 Document document = new Document(); // 每個Document可以有自己的屬性(字段),所有字段名都是自定義的,值都是string類型 // Field.Store.YES不僅要對文章進行分詞記錄,也要保存原文,就不用去數據庫里查一次了 document.Add(new Field("id", id.ToString(), Field.Store.YES, Field.Index.NOT_ANALYZED)); // 需要進行全文檢索的字段加 Field.Index. ANALYZED // Field.Index.ANALYZED:指定文章內容按照分詞后結果保存,否則無法實現后續的模糊查詢 // WITH_POSITIONS_OFFSETS:指示不僅保存分割后的詞,還保存詞之間的距離 document.Add(new Field("title", article.Title, Field.Store.YES, Field.Index.ANALYZED, Field.TermVector.WITH_POSITIONS_OFFSETS)); document.Add(new Field("msg", article.Msg, Field.Store.YES, Field.Index.ANALYZED, Field.TermVector.WITH_POSITIONS_OFFSETS)); if (task.TaskType != TaskTypeEnum.Add) { // 防止重復索引,如果不存在則刪除0條 writer.DeleteDocuments(new Term("id", id.ToString()));// 防止已存在的數據 => delete from t where id=i } // 把文檔寫入索引庫 writer.AddDocument(document); log.Debug(string.Format("Index {0} has been writen to index library!", id.ToString())); } writer.Close(); // Close后自動對索引庫文件解鎖 directory.Close(); // 不要忘了Close,否則索引結果搜不到 log.Debug("The index library has been closed!"); } else { Thread.Sleep(2000); } } } ...... }
這里使用了.NET內置的隊列數據結構Queue來實現更新索引任務的隊列。
③考慮到新增索引和更新索引操作的差異,為頁面提供兩個接口,其本質都是向任務隊列插入一條新的任務。只不過任務的TaskType枚舉不一樣,通過此枚舉標識,在更新索引時會進行判斷是否需要刪除原來的索引進行重建。
public class IndexManager { ...... public void AddArticle(IndexTask task) { task.TaskType = TaskTypeEnum.Add; indexQueue.Enqueue(task); } public void UpdateArticle(IndexTask task) { task.TaskType = TaskTypeEnum.Update; indexQueue.Enqueue(task); } } public class IndexTask { public long TaskId { get; set; } public TaskTypeEnum TaskType { get; set; } } public enum TaskTypeEnum { Add, Update }
(2)IndexManager的使用
在文章編輯保存按鈕的事件中使用IndexManager暴露的兩個接口方法進行索引的創建和更新:
protected void btnSave_Click(object sender, EventArgs e) { string action = Request["action"]; if (action == "Edit") { ...... // 更新數據庫 articleService.Update(art); // 更新索引庫 IndexTask task = new IndexTask(); task.TaskId = id; IndexManager.Instance.UpdateArticle(task); Response.Redirect("ArticleList.aspx"); } else if (action == "AddNew") { ...... // 更新數據庫 art = articleService.Add(art); // 更新索引庫 IndexTask task = new IndexTask(); task.TaskId = art.Id; IndexManager.Instance.AddArticle(task); Response.Redirect("ArticleList.aspx"); } else { throw new Exception("action錯誤!"); } }
2.2 統計任務的調度與執行
(1)統計任務的背景
考慮到用戶可能對其他用戶搜索的熱詞的需求,系統需要對用戶輸入的搜索詞進行記錄,並統計出一段時間內用戶搜索頻率最高的一些關鍵詞,類似於微博的熱搜榜:
而我們要做的就是需要統計一周內所有用戶搜索次數最多的5個關鍵詞,並固定顯示在搜索頁面中。通過SearchLog表(用戶的每一次搜索操作都會記錄到數據庫中)的分析,我們可以通過如下語句進行統計:
因此,我們只需要將Top 5的熱詞綁定到頁面即可。
(2)借助Quartz.Net實現定時統計任務
Quartz.NET是一個開源的作業調度框架,是OpenSymphony 的 Quartz API的.NET移植,它用C#寫成,可用於winform和asp.net應用中。它提供了巨大的靈活性而不犧牲簡單性。你能夠用它來為執行一個作業而創建簡單的或復雜的調度,就像你創建一個Windows的定時任務一樣,So Easy!
這里我們的業務流程是:每一個小時(如果間隔很短會對數據庫造成壓力)對SearchLogStatics表(搜索記錄統計表)進行更新,更新的詳細流程如下圖所示:
使用Quartz.Net有三個核心部分:Schedule、Job和Trigger,一句話概括就是:給某個人(工作線程)指定一個計划(Schedule),具體是做什么事(Job),在什么時候開始做(Trigger)。
public static class SearchLogScheduler { public static void Start() { // 每隔一段時間執行任務 IScheduler sched; ISchedulerFactory sf = new StdSchedulerFactory(); sched = sf.GetScheduler(); // IndexJob為實現了IJob接口的類 JobDetail job = new JobDetail("job1", "group1", typeof(BuildStasticsJob)); // 5秒后開始第一次運行 DateTime ts = TriggerUtils.GetNextGivenSecondDate(null, 5); // 每隔1小時執行一次 TimeSpan interval = TimeSpan.FromHours(1); // 每若干小時運行一次,小時間隔由appsettings中的IndexIntervalHour參數指定 Trigger trigger = new SimpleTrigger("trigger1", "group1", "job1", "group1", ts, null, SimpleTrigger.RepeatIndefinitely, interval); sched.AddJob(job, true); sched.ScheduleJob(trigger); sched.Start(); } } /// <summary> /// 具體要執行的任務 /// </summary> public class BuildStasticsJob : IJob { private SearchLogStasticsService stasticService; public BuildStasticsJob() { stasticService = new SearchLogStasticsService(); } public void Execute(JobExecutionContext context) { // 刪除所有統計記錄 stasticService.Delete(); // 重新統計插入表中 stasticService.Stastic(); } }
2.3 獲取搜索結果
(1)搜索頁的工作
在搜索主頁面加載時,需要進行三件事:
protected void Page_Load(object sender, EventArgs e) { // 綁定一周熱詞 BindHotKeywords(); if (Request["keyword"] == null) { return; } string keyword = Request["keyword"].ToString(); // 綁定搜索結果 BindPagerHtml(keyword); // 添加搜索記錄 AddSearchLog(keyword); }
(2)這里主要看看如何獲取搜索結果
private void BindSearchResult(string keyword, int startIndex, int pageSize, out int totalCount) { string indexPath = Context.Server.MapPath("~/Index"); // 索引文檔保存位置 FSDirectory directory = FSDirectory.Open(new DirectoryInfo(indexPath), new NoLockFactory()); IndexReader reader = IndexReader.Open(directory, true); IndexSearcher searcher = new IndexSearcher(reader); IEnumerable<string> keyList = SplitHelper.SplitWords(keyword); PhraseQuery queryTitle = new PhraseQuery(); foreach (var key in keyList) { queryTitle.Add(new Term("title", key)); } queryTitle.SetSlop(100); PhraseQuery queryMsg = new PhraseQuery(); foreach (var key in keyList) { queryMsg.Add(new Term("msg", key)); } queryMsg.SetSlop(100); BooleanQuery query = new BooleanQuery(); query.Add(queryTitle, BooleanClause.Occur.SHOULD); // SHOULD => 可以有,但不是必須的 query.Add(queryTitle, BooleanClause.Occur.SHOULD); // SHOULD => 可以有,但不是必須的 // TopScoreDocCollector:盛放查詢結果的容器 TopScoreDocCollector collector = TopScoreDocCollector.create(1000, true); // 使用query這個查詢條件進行搜索,搜索結果放入collector searcher.Search(query, null, collector); // 首先獲取總條數 totalCount = collector.GetTotalHits(); // 從查詢結果中取出第m條到第n條的數據 ScoreDoc[] docs = collector.TopDocs(startIndex, pageSize).scoreDocs; // 遍歷查詢結果 IList<SearchResult> resultList = new List<SearchResult>(); for (int i = 0; i < docs.Length; i++) { // 拿到文檔的id,因為Document可能非常占內存(DataSet和DataReader的區別) int docId = docs[i].doc; // 所以查詢結果中只有id,具體內容需要二次查詢 // 根據id查詢內容:放進去的是Document,查出來的還是Document Document doc = searcher.Doc(docId); SearchResult result = new SearchResult(); result.Url = "ViewArticle.aspx?id=" + doc.Get("id"); result.Title = HighlightHelper.HighLight(keyword, doc.Get("title")); result.Msg = HighlightHelper.HighLight(keyword, doc.Get("msg")) + "......"; resultList.Add(result); } // 綁定到Repeater rptSearchResult.DataSource = resultList; rptSearchResult.DataBind(); }
這里使用Lucene.Net提供的BooleanQuery進行復合查詢,何為復合查詢?舉個例子,假設某個帖子的Title為“阿凡達大戰機器貓”,帖子內容Content為“呵呵,你妹!”。這時,假設我們只對Content進行查詢,那么用戶搜索阿凡達就會搜不到。所以,我們需要對Title和Content都進行查詢,也就需要使用BooleanQuery。
2.4 搜索建議提示
相信我們在使用百度等搜索引擎進行搜索時都會看到每當我們輸入一個詞時,會彈出提示框,下面有很多相關的搜索項。這里我們可以通過AJAX操作完成搜索建議功能。
這里我們得AutoComplete使用的是一個jQuery UI的AutoComplete插件,前端調用其封裝的Ajax請求方法:
$(function () { $("#txtKeyword").autocomplete({ source: "SearchSuggestionHandler.ashx", select: function (event, ui) { $("#txtKeyword").val(ui.item.value); $("#mainForm").submit(); } }); $("#txtKeyword").focus(); });
后端是一個一般處理程序,負責將Keyword與數據庫中搜索記錄表中的Item進行匹配,如果有匹配項則序列化為JSON傳遞到前端,前端負責將JSON反序列化並顯示到AutoComplete框中:
public class SearchSuggestionHandler : IHttpHandler { public void ProcessRequest(HttpContext context) { context.Response.ContentType = "text/plain"; // 注意這里傳過來的參數name是term string keyword = context.Request["term"]; IList<string> keywordList = new List<string>(); SearchLogStasticsService statService = new SearchLogStasticsService(); DataTable dt = statService.GetSuggestion(keyword); foreach (DataRow dr in dt.Rows) { keywordList.Add(Convert.ToString(dr["Word"])); } JavaScriptSerializer jss = new JavaScriptSerializer(); string json = jss.Serialize(keywordList); context.Response.Write(json); } }
三、效果演示
前面說了那么多,終於到了Show Time。不過,也沒什么好Show的:
(1)一周熱詞
(2)搜索提示
(3)搜索結果
附件下載
站內搜索Demo:https://github.com/EdisonChou/SearchEngineWithLuceneNet
【提示:數據庫文件在App_Data目錄下,建議使用MS SQL Server 2008及以上版本附加】
參考資料
(1)楊中科,《Lucene.Net站內搜索公開課》
(2)痞子一毛,《Lucene.Net》
(3)MeteorSeed,《使用Lucene.Net實現全文檢索》
(4)Lucene.Net官方網站:http://lucenenet.apache.org/download.html