前段时间设计了系统的评论模块,并写了篇文章 评论模块 - 后端数据库设计及功能实现 讲解。
大佬们在评论区提出了些优化建议,总结一下:
- 之前评论一共分了两张表,一个评论主表,一个回复表。这两张表的字段区别不大,在主表上加个 pid 字段就可以不用回复表合成一张表了。
- 评论表中存了用户头像,会引发一些问题。比如用户换头像时要把评论也一起更新不太合适,还可能出现两条评论头像不一致的情况。
的确数据库设计的有问题,感谢 wangbjun 和 JWang。
下面就对评论模块进行优化改造,首先更改表结构,合成一张表。评论表不存用户头像的话,需要从用户服务获取。用户服务提供获取头像的接口,两个服务间通过 Feign 通信。
这样有个问题,如果一个资源的评论比较多,每个评论都调用用户服务查询头像还是有点慢,所以对评论查询加个 Redis 缓存。要是有新的评论,就把这个资源缓存的评论删除,下次请求时重新读数据库并将最新的数据缓存到 Redis 中。
代码出自开源项目
coderiver
,致力于打造全平台型全栈精品开源项目。 项目地址:https://github.com/cachecats/coderiver
本文将分四部分介绍
- 数据库改造
- 用户服务提供获取头像接口
- 评论服务用 Feign 访问用户服务取头像
- 使用 Redis 缓存数据
一、数据库改造
数据库表重新设计如下
代码语言:javascript复制CREATE TABLE `comments_info` (
`id` varchar(32) NOT NULL COMMENT '评论主键id',
`pid` varchar(32) DEFAULT '' COMMENT '父评论id',
`owner_id` varchar(32) NOT NULL COMMENT '被评论的资源id,可以是人、项目、资源',
`type` tinyint(1) NOT NULL COMMENT '评论类型:对人评论,对项目评论,对资源评论',
`from_id` varchar(32) NOT NULL COMMENT '评论者id',
`from_name` varchar(32) NOT NULL COMMENT '评论者名字',
`to_id` varchar(32) DEFAULT '' COMMENT '被评论者id',
`to_name` varchar(32) DEFAULT '' COMMENT '被评论者名字',
`like_num` int(11) DEFAULT '0' COMMENT '点赞的数量',
`content` varchar(512) DEFAULT NULL COMMENT '评论内容',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `owner_id` (`owner_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论表';
相比之前添加了父评论id pid
,去掉了用户头像。owner_id
是被评论的资源id,比如一个项目下的所有评论的 owner_id
都是一样的,便于根据资源 id 查找该资源下的所有评论。
与数据表对应的实体类 CommentsInfo
package com.solo.coderiver.comments.dataobject;
import lombok.Data;
import org.hibernate.annotations.DynamicUpdate;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.io.Serializable;
import java.util.Date;
/**
评论表主表
*/
@Entity
@Data
@DynamicUpdate
public class CommentsInfo implements Serializable{
<span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">long</span> serialVersionUID = -<span class="hljs-number">4568928073579442976L</span>;
<span class="hljs-comment">//评论主键id</span>
<span class="hljs-meta">@Id</span>
<span class="hljs-keyword">private</span> String id;
<span class="hljs-comment">//该条评论的父评论id</span>
<span class="hljs-keyword">private</span> String pid;
<span class="hljs-comment">//评论的资源id。标记这条评论是属于哪个资源的。资源可以是人、项目、设计资源</span>
<span class="hljs-keyword">private</span> String ownerId;
<span class="hljs-comment">//评论类型。1用户评论,2项目评论,3资源评论</span>
<span class="hljs-keyword">private</span> Integer type;
<span class="hljs-comment">//评论者id</span>
<span class="hljs-keyword">private</span> String fromId;
<span class="hljs-comment">//评论者名字</span>
<span class="hljs-keyword">private</span> String fromName;
<span class="hljs-comment">//被评论者id</span>
<span class="hljs-keyword">private</span> String toId;
<span class="hljs-comment">//被评论者名字</span>
<span class="hljs-keyword">private</span> String toName;
<span class="hljs-comment">//获得点赞的数量</span>
<span class="hljs-keyword">private</span> Integer likeNum;
<span class="hljs-comment">//评论内容</span>
<span class="hljs-keyword">private</span> String content;
<span class="hljs-comment">//创建时间</span>
<span class="hljs-keyword">private</span> Date createTime;
<span class="hljs-comment">//更新时间</span>
<span class="hljs-keyword">private</span> Date updateTime;
}
数据传输对象 CommentsInfoDTO
在 DTO 对象中添加了用户头像,和子评论列表 children
,因为返给前端要有层级嵌套。
package com.solo.coderiver.comments.dto;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
@Data
public class CommentsInfoDTO implements Serializable {
<span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">long</span> serialVersionUID = -<span class="hljs-number">6788130126931979110L</span>;
<span class="hljs-comment">//评论主键id</span>
<span class="hljs-keyword">private</span> String id;
<span class="hljs-comment">//该条评论的父评论id</span>
<span class="hljs-keyword">private</span> String pid;
<span class="hljs-comment">//评论的资源id。标记这条评论是属于哪个资源的。资源可以是人、项目、设计资源</span>
<span class="hljs-keyword">private</span> String ownerId;
<span class="hljs-comment">//评论类型。1用户评论,2项目评论,3资源评论</span>
<span class="hljs-keyword">private</span> Integer type;
<span class="hljs-comment">//评论者id</span>
<span class="hljs-keyword">private</span> String fromId;
<span class="hljs-comment">//评论者名字</span>
<span class="hljs-keyword">private</span> String fromName;
<span class="hljs-comment">//评论者头像</span>
<span class="hljs-keyword">private</span> String fromAvatar;
<span class="hljs-comment">//被评论者id</span>
<span class="hljs-keyword">private</span> String toId;
<span class="hljs-comment">//被评论者名字</span>
<span class="hljs-keyword">private</span> String toName;
<span class="hljs-comment">//被评论者头像</span>
<span class="hljs-keyword">private</span> String toAvatar;
<span class="hljs-comment">//获得点赞的数量</span>
<span class="hljs-keyword">private</span> Integer likeNum;
<span class="hljs-comment">//评论内容</span>
<span class="hljs-keyword">private</span> String content;
<span class="hljs-comment">//创建时间</span>
<span class="hljs-keyword">private</span> Date createTime;
<span class="hljs-comment">//更新时间</span>
<span class="hljs-keyword">private</span> Date updateTime;
<span class="hljs-keyword">private</span> List<CommentsInfoDTO> children;
}
二、用户服务提供获取头像接口
为了方便理解先看一下项目的结构,本项目中所有的服务都是这种结构
每个服务都分为三个 Module,分别是 client
, common
, server
。
client
:为其他服务提供数据,Feign 的接口就写在这层。common
:放client
和server
公用的代码,比如公用的对象、工具类。server
: 主要的逻辑代码。
在 client
的 pom.xml
中引入 Feign 的依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
用户服务 user
需要对外暴露获取用户头像的接口,以使评论服务通过 Feign 调用。
在 user_service
项目的 server
下新建 ClientController
, 提供获取头像的接口。
package com.solo.coderiver.user.controller;
import com.solo.coderiver.user.common.UserInfoForComments;
import com.solo.coderiver.user.dataobject.UserInfo;
import com.solo.coderiver.user.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
对其他服务提供数据的 controller
*/
@RestController
@Slf4j
public class ClientController {
<span class="hljs-meta">@Autowired</span>
UserService userService;
<span class="hljs-comment">/**
* 通过 userId 获取用户头像
*
* <span class="hljs-doctag">@param</span> userId
* <span class="hljs-doctag">@return</span>
*/</span>
<span class="hljs-meta">@GetMapping</span>(<span class="hljs-string">"/get-avatar"</span>)
<span class="hljs-function"><span class="hljs-keyword">public</span> UserInfoForComments <span class="hljs-title">getAvatarByUserId</span><span class="hljs-params">(@RequestParam(<span class="hljs-string">"userId"</span>)</span> String userId) </span>{
UserInfo info = userService.findById(userId);
<span class="hljs-keyword">if</span> (info == <span class="hljs-keyword">null</span>){
<span class="hljs-keyword">return</span> <span class="hljs-keyword">null</span>;
}
<span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> UserInfoForComments(info.getId(), info.getAvatar());
}
}
然后在 client
定义 UserClient
接口
package com.solo.coderiver.user.client;
import com.solo.coderiver.user.common.UserInfoForComments;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "user")
public interface UserClient {
@GetMapping("/user/get-avatar")
UserInfoForComments getAvatarByUserId(@RequestParam("userId") String userId);
}
三、评论服务用 Feign 访问用户服务取头像
在评论服务的 server
层的 pom.xml
里添加 Feign 依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
并在入口类添加注解 @EnableFeignClients(basePackages = "com.solo.coderiver.user.client")
注意到配置扫描包的全类名
package com.solo.coderiver.comments;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
@SpringBootApplication
@EnableDiscoveryClient
@EnableSwagger2
@EnableFeignClients(basePackages = "com.solo.coderiver.user.client")
@EnableCaching
public class CommentsApplication {
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">void</span> <span class="hljs-title">main</span><span class="hljs-params">(String[] args)</span> </span>{
SpringApplication.run(CommentsApplication.class, args);
}
}
封装 CommentsInfoService
,提供保存评论和获取评论的接口
package com.solo.coderiver.comments.service;
import com.solo.coderiver.comments.dto.CommentsInfoDTO;
import java.util.List;
public interface CommentsInfoService {
/**
* 保存评论
*
* @param info
* @return
*/
CommentsInfoDTO save(CommentsInfoDTO info);
/**
* 根据被评论的资源id查询评论列表
*
* @param ownerId
* @return
*/
List<CommentsInfoDTO> findByOwnerId(String ownerId);
}
CommentsInfoService
的实现类
package com.solo.coderiver.comments.service.impl;
import com.solo.coderiver.comments.converter.CommentsConverter;
import com.solo.coderiver.comments.dataobject.CommentsInfo;
import com.solo.coderiver.comments.dto.CommentsInfoDTO;
import com.solo.coderiver.comments.repository.CommentsInfoRepository;
import com.solo.coderiver.comments.service.CommentsInfoService;
import com.solo.coderiver.user.client.UserClient;
import com.solo.coderiver.user.common.UserInfoForComments;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Slf4j
public class CommentsInfoServiceImpl implements CommentsInfoService {
@Autowired
CommentsInfoRepository repository;
@Autowired
UserClient userClient;
@Override
@CacheEvict(cacheNames = "comments", key = "#dto.ownerId")
public CommentsInfoDTO save(CommentsInfoDTO dto) {
CommentsInfo result = repository.save(CommentsConverter.DTO2Info(dto));
return CommentsConverter.info2DTO(result);
}
@Override
@Cacheable(cacheNames = "comments", key = "#ownerId")
public List<CommentsInfoDTO> findByOwnerId(String ownerId) {
List<CommentsInfo> infoList = repository.findByOwnerId(ownerId);
List<CommentsInfoDTO> list = CommentsConverter.infos2DTOList(infoList)
.stream()
.map(dto -> {
//从用户服务取评论者头像
UserInfoForComments fromUser = userClient.getAvatarByUserId(dto.getFromId());
if (fromUser != null) {
dto.setFromAvatar(fromUser.getAvatar());
}
//从用户服务取被评论者头像
String toId = dto.getToId();
if (!StringUtils.isEmpty(toId)) {
UserInfoForComments toUser = userClient.getAvatarByUserId(toId);
if (toUser != null) {
dto.setToAvatar(toUser.getAvatar());
}
}
return dto;
}).collect(Collectors.toList());
return sortData(list);
}
/**
* 将无序的数据整理成有层级关系的数据
*
* @param dtos
* @return
*/
private List<CommentsInfoDTO> sortData(List<CommentsInfoDTO> dtos) {
List<CommentsInfoDTO> list = new ArrayList<>();
for (int i = 0; i < dtos.size(); i ) {
CommentsInfoDTO dto1 = dtos.get(i);
List<CommentsInfoDTO> children = new ArrayList<>();
for (int j = 0; j < dtos.size(); j ) {
CommentsInfoDTO dto2 = dtos.get(j);
if (dto2.getPid() == null) {
continue;
}
if (dto1.getId().equals(dto2.getPid())) {
children.add(dto2);
}
}
dto1.setChildren(children);
//最外层的数据只添加 pid 为空的评论,其他评论在父评论的 children 下
if (dto1.getPid() == null || StringUtils.isEmpty(dto1.getPid())) {
list.add(dto1);
}
}
return list;
}
}
从数据库取出来的评论是无序的,为了方便前端展示,需要对评论按层级排序,子评论在父评论的 children
字段中。
返回的数据:
代码语言:javascript复制{
"code": 0,
"msg": "success",
"data": [
{
"id": "1542338175424142145",
"pid": null,
"ownerId": "1541062468073593543",
"type": 1,
"fromId": "555555",
"fromName": "张扬",
"fromAvatar": null,
"toId": null,
"toName": null,
"toAvatar": null,
"likeNum": 0,
"content": "你好呀",
"createTime": "2018-11-16T03:16:15.000 0000",
"updateTime": "2018-11-16T03:16:15.000 0000",
"children": []
},
{
"id": "1542338522933315867",
"pid": null,
"ownerId": "1541062468073593543",
"type": 1,
"fromId": "555555",
"fromName": "张扬",
"fromAvatar": null,
"toId": null,
"toName": null,
"toAvatar": null,
"likeNum": 0,
"content": "你好呀嘿嘿",
"createTime": "2018-11-16T03:22:03.000 0000",
"updateTime": "2018-11-16T03:22:03.000 0000",
"children": []
},
{
"id": "abc123",
"pid": null,
"ownerId": "1541062468073593543",
"type": 1,
"fromId": "333333",
"fromName": "王五",
"fromAvatar": "http://avatar.png",
"toId": null,
"toName": null,
"toAvatar": null,
"likeNum": 3,
"content": "这个小伙子不错",
"createTime": "2018-11-15T06:06:10.000 0000",
"updateTime": "2018-11-15T06:06:10.000 0000",
"children": [
{
"id": "abc456",
"pid": "abc123",
"ownerId": "1541062468073593543",
"type": 1,
"fromId": "222222",
"fromName": "李四",
"fromAvatar": "http://222.png",
"toId": "abc123",
"toName": "王五",
"toAvatar": null,
"likeNum": 2,
"content": "这个小伙子不错啊啊啊啊啊",
"createTime": "2018-11-15T06:08:18.000 0000",
"updateTime": "2018-11-15T06:36:47.000 0000",
"children": []
}
]
}
]
}
四、使用 Redis 缓存数据
其实缓存已经在上面的代码中做过了,两个方法上的
代码语言:javascript复制@Cacheable(cacheNames = "comments", key = "#ownerId")
@CacheEvict(cacheNames = "comments", key = "#dto.ownerId")
两个注解就搞定了。第一次请求接口会走方法体
关于 Redis 的使用方法,我专门写了篇文章介绍,就不在这里多说了,需要的可以看看这篇文章:
Redis详解 - SpringBoot整合Redis,RedisTemplate和注解两种方式的使用
以上就是对评论模块的优化,欢迎大佬们提优化建议~
项目地址:https://github.com/cachecats/coderiver