四、达人探店
1. 发布探店笔记
探店笔记类似于点评网站的评价,往往是图文结合。对应的表有两个:
- tb_blog:探店笔记表,包含笔记中的标题、文字、图片等
- tb_blog_comments:其他用户对探店笔记的评价
步骤①:根据个人情况修改路径


注:建议把nginx.conf文件里的负载均衡删了,重新加载配置


案例:实现查看发布探店笔记的接口
需求:点击首页的探店笔记,会进入详情页面,实现该页面的查询接口:

实现步骤:
①BlogController
java
package com.hmdp.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.Blog;
import com.hmdp.entity.User;
import com.hmdp.service.IBlogService;
import com.hmdp.service.IUserService;
import com.hmdp.utils.SystemConstants;
import com.hmdp.utils.UserHolder;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;
/**
* <p>
* 前端控制器
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@RestController
@RequestMapping("/blog")
public class BlogController {
@Resource
private IBlogService blogService;
// ... ...
@GetMapping("/hot")
public Result queryHotBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
return blogService.queryHotBlog(current);
}
/**
* 查询blog详情
* @param id
* @return
*/
@GetMapping("/{id}")
public Result queryBlogById(@PathVariable("id") Long id) {
return blogService.queryBlogById(id);
}
}
②IBlogService
java
package com.hmdp.service;
import com.hmdp.dto.Result;
import com.hmdp.entity.Blog;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* <p>
* 服务类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
public interface IBlogService extends IService<Blog> {
Result queryHotBlog(Integer current);
Result queryBlogById(Long id);
}
③BlogServiceImpl
java
package com.hmdp.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
import com.hmdp.entity.Blog;
import com.hmdp.entity.User;
import com.hmdp.mapper.BlogMapper;
import com.hmdp.service.IBlogService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.service.IUserService;
import com.hmdp.utils.SystemConstants;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Resource
private IUserService userService;
@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(this::queryBlogUser);
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. 结果返回
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. 点赞
在首页的探店笔记排行榜和探店图文详情页都有点赞的功能:

案例:完善点赞功能
需求:
- 同一个用户只能点赞一次,再次点击则取消点赞
- 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)
实现步骤:
①给Blog类中添加一个isLike字段,标示是否被当前用户点赞
②修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞则点赞数+1,已点赞则点赞数-1
③修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
④修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
①BlogController
java
package com.hmdp.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.Blog;
import com.hmdp.entity.User;
import com.hmdp.service.IBlogService;
import com.hmdp.service.IUserService;
import com.hmdp.utils.SystemConstants;
import com.hmdp.utils.UserHolder;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;
@RestController
@RequestMapping("/blog")
public class BlogController {
@Resource
private IBlogService blogService;
@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
return blogService.likeBlog(id);
}
@GetMapping("/hot")
public Result queryHotBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
return blogService.queryHotBlog(current);
}
/**
* 查询blog详情
* @param id
* @return
*/
@GetMapping("/{id}")
public Result queryBlogById(@PathVariable("id") Long id) {
return blogService.queryBlogById(id);
}
}
②IBlogService
java
package com.hmdp.service;
import com.hmdp.dto.Result;
import com.hmdp.entity.Blog;
import com.baomidou.mybatisplus.extension.service.IService;
public interface IBlogService extends IService<Blog> {
Result queryHotBlog(Integer current);
Result queryBlogById(Long id);
Result likeBlog(Long id);
}
③BlogServiceImpl
java
package com.hmdp.service.impl;
import cn.hutool.core.util.BooleanUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
import com.hmdp.entity.Blog;
import com.hmdp.entity.User;
import com.hmdp.mapper.BlogMapper;
import com.hmdp.service.IBlogService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.service.IUserService;
import com.hmdp.utils.RedisConstants;
import com.hmdp.utils.SystemConstants;
import com.hmdp.utils.UserHolder;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Resource
private IUserService userService;
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 分页查询热点笔记
* @param current
* @return
*/
@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);
}
/**
* 查询笔记详情
* @param id
* @return
*/
@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);
// 4. 结果返回
return Result.ok(blog);
}
private void isBlogLiked(Blog blog) {
// 1. 获取登录用户
Long userId = UserHolder.getUser().getId();
// 2. 判断当前登录用户是否已经点赞
String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();
Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
blog.setIsLike(BooleanUtil.isTrue(isMember));
}
private void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
/**
* 点赞或取消点赞笔记
* @param id
* @return
*/
@Override
public Result likeBlog(Long id) {
// 1. 获取登录用户
Long userId = UserHolder.getUser().getId();
// 2. 判断当前登录用户是否已经点赞
String key = RedisConstants.BLOG_LIKED_KEY + id;
Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
if (BooleanUtil.isFalse(isMember)) {
// 3. 如果未点赞,可以点赞
// 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());
}
}
// 5. 结果返回
return Result.ok();
}
}
注:@Resource按名称匹配,@Autowired按类型匹配
3. 点赞排行榜
在探店笔记的详情页面,应该把给该笔记点赞的人显示出来,比如最早点赞的TOP5,形成点赞排行榜:

