自反+遞迴 實現評論的無限引用 蓋樓效果的實現
引言
大家每天都在看部落格,發表評論,實現一個評論系統也是一名Web開發者的基本要求。雖然評論只是一個很普通的功能,但是實現評論的引用,尤其是無限引用,卻有一定的困難。本文不是本人原創,而是引自一名“網易工程隊”的正規軍,向大家展示一下“蓋樓”的方法。下面是他的文章:
NOTE:本文使用 基於業務物件(List<Comment>)的篩選 來進行引用列表的搜尋,對資料庫僅進行了一次讀取。想也應該能想明白:不管是初始評論還是包含引用的評論都屬於同一文章下,一次讀取該文章下的評論,進行列表搜尋就可以了,為什麼要多次讀取資料庫!?
儘管如此,使用遞迴的效率依然是很低的,會進行頻繁的方法呼叫,所以這篇文章的方法基本上只有實驗價值,沒有使用價值。可以考慮在Comment表中建一個欄位,QuoteContent,用來儲存引用的內容,QuoteContent可以使用文中的方法來獲得。
評論引用的“傳統方法”
稱之為“傳統方法”,是因為這種方法很多的論壇都在採用,比如說 藍色理想。做法是在點引用的時候,在回帖人的正文中,加入程式碼比如“[quote]引用內容...[/quote]回覆正文”,然後在輸出的時候,將UBB程式碼用正則表示式替換成HTML程式碼。
這種方法的好處是取出資料速度比較快,直接從資料庫讀出再送顯就可以了。缺點是寫正則表示式比較麻煩,而且容易出錯,比如一個[quote]的巢狀位置不正確,就會使表示式失效;還有就是會讓資料庫儲存額外的資料(引用的內容也儲存了)。
這種方法很多人可能都用過,我們就不討論了,直接進入我們的正題。
自反關係的表結構
我們先介紹一下本文會頻繁用到的兩個術語:
- 初始評論:表示這個評論沒有引用其他任何評論。
- 引用評論:表示這個評論包含對其他評論的引用。
資料表的結構是實現無限引用的前提,設計的不好就會很難實現。我們先看一下建表的指令碼:
Create Table Comment
(
Id int identity(1,1) Not Null,
UserName Varchar(200) Not Null,
Content Varchar(2000) Not Null,
PostDate DateTime Not Null Default GetDate(),
CommentId Int Null, -- 外來鍵,自反關係
ArticleId Int Not Null
Constraint pk_Comment Primary Key(Id)
Constraint fk_Comment_Comment Foreign Key(CommentId) References Comment(Id)
)
- Id:評論的Id。
- UserName:通常情況下,這裡是個int型別的UserId,引用一個User表,但在本文中簡單起見,直接用Varchar型別。
- Content:評論的內容。
- PostDate:評論發表的時間。
這些我想都比較容易看懂,我們下來主要看CommentId和ArticleId:
CommentId:這是一個關鍵欄位。這個欄位引用了本身(Comment表)的Id欄位,構成一個自反關係,它也是Comment表的外來鍵。當一個評論是初始評論時,它為Null;當一個評論是引用評論時,它為該評論所引用的評論的Id。當我們需要獲取某一個引用評論時,需要順著它的CommentId縱深查詢,直到找到CommmentId為Null的評論。
舉例來說:如果我想顯示Id為17的評論,我先看它的CommentId是否為Null,如果為Null,那麼它是一個初始評論,直接返回;如果不為Null,則尋找Id等於它的CommentId的評論,找到以後,再檢查這個評論的CommentId,重複之前的過程,一直找到CommentId為Null的評論為止。
ArticleId:這個大家應該比較熟悉了,它是評論所屬的文章的Id。
NOTE:這裡我想說一下,如果想建立外來鍵(自反是一種特殊的外來鍵)。那麼外來鍵所包含的欄位要麼為Null,要麼為一個存在的主鍵。我發現很多人不喜歡用Null,他們也不建外來鍵,如果讓他們來實現上面的表結構,當一個評論是初始評論時,他會給CommentId 賦值為0,而不是Null。雖然這樣也沒什麼錯,但我個人很不喜歡,不夠規範。
頁面實現
雖然我曾經花了不少時間學習Web標準,但是以後我不會再過分地分散精力了,我的文章也不會講述Css和Web標準,所以這裡只給出實現並略做一點說明。
儘管本文中評論部分的頁面是動態生成的HTML,但是我們往往需要先設計一下HTML,編寫好樣式表,然後才去寫程式,我們看下一個無限引用的HTML程式碼可能是什麼樣的。在任意一個站點下建立一個頁面NestedComment.aspx:
<div id="commentHolder">
<div class='comment'>
<p class='title'><span>2008-3-24 16:33:49 發表</span>內蒙古網友</p>
<div>
<div>
<div><span>廣州網友 原貼:</span><br />
向馬XX同志榮升臺灣省省長表示祝賀!
</div>
<span>四川網友 原貼:</span><br />
四川人民發來賀電!
</div>
<span>陝西西安網友 原貼:</span><br />
陝西網友發來賀電
</div>
<p>內蒙網友發來賀電</p>
</div>
<div class='comment'>略...</div>
<div class='comment'>略...</div>
<div class='comment'>略...</div>
<div class='comment'>略...</div>
</div>
可以看到,每一條評論都包含在一個css Class為comment的div中,所有的div又包含在一個Id為commentHolder的div中,作為它們的容器。我們之後要生成的程式碼,將會以上面的HTML程式碼作為格式和模板。現在把它們註釋掉,放置一個Repeater控制元件,程式碼如下:
<div id="commentHolder">
<asp:Repeater runat="server" ID="rpComment" EnableViewState="false">
<ItemTemplate>
<%# GetContent(Container.DataItem) %>
</ItemTemplate>
</asp:Repeater>
</div>
注意到將EnableViewState設為了False,以及在ItemTemplate中放置了一個方法GetContent(),並將當前繫結的專案作為引數傳遞了進去,這些我們在後置程式碼中會再講到。
我們再看一下Css樣式:
<style type="text/css" >
*{margin:0;padding:0;}
body{margin:10px;font-size:14px;font-family:宋體}
h1{font-size:26px;margin:10px 0 15px;}
#commentHolder{width:540px;border-bottom:1px solid #aaa;}
.comment{padding:5px 8px;background:#f8fcff;border:1px solid #aaa;font-size:14px;border-bottom:none;}
.comment p{padding:5px 0;}
.comment p.title{color:#1f3a87;font-size:12px;}
.comment p span{float:right;color:#666}
.comment div{background:#ffe;padding:3px;border:1px solid #aaa;line-height:140%;margin-bottom:5px;}
.comment div span{color:#1f3a87;font-size:12px;}
</style>
後置程式碼
Comment 實體類
我們先建立一個實體類 Comment,這個類用於對映資料庫中的表Comment:
public class Comment {
private int id;
private string userName;
private string content;
private DateTime postDate;
private int commentId;
private int articleId;
public int Id {
get { return id; }
set { id = value; }
}
public string UserName {
get { return userName; }
set { userName = value; }
}
public string Content {
get { return content; }
set { content = value; }
}
public DateTime PostDate {
get { return postDate; }
set { postDate = value; }
}
public int CommentId {
get { return commentId; }
set { commentId = value; }
}
public int ArticleId {
get { return articleId; }
set { articleId = value; }
}
}
評論的排序一般有兩種:一種是最新評論在最上面,一種是最新評論在最下面。我個人比較喜歡最新評論在最上面這種,但是在引用評論中引用的評論列表肯定是最早的在最上面,所以我們需要實現列表的排序,一種是順序,一種是倒序。關於如何實現列表排序,在 基於業務物件的排序 中已經很詳細的寫明瞭,這裡就不再討論,只給出程式碼。修改Comment類,新增如下程式碼:
public class Comment {
//... 上面略
public static CommentComparer GetComparer(bool isAscending) {
return new CommentComparer(isAscending);
}
public static CommentComparer GetComparer() {
return GetComparer(true);
}
// 巢狀類,用於排序
public class CommentComparer : IComparer<Comment> {
private bool isAscending;
public CommentComparer(bool isAscending) {
this.isAscending = isAscending;
}
public int Compare(Comment x, Comment y) {
if (isAscending)
return x.Id.CompareTo(y.Id);
else
return y.Id.CompareTo(x.id);
}
}
}
獲取評論列表:GetList(int articleId)方法
我們接著在程式碼後置類中新增一個方法,GetList(int articleId),這個方法通常是根據文章Id(articleId)從資料庫中獲取這個文章下的所有評論,並返回一個List<Comment>列表物件。但是本文中,為了簡單起見,我直接手動建立了這個列表物件(需要注意的是對於CommentId為Null的評論,我們將它的CommentId設為0,也可以使用 int?,這樣int型別也可以設定為null,但我個人不大喜歡這樣):
// 應該來自於資料庫,這裡直接 HardCoding 了
// articleId 是文章的Id,返回此文章下的所有評論
private List<Comment> GetList(int articleId)
{
List<Comment> list = new List<Comment>();
Comment cmt1 = new Comment();
cmt1.Id = 15; // 評論Id
cmt1.ArticleId = articleId; // 文章Id
cmt1.CommentId = 0; // 起始評論
cmt1.Content = "向馬XX同志榮升臺灣省省長表示祝賀!";
cmt1.PostDate = DateTime.Now.AddMinutes(-25); // 25分鐘前發表
cmt1.UserName = "廣州網友"; // 使用者名稱
Comment cmt2 = new Comment();
cmt2.Id = 16;
cmt2.ArticleId = articleId;
cmt2.CommentId = 15; // 引用id為15的評論
cmt2.Content = "四川人民發來賀電!";
cmt2.PostDate = DateTime.Now.AddMinutes(-19);
cmt2.UserName = "四川網友";
Comment cmt3 = new Comment();
cmt3.Id = 17;
cmt3.ArticleId = articleId;
cmt3.CommentId = 16; // 引用id為16的評論
cmt3.Content = "陝西人民發來賀電";
cmt3.PostDate = DateTime.Now.AddMinutes(-16);
cmt3.UserName = "陝西西安網友";
Comment cmt4 = new Comment();
cmt4.Id = 18;
cmt4.ArticleId = articleId;
cmt4.CommentId = 0; // 又一則起始評論
cmt4.Content = "希望臺灣和平穩定發展。";
cmt4.PostDate = DateTime.Now.AddMinutes(-13);
cmt4.UserName = "黑龍江網友";
Comment cmt5 = new Comment();
cmt5.Id = 19;
cmt5.ArticleId = articleId;
cmt5.CommentId = 17; // 引用Id為17的評論
cmt5.Content = "寧夏人民發來賀電";
cmt5.PostDate = DateTime.Now.AddMinutes(-8);
cmt5.UserName = "寧夏網友";
Comment cmt6 = new Comment();
cmt6.Id = 20;
cmt6.ArticleId = articleId;
cmt6.CommentId = 18; // 引用Id為18的評論
cmt6.Content = "支援樓上";
cmt6.PostDate = DateTime.Now.AddMinutes(-5);
cmt6.UserName = "加拿大網友";
Comment cmt7 = new Comment();
cmt7.Id = 21;
cmt7.ArticleId = articleId;
cmt7.CommentId = 17; // 引用Id為17的評論
cmt7.Content = "內蒙人民發來賀電";
cmt7.PostDate = DateTime.Now.AddMinutes(-2);
cmt7.UserName = "內蒙古網友";
list.Add(cmt1);
list.Add(cmt2);
list.Add(cmt3);
list.Add(cmt4);
list.Add(cmt5);
list.Add(cmt6);
list.Add(cmt7);
return list;
}
填充Repeater控制元件,Page_Load 事件程式碼
我們在Page_Load中呼叫GetList()方法,獲取評論列表,將它按倒序排列,然後填充了Repeater控制元件:
protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
List<Comment> list = GetList(16); // 獲取ArticleId為16的所有評論
list.Sort(Comment.GetComparer(false)); // 倒序排列
ViewState["List"] = list; // 設定ViewState
rpComment.DataSource = list;
rpComment.DataBind();
}
}
注意到,我們使用ViewState儲存了列表,一會還會看到,我們會從ViewState中還原列表,此時,Comment物件必須被標記為可序列化,修改Comment類,在頂部新增Serializable特性:
[Serializable]
public class Comment { /*略*/}
獲取輸出:GetContent()方法
接下來我們需要編寫我們的核心方法GetContent,它嵌入在Repeater控制元件的ItemTemplate中,並接受Container.DateItem作為引數,而Container.DateItem代表的是Repeater控制元件顯示的一個數據項,也就是一個Comment型別例項,再進一步就是資料庫表中的一行。
遞迴呼叫:AddComment()方法
在實現GetContent()方法之前,我們首先應該考慮如何根據一則評論,獲取它所引用的所有評論。如果我們需要編寫一個方法,那麼這個方法需要接收哪些引數:
- 方法肯定需要當前顯示的評論(Comment),然後才能根據這個評論所引用的評論的Id,也就是它的CommentId屬性,去逐層深入地搜尋其他的Comment。
- 我們應該有一個列表來儲存搜尋到的Comment,我們管這個列表叫 quoteList,它是List<Comment>型別。
- 我們需要傳遞搜尋的物件,也就是當前文章下的所有評論列表,也是一個List<Comment>型別。
再看看這個方法的流程應該是什麼:
- 檢查傳遞進來的評論,判斷它的CommentId,如果為0,那麼是起始評論,退出方法。
- 如果不是,搜尋Id等於它的CommentId的評論;將找到的評論加入quoteList引用列表;然後再次呼叫方法,並傳遞找到的評論(遞迴呼叫)。直到找出CommentId為0的評論為止。
現在我們來看一下AddComment()方法的程式碼:
// 向quoteList中新增 符合條件的Comment
protected void AddComment(List<Comment> list, List<Comment> quoteList, Comment cmt)
{
if (cmt.CommentId != 0)
{
Comment find = list.Find(new Predicate<Comment>(cmt.MatchRule));
quoteList.Add(find);
// 遞迴呼叫,只要CommentId不為零,就加入到引用評論列表
AddComment(list, quoteList, find);
}else
return;
}
上面的引數 list代表某一文章下的全部評論列表,cmt代表當前要顯示的評論,quoteList代表當前要顯示的評論所引用的評論列表。
列表搜尋:Predicate<T>(T obj)委託
注意上面,在找尋Id等於當前評論的CommentId的評論,我使用了list.Find()方法。
如何進行列表的搜尋,我在 基於業務物件的篩選 中已經詳細介紹了,這裡只給出實現過程,不再進行講述。需要注意的是:在 基於業務物件的篩選 一文中,我是建立了一個類封裝篩選的規則,而在本文中,我們將篩選規則,也就是MatchRule()方法(這個方法實現了Predicate<T>委託)直接寫在了 Comment 類中,它的作用是:搜尋列表,並返回Id與當前評論的CommentId相同的評論。
修改Comment類,新增如下程式碼:
// 實現 Predicate<T> 委託,搜尋Id 等於當前評論的CommentId的評論
public bool MatchRule(Comment cmt) {
return (this.commentId == cmt.id);
}
輸出顯示:GetContent()方法
OK,有了前面的鋪墊,下面的工作就直白的多,我先給出程式碼,再做以說明:
// 根據當前的Comment得到HTML輸出
protected string GetContent(object objComment)
{
string output = "";
List<Comment> list = (List<Comment>)rpComment.DataSource; // 獲取全部列表
Comment cmt = (Comment)objComment; // 獲取當前評論
List<Comment> quoteList = new List<Comment>(); // 建立當前評論所引用的評論列表
AddComment(list, quoteList, cmt); // 為當前評論的引用列表新增專案
quoteList.Sort(Comment.GetComparer()); // 對列表排序,順序排列
foreach (Comment quote in quoteList) // 生成引用的評論列表
{
output = String.Format(
"<div>{0}<span>{1} 原貼:</span><br />{2}</div>",
output, quote.UserName, quote.Content);
}
// 添加當前引用
output = String.Format(
"<div class='comment'><p class='title'><span>{0} 發表</span>{1}</p>{2}<p>{3}</p></div>",
cmt.PostDate, cmt.UserName, output ,cmt.Content);
return output;
}
在這段程式碼中,我們獲取了當前評論 cmt,然後又通過Repeater控制元件的DataSource屬性獲取了全部列表,建立了當前評論所引用的評論列表,然後呼叫了AddComment()方法,對引用列表填充了專案,然後通過Sort()方法將它按Id順序排列。最後,我們遍歷了引用列表,按照我們之前講述的HTML程式碼產生了輸出,然後返回到頁面上。
測試:發表評論
現在所有的工作都做完了,但是為了更有趣一些,讓我們為程式添加發表評論的功能。先在頁面上新增如下程式碼:
<br />
引用評論 :
<asp:DropDownList ID="ddlCommentId" runat="server">
<asp:ListItem>21</asp:ListItem>
<asp:ListItem>20</asp:ListItem>
<asp:ListItem>19</asp:ListItem>
<asp:ListItem>18</asp:ListItem>
<asp:ListItem>17</asp:ListItem>
<asp:ListItem>16</asp:ListItem>
<asp:ListItem>15</asp:ListItem>
</asp:DropDownList>
姓名:<asp:TextBox ID="txtUserName" runat="server" Width="100px"></asp:TextBox>
<br />
<asp:TextBox ID="txtContent" TextMode="MultiLine" runat="server" Height="92px" Width="300px"></asp:TextBox>
<asp:Button ID="btnSubmit" runat="server" Text="提交" OnClick="btnSubmit_Click" />
然後編寫btnSubmit的Click事件:
// 按鈕提交事件,通常是要儲存到資料庫
// 作為演示,這裡使用ViewState進行持久化
protected void btnSubmit_Click(object sender, EventArgs e)
{
// 從ViewState中獲取 Comment列表
List<Comment> list = ViewState["List"] as List<Comment>;
Comment cmt = new Comment();
cmt.ArticleId = 16;
cmt.CommentId = Convert.ToInt32(ddlCommentId.SelectedValue);
cmt.Content = txtContent.Text;
cmt.Id = 15 + list.Count; // 設定當前評論的Id
cmt.PostDate = DateTime.Now;
cmt.UserName = txtUserName.Text;
// 將新評論的id新增到DropDownList中
ListItem item = new ListItem(cmt.Id.ToString());
ddlCommentId.Items.Insert(0, item);
ddlCommentId.SelectedIndex = 0;
list.Add(cmt); // 新增新評論。
list.Sort(Comment.GetComparer(false)); // 倒序排列回帖
// ViewState["List"] = list; 這裡是沒有必要的,因為ViewState和list引用的是用同一個物件
rpComment.DataSource = list;
rpComment.DataBind();
}
我們從ViewState中獲取了列表,然後根據使用者輸入建立新評論,然後新增在了列表中,最後,我們讓Repeater控制元件再次繫結評論列表list。
注意在上面的程式碼中,有的人會在向列表中添加了評論後,對ViewState再次賦值:
ViewState["List"] = list;
這是沒有必要的,因為ViewState和list引用的是同一個物件,當你在list上呼叫Add方法新增專案的時候,ViewState也已經添加了。你可以在上面的方法中新增如下程式碼來驗證:
Label lbResult = new Label();
lbResult.Text = Object.ReferenceEquals(list, ViewState["List"]).ToString();
Page.Controls.Add(lbResult); // 返回True,說明ViewState與list引用了同一個物件
現在我們開啟頁面,然後新增評論,就會看到類似下面的顯示:
總結
本文中,我們綜合使用了前面學習的知識,實現了類似網易的無限評論引用功能。
我們先了解了評論引用的傳統實現方式,然後介紹了資料庫表結構、建立了資料庫表的對映類Comment。接著,我們根據靜態頁面建立了頁面,並添加了樣式。在後置程式碼中我們實現了主要的邏輯,包括列表排序、篩選,根據當前評論遞迴地查詢所有引用的評論,以及如何產生輸出。
最後,我們添加了發表評論的功能,對程式進行了測試。
感謝閱讀,希望這篇文章能給你帶來幫助!