达人探店
一、发布探店笔记
发布探店笔记功能是项目本身就完成了的功能,他会把图片存在本地,有兴趣可以去看源码,在UploadCOntroller类下
二、查看探店笔记
这个功能项目本身是没有完成这个接口的,所以需要我们自己去完成。
三、点赞功能
项目本身已经实现了点赞功能
项目原本的写法是通过点击点赞标识,直接向数据库发起请求,更新liked字段,并没有加任何判断。
这样看似没问题,但是却不符合逻辑,因为一个用户可以对同一篇笔记多次点赞,而现实应该是点一次赞,再点一次取消赞。
所以我们需要根据下面的需求更新代码:
使用redis的ZSet集合存储(key:被点赞的博客id,value:点赞的用户id)
使用Zset是为了后面方便按照用户的点赞时间排序(展示最先点赞的5个用户)
/*** 点赞功能** @param id* @return*/@Overridepublic Result likeBlog(Long id) {ZSetOperations<String, String> opsForZSet = stringRedisTemplate.opsForZSet();//1.获取当前登录用户UserDTO user = UserHolder.getUser();//2.判断当前用户是否在点赞列表中String key = "blog:liked:" + id;Double score = opsForZSet.score(key, user.getId().toString());if (score == null) {//3.如果不在,则点赞//3.1 数据库对应blog的点赞数+1boolean isSuccess = this.update().setSql("liked = liked + 1").eq("id", id).update();//3.1 redis中blog对应的set集合添加当前用户if (isSuccess) {opsForZSet.add(key, user.getId().toString(), System.currentTimeMillis());}} else {//4.如果在,则取消点赞//4.1 数据库对应blog的点赞数-1boolean isSuccess = this.update().setSql("liked = liked - 1").eq("id", id).update();//4.2 redis中blog的set集合移除当前用户if (isSuccess) {opsForZSet.remove(key, user.getId().toString());}}return Result.ok();}/*** 判断当前用户有没有给传来的博客点过赞* @param blog*/private void isBlogLiked(Blog blog) {ZSetOperations<String, String> opsForZSet = stringRedisTemplate.opsForZSet();//1.获取当前登录用户UserDTO user = UserHolder.getUser();if (user == null) {//用户未登录return;}//2.判断当前用户是否在点赞列表中String key = "blog:liked:" + blog.getId();Double score = opsForZSet.score(key, user.getId().toString());blog.setIsLike(score != null);}
点赞排行榜
在redis中使用ZSet存储用户点赞情况,key为博客的id,value为给这个博客点赞的用户id,并且每个entry都有一个score,这个score为当前点赞时间的距离1970.1.1的毫秒数,使用range方法获取前五个点赞的用户id。拿着用户id去数据库查出它们的头像等信息并展示。
四、关注和取关
这里没有什么特别要说的,只是拿着前端传来的字段修改数据库
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {@ResourceStringRedisTemplate stringRedisTemplate;/*** 先判断是否关注,已关注则取关,未关注则关注** @param id* @param isFollow 传来的参数为true,则是要关注,false则是要取关* @return*/@Overridepublic Result follow(Long id, Boolean isFollow) {Long userId = UserHolder.getUser().getId();String key = "follows:" + userId;SetOperations<String, String> opsForSet = stringRedisTemplate.opsForSet();if (BooleanUtil.isTrue(isFollow)) {Follow follow = new Follow();follow.setFollowUserId(id);follow.setUserId(userId);follow.setCreateTime(LocalDateTime.now());this.save(follow);//关注后还要将被关注的用户id存在redis中opsForSet.add(key, String.valueOf(id));} else {LambdaQueryWrapper<Follow> lambdaQueryWrapper = new LambdaQueryWrapper<>();lambdaQueryWrapper.eq(Follow::getFollowUserId, id).eq(Follow::getUserId, userId);this.remove(lambdaQueryWrapper);//取消关注后还要将被取消关注的用户从redis的set集合中移除opsForSet.remove(key, id);}return Result.ok();}/*** 判断当前传来的用户是否被当前用户关注了* @param id* @return*/@Overridepublic Result isFollow(Long id) {Long userId = UserHolder.getUser().getId();
// LambdaQueryWrapper<Follow> lambdaQueryWrapper = new LambdaQueryWrapper<>();
// lambdaQueryWrapper.eq(Follow::getFollowUserId, id).eq(Follow::getUserId, userId);
// Follow one = this.getOne(lambdaQueryWrapper);
// if (one == null) {
// return Result.ok(false);
// } else {
// return Result.ok(true);
// }/*** 在添加了“关注后将关注用户id添加到redis存储”功能后,* 就不需要查询数据库来判断当前用户有咩有关注传来的用户了,直接在redis中判断*/String key = "follows:" + userId;Boolean member = stringRedisTemplate.opsForSet().isMember(key, String.valueOf(id));if (BooleanUtil.isTrue(member)) {return Result.ok(true);} else {return Result.ok(false);}}
}
共同关注
共同关注是指userA和userB两个人关注的列表中相同的用户。
这里选择使用redis的Set数据结构求交集。
那么在向数据库中保存前端传来的关注信息的同时,还需要将当前登录用户关注的用户id在redis中保存一份,例如:这里的意思是,用户1010关注了用户2。
/*** 先判断是否关注,已关注则取关,未关注则关注** @param id* @param isFollow 传来的参数为true,则是要关注,false则是要取关* @return*/@Overridepublic Result follow(Long id, Boolean isFollow) {Long userId = UserHolder.getUser().getId();String key = "follows:" + userId;SetOperations<String, String> opsForSet = stringRedisTemplate.opsForSet();if (BooleanUtil.isTrue(isFollow)) {Follow follow = new Follow();follow.setFollowUserId(id);follow.setUserId(userId);follow.setCreateTime(LocalDateTime.now());this.save(follow);//关注后还要将被关注的用户id存在redis中opsForSet.add(key, String.valueOf(id));} else {LambdaQueryWrapper<Follow> lambdaQueryWrapper = new LambdaQueryWrapper<>();lambdaQueryWrapper.eq(Follow::getFollowUserId, id).eq(Follow::getUserId, userId);this.remove(lambdaQueryWrapper);//取消关注后还要将被取消关注的用户从redis的set集合中移除opsForSet.remove(key, id);}return Result.ok();}
在完成向数据库保存关注信息的同时向redis中存一份的功能后,
我们就可以获取用户1010,和用户1011的关注列表;
比如登录的用户是1010,他要查询他和1011的共同关注,只需要求他们两个关注列表的交集就可以了,使用redis的Set数据结构中的intersect方法就可以获得共同关注的userId。拿着id去数据库查用户头像,名称等信息。
@Overridepublic Result getCommonFollows(Long id) {SetOperations<String, String> opsForSet = stringRedisTemplate.opsForSet();Long userId = UserHolder.getUser().getId();String key = "follows:" + userId;String key2 = "follows:" + id;Set<String> intersect = opsForSet.intersect(key, key2);if(intersect == null || intersect.isEmpty()){return Result.ok(Collections.emptyList());}List<Long> userIdCollect = intersect.stream().map(Long::valueOf).collect(Collectors.toList());List<User> users = baseMapper.selectBatchIds(userIdCollect);List<UserDTO> collect = users.stream().map(item -> BeanUtil.copyProperties(item, UserDTO.class)).collect(Collectors.toList());return Result.ok(collect);}
五、关注列表笔记推送
feed流
拉模式
拉模式是指张三,李四发布了笔记后,赵六查看收件箱时,会从发件箱拉取笔记到收件箱,再根据时间戳排序。这种模式又叫做读扩散,因为是等到要读的时候才拉取。
优点:
不占内存
缺点:
拉取耗时,因为要等到读的时候才拉取。
推模式
张三,李四在发送笔记时直接将笔记推到自己的所有粉丝的收件箱中。
优点:延时低,因为在发布笔记时就直接推给粉丝了,粉丝不必等打开收件箱时再拉取。
缺点:如果粉丝多,比如张三有一个亿的粉丝,那么他就需要推送一个亿份笔记,对内存要求较高
推拉结合
详情看视频
推拉结合
推模式的代码实现
实现思路
- 用户A发布笔记,从数据库查出博主的粉丝id
- 将笔记的id存到每个粉丝的收件箱中(收件箱是redis的一个ZSet集合,key为“feed:userId,value为博客id)
- 使用ZSet是为了排序,score值设置为当前时间戳
代码:
/*** 保存笔记,并且将笔记的id发送给笔记发布者的粉丝** @param blog* @return*/@Overridepublic Result saveBlog(Blog blog) {// 1.获取登录用户UserDTO user = UserHolder.getUser();blog.setUserId(user.getId());// 2.保存探店博文boolean save = blogService.save(blog);if (save) {ZSetOperations<String, String> ops = stringRedisTemplate.opsForZSet();//3.查询笔记发布者的所有粉丝 select * from tb_follow where follow_user_id = ?LambdaQueryWrapper<Follow> lambdaQueryWrapper = new LambdaQueryWrapper<>();lambdaQueryWrapper.eq(Follow::getFollowUserId, user.getId());List<Follow> list = followService.list(lambdaQueryWrapper);//4.推送笔记id给粉丝for (Follow follow : list) {//4.1 获取粉丝idLong userId = follow.getUserId();//4.2 推送ops.add(RedisConstants.FEED_KEY + userId, blog.getId().toString(), System.currentTimeMillis());}}// 5.返回笔记idreturn Result.ok(blog.getId());}
用户A发布了一篇笔记
则用户B的收件箱会收到博客id
实现粉丝关注页面的分页查询功能(滚动分页)
滚动分页原理分析
上面使用推模式将笔记id推送到了粉丝的收件箱,这一节我们将会根据笔记id查出笔记,并且会根据时间排序。
在这一节中会使用到ZSet的很多命令,我们先模拟一些数据,来测试一下滚动分页和普通分页的区别
数据:
操作:
先测试按照角标查询
倒序查询,并且结果带上分数
添加一条数据,再查询3条数据
此时我们使用zrevrange查询倒数3至5条数据(从0开始的),按照我们的想法,预期结果应该是3,2,1。但是结果却是4,3,2。这样导致数据4又被查询了一遍。
可能有人会说,再查一边就再查一边呗,没影响。
影响可大了,因为在实际使用中,推文或者博客的发布速度很快,可能短时间内就会发布很多条,那么作为粉丝/用户,就可能存在永远都刷不到3,2,1这三条数据。
按照分数查询
按照角标查询会出现重复查询的问题,所以我们需要使用按照分数查询,Zset提供了这样的命令
这条命令的含义是从分数最大值1000开始查询,一直到0,limit后面的0是指偏移量,3为个数限制。
因为前面插入了一条数据,所以结果是7,6,5
这里再插入一条数据,查询时,max参数使用上次查询结果的最后一条数据的分数,min参数还是0,limit的offset参数切换为1,因为5是上次查询的结果,不需要再次查询了,所以偏移量设置为1,限制个数还是3。
查询结果为4,3,2
这里可以看到,使用分数分页查询,就不会出现重复查询的情况了
但是这样还是会出现一个情况,就是当数据的分数相同时,limit的参数offset会发生改变,会变成相同分数的个数,具体情况看视频的第10分钟后的内容redis Zset实现滚动查询
根据上面的情况总结出来命令的各个参数应该填什么:
下面我们将会实现这个接口
本次查询推送的最小时间戳会作为下次查询的lastId。
我们需要封装一个类来存放返回值。
package com.hmdp.dto;import lombok.Data;import java.util.List;@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);}
写在BlogService中的滚动分页方法
/*** 查询用户的关注者发布的笔记** @param max 上次查询结果的分数的最小值* @param offset 偏移量* @return*/@Overridepublic Result queryBlogOfFollow(Long max, Integer offset) {//1.获取当前用户UserDTO user = UserHolder.getUser();//2.查询收件箱ZSetOperations<String, String> szo = stringRedisTemplate.opsForZSet();Set<ZSetOperations.TypedTuple<String>> typedTuples = szo.reverseRangeByScoreWithScores(RedisConstants.FEED_KEY + user.getId(), 0, max, offset, 2);//3.非空判断if (typedTuples == null || typedTuples.isEmpty()) {return Result.ok();}//4.解析数据:blogId,score(时间戳),offset(需要我们从查出来的数据进行计算,得到偏移量)ArrayList<Long> blogIds = new ArrayList<>();long min = 0L;int returnOffset = 1;for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {//获取blogIdLong id = Long.valueOf(Objects.requireNonNull(typedTuple.getValue()));blogIds.add(id);//获取时间戳,计算偏移量Long score = Objects.requireNonNull(typedTuple.getScore()).longValue();if (score == min) {returnOffset++;} else {min = score;returnOffset = 1;}}//5.查询博客String idStr = StrUtil.join(",", blogIds);List<Blog> blogs = query().in("id", blogIds).last("ORDER BY FIELD(id," + idStr + ")").list();for (Blog blog : blogs) {//查询出博客的用户信息queryBlogUser(blog);//查询出当前用户对博客的点赞情况isBlogLiked(blog);}//6.封装结果ScrollResult scrollResult = new ScrollResult();scrollResult.setList(blogs);scrollResult.setOffset(returnOffset);scrollResult.setMinTime(min);return Result.ok(scrollResult);}
六、附近商户
GEO数据结构
练习:
1.添加上面的北京火车站地理信息
在redis中数据以Zset的形式存储
2.计算北京西站到北京站的距离
默认距离单位是 米
3.搜索天安门(116.397904 39.909005)附近10km内的所有火车站,并按照距离升序
我安装的redis是6.2版本之前的,所以只能使用georadius命令
附近商户搜索
数据库中的shop表存储了店铺的信息,将店铺的id和坐标存入geo数据结构,然后从redis中获取店铺的id(按照离当前用户的坐标的距离远近排序),然后根据店铺id从数据库中查询店铺的信息。
但是在使用时,我们查询店铺的信息都是按照店铺类型进行查询的,比如查询美食类,查询ktv类,所以在将店铺信息向redis中存储时还需要根据店铺类型进行分组。
编写一个单元测试,将数据库中的店铺id和地理信息存入redis
/*** 将店铺的地理信息存入redis*/@Testvoid loadShopData(){GeoOperations<String, String> geoOps = stringRedisTemplate.opsForGeo();//1.查询店铺信息List<Shop> list = shopServiceImpl.list();//2.把店铺分组,按照typeId分组,typeId一致的分到一个geo集合中//TODO 这是Stream流的写法,非常优雅,可以花时间学习一下 通过店铺类型id分类 生成一个map集合Map<Long, List<Shop>> collect = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));//3.分批完成,写入redisSet<Map.Entry<Long, List<Shop>>> entrySet = collect.entrySet();for (Map.Entry<Long, List<Shop>> longListEntry : entrySet) {Long key = longListEntry.getKey();List<Shop> value = longListEntry.getValue();//这样的话,每个店铺都要向redis发起一次请求
// for (Shop shop : value) {
// Long id = shop.getId();
// Double x = shop.getX();
// Double y = shop.getY();
// geoOps.add(RedisConstants.SHOP_GEO_KEY+key,new Point(x,y),id.toString());
// }//批量插入List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>();for (Shop shop : value) {Long id = shop.getId();Double x = shop.getX();Double y = shop.getY();locations.add(new RedisGeoCommands.GeoLocation<String>(id.toString(),new Point(x,y)));}geoOps.add(RedisConstants.SHOP_GEO_KEY+key,locations);}}
结果显示存入了两个店铺分类1和2
编写接口和业务代码
/*** 根据商铺类型分页查询商铺信息* @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.queryShopByTypeId(typeId,current,x,y);
// // 根据类型分页查询
// Page<Shop> page = shopService.query()
// .eq("type_id", typeId)
// .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
// // 返回数据
// return Result.ok(page.getRecords());}/*** 根据店铺的TypeId查询店铺信息** @param typeId 店铺类型id* @param current 当前要获取数据的页码* @param x 当前用户的经度* @param y 当前用户的纬度* @return*/@Overridepublic Result queryShopByTypeId(Integer typeId, Integer current, Double x, Double y) {//1.判断是否需要根据坐标查询if (x == null || y == null) {// 根据类型分页查询Page<Shop> page = shopService.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,distanceRedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()//创建一个新的GeoRadiusCommandArgs对象.includeDistance()//查询结果包含与中心点的距离.limit(end);//查询从0开始到end结束的数据,所以分页还需要我们手动做逻辑分页GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo().radius(RedisConstants.SHOP_GEO_KEY + typeId,new Circle(new Point(x, y), 5000),args);//非空判断if(results == null){return Result.ok(Collections.emptyList());}List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();//如果查询出来的数据不足from个,则返回空,因为后续操作会使用strem流的skip对数据进行跳过,如果跳过了超出数据个数的次数,在stream流中的操作将会引发空指针if(list.size() <= from){return Result.ok(Collections.emptyList());}//4.解析出商户id//这里使用stream跳过from条数据是在做逻辑分页List<Long> ids = new ArrayList<>();//存储店铺idMap<Long,Distance> map = new HashMap<>();//存储店铺和店铺距离中心点的距离list.stream().skip(from).forEach(result ->{String id = result.getContent().getName();//获取店铺idDistance distance = result.getDistance();//获取店铺距离中心点的距离ids.add(Long.valueOf(id));map.put(Long.valueOf(id),distance);});//5.根据商户id查询数据库 这里要根据ids中的id顺序查询数据库String idStr = StrUtil.join(",",ids);List<Shop> shops=query().in("id",ids).last("ORDER BY FIELD(id,"+idStr+")").list();for (Shop shop : shops) {Long id = shop.getId();shop.setDistance(map.get(id).getValue());}//6.返回结果return Result.ok(shops);}
七、用户签到
bitmap签到用法
bitmap数据结构
练习:
使用bitset向ket为b的bitmap中添加数据,第0、1、2、4位设置为了1,第3位默认为0.
使用bitget查看,第0、1、2、4位都是1,第3位是0
使用bitfield查看多位
bitfield命令后面的参数很多,我们只想要他的查看功能
b是数据的key,get是查看,u是结果以无符号数字展示(对应的有i,有符号数字),u后面跟着的3是指查看几位,0是offset是指从第几位查起。
结果是7,我们前面插入的数据是11101,从0位开始查看3个,即111,转为十进制数就是7
签到功能实现
@PostMapping("/sign")public Result sign(){return userService.sign();}/*** 用户签到* @return*/@Overridepublic Result sign() {//1.获取当前登录用户UserDTO user = UserHolder.getUser();//2.获取当前日期LocalDateTime now = LocalDateTime.now();//获取年月String format = now.format(DateTimeFormatter.ofPattern("yyyy/MM"));//3.获取当前是该月第几天int dayOfMonth = now.getDayOfMonth();//4.拼接key前缀String key = USER_SIGN_KEY+user.getId()+format;//5.写入redisstringRedisTemplate.opsForValue().setBit(key,dayOfMonth-1,true);//月份中的天数是从1开始,而bitmap的第一位是0//6.返回结果return Result.ok();}
统计连续签到天数
问题2中的0是偏移量,dayOfMonth是今天是当前月的第几天,是指从0开始取多少个bit位。
重点:问题3,使用位运算
/*** 统计当前用户本月截至今天连续签到天数* @return*/@GetMapping("/sign/count")public Result signCount(){return userService.signCount();}/*** 统计本月截至今天,连续签到天数** @return*/@Overridepublic Result signCount() {//1.获取当前用户UserDTO user = UserHolder.getUser();//2.获取当前年月LocalDateTime now = LocalDateTime.now();String format = now.format(DateTimeFormatter.ofPattern("yyyy/MM"));//3.获取当前是本月第几天int dayOfMonth = now.getDayOfMonth();//4.获取bitmap中从0开始当今天代表的bit序列(得到一个十进制数String key = USER_SIGN_KEY + user.getId() + format;List<Long> list = stringRedisTemplate.opsForValue().bitField(key,BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));//为什么会得到一个list呢,因为bitField命令里面还有很多子命令,可能会用到多个子命令,会有多个结果,所以结果是一个list,if (list == null || list.size() == 0) {//返回结果为空,直接返回一个0,代表连续签到天数为0return Result.ok(0);}Long result = list.get(0);if(result==null){return Result.ok(0);}//5.遍历循环,统计本月截至今天,连续签到天数int count = 0;while(true){Long x = result & 1;if(x == 0){//如果当前bit序列最后一位是0,说明断签,退出循环break;}else {count++;//如果不为0,则count++ 连续签到天数加1result >>= 1;//bit序列右移一位,抛弃最后一个bit位,继续判读下一个bit位}}//6.返回结果return Result.ok(count);}