1. 程式人生 > >自反+遞迴 實現評論的無限引用 蓋樓效果的實現

自反+遞迴 實現評論的無限引用 蓋樓效果的實現

引言

大家每天都在看部落格,發表評論,實現一個評論系統也是一名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()方法之前,我們首先應該考慮如何根據一則評論,獲取它所引用的所有評論。如果我們需要編寫一個方法,那麼這個方法需要接收哪些引數:

  1. 方法肯定需要當前顯示的評論(Comment),然後才能根據這個評論所引用的評論的Id,也就是它的CommentId屬性,去逐層深入地搜尋其他的Comment。
  2. 我們應該有一個列表來儲存搜尋到的Comment,我們管這個列表叫 quoteList,它是List<Comment>型別。
  3. 我們需要傳遞搜尋的物件,也就是當前文章下的所有評論列表,也是一個List<Comment>型別。

再看看這個方法的流程應該是什麼:

  1. 檢查傳遞進來的評論,判斷它的CommentId,如果為0,那麼是起始評論,退出方法。
  2. 如果不是,搜尋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。接著,我們根據靜態頁面建立了頁面,並添加了樣式。在後置程式碼中我們實現了主要的邏輯,包括列表排序、篩選,根據當前評論遞迴地查詢所有引用的評論,以及如何產生輸出。

最後,我們添加了發表評論的功能,對程式進行了測試。

感謝閱讀,希望這篇文章能給你帶來幫助!