社交功能与Feed流
# 社交功能与Feed流
# 7. 你是怎么实现发送笔记和点赞的功能?
# 1. 发布笔记
设置数据库的时候设置了用户 id,但是发布笔记的时候,需要用户姓名、图标等信息。这个时候在实体类就需要加上几个信息,用 @TableField(exist = false) 注解,标识数据库没有的字段。
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店博文
blogService.save(blog);
// 返回id
return Result.ok(blog.getId());
}
2
3
4
5
6
7
8
9
10
还有一个上传图片,也是发送一个请求。我这里只是放在本地,实际开发中图片一般会放在 Nginx 上或者是云存储上。
# 2. 查看笔记
在 Service 类中创建对应方法之后,在 Impl 类中实现。我们查看用户探店笔记的时候,需要额外设置用户名和其头像,由于设置用户信息这个操作比较通用,所以这里封装成了一个方法。
@Override
public Result queryById(Integer id) {
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在或已被删除");
}
queryBlogUser(blog);
return Result.ok(blog);
}
private void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 3. 点赞功能
需求:
- 同一个用户只能对同一篇笔记点赞一次,再次点击则取消点赞
- 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段 Blog 类的 isLike 属性)
实现步骤:
- 修改点赞功能,利用 Redis 中的 set 集合来判断是否点赞过,未点赞则点赞数 +1,已点赞则点赞数 -1
- 修改根据 id 查询的业务,判断当前登录用户是否点赞过,赋值给 isLike 字段
- 修改分页查询 Blog 业务,判断当前登录用户是否点赞过,赋值给 isLike 字段
点赞时会将当前登录用户的 ID 存入对应博客的 set 集合中,这个 set 集合中的元素即为点赞该博客的所有用户 ID。因此,在存入 set 集合中时,key 的格式为 blog:liked:id,其中 id 为博客的 ID;value 的值为点赞该博客的用户 ID。这样,在查询博客时,可以使用 RedisTemplate 从该 set 集合中查找当前登录用户的 ID 是否存在,以判断用户是否点赞了该博客。
# 4. 点赞排行榜
当我们点击探店笔记详情页面时,应该按点赞顺序展示点赞用户,比如显示最早点赞的 TOP5,形成点赞排行榜,就跟 QQ 空间发的说说一样,可以看到有哪些人点了赞。
之前的点赞是放到 Set 集合中,但是 Set 集合不能排序,所以这个时候,我们就可以改用 SortedSet(Zset)。
集合区别对比:
| List | Set | SortedSet | |
|---|---|---|---|
| 排序方式 | 按添加顺序排序 | 无法排序 | 根据 score 值排序 |
| 唯一性 | 不唯一 | 唯一 | 唯一 |
| 查找方式 | 按索引查找或首尾查找 | 根据元素查找 | 根据元素查找 |
@Override
public Result likeBlog(Long id) {
// 1. 获取当前用户信息
Long userId = UserHolder.getUser().getId();
// 2. 如果当前用户未点赞,则点赞数 +1,同时将用户加入set集合
String key = BLOG_LIKED_KEY + id;
// 尝试获取score
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
// 为null,则表示集合中没有该用户
if (score == null) {
// 点赞数 +1
boolean success = update().setSql("liked = liked + 1").eq("id", id).update();
// 将用户加入set集合
if (success) {
stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
}
// 3. 如果当前用户已点赞,则取消点赞,将用户从set集合中移除
} else {
// 点赞数 -1
boolean success = update().setSql("liked = liked - 1").eq("id", id).update();
if (success) {
// 从set集合移除
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
}
return Result.ok();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
这是博客系统点赞功能的优化版本实现代码。与之前使用 set 数据结构不同的是,这里使用了 Redis 的 zset 数据结构来存储点赞用户。
zset 是一个有序的、不重复的元素集合,能够存储元素和元素对应的分值(在博客系统中使用分值记录用户点赞时间),可以使用 RedisTemplate 来操作 zset 集合。在这个实现版本中,点赞时会将当前登录用户的 ID 和当前时间的毫秒值存入 zset 集合中,这个 zset 集合中的每一个元素都是一个用户的 ID 和用户点赞的时间。因此,尝试获取 score 时就可以判断当前用户是否已经点赞了该博客以及点赞时间。
在存入 zset 集合时,key 的格式为 blog:liked:id,其中 id 为博客的 ID;value 的值为点赞该博客的用户 ID;score 的值为点赞该博客的时间戳。
在查询博客时,可以使用 RedisTemplate 从该 zset 集合中查找当前登录用户的 ID 是否存在,以及对应的 score 是否过期。如果已经点赞并且未过期,则将对应的结果设置到博客对象的 isLike 属性中。
# 8. 你是怎么实现好友关注和粉丝功能的?
# 1. 关注和取消关注
当我们进入到笔记详情页面时,会发送一个请求,判断当前登录用户是否关注了笔记博主:
- 请求网址:
http://localhost:8080/api/follow/or/not/2 - 请求方法:GET
当我们点击关注按钮时,会发送一个请求,实现关注/取关:
- 请求网址:
http://localhost:8080/api/follow/2/true - 请求方法:PUT
Controller:
@RestController
@RequestMapping("/follow")
public class FollowController {
@Resource
private IFollowService followService;
// 判断当前用户是否关注了该博主
@GetMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId) {
return followService.isFollow(followUserId);
}
// 实现取关/关注
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {
return followService.follow(followUserId, isFollow);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
FollowServiceImpl:
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {
@Override
public Result isFollow(Long followUserId) {
// 获取当前登录的userId
Long userId = UserHolder.getUser().getId();
LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
// 查询当前用户是否关注了该笔记的博主
queryWrapper.eq(Follow::getUserId, userId).eq(Follow::getFollowUserId, followUserId);
// 只查询一个count就行了
int count = this.count(queryWrapper);
return Result.ok(count > 0);
}
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 获取当前用户id
Long userId = UserHolder.getUser().getId();
// 判断是否关注
if (isFollow) {
// 关注,则将信息保存到数据库
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
save(follow);
} else {
// 取关,则将数据从数据库中移除
LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Follow::getUserId, userId).eq(Follow::getFollowUserId, followUserId);
remove(queryWrapper);
}
return Result.ok();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
首先,通过 UserHolder 类获取当前登录用户的信息。
在查询是否关注时,使用 LambdaQueryWrapper 封装查询条件,查询当前用户是否关注了指定的用户。如果查询结果数量大于 0,则表示当前用户已经关注了该用户,否则未关注该用户。
在关注和取关时,根据传入的 isFollow 参数来判断当前用户的操作,并将关注和取关的信息保存到数据库中,并使用 Redis 来缓存关注用户的信息。这里为了方便,未对 Redis 缓存的数据进行有效期管理,当数据发生变化时需要对 Redis 缓存进行更新。
# 2. 共同关注
- 查看共同关注请求网址:
http://localhost:8080/api/follow/common/undefined - 请求方法:GET
- 查看用户自己的笔记并分页请求网址:
http://localhost:8080/api/blog/of/user?&id=2¤t=1 - 请求方法:GET
@GetMapping("/of/user")
public Result queryBlogByUserId(@RequestParam(value = "current", defaultValue = "1") Integer current, @RequestParam("id") Long id) {
LambdaQueryWrapper<Blog> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Blog::getUserId, id);
Page<Blog> pageInfo = new Page<>(current, SystemConstants.MAX_PAGE_SIZE);
blogService.page(pageInfo, queryWrapper);
List<Blog> records = pageInfo.getRecords();
return Result.ok(records);
}
2
3
4
5
6
7
8
9
实现方式:
在 set 集合中,有交集、并集、补集的 API,可以把二者关注的人放入到 set 集合中,然后通过 API 查询两个 set 集合的交集。
所以,在关注博主的同时,需要将数据放到 set 集合中,方便后期我们实现共同关注。当取消关注时,也需要将数据从 set 集合中删除。
@Override
public Result followCommons(Long id) {
// 获取当前用户id
Long userId = UserHolder.getUser().getId();
String key1 = "follows:" + id;
String key2 = "follows:" + userId;
// 对当前用户和博主用户的关注列表取交集
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);
if (intersect == null || intersect.isEmpty()) {
// 无交集就返回个空集合
return Result.ok(Collections.emptyList());
}
// 将结果转为list
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
// 之后根据ids去查询共同关注的用户,封装成UserDto再返回
List<UserDTO> userDTOS = userService.listByIds(ids).stream().map(user ->
BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());
return Result.ok(userDTOS);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 3. Feed 流实现方式
Feed 流的实现有两种模式:
- Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注(B 站关注的 up,朋友圈等)
- 优点:信息全面,不会有缺失,并且实现也相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
- 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容,推送用户感兴趣的信息来吸引用户
- 优点:投喂用户感兴趣的信息,用户粘度很高,容易沉迷
- 缺点:如果算法不精准,可能会起到反作用(给你推的你都不爱看)
这里针对好友的操作,采用的是 Timeline 方式,只需要拿到我们关注用户的信息,然后按照时间排序即可。
采用 Timeline 模式,有三种具体的实现方案:
- 拉模式
- 推模式
- 推拉结合
# 拉模式(读扩散)
该模式的核心含义是:当张三和李四、王五发了消息之后,都会保存到自己的发件箱中。如果赵六要读取消息,那么他会读取他自己的收件箱,此时系统会从他关注的人群中,将他关注人的信息全都进行拉取,然后进行排序。
- 优点:比较节约空间,因为赵六在读取信息时,并没有重复读取,并且读取完之后,可以将他的收件箱清除
- 缺点:有延迟,当用户读取数据时,才会去关注的人的发件箱中拉取信息。假设该用户关注了海量用户,那么此时就会拉取很多信息,对服务器压力巨大
# 推模式(写扩散)
推模式是没有写邮箱的,当张三写了一个内容,此时会主动把张三写的内容发送到它粉丝的收件箱中。假设此时李四再来读取,就不用再去临时拉取了。
- 优点:时效快,不用临时拉取
- 缺点:内存压力大,假设一个大 V 发了一个动态,很多人关注他,那么就会写很多份数据到粉丝那边去
# 推拉结合(读写混合)
推拉模式是一个折中的方案,兼具推和拉两种模式的优点。
站在发件人这一边,如果是普通人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝收件箱中,因为普通人的粉丝数量较少,所以这样不会产生太大压力。但如果是大 V,那么他是直接将数据写入一份到发件箱中去,再直接写一份到活跃粉丝的收件箱中。
站在收件人这边来看,如果是活跃粉丝,那么大 V 和普通人发的都会写到自己的收件箱里;但如果是普通粉丝,由于上线不是很频繁,所以等他们上线的时候,再从发件箱中去拉取信息。
# 4. 推送到粉丝收件箱(Feed 流分页)
我这里用的是推模式,因为并没有那么多数据。
需求:
- 修改新增探店笔记的业务,在保存 blog 到数据库的同时,推送到粉丝的收件箱
- 收件箱满足可以根据时间戳排序,必须使用 Redis 的数据结构实现
- 查询收件箱数据时,可实现分页查询
注意:Feed 流中的数据会不断更新,所以数据的角标也会不断变化,所以我们不能使用传统的分页模式。
假设在 t1 时刻,我们取读取第一页,此时 page = 1,size = 5,那么我们拿到的就是 10~6 这几条记录。假设 t2 时刻有发布了一条新纪录,那么在 t3 时刻,我们来读取第二页,此时 page = 2,size = 5,那么此时读取的数据是从 6 开始的,读到的是 6~2,那么我们就读到了重复的数据。所以我们要使用 Feed 流的分页,不能使用传统的分页。
Feed 流的滚动分页:
- 我们需要记录每次操作的最后一条,然后从这个位置去开始读数据。
- 举个例子:我们从 t1 时刻开始,拿到第一页数据,拿到了 10~6,然后记录下当前最后一次读取的记录,就是 6。t2 时刻发布了新纪录,此时这个 11 在最上面,但不会影响我们之前拿到的 6。此时 t3 时刻来读取第二页,第二页读数据的时候,从 6-1=5 开始读,这样就拿到了 5~1 的记录。我们在这个地方可以使用 SortedSet 来做,使用时间戳来充当表中的 1~10。
核心思路:我们保存完探店笔记后,获取当前用户的粉丝列表,然后将数据推送给粉丝。
@Override
public Result saveBlog(Blog blog) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店博文
save(blog);
// 条件构造器
LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
// 从follow表中,查找当前用户的粉丝 select * from follow where follow_user_id = user_id
queryWrapper.eq(Follow::getFollowUserId, user.getId());
// 获取当前用户的粉丝
List<Follow> follows = followService.list(queryWrapper);
for (Follow follow : follows) {
Long userId = follow.getUserId();
String key = FEED_KEY + userId;
// 推送数据
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
}
// 返回id
return Result.ok(blog.getId());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 5. 实现滚动分页查询收件箱
- 需求:在个人主页的关注栏中,查询并展示推送的 Blog 信息
- 具体步骤如下:
- 每次查询完成之后,我们要分析出查询出的最小时间戳,这个值会作为下一次的查询条件
- 我们需要找到与上一次查询相同的查询个数,并作为偏移量。下次查询的时候,跳过这些查询过的数据,拿到我们需要的数据(例如时间戳 8 6 6 5 5 4,我们每次查询 3 个,第一次是 8 6 6,此时最小时间戳是 6,如果不设置偏移量,会从第一个 6 之后开始查询,那么查询到的就是 6 5 5,而不是 5 5 4)
- 综上:我们的请求参数中需要携带 lastId 和 offset,即上一次查询时的最小时间戳和偏移量,这两个参数
具体看视频:https://www.bilibili.com/video/BV1cr4y1671t?p=87 (opens new window)



