背景
在開發博客系統的時候,遇到了一個技術難點,就是如何實現用戶對博客的評論和回覆功能?
嘗試了很多套方案,最後還是採用了Java的鏈表結構來查詢處理評論回覆。
博客如何實現評論回覆功能
數據庫設計
這裏主要展示有關評論和回覆的核心數據庫設計:
Blog(博客表)
User(用戶表)
Observe評論表
數據庫設計概要
- 博客表主要用於id和評論表的
blog_id
關聯,用於表示這條評論是和哪篇博客相關聯的。 - 用戶表主要用於id和評論表的observer_id關聯,用於表示這條評論和哪個用戶相關聯。
- 博客表和用戶表不是關鍵性內容,只用瞭解其id和評論表有關聯即可。
- 關鍵點在於評論表設計的
last_id
字段
- 如果
last_id
爲null
,說明此條評論爲一級評論(直接評論這篇博客) - 如果
last_id
不爲null
,其值代表回覆哪條評論(上一級評論的id)
依據這樣的last_id
設計,可完美實現一條鏈狀的評論(多級評論回覆)。通過last_id我們就能找到這條回覆是回覆的哪條評論。
java實現評論的查詢
存儲
對於存儲結構,使用鏈表。通過鏈表,我們可以一步步找到最後一條評論。因爲一條評論可能存在多人回覆(同級評論或者回復有多條),所以我們採用List存儲下一個對象(下一級評論回覆)。
查詢
- 先查詢
last_id
爲null
的情況,即所有的一級評論集合。 - 然後查詢
last_id
不爲null
的情況,即所有非一級評論集合(爲評論的回覆)。 - 通過
last_id
找到對應的評論、回覆,添加到鏈表中。
具體實現
數據庫查詢主要用的是mybatis的通用mapper,如果不會,可參看我的另一篇博客:SSM項目中如何使用通用mapper(tk.mapper)?如何使用註解簡化mybatis的開發(捨棄大量的xml文件)?註解處理一對一、一對多、多對多的關係?
主要演示查詢的實現,增刪改都比較簡單,暫略。
評論的實體類
@Entity
@Data
@Table(name="observe")
public class Observe implements Serializable {
/** 評論id */
@Id
@Column(name = "id")
@GeneratedValue(strategy=GenerationType.IDENTITY)
@NotNull(groups = UpdateGroup.class,message = "修改評論信息,id不可以空")
private Long id;
/** 所屬博客id */
@Column(name="blog_id")
@NotNull(message = "所屬博客id不能爲空")
private Long blogId;
/** 評論者id */
@Column(name="observer_id")
@NotNull(groups = UpdateGroup.class,message = "評論者id不能爲空")
private Long observerId;
/** 評論內容 */
@Column(name="observe_content")
@NotBlank(message = "評論內容不能爲空")
private String observeContent;
/** 評論上一級的id */
@Column(name="last_id")
private Long lastId;
/** 是否刪除 */
@Column(name="is_delete")
private Boolean delete;
/** 創建時間(評論時間) */
@Column(name="create_date")
@JSONField(format="yyyy-MM-dd HH:mm:ss")
private Timestamp createDate;
/** 修改刪除 */
@Column(name="update_date")
@JSONField(format="yyyy-MM-dd HH:mm:ss")
private Timestamp updateDate;
public interface UpdateGroup {
}
}
評論的鏈表結構
繼承評論的實體類
/**
* 功能描述:封裝博客評論的BO <br>
* 採用鏈表結構實現
**/
@EqualsAndHashCode(callSuper = true)
@Data
@NoArgsConstructor
public class ObserveNodeBO extends Observe {
/**
* 評論的用戶信息
*/
private User user;
/**
* 下一條回覆
*/
private List<ObserveNodeBO> nextNodes = new ArrayList<>();
public ObserveNodeBO ( ObserveNodeBO observeNodeBo ) {
super();
setId(observeNodeBo.getId());
setBlogId(observeNodeBo.getBlogId());
setObserverId(observeNodeBo.getObserverId());
setObserveContent(observeNodeBo.getObserveContent());
setLastId(observeNodeBo.getLastId());
setDelete(observeNodeBo.getDelete());
setCreateDate(observeNodeBo.getCreateDate());
setUpdateDate(observeNodeBo.getUpdateDate());
this.user = observeNodeBo.getUser();
}
}
評論的mapper(數據庫操作)
評論與用戶之間是一對一的關係,所以這裏使用mybatis的註解實現一對一關聯。
/**
* 功能描述: 自定義的mapper
* ‘@RegisterMapper’ 使自定義的mapper可以被掃描到
**/
@RegisterMapper
public interface CommentMapper<T> extends Mapper<T>, IdListMapper<T, Long>, InsertListMapper<T> {
}
@Component
public interface ObserveMapper extends CommentMapper<Observe> {
/**
* 功能描述:根據博客id和lastId爲空,查詢所有的一級評論信息集合
* @param blogId 博客id
* @return 一級評論信息集合
* @author RenShiWei
* Date: 2020/4/16 10:37
*/
@Select("SELECT * FROM observe o LEFT JOIN user u " +
"ON o.observer_id=u.id " +
"WHERE o.blog_id=#{blogId} AND o.last_id is null")
@Results({
@Result(id = true, column = "id", property = "id"),
@Result(column = "blog_id", property = "blogId"),
@Result(column = "observer_id", property = "observerId"),
@Result(column = "observe_content", property = "observeContent"),
@Result(column = "observer_id", property = "user",
one = @One(select = "com.blog.module.business.mapper.UserMapper.queryUserForObserve",
fetchType = FetchType.EAGER)),
@Result(column = "last_id", property = "lastId"),
@Result(column = "is_delete", property = "delete"),
@Result(column = "create_date", property = "createDate"),
@Result(column = "update_date", property = "updateDate")
})
List<ObserveNodeBO> queryFirstObserveList (@Param("blogId") Long blogId );
/**
* 功能描述:根據博客id和lastId不爲空,查詢所有的二級評論信息集合
* @param blogId 博客id
* @return 二級評論信息集合
* @author RenShiWei
* Date: 2020/4/16 10:37
*/
@Select("SELECT * FROM observe o LEFT JOIN user u " +
"ON o.observer_id=u.id " +
"WHERE o.blog_id=#{blogId} AND o.last_id is not null")
@Results({
@Result(id = true, column = "id", property = "id"),
@Result(column = "blog_id", property = "blogId"),
@Result(column = "observer_id", property = "observerId"),
@Result(column = "observe_content", property = "observeContent"),
@Result(column = "observer_id", property = "user",
one = @One(select = "com.blog.module.business.mapper.UserMapper.queryUserForObserve",
fetchType = FetchType.EAGER)),
@Result(column = "last_id", property = "lastId"),
@Result(column = "is_delete", property = "delete"),
@Result(column = "create_date", property = "createDate"),
@Result(column = "update_date", property = "updateDate")
})
List<ObserveNodeBO> querySecondObserveList (@Param("blogId") Long blogId );
}
@Component
public interface UserMapper extends CommentMapper<User> {
/**
* 功能描述:根據主鍵id查詢用戶信息
* (在observe中一對一關係使用)
*
* @param id 用戶id
* @return 用戶信息
* @author RenShiWei
* Date: 2020/4/16 10:43
*/
@Select("SELECT * FROM user WHERE id=#{id}")
@Results({
@Result(id = true, column = "id", property = "id"),
@Result(column = "create_date", property = "createDate"),
@Result(column = "last_id", property = "lastId"),
@Result(column = "is_delete", property = "delete"),
@Result(column = "update_date", property = "updateDate")
})
User queryUserForObserve (@Param("id") Long id );
}
評論的業務邏輯實現
public interface ObserveService {
/**
* 功能描述:根據博客id,查詢此博客的所有評論信息
* @param blogId 博客id
* @return 博客的評論信息
*/
List<ObserveNodeBO> queryObserveByBlogId( Long blogId);
/**
* 功能描述:根據評論id查詢用戶信息
* @param observeId 評論id
* @return 評論信息,攜帶用戶信息
*/
ObserveUserBo queryObserveUserById(Long observeId);
}
@Service
public class ObserveServiceImpl implements ObserveService {
@Autowired
private ObserveMapper observeMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private BlogMapper blogMapper;
/**
* 功能描述:根據博客id,查詢此博客的所有評論信息
*
* @param blogId 博客id
* @return 博客的評論信息
*/
@Override
public List<ObserveNodeBO> queryObserveByBlogId ( Long blogId ) {
//所有未處理的一級評論集合
List<ObserveNodeBO> firstObserveList = observeMapper.queryFirstObserveList(blogId);
//所有未處理的二級評論集合
List<ObserveNodeBO> secondObserveList = observeMapper.querySecondObserveList(blogId);
//將二級評論用鏈表的方式添加到一級評論
List<ObserveNodeBO> list = addAllNode(firstObserveList, secondObserveList);
//控制檯打印評論回覆
show(list);
//返回處理後的評論信息
return list;
}
/**
* 功能描述:根據評論id查詢用戶信息
*
* @param observeId 評論id
* @return 評論信息,攜帶用戶信息
*/
@Override
public ObserveUserBo queryObserveUserById ( Long observeId ) {
Observe observe = observeMapper.selectByPrimaryKey(observeId);
User user = userMapper.selectByPrimaryKey(observe.getObserverId());
ObserveUserBo observeUserBo = new ObserveUserBo();
observeUserBo.setObserve(observe);
observeUserBo.setUser(user);
return observeUserBo;
}
/**
* 功能描述:將單個node添加到鏈表中
*
* @param firstList 第一層評論集合(鏈表)
* @param observeNode 非第一層評論的回覆信息
* @return 是否添加
*/
private boolean addNode ( List<ObserveNodeBO> firstList, ObserveNodeBO observeNode ) {
//循環添加
for (ObserveNodeBO node : firstList) {
//判斷留言的上一段是否是這條留言(判斷這條回覆,是否是當前評論的回覆)
if (node.getId().equals(observeNode.getLastId())) {
//是,添加,返回true
node.getNextNodes().add(observeNode);
return true;
} else {
//否則遞歸繼續判斷
if (node.getNextNodes().size() != 0) {
if (addNode(node.getNextNodes(), observeNode)) {
return true;
}
}
}
}
return false;
}
/**
* 功能描述:將查出來的lastId不爲null的回覆都添加到第一層Node集合中
*
* @param firstList 第一層評論集合(鏈表)
* @param thenList 非第一層評論集合(鏈表)
* @return 所有評論集合(非第一層評論集合對應添加到第一層評論集合,返回)
*/
private List<ObserveNodeBO> addAllNode ( List<ObserveNodeBO> firstList, List<ObserveNodeBO> thenList ) {
while (thenList.size() != 0) {
int size = thenList.size();
for (int i = 0; i < size; i++) {
if (addNode(firstList, new ObserveNodeBO(thenList.get(i)))) {
thenList.remove(i);
i--;
size--;
}
}
}
return firstList;
}
/**
* 功能描述:打印評論的鏈表回覆信息
*
* @param list 評論信息(鏈表集合)
*/
private void show ( List<ObserveNodeBO> list ) {
for (ObserveNodeBO node : list) {
System.out.println(node.getObserverId() + " 用戶回覆了" + node.getLastId() + ":" + node.getObserveContent());
//遞歸打印回覆信息
if (node.getNextNodes().size() != 0) {
show(node.getNextNodes());
}
}
}
}
評論的接口
@RestController
@RequestMapping("/api/observe")
public class ObserveController {
@Autowired
private ObserveService observeService;
/**
* 功能描述:根據博客id,查詢此博客的所有評論信息(鏈表類型的數據)
* @param blogId 博客id
* @return 博客的評論信息
*/
@GetMapping("/{blogId}")
public ResponseEntity<List<ObserveNodeBO>> queryObserveByBlogId (
@ApiParam(name = "blogId", value = "博客id", required = true) @PathVariable Long blogId
) {
return ResponseEntity.ok(observeService.queryObserveByBlogId(blogId));
}
/**
* 功能描述:根據評論id查詢用戶信息(評論信息,攜帶用戶信息)
* @param observeId 評論id
* @return 評論信息,攜帶用戶信息
*/
@GetMapping("/user/{observeId}")
public ResponseEntity<ObserveUserBo> queryObserveUserById (
@ApiParam(name = "observeId", value = "評論id", required = true)@PathVariable Long observeId
) {
return ResponseEntity.ok(observeService.queryObserveUserById(observeId));
}
}
查詢評論的實例
數據庫數據
博客id爲29的所有評論信息
查詢後回顯的數據
[
{
"id": 27,
"blogId": 29,
"observerId": 63,
"observeContent": "你好",
"lastId": null,
"delete": false,
"createDate": "2020-05-13 12:01:24",
"updateDate": "2020-05-13 12:01:24",
"user": {
"id": 63,
"nickname": "焦前進",
"email": "[email protected]",
"picture": "https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg",
"identity": 0,
"delete": false,
"createDate": "2020-05-13 12:01:24",
"updateDate": "2020-05-13 12:01:24",
"account": null,
"password": null
},
"nextNodes": []
},
{
"id": 28,
"blogId": 29,
"observerId": 64,
"observeContent": "你也好啊",
"lastId": null,
"delete": false,
"createDate": "2020-05-13 12:19:16",
"updateDate": "2020-05-13 12:19:16",
"user": {
"id": 64,
"nickname": "末",
"email": "[email protected]",
"picture": "https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg",
"identity": 0,
"delete": false,
"createDate": "2020-05-13 12:19:16",
"updateDate": "2020-05-13 12:19:16",
"account": null,
"password": null
},
"nextNodes": [
{
"id": 29,
"blogId": 29,
"observerId": 65,
"observeContent": "大家好",
"lastId": 28,
"delete": false,
"createDate": "2020-05-13 12:20:25",
"updateDate": "2020-05-13 12:20:25",
"user": {
"id": 65,
"nickname": "小黑",
"email": "[email protected]",
"picture": "https://fuss10.elemecdn.com/8/27/f01c15bb73e1ef3793e64e6b7bbccjpeg.jpeg",
"identity": 0,
"delete": false,
"createDate": "2020-05-13 12:20:25",
"updateDate": "2020-05-13 12:20:25",
"account": null,
"password": null
},
"nextNodes": [
{
"id": 30,
"blogId": 29,
"observerId": 66,
"observeContent": "我喜歡小黑",
"lastId": 29,
"delete": false,
"createDate": "2020-05-13 12:21:39",
"updateDate": "2020-05-13 12:21:39",
"user": {
"id": 66,
"nickname": "小白",
"email": "[email protected]",
"picture": "https://fuss10.elemecdn.com/1/8e/aeffeb4de74e2fde4bd74fc7b4486jpeg.jpeg",
"identity": 0,
"delete": false,
"createDate": "2020-05-13 12:21:39",
"updateDate": "2020-05-13 12:21:39",
"account": null,
"password": null
},
"nextNodes": [
{
"id": 31,
"blogId": 29,
"observerId": 67,
"observeContent": "ddd",
"lastId": 30,
"delete": false,
"createDate": "2020-05-13 12:23:54",
"updateDate": "2020-05-13 12:23:54",
"user": {
"id": 67,
"nickname": "小黃",
"email": "[email protected]",
"picture": "https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg",
"identity": 0,
"delete": false,
"createDate": "2020-05-13 12:23:54",
"updateDate": "2020-05-13 12:23:54",
"account": null,
"password": null
},
"nextNodes": []
}
]
}
]
}
]
}
]
Element UI樹形控件實現評論信息的回顯
這裏使用的普通H5項目引入Element UI,如果是vue-cli項目更爲方便,原理都是相通的。
引入依賴的目錄結構(當然也可以使用CDN的方式引入)
關於Element UI引入,資源已經上傳,可在文章頂部下載。
HTML(只保留關鍵部分)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>博客詳情</title>
<link rel="stylesheet" href="../plugins/element/element-ui-index.css"/>
<link rel="stylesheet" href="../css/blog-detail.css"/>
<script src="../plugins/jquery/jquery-3.5.0.min.js"></script>
<script src="../plugins/jquery/jquery.cookie-1.4.1.min.js"></script>
<script src="../plugins/vue/vue-2.6.11.js"></script>
<script src="../plugins/element/element-ui-index.js"></script>
<script src="../js/blog-detail.js"></script>
</head>
<div class="content">
<!--正文-->
<section>
<div id="blog-detail">
<!-- 展示已有評論 -->
<div class="look-observe">
<el-tree
ref="tree"
:data="observes"
:props="defaultProps"
>
<div class="custom-tree-node" slot-scope="{ node, data }">
<div class="observe-top">
<img :src="data.user.picture" alt="頭像" class="observe-header-img">
<span class="observe-nickname">{{data.user.nickname}}</span>
<span class="observe-user" v-text="data.lastId==null?'回覆了博主':'回覆了你'"></span>
<el-tag class="observe-tag" @click="getLastId(data)" round size="mini">回覆</el-tag>
<span class="observe-date">{{data.createDate}}</span>
</div>
<p class="observe-content">{{data.observeContent}}</p>
</div>
</el-tree>
</div>
</div>
</div>
</section>
</div>
CSS
/*評論顯示*/
.look-observe {
padding: 2rem 2rem;
background-color: #ffffff;
}
#observe-title {
text-align: center;
font-weight: bold;
}
#my-observe-user {
font-size: 1.6rem;
font-weight: bold;
margin: 1rem 0;
}
#clear-observe-tag {
width: 10rem;
height: 3rem;
padding: 0.5rem;
cursor: pointer;
text-align: center;
margin-left: 85%;
}
#my-observe-user-content {
font-size: 1.4rem;
}
.custom-tree-node {
width: 100%;
}
.el-tree-node__content {
height: 8rem;
padding: 1rem 2rem;
border-radius: 1rem;
border: 0.1rem solid #eee;
}
.observe-content {
font-size: 1.4rem;
margin: 1rem 0;
}
.observe-top {
line-height: 3rem;
height: 3rem;
}
.observe-top span {
display: inline-block;
vertical-align: middle;
}
.observe-header-img {
display: inline-block;
vertical-align: middle;
height: 3rem;
width: 3rem;
border-radius: 3rem;
}
.observe-nickname {
font-weight: bold;
font-size: 1.5rem;
margin: 0 1rem;
}
.observe-user {
margin: 0 1rem;
font-size: 1.2rem;
}
.observe-date {
float: right;
margin-right: 2rem;
}
.observe-tag {
float: right;
}
JS
$(document).ready(function () {
//側邊欄的vue
let blogDetail = new Vue({
el: '#blog-detail',
data: {
blogId: '', //博客id
lastId: '', //評論的lastId
observes: [], //已經評論的集合
defaultProps: {
children: 'nextNodes',
label: 'observeContent',
id: 'id',
},
},
methods: {
//查詢此篇博客的所有評論信息
getBlogObserve() {
$.get(baseUrl + "/api/observe/" + this.blogId,
function (data, status, xhr) {
blogDetail.observes = data;
}, "json").fail(function (error) {
blogDetail.$message({
showClose: true,
message: "查詢博客評論失敗" + error.responseJSON.message,
type: 'error'
});
});
},
//進入博客詳情頁,博客的瀏覽量+1
increaseViewCount() {
if ($.cookie("viewId") !== this.blogId) {
$.ajax({
url: baseUrl + "/api/blog/increase-view-number/" + this.blogId,
dataType: "text",
type: "put",
success: function (data, status, xhr) {
//設置過期時間爲1h
let date = new Date();
date.setTime(date.getTime() + 60 * 60 * 60 * 1000);
$.cookie("viewId", blogDetail.blogId, {"path": "/",}, {expires: date});
},
error: function (error) {
blogDetail.$message({
showClose: true,
message: error.responseJSON.message,
type: 'error'
});
}
});
}
},
//點擊點贊,增加博客的點贊量
increaseLikeCount() {
if ($.cookie("likeId") !== this.blogId) {
$.ajax({
url: baseUrl + "/api/blog/increase-like-number/" + this.blogId,
dataType: "text",
type: "put",
success: function (data, status, xhr) {
//設置過期時間爲1h
let date = new Date();
date.setTime(date.getTime() + 60 * 60 * 60 * 1000);
$.cookie("likeId", blogDetail.blogId, {"path": "/",}, {expires: date});
},
error: function (error) { //請求失敗後的回調方法
blogDetail.$message({
showClose: true,
message: error.responseJSON.message,
type: 'error'
});
}
});
}else{
blogDetail.$message({
showClose: true,
message: "您已經點過贊啦!",
type: 'success'
});
}
},
},
mounted() {
this.getBlogObserve();
},
created() {
//獲取博客的id,利用的是導航欄拼接,這裏可忽略
this.blogId = window.location.search.substr(1);
}
});
});
博客的訪問量和點贊量是利用cookie存儲對應的博客id來實現的,如果有次cookie則不能增加瀏覽量和點贊量,當然也還存在很多的bug,這個訪問量和點贊量只能在一定程度上解決問題,並不完善(瀏覽和點讚的後端實現暫略,不是重點)。
關鍵
利用Element UI實現評論回顯的關鍵代碼如下:
<el-tree
ref="tree"
:data="observes"
:props="defaultProps"
>
<div class="custom-tree-node" slot-scope="{ node, data }">
<div class="observe-top">
<img :src="data.user.picture" alt="頭像" class="observe-header-img">
<span class="observe-nickname">{{data.user.nickname}}</span>
<span class="observe-user" v-text="data.lastId==null?'回覆了博主':'回覆了你'"></span>
<el-tag class="observe-tag" @click="getLastId(data)" round size="mini">回覆</el-tag>
<span class="observe-date">{{data.createDate}}</span>
</div>
<p class="observe-content">{{data.observeContent}}</p>
</div>
</el-tree>
:data="observes"
是評論信息集合,及需要展示在屬性控件的數據:props="defaultProps"
主要用於配置每個節點的選項slot-scope
是樹形控件的插槽
詳細的樹形控件API可參看官網:https://element.eleme.cn/#/zh-CN/component/tree
回顯數據實例展示
多級評論回覆層級結構非常鮮明