使用SpringBoot和Redis构建个人博客(三)
上两篇文章讲了Redis sentinel环境的搭建和SpringBoot+redis环境搭建,本章进入业务代码的开发。
Redis数据结构的设计
首先介绍一下这个系统中用到的Redis的数据结构和命令,应该说熟悉redis的数据结构是用好它的最基本要求。希望看完了以后,大家以后用redis就别再只用字符串了,那样等于放弃了一片森林。
- Hash,类似于java中的HashMap, 可以使用一个key存储多个属性,类似于关系型数据库的一行。比如一篇博客,包含标题、内容、作者、发布时间等。可以用博客id为key, value使用hash存储属性。Redis还提供了针对Hash中单个属性的操作命令,比如在用户打开文章时可以原子的对文章的阅读数属性加1;
- List,类似于java中的ArrayList,数据顺序按照插入的顺序。redis支持从List左右两端存取数据,并支持获取数据时如果List为空则阻塞等待,所以可以作为分布式消息队列使用。比如用户发布的博客,可以按照创建顺序,将ID放入List中,用于查询博客列表;
- Set,类似于Java中的HashSet,数据无序存储,方便随机获取。redis针对Set提供了很多求集合的交并差得命令;
- SortedSet,有序集合。集合中每个元素除了值之外,还存储了元素的score,所有集合中的元素按score排列。redis提供了顺序遍历,逆序遍历,根据下标范围和分数范围获取子集合等命令。比如用户的博客可以以发布时间为score,放入有序集合;
- INCR命令,针对数字类型,将value原子的加1,如果key不存在,默认新建key,并赋值为1。非常适合用来实现自增的id生成。相对应的还有DECR用于自减操作;
本系统中用到的Redis的数据结构基本就以上这些了。下面我们从最基本的操作(新建一篇博客)开始设计redis的数据存储。在这之前我们首先对redis key的格式做一个约束,所有的key使用如下格式: 模块名:属性:ID
。这样做的好处是便于查找,而且防止多人合作时不小心造成key的冲突。
新建文章
生成ID
新的博客首先需要有个ID,关系型数据库(如MySQL)一般提供了自增长id,redis可以使用INCR命令达到同样的效果。比如记录上一个博客id的key是article:nextval
,则获取一个新的ID的命令是 INCR article:nextval 1
, 这个命令的效果是如果key存在并且是数字,则加1并返回加1后的值,如果key不存在则新建一个key并设置默认值为0后加1。因为这个命令是原子操作,所以不会存在并发导致多个线程取到同一个值。key的定义如下:
key | value类型 | 使用方式 |
---|---|---|
article:nextval | string | 博客id sequence,每次加1 |
java中获取下一个博客id的代码如下,这里我们把获取id的方法封装到一个工具类里面,其它Dao直接调用即可。
@Repository
public class RedisSequenceImpl implements SequenceSupport {
@Autowired
private RedisSupport redisSupport;
@Override
public Long nextValue(String sequenceName) {
return redisSupport.incr(sequenceName, 1);
}
}
@Repository
public class ArticleDaoImpl implements ArticleDao {
private static final String ARTICLE_SEQ = "article:nextval";
@Autowired
private SequenceSupport seqSupport;
@Override
public void create(ArticleDto article) {
article.setId(seqSupport.nextValue(ARTICLE_SEQ));
。。。
}
}
存储文章内容
前面讲redis数据结构时已经讲到,关系型数据库中表的一行数据可以用redis的hash结构来存储。这里我们使用两个key来存储一篇文章,第一个key的value是hash,存文章的基本属性,作者、时间、阅读次数、标题和正文的前64个字等属性。第二个key的value是string,用来存储文章的正文,也就是正文完整的html。
为什么要这么做呢?因为使用redis的一个很重要的原则就是要减小value的大小,如果把文章正文存在hash里面,value会很大,每次修改一个很小的属性(比如阅读数加1)都会造成内存的重新分配。还有一个原因是大部分阅读博客都是先查询列表,再查看详情。正文单独存储可以在需要的时候再查询。所以redis中数据结构如下:
key | value类型 | 使用方式 |
---|---|---|
article:${id} | Hash | 存储一篇文章,hash中用属性名做key,属性值做value。${id}代表上面获取的文章id |
article:content:${id} | string | 存储文章正文详细内容,富文本 |
文章存储的问题解决了,还要解决查询的问题。查询相关需解决这样几个问题:
- 用户看博客,一种是通过搜索引擎引流过来的,这种会根据id直接查询;还有一种是先查列表,然后再进单个文章,所以我们需要SortedSet存储一个已发布博客的列表,使用发布时间作为score。对于置顶的文章,我们需要score值设置的更大,所以所有置顶文章10+发布时间作为score.
- 对于作者来说,除了已发布博客之外,还要查看已保存未发布的文章,所以需要一个单独的SortedSet来存储所有博客,使用创建时间作为score
- 在上一章讲到,在文章信息发生变更后,需要通知搜索引擎。这里我们使用redis的List来实现一个消息队列的功能。每次变化后将文章id从左边放入List,搜索引擎的服务从右边读取。
为了满足以上要求,添加如下几个结构:
key | value类型 | 使用方式 |
---|---|---|
article:ids | SortedSet | 存储已创建的博客列表,以创建时间排序 |
article:pub:ids | SortedSet | 存储已发布的博客列表,按发布时间排序 |
article:msg | List | 博客变更列表 |
以上就是文章存储的所有数据结构了,下面我们进入代码部分,从controller开始,看发布一篇完整的博客需要几个步骤。
新建博客代码逻辑
首先是controller,所有后台管理类url都用/admin开头,这样方便以后加权限控制。Controller主要就是做参数校验,然后调用service保存文章。
@RestController
@RequestMapping("/admin/article")
public class ArticleAdminController {
@PostMapping("/add")
public Response<Void> add(@RequestBody @Validated({ValidGroups.AddGroup.class,Default.class}) ArticleDto article, BindingResult bindingResult){
if(bindingResult.hasErrors())
return new Response<>(ResultCode.INVALID_PARAM, bindingResult.getAllErrors().get(0).getDefaultMessage());
Response<ArticleDto> response = articleService.create(article);
if(response.getCode() > 0)
return new Response<>(response.getCode(), response.getMessage());
return new Response<>();
}
}
再看Service的实现(请看注释):
@Service
public class ArticleServiceImpl implements ArticleService{
@Override
public Response<ArticleDto> create(ArticleDto article) {
//对提交的文章内容做过滤,去掉css和js,只保留最基本的html
article.setContent(HtmlUtils.getSafeBody(article.getContent()));
//调用Dao接口将文章数据保存进Redis
articleDao.create(article);
//如果用户是保存文章的同时发布,在调用发布的方法修改状态
if(article.getStatus().intValue() == 1)
articleDao.updatePubStatus(article);
//将文章ID放入消息队列,通知搜索引擎
messageDao.push(article.getId());
return new Response<>(article);
}
}
Service中一共调用了Dao中3个方法,保存内容->发布->发消息,看下具体实现,这里可以主要关注下Redis命令的用法:
@Repository
public class ArticleDaoImpl implements ArticleDao {
@Override
public void create(ArticleDto article) {
//获取一个新的博客ID
article.setId(seqSupport.nextValue(ARTICLE_SEQ));
//设置默认属性的值
if(article.getStatus() == null)
article.setStatus(0); //默认保存未发布
java.util.Date now = new java.util.Date();
article.setCreated(now);
article.setModified(now);
if(article.getAllowComment()==null)
article.setAllowComment(true); //是否允许留言
if(article.getAllowShare()==null)
article.setAllowShare(false); //是否允许转发
//博客Hash结构里只保存文章的前64个字用于前端列表显示
String content = article.getContent();
String header = StringUtils.left(HtmlUtils.getBodyText(content), 64);
article.setContent(header);
//使用Pipline将数据保存到Redis
SessionCallback<Void> sessionCallback = new SessionCallback<Void>() {
@Override
public <K, V> Void execute(RedisOperations<K, V> redisOperations) throws DataAccessException {
//保存文章基本属性,使用Hash的HMSET命令,BeanUtils.beanToMap这个方法是将POJO转成Map
redisOperations.opsForHash().putAll((K)("article:" + article.getId()), BeanUtils.beanToMap(article, "userLike"));
//将文章的完整HTML单独保存一个key,使用SET命令
redisOperations.opsForValue().set((K)("article:content:" + article.getId()), (V)content);
//将文章ID放入所有文章列表,使用创建时间作为score,使用SortedSet的zAdd命令
String score = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
redisOperations.opsForZSet().add((K)"article:ids", (V)article.getId(), Double.parseDouble(score));
return null;
}
};
redisSupport.executePipelined(sessionCallback);
log.debug("Save activle article:{} success", JSON.toJSONString(article));
}
/**
* 将文章状态更新成已发布/未发布
*/
@Override
public void updatePubStatus(ArticleDto article){
Assert.notNull(article.getId(), "id must not be null");
Assert.notNull(article.getStatus(), "status must not be null");
//使用Hash的HMSET命令更新status和发布时间(如果是发布文章)
ArticleDto newDto = new ArticleDto();
java.util.Date now = Calendar.getInstance().getTime();
newDto.setStatus(article.getStatus());
if(article.getStatus() == 1)
newDto.setIssueTime(now);
newDto.setModified(now);
redisSupport.hmset("article:"+article.getId(), BeanUtils.beanToMap(newDto));
//如果是发布文章,先判断是不是置顶,如果是置顶,则score在原score的基础上前面加10。
//然后将文章id加到已发布集合中
//如果是撤回发布,直接使用ZREM命令将id移除
if(article.getStatus()==1) {
String score = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
if(BooleanUtils.isTrue(article.getIstop()))
score = "10"+score;
redisSupport.zAdd("article:pub:ids", article.getId(), Double.parseDouble(score));
}else if(article.getStatus()==0) {
redisSupport.zRem("article:pub:ids", article.getId());
}
}
}
@Repository
public class ArticleMessageDaoImpl implements ArticleMessageDao {
//发布消息,直接将ID从List左边推入,使用LPUSH命令
@Override
public void push(Long articleId) {
redisSupport.lPush(article_msg_queue, articleId);
}
}
到这里,一个新的博客就发布完成了,这里面除了redis命令的使用外,在create方法中还用到pipeline来提交命令。使用pipeline在一次执行多条命令时可以显著减少命令执行时间。原因是在使用pipeline时,客户端会将多条命令打包一次性提交给redis,大大减少了网络往返的时间。
从新建博客的过程中可以发现,Redis和关系型数据库最大的区别有两个,一个是列表和属性要分成2个key来做存储,再就是因为查询的时候不支持过滤,所以如果需要条件查询,需要提前将列表准备好,如上面的所有文章和已发布文章需要分开来存储。
所以对于博客这种数据结构简单,查询也不复杂的业务使用redis是完全没有问题的。如果需要多个条件的组合查询,关系型数据的优势更明显,但是redis也不是不能实现,只是会加大复杂度,需要开发人员对redis非常熟悉。
博客查询逻辑
相对于发布来说,查询就要简单很多了,我们直接看Dao的代码就可以了。
@Override
public List<ArticleDto> listPub(int startIndex, int pageSize) {
//首先确定开始和结束的index
int stopIndex = startIndex+pageSize-1;
//使用SortedSet的ZREVRANGE命令获取ID列表,这个命令会按 score 值从大到小来获取区间数据
//从大到小的原因是越晚发布的博客score值越大,置顶的博客score最大
Set<Object> ids = redisSupport.zRevRange("article:pub:ids", startIndex, stopIndex);
//遍历获取的ID,使用Hash的HMGET命令获取博客其他属性,并转成POJO
List<ArticleDto> articleList =
ids.stream()
.map(e -> {
Map<Object,Object> result = redisSupport.hmget("article:" + e);
return BeanUtils.mapToBean(result, ArticleDto.class);})
.filter(e->(e!=null) && e.getId()!=null && NumberUtils.zeroOnNull(e.getStatus())==1)
.sorted(ArticleDto::compareByIssueTime)
.collect(Collectors.toList());
return articleList;
}
文章点赞
点赞功能相对简单,主要操作两个数据。一个是需要修改博客属性中的点赞数量,点赞时加1,取消赞是减1。然后每篇博客我们需要记录点赞人的集合,使用Set可以自动去重,防止一个人重复点赞。
数据结构如下:
key | value类型 | 使用方式 |
---|---|---|
article:like:{articleID} | Set | 对文章点赞的user集合 |
代码如下:
@RestController
@RequestMapping("/article")
public class ArticleController {
@PostMapping("/{articleId}/like")
public Response<Integer> like(@PathVariable Long articleId,@SessionAttr("user") UserDto user){
if(articleId < 0)
return new Response<>(ResultCode.INVALID_PARAM,"文章不存在");
return articleService.addLike(articleId, user.getUserId());
}
}
@Service
public class ArticleServiceImpl implements ArticleService{
@Override
public Response<Integer> addLike(Long articleId, Long userId) {
ArticleDto article = articleDao.getSummary(articleId);
if(article != null) {
Integer likeCount = article.getLikeCount();
//将userId加入集合,成功返回true,已点过赞返回false
boolean result = articleLikeDao.add(articleId, userId);
if(result) //成功后博客点赞数+1
likeCount = articleDao.increaseLikeNum(articleId);
return new Response<>(likeCount==null ? 0:likeCount);
}
return new Response<>(ResultCode.LOGICAL_ERROR, "文章不存在或者已被删除");
}
}
@Repository
public class ArticleLikeDaoImpl implements ArticleLikeDao {
@Override
public boolean add(Long articleId, Long userId) {
Assert.notNull(articleId, "article id must not be null");
Assert.notNull(userId,"user id must not be null");
//将userId使用SADD命令加入集合,num为加入成功数
long num = redisSupport.sAdd("article:like:"+articleId, userId);
return (num > 0);
}
}
@Repository
public class ArticleDaoImpl implements ArticleDao {
@Override
public Integer increaseLikeNum(Long id) {
Assert.notNull(id, "id must not be null");
//将博客的点赞数+1,返回值为当前likeCount属性的值
return (int)redisSupport.hincr("article:"+id,"likeCount", 1);
}
}
点赞的操作有两点值得关注:
- 使用SADD往集合中添加元素时,会返回实际新增了几个,就是说如果元素已经在集合里面了,会返回0
- Hash提供原子的HINCR命令将属性值+1,并且返回加1后的值
以上两个特性很赞有没有?秒杀关系型数据库有没有?
用户评论
评论的功能相对简单,下面列一下数据结构,代码就不贴了,感兴趣的可以看git上的代码
key | value类型 | 使用方式 |
---|---|---|
comment:nextval | string | 评论Id的自增序列 |
comment:{commentID} | Hash | 存储一条留言 |
comment:ids:{articleID} | List | 文章的留言列表 |
comment:like:{userID} | Set | 用户点过赞的评论列表 |
到此为止,使用Spingboot+Redis实现的个人博客基本就结束了,权限管理和个人资料等功能因为相对简单,所以没有实现。
下一篇文章会讲一下实现过程中碰到的问题以及Redis的一些使用技巧,敬请期待