目录
1. 项目介绍
2.各个功能模块
2.1 登录模块
2.1.1 实现短信登录
2.1.2 编写拦截器
2.2 查询商户模块
2.2.1 主页面查询商户类型
2.2.3 按距离查询商户
2.3 优惠券秒杀模块
2.4 博客模块
2.4.1 点赞
2.5 订阅模块
2.6 签到模块
2.6.1 签到功能
2.6.2 获取当月连续签到天数
3.项目学习收获
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 ((str)) {
-
return true;
-
}
-
return !(regex);
-
}
-
}
手机号码格式无误后生成验证码发送至手机,并将验证码内容写入到Redis。设置过期时间;
系统根据输入的手机号验证码进行与Redis中写入的验证码比对一致,即可登录成功,从MySQL中获取用户信息并生成Token,以Token为key将用户信息写入Redis中(hash),新用户则会注册信息并登录;
2.1.2 编写拦截器
登录拦截器,一些功能需要登录后才能使用
-
public class LoginInterceptor implements HandlerInterceptor {
-
/***
-
* @description: 登录拦截方法
-
* @param: [, , ]
-
* @return: boolean
-
* @date: 2022/10/25 17:27
-
*/
-
@Override
-
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
-
// 1.判断是否要做拦截
-
if(()==null){
-
(401);
-
return false;
-
}
-
// 2.有用户则放行
-
return true;
-
}
-
-
@Override
-
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
-
();
-
}
-
}
刷新Token拦截器,用户长时间没有操作会使Token过期,每次用户点击可以刷新Token过期时间
-
public class RefreshTokenInterceptor implements HandlerInterceptor {
-
private StringRedisTemplate stringRedisTemplate;
-
-
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate){
-
this.stringRedisTemplate=stringRedisTemplate;
-
}
-
/***
-
* @description: 登录拦截方法
-
* @param: [, , ]
-
* @return: boolean
-
* @date: 2022/10/25 17:27
-
*/
-
@Override
-
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
-
// 1.获取token
-
String token = ("authorization");
-
// 2.判断token是否为空
-
if((token)){
-
return true;
-
}
-
// 4.基于token获取redis中的用户
-
Map<Object, Object> userMap = ()
-
.entries(LOGIN_USER_KEY + token);
-
// 3.判断用户是否为空
-
if(()){
-
return true;
-
}
-
// 5.将查询到的hash数据转为UserDTO对象
-
UserDTO userDTO = (userMap, new UserDTO(),false);
-
// 6.存在则保存用户信息到ThreadLocal
-
(userDTO);
-
// 7.刷新token有效期
-
(LOGIN_USER_KEY + token,LOGIN_USER_TTL, );
-
// 8.放行
-
return true;
-
}
-
-
@Override
-
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
-
();
-
}
-
}
在注册中心添加这两个拦截器
-
@Configuration
-
public class MVCConfig implements WebMvcConfigurer {
-
@Resource
-
private StringRedisTemplate stringRedisTemplate;
-
@Override
-
public void addInterceptors(InterceptorRegistry registry) {
-
(new LoginInterceptor())//登录拦截器
-
.excludePathPatterns(
-
"/user/code",
-
"/user/login",
-
"/blog/hot",
-
"/shop/**",
-
"/shop-type/**",
-
"/voucher/**"
-
).order(1);
-
(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: [, , ]
-
* @return: void
-
* @date: 2022/10/29 21:06
-
*/
-
public void set(String key, Object value, Long time, TimeUnit unit){
-
().set(key, (value),time,);
-
}
-
-
/***
-
* @description: 设置逻辑过期
-
* @param: [, , , ]
-
* @return: void
-
* @date: 2022/10/29 21:08
-
*/
-
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
-
// 1.设置逻辑过期
-
RedisData redisData = new RedisData();
-
(value);
-
(().plusSeconds((time)));
-
// 2.写入Redis
-
().set(key,(redisData));
-
}
-
/***
-
* @description: 缓存穿透策略之设置默认值
-
* @param: [, ID, <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 = ().get(key);
-
// 2.判断缓存是否存在
-
if((json)){
-
// 3.存在,直接返回
-
return (json,type);
-
}
-
// 4.不存在,判断是否是空字符串
-
if(json!=null){
-
// 5.是空字符串
-
return null;
-
}
-
// 6.不是空字符串,则向数据库中查找
-
R r = (id);
-
// 7.数据库中未找到,设置值为空字符串并插入缓存
-
if (r==null) {
-
().set(key,"",CACHE_NULL_TTL,);
-
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 = ().get(key);
-
if((json)){
-
// 2.如果缓存未命中
-
return null;
-
}
-
// 3.如果命中,把json字符反序列化为对象
-
RedisData redisData = (json, );
-
R r = ((JSONObject) (), type);
-
LocalDateTime expireTime = ();
-
(expireTime);
-
// 5.判断是否过期
-
if((())){
-
// 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 = (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= (10);
-
-
/**
-
* @description: 获取锁
-
* @param:
-
* @return:
-
* @date: 2022/10/30 14:23
-
*/
-
private boolean tryLock(String key){
-
Boolean flag = ().setIfAbsent(key, "1", 10, );
-
return (flag);
-
}
-
/**
-
* @description: 释放锁
-
* @param: []
-
* @return: void
-
* @date: 2022/10/30 14:24
-
*/
-
private void unLock(String key){
-
("key");
-
}
-
-
}
2.2.3 按距离查询商户
第一步需要将商铺坐标按分类写入Redis(Geo),关键代码如下
-
@Test
-
void loadShopData(){
-
// 1.查询店铺信息
-
List<Shop> shopList = ();
-
// 2.把店铺分组,按照typeId分组,typeId一致发到一个集合
-
Map<Long, List<Shop>> map = ().collect((Shop::getTypeId));
-
// 3.分批完成写入Redis
-
for (<Long, List<Shop>> entry : ()) {
-
// 获取类型id
-
Long typeTd = ();
-
String key = SHOP_GEO_KEY + typeTd;
-
// 获取通类型的店铺集合
-
List<Shop> shops = ();
-
List<<String>> locations = new ArrayList<>(());
-
// 写入Redis GEOADD key 经度 纬度 member
-
for (Shop shop : shops) {
-
(new RedisGeoCommands.GeoLocation<>(().toString(),
-
new Point((), ())
-
));
-
}
-
().add(key,locations);
-
}
-
}
请求参数中需要包含坐标,分页页码信息,类别ID,先向Redis中读取该类别的直到改页最后一个商铺商铺信息,并以距离排序,关键代码如下
-
GeoResults<<String>> results = ()
-
.search(key,
-
(x, y),
-
new Distance(5000),
-
RedisGeoCommands.
-
GeoSearchCommandArgs.
-
newGeoSearchArgs().
-
includeDistance().limit(end)
-
);
再将数据进行解析,并把该页第一个商铺前面的商铺信息都跳过得到想要商铺的id和对应distance的键值对集合
-
List<GeoResult<<String>>> list=();
-
if(()<=from){
-
return ();
-
}
-
// 5.截取from-end的部分
-
List<Long> ids= new ArrayList<>(());
-
HashMap<String, Distance> distanceMap = new HashMap<>(());
-
// 截取掉from之前的部分,不重复查询
-
().skip(from).forEach(result->{
-
// 获取店铺id
-
String shopId = ().getName();
-
((shopId));
-
// 获取距离
-
Distance distance = ();
-
(shopId,distance);
-
});
最后根据id查出商铺信息并将设置distance属性,返回商铺信息集合。
补充:如果不按距离排序则直接按页码和页面尺寸查询店铺信息
-
Page<Shop> page=("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_SCRIPT.setResultType();
-
}
-
// 创建一个队列
-
private BlockingQueue<VoucherOrder> orderTasks=new ArrayBlockingQueue<>(1024*1024);
-
// 创建单线程化线程池,用来运行实现Runnable的类
-
private static final ExecutorService SECKILL_ORDER_EXCUTOR= ();
-
// 等依赖加载完再全部执行
-
@PostConstruct
-
private void init(){
-
SECKILL_ORDER_EXCUTOR.submit(new VoucherOrderHandler());
-
}
-
-
private class VoucherOrderHandler implements Runnable{
-
-
@Override
-
public void run() {
-
while (true) {
-
try {
-
// 获取队列中的订单信息
-
VoucherOrder voucherOrder = ();
-
// 创建订单
-
handleVoucherOrder(voucherOrder);
-
} catch (InterruptedException e) {
-
("订单处理异常",e);
-
}
-
}
-
-
}
-
}
-
-
private void handleVoucherOrder(VoucherOrder voucherOrder){
-
// 1.获取用户
-
Long userId = ();
-
// 2.创建锁对象
-
RLock lock = ("lock:order:" + userId);
-
// 3.判断是否获取锁成功
-
boolean isLock = ();
-
if(!isLock){
-
// 获取锁失败
-
("不允许重复下单");
-
return;
-
}
-
try {
-
(voucherOrder);
-
} finally {
-
();
-
}
-
-
}
-
-
/**
-
* @description: 购买优惠券
-
* @param: []
-
* @return:
-
* @date: 2022/11/2 21:14
-
*/
-
private IVoucherOrderService proxy;
-
@Override
-
public Result seckillVoucher(Long voucherId) {
-
// 1.执行lua脚本
-
// 获取userID
-
Long userId = ().getId();
-
Long result = (
-
SECKILL_SCRIPT,
-
(),
-
(),
-
()
-
);
-
// 2.判断结果为0
-
int i = ();
-
if(i!=0)
-
// 2.1不为0,代表没有购买资格
-
{
-
return (i==1 ? "库存不足" : "不能重复下单");
-
}
-
// 2.2为0,有购买资格,把下单信息保存到阻塞队列
-
long orderId = ("order");
-
VoucherOrder voucherOrder = new VoucherOrder();
-
(orderId);
-
(userId);
-
(voucherId);
-
// TODO保存阻塞队列
-
(voucherOrder);
-
// 获取代理对象
-
proxy=(IVoucherOrderService) ();
-
// 3.返回订单id
-
return (orderId);
-
}
-
-
/**
-
* @description: 创建订单
-
* @param: []
-
* @date: 2022/11/3 20:56
-
* @param voucherOrder
-
*/
-
@Transactional
-
public void createVoucherOrder(VoucherOrder voucherOrder) {
-
Long userId = ();
-
// 查询订单
-
Integer count = query().eq("voucher_id", ()).eq("user_id", userId).count();
-
if(count>0){
-
("用户已经购买过一次");
-
return ;
-
}
-
// 5.扣减库存
-
boolean result = ()
-
.setSql("stock=stock-1")
-
.eq("voucher_id", ()).gt("stock",0).update();//where stock >0
-
if(!result){
-
// 扣减失败
-
("库存不足!");
-
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 = ().getId();
-
Blog blog = query().eq("id", id).one();
-
// 2.判断当前用户是否已经点赞
-
String key = BLOG_LIKED_KEY + id;
-
Double isLike = ().score(key, ());
-
// 3.如果未点赞,可以点赞
-
if(isLike==null)
-
{
-
// 4.数据库该帖点赞+1
-
boolean isSuccess = update().setSql("liked=liked+1").eq("id", id).update();
-
// 5.保存用户id到该贴子的Redis的Zset集合,并更新blog的isLike属性
-
if((isSuccess)){
-
().add(key,(),());
-
(true);
-
}
-
return ();
-
}
-
// 6.如果已经点赞
-
// 7.数据库该贴点赞-1;
-
boolean isSuccess = update().setSql("liked=liked-1").eq("id", id).update();
-
// 8.把set集合中的用户id移除
-
if((isSuccess)){
-
().remove(key,());
-
(false);
-
-
}
-
return ();
-
}
点赞列表:
-
public Result queryLikesById(Long id) {
-
// 1.获取key
-
String key = BLOG_LIKED_KEY + id;
-
// 2.查询点赞时间前五的userId
-
Set<String> userIds = ().range(key, 0, 4);
-
if(userIds==null||()){
-
return ();
-
}
-
// 3.根据userId查询User
-
List<Long> list = ().map(Long::valueOf).collect(());
-
String idStr = (",", list);
-
// 4.返回User集
-
List<UserDTO> UserDTOS = ()
-
.in("id",list)
-
.last("ORDER BY FIELD(id,"+idStr+")")
-
.list()
-
.stream()
-
.map(user -> (user,))
-
.collect(());
-
return (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 = ().getId();
-
String key = FEED_KEY+userId;
-
// 2.查询信箱
-
Set<<String>> typedTuples = ()
-
.reverseRangeByScoreWithScores(key,0,max,offset,3);
-
-
(typedTuples);
-
if(typedTuples==null||()){
-
return ();
-
}
-
List<Long> ids = new ArrayList<>(());
-
// 3.解析数据
-
long minTime=0;
-
Integer os=1;
-
for (<String> tuple : typedTuples) {
-
// 获取blogId
-
((()));
-
// 获取分数
-
long score = ().longValue();
-
if(minTime==score){
-
os++;
-
}else {
-
os=1;
-
minTime=score;
-
}
-
}
-
// 4.根据id查询blog
-
String idStr = (",", ids);
-
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
-
for (Blog blog : blogs) {
-
// 获取点赞信息
-
isLiked(blog);
-
// 获取用户信息
-
User user = (());
-
(());
-
(());
-
}
-
// 5.封装并返回
-
ScrollResult scrollResult = new ScrollResult();
-
(blogs);
-
(os);
-
(minTime);
-
return (scrollResult);
-
}
2.6 签到模块
2.6.1 签到功能
使用时间bitMap,打卡取1,为打卡取0,从第0位开始,n日的打卡数据在n-1位
-
// 2.获取日期
-
LocalDateTime now = ();
-
// 3.拼接key
-
String keySuffix = ((":yyyyMM"));
-
String key = USER_SIGN_KEY + userId + keySuffix;
-
// 4.获取今天是本月的第几天
-
int dayOfMonth = ();
-
// 5.写入Redis setBit key offset 1
-
().setBit(key,dayOfMonth-1,true);
2.6.2 获取当月连续签到天数
把当月签到数据和1做与运算,得到最近一天是否打卡,为0则直接返回,为1则把签到数据右移一位和1做与运算,循环,直到与运算结果为0,循环次数为连续签到天数。
-
// 2.获取用户在本月当前签到数据
-
LocalDateTime now = ();
-
String keySuffix = ((":yyyyMM"));
-
String key = USER_SIGN_KEY + userId + keySuffix;
-
int dayOfMonth = ();
-
List<Long> result = ().bitField(
-
key,
-
()
-
.get((dayOfMonth)).valueAt(0)
-
);
-
if(result==null||()){
-
return (0);
-
}
-
Long sign = (0);
-
if(sign==0||sign==null){
-
return (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.项目学习收获
项目实战可能碰到的场景,及问题,和解决方案