藉助 Lucene.Net 構建站內搜索引擎(下)

前言:上一篇咱們學習了Lucene.Net的基本概念、分詞以及實現了一個最簡單的搜索引擎,這一篇咱們開始開發一個初具規模的站內搜索項目,經過開發站內搜索模塊,咱們能夠方便地在項目中集成站內搜索功能。本次示例Demo麻雀雖小,五臟俱全,值得學習。css

1、項目初窺

1.1 項目背景

  本項目模擬一個BBS論壇的文章內容管理系統,當用戶發帖以後首先將內容存到數據庫,而後對內容進行分詞後存入索引庫。所以,當用戶在論壇站內搜索模塊進行搜索時,會直接從索引庫中進行匹配並獲取查詢結果。站內搜索界面的效果以下圖所示:html

  因此,本Demo的重點就在於如何搭建這樣的一個站內搜索模塊,其餘例如文章帖子的CRUD不會多作介紹,請自行下載源碼查看。前端

  首先,來看看本Demo的項目結構,雖然只是作一個小Demo,仍是使用了簡單地三層結構來進行開發:git

  (1)Manulife.SearchEngine.Daogithub

  顧名思義,數據訪問層,與數據庫進行交互,各類SQL!數據庫

  (2)Manulife.SearchEngine.Serviceapache

  業務邏輯層,對數據訪問接口進行簡單的封裝,爲UI層提供服務接口。json

  (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、核心代碼

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);
        }
    }

3、效果演示

  前面說了那麼多,終於到了Show Time。不過,也沒什麼好Show的:

  (1)一週熱詞

  (2)搜索提示

  (3)搜索結果

附件下載

  站內搜索Demohttps://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

 

相關文章
相關標籤/搜索