目錄:ASP.NET MVC企業級實戰目錄 像www.verycd.com、博客園、淘寶、京東都有實現站內搜索功能,站內搜索無論在性能和用戶體驗上都非常不錯,本節,通過使用Lucene.Net來實現站內搜索。 演示效果預覽如下圖10-22~10-24所示。 圖10-22 圖10-23 圖10-24 ...
目錄:ASP.NET MVC企業級實戰目錄
像www.verycd.com、博客園、淘寶、京東都有實現站內搜索功能,站內搜索無論在性能和用戶體驗上都非常不錯,本節,通過使用Lucene.Net來實現站內搜索。
演示效果預覽如下圖10-22~10-24所示。
圖10-22
圖10-23
圖10-24
在10.4節,已經完成了搜索的第一個版本,但是還有許多地方需要優化。比如說,我要統計關鍵詞搜索的頻率高的詞,也即熱詞,以及像百度搜索那樣,在輸入關鍵字後,會自動把搜索相關的熱詞自動以下拉列表的形式帶出來。還有諸如搜索結果分頁,查看文章明細等。
10.5.1 熱詞統計
思路:
1、 首先,我們腦海裡要明確一點:搜索關鍵字的統計,實時性是不高的。也就是說我們可以定期的去進行統計。
2、 客戶的每一次搜索記錄,我們都需要存起來,這樣才能夠統計得到。
從第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伺服器的壓力。
- 新建控制台程式QuartzNet,添加Quartz.dll和Common.Logging.dll的程式集引用,這裡採用Database First的方式,添加ADO.NET實體數據模型,把表SearchTotals、SearchDetails添加進來。
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,然後我們去進行搜索操作,這樣後臺就定期的生成了搜索統計記錄。
10.5.2 熱門搜索
10.5.2.1 展示熱門搜索
其實就是從表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>
10.5.2.2 搜索下拉框
這裡我引入一個第三方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.5.3 標題和內容都支持搜索並高亮展示
在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
10.5.4 與查詢、或查詢、分頁
前面我們在搜索的時候,其實採用的都是與查詢,也就是說,我輸入“諸葛亮周瑜”,則只會查找出,既存在諸葛亮,又存在周瑜的記錄。那麼有時候,我們是想查詢存在諸葛亮或者周瑜的記錄的,這也就是所謂的或查詢。
我在界面添加一個覆選框“或查詢”,來讓用戶決定採用何種方式進行查詢。
至於分頁,這裡採用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>View Code
然後,各位看官請再看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