牛客高级项目课九--关注业务(一)

总体设计

概念:

  1. A关注B
  2. A是B的粉丝(follower)
  3. B是A的关注对象(followee)

特点:

  1. 多对多服务
  2. ID和ID关联,有序

存储结构:

redis: zset(不使用zlist,主要是考虑查询效率的原因)

Service:

  1. 通用关注接口
  2. 粉丝列表分页
  3. 关注对象列表分页

Controller:

  1. 首页问题关注数
  2. 详情页问题关注列表
  3. 粉丝/关注人列表

Redis 基础

理论简介

有序集合对象

有序集合对象的编码可以是ziplist或者skiplist, 这里只介绍skiplist(详情参见《Redis设计与实现》P305 8.6)
skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表

typedef struct zset{
//跳跃表(按score从小到大保存了所有集合的元素,每个跳跃表节点保存了一个集合的元素,跳表节点score属性保存元素分值,可进行范围操作:ZRANK,ZRANGE)
    zskiplist *zsl;
//字典(为有序集合创建了一个从成员到分值的映射:键保存元素的成员,而值保存了元素的分值,O(1)时间复杂度可以查找给定成员分值ZSCORE)
    dict *dict;
}zset;

Redis 事务

事务就是一种将多个命令一次性打包,按顺序执行的机制,并且在事务执行期间,服务器不会中断事务去执行其他客户端的命令请求,必须一次性全部执行完(原子性)。(详细参见《Redis设计与实现》P1279 第19章)
通常过程如下:

MULTI //事务开始的标志符号
.......  //一系列对redis数据库操作的命令
EXEC //这个命令会将上面的所有操作命令一次性提交给服务器执行

也就是三个阶段:
1)事务开始
2)命令入队
3)事务执行

事务开始

以MULTI为标志符号,将客户端从非事务状态切换到事务状态,通过在客户端状态的flags属性中打开REDIS_MULTI标识完成。

命令入队

命令入队的判断流程如下图:
牛客高级项目课九--关注业务(一)

事务状态

每个redis客户端都有自己的事务状态,事务状态保存在客户端状态的mastate属性中。
事务状态包含一个事务队列以及一个已入队命令的计数器,事务队列的保存方式是FIFO,所以在执行时可以按顺序执行。

执行事务

以EXEC为标志,收到这个命令后,服务器端会遍历这个客户端的事务队列,执行事务队列中的所有命令,最后把执行结果全部返回给客户端。

Redis事务中的命令还有WATCH(监视)等,不再详细介绍。

实际操作

使用jedis包来操作redis数据库。所有的命令封装在JedisAdapter.Java文件中。
基础封装如下:

zadd 添加元素

    public long zadd(String key, double score, String value) {
        Jedis jedis = null;
        try {
            jedis = pool.getResource();//从线程池中取一个 pool定义为private JedisPool pool;
            return jedis.zadd(key, score, value);
        } catch (Exception e) {
            logger.error("发生异常" + e.getMessage());//这里一般是getMessage,但是我其实更喜欢toString,打印出来的信息更多,好定位
        } finally {
            if (jedis != null) {
                jedis.close();//注意一定要关,不关后面就没有资源用了
            }
        }
        return 0;
    }

zrem 删除元素

    public long zrem(String key, String value) {
        Jedis jedis = null;
        try {
            jedis = pool.getResource();
            return jedis.zrem(key, value);
        } catch (Exception e) {
            logger.error("发生异常" + e.getMessage());
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        return 0;
    }

zrange 返回给定索引范围内的所有元素,score从小到大

    public Set<String> zrange(String key, int start, int end) {
        Jedis jedis = null;
        try {
            jedis = pool.getResource();
            return jedis.zrange(key, start, end);
        } catch (Exception e) {
            logger.error("发生异常" + e.getMessage());
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        return null;
    }

zrevrange,反着取出来,score从大到小

    public Set<String> zrevrange(String key, int start, int end) {
        Jedis jedis = null;
        try {
            jedis = pool.getResource();
            return jedis.zrevrange(key, start, end);
        } catch (Exception e) {
            logger.error("发生异常" + e.getMessage());
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        return null;
    }

zcard 集合元素的个数统计

    public long zcard(String key) {
        Jedis jedis = null;
        try {
            jedis = pool.getResource();
            return jedis.zcard(key);
        } catch (Exception e) {
            logger.error("发生异常" + e.getMessage());
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        return 0;
    }

zscore 查找元素分值

    public Double zscore(String key, String member) {
        Jedis jedis = null;
        try {
            jedis = pool.getResource();
            return jedis.zscore(key, member);
        } catch (Exception e) {
            logger.error("发生异常" + e.getMessage());
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        return null;
    }

multi 事务开始标志

    public Transaction multi(Jedis jedis) {
        try {
            return jedis.multi();
        } catch (Exception e) {
            logger.error("发生异常" + e.getMessage());
        } 
        return null;
    }

exec 事务执行标志

    public List<Object> exec(Transaction tx, Jedis jedis) {
        try {
            return tx.exec();
        } catch (Exception e) {
            logger.error("发生异常" + e.getMessage());
            tx.discard();
        } finally {
            if (tx != null) {
                try {
                    tx.close(); //关闭transaction
                } catch (IOException ioe) {
                    logger.error("端口异常" + ioe.getMessage());
                }
            }
            if (jedis != null) {
                jedis.close();
            }
        }
        return null;
    }

Service

对redis操作进行了封装以后,下面就可以开始向上封装为service层了,这一层中主要负责提供一个通用的关注服务,那么他所做的功能就大体可以分为:
1、关注某个对象(人or问题)follow
2、取消关注某个对象(人or问题)unfollow
3、获取某个对象的粉丝 getFollowers
4、获取粉丝数目 getFollowersCount
5、获取某个人关注对象的数目 getFolloeeCount
6、判断是否是粉丝 isFollower
在redis中所有的数据都是以{key:value}的形式存在,所以针对不同的业务,在写具体操作之前我们需要有一个key的生成器。
RedisKeyUtil.java

    //粉丝
    private static String BIZ_FOLLOWER = "FOLLOWER";
    //关注的事物
    private static String BIZ_FOLLOWEE = "FOLLOWEE";
    //每个实体所有粉丝的key
    public static String getFollowerKey(int entityType, int entityId) {
        return BIZ_FOLLOWER + SPLIT + String.valueOf(entityType) + SPLIT + String.valueOf(entityId);
    }
    //某一个用户关注某一类事件(人or问题)的key
    public static String getFolloweeKey(int userId, int entityType) {
        return BIZ_FOLLOWEE + SPLIT + String.valueOf(userId) + SPLIT + String.valueOf(entityType);
    }

有了key的生成器,我们的数据库就可以设计为按时间排序(score值为时间,键为对应的key,值为:
1、如果是follower,值为粉丝的id
2、如果为followee,值为被关注的事务(人或问题)的id
注意一个关注行为,对于不同的对象来说将同时产生follower和followee。Eg.
A关注问题1,对于问题1来说,A是问题1的follower;对于A来说,问题1是A的followee。
因此,follower的键值对创建,永远伴随着followee键值对的创建。取消也是一样的效果。是不可分开的2个操作,这也就是为什么要引入具有原子性的Redis事务。
基于以上分析,可以写出关注和不关注的代码如下:

follow 关注

    public boolean follow(int userId, int entityType, int entityId){
        String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);
        String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
        Date date = new Date();
        // 实体的粉丝增加当前用户
        Jedis jedis = jedisAdapter.getJedis();//getjedis的作用就是pool.getresources
        Transaction tx = jedisAdapter.multi(jedis);//创建事务tx
        tx.zadd(followerKey, date.getTime(), String.valueOf(userId));//事务列表加入zadd指令,这里是添加一个follower
        // 当前用户对这类实体关注+1
        tx.zadd(followeeKey, date.getTime(), String.valueOf(entityId));//事务列表加入zadd指令,添加一个followee
        List<Object> ret = jedisAdapter.exec(tx, jedis);//执行,返回执行结果
        return ret.size() == 2 && (Long) ret.get(0) > 0 && (Long) ret.get(1) > 0;//有2条指令,所以size应该为2,值应该>0代表操作成功
    }

unfollow 取消关注

    public boolean unfollow(int userId, int entityType, int entityId){
        String followerKey = RedisKeyUtil.getFollowerKey(entityType,entityId);
        String followeeKey = RedisKeyUtil.getFolloweeKey(userId,entityType);
        Date date = new Date();
        Jedis jedis = jedisAdapter.getJedis();
        Transaction tx = jedisAdapter.multi(jedis);
        tx.zrem(followerKey, String.valueOf(userId));
        tx.zrem(followeeKey, String.valueOf(entityId));
        List<Object> ret = jedisAdapter.exec(tx,jedis);
        return  ret.size()==2 &&(Long)ret.get(0)>0 &&(Long)ret.get(1)>0;
    }

原理和follow一样,不再重复。

getFollowers getFollowees

这2个函数主要是为了方便在页面上显示出来粉丝列表和某用户的关注列表,这里可以利用zrang和zrevrange函数取出排好序的想要的条数,和mysql中的limit相似。
以getFollowers为例,返回的值虽然实际上是保存的被关注的人的userId,或别关注的问题的questionId, 但是在redis中是以string类型存储的,因此需要一个辅助函数,转化为interger类型方便后续操作和mysql数据类型的对接。

    //把string装换为int的辅助函数
    private List<Integer> getIdsFromSet(Set<String> idset){
        List<Integer> ids = new ArrayList<>();
        for(String str : idset){
            ids.add(Integer.parseInt(str));//将字符串参数作为有符号的十进制整数进行解析
        }
        return ids;
    }

getFollowers getFollowees函数如下:

    public List<Integer> getFollowers(int entityType,int entityId, int count){
        String followerKey = RedisKeyUtil.getFollowerKey(entityType,entityId);
        return getIdsFromSet(jedisAdapter.zrevrange(followerKey,0,count));
    }
    public List<Integer> getFollowers(int entityType,int entityId, int offset, int count){
        String followerKey = RedisKeyUtil.getFollowerKey(entityType,entityId);
        return getIdsFromSet(jedisAdapter.zrevrange(followerKey,offset,count+offset));
    }

   public List<Integer> getFollowees(int userId,int entityType, int count){
        String followeeKey = RedisKeyUtil.getFolloweeKey(userId,entityType);
        return getIdsFromSet(jedisAdapter.zrevrange(followeeKey,0,count));
    }
    public List<Integer> getFollowees(int userId,int entityType, int offset, int count){
        String followeeKey = RedisKeyUtil.getFolloweeKey(userId,entityType);
        return getIdsFromSet(jedisAdapter.zrevrange(followeeKey,offset,count+offset));
    }

getFollowerCount getFolloweeCount

    public long getFollowerCount(int entityType, int entityId){
        String followerKey = RedisKeyUtil.getFollowerKey(entityType,entityId);
        return jedisAdapter.zcard(followerKey);
    }
    public long getFolloweeCount(int userId,int entityType){
        String followeeKey = RedisKeyUtil.getFolloweeKey(userId,entityType);
        return jedisAdapter.zcard(followeeKey);
    }

到这里,service基本就完成了

Controller

controller是最上层和web端直接的接口,可以指定访问的方法,URL等。
这里根据前端需要的信息,我们大概要3件事:
1、js触发的“关注”行为
2、js触发的“取消关注”行为
3、用户粉丝详情页和用户关注的人详情页
3、首页关注人数的显示
4、问题详情页的关注列表显示
其中1、2又分为关注对象为人和关注对象为问题2种情况,这里可以在前端显示的只有关注问题这种情况,但不阻碍我们写关注对象为人的后端代码。

关注对象为人的关注行为和取消关注行为

    @RequestMapping(path={"/followUser"},method = {RequestMethod.POST})
    @ResponseBody
    public String followUser(@RequestParam("userId") int userId){
        if(hostHolder.getUser()==null){
            return ZhihuUtil.getJSONString(999);
        }
        boolean ret = followService.follow(hostHolder.getUser().getId(), EntityType.ENTITY_USER, userId);
        //返回关注的人数
        return ZhihuUtil.getJSONString(ret? 0:1, String.valueOf(followService.getFolloweeCount(EntityType.ENTITY_USER,hostHolder.getUser().getId())));
    }

    @RequestMapping(path={"/unfollowUser"}, method = {RequestMethod.POST})
    @ResponseBody
    public String unfollowUser(@RequestParam("userId") int userId){
        if(hostHolder.getUser()==null){
            return ZhihuUtil.getJSONString(999);
        }
        boolean ret = followService.unfollow(hostHolder.getUser().getId(), EntityType.ENTITY_USER, userId);
        //返回关注的人数
        return ZhihuUtil.getJSONString(ret? 0 : 1, String.valueOf(followService.getFolloweeCount(EntityType.ENTITY_USER,hostHolder.getUser().getId())));
    }

关注对象为问题的关注和取消关注

   @RequestMapping(path={"/followQuestion"},method = {RequestMethod.POST})
    @ResponseBody
    public String followQuestion(@RequestParam("questionId") int questionId){
        if(hostHolder.getUser()==null){
            return ZhihuUtil.getJSONString(999);
        }

        Question currentQuestion = questionService.selectById(questionId);
        boolean ret = followService.follow(hostHolder.getUser().getId(), EntityType.ENTITY_QUESTION, questionId);
        Map<String, Object> info = new HashMap<>();
        info.put("headUrl", hostHolder.getUser().getHeadUrl());
        info.put("name",hostHolder.getUser().getName());
        info.put("id",hostHolder.getUser().getId());
        info.put("count", followService.getFollowerCount(EntityType.ENTITY_QUESTION, questionId));
        return ZhihuUtil.getJSONString(ret? 0:1, info);
    }

    @RequestMapping(path={"/unfollowQuestion"}, method = {RequestMethod.POST})
    @ResponseBody
    public String unfollowQuestion(@RequestParam("questionId") int questionId){
        if(hostHolder.getUser()==null){
            return ZhihuUtil.getJSONString(999);
        }

        Question currentQuestion = questionService.selectById(questionId);
        if(currentQuestion == null){
            return ZhihuUtil.getJSONString(1,"问题不存在");
        }
        boolean ret = followService.unfollow(hostHolder.getUser().getId(), EntityType.ENTITY_QUESTION, questionId);
        Map<String, Object> info = new HashMap<>();
        info.put("id",hostHolder.getUser().getId());
        info.put("count", followService.getFollowerCount(EntityType.ENTITY_QUESTION, questionId));
        return ZhihuUtil.getJSONString(ret? 0:1, info);
    }

用户粉丝详情页和用户关注的人详情页

粉丝详情页

    @RequestMapping(path={"/user/{uid}/followers"},method = RequestMethod.GET)
    public String followers(Model model, @PathVariable("uid") int userId){
        List<Integer> followerIds = followService.getFollowers(EntityType.ENTITY_USER,userId,0,10);
        if(hostHolder.getUser()!=null){
            model.addAttribute("followers",getUsersInfo(hostHolder.getUser().getId(),followerIds));
        }else{
            model.addAttribute("followers", getUsersInfo(0, followerIds));
        }
        model.addAttribute("followerCount",followService.getFollowerCount(EntityType.ENTITY_USER,userId));
        model.addAttribute("curUser",userService.selectById(userId));
        return "followers";
    }

用户关注的人详情页

    @RequestMapping(path={"/user/{uid}/followees"},method = RequestMethod.GET)
    public String followees(Model model, @PathVariable("uid") int userId){
        List<Integer> followeeIds = followService.getFollowees(userId,EntityType.ENTITY_USER,0,10);
        if(hostHolder.getUser()!=null){
            model.addAttribute("followees",getUsersInfo(hostHolder.getUser().getId(),followeeIds));
        }else{
            model.addAttribute("followees", getUsersInfo(0, followeeIds));
        }
        model.addAttribute("followeeCount",followService.getFolloweeCount(userId,EntityType.ENTITY_USER));
        model.addAttribute("curUser",userService.selectById(userId));
        return "followees";
    }

其中getUsersInfo是一个公用的函数,取了其中几个值:

    private List<ViewObject> getUsersInfo(int localUserId, List<Integer> userIds) {
        List<ViewObject> userInfos = new ArrayList<ViewObject>();
        for (Integer uid : userIds) {
            User user = userService.selectById(uid);
            if (user == null) {
                continue;
            }
            ViewObject vo = new ViewObject();
            vo.set("user", user);
            vo.set("commentCount", commentService.getUserCommentCount(uid));
            vo.set("followerCount", followService.getFollowerCount(EntityType.ENTITY_USER, uid));
            vo.set("followeeCount", followService.getFolloweeCount(uid, EntityType.ENTITY_USER));
            if (localUserId != 0) {
                vo.set("followed", followService.isFollower(localUserId, EntityType.ENTITY_USER, uid));
            } else {
                vo.set("followed", false);
            }
            userInfos.add(vo);
        }
        return userInfos;
    }

到这里FollowController的功能基本完成了,下面主要是主页中关注人数的显示和问题详情页关注人列表的显示,这里使用viewObject类型(自定义的class,主要是{string: object})记录下这些需要的参数。

主页中关注人数显示

HomeController.java中添加这部分代码
在getQuestion函数中添加:

    private List<ViewObject> getQuestions(int userId, int offset, int limit) {
        List<Question> questionList = questionService.getLatestQuestions(userId, offset, limit);
        List<ViewObject> vos = new ArrayList<>();
        for (Question question : questionList) {
            ViewObject vo = new ViewObject();
            vo.set("question", question);
            //添加部分,取出关注人数----------
            vo.set("followCount", followService.getFollowerCount(EntityType.ENTITY_QUESTION, question.getId())); 
            //----------------------------
            vo.set("user", userService.getUser(question.getUserId()));
            vos.add(vo);
        }
        return vos;
    }

问题详情页中关注人数列表显示

QuestionController.java中的questionDetail函数中添加如下代码,添加在返回前面即可:

        List<ViewObject> followUsers = new ArrayList<ViewObject>();
        // 获取关注的用户信息
        List<Integer> users = followService.getFollowers(EntityType.ENTITY_QUESTION, qid, 20);
        for (Integer userId : users) {
            ViewObject vo = new ViewObject();
            User u = userService.getUser(userId);
            if (u == null) {
                continue;
            }
            vo.set("name", u.getName());
            vo.set("headUrl", u.getHeadUrl());
            vo.set("id", u.getId());
            followUsers.add(vo);
        }
        model.addAttribute("followUsers", followUsers);
        if (hostHolder.getUser() != null) {
            model.addAttribute("followed", followService.isFollower(hostHolder.getUser().getId(), EntityType.ENTITY_QUESTION, qid));
        } else {
            model.addAttribute("followed", false);
        }

到这里基本就结束了,关注功能基本完成。

小结和注意

容易出问题的地方还是在redis数据库的操作中,很有可能数据库没有配置好,导致事务命令不成功,可以在followService.java中的follow函数那里打断点查看事务ret的size是否为2,返回值是否都大于0。这里我也出了一点问题,但是换了一个数据库就好了,很奇怪,换数据库可以在JedisAdapter.java中进行

    @Override
    public void afterPropertiesSet() throws Exception {
        pool = new JedisPool("redis://localhost:6379/10");
    }

我的10号库似乎有问题,执行事务的时候一直返回值为0,换成9就好了,不是很了解原因,Mark一下。


尾记:第一篇博客,因为将来工作原因,学习java断断续续2个月了,想着还是记下来比较好,也可以分享给大家,一起交流学习,有问题处希望大家不惜赐教^ _ ^