自反+递归 实现评论的无限引用
引言
大家每天都在看博客,发表评论,实现一个评论系统也是一名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。接着,我们根据静态页面创建了页面,并添加了样式。在后置代码中我们实现了主要的逻辑,包括列表排序、筛选,根据当前评论递归地查找所有引用的评论,以及如何产生输出。
最后,我们添加了发表评论的功能,对程序进行了测试。
感谢阅读,希望这篇文章能给你带来帮助!