|------|------------|--------|---------------|
| | List | Set | SortedSet |
| 排序方式 | 按添加顺序排序 | 无法排序 | 根据score值排序 |
| 唯一性 | 不唯一 | 唯一 | 唯一 |
| 查找方式 | 按索引查找或首尾查找 | 根据元素查找 | 根据元素查找 |
①修改之前的点赞逻辑,以及笔记查询逻辑
java
package com.hmdp.service.impl;
import cn.hutool.core.util.BooleanUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
import com.hmdp.entity.Blog;
import com.hmdp.entity.User;
import com.hmdp.mapper.BlogMapper;
import com.hmdp.service.IBlogService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.service.IUserService;
import com.hmdp.utils.RedisConstants;
import com.hmdp.utils.SystemConstants;
import com.hmdp.utils.UserHolder;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Resource
private IUserService userService;
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 分页查询热点笔记
* @param current
* @return
*/
@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);
}
/**
* 查询笔记详情
* @param id
* @return
*/
@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);
// 4. 结果返回
return Result.ok(blog);
}
private void isBlogLiked(Blog blog) {
// 1. 获取登录用户
Long userId = UserHolder.getUser().getId();
// 2. TODO 判断当前登录用户是否已经点赞
String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
blog.setIsLike(score != null);
}
private void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
/**
* 点赞或取消点赞笔记
* @param id
* @return
*/
@Override
public Result likeBlog(Long id) {
// 1. 获取登录用户
Long userId = UserHolder.getUser().getId();
// 2. TODO 判断当前登录用户是否已经点赞
String key = RedisConstants.BLOG_LIKED_KEY + id;
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
if (score == null) {
// 3. 如果未点赞,可以点赞
// 3.1 数据库点赞数 + 1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
// 3.2 TODO 保存用户到Redis的zset集合 zadd key value score
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的zset集合移除
if (isSuccess) {
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
}
// 5. 结果返回
return Result.ok();
}
}
②BlogController
java
/**
* 查询笔记的点赞用户列表TOP5
* @param id
* @return
*/
@GetMapping("/likes/{id}")
public Result queryBlogLikes(@PathVariable("id") Long id) {
return blogService.queryBlogLikes(id);
}
③IBlogService
java
Result queryBlogLikes(Long id);
④BlogServiceImpl
java
/**
* 查询笔记的点赞用户列表TOP5
*
* @param id
* @return
*/
@Override
public Result queryBlogLikes(Long id) {
// 1. 查询top5的点赞用户 zrange key 0 4
String key = RedisConstants.BLOG_LIKED_KEY + id;
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (top5 == null || top5.isEmpty()) {
return Result.ok(Collections.emptyList());
}
// 2. 解析出其中的用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
String idStr = StrUtil.join(",", ids);
// 3. 根据id查询用户
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);
}

五、好友关注
1. 关注和取关
在探店图文的详情页面中,可以关注发布笔记的作者:

案例:实现关注和取关功能
需求:基于该表数据结构,实现两个接口:
- 关注和取关接口
- 判断是否关注的接口
关注是User之间的关系,是博主与粉丝的关系,数据库中有一张tb_follow表来标示:

