开发一个模块,首先需要明确需求。
在笔者的个人网站里,需要添加一个评论模块,它主要用于文章评论、网站留言等评论功能。除此之外,我还希望这个模块具有以下几个特性:
1 较强的扩展性
所谓扩展性,就是以后如果有其它模块(如微博模块、相册模块等)需要评论系统时,评论系统的代码和基本接口不用修改。
2 接口易用性
我希望对前端提供的接口是易用的。具体表现为:
- 分页与排序灵活
- 一次请求获取父评论及其子评论,前端不用发多个请求
3 安全性
评论模块需要有后台管理功能。管理员可以增删改查,这些接口需要保证安全性。
这里先使用Java创建实体,再由Hibernate
框架生成数据表。Java代码如下:
/**
* 评论
*/
@Entity
@Data
@ApiModel(description = "评论")
public class Comment extends _BaseEntity {
@ApiModelProperty("评论内容")
private String content;
@ApiModelProperty("评论人真实姓名")
private String realName;
@Column(nullable = false)
@ApiModelProperty("评论人昵称")
private String nickName;
@ApiModelProperty("联系方式")
private String connect;
@ApiModelProperty("个人网站")
private String site;
@Column
@ApiModelProperty("回复对象的昵称")
private String replyNickName;
@ApiModelProperty("是否是管理员回复")
private boolean replyAdmin;
@ApiModelProperty(value = "评论的类型",
notes = "article,talk,message等")
private String type;
@ApiModelProperty(value = "相关id",
notes = "分别对应article.id, talk.id,若为0表示为message")
private Long relationId;
@ApiModelProperty("父评论id,若为0则表示没有父评论")
private Long pid;
@Transient
private List<Comment> children = new LinkedList<>();
}
1 关于_BaseEntity
这个类是我的所有实体都会继承的类,里面有一些公共字段。具体代码:
@Data
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class _BaseEntity implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
protected Long id;
@CreatedDate
@ApiModelProperty("创建时间")
protected Date addTime;
@LastModifiedDate
@ApiModelProperty("最后更新时间")
protected Date updateTime;
/**
* -2 : 已删除
* -1 : 冻结中
* 0 : 待审核
* 1 : 正常
* null: 未使用这个字段
* 其它 : 自定义
*/
@ApiModelProperty("状态")
protected Integer status;
}
2 关于父子评论
这里用字段pid
代表父评论的id,用非持久化属性children
代表子评论列表。
在新建评论时,如果有父评论,前端传入的实体数据里包含了父评论的id。
在查询时,去数据库查询出子评论列表,放入children
里。
3 type为什么是String类型的
从性能上来讲,当然是数字类型的更高。但不方便扩展,对前端也不太友好。但这个字段确实是查询经常会用到的字段。这里可以添加索引。如果对性能有较高的要求,这里可以设计成数字类型存放在数据库。
4 relationId
这个字段意为相关实体id,比如要查id为5的文章下的评论,那就是type为article, ralationId为5。
如果是留言板,这里的type为message
,relationId为0。
这个字段也经常查询,所以数据库最好是也在这个字段建立一个索引。
使用SpringMVC实现Restful接口,同时使用了Swagger2做接口文档,用SpringSecurity做权限控制。具体代码:
@RestController
@RequestMapping("comment")
@Api(description = "评论接口")
public class CommentController {
@Autowired
CommentService commentService;
@ApiOperation(value = "分页获取评论")
@GetMapping
@PublicAPI
public Page<Comment> getPage(
@RequestParam String type,
@RequestParam Long relationId,
@PageableDefault(sort = "addTime",
direction = Sort.Direction.DESC,
size = 10) Pageable pageable) {
return commentService.getPage(type, relationId, pageable);
}
@ApiOperation(value = "添加一个评论")
@PostMapping
@PublicAPI
@PreAuthorize("#common.id == null")
public Long save(@RequestBody @Validated Comment comment) {
return commentService.saveByPortal(comment);
}
@ApiOperation(value = "管理员添加或更新一个评论",
notes = "如果有id则为更新, 如果无id则为添加")
@PostMapping("manage")
public Long saveByAdmin(@RequestBody @Validated Comment comment) {
return commentService.saveByAdmin(comment);
}
@ApiOperation("根据id删除评论")
@DeleteMapping("{id:\\d+}")
public void deleteById(@PathVariable Long id) {
deleteById(id);
}
@ApiOperation("得到还未回复的评论列表")
@GetMapping("noReply")
public List<Comment> getNoReplyList(@RequestParam String type) {
return commentService.getNoReplyList(type);
}
}
关于权限
这里简单介绍一下,@PublicAPI
是自定义的注解,使用这个注解的方法对应的接口,可以被匿名用户访问。
@PreAuthorize
这个注解是SpringSecurity内置的基于方法级别的注解。
其它接口是灵活的基于RABC的权限控制系统。
与权限相关的具体信息留到以后介绍权限模块的时候再详细解释。
关于分页
这里使用了Spring Data JPA内置的分页功能。主要用到的类就是Pageable
。
先看看Service层的接口:
public interface CommentService {
Page<Comment> getPage(String type, Long relationId, Pageable pageable);
Long saveByPortal(Comment comment);
Long saveByAdmin(Comment comment);
void deleteById(Long id);
// 得到未回复的评论
List<Comment> getNoReplyList(String type);
}
也就是基本的增删改查。需要注意的是门户保存和管理端保存是不一样的。
实现类代码:
@Service
@Transactional
public class CommentServiceImpl implements CommentService {
@Autowired
CommentDao commentDao;
@Autowired
RedisTemplate redisTemplate;
@Override
public Page<Comment> getPage(String type, Long relationId, Pageable pageable) {
Page<Comment> page = commentDao.findAllByTypeAndRelationIdAndPid(type, relationId, 0L, pageable);
page.forEach( comment -> {
comment.setChildren(commentDao.findAllByPid(comment.getId()));
});
return page;
}
@Override
public Long saveByPortal(Comment comment) {
comment.setReplyAdmin(false);
commentDao.save(comment);
redisTemplate.opsForList().leftPush(CommentConst.COMMENT_LIST_KEY, comment);
return comment.getId();
}
@Override
public Long saveByAdmin(Comment comment) {
comment.setReplyAdmin(true);
commentDao.save(comment);
if (comment.getPid() != null) {
removeNoReply(comment);
}
return comment.getId();
}
@Override
public void deleteById(Long id) {
commentDao.deleteById(id);
}
@Override
public List<Comment> getNoReplyList(String type) {
ListOperations<String, Comment> listOperations = redisTemplate.opsForList();
List<Comment> comments = listOperations.range(CommentConst.COMMENT_LIST_KEY,
0, listOperations.size(CommentConst.COMMENT_LIST_KEY));
if (!Strings.isNullOrEmpty(type.trim()))
comments = comments.stream().filter( x -> x.getType().equals(type)).collect(Collectors.toList());
return comments;
}
private void removeNoReply(Comment comment) {
redisTemplate.opsForList().remove(CommentConst.COMMENT_LIST_KEY, 1, comment);
}
}
这里使用了Redis来存放未回复评论,使用的工具是Spring生态的RedisTemplate
。
如果对性能有要求,还可使用Redis
作为查询缓存。
仓储层的代码比较简单,使用Spring Data JPA,自定义了两个接口:
public interface CommentDao extends JpaRepository<Comment, Long> {
Page<Comment> findAllByTypeAndRelationIdAndPid(String type, Long relationId, Long pid, Pageable pageable);
List<Comment> findAllByPid(Long pid);
}
前端大概就是一些简单的数据交互与展示。
需要注意的是本系统设计的评论模块的匿名的,所以用户信息可以使用localStoreage
来记录。
由于笔者的前端是使用Vue做的,基于代码复用的原则,可以单独写一个评论组件。具体设计和代码就不展开了,有兴趣的读者可以查看源代码
PS: UI框架是Vuetify。
针对不同的应用场景,读者可以根据此设计扩展和优化。比如上文提到的性能优化。
如果需要用户登录才能评论,可以使用头像,这样前端会更好看一点。但这样就会涉及到评论昵称等信息,要不要存放在Comment
实体里的抉择,即:要不要通过反范式设计来提高性能?。本文不涉及用户登录,所以不存在这个问题。
目前管理端对评论的控制只是简单的增删改查,设计上可以更灵活点。。。