使用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的一些使用技巧,敬请期待

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,658评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,482评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,213评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,395评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,487评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,523评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,525评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,300评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,753评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,048评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,223评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,905评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,541评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,168评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,417评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,094评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,088评论 2 352

推荐阅读更多精彩内容

  • 关于Mongodb的全面总结 MongoDB的内部构造《MongoDB The Definitive Guide》...
    中v中阅读 31,916评论 2 89
  • 转眼间,进去大学的生活已经一年半了,加入燃烧剧社也已经同样长时间了。我也从一个青涩的新人变成了一只老鸟。每天讲着些...
    adjoker阅读 139评论 0 0
  • 每个人追求的不一样,适合自己的才是最好的。没必要羡慕别人,你也很棒。
    案前笔墨阅读 206评论 0 0