①FollowController
java
package com.hmdp.controller;
import com.hmdp.dto.Result;
import com.hmdp.service.IFollowService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* <p>
* 前端控制器
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@RestController
@RequestMapping("/follow")
public class FollowController {
@Autowired
private IFollowService followService;
/**
* 关注或取关
* @param followUserId
* @param isFollow
* @return
*/
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {
return followService.follow(followUserId, isFollow);
}
/**
* 判断是否关注
* @param followUserId
* @return
*/
@GetMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId) {
return followService.isFollow(followUserId);
}
}
②IFollowService
java
package com.hmdp.service;
import com.hmdp.dto.Result;
import com.hmdp.entity.Follow;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* <p>
* 服务类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
public interface IFollowService extends IService<Follow> {
Result follow(Long followUserId, Boolean isFollow);
Result isFollow(Long followUserId);
}
③FollowServiceImpl
java
package com.hmdp.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.Follow;
import com.hmdp.mapper.FollowMapper;
import com.hmdp.service.IFollowService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {
/**
* 关注或取关
* @param followUserId
* @param isFollow
* @return
*/
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 1. 获取登录用户
UserDTO user = UserHolder.getUser();
if (user == null) {
return Result.fail("请先登录");
}
Long userId = user.getId();
// 2. 判断是关注或是取关
if (isFollow) {
// 3. 关注,新增数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
save(follow);
} else {
// 4. 取关,删除数据 delete from tb_follow where userId = ? and follow_user_id = ?
remove(new QueryWrapper<Follow>()
.eq("user_id", userId)
.eq("follow_user_id", followUserId));
}
// 5. 结果返回
return Result.ok();
}
/**
* 判断是否关注
* @param followUserId
* @return
*/
@Override
public Result isFollow(Long followUserId) {
// 1. 获取登录用户
UserDTO user = UserHolder.getUser();
if (user == null) {
return Result.fail("请先登录");
}
Long userId = user.getId();
// 2. 查询是否关注
Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
// 3. 判断count是否大于0
return Result.ok(count > 0);
}
}
2. 共同关注
点击博主头像,可以进入博主首页:

①UserController
java
package com.hmdp.controller;
import cn.hutool.core.bean.BeanUtil;
import com.hmdp.dto.LoginFormDTO;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import com.hmdp.entity.UserInfo;
import com.hmdp.service.IUserInfoService;
import com.hmdp.service.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpSession;
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private IUserService userService;
@Resource
private IUserInfoService userInfoService;
// ... ...
/**
* 根据id查询用户
* @param userId
* @return
*/
@GetMapping("/{id}")
public Result queryUserById(@PathVariable("id") Long userId) {
// 1. 查询用户
User user = userService.getById(userId);
if (user == null) {
return Result.ok();
}
// 2. 属性拷贝
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 3. 返回
return Result.ok(userDTO);
}
}
②BlogController
java
package com.hmdp.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.Blog;
import com.hmdp.entity.User;
import com.hmdp.service.IBlogService;
import com.hmdp.service.IUserService;
import com.hmdp.utils.SystemConstants;
import com.hmdp.utils.UserHolder;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;
@RestController
@RequestMapping("/blog")
public class BlogController {
@Resource
private IBlogService blogService;
// ... ...
@GetMapping("/of/user")
public Result queryBlogByUserId(
@RequestParam(value = "current", defaultValue = "1") Integer current,
@RequestParam("id") Long id) {
// 1. 根据用户查询
Page<Blog> page = blogService.query()
.eq("user_id", id)
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 2. 获取当前页数据
List<Blog> records = page.getRecords();
// 3. 结果返回
return Result.ok(records);
}
}

案例:实现共同关注功能
需求:利用Redis中恰当的数据结构,实现功能关注功能。在博主个人主页展示出当前用户与博主的共同好友。

①FollowController
java
/**
* 查询共同关注
* @param id
* @return
*/
@GetMapping("/common/{id}")
public Result followCommons(@PathVariable("id") Long id) {
return followService.followCommons(id);
}
②IFollowService
java
Result followCommons(Long id);
③FollowServiceImpl
java
/**
* 查询共同关注
* @param id
* @return
*/
@Override
public Result followCommons(Long id) {
// 1. 获取当前用户
UserDTO user = UserHolder.getUser();
if (user == null) {
return Result.fail("请先登录");
}
Long userId = user.getId();
String key = "follows:" + userId;
// 2. 求交集
String key2 = "follows:" + id;
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, 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. 查询用户
List<UserDTO> users = userService.listByIds(ids)
.stream()
.map(u -> BeanUtil.copyProperties(u, UserDTO.class))
.collect(Collectors.toList());
// 5. 结果返回
return Result.ok(users);
}
3. 关注推送
关注推送也叫做Feed流,直译为投喂。为用户持续的提供"沉浸式"的体验,通过无限下拉刷新获取新的信息。

