👨?🎓作者简介:一位大四、研0学生,正在努力准备大四暑假的实习
🌌上期文章:Redis:原理速成+项目实战——Redis实战11(达人探店(Redis实现点赞、热榜))
📚订阅专栏:Redis:原理速成+项目实战
希望文章对你们有所帮助
上一节用Redis实现了对笔记的点赞,现实生活中当访问其他用户的笔记的时候,一般也会有个关注该用户的按钮,比如你可以写这篇博客的作者点点关注,嘻嘻嘻。
除了简单的关注和取关,还可以在用户之间查看到共同关注的用户,并实现关注推送,最后粉丝查看消息推送的时候要能够实现滚动分页查询,这样看起来更符合现实的社交类APP。
这一部分有些内容就是单纯的CRUD,不得不说做CRUD真是爽,简单且不耗时间,后面的滚动分页查询细节还是有点多的。
当我们点击关注/取关按钮的时候,将会发起请求,请求包含被关注用户的id,以及true或false来分别表示你的动作是关注还是取关。
因此,当我们打开笔记的详情页的时候,就应该要主动发起一个GET请求,携带信息,来表示你有没有关注过当前用户。
基于此,我们要实现2个接口:
1、关注和取关
2、判断是否关注
用户之间是多对多之间的关系,需要一个中间表tb_follow:
我以前做项目的时候都是喜欢在这里加一个boolean字段,关注了就true否则就false,但是这边没有这个字段,我也不做修改了,就这么做下去,当取关的时候就直接删除这条数据就好了。
这个业务其实就是增删改查,非常简单。
FollowController:
@RestController
@RequestMapping("/follow")
public class FollowController {
@Resource
private IFollowService followService;
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow){
return followService.follow(followUserId, isFollow);
}
@GetMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId){
return followService.isFollow(followUserId);
}
}
FollowServiceImlp:
@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.setUserId(userId);
follow.setFollowUserId(followUserId);
save(follow);
}else{
//取关,删除数据
remove(new QueryWrapper<Follow>()
.eq("user_id", userId).eq("follow_user_id", followUserId));
}
return Result.ok();
}
@Override
public Result isFollow(Long followUserId) {
Long userId = UserHolder.getUser().getId();
//查询是否关注
Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
return Result.ok(count > 0);
}
}
当在笔记中点击该用户头像的时候会跳转到其主页:
在这里我们应该要发起2个GET请求:
1、根据该用户的id查询该用户
2、查询该用户的所有笔记
其实还是一通增删改查。
UserController:
@GetMapping("/{id}")
public Result queryUserById(@PathVariable("id") Long userId){
return userService.queryUserById(userId);
}
UserServiceImpl:
@Override
public Result queryUserById(Long userId) {
User user = getById(userId);
if(user == null){
return Result.ok();
}
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
return Result.ok(userDTO);
}
BlogController:
@GetMapping("of/user")
public Result queryBlogByUserId(
@RequestParam(value = "current", defaultValue = "1") Integer current,
@RequestParam("id") Long id){
return blogService.queryBlogByUserId(current, id);
}
BlogServiceImpl:
@Override
public Result queryBlogByUserId(Integer current, Long id) {
//根据用户查询笔记
Page<Blog> page = query()
.eq("user_id", id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
//获取当前页信息
List<Blog> records = page.getRecords();
return Result.ok(records);
}
这个功能的实现思路也很容易,因为我们已经拥有了当前用户以及写文章用户的id,都根据id查询关注以后,求个交集即可。
为了提高性能,在Redis中选用set来做,set也拥有求交集的命令。
但是,这就有一个问题,要实现交集,我们首先要找到这两个用户对应的set集合,所以每当用户点击关注功能的时候,都应该将自己关注的这个用户id保存到Redis中去,这样会方便我们后序实现共同关注功能的查询。
每次关注的时候,关注对象除了要放在数据库中,还要放到Redis中:
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result follow(Long followUserId, Boolean isFollow) {
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
//判断当前状态是关注还是取关
if(isFollow){
//关注,新增数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
if(isSuccess){
//放入集合
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
}else{
//取关
boolean isSuccess = remove(new QueryWrapper<Follow>()
.eq("user_id", userId).eq("follow_user_id", followUserId));
if(isSuccess){
//从Redis中移出关注用户的id
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
}
return Result.ok();
}
FollowServiceImpl:
@Override
public Result followCommons(Long id) {
Long userId = UserHolder.getUser().getId();
String key1 = "follows:" + userId;
String key2 = "follows:" + id;
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);
if(intersect == null || intersect.isEmpty()){
return Result.ok(Collections.emptyList());
}
//将id解析成Long型
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
//查询用户,需要注入IuserService类,查询出来再转成DTO
List<UserDTO> users = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(users);
}
关注推送也叫Feed流,也叫投喂,为用户持续提供体验,通过无线下拉刷新获取新信息。
Feed不再是用户自行去查询内容,而是内容自动推送匹配给用户。
Feed流产品有2种常见模式:
TimeLine:不做内容筛选,简单按照发布时间排序,常用于好友或关注
(1)优点:信息全面,不会有缺失,实现简单
(2)缺点:信息噪音多,用户不一定感兴趣
智能排序:利用智能算法屏蔽违规的、用户不感兴趣的内容。推送用户感兴趣信息
(1)优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
(2)缺点:若算法不精准,可能起反作用
这里是基于好友的Feed流,所以使用TimeLine模式,要做智能排序的话要自行去研究智能算法,这个还是要花不少时间看看论文啥的。TimeLine模式的实现方案有3种:
(1)拉模式(读扩散):每个用户发完就放在发件箱,要读的时候就把所有关注的人的消息都读出来
(2)推模式(写扩散):发完笔记直接就推给每个粉丝的收件箱,收件箱做排序
(3)推拉结合:读写混合,结合两种模式的优点(活跃粉丝的用推模式,普通粉丝用拉模式)
因此,对于有大V的那类社交软件(例如围脖),最好的方式是,当一个用户发笔记的时候,要能直接发到其活跃粉丝的收件箱中,同时自己的邮件也发送到自己的发件箱中,当普通的用户要读取自己的笔记的时候再拿出来。
在这个项目里,将会使用推模式,当每个人发表探店笔记的时候,都需要推送笔记id到其粉丝的收件箱中,粉丝需要的时候就根据这个id即可查询到笔记。
我们可以利用Redis的数据结构来实现需求:
收件箱要根据时间戳排序,需要用Redis的数据结构,容易想到SortedSet。
查收件箱时,要能够分页查询,需要数据集合有角标,容易想到List。
虽然SortedSet没有分页查询需要的角标,但SortedSet却具有排名性质。又因为Feed流的数据是会不断更新的,数据角标也会变化,因此不能用传统的分页模式。
传统分页会出现重复读取的情况:
因此需要使用滚动分页查询:
因此,我们应该使用SortedSet,并且根据score来查询。
修改saveBlog的业务,使其能够推送到收件箱:
public Result saveBlog(Blog blog) {
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
boolean isSuccess = save(blog);
if(!isSuccess){
return Result.fail("新增笔记失败");
}
//查询粉丝
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
//推送id给粉丝
for (Follow follow : follows) {
//获取粉丝Id
Long userId = follow.getUserId();
//推送到粉丝的收件箱(SortedSet)
String key = "feed:" + userId;
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
}
return Result.ok(blog.getId());
}
再发一条,粉丝可以在收件箱中看到:
已经推送,现在需要粉丝来查询。需要再熟悉一下SortedSet的一些相关命令,可以看我之前的文章:
Redis常见命令
如果我们根据角标来查询(ZREVRANGE),就会出现重复查询的情况,因此应该要以分数来查询(ZRANGEBYSCORE),下一页的查询直接根据上一页查询中分数的最小值的角标位置即可。
ZREVRANGEBYSCORE key MAX_SOCRE MIN_SOCRE WITHSCORES LIMIT L R
表示查询分数区间在(MAX_SCORE, MIN_SOCRE]的元素中的[L, R)之间的元素
由于时间戳可能是会有一样的,所以查询出来的结果,要查询起来还得用(L,R]来跳过,根据业务,可以将滚动分页查询设置为:
MAX_SOCRE:上一次查询的最小时间戳 | 当前时间戳(若是第一页的话,当前时间戳一定会大于容器内所有score)
MIN_SCORE:0
offset:在上一次的结果中,与最小值一样的元素的个数 | 0(第一页第一次查询)
count:固定的指定查几条
在个人主页的“关注”中,携带上一次查询的最小时间戳与偏移量,使用GET查询推送的Blog,并返回List<Blog>、本次查询的最小时间戳以及偏移量。
1、封装一下返回值:
@Data
public class ScrollResult {
private List<?> list;//定义成泛型
private Long minTime;
private Integer offset;
}
BlogController:
@GetMapping("/of/follow")
public Result queryBlogOfFollow(
@RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset){
return blogService.queryBlogOfFollow(max, offset);
}
BlogServiceImpl:
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
//获取当前用户,查询其收件箱
Long userId = UserHolder.getUser().getId();
String key = FEED_KEY + userId;//FEED_KEY = "feed:"
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if(typedTuples == null || typedTuples.isEmpty()){
return Result.ok();
}
//解析数据,blogId、minTime、offset
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0;
int os = 1;//数一下最小值的个数
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
//获取BlogId
String idStr = tuple.getValue();
ids.add(Long.valueOf(idStr));
//获取分数(时间戳)
long time = tuple.getScore().longValue();
if(time == minTime) os++;
else{
minTime = time;
os = 1;
}
}
//查询blog,因为mysql会用IN来查询,所以需要指定OrderBY
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
//blog要带上自己是否点赞过的信息
for (Blog blog : blogs) {
//查询blog相关用户
queryBlogUser(blog);
//查询blog是否被点赞
isBlogLiked(blog);
}
//封装返回
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os);
r.setMinTime(minTime);
return Result.ok(r);
}
这时候关注的人再发一条笔记,往下滚动看不见,但是网上滚动即可刷新,然后发现这一条: