目录
一、实现关注/取关
前端请求:/follow/2/true
--> 后端:@PutMapping("/{id}/{isFollow}")
redis使用:set的add方法,存入key,value(followerId)(关注的人的id)
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 1. 获取当前登录用户
long userId = UserHolder.getUser().getId();
String key = FOLLOW_USER_KEY + userId;
// 2. 判断是否关注
if (isFollow) {
// 未关注,进行关注操作
Follow follow = new Follow().setUserId(userId).setFollowUserId(followUserId).setCreateTime(LocalDateTime.now());
boolean isSuccess = save(follow);
if (isSuccess) {
// 成功的话就放在redis里面
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
} else {
return Result.fail("关注失败,请稍后再试!");
}
} else {
// 关注,进行取关操作 :数据库--redis
// remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
return Result.ok();
}
二、查看是否关注
前端请求:follow/or/not/1
--> 后端:@GetMapping("or/not/{id}")
redis使用:set的isMember
方法,判断是否存在某个value,传入key、value(followUserId)
Boolean member = stringRedisTemplate.opsForSet().isMember(FOLLOW_USER_KEY + userId, followUserId.toString());
@Override
public Result isFollow(Long followUserId) {
// 1. 获取当前登录用户
long userId = UserHolder.getUser().getId();
// 2. 从数据库中查是否有关注数据 --> 从redis中查
// Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
Boolean member = stringRedisTemplate.opsForSet().isMember(FOLLOW_USER_KEY + userId, followUserId.toString());
return Result.ok(member);
}
三、查看共同关注
前端请求:follow/common/1
--> 后端:@GetMapping("/common/{id}")
redis使用:set的intersect
方法查询交集,传入两个key查找交集value
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(userKey, otherKey);
难点:
1. 获取的交集是Set 类型的,如何转换成List 类型方便后续查询具体用户信息时调用
List<Long> ids = intersect
.stream()
.map(Long::valueOf)
.collect(Collectors.toList());
2. 将获取到的user转换为userDTO
// 查询用户
// listByIds返回的是一个List<User>,所以需要进行转换
// .stream() 方法将集合 ids 转换为一个 Java 8 流(Stream)
// .map(user -> BeanUtil.copyProperties(user, UserDTO.class)) 是一个映射操作,它将集合中的每个元素从 User 类型转换为 UserDTO 类型。
// .collect(Collectors.toList()将流中的映射后的 UserDTO 对象收集到一个新的 List<UserDTO> 中。这个操作将流转换为一个列表,其中包含了经过映射后的 UserDTO 对象。
List<UserDTO> users = userService
.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
代码实现
@Override
public Result followCommons(Long otherUserId) {
// 1. 获取当前登录用户
long userId = UserHolder.getUser().getId();
String userKey = FOLLOW_USER_KEY + userId;
String otherKey = FOLLOW_USER_KEY + otherUserId;
//求交集
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(userKey, otherKey);
// 没有的话直接返回空列表
if (intersect == null || intersect.isEmpty()) {
return Result.ok(Collections.emptyList());
}
// 解析id集合
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
// 查询用户
List<UserDTO> users = userService.listByIds(ids).stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());
return Result.ok(users);
}
四、关注推送
Feed流实现方案
Feed流产品有两种常见模式:
- Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
优点 | 信息全面,不会有缺失。并且实现也相对简单 |
---|---|
缺点 | 信息噪音较多,用户不一定感兴趣,内容获取效率低 |
2. 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
优点 | 投喂用户感兴趣信息,用户粘度很高,容易沉迷 |
---|---|
缺点 | 如果算法不精准,可能起到反作用 |
采用Timeline的模式。该模式的实现方案有三种:
1. 拉模式:只有粉丝⽤户在读取收件箱的时候, 才会根据其关注的⽤户进⾏拉取,把博主发件箱⾥的消息拉取到粉丝⽤户的收件箱⾥,然后对收件箱⾥的消息按时 间戳进⾏排序。
优点:节约空间
缺点:比较延迟,假设用户关注了大用户,此时就会拉取海量的内容,对服务器压力巨大。
2. 推模式:当⽤户(博主)发送消息时,会把消息+时间戳直接发送到所有粉丝⽤户的收件箱中,并按时间戳进⾏排序。当粉 丝⽤户在读取收件箱的消息时,直接读取。
优点: 延迟低
缺点: 发消息时,内容占⽤较⾼。因为每个粉丝都会保留⼀份消息。
3. 推拉模式:对于粉丝少的博主⽤户,采⽤推模式。 对于粉丝多的博主⽤户,根据粉丝⽤户类型进⾏判断: 活跃度⾼的粉丝⽤户,采⽤推模式 活跃度低的粉丝⽤户,采⽤拉模式
推拉模式兼具推和拉两种模式的优点
总结
需求:
(1)修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
(2)收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
(3)查询收件箱数据时,可以实现分页查询
注意:
当粉丝⽤户需要按分页模式来读取收件箱的信息时,不能采⽤传统的分页模式(按数据的⾓标开始查)。因为Feed 流中的数据会不断更新,所以数据的⾓标也在不断变化。传统的分页模式,会出现消息重复读的问题。
redis实现:用zset方便排序,时间戳做score
@Override
public Result saveBlog(Blog blog) {
if (blog.getShopId() == null || blog.getTitle() == null || blog.getContent() == null) {
return Result.fail("提交前情把Blog全部信息填写完整(●'◡'●)");
}
// 1. 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 2.保存探店笔记
boolean isSuccess = save(blog);
if (!isSuccess) {
return Result.fail("新增笔记失败!");
}
// 3.查询笔记作者的所有粉丝
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
// 4.推送笔记id给所有粉丝
for (Follow follow : follows) {
// 4.1.获取粉丝id
Long userId = follow.getUserId();
// 4.2.推送 (思路就是只把blog的id传到redis里面,到时候再调用bolg的query方法获取详情)
String key = FEED_KEY + userId;
// 还是要按时间戳当作value,因为要进行排序
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
}
return Result.ok(blog.getId());
}
五、实现分页查询收邮箱
具体操作如下:
1、每次查询完成后,我们要分析出查询出数据的最小时间戳,这个值会作为下一次查询的条件
2、我们需要找到与上一次查询相同的查询个数作为偏移量,下次查询时,跳过这些查询过的数据,拿到我们需要的数据
综上:我们的请求参数中就需要携带 lastId:上一次查询的最小时间戳minTime和偏移量offset这两个参数。
这两个参数第一次会由前端来指定,以后的查询就根据后台结果作为条件,再次传递到后台。
redis使用:opsForZSet的reverseRangeByScoreWithScores方法,传入key、minTime、maxTime、offset、每次查询数量
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, max, offset, 2);
@Data
public class ScrollResult {
// 查询的Blog结果
private List<?> list;
// 上次查询的最小时间戳
private Long minTime;
// 偏移量
private Integer offset;
}
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
// 2. 查询自己的收件箱
String key = FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok(Collections.emptyList());
}
// 解析数据:blogId、minTime(时间戳)、offset
ArrayList<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 2;
int os = 1;
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
ids.add(Long.valueOf(tuple.getValue()));
long time = tuple.getScore().longValue();
if (time == minTime) {
os++;
} else {
minTime = time;
os = 1;
}
}
os = minTime == max ? os : os + offset;
// 根据id查blog
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
// 查询blog相关信息
for (Blog blog : blogs) {
queryBlogUser(blog);
isBlogLike(blog);
}
// 封装并返回
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os);
r.setMinTime(minTime);
return Result.ok(r);
}
流程
1. 获取登录用户的 ID
Long userId = UserHolder.getUser().getId();
2. 查询用户的收件箱
String key = FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok(Collections.emptyList());
}
- 构建 Redis 的键名,
FEED_KEY
是一个常量,feed:123
,表示用户 ID 为123
的收件箱。 - 使用
stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores
方法从 Redis 的有序集合(Sorted Set)中查询数据。reverseRangeByScoreWithScores
方法的参数含义如下:key
:要查询的有序集合的键名。0
:分数的最小值,表示从分数为0
开始查询。max
:分数的最大值,表示查询分数小于等于max
的元素。offset
:偏移量,表示从第offset
个元素开始查询。2
:查询的元素数量,表示最多查询2
个元素。
- 该方法返回一个
Set<ZSetOperations.TypedTuple<String>>
集合,其中每个TypedTuple
对象包含元素的值(博客 ID)和对应的分数(时间戳)。 - 如果查询结果为空,则直接返回一个空列表。
3. 解析查询结果
ArrayList<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 2;
int os = 1;
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
ids.add(Long.valueOf(tuple.getValue()));
long time = tuple.getScore().longValue();
if (time == minTime) {
os++;
} else {
minTime = time;
os = 1;
}
}
os = minTime == max ? os : os + offset;
- 创建一个
ArrayList
用于存储博客 ID。 - 初始化
minTime
为2
,os
为1
。minTime
用于记录查询结果中最小的时间戳,os
用于记录偏移量。 - 遍历查询结果,将每个
TypedTuple
对象中的博客 ID 转换为Long
类型并添加到ids
列表中。 - 获取每个
TypedTuple
对象的分数(时间戳),如果当前时间戳等于minTime
,则os
加1
;否则,更新minTime
为当前时间戳,并将os
重置为1
。 - 根据
minTime
和max
的关系更新os
的值,如果minTime
等于max
,则保持os
不变;否则,将os
加上传入的offset
。
4. 根据博客 ID 查询博客信息
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
- 使用
StrUtil.join
方法将ids
列表中的元素用逗号连接成一个字符串,例如1,2,3
。 - 使用 MyBatis-Plus 的
query
方法构建查询条件,通过in
方法筛选出id
字段在ids
列表中的博客记录。 - 使用
last
方法在 SQL 语句末尾添加自定义的排序条件,ORDER BY FIELD(id," + idStr + ")"
表示按照id
列表中的顺序对查询结果进行排序,确保返回的博客列表顺序与 Redis 中查询结果的顺序一致。 - 调用
list
方法执行查询并返回博客列表。
5. 查询博客相关信息
for (Blog blog : blogs) {
queryBlogUser(blog);
isBlogLike(blog);
}
- 遍历查询到的博客列表,对每篇博客调用
queryBlogUser
方法查询博客作者的用户信息,并将用户信息设置到博客对象中。 - 调用
isBlogLike
方法判断当前登录用户是否点赞了该博客,并将判断结果设置到博客对象的isLike
属性中。
6. 封装并返回结果
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os);
r.setMinTime(minTime);
return Result.ok(r);
- 创建一个
ScrollResult
对象,用于封装查询结果。 - 将查询到的博客列表设置到
ScrollResult
对象的list
属性中。 - 将计算得到的偏移量
os
设置到ScrollResult
对象的offset
属性中。 - 将最小时间戳
minTime
设置到ScrollResult
对象的minTime
属性中。 - 使用
Result.ok
方法将ScrollResult
对象封装成统一的返回结果并返回。
综上,该方法通过从 Redis 中查询用户收件箱信息,获取博客 ID,再根据博客 ID 从数据库中查询博客信息,并补充博客相关的用户信息和点赞状态,最后将结果封装成 ScrollResult
对象返回,实现了滚动分页查询用户关注的博主发布的博客的功能。