Feed流的模式
Feed流产品有两种常见模式:
- Timeline :不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
- 优点:信息全面,不会有缺失。并且实现也相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
- 智能排序 :利用智能推荐算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
- 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
- 缺点:如果算法不精准,可能起到反作用
本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:
- 拉模式:也叫做读扩散(内存占用较低,但延迟高)

- 推模式:也叫写扩散(延迟较低,但内存占用高)

- 推拉结合:也叫做读写混合,兼具推和拉两种模式的优点

|--------|------|-----------|-------------|
| | 拉模式 | 推模式 | 推拉结合 |
| 写比例 | 低 | 高 | 中 |
| 读比例 | 高 | 低 | 中 |
| 用户读取延迟 | 高 | 低 | 低 |
| 实现难点 | 复杂 | 简单 | 很复杂 |
| 使用场景 | 很少使用 | 用户量少、没有大V | 过千万的用户量,有大V |
案例:基于推模式实现关注推送功能
需求:
- 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
- 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
- 查询收件箱数据时,可以实现分页查询
Feed流的分页问题
Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。

Feed流的滚动分页

实现步骤:
①BlogController
java
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
return blogService.saveBlog(blog);
}
②IBlogService
java
Result saveBlog(Blog blog);
③BlogServiceImpl
java
@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> fans = followService.query().eq("follow_user_id", user.getId()).list();
// 4. 推送笔记id给所有粉丝
for (Follow fan : fans) {
// 4.1 获取粉丝id
Long userId = fan.getUserId();
// 4.2 推送给粉丝T
String key = RedisConstants.FEED_KEY + userId;
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
}
// 5. 返回id
return Result.ok(blog.getId());
}
案例:实现关注推送页面的分页查询
需求:在个人主页的"关注"卡片中,查询并展示推送的Blog信息

滚动分页的查询参数:
- max:当前时间戳 | 上一次查询的最小时间戳
- min:0
- offset:0 | 在上一次的结果中,与最小值一样的元素的个数
- count:3(与前端约定)
①BlogController
java
/**
* 查询所关注博主的笔记
* @param max
* @param offset
* @return
*/
@GetMapping("/of/follow")
public Result queryBlogOfFollow(@RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset) {
return blogService.queryBlogOfFollow(max, offset);
}
②IBlogService
java
Result queryBlogOfFollow(Long max, Integer offset);
③BlogServiceImpl
java
/**
* 查询所关注博主的笔记
* @param max
* @param offset
* @return
*/
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1. 获取当前用户
UserDTO user = UserHolder.getUser();
if (user == null) {
return Result.fail("请先登录");
}
Long userId = user.getId();
// 2. 获取收件箱
String key = RedisConstants.FEED_KEY + userId;
// ZREVRANGEBYSCORE key Min Max Score LIMIT offset count
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 os = 1;
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) {
os++;
} else {
minTime = time;
os = 1;
}
}
// 5. 根据blogId查询blog
String idStr = StrUtil.join(",", ids);
List<Blog> blogList = query().in("id", ids)
.last("ORDER BY FIELD(id, " + idStr + ")")
.list();
for (Blog blog : blogList) {
// 5.1 查询blog的作者
queryBlogUser(blog);
// 5.2 查询blog是否被当前用户点赞
isBlogLiked(blog);
}
// 6. 封装返回
ScrollResult result = new ScrollResult();
result.setList(blogList);
result.setOffset(os);
result.setMinTime(minTime);
return Result.ok(result);
}
效果:

六、附近的商户
1. GEO数据结构
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEP的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
- GEOADD:添加一个地理空间信息,包含:经度(longitude)、维度(latitude)、值(member)
- GEODIST:计算指定的两个点之间的距离并返回
- GEOHASH:将指定member的坐标转为hash字符串形式并返回
- GEOPOS:返回指定member的坐标
- GEORADIUS:指定圆心、半径,找到该园内包含的所有member,并按照与圆心之间的距离排序后返回。6.2以后已废弃
- GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2新功能
- GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。6.2新功能
案例:练习Redis的GEO功能
需求:
- 添加下面几条数据
- 北京南站(116.378248 39.865275)
- 北京站(116.42803 39.903738)
- 北京西站(116.322287 39.893729)


- 计算北京南站到北京西站的距离


- 搜索天安门(116.397904 39.909005)附近10km内的所有火车站,并按照距离升序排序


或


2. 附近商户搜索
在首页中点击某个频道,即可看到频道下的商户:

按照商户类型做分组,类型相同的商户作为同一组,以typeId为key存入同一个GEO集合中即可

①
java
package com.hmdp;
import com.hmdp.entity.Shop;
import com.hmdp.service.IShopService;
import com.hmdp.utils.RedisConstants;
import com.hmdp.utils.RedisWorker;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.geo.Point;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
@SpringBootTest
class HmDianPingApplicationTests {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private IShopService shopService;
@Test
void loadShopData() {
// 1. 查询店铺信息
List<Shop> list = shopService.list();
// 2. 按照商户类型分组 typeId
Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
// 3. 分批写入redis
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
// 3.1 获取类型id
Long typeId = entry.getKey();
// 3.2 获取同类型的店铺的集合
List<Shop> value = entry.getValue();
String key = RedisConstants.SHOP_GEO_KEY + typeId;
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
// 3.3 批量写入redis GEOADD key 经度 纬度 member
for (Shop shop : value) {
// redisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
locations.add(new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(), shop.getY())
));
}
redisTemplate.opsForGeo().add(key, locations);
}
}
}

①SpringDataRedis的2.3.9版本并不支持Redis6.2提供的GEOSEARCH命令,因此我们需要提高其版本,修改POM文件,内容如下:
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
</exclusion>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.6.2</version>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.1.6.RELEASE</version>
</dependency>
②ShopController
java
/**
* 根据商铺类型分页查询商铺信息
* @param typeId 商铺类型
* @param current 页码
* @return 商铺列表
*/
@GetMapping("/of/type")
public Result queryShopByType(
@RequestParam("typeId") Integer typeId,
@RequestParam(value = "current", defaultValue = "1") Integer current,
@RequestParam(value = "x", required = false) Double x,
@RequestParam(value = "y", required = false) Double y
) {
return shopService.queryShopByType(typeId, current, x, y);
}
③IShopService
java
Result queryShopByType(Integer typeId, Integer current, Double x, Double y);
④ShopServiceImpl
java
/**
* 根据商铺名称关键字分页查询商铺信息
* @param typeId
* @param current
* @param x
* @param y
* @return
*/
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 1. 判断是否需要根据坐标查询
if (x == null || y == null) {
// 不需要坐标查询,按照数据库查询
Page<Shop> page = query().eq("type_id", typeId)
.page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
// 返回数据
return Result.ok(page.getRecords());
}
// 2. 计算分页参数
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
// 3. 查询redis、按照距离升序、分页、结果:shopId、distance
String key = RedisConstants.SHOP_GEO_KEY + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo()
.search(
key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
);
// 4. 解析出shopId
if (results == null ) {
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
if (list.size() <= from ) {
// 没有下一页了,结束
return Result.ok(Collections.emptyList());
}
// 4.1 截取from - end的部分
List<Long> ids = new ArrayList<>(list.size());
Map<String, Distance> distanceMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(r -> {
// 4.2 获取店铺id
String shopIdStr = r.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
// 4.3 获取距离
Distance distance = r.getDistance();
distanceMap.put(shopIdStr, distance);
});
// 5. 根据id查询shop
String idStr = StrUtil.join(",", ids);
List<Shop> shopList = query().in("id", ids)
.last("ORDER BY FIELD(id," + idStr + ")")
.list();
for (Shop shop : shopList) {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
// 6. 结果返回
return Result.ok(shopList);
}

七、用户签到
1. BitMap用法

假如有1000万用户,平均每人每年签到次数为10次,则这张表一年的数据量为1亿条
每签到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共22字节的内存,一个月则最多需要600多字节
我们按月来统计用户签到信息,签到记录为1,未签到则记录为0

把每一个bit位对应当月的每一天,形成了映射关系。用0和1标识业务状态,这种思路就称为位图(BitMap)。Redis中是利用String类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是2^32个bit位。
BitMap的操作命令有:
- SETBIT:向指定位置(offset)存入一个0或1

- GETBIT:获取指定位置(offset)的bit值

- BITCOUNT:统计BitMap中值为1的bit位的数量

- BITFIELD:操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值

- BITFIELD_RO:获取BitMap中bit数组,并以十进制形式返回(只读)
bash
BITFIELD_RO mykey GET u4 0 # 只读方式获取位字段
- BITOP:将多个BitMap的结果做位运算(与、或、异或)

- BITPOS:查找bit数组中指定范围内第一个0或1出现的位置

2. 签到功能
案例:签到功能
需求:实现签到接口,将当前用户当天签到信息保存到Redis中

提示:因为BitMap底层是基于String数据结构,因此其操作也都封装在字符串相关操作中了。

①UserController
java
/**
* 用户签到
* @return
*/
@PostMapping("/sign")
public Result sign() {
return userService.sign();
}
②IUserService
java
Result sign();
③UserServiceImpl
java
/**
* 用户签到
* @return
*/
@Override
public Result sign() {
// 1. 获取当前登录用户
UserDTO user = UserHolder.getUser();
if (user == null) {
return Result.fail("请先登录");
}
Long userId = user.getId();
// 2. 获取日期
LocalDateTime now = LocalDateTime.now();
// 3. 拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;
// 4. 获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5. 写入redis SETBIT key offset 1
redisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
// 6. 结果返回
return Result.ok();
}


3. 签到统计
问题1:什么是连续签到次数?
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。

问题2:如何得到本月到今天为止的所有签到数据?
bash
BITFIELD key GET u[dayOfMonth] 0
问题3:如何从后向前遍历每个bit位?
与1做与运算,就能得到最后一个bit位。随后右移1位,下一个bit位就成为了最后一个bit位。
案例:实现签到统计功能
需求:实现下面接口,统计当前用户截止当前时间在本月的连续签到天数

①UserController
java
/**
* 连续签到天数
* @return
*/
@GetMapping("/sign/count")
public Result signCount() {
return userService.signCount();
}
②IUserService
java
Result signCount();
③UserServiceImpl
java
/**
* 连续签到天数
* @return
*/
@Override
public Result signCount() {
// 1. 获取当前登录用户
UserDTO user = UserHolder.getUser();
if (user == null) {
return Result.fail("请先登录");
}
Long userId = user.getId();
// 2. 获取日期
LocalDateTime now = LocalDateTime.now();
// 3. 拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;
// 4. 获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5. 获取本月截止今天为止的所有签到记录,返回的是一个十进制的数字
List<Long> result = redisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
);
if (result == null || result.isEmpty()) {
// 没有任何签到结果
return Result.ok(0);
}
Long num = result.get(0);
if (num == null || num == 0) {
return Result.ok(0);
}
// 6. 循环遍历
int count = 0;
while(true) {
// 让这个数字与1做与运输,得到数字的最后一个bit位
// 判断这个bit位是否是0
if ((num & 1) == 0) {
// 如果是0,说明未签到,结束
break;
} else {
// 如果不为0,说明已签到,计数器加1
count++;
}
// 把数字右移一位,抛弃最后一个bit位,继续下一个bit位
num >>>= 1;
}
// 7. 结果返回
return Result.ok(count);
}

八、UV统计
1. HyperLogLog用法
- UV:全称Unique Visitor,也叫独立访客量,是指通过互连网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
- PV:全称Page View,也叫页面访问或点击量,用户每访问网站的一个页面,记录一次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
UV统计在服务端做会比较麻烦,因为要判断该用户是否已经被统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖。
HyperLogLog(HLL)是从LogLog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理可以参考:HyperLogLog 算法的原理讲解以及 Redis 是如何应用它的聪明的你可能会马上想到,用 HashMap 这种数 - 掘金
Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。

2. UV统计
我们直接利用单元测试,向HyperLogLog中添加100万条数据,看看内存占用和统计效果如何:
java
@Test
void testHyperLogLog() {
// 准备数组,装用户数据
String[] users = new String[1000];
// 数组角标
int index = 0;
for (int i = 1; i <= 1000000; i++) {
// 赋值
users[index++] = "user_" + i;
// 每1000条发送一次
if (i % 1000 == 0) {
index = 0;
redisTemplate.opsForHyperLogLog().add("hll1", users);
}
}
// 统计数量
Long size = redisTemplate.opsForHyperLogLog().size("hll1");
System.out.println("size = " + size);
}
