像www.verycd.com、博客園、淘寶、京東都有實現站內搜索功能,站內搜索不管在性能和用戶體驗上都很是不錯,本節,經過使用Lucene.Net來實現站內搜索。javascript
演示效果預覽以下圖10-22~10-24所示。css
圖10-22html
圖10-23java
圖10-24jquery
在10.4節,已經完成了搜索的第一個版本,可是還有許多地方須要優化。好比說,我要統計關鍵詞搜索的頻率高的詞,也即熱詞,以及像百度搜索那樣,在輸入關鍵字後,會自動把搜索相關的熱詞自動如下拉列表的形式帶出來。還有諸如搜索結果分頁,查看文章明細等。redis
思路:sql
一、 首先,咱們腦海裏要明確一點:搜索關鍵字的統計,實時性是不高的。也就是說咱們能夠按期的去進行統計。數據庫
二、 客戶的每一次搜索記錄,咱們都須要存起來,這樣纔可以統計獲得。安全
從第1點,咱們腦海中就會呈現一張彙總統計表,從第2點中,咱們會想到使用一張搜索記錄明細表。那方案就很明瞭了,只須要按期的從明細表中Group by查詢,而後把查詢結構放到彙總表中。怎麼放到彙總表中?是直接Update更新嗎?其實咱們能夠有更快捷的方式,那就是對彙總表先進行truncate,而後再進行insert操做。服務器
表10-1 搜索彙總統計表SearchTotals
字段名稱 |
字段類型 |
說明 |
Id |
char(36) |
主鍵,採用Guid方式存儲 |
KeyWords |
nvarchar(50) |
搜索關鍵字 |
SearchCounts |
int |
搜索次數 |
表10-2 搜索明細表SearchDetails
字段名稱 |
字段類型 |
說明 |
Id |
char(36) |
主鍵,採用Guid方式存儲 |
KeyWords |
nvarchar(50) |
搜索關鍵字 |
SearchDateTime |
datetime |
搜索時間 |
操做步驟:
(1)在Models文件夾中,新建兩個類SearchTotal、SearchDetail。
SearchTotal.cs代碼:
using System; using System.ComponentModel.DataAnnotations; namespace SearchDemo.Models { public class SearchTotal { public Guid Id { get; set; } [StringLength(50)] public string KeyWords { get; set; } public int SearchCounts { get; set; } } }
SearchDetail.cs代碼:
using System; using System.ComponentModel.DataAnnotations; namespace SearchDemo.Models { public class SearchDetail { public Guid Id { get; set; } [StringLength(50)] public string KeyWords { get; set; } public Nullable<DateTime> SearchDateTime { get; set; } } }
(2)修改SearchDemoContext類,新增了屬性SearchTotal、SearchDetail。
using System.Data.Entity; namespace SearchDemo.Models { public class SearchDemoContext : DbContext { public SearchDemoContext() : base("name=SearchDemoContext") { } public DbSet<Article> Article { get; set; } //下面兩個屬性是新增長的 public DbSet<SearchTotal> SearchTotal { get; set; } public DbSet<SearchDetail> SearchDetail { get; set; } } }
3)更新數據庫
因爲修改了EF上下文,新增了兩個模型類,因此須要進行遷移更新數據庫操做。
將應用程序從新編譯,而後選擇工具->庫程序包管理器->程序包管理控制檯。
打開控制檯,輸入enable-migrations -force ,而後回車。回車後會在項目項目資源管理器中會出現Migrations文件夾,打開Configuration.cs 文件,將AutomaticMigrationsEnabled 值改成 true,而後在控制檯中輸入 update-database 運行。操做完成以後,會在數據庫SearchDemo中多新建兩張表SearchTotals、SearchDetails,而原來的Articles表保持不變。如圖10-20所示。
圖10-20
(4)保存搜索記錄
用戶在每次搜索的時候,要把搜索記錄存入SearchDetails表中。爲了方便,這裏我是在用戶每次點擊搜索以後就當即往SearchDetails表中插入記錄了,也就是同步操做,而實際上,若是爲了提高搜索的效率,咱們能夠採用異步操做,即把搜索記錄的數據先寫入redis隊列中,後臺再開闢一個線程來監聽redis隊列,而後把隊列中的搜索記錄數據寫入到數據表中。由於在每次點擊搜索的時候,咱們把記錄往redis寫和把記錄直接往關係型數據庫中寫的效率是相差很大的。
//先將搜索的詞插入到明細表。 SearchDetail _SearchDetail = new SearchDetail { Id = Guid.NewGuid(), KeyWords = kw, SearchDateTime = DateTime.Now }; db.SearchDetail.Add(_SearchDetail); int r = db.SaveChanges();
(5)定時更新SearchTotals表記錄
看到這種定時任務操做,這裏能夠採用Quartz.Net框架,爲了方便,我把Quartz.Net的Job寄宿在控制檯程序中,而實際工做中,我則更傾向於將其寄宿在Windows服務中。若是有必要,能夠把這個定時更新SearchTotals表記錄的程序部署到獨立的服務器,這樣能夠減輕Web服務器的壓力。
2.添加KeyWordsTotalService.cs類,裏面封裝兩個方法,清空SearchTotals表,而後把SearchDetails表的分組查詢結構插入到SearchTotals表,這裏我只統計近30天內的搜索明細。
namespace QuartzNet { public class KeyWordsTotalService { private SearchDemoEntities db = new SearchDemoEntities(); /// <summary> /// 將統計的明細表的數據插入。 /// </summary> /// <returns></returns> public bool InsertKeyWordsRank() { string sql = "insert into SearchTotals(Id,KeyWords,SearchCounts) select newid(),KeyWords,count(*) from SearchDetails where DateDiff(day,SearchDetails.SearchDateTime,
getdate())<=30 group by SearchDetails.KeyWords"; return this.db.Database.ExecuteSqlCommand(sql) > 0; } /// <summary> /// 刪除彙總中的數據。 /// </summary> /// <returns></returns> public bool DeleteAllKeyWordsRank() { string sql = "truncate table SearchTotals"; return this.db.Database.ExecuteSqlCommand(sql) > 0; } } }
3. 添加TotalJob.cs類,繼承Ijob接口,並實現Execute方法。
namespace QuartzNet { public class TotalJob : IJob { /// <summary> /// 將明細表中的數據插入到彙總表中。 /// </summary> /// <param name="context"></param> public void Execute(JobExecutionContext context) { KeyWordsTotalService bll = new KeyWordsTotalService(); bll.DeleteAllKeyWordsRank(); bll.InsertKeyWordsRank(); } } }
4.修改Program.cs類
using Quartz; using Quartz.Impl; using System; namespace QuartzNet { class Program { static void Main(string[] args) { IScheduler sched; ISchedulerFactory sf = new StdSchedulerFactory(); sched = sf.GetScheduler(); JobDetail job = new JobDetail("job1", "group1", typeof(TotalJob));//IndexJob爲實現了IJob接口的類 DateTime ts = TriggerUtils.GetNextGivenSecondDate(null, 5);//5秒後開始第一次運行 TimeSpan interval = TimeSpan.FromSeconds(50);//每隔50秒執行一次 Trigger trigger = new SimpleTrigger("trigger1", "group1", "job1", "group1", ts, null, SimpleTrigger.RepeatIndefinitely, interval);//每若干時間運行一次,時間間隔能夠放到配置文件中指定 sched.AddJob(job, true); sched.ScheduleJob(trigger); sched.Start(); Console.ReadKey(); } } }
這裏我是直接把Job和計劃都直接寫到代碼中了,理由仍是由於方便。而實際工做中,咱們應當把這些信息儘可能寫到配置文件中,這樣後面改動起來方便,不須要修改代碼,只須要修改配置文件。
爲了儘快看到效果,我這裏是每隔50秒就進行了一次統計操做,而在實際應用中,咱們的時間間隔多是幾個小時甚至一天,由於像這樣的大數據統計,對實時性的要求不高,咱們能夠儘可能減小對數據庫的IO讀寫次數。
保持運行控制檯程序QuartzNet,而後咱們去進行搜索操做,這樣後臺就按期的生成了搜索統計記錄。
其實就是從表SearchTotals中按照搜索次數進行降序排列,而後取出數條記錄而已。
LastSearch控制器中的Index方法中添加以下代碼:
var keyWords = db.SearchTotal.OrderByDescending(a => a.SearchCounts).Select(x => x.KeyWords).Skip(0).Take(6).ToList(); ViewBag.KeyWords = keyWords;
View視圖中
<div id="divKeyWords"><span>熱門搜索:</span>@if (ViewBag.KeyWords != null) { foreach (string v in ViewBag.KeyWords) { <a href="#">@v</a> } }</div>
接下來,我想要實現以下圖10-21所示的效果:
圖10-21
當我點擊一個熱詞的時候,自動加載到文本框,並點擊「搜索」按鈕。
在View中添加代碼:
<script type="text/javascript"> $(function () { $("#divKeyWords a").click(function () { $("#txtSearch").val($(this).html()); $("#btnSearch").click(); }); }); </script>
這裏我引入一個第三方js框架Autocomplete,它能在文本框中輸入文字的時候,自動從後臺抓去數據下拉列表。
雲盤中我提供了Autocomplete.rar,將其解壓,而後拷貝到SearchDemo項目中的lib目錄下。
在SearchDemo項目中的KeyWordsTotalService.cs類中添加方法
using System; using System.Collections.Generic; using System.Data.SqlClient; using System.Linq; namespace SearchDemo.Common { public class KeyWordsTotalService { private SearchDemoContext db = new SearchDemoContext(); public List<string> GetSearchMsg(string term) { try { //存在SQL注入的安全隱患 //string sql = "select KeyWords from SearchTotals where KeyWords like '"+term.Trim()+"%'"; //return db.Database.SqlQuery<string>(sql).ToList(); string sql = "select KeyWords from SearchTotals where KeyWords like @term"; return db.Database.SqlQuery<string>(sql, new SqlParameter("@term", term+"%")).ToList(); } catch (Exception ex) { throw new Exception(ex.Message); } } } }
而後在LastSearch控制器中添加方法:
/// <summary> /// 獲取客戶列表 模糊查詢 /// </summary> /// <param name="term"></param> /// <returns></returns> public string GetKeyWordsList(string term) { if (string.IsNullOrWhiteSpace(term)) return null; var list = new KeyWordsTotalService().GetSearchMsg(term); //序列化對象 //儘可能不要用JavaScriptSerializer,爲何?性能差,徹底可用Newtonsoft.Json來代替 //System.Web.Script.Serialization.JavaScriptSerializer js = new System.Web.Script.Serialization.JavaScriptSerializer(); //return js.Serialize(list.ToArray()); return JsonConvert.SerializeObject(list.ToArray()); }
咱們來看View:
<link href="~/lib/Autocomplete/css/ui-lightness/jquery-ui-1.8.17.custom.css" rel="stylesheet" /> <script src="~/lib/Autocomplete/js/jquery-ui-1.8.17.custom.min.js"></script> <script type="text/javascript"> $(function () { $("#divKeyWords a").click(function () { $("#txtSearch").val($(this).html()); $("#btnSearch").click(); }); getKeyWordsList("txtSearch"); }); //自動加載搜索列表 function getKeyWordsList(txt) { if (txt == undefined || txt == "") return; $("#" + txt).autocomplete({ source: "/LastSearch/GetKeyWordsList", minLength: 1 }); } </script>
在10.4中,只支持在內容中對關鍵詞進行搜索,而實際上,咱們可能既要支持在標題中搜索,也要在內容中搜索。
這裏引入了BooleanQuery,咱們的查詢條件也添加了一個titleQuery。
搜索方法中,以下代碼有修改:
PhraseQuery query = new PhraseQuery();//查詢條件 PhraseQuery titleQuery = new PhraseQuery();//標題查詢條件 List<string> lstkw = LuceneHelper.PanGuSplitWord(kw);//對用戶輸入的搜索條件進行拆分。 foreach (string word in lstkw) { query.Add(new Term("Content", word));//contains("Content",word) titleQuery.Add(new Term("Title", word)); } query.SetSlop(100);//兩個詞的距離大於100(經驗值)就不放入搜索結果,由於距離太遠相關度就不高了 BooleanQuery bq = new BooleanQuery(); //Occur.Should 表示 Or , Must 表示 and 運算 bq.Add(query, BooleanClause.Occur.SHOULD); bq.Add(titleQuery, BooleanClause.Occur.SHOULD); TopScoreDocCollector collector = TopScoreDocCollector.create(1000, true);//盛放查詢結果的容器 searcher.Search(bq, null, collector);//使用query這個查詢條件進行搜索,搜索結果放入collector
前面咱們在搜索的時候,其實採用的都是與查詢,也就是說,我輸入「諸葛亮周瑜」,則只會查找出,既存在諸葛亮,又存在周瑜的記錄。那麼有時候,咱們是想查詢存在諸葛亮或者周瑜的記錄的,這也就是所謂的或查詢。
我在界面添加一個複選框「或查詢」,來讓用戶決定採用何種方式進行查詢。
至於分頁,這裏採用MvcPager,關於MvcPager的使用方法請參見4.6.3。
View完整代碼預覽:
@{ ViewBag.Title = "Index"; } @model PagedList<SearchDemo.Models.SearchResult> @using Webdiyer.WebControls.Mvc; @using SearchDemo.Models; <style type="text/css"> .search-text2{ display:block; width:528px; height:26px; line-height:26px; float:left; margin:3px 5px; border:1px solid gray; outline:none; font-family:'Microsoft Yahei'; font-size:14px;} .search-btn2{width:102px; height:32px; line-height:32px; cursor:pointer; border:0px; background-color:#d6000f;font-family:'Microsoft Yahei'; font-size:16px;color:#f3f3f3;} .search-list-con{width:640px; background-color:#fff; overflow:hidden; margin-top:0px; padding-bottom:15px; padding-top:5px;} .search-list{width:600px; overflow:hidden; margin:15px 20px 0px 20px;} .search-list dt{font-family:'Microsoft Yahei'; font-size:16px; line-height:20px; margin-bottom:7px; font-weight:normal;} .search-list dt a{color:#2981a9;} .search-list dt a em{ font-style:normal; color:#cc0000;} #divKeyWords {text-align:left;width:520px;padding-left:4px;} #divKeyWords a {text-decoration:none;} #divKeyWords a:hover {color:red;} </style> <link href="~/lib/Autocomplete/css/ui-lightness/jquery-ui-1.8.17.custom.css" rel="stylesheet" /> @using(@Html.BeginForm(null, null, FormMethod.Get)) { @Html.Hidden("hidfIsOr") <div>@Html.TextBox("txtSearch", null, new { @class="search-text2"})<input type="submit" value="搜索" name="btnSearch" id="btnSearch" class="search-btn2"/><input type="checkbox" id="isOr" value="false"/>或查詢</div> <div id="divKeyWords"><span>熱門搜索:</span>@if (ViewBag.KeyWords != null) { foreach (string v in ViewBag.KeyWords) { <a href="#">@v</a> } }</div> <div class="search-list-con"> <dl class="search-list"> @if (Model != null&& Model.Count > 0) { foreach (var viewModel in Model) { <dt><a href="@viewModel.Url" target="_blank">@MvcHtmlString.Create(viewModel.Title)</a><span style="margin-left:50px;">@viewModel.CreateTime</span></dt> <dd>@MvcHtmlString.Create(viewModel.Msg)</dd> } } @Html.Pager(Model, new PagerOptions { PageIndexParameterName = "id", ShowPageIndexBox = true, FirstPageText = "首頁", PrevPageText = "上一頁", NextPageText = "下一頁", LastPageText = "末頁", PageIndexBoxType = PageIndexBoxType.TextBox, PageIndexBoxWrapperFormatString = "請輸入頁數{0}", GoButtonText = "轉到" }) <br /> >>分頁 共有 @(Model==null? 0: Model.TotalItemCount) 篇文章 @(Model==null?0:Model.CurrentPageIndex)/@(Model==null?0:Model.TotalPageCount) </dl> </div> <div>@ViewData["ShowInfo"]</div> } <script type="text/javascript"> $(function () { $("#divKeyWords a").click(function () { $("#txtSearch").val($(this).html()); $("#btnSearch").click(); }); getKeyWordsList("txtSearch"); $("#isOr").click(function () { if ($(this).attr("checked") == "checked") { $("#hidfIsOr").val(true); } else { $("#hidfIsOr").val(false); } }); if ($("#hidfIsOr").val() == "true") { $("input[type='checkbox']").prop("checked", true); } }); //自動加載搜索列表 function getKeyWordsList(txt) { if (txt == undefined || txt == "") return; $("#" + txt).autocomplete({ source: "/LastSearch/GetKeyWordsList", minLength: 1 }); } </script> <script src="~/lib/Autocomplete/js/jquery-ui-1.8.17.custom.min.js"></script>
而後,各位看官請再看LastSearch控制器中的方法:
public class LastSearchController : Controller { // // GET: /LastSearch/ string indexPath = System.Configuration.ConfigurationManager.AppSettings["lucenedir"]; private SearchDemoContext db = new SearchDemoContext(); public ActionResult Index(string txtSearch, bool? hidfIsOr, int id = 1) { PagedList<SearchResult> list = null; if (!string.IsNullOrEmpty(txtSearch))//若是點擊的是查詢按鈕 { //list = Search(txtSearch); list = (hidfIsOr == null || hidfIsOr.Value == false) ? OrSearch(txtSearch, id) : AndSearch(txtSearch, id); } var keyWords = db.SearchTotal.OrderByDescending(a => a.SearchCounts).Select(x => x.KeyWords).Skip(0).Take(6).ToList(); ViewBag.KeyWords = keyWords; return View(list); } //與查詢 PagedList<SearchResult> AndSearch(String kw, int pageNo, int pageLen = 4) { FSDirectory directory = FSDirectory.Open(new DirectoryInfo(indexPath), new NoLockFactory()); IndexReader reader = IndexReader.Open(directory, true); IndexSearcher searcher = new IndexSearcher(reader); PhraseQuery query = new PhraseQuery();//查詢條件 PhraseQuery titleQuery = new PhraseQuery();//標題查詢條件 List<string> lstkw = LuceneHelper.PanGuSplitWord(kw);//對用戶輸入的搜索條件進行拆分。 foreach (string word in lstkw) { query.Add(new Term("Content", word));//contains("Content",word) titleQuery.Add(new Term("Title", word)); } query.SetSlop(100);//兩個詞的距離大於100(經驗值)就不放入搜索結果,由於距離太遠相關度就不高了 BooleanQuery bq = new BooleanQuery(); //Occur.Should 表示 Or , Must 表示 and 運算 bq.Add(query, BooleanClause.Occur.SHOULD); bq.Add(titleQuery, BooleanClause.Occur.SHOULD); TopScoreDocCollector collector = TopScoreDocCollector.create(1000, true);//盛放查詢結果的容器 searcher.Search(bq, null, collector);//使用query這個查詢條件進行搜索,搜索結果放入collector int recCount=collector.GetTotalHits();//總的結果條數 ScoreDoc[] docs = collector.TopDocs((pageNo - 1) * pageLen, pageNo*pageLen).scoreDocs;//從查詢結果中取出第m條到第n條的數據 List<SearchResult> list = new List<SearchResult>(); string msg = string.Empty; string title = string.Empty; for (int i = 0; i < docs.Length; i++)//遍歷查詢結果 { int docId = docs[i].doc;//拿到文檔的id,由於Document可能很是佔內存(思考DataSet和DataReader的區別) //因此查詢結果中只有id,具體內容須要二次查詢 Document doc = searcher.Doc(docId);//根據id查詢內容。放進去的是Document,查出來的仍是Document SearchResult result = new SearchResult(); result.Id = Convert.ToInt32(doc.Get("Id")); msg = doc.Get("Content");//只有 Field.Store.YES的字段才能用Get查出來 result.Msg = LuceneHelper.CreateHightLight(kw, msg);//將搜索的關鍵字高亮顯示。 title = doc.Get("Title"); foreach (string word in lstkw) { title=title.Replace(word,"<span style='color:red;'>"+word+"</span>"); } //result.Title=LuceneHelper.CreateHightLight(kw, title); result.Title = title; result.CreateTime = Convert.ToDateTime(doc.Get("CreateTime")); result.Url = "/Article/Details?Id=" + result.Id + "&kw=" + kw; list.Add(result); } //先將搜索的詞插入到明細表。 SearchDetail _SearchDetail = new SearchDetail { Id = Guid.NewGuid(), KeyWords = kw, SearchDateTime = DateTime.Now }; db.SearchDetail.Add(_SearchDetail); int r = db.SaveChanges(); PagedList<SearchResult> lst = new PagedList<SearchResult>(list, pageNo, pageLen, recCount); lst.TotalItemCount = recCount; lst.CurrentPageIndex = pageNo; return lst; } //或查詢 PagedList<SearchResult> OrSearch(String kw, int pageNo, int pageLen = 4) { FSDirectory directory = FSDirectory.Open(new DirectoryInfo(indexPath), new NoLockFactory()); IndexReader reader = IndexReader.Open(directory, true); IndexSearcher searcher = new IndexSearcher(reader); List<PhraseQuery> lstQuery = new List<PhraseQuery>(); List<string> lstkw = LuceneHelper.PanGuSplitWord(kw);//對用戶輸入的搜索條件進行拆分。 foreach (string word in lstkw) { PhraseQuery query = new PhraseQuery();//查詢條件 query.SetSlop(100);//兩個詞的距離大於100(經驗值)就不放入搜索結果,由於距離太遠相關度就不高了 query.Add(new Term("Content", word));//contains("Content",word) PhraseQuery titleQuery = new PhraseQuery();//查詢條件 titleQuery.Add(new Term("Title", word)); lstQuery.Add(query); lstQuery.Add(titleQuery); } BooleanQuery bq = new BooleanQuery(); foreach (var v in lstQuery) { //Occur.Should 表示 Or , Must 表示 and 運算 bq.Add(v, BooleanClause.Occur.SHOULD); } TopScoreDocCollector collector = TopScoreDocCollector.create(1000, true);//盛放查詢結果的容器 searcher.Search(bq, null, collector);//使用query這個查詢條件進行搜索,搜索結果放入collector int recCount = collector.GetTotalHits();//總的結果條數 ScoreDoc[] docs = collector.TopDocs((pageNo - 1) * pageLen, pageNo * pageLen).scoreDocs;//從查詢結果中取出第m條到第n條的數據 List<SearchResult> list = new List<SearchResult>(); string msg = string.Empty; string title = string.Empty; for (int i = 0; i < docs.Length; i++)//遍歷查詢結果 { int docId = docs[i].doc;//拿到文檔的id,由於Document可能很是佔內存(思考DataSet和DataReader的區別) //因此查詢結果中只有id,具體內容須要二次查詢 Document doc = searcher.Doc(docId);//根據id查詢內容。放進去的是Document,查出來的仍是Document SearchResult result = new SearchResult(); result.Id = Convert.ToInt32(doc.Get("Id")); msg = doc.Get("Content");//只有 Field.Store.YES的字段才能用Get查出來 result.Msg = LuceneHelper.CreateHightLight(kw, msg);//將搜索的關鍵字高亮顯示。 title = doc.Get("Title"); foreach (string word in lstkw) { title = title.Replace(word, "<span style='color:red;'>" + word + "</span>"); } //result.Title=LuceneHelper.CreateHightLight(kw, title); result.Title = title; result.CreateTime = Convert.ToDateTime(doc.Get("CreateTime")); result.Url = "/Article/Details?Id=" + result.Id + "&kw=" + kw; list.Add(result); } //先將搜索的詞插入到明細表。 SearchDetail _SearchDetail = new SearchDetail { Id = Guid.NewGuid(), KeyWords = kw, SearchDateTime = DateTime.Now }; db.SearchDetail.Add(_SearchDetail); int r = db.SaveChanges(); PagedList<SearchResult> lst = new PagedList<SearchResult>(list, pageNo, pageLen, recCount); lst.TotalItemCount = recCount; lst.CurrentPageIndex = pageNo; return lst; } /// <summary> /// 獲取客戶列表 模糊查詢 /// </summary> /// <param name="term"></param> /// <returns></returns> public string GetKeyWordsList(string term) { if (string.IsNullOrWhiteSpace(term)) return null; var list = new KeyWordsTotalService().GetSearchMsg(term); //序列化對象 //儘可能不要用JavaScriptSerializer,爲何?性能差,徹底可用Newtonsoft.Json來代替 //System.Web.Script.Serialization.JavaScriptSerializer js = new System.Web.Script.Serialization.JavaScriptSerializer(); //return js.Serialize(list.ToArray()); return JsonConvert.SerializeObject(list.ToArray()); }
至此,站內搜索的基本功能均已完成。