菜鸟项目练习:黑马点评项目总结
Posted 想譚詩नरक
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了菜鸟项目练习:黑马点评项目总结相关的知识,希望对你有一定的参考价值。
目录
1. 项目介绍
黑马点评项目是一个前后端分离项目,类似于大众点评,实现了发布查看商家,达人探店,点赞,关注等功能,业务可以帮助商家引流,增加曝光度,也可以为用户提供查看提供附近消费场所,主要。用来配合学习Redis的知识。
1.1 项目使用的技术栈
SpringBoot+mysql+Lombok+MyBatis-Plus+Hutool+Redis
1.2项目架构
采用单体架构
后端部署在Tomcat上,前端部分部署在nginx 。
2.各个功能模块
2.1 登录模块
2.1.1 实现短信登录
编写一个工具类校验手机号格式,例如
public class RegexUtils
/**
* 是否是无效手机格式
* @param phone 要校验的手机号
* @return true:符合,false:不符合
*/
public static boolean isPhoneInvalid(String phone)
return mismatch(phone, RegexPatterns.PHONE_REGEX);
/**
* 是否是无效邮箱格式
* @param email 要校验的邮箱
* @return true:符合,false:不符合
*/
public static boolean isEmailInvalid(String email)
return mismatch(email, RegexPatterns.EMAIL_REGEX);
/**
* 是否是无效验证码格式
* @param code 要校验的验证码
* @return true:符合,false:不符合
*/
public static boolean isCodeInvalid(String code)
return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX);
// 校验是否不符合正则格式
private static boolean mismatch(String str, String regex)
if (StrUtil.isBlank(str))
return true;
return !str.matches(regex);
手机号码格式无误后生成验证码发送至手机,并将验证码内容写入到Redis。设置过期时间;
系统根据输入的手机号验证码进行与Redis中写入的验证码比对一致,即可登录成功,从MySQL中获取用户信息并生成Token,以Token为key将用户信息写入Redis中(hash),新用户则会注册信息并登录;
2.1.2 编写拦截器
登录拦截器,一些功能需要登录后才能使用
public class LoginInterceptor implements HandlerInterceptor
/***
* @description: 登录拦截方法
* @param: [javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, java.lang.Object]
* @return: boolean
* @date: 2022/10/25 17:27
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
// 1.判断是否要做拦截
if(UserHolder.getUser()==null)
response.setStatus(401);
return false;
// 2.有用户则放行
return true;
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception
UserHolder.removeUser();
刷新Token拦截器,用户长时间没有操作会使Token过期,每次用户点击可以刷新Token过期时间
public class RefreshTokenInterceptor implements HandlerInterceptor
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate)
this.stringRedisTemplate=stringRedisTemplate;
/***
* @description: 登录拦截方法
* @param: [javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, java.lang.Object]
* @return: boolean
* @date: 2022/10/25 17:27
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
// 1.获取token
String token = request.getHeader("authorization");
// 2.判断token是否为空
if(StrUtil.isBlank(token))
return true;
// 4.基于token获取redis中的用户
Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
.entries(LOGIN_USER_KEY + token);
// 3.判断用户是否为空
if(userMap.isEmpty())
return true;
// 5.将查询到的hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(),false);
// 6.存在则保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token,LOGIN_USER_TTL, TimeUnit.SECONDS);
// 8.放行
return true;
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception
UserHolder.removeUser();
在注册中心添加这两个拦截器
@Configuration
public class MVCConfig implements WebMvcConfigurer
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry)
registry.addInterceptor(new LoginInterceptor())//登录拦截器
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/voucher/**"
).order(1);
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);//刷新Token有效期
2.2 查询商户模块
2.2.1 主页面查询商户类型
进入主页,先从Redis中读出商户分类信息,若Redis中为空则向MySQL中读取,并写入Redis中。主页店铺分类信息为常用信息,应使用Redis避免频繁读取数据库。
2.2.2 商户详情页
该功能的实现分别应对Redis缓存容易出现的三种给出了三个不同的解决方案:
1)缓存穿透(用户对不存在的数据进行大量请求,在Redis中为未中便会请求MySQL数据库,造成数据库崩溃)
解决措施(缓存空对象,布隆过滤器)
这里采用设置默认值的方式应对穿透,当请求像MySQL中也未命中数据时,会返回一个默认值并写入Redis缓存。
2)缓存击穿(热点数据在Redis中的缓存失效,大量同时访问MySQL造成崩溃)
解决措施(设置逻辑过期,互斥锁)
这里采用给热点数据在Redis中的缓存设置逻辑过期+互斥锁
3)缓存雪崩(Redis中大量缓存同时失效或Redis宕机,大量请求同时访问数据库,造成数据库崩溃)
解决措施(设置多级缓存,采用Redis集群服务,给缓存过期时间加上一个随机值,在业务中添加限流)
这里采取给缓存过期时间加随机数的方式改进
解决方法封装成一个工具类了
@Component
@Slf4j
public class CacheClient
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate)
this.stringRedisTemplate = stringRedisTemplate;
/***
* @description: 插入缓存
* @param: [java.lang.String, java.lang.Object, java.lang.Long]
* @return: void
* @date: 2022/10/29 21:06
*/
public void set(String key, Object value, Long time, TimeUnit unit)
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,TimeUnit.MINUTES);
/***
* @description: 设置逻辑过期
* @param: [java.lang.String, java.lang.Object, java.lang.Long, java.util.concurrent.TimeUnit]
* @return: void
* @date: 2022/10/29 21:08
*/
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit)
// 1.设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 2.写入Redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
/***
* @description: 缓存穿透策略之设置默认值
* @param: [java.lang.String, ID, java.lang.Class<R>]
* @return: R
* @date: 2022/10/29 23:18
*/
public <R,ID> R queryWithPassThrough(
String keyPrefix,ID id,Class<R> type,Function<ID,R> dbFallBack, Long time, TimeUnit unit
)
String key = keyPrefix + id;
// 1.从Redis中查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断缓存是否存在
if(StrUtil.isNotBlank(json))
// 3.存在,直接返回
return JSONUtil.toBean(json,type);
// 4.不存在,判断是否是空字符串
if(json!=null)
// 5.是空字符串
return null;
// 6.不是空字符串,则向数据库中查找
R r = dbFallBack.apply(id);
// 7.数据库中未找到,设置值为空字符串并插入缓存
if (r==null)
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
// 8.找到数据源
this.set(key,r,time,unit);
return r;
public <R,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type,Function<ID,R> dbFallBack,Long time,TimeUnit unit)
String key = keyPrefix + id;
// 1.从redis查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isBlank(json))
// 2.如果缓存未命中
return null;
// 3.如果命中,把json字符反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
System.out.println(expireTime);
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now()))
// 5.1.未过期,直接返回对象
return r;
// 5.2已过期,缓存重建
// 6.缓存重建
// 6.1获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2判断是否获取锁成功
if(isLock)
// 6.3成功
CACHE_REBUILD_EXECUTOR.submit(()->
try
// 查询数据库
R r1 = dbFallBack.apply(id);
// 写入缓存
this.setWithLogicalExpire(key,r1,time,unit);
catch (Exception e)
throw new RuntimeException(e);
finally
unLock(lockKey);
);
return r;
/**
* @description: 线程池
* @param:
* @return:
* @date: 2022/10/30 14:27
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(10);
/**
* @description: 获取锁
* @param:
* @return:
* @date: 2022/10/30 14:23
*/
private boolean tryLock(String key)
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
/**
* @description: 释放锁
* @param: [java.lang.String]
* @return: void
* @date: 2022/10/30 14:24
*/
private void unLock(String key)
stringRedisTemplate.delete("key");
2.2.3 按距离查询商户
第一步需要将商铺坐标按分类写入Redis(Geo),关键代码如下
@Test
void loadShopData()
// 1.查询店铺信息
List<Shop> shopList = shopService.list();
// 2.把店铺分组,按照typeId分组,typeId一致发到一个集合
Map<Long, List<Shop>> map = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));
// 3.分批完成写入Redis
for (Map.Entry<Long, List<Shop>> entry : map.entrySet())
// 获取类型id
Long typeTd = entry.getKey();
String key = SHOP_GEO_KEY + typeTd;
// 获取通类型的店铺集合
List<Shop> shops = entry.getValue();
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(shops.size());
// 写入Redis GEOADD key 经度 纬度 member
for (Shop shop : shops)
locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(),
new Point(shop.getX(), shop.getY())
));
stringRedisTemplate.opsForGeo().add(key,locations);
请求参数中需要包含坐标,分页页码信息,类别ID,先向Redis中读取该类别的直到改页最后一个商铺商铺信息,并以距离排序,关键代码如下
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
.search(key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.
GeoSearchCommandArgs.
newGeoSearchArgs().
includeDistance().limit(end)
);
再将数据进行解析,并把该页第一个商铺前面的商铺信息都跳过得到想要商铺的id和对应distance的键值对集合
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list=results.getContent();
if(list.size()<=from)
return Result.ok();
// 5.截取from-end的部分
List<Long> ids= new ArrayList<>(list.size());
HashMap<String, Distance> distanceMap = new HashMap<>(list.size());
// 截取掉from之前的部分,不重复查询
list.stream().skip(from).forEach(result->
// 获取店铺id
String shopId = result.getContent().getName();
ids.add(Long.valueOf(shopId));
// 获取距离
Distance distance = result.getDistance();
distanceMap.put(shopId,distance);
);
最后根据id查出商铺信息并将设置distance属性,返回商铺信息集合。
补充:如果不按距离排序则直接按页码和页面尺寸查询店铺信息
Page<Shop> page=query.eq("type_id",typeId)
.page(new Page<>(current,SystemConstants.DEFEAUT_PAGE_SIZE));
2.3 优惠券秒杀模块
采用异步下单的方式,先运行Lua脚本,判断是否下过单,若未下过单,则扣减Redis库存,脚本运行成功,有购买资格,则生成一个全局Id作为订单id,生成订单信息,把订单保存到一个阻塞队列,阻塞队列收到订单后,获取分布式锁后再把订单信息和库存信息同步到MySQL,然后释放锁。该模块利用分布式锁实现一人一单功能,利用Lua确保库存不会变负数。
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService
@Resource
private ISeckillVoucherService iSeckillVoucherService;
@Resource
private RedissonClient redissonClient;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static
SECKILL_SCRIPT=new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
// 创建一个队列
private BlockingQueue<VoucherOrder> orderTasks=new ArrayBlockingQueue<>(1024*1024);
// 创建单线程化线程池,用来运行实现Runnable的类
private static final ExecutorService SECKILL_ORDER_EXCUTOR= Executors.newSingleThreadExecutor();
// 等依赖加载完再全部执行
@PostConstruct
private void init()
SECKILL_ORDER_EXCUTOR.submit(new VoucherOrderHandler());
private class VoucherOrderHandler implements Runnable
@Override
public void run()
while (true)
try
// 获取队列中的订单信息
VoucherOrder voucherOrder = orderTasks.take();
// 创建订单
handleVoucherOrder(voucherOrder);
catch (InterruptedException e)
log.error("订单处理异常",e);
private void handleVoucherOrder(VoucherOrder voucherOrder)
// 1.获取用户
Long userId = voucherOrder.getUserId();
// 2.创建锁对象
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 3.判断是否获取锁成功
boolean isLock = lock.tryLock();
if(!isLock)
// 获取锁失败
log.error("不允许重复下单");
return;
try
proxy.createVoucherOrder(voucherOrder);
finally
lock.unlock();
/**
* @description: 购买优惠券
* @param: [java.lang.Long]
* @return: com.hmdp.dto.Result
* @date: 2022/11/2 21:14
*/
private IVoucherOrderService proxy;
@Override
public Result seckillVoucher(Long voucherId)
// 1.执行lua脚本
// 获取userID
Long userId = UserHolder.getUser().getId();
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
userId.toString()
);
// 2.判断结果为0
int i = result.intValue();
if(i!=0)
// 2.1不为0,代表没有购买资格
return Result.fail(i==1 ? "库存不足" : "不能重复下单");
// 2.2为0,有购买资格,把下单信息保存到阻塞队列
long orderId = redisIdWorker.nextId("order");
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
// TODO保存阻塞队列
orderTasks.add(voucherOrder);
// 获取代理对象
proxy=(IVoucherOrderService) AopContext.currentProxy();
// 3.返回订单id
return Result.ok(orderId);
/**
* @description: 创建订单
* @param: [java.lang.Long]
* @date: 2022/11/3 20:56
* @param voucherOrder
*/
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder)
Long userId = voucherOrder.getUserId();
// 查询订单
Integer count = query().eq("voucher_id", voucherOrder.getVoucherId()).eq("user_id", userId).count();
if(count>0)
log.error("用户已经购买过一次");
return ;
// 5.扣减库存
boolean result = iSeckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherOrder.getVoucherId()).gt("stock",0).update();//where stock >0
if(!result)
// 扣减失败
log.error("库存不足!");
return;
save(voucherOrder);
// 返回订单id
return;
2.4 博客模块
2.4.1 点赞
用户浏览博客时,可以对博客进行点赞,点赞过的用户id,写入,Redis缓存中(zset:博客id,用户ID,时间)博客页并展示点赞次数和点赞列表头像,展示点赞列表时,注意点赞列表按时间排序,点赞时间早的排在前面,SQL语句应拼接order By 。
点赞功能:
public Result addLike(Long id)
// 1.获取当前用户
Long userId = UserHolder.getUser().getId();
Blog blog = query().eq("id", id).one();
// 2.判断当前用户是否已经点赞
String key = BLOG_LIKED_KEY + id;
Double isLike = stringRedisTemplate.opsForZSet().score(key, userId.toString());
// 3.如果未点赞,可以点赞
if(isLike==null)
// 4.数据库该帖点赞+1
boolean isSuccess = update().setSql("liked=liked+1").eq("id", id).update();
// 5.保存用户id到该贴子的Redis的Zset集合,并更新blog的isLike属性
if(BooleanUtil.isTrue(isSuccess))
stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());
blog.setIsLike(true);
return Result.ok();
// 6.如果已经点赞
// 7.数据库该贴点赞-1;
boolean isSuccess = update().setSql("liked=liked-1").eq("id", id).update();
// 8.把set集合中的用户id移除
if(BooleanUtil.isTrue(isSuccess))
stringRedisTemplate.opsForZSet().remove(key,userId.toString());
blog.setIsLike(false);
return Result.ok();
点赞列表:
public Result queryLikesById(Long id)
// 1.获取key
String key = BLOG_LIKED_KEY + id;
// 2.查询点赞时间前五的userId
Set<String> userIds = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if(userIds==null||userIds.isEmpty())
return Result.ok();
// 3.根据userId查询User
List<Long> list = userIds.stream().map(Long::valueOf).collect(Collectors.toList());
String idStr = StrUtil.join(",", list);
// 4.返回User集
List<UserDTO> UserDTOS = userService.query()
.in("id",list)
.last("ORDER BY FIELD(id,"+idStr+")")
.list()
.stream()
.map(user -> BeanUtil.copyProperties(user,UserDTO.class))
.collect(Collectors.toList());
return Result.ok(UserDTOS);
2.4.2 关注作者
与点赞功能相似,将关注用户写入Redis中(String:用户id,被关注与id)
2.5 订阅模块
用户发布的内容推送给粉丝,实现策略有三种模式:拉取模式,推模式,推拉结合模式
该处实现了推模式,发布博客时,把博客推送给粉丝,会向粉丝的信箱(ZSet:粉丝id,博客id,时间)中存入博客id,用户查看订阅时,即根据信箱滚动分页查询最新的博客
public Result queryBlogByFollow(Long max, Integer offset)
// 1.获取当前用户id
Long userId = UserHolder.getUser().getId();
String key = FEED_KEY+userId;
// 2.查询信箱
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key,0,max,offset,3);
System.out.println(typedTuples);
if(typedTuples==null||typedTuples.isEmpty())
return Result.ok();
List<Long> ids = new ArrayList<>(typedTuples.size());
// 3.解析数据
long minTime=0;
Integer os=1;
for (ZSetOperations.TypedTuple<String> tuple : typedTuples)
// 获取blogId
ids.add(Long.valueOf(tuple.getValue()));
// 获取分数
long score = tuple.getScore().longValue();
if(minTime==score)
os++;
else
os=1;
minTime=score;
// 4.根据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)
// 获取点赞信息
isLiked(blog);
// 获取用户信息
User user = userService.getById(blog.getUserId());
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
// 5.封装并返回
ScrollResult scrollResult = new ScrollResult();
scrollResult.setList(blogs);
scrollResult.setOffset(os);
scrollResult.setMinTime(minTime);
return Result.ok(scrollResult);
2.6 签到模块
2.6.1 签到功能
使用时间bitMap,打卡取1,为打卡取0,从第0位开始,n日的打卡数据在n-1位
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
// 3.拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5.写入Redis setBit key offset 1
stringRedisTemplate.opsForValue().setBit(key,dayOfMonth-1,true);
2.6.2 获取当月连续签到天数
把当月签到数据和1做与运算,得到最近一天是否打卡,为0则直接返回,为1则把签到数据右移一位和1做与运算,循环,直到与运算结果为0,循环次数为连续签到天数。
// 2.获取用户在本月当前签到数据
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
int dayOfMonth = now.getDayOfMonth();
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
);
if(result==null||result.isEmpty())
return Result.ok(0);
Long sign = result.get(0);
if(sign==0||sign==null)
return Result.ok(0);
// 3.取出和1做与运算
int count=0;
while (true)
if ((sign&1)==0)
// 4.判断是否为0
// 4.1为0则返回
break;
else
// 4.2为1则count++,并将sign右移
count++;
sign>>>=1;
3.项目学习收获
项目实战可能碰到的场景,及问题,和解决方案
黑马瑞吉外卖——
以上是关于菜鸟项目练习:黑马点评项目总结的主要内容,如果未能解决你的问题,请参考以下文章
微服务 SpringBoot 整合 Redis GEO 实现附近商户功能
Redis实战之Session实现短信登录以及Redis完善登录功能