一、关注和取关
在探店图文的详情页中,可以关注发布笔记的作者: 需求:基于该表数据结构,实现两个接口: ① 关注和取关接口 ② 判断是否关注的接口
关注是 User 之间的关系,是博主与粉丝的关系,数据库中有一张 tb_follow 表来标识: 注意:这里需要把主键修改为自增长 FollowController
@RestController
@RequestMapping("/follow")
public class FollowController {
@Autowired
private IFollowService followService;
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow){
return followService.follow(followUserId, isFollow);
}
@PutMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId){
return followService.isFollow(followUserId);
}
}
IFollowService
public interface IFollowService extends IService<Follow> {
Result follow(Long followUserId, Boolean isFollow);
Result isFollow(Long followUserId);
}
FollowServiceImpl
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {
@Override
public Result follow(Long followUserId, Boolean isFollow) {
Long userId = UserHolder.getUser().getId();
if(isFollow){
Follow follow = new Follow();
follow.setFollowUserId(followUserId);
follow.setUserId(userId);
save(follow);
} else {
remove(new QueryWrapper<Follow>().eq("follow_user_id", followUserId).eq("user_id", userId));
}
return Result.ok();
}
@Override
public Result isFollow(Long followUserId) {
Long userId = UserHolder.getUser().getId();
Integer count = query().eq("follow_user_id", followUserId).eq("user_id", userId).count();
return Result.ok(count > 0);
}
}
二、共同关注
点击博主头像,可以进入博主首页: 博主个人首页依赖两个接口: ① UserController 根据 id 查询 User 信息
@GetMapping("/{id}")
public Result queryUserById(@PathVariable("id") Long userId){
User user = userService.getById(userId);
if(user == null){
return Result.ok();
}
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
return Result.ok(userDTO);
}
② BlogController 根据 id 查询博主的探店笔记
@GetMapping("/of/user")
public Result queryBlogByUserId(
@RequestParam(value = "current", defaultValue = "1") Integer current,
@RequestParam("id") Long id) {
Page<Blog> page = blogService.query().
eq("user_id", id).
page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
List<Blog> records = page.getRecords();
return Result.ok(records);
}
需求:利用 Redis 中的 set 类型的数据结构,实现共同关注功能(set 数据结构可以用来计算指定 key 之间元素的交集)。在博主个人页面展示出当前用户与博主的共同好友。 FollowController
@RequestMapping("/follow")
public class FollowController {
@Autowired
private IFollowService followService;
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow){
return followService.follow(followUserId, isFollow);
}
@PutMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId){
return followService.isFollow(followUserId);
}
@GetMapping("/common/{id}")
public Result followCommons(@PathVariable("id") Long id){
return followService.followCommons(id);
}
}
IFollowService
public interface IFollowService extends IService<Follow> {
Result follow(Long followUserId, Boolean isFollow);
Result isFollow(Long followUserId);
Result followCommons(Long id);
}
FollowServiceImpl:对 follow 方法进行改进,在向数据库写入关注信息时,同时保存至 Redis 中,在取消关注时,除了删除数据库中的数据,同时移除 Redis 中的相关数据。followCommons 为查询共同关注的方法,使用 set 数据结构的特性进行查询。
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private IUserService userService;
@Override
public Result follow(Long followUserId, Boolean isFollow) {
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
if(isFollow){
Follow follow = new Follow();
follow.setFollowUserId(followUserId);
follow.setUserId(userId);
boolean isSuccess = save(follow);
if (isSuccess) {
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
} else {
boolean isSuccess = remove(new QueryWrapper<Follow>().eq("follow_user_id", followUserId).eq("user_id", userId));
if (isSuccess) {
stringRedisTemplate.opsForSet().remove(key, followUserId);
}
}
return Result.ok();
}
@Override
public Result isFollow(Long followUserId) {
Long userId = UserHolder.getUser().getId();
Integer count = query().eq("follow_user_id", followUserId).eq("user_id", userId).count();
return Result.ok(count > 0);
}
@Override
public Result followCommons(Long id) {
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
String key2 = "follows:" + id;
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
if(intersect == null){
return Result.ok(Collections.emptyList());
}
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
List<UserDTO> collect = userService.listByIds(ids).stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());
return Result.ok(collect);
}
}
三、关注推送
3.1 Feed 流实现方案分析
关注推送也叫做 Feed 流,直译为投喂。为用户持续的提供”沉浸式“的体验,通过无限下拉刷新获取新的信息。
Feed 流产品有两种常见模式: ① Timeline:不做内容筛选,简单的按照内容发布时间排序,常用语好友或关注。例如朋友圈 优点:信息全面,不会有缺失,并且实现也相对简单 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低 ② 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容,推送用户感兴趣的信息来吸引用户 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷 缺点:如果算法不精准,可能起到反作用
本例中的个人页面,是基于关注的好友来做 Feed 流,因此采用 Timeline 的模式。该模式的实现方案有三种: ① 拉模式 ② 推模式 ③ 推拉结合
拉模式: 也叫做读扩散 假设有三个人,分别是张三、李四、王五,这三个人分别会在自己的账号发布自己的笔记或者视频,在这里我们统一称之为消息,这三个人发布的所有的消息都会被发送到发件箱中,发送到发件箱中的消息除了消息本身之外,还需要带有时间戳。粉丝赵六会有一个收件箱,平时收件箱是空的,只有他在读取消息时,才会把赵六关注的所有人的发件箱中的消息拉取到他的收件箱中,拉取到收件箱后,消息会按照携带的时间戳进行排序,然后赵六就可以读取消息了。 优点:节省内存空间。收件箱中的消息在读取完后就会被清空,下次需要读取的时候会重新从所有关注人的发件箱中拉取。消息只保存了一份。 缺点:每次读取消息都要重新拉取发件箱中的消息,然后再做排序,比较耗时。
推模式: 也叫做写扩散 假设现在有两个 up 主:张三、李四,有三个粉丝:粉丝1、粉丝2、粉丝3,粉丝1关注了张三,粉丝2和3都关注了张三和李四,如果张三此时想要发送消息,那么这条消息会直接推送到张三的所有粉丝的收件箱中,而李四发送的消息也会被推送到粉丝2和3的收件箱中,收件箱收到消息后会对所有的消息进行排序,粉丝在读取消息时,就已经是排序好的消息了。这样的一个好处就是延时低,不必每次读取时都需要重新拉取消息。但这种方式内存占用会比较高,up 主每次发消息都要同步所有的粉丝,如果粉丝数过多,超过几百万,就需要复制几百万份。
推拉结合模式: 也叫做读写混合,兼具推和拉两种模式的优点。 普通粉丝人数众多,但是活跃度较低,读取消息的频率也就低,可采用拉模式; 而活跃粉丝人数少,但是活跃度高,读取消息的频率高,可采用推模式。 大 V 发送消息时,会直接将消息推送到活跃粉丝的发件箱中,而针对于普通粉丝,消息会先发送到发件箱中,当普通粉丝读取消息时,会直接从发件箱中拉取。 三种模式对比:
3.2 基于推模式实现关注推送功能
3.2.1 需求分析
需求: ① 修改新增探店笔记的业务,在保存 Blog 到数据库的同时,推送到粉丝的收件箱 ② 收件箱满足可以根据时间戳排序,必须用 Redis 的数据结构实现 ③ 查询收件箱数据时,可以实现分页查询
需求分析: Redis 中 List、SortedSet 两种数据结构均可以实现排序功能。List 要想实现按时间排序,可以按照插入顺序排。SortedSet 排序则是按照 score 进行排序,score 值中可以存储时间戳来实现按照时间排序。 Redis中的 List 可以按照角标进行查询,完全可以实现分页查询。而 SortedSet 虽然没有角标,但是排序完成后,会有一个排名机制,可以使用排名机制进行查询,也能实现分页查询。 那究竟应该选择哪种数据结构来分页查询功能呢? 由于Feed 流中的数据会不断更新,这就导致数据的角标也在不断变化,因此不能采用传统的分页模式。 来看下传统分页模式: 假设 t1 时刻,Feed 流中有 10 条消息,此时从 Feed 流中读取前 5 条消息,在 t2时刻向 Feed 流中插入了一条新消息,当在 t3 时刻再去读取后 5 条数据时,就会出现数据重复读取的问题。为了避免这种情况,可以采用滚动分页。所谓滚动分页,其实就是记录每一次查询的最后一条消息的下标,下一次查询时从该条消息开始查询。 假设 t1 时刻,Feed 流中有 10 条消息,此时从 Feed 流中读取前 5 条消息,在 t2时刻向 Feed 流中插入了一条新消息,当在 t3 时刻再去读取后 5 条数据时,查询会从记录的 lastId 开始,向后查询 5 条,这样也就不会出现查询重复的问题。 SortedSet 除了可以按照时间戳排序,还支持按照 score 值的范围进行查询,即按照时间戳范围进行查询。每次查询时,记录最小的时间戳,下次查询时,从比该时间戳还小的下一个时间戳开始查询。 为了保证发布的消息不会重复进入需要推送的粉丝的收件箱中,以及保证查询速度,使用 SortedSet 更优。
3.2.2 推送到粉丝收件箱
修改 BlogController 中的 saveBlog 方法
@RestController
@RequestMapping("/blog")
public class BlogController {
@Resource
private IBlogService blogService;
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
return blogService.saveBlog(blog);
}
}
IBlogService
public interface IBlogService extends IService<Blog> {
Result saveBlog(Blog blog);
}
BlogServiceImpl
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private IFollowService followService;
@Override
public Result saveBlog(Blog blog) {
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
boolean isSuccess = save(blog);
if(!isSuccess){
return Result.ok();
}
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
if(follows == null || follows.isEmpty()){
return Result.ok(blog.getId());
}
for (Follow follow: follows) {
Long userId = follow.getUserId();
stringRedisTemplate.opsForZSet().add(RedisConstants.FEED_KEY + userId, blog.getId().toString(), System.currentTimeMillis());
}
return Result.ok(blog.getId());
}
}
3.3 实现关注推送页面的分页查询
3.3.1 需求分析
需求:在个人主页的”关注“卡片中,查询并展示推送的 Blog 信息: 实现滚动分页需要使用下面的命令:
ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]
返回有序集 key 中, score 值介于 max 和 min 之间(默认包括等于 max 或 min )的所有的成员。有序集成员按 score 值递减(从大到小)的次序排列。WITHSCORES 表示是否返回成员分数。LIMIT 表示是否分页,如果带有 LIMIT,则后面必须带有offset、count,offset表示偏移量(相对于max值而言),count 表示结果数量。关于 ZREVRANGEBYSCORE 更多的介绍可以参看这位大佬的文章Redis有序集合命令ZREVRANGEBYSCORE详解与应用 这里要注意是:一旦使用分页查询后,max 以及 offset 的值就是一个动态变化的值了。其中,max 值是上一次查询结果中的最小分数(即时间戳)。而 offset 的取值也与上一次查询结果中的最小分数有关,如果上一次查询结果中的最小分数值重复多次出现,offset的值就应该为最小分数重复出现的次数。 如下图所示: 其中 m7 和 m6 的 score 值相同 根据上图可以看出 offset 是需要动态获取。
定义滚动查询结果类 ScrollResult
@Data
public class ScrollResult {
private List<?> list;
private Long minTime;
private Integer offset;
}
BlogController
@RestController
@RequestMapping("/blog")
public class BlogController {
@Resource
private IBlogService blogService;
@GetMapping("/of/follow")
public Result queryBlogOfFollow(@RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset){
return blogService.queryBlogOfFollow(max, offset);
}
}
IBlogService
public interface IBlogService extends IService<Blog> {
Result queryBlogOfFollow(Long max, Integer offset);
}
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
UserDTO user = UserHolder.getUser();
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().
reverseRangeByScoreWithScores(RedisConstants.FEED_KEY + user.getId(), 0, max, offset, 2);
if(typedTuples == null) {
return Result.ok(Collections.emptyList());
}
int count = 1;
long minTime = 0;
List<Long> ids = new ArrayList<>(typedTuples.size());
for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
ids.add(Long.valueOf(typedTuple.getValue()));
long time = typedTuple.getScore().longValue();
if(time == minTime){
count++;
} else {
minTime = time;
count = 1;
}
}
String join = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("order by field(id," + join + ")").list();
ScrollResult scrollResult = new ScrollResult();
scrollResult.setList(blogs);
scrollResult.setMinTime(minTime);
scrollResult.setOffset(count);
return Result.ok(scrollResult);
}
}
|