redis黑马点评项目学习笔记 mac m1pro windows 含项目配置教学
- mac M1pro环境配置
- windows11 wsl2 ubuntu 环境配置
- 一.短信登录
- 1. 1发送验证码
- 1.2短信登录+注册
- 1.3登录校验拦截器
- 补缺Cookie Session Token
- 1.4基于redis+token认证实现短信登陆
- 1.5完善token认证的刷新机制
- 2.商户查询缓存
- 2.1添加商户缓存(到redis中)
- 2.2添加商户类型缓存(到redis中)
- 2.3(重点)redis中缓存更新策略分析(分四步)
mac M1pro环境配置
-
视频里用的是虚拟机,但我不想配虚拟机,太臃肿了,我选择docker这里我用的是OrbStack,这款软件使用的是rust编写,性能占用低(相比于dockerDesktop)
brew insall orbstack
或者去官网下载 -
docker拉取redis 和 mysql镜像,注意常规镜像pull下来run后使用的是rosetta转译,性能会下降.
因此我们拉取arm64v8/mysql和arm64v8/redis这两个镜像
pull docker pull arm64v8/mysql
pull docker pull arm64v8/redis
这里种类是Apple说明没有经过转译 -
部署mysql和redis
docker run -p 3306:3306 \--name mysql \-v mysql_data:/var/lib/mysql \-v mysql_conf:/etc/mysql/conf.d \--privileged=true \-e MYSQL_ROOT_PASSWORD=123456 \-d arm64v8/mysql
docker run -p 6379:6379 \ --name redis \ -v redis_data:/data \ -v redis_conf:/etc/redis/redis.conf \ -d arm64v8/redis \ redis-server /etc/redis/redis.conf
使用
docker ps
查看是否成功
-
进入mysql数据库中导入sql表并运行sql表语句
# 1.拷贝SQL文件到mysql容器中(在容器外,自己的sql文件所在位置,mysql是容器名) docker cp yyy.sql mysql:/hmdp.sql# 2. 创建数据库(第一个mysql是容器名,第二个mysql是程序名称 也可以/bin/bash或者bash) docker exec -it mysql mysql -uroot -p123456 mysql> create database hmdp;# 创建黑马点评数据库 mysql> use hmdp; # 使用黑马点评数据库# 3.登陆控制台执行source 命令,执行sql文件 mysql> source hmdp.sql
-
进入Datagrip软件查看一下redis 和 mysql(我用的这个可视化软件,它可以连接很多种数据库)
-
项目后端
# 1.clone git clone https://gitee.com/chiroua/black-horse-review.git # 2.切换分支, init分支就是项目最开始的代码 git checkout init # 3.修改application.yaml中mysql和redis的ip,端口,密码等,这里我redis没有设置密码直接注释了.
因为使用的是mysql8的版本
还要将数据配置文件里driver-class-name: com.mysql.jdbc.Driver修改为如下driver-class-name: com.mysql.cj.jdbc.Driver;否则启动项目有报错- 修改idea默认的maven, idea自带一个叫
已捆绑(Maven3)
的maven,默认的所在路径为/.m2
,settings和repo都在这个目录下,但是我没试过,我这里用的自己的maven仓库,记得修改一下maven仓库镜像地址,搜索关键词maven 阿里
-
项目前端
brew install nginx
- 将
/opt/homebrew/Cellar/nginx/1.25.3/
下的html夹替换成src/main/resources/nginx-1.18.0/
下的html文件夹 - 将
/opt/homebrew/etc/nginx/nginx.conf
替换成src/main/resources/nginx-1.18.0/conf/nginx.conf
接下来重新加载nginx配置文件sudo nginx -s reload
- 启动nginx
sudo nginx
ps -ef | grep nginx
查看nginx是否成功启动
- nginx成功启动后,我们启动后端,访问本地8080端口即可成功运行
windows11 wsl2 ubuntu 环境配置
-
mysql
# 安装mysql su root apt update sudo apt-get install mysql-server sudo service mysql start#一般就直接启动了不需要这一步 mysql ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password';# 修改root用户密码 exit sudo mysql_secure_installation # 使用脚本删除匿名用户和匿名用户访问的数据库## 接下来 执行sql文件,构建数据库 cp /mnt/c/.....hmdp.sql /tmp/ #每个人不一样,可以参考我复制到/tmp目录下 mysql -u root -p CREATE DATABASE hmdp; use hmdp; source /tmp/hmdp.sql
更多操作查看此博客
-
redis
# 我直接安装的 没设置密码 su root apt update sudo apt-get install redis-server # 执行redis-cli查看redis正常启动即可
-
datagrip连接redis和mysql, 其中redis指定不需要用户和密码即可
-
git clone git@gitee.com:chiroua/black-horse-review.git # 修改maven仓库的settings 将源设置成aliyun,这里我直接用idea自带的maven仓库了 # 修改applicati.yml配置文件中mysql 和 redis相关配置
因为使用的是mysql8的版本
还要将数据配置文件里driver-class-name: com.mysql.jdbc.Driver修改为如下driver-class-name: com.mysql.cj.jdbc.Driver;否则启动项目有报错 -
前端也有一处问题 niginx目录下打开终端
start nginx.exe
后访问8080无效,
查看nginx目录下的logs/error.log发现2024/01/04 21:58:41 [emerg] 37092#35300: CreateDirectory() "C:\Users\gx\Desktop\hmdp\black-horse-review\src\main\resources\nginx-1.18.0/temp/client_body_temp" failed (3: The system cannot find the path specified)
我们手动在logs的同级目录下创建一个temp/client_body_temp即可
一.短信登录
1. 1发送验证码
- 业务思路: 基本的校验->验证码和手机号保存在session中(不明白session token cookie去学),利用RandomUtil工具类生成验证码即可;session中存储的手机号和验证码是为了登录时的校验功能
@Overridepublic Result sendCode(String phone, HttpSession session) {//获得用户手机号//1.校验手机号正确性if (RegexUtils.isPhoneInvalid(phone))return Result.fail("手机号格式不正确");session.setAttribute("phone", phone);//2.生成验证码String code = RandomUtil.randomNumbers(6);//3.保存验证码session.setAttribute("code", code);//4.发送验证码log.debug("发送短信验证码成功, 验证码:{}", code);//5.返回okreturn Result.ok();}
1.2短信登录+注册
- 业务思路:controller 中 return UserService
接口
中的方法, 具体实现在实现类UserServiceImpl中去重写即可;基本的参数校验->从session中取出验证码和手机号->判断手机号前后是否一致,判断验证码是否正确,判断用户是否存在,不存在则注册一个保存到数据库中 - 说在前面: 本项目使用了mybatis-plus, 直接的好处是可以直接用save(), query等api, 原因是因为
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService
我们观察发现 UserServiceImpl类继承了 ServiceImpl<T,T>, 同时指定了两个泛型UserMapper,和User,我们去entity下的user类可以发现,其中指明了该实体类对应的表名,这就是二者之间的联系. - 代码优化:这里我优化了一下, 应当先检查当前手机号和session中存储的手机号是否一致
- 注意代码命名规则,:比如session中的code命名为cacheCode,;类名:驼峰命名法+ 首字母大写, 方法名驼峰命名法+首字母小写
- api设计规则:设计的类名称为IShopTypeService,对应的路径名称为shop-type
- 写代码流程:先把思路捋清楚,比如1.2.3.4.每一步干什么,然后再写代码
@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {//1.校验手机号格式是否正确String phone = loginForm.getPhone();if(RegexUtils.isPhoneInvalid(phone)) return Result.fail("手机号格式不正确!");//这里不知道为啥,不用return true代表格式不正确//2.校验手机号是否和session里面的一致String cachePhone = (String) session.getAttribute("phone");if (!cachePhone.equals(phone)) return Result.fail("前后手机号不匹配!");//3.校验验证码Object cacheCode = session.getAttribute("code");String code = loginForm.getCode();if (cacheCode == null || !cacheCode.toString().equals(code)) return Result.fail("验证码错误!");//4.判断用户是否存在User user = query().eq("phone", phone).one();//5.不存在的话直接创建一个用户if (user == null) user = createUserWithPhone(phone);//6.保存用户信息到session中session.setAttribute("user", user);return Result.ok();}//注册private User createUserWithPhone(String phone) {//1.创建用户User user = new User();user.setPhone(phone);user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));//2.保存用户到数据库中save(user);return user;}
1.3登录校验拦截器
- 业务思路:
- 设计拦截器的原因: 点评的不少业务都需要验证当前是否是登录状态,比如我们观察前端info.html(个人信息界面)的代码发现会发送如下请求
其中有一个Cookie, 如果每个业务都要从http request的session中取的话会很麻烦,因此我们设置一个登录拦截器LoginInterceptor类, 所有的业务都会先经过这个类最后到达controller, 我们在这个类中将当前session中的user属性存储到本地线程中,要用的时候直接从本地线程UserHolder类(封装了TreadLocal类对象)中取就行. - 防止用户隐私泄露: 我们session中一开始存的是完整的user对象(包含了user的密码等个人隐私), 我们封装一个UserDto类, 将user中的信息拷贝到userDto中, session中存userDto就行, 后续的UserHolder等cache类从session中拿到的数据也就是userDto了. 但是我们createUser方法往数据库中存储的时候还是存储完整的user对象哦
- 实现WebMvcConfigurer类中的addInterceptor方法, 并设置哪些请求路径不需要被拦截
- 设计拦截器的原因: 点评的不少业务都需要验证当前是否是登录状态,比如我们观察前端info.html(个人信息界面)的代码发现会发送如下请求
- 除了拦截器外还可以设计过滤器
- 缺: 什么是TreadLocal内存泄露,为什么afterCompletion后要removeUser(); 什么是session cookie token
- mac idea 实现方法快捷键 ^ + o
@Overridepublic Result me() {UserDTO user = UserHolder.getUser();return Result.ok(user);}
@Data
public class UserDTO {private Long id;private String nickName;private String icon;
}
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.获取session中的用户HttpSession session = request.getSession();Object user = session.getAttribute("user");//2.用户是否存在,不存在返回false,并设置状态码if (user == null){response.setStatus(401);return false;}//3.存在就将当前用户存储在ThreadLocal中UserHolder.saveUser((UserDTO) user);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除用户,防止内存泄漏UserHolder.removeUser();}
}
@Configuration
public class MyConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(// 匿名对象 stream coding"/user/code","/voucher/**","/blog/hot","/shop/**","/shop-type/**","/upload/**","/user/login");}
}
补缺Cookie Session Token
因为Http是一种无状态的协议,为了记住这种状态, 引入了 session cookie token
- Cookie: 存储在前端, 数据的一种载体, 后端可以将数据包装在Cookie中发送给客户端端, 比如将session放在cookie中送到客户端, cookie跟随HTTP的每个请求发送出去
- Session: 存储在后端
- Token: 在很多地方都会用到, 只是一个通用的名词;诞生在服务器这端, 但保存在浏览器这边, 由客户端主导一切, 可以存放在Cookie或者Storage中, jwt即
json web token
是一种特殊的token,或者说token的一种 - 总结: cookie是载体,而token和session则是靠它实现的验证机制,本质就是cookie上携带的字符串;
session和token是两种认证机制
。cookie和localstorage等是存储session id或token的载体
1.4基于redis+token认证实现短信登陆
- 业务思路: 这里采用token认证机制, 不再使用session了, 因为如果是tomcat服务器集群,session存储在不同的tomcat服务器中, 假如小明的session存储在A服务器中, 第二次小明发送请求的时候经过负载均衡请求到达B服务器, 那么B服务器没有小明的session,小明又要重新登录; 但是如果让多个服务器之间数据同步的话会造成数据冗余,同时同步数据也需要一定的时间,综上我们选择redis来存储数据
- 用redis存储code, phone作为其key
- 用redis存储user信息, token作为其key
token的本质是一种验证机制, 这里token作为当前用户个人信息的key!!
- springboot内置的tomcat会帮我们维护session,这里我们自己维护redis的时候注意数据的丢失, 因为不可能让数据一直存储在redis中
- 具体实现业务代码的时候注意自己new的对象spring不会帮我们管理的
- 缺: 什么样的对象可以让spring帮我们管理?
@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result sendCode(String phone, HttpSession session) {//获得用户手机号//1.校验手机号正确性if (RegexUtils.isPhoneInvalid(phone))return Result.fail("手机号格式不正确");//session.setAttribute("phone", phone); 使用redis后我还没想好它的key是啥//2.生成验证码String code = RandomUtil.randomNumbers(6);//3.保存验证码到redis中并设置缓存时间stringRedisTemplate.opsForValue().set(LOGIN_USER_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);//stream流//4.发送验证码log.debug("发送短信验证码成功, 验证码:{}", code);//5.返回okreturn Result.ok();}@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {//1.校验手机号格式是否正确String phone = loginForm.getPhone();if(RegexUtils.isPhoneInvalid(phone)) return Result.fail("手机号格式不正确!");//这里不知道为啥,不用return true代表格式不正确
// //2.校验手机号是否和session里面的一致
// String cachePhone = (String) session.getAttribute("phone");
// if (!cachePhone.equals(phone)) return Result.fail("前后手机号不匹配!");//3.校验验证码String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_USER_KEY + phone);String code = loginForm.getCode();if (cacheCode == null || !cacheCode.equals(code)) return Result.fail("验证码错误!");//4.判断用户是否存在User user = query().eq("phone", phone).one();//5.不存在的话直接创建一个用户if (user == null) user = createUserWithPhone(phone);//6.保存用户信息到redis中, token作为key并设置缓存时间String token = UUID.randomUUID().toString(true);String tokenKey = LOGIN_USER_KEY + token;Map<String, Object> userMap = BeanUtil.beanToMap(user, new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);//设置token有效期stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);//7.返回tokenreturn Result.ok(token);}
public class LoginInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;//用来接住外部传过来的StringRedisTemplatepublic LoginInterceptor(StringRedisTemplate stringRedisTemplate){this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.获得token, 判断token是否为空String token = request.getHeader("authorization");if (StrUtil.isBlank(token)) return false;//2.通过token获取redis中的用户信息,并判断同户是否存在,不存在返回false,并设置状态码String tokenKey = LOGIN_USER_KEY + token;Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);if (userMap.isEmpty()){//及时为空也会被包装成一个对象,因此这里不能判断null而是isEmptyresponse.setStatus(401);return false;}//3.将map变成userDtoUserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);//4.存在就将当前用户存储在ThreadLocal中UserHolder.saveUser(userDTO);//5.每拦截一次, redis中存储的用户信息刷新一次stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除用户,防止内存泄漏UserHolder.removeUser();}
}
@Configuration
public class MyConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;//该类中有一个@Configuration注解说明该类可以由spring来帮我们管理对象@Overridepublic void addInterceptors(InterceptorRegistry registry) {//再该类中让spring帮我们管理对象然后传给LoginInterceptorregistry.addInterceptor(new LoginInterceptor(stringRedisTemplate)).excludePathPatterns(// 匿名对象 stream coding"/user/code","/voucher/**","/blog/hot","/shop/**","/shop-type/**","/upload/**","/user/login");}
}
1.5完善token认证的刷新机制
- 业务思路: 因为之前的一层拦截器只能拦截部分请求, 假如用户一直在浏览index页面30min,那么再访问需要user信息的页面的时候就会需要重新登录, 因此这里再多设置一层拦截器拦截所有请求
- 第一层拦截器: 拦截所有请求 + 将当前用户信息存储到ThreadLocal中;判断是否有用户的任务交给下一层,本层的任务是存储user到当前thread+刷新token(不判断token是否为空, 若无token代表无user信息, 无user信息的情况给第二层拦截器处理)
- 第二层拦截器: 拦截部分需要user信息的页面的请求, 若从ThreadLocal中取出来的数据为空则直接return false 即可
- 注意注册拦截器的时候拦截器有优先级顺序, 默认情况下都是0, 谁先注册谁先执行, 或者我们手动更改拦截器的优先级(优先级越小越先执行)
public class RefreshTokenInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;//用来接住外部传过来的StringRedisTemplatepublic RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate){this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.获得token, 判断token是否为空String token = request.getHeader("authorization");if (StrUtil.isBlank(token)) {return true;//均return true, 判断是否有用户的任务交给下一层,本层的任务是存储user到当前thread+刷新token}//2.通过token获取redis中的用户信息,并判断同户是否存在,不存在返回false,并设置状态码String tokenKey = LOGIN_USER_KEY + token;Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);if (userMap.isEmpty()){//及时为空也会被包装成一个对象,因此这里不能判断null而是isEmptyreturn true;}//3.将map变成userDtoUserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);//4.存在就将当前用户存储在ThreadLocal中UserHolder.saveUser(userDTO);//5.每拦截一次, redis中存储的用户信息刷新一次stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除用户,防止内存泄漏UserHolder.removeUser();}
}
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.从UserHolder获得用户UserDTO userDto = UserHolder.getUser();//2.判断是否有user用户if (userDto == null) {response.setStatus(401);return false;}return true;}//不用重写afterCompletion方法, 第一层拦截器负责重写拦截器执行顺序:preHandle: 拦截器1 -> 拦截器2afterCompletion: 拦截器2 - > 拦截器1
}
@Configuration
public class MyConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;//该类中有一个@Configuration注解说明该类可以由spring来帮我们管理对象@Overridepublic void addInterceptors(InterceptorRegistry registry) {//再该类中让spring帮我们管理对象然后传给LoginInterceptorregistry.addInterceptor(new LoginInterceptor()).excludePathPatterns(// 匿名对象 stream coding"/user/code","/voucher/**","/blog/hot","/shop/**","/shop-type/**","/upload/**","/user/login").order(1);registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);}
}
2.商户查询缓存
从本节开始我会说明这个业务的原因和业务的思路是如何的
2.1添加商户缓存(到redis中)
- 业务原因
- 业务思路
查看网络请求时间验证是否快了很多
2.2添加商户类型缓存(到redis中)
2.3(重点)redis中缓存更新策略分析(分四步)
- 业务原因: 如果数据库中的数据更新了, redis中的缓存如何更新?更新选择什么样的更新策略才能保证效率, 数据的一致性等问题?
- 业务思路:
- 首先我们看下缓存有哪些更细策略,对应的维护方法是如何的
- 上面我们知道了内存淘汰机制和超时剔除机制是如何实现的, 接下来重点介绍
主动更新
,主动更新分以下三种, 最终我们考虑到数据的可靠性,我们还是选择第一种.
- 上面我们已经考虑完选择Cache Aside这种数据库和缓存同时更新的策略,接下来思考细节问题:
-
- 更新缓存采用什么方式?结论:删除缓存的方式
-
- 如何保证原子性?结论:用事务机制
-
- (重点)先操作数据库还是缓存? 这两种方案各自会有何问题(考虑微观上操作系统中线程是抢占式的,及时代码中写了先xx在yy,但我们更新xx后再更新yy时, xx可能还没更新完或者一系列问题)
- (重点)先操作数据库还是缓存? 这两种方案各自会有何问题(考虑微观上操作系统中线程是抢占式的,及时代码中写了先xx在yy,但我们更新xx后再更新yy时, xx可能还没更新完或者一系列问题)
- 接下来我们探讨上面遗留下来的问题
先操作数据库还是缓存?
我们观察得知,其实发生下面这两种异常情况的主要原因是,删除缓存
更新数据库
查询缓存未命中,查询数据库
写入缓存
这四步因为线程执行顺序问题导致的一系列的后果; 观察下图的时候主要观察删除缓存
更新数据库
这两步
先删除缓存再操作数据库:
- 首先我们看下缓存有哪些更细策略,对应的维护方法是如何的
先操作数据库再删除缓存
方案二先操作数据库再删除缓存这种方案出现异常情况的概率比较低, 因为2.更新数据库
这一步操作时间一般比较长, 想让4.写入缓存
在2.更新数据库
后面发生的概率比较低;
要么查询缓存
和写入缓存
都在更新数据库
前面,那么查询缓存和写入缓存的都是旧数据之后靠淘汰机制兜底;
要么就都在更新数据库后面, 这样你未命中缓存后查数据库也是查的新数据.
- 总结就一句话:
先干时间长的, 再干时间短的
这种同步问题不要绕弯子, 太多细节会把自己弄晕,身为初学者, 直接先更新数据库再删除缓存就完事了; - 因为数据库的IO速度慢,时间长,如果先更新数据库那么在当前线程执行
更新数据库操作
到删除缓存
这期间很容易发生幺蛾子, 比如其他线程的查询操作来了,这样查询的和缓存的都是旧数据
; - 但如果
先删除缓存
, 再更新数据库
就会好很多,因为缓存的操作很快的, 你如果再缓存到更新数据库期间发生幺蛾子(那么大不了查询到的是旧数据,但起码缓存中存储的是新数据呀
)