Javaredis

使用SpringBoot和Redis构建个人博客(三)

2018-08-08  本文已影响77人  空挡

上两篇文章讲了Redis sentinel环境的搭建和SpringBoot+redis环境搭建,本章进入业务代码的开发。

Redis数据结构的设计

首先介绍一下这个系统中用到的Redis的数据结构和命令,应该说熟悉redis的数据结构是用好它的最基本要求。希望看完了以后,大家以后用redis就别再只用字符串了,那样等于放弃了一片森林。

本系统中用到的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 存储文章正文详细内容,富文本

文章存储的问题解决了,还要解决查询的问题。查询相关需解决这样几个问题:

为了满足以上要求,添加如下几个结构:

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);
    }
}

点赞的操作有两点值得关注:

以上两个特性很赞有没有?秒杀关系型数据库有没有?

用户评论

评论的功能相对简单,下面列一下数据结构,代码就不贴了,感兴趣的可以看git上的代码

key value类型 使用方式
comment:nextval string 评论Id的自增序列
comment:{commentID} Hash 存储一条留言
comment:ids:{articleID} List 文章的留言列表
comment:like:{userID} Set 用户点过赞的评论列表

到此为止,使用Spingboot+Redis实现的个人博客基本就结束了,权限管理和个人资料等功能因为相对简单,所以没有实现。
下一篇文章会讲一下实现过程中碰到的问题以及Redis的一些使用技巧,敬请期待

上一篇下一篇

猜你喜欢

热点阅读