本博客为个人学习笔记,学习网站与详细见:黑马程序员Redis入门到实战 P78 - P87
目录
达人探店
发布探店笔记
需求:实现以下上传图片与发布笔记两个接口。
上传图片接口代码如下。
@Slf4j
@RestController
@RequestMapping("upload")
public class UploadController {
@PostMapping("blog")
public Result uploadImage(@RequestParam("file") MultipartFile image) {
try {
// 获取原始文件名称
String originalFilename = image.getOriginalFilename();
// 生成新文件名
String fileName = createNewFileName(originalFilename);
// 保存文件
image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
// 返回结果
log.debug("文件上传成功,{}", fileName);
return Result.ok(fileName);
} catch (IOException e) {
throw new RuntimeException("文件上传失败", e);
}
}
}
发布笔记接口代码如下。
@RestController
@RequestMapping("/blog")
public class BlogController {
@Resource
private IBlogService blogService;
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
//获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUpdateTime(user.getId());
//保存探店博文
blogService.saveBlog(blog);
//返回id
return Result.ok(blog.getId());
}
}
查看探店笔记
接口方法的具体实现如下。
@Override
public Result queryBlogById(Long id) {
// 1.查询blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在");
}
// 2.为blog添加作者信息
queryBlogUser(blog);
return Result.ok(blog);
}
点赞功能
接口如下图所示。
需求如下。
1. 同一个用户对同一篇笔记只能点赞一次,再次点击则视为取消点赞;
2. 如果当前用户已经点赞笔记,则点赞按钮高亮显示(前端通过判断Blog类的isLike属性实现高亮功能,因此后端需要为Blog类的isLike属性赋值);
实现步骤如下。
1. 给Blog类中添加一个isLike字段,表示是否被当前用户点赞,前端可根据该字段实现高亮功能;
2. 修改点赞功能,利用Redis的set集合判断笔记是否被当前用户点赞过,若未点赞过则点赞数+1,若已点赞过则点赞数-1;
3. 修改根据id查询Blog的业务:在查询到Blog后,判断当前用户是否点赞过,为Blog的isLike字段赋值;
4. 修改分页查询Blog业务:在查询到Blog后,判断当前登录用户是否点赞过,为每个Blog的isLike字段赋值。
1. 给Blog类中添加一个isLike字段
@TableField(exist = false)
private Boolean isLike;
2.全部代码如下
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Resource
private IUserService userService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override //分页查询
public Result queryHotBlog(Integer current) {
// 根据用户查询
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
// 查询用户
records.forEach(blog -> {
this.queryBlogUser(blog);
this.isBlogLiked(blog);
});
return Result.ok(records);
}
@Override
public Result queryBlogById(Long id) {
// 1.查询blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在");
}
// 2.为blog添加作者信息
queryBlogUser(blog);
// 3.查询当前blog是否被点赞
isBlogLiked(blog);
return Result.ok(blog);
}
//为Blog的isLike字段赋值
private void isBlogLiked(Blog blog) {
// 1.获取当前登录用户
UserDTO user = UserHolder.getUser();
if (user == null) {
//若用户未登录,则返回空
return;
}
Long userId = user.getId();
// 2.判断当前登录用户是否已经点赞
String key = "blog:liked:" + blog.getId();
Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
blog.setIsLike(BooleanUtil.isTrue(isMember));
}
@Override //点赞接口方法具体实现
public Result likeBlog(Long id) {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.判断当前登录用户是否已经点赞
String key = "blog:liked:" + id;
Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
// 3.如果未点赞
if (BooleanUtil.isFalse(isMember)) {
// 3.1数据库点赞数+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
// 3.2保存用户到Redis的 set集合
if (isSuccess)
stringRedisTemplate.opsForSet().add(key, userId.toString());
} else {
// 4.如果已点赞
// 4.1数据库点赞数-1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
// 4.2将该用户从Redis的set集合中移除
if (isSuccess)
stringRedisTemplate.opsForSet().remove(key, userId.toString());
}
return Result.ok();
}
//为笔记添加作者信息,以便前端展示
private void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
}
点赞排行榜
需求与接口信息如下。
分析:在点赞功能的实现中,我们使用的是set集合,但是set集合没有排序功能,因此,我们需要采用一个可以排序的set集合,即sortedSet集合,用时间戳作为分数。
redis各种集合对比如下。
代码修改与添加如下。
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Resource
private IUserService userService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override //分页查询
public Result queryHotBlog(Integer current) {
// 根据用户查询
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
// 查询用户
records.forEach(blog -> {
this.queryBlogUser(blog);
this.isBlogLiked(blog);
});
return Result.ok(records);
}
@Override
public Result queryBlogById(Long id) {
// 1.查询blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在");
}
// 2.为blog添加作者信息
queryBlogUser(blog);
// 3.查询当前blog是否被点赞
isBlogLiked(blog);
return Result.ok(blog);
}
//为Blog的idLike字段赋值
private void isBlogLiked(Blog blog) {
// 1.获取当前登录用户
UserDTO user = UserHolder.getUser();
if (user == null) {
//若用户未登录,则返回空
return;
}
Long userId = user.getId();
// 2.判断当前登录用户是否已经点赞
String key = "blog:liked:" + blog.getId();
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
blog.setIsLike(score != null);
}
@Override
//点赞接口方法具体实现
public Result likeBlog(Long id) {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.判断当前登录用户是否已经点赞
String key = "blog:liked:" + id;
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
// 3.如果未点赞
if (score == null) {
// 3.1数据库点赞数+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
// 3.2保存用户到Redis的 set集合
if (isSuccess)
stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
} else {
// 4.如果已点赞
// 4.1数据库点赞数-1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
// 4.2将该用户从Redis的set集合中移除
if (isSuccess)
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
return Result.ok();
}
@Override //点赞排行榜
public Result queryBlogLikes(Long id) {
String key = "blog:liked:" + id;
// 1.查询top5的点赞用户
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (top5 == null || top5.isEmpty()) { //如果没有用户点赞该Blog,则返回空集合
return Result.ok(Collections.emptyList());
}
// 2.解析出top5中的用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
// 3.根据用户id查询用户
List<UserDTO> userDTOS = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
// 4.返回
return Result.ok(userDTOS);
}
//为笔记添加作者信息,以便前端展示
private void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
}
代码修改后,在测试时发现bug,即点赞排行榜中用户排序并没有按照预期的时间先后顺序排序。原因是我们使用的查询是MP的listByIds(ids),其底层逻辑是用IN关键字查询,而IN查询是不会按照ids中元素的顺序排序的,因此我们需要添加order by自定义查询结果顺序。
修改后代码如下。
@Override //点赞排行榜
public Result queryBlogLikes(Long id) {
String key = "blog:liked:" + id;
// 1.查询top5的点赞用户
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (top5 == null || top5.isEmpty()) { //如果没有用户点赞该Blog,则返回空集合
return Result.ok(Collections.emptyList());
}
// 2.解析出top5中的用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
// 3.根据用户id查询用户
String idStr = StrUtil.join(",", ids);
List<UserDTO> userDTOS = userService.query()
.in("id", ids).last("ORDER BY FIELD(id," + idStr + ")")
.list()
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
// 4.返回
return Result.ok(userDTOS);
}
好友关注
关注与取关
接口信息如下。
Controller层代码如下。
@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 follow(@PathVariable("id") Long followUserId) {
return followService.isFollow(followUserId);
}
}
Service层代码如下。
//接口类代码
public interface IFollowService extends IService<Follow> {
Result follow(Long followUserId, Boolean isFollow);
Result isFollow(Long followUserId);
}
//实现类代码
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 1.获取当前登录用户id
Long userID = UserHolder.getUser().getId();
// 2.判断是关注还是取关
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("userId", userId).eq("follow_user_id", followUserId).count();
return Result.ok(count > 0);
}
}
共同关注
需求:利用Redis中恰当的数据结构,实现共同关注功能。在博主个人页面展示出当前用户与博主的共同关注用户。
Redis的set集合为我们提供了交集并集补集的api,我们可以为每个用户提供一个set集合,并将各用户关注的用户存入到各自的set集合中。当我们需要找出两个用户的共同关注时,我们只需要对这两个用户的set集合求并集即可。
因此,我们首先需要对关注与取关的代码进行修改,在关注与取关的同时,需要同步redis的set集合数据,修改代码如下。
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 1.获取当前登录用户id
Long userID = UserHolder.getUser().getId();
String key = "follows:" + userID;
// 2.判断是关注还是取关
if (isFollow) {
// 关注,新增数据
Follow follow = new Follow();
follow.setUserId(userID);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
// 同时将关注信息保存到redis的set集合中
if(isSuccess)
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
} else {
boolean isSuccess = remove(new QueryWrapper<Follow>()
.eq("user_id", userID).
eq("follow_user_id", followUserId));
// 同时将关注信息从redis的set集合中删除
if(isSuccess)
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
return Result.ok();
}
共同关注接口方法具体实现代码如下。
@Override
public Result followCommons(Long id) {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.求交集
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());
// 3.解析id集合
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
// 4.查询用户并转换成UTO
List<UserDTO> users = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(users);
}
关注推送
当一个用户发布了笔记,我们会将该笔记推送给他的粉丝,这个需求我们称为Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。
Timeline
该模式的核心含义是:当张三、李四和王五发了动态后,会将动态信息保存到自己的发件箱中。如果赵六要读取信息,那么他会读取自己收件箱中的信息,此时系统会从他关注的人群中,把他关注人的动态信息全部拉取到收件箱,然后再进行排序。
优点:节省空间,因为赵六在查看信息时不会重复获取,而且查看完后可以清理收件箱。
缺点:存在延迟,只有当用户读取数据时才进行信息拉取。如果用户关注了大量博主,会导致服务器在拉取大量内容时承受巨大压力。
推模式不存在发件箱,当张三发了动态后,系统会主动将动态推送到其粉丝的收件箱中。当粉丝读取动态时,将不再需要临时拉取数据。
优点:响应速度快,无需临时拉取数据。
缺点:内存压力大,例如一个知名用户发布信息时,由于粉丝众多,系统需要将大量数据写入粉丝的收件箱。
推拉模式是一个折中的解决方案。从发件博主的角度来看,对于普通博主,系统采用写扩散的方式,直接将动态信息写入他们粉丝的收件箱,因为普通博主的粉丝数量少,因此这样做不会造成太大压力。对于大V博主,系统会先将动态信息写入他们的发件箱,然后再写入到活跃粉丝的收件箱。现在从粉丝的角度来看,对于活跃粉丝,无论是大V博主还是普通博主发布的内容都会直接写入到他们的收件箱。而对于普通粉丝,由于他们上线不频繁,所以系统会等到他们上线时,再从他们关注博主的发件箱中临时拉取信息。
具体实现
本案例我们采用推模式实现Feed流推送,即博主发布笔记时,会自动将该笔记推送到其粉丝的收件箱中,当粉丝查看自己关注博主的笔记时,程序会在该粉丝的收件箱中查询笔记并返回前端进行展示。
我们使用Feed流的滚动分页代替传统分页。我们需要记录每次操作获取到的最后一条笔记,然后下次获取数据时从这条笔记以下的数据开始读取。如下图所示,举例来说,从t1时刻开始,我们获取第一页数据,例如10到6的笔记,然后记录下当前页的最后一条笔记,即6。在t2时刻,博主发布了新的记录,笔记11出现在最顶上,但不影响我们后续的数据获取。在t3时刻,开始获取第二页数据,根据之前记录的笔记6,我们从6后面的笔记5开始获取,从而得到了5到1的笔记数据。我们可以利用sortedSet来实现这个功能,它支持sorce范围查询,我们可以为每条笔记的sorce赋值时间戳,从而实现滚动分页。
首先对发布笔记的代码进行修改如下,在博主发布笔记后,需要将该笔记推送到其粉丝的收件箱中。
@Override
public Result saveBlog(Blog 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推送给粉丝的收件箱(zset)
String key = "feed:" + userId;
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
}
return Result.ok(blog.getId());
}
我们采用的是按分数查询,而不是按角标查询,查询命令如下图所示。
注意!!!:分数范围查询是从第一个socre值与最大值参数相同的数据开始查询的。
举例:设查询条数为3,偏移量固定为0,数据的Value值与score值如下:
m1、8
m2、6
m3、6
m4、6
m5、3
m6、2
第一次查询到的3条数据为m1、m2、m3,得到的最小时间戳为6
第二次查询时,我们将score最大值参数设置为上一次查询结果的最小时间戳,即6,由于偏移量为0,因此查询会从第一个score值等于6的数据开始查询,得到的结果是m2、m3、m6,此时查询到的部分数据与第一次查询得到的数据重复。因此我们应该在查询时设置偏移量,而偏移量的值应该为上一次查询结果中,时间戳与最小时间戳相同的笔记数量。
上一次查询结果为m1、m2、m3,最小时间戳为6,因此查询结果中时间戳与最小时间戳相同的笔记为m2、m3,数量为2。因此第二次查询命令的偏移量参数为2。当第二次查询偏移量参数为2时,起点将从m2向下偏移两位到m4,于是第2次查询得到的结果为m4、m5、m6,查询结果不再与第一次查询得到的结果重复。具体参数填写如下。
参数 | 说明 |
score最大值 | 查询第一页:传入当前时间戳 |
查询其它页:传入上一页查询得到的笔记中最小的时间戳 | |
score最小值 | 固定为0 |
偏移量 | 上一页查询得的笔记中,时间戳与最小时间戳相同的笔记数量 |
查询条数 | 自定义 |
具体操作如下:
1、每次查询完成后,我们需要分析出查询到的笔记的最小时间戳,这个值会作为下一次查询的条件;
2、我们需要在上一次查询结果中,计算时间戳与最小时间戳相同的笔记数量,该数量将作为下一次查询的偏移量值。
综上:我们的请求参数中需要携带lastId和offset,即上一次查询到的最小时间戳和偏移量这两个参数。这两个参数在第一次查询时会由前端来指定(即当前时间戳和0),之后的查询会根据后台结果作为条件,再次传递到后台。
首先定义返回结果类如下。
@Data
public class ScrollResult {
private List<?> list;
private Long minTime;
private Integer offset;
}
Controller层代码
@GetMapping
public Result queryBlogOfFollow(@RequestParam("lastId") Long max,
@RequestParam(value = "offset", defaultValue = "0") Integer offset) {
return blogService.queryBlogOfFollow(max, offset);
}
接口方法的具体实现如下
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1.获取当前用户
Long userId = UserHolder.getUser().getId();
// 2.查询收件箱 ZREVRANGEBYSCORE Key Max Min LIMIT offset count
String key = "feed:" + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 3);
// 3.非空判断
if (typedTuples == null || typedTuples.isEmpty())
return Result.ok();
// 4.解析数据:blogId、minTime(时间戳)、offset
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0; //用于记录最小分数(时间戳)
int off = 1; //计算typedTuples中分数与最小分数值相同的笔记个数
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
// 4.1 获取id
ids.add(Long.valueOf(tuple.getValue()));
// 4.2 获取分数(时间戳)
long time = tuple.getScore().longValue();
if (time == minTime)
off++;
else {
minTime = time;
off = 1;
}
}
// 5.根据id查询blog
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
for (Blog blog : blogs) {
// 5.1.为blog添加作者信息
queryBlogUser(blog);
// 5.2.查询当前blog是否被点赞
isBlogLiked(blog);
}
// 6.封装并返回
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(off);
r.setMinTime(minTime);
return Result.ok(r);
}