我是如何做评论模块的?


需求

开发一个模块,首先需要明确需求。

在笔者的个人网站里,需要添加一个评论模块,它主要用于文章评论、网站留言等评论功能。除此之外,我还希望这个模块具有以下几个特性:

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。

这个字段也经常查询,所以数据库最好是也在这个字段建立一个索引。


Restful接口

使用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

先看看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实体里的抉择,即:要不要通过反范式设计来提高性能?。本文不涉及用户登录,所以不存在这个问题。

目前管理端对评论的控制只是简单的增删改查,设计上可以更灵活点。。。

点击这里查看效果

点赞