2022黑马Redis跟学笔记.实战篇 二
- 实战篇Redis
- 开篇导读
- 4.1短信登录
- 4.1.1. 搭建黑马点评项目
- 一、导入黑马点评项目
- 二、导入SQL
- 三、有关当前模型
- 四、导入后端项目
- 相关依赖
- 配置redis和mysql连接
- 项目组成概述
- 关闭Linux防火墙
- 五、导入前端工程
- 六、 运行前端项目
- 4.1.2. 基于Session实现登录流程
- 1.实现发送短信验证码功能
- 2. 实现登录拦截和校验功能
- 3. 隐藏用户敏感信息
- 4.1.3. session共享问题
- 集群的session共享问题
- 4.1.4. Redis实现共享session
- 1.设计key的结构
- 2. 设计key的具体细节
- 3. 整体访问流程
- 4. 基于Redis实现短信登录
- (1).修改发送短信验证码
- (2).修改短信验证码登录、注册
- 4.1.5. Redis实现session的刷新问题
- 1. 初始方案思路总结:
- 2. 优化方案
- 3. 代码
实战篇Redis
开篇导读
亲爱的小伙伴们大家好,马上咱们就开始实战篇的内容了,相信通过本章的学习,小伙伴们就能理解各种redis的使用啦,接下来咱们来一起看看实战篇我们要学习一些什么样的内容。
- 短信登录
这一块我们会使用redis共享session来实现。
- 商户查询缓存
通过本章节,我们会理解缓存击穿,缓存穿透,缓存雪崩等问题,让小伙伴的对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容。
- 优惠卷秒杀
通过本章节,我们可以学会Redis的计数器功能, 结合Lua完成高性能的redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列。
- 附近的商户
我们利用Redis的GEOHash来完成对于地理坐标的操作。
- UV统计
主要是使用Redis来完成统计功能。
- 用户签到
使用Redis的BitMap数据统计功能。
- 好友关注
基于Set集合的关注、取消关注,共同关注等等功能,这一块知识咱们之前就讲过,这次我们在项目中来使用一下。
- 达人探店
基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞的排行榜功能。
以上这些内容咱们统统都会给小伙伴们讲解清楚,让大家充分理解如何使用Redis。
4.1短信登录
4.1.1. 搭建黑马点评项目
一、导入黑马点评项目
二、导入SQL
其中的表有:
●tb_user: 用户表
●tb_user_info: 用户详情表
●tb_shop:商户信息表
●tb_shop_ type: 商户类型表
●tb_blog: 用户日记表(达人探店日记)
●tb_follow: 用户关注表
●tb_voucher:优惠券表
●tb_voucher_order: 优惠券的订单表
三、有关当前模型
手机或者app端发起请求,请求我们的nginx服务器,nginx基于七层模型走的事HTTP协议,可以实现基于Lua直接绕开tomcat访问redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游tomcat服务器,打散流量,我们都知道一台4核8G的tomcat,在优化和处理简单业务的加持下,大不了就处理1000左右的并发, 经过nginx的负载均衡分流后,利用集群支撑起整个项目,同时nginx在部署了前端项目后,更是可以做到动静分离,进一步降低tomcat服务的压力,这些功能都得靠nginx起作用,所以nginx是整个项目中重要的一环。
在tomcat支撑起并发流量后,我们如果让tomcat直接去访问Mysql,根据经验Mysql企业级服务器只要上点并发,一般是16或32 核心cpu,32 或64G内存,像企业级mysql加上固态硬盘能够支撑的并发,大概就是4000起~7000左右,上万并发, 瞬间就会让Mysql服务器的cpu,硬盘全部打满,容易崩溃,所以我们在高并发场景下,会选择使用mysql集群,同时为了进一步降低Mysql的压力,同时增加访问的性能,我们也会加入Redis,同时使用Redis集群使得Redis对外提供更好的服务。
四、导入后端项目
在资料中提供了一个项目源码:
打开项目
设置编码
配置Maven
配置Maven的下载路径
-DarchetypeCatalog=internal
如果pom.xml中的2.3.12.RELEASE报红,可以采取这个方法
点击重启,即可
相关依赖
简单看一下pom.xml的依赖
配置redis和mysql连接
项目组成概述
打开service窗口
选择spring boot
点击运行,就可以启动该项目了。
项目启动成功
关闭Linux防火墙
如果是Linux上的Redis,那么还需要关闭防火墙
在Linux命令行中
查看防火墙状态
systemctl status firewalld
说明防火墙启动的,要关闭防火墙
关闭防火墙
systemctl stop firewalld.service
关闭开机自启防火墙
systemctl disable firewalld.service
此刻查看防火墙状态是
先关闭redis服务
systemctl stop redis
然后找到redis.conf关闭保护模式
找到95行,设置为no
protected-mode no
重启redis服务
systemctl start redis
查看redis服务状态
systemctl status redis
登录:http://localhost:8081/shop-type/list可以查看相关数据
有数据的原因是后台ShopTypeController.java写好了逻辑地址
五、导入前端工程
在资料中提供了一个nginx文件夹
将其复制到任意目录,要确保该目录不包含中文、特殊字符和空格,例如:
六、 运行前端项目
在nginx所在目录下打开一个CMD窗口,输入命令:
start nginx.exe
打开chrome浏览器,在空白页面点击鼠标右键,选择检查,即可打开开发者工具:
切换为手机模式
选择具体手机型号
然后访问: http://127.0.0.1:8080,即可看到页面:
注意,此时是启动spring boot的,否则界面里没图片
未启动Spring Boot
启动Spring Boot
4.1.2. 基于Session实现登录流程
发送短信验证码:
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号。
如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户。
短信验证码登录、注册:
用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息。
校验登录状态:
用户在请求时候,会从cookie中携带者JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并且放行。
1.实现发送短信验证码功能
页面流程
点击我的之后,点击发送验证码,报错,但是接收到了POST请求
具体代码如下
贴心小提示:
具体逻辑上文已经分析,我们仅仅只需要按照提示的逻辑写出代码即可。
修改UserController.java
- 发送验证码
UserController.java
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// TODO 发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}
修改IUserService.java,添加
Result sendCode(String phone, HttpSession session);
UserServiceImpl.java,添加
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号(是否符合手机号的规范)
if (RegexUtils.isPhoneInvalid(phone)) {
// 1.1 如果不符合,返回错误信息
/**
* 注意该方法是return !str.matches(regex);
所以true是验证不通过
*/
return Result.fail("手机号格式错误,请检查!");
}
// 1.2 如果符合,生成验证码(使用hutool提供的工具类)
String code = RandomUtil.randomNumbers(6);
// 2. 保存验证码到session
session.setAttribute("code", code);
// 3. 发送验证码
log.debug("发送短信验证码成功,验证码是:" + code);
// 返回ok
return Result.ok();
}
}
注意代码写完之后,要重启之后才生效
重启后点击发送验证码,前台开发者工具-网络-预览 显示成功
再看一下IDEA的控制台
- 登录
填写账号和密码,勾选已经阅读协议,发现报错
查看标头,请求URL中没有跟用户信息的参数
再去看负载,发现是json格式的
短信验证登录
看UserController.java
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// TODO 实现登录功能
return userService.login(loginForm, session);
}
修改IUserService.java,添加抽象方法
Result login(LoginFormDTO loginForm, HttpSession session);
修改UserServiceImpl.java
这里注意,使用了两种方式二选一,Mybatisplus提供了mapper接口的方法和service接口的方法。
UserServiceImpl.java
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Autowired
private UserMapper userMapper;
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验手机号和验证码
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误,请检查!");
}
// 2. 校验验证码是否正确
Object o = session.getAttribute("code");
String code_Session = (String) o;
String code_loginForm = loginForm.getCode();
// 2.1 验证码错误,报错
if (null == code_Session || "".equals(code_Session)) {
return Result.fail("验证码过期,请重新生成!");
}
if (null == code_loginForm || "".equals(code_loginForm)) {
return Result.fail("验证码为空,请重新输入!");
}
if (!code_Session.equals(code_loginForm)) {
// 验证码核验不一致,报错
return Result.fail("验证码错误,请检查!");
}
// 2.2 验证码正确,根据手机号查询用户
// SELECT * FROM comment.tb_user WHERE phone = ?
// 以下方式一和方式二,二选一即可
// 方式一:根据Mapper查询
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getPhone, phone);
User user = userMapper.selectOne(wrapper);
// 方式二:在Service中查询
//user = query().eq("phone",phone).one();
// 3.判断用户是否存在
if (null == user) {
// 3.1 不存在就创建新用户并保存
user = createUserWithPhone(phone);
}
// 4.保存用户到session
session.setAttribute("user", user);
return Result.ok();
}
/**
* @param
* @return void
* @description //根据手机号创建用户并且保存
* @param: phone
* @date 2023/2/11 13:30
* @author wty
**/
private User createUserWithPhone(String phone) {
// 1.创建新用户
User user = new User();
user.setPhone(phone);
// 随机生成的用户名:"user_" + 随机10位
String nickName = SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10);
user.setNickName(nickName);
// 2.保存用户 insert into tb_user values (?,?,?,?,?,?,?)
// 以下方式二选一即可
// 方式一: 用Mapper接口
userMapper.insert(user);
// 方式二: 用Service接口
//save(user);
return user;
}
}
如果用Mapper接口的话,需要加上注解
UserMapper.java
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
最后运行项目,点击登录
登录后,数据在mysql中插入成功,但是前台界面一闪而过
一闪而过的原因是还没有做登录校验。
2. 实现登录拦截和校验功能
温馨小贴士:tomcat的运行原理
当用户发起请求时,会访问我们像tomcat注册的端口,任何程序想要运行,都需要有一个线程对当前端口号进行监听,tomcat也不例外,当监听线程知道用户想要和tomcat进行连接时,会由监听线程创建socket连接,socket都是成对出现的,用户通过socket互相传递数据,当tomcat端的socket接受到数据后,此时监听线程会从tomcat的线程池中取出一个线程执行用户请求,在我们的服务部署到tomcat后,线程会找到用户想要访问的工程,然后用这个线程转发到工程中的controller,service,dao中,并且访问对应的DB,在用户执行完请求后,再统一返回,再找到tomcat端的socket,再将数据写回到用户端的socket,完成请求和响应。
通过以上讲解,我们可以得知每个用户其实对应都是去找tomcat线程池中的一个线程来完成工作的, 使用完成后再进行回收,既然每个请求都是独立的,所以在每个用户去访问我们的工程时,我们可以使用Threadlocal来做到线程隔离,每个线程操作自己的一份数据。
温馨小贴士:关于Threadlocal
如果小伙伴们看过ThreadLocal的源码,你会发现在ThreadLocal中,无论是他的put方法和他的get方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map,只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离。
拦截器代码
新建LoginInterceptor.java
LoginInterceptor.java
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private UserMapper userMapper;
/**
* @param
* @return boolean
* @description //前置拦截器
* @param: request
* @param: response
* @param: handler
* @date 2023/2/11 14:13
* @author wty
**/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取session
HttpSession session = request.getSession();
// 2.获取sessionh中的用户
Object o = session.getAttribute("user");
User user = (User) o;
// 3.判断用户是否存在
if (null == user) {
// 3.2 不存在就拦截,返回状态码401(未授权)
response.setStatus(401);
return false;
}
// 3.1 存在就保存到ThreadLocal中
UserHolder.saveUser(user);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
// 这里注意ThreadLocal中key是弱引用,可能被回收
// 而value 是强引用不会被回收,所以user对象没被回收
UserHolder.removeUser();
}
}
这里保存user对象用到了Threadlocal
存储如下:
这里注意User.java类要简单修改,继承UserDTO,相当于进行了扩写。
这里用UserDTO的原因是,session中不用存储全部的用户信息
让拦截器生效
新建类MvcConfig.java
注意: 这里放行的没有/user/me 如果加了请赶紧删掉,不然一点击登录就会跑到首页,再点击我的,又跑到登录上了。
@Configuration
public class MvcConfig implements WebMvcConfigurer {
/**
* @param
* @return void
* @description //添加拦截器
* @param: registry
* @date 2023/2/11 14:43
* @author wty
**/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
// 以下几个都是放行的
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/upload/**",
"/voucher/**",
"/shop-type/**"
);// 通过排除一些不必要的路径,不用所有都拦截
}
}
最后我们要让Controller获取到拦截器过滤后的结果。
修改UserController.java
@GetMapping("/me")
public Result me() {
// TODO 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
此时我们运行测试类发现报错,类不兼容
明白了,我们在第一次UserServiceImpl.java
login方法需要修改成userDTO对象
UserServiceImpl.java修改代码如下
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验手机号和验证码
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误,请检查!");
}
// 2. 校验验证码是否正确
Object o = session.getAttribute("code");
String code_Session = (String) o;
String code_loginForm = loginForm.getCode();
// 2.1 验证码错误,报错
if (null == code_Session || "".equals(code_Session)) {
return Result.fail("验证码过期,请重新生成!");
}
if (null == code_loginForm || "".equals(code_loginForm)) {
return Result.fail("验证码为空,请重新输入!");
}
if (!code_Session.equals(code_loginForm)) {
// 验证码核验不一致,报错
return Result.fail("验证码错误,请检查!");
}
// 2.2 验证码正确,根据手机号查询用户
// SELECT * FROM comment.tb_user WHERE phone = ?
// 以下方式一和方式二,二选一即可
// 方式一:根据Mapper查询
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getPhone, phone);
User user = userMapper.selectOne(wrapper);
// 方式二:在Service中查询
//user = query().eq("phone",phone).one();
// 3.判断用户是否存在
if (null == user) {
// 3.1 不存在就创建新用户并保存
user = createUserWithPhone(phone);
}
// 这里要注意session里不宜保存User的全部信息,容易泄密个人信息,这个放一部分即可
// 需要把User转成UserDTO
UserDTO userDTO = new UserDTO();
userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 4.保存用户到session
session.setAttribute("user", userDTO);
return Result.ok();
}
LoginInterceptor.java也更改成UserDTO
LoginInterceptor.java代码如下
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取session
HttpSession session = request.getSession();
// 2.获取sessionh中的用户
Object o = session.getAttribute("user");
UserDTO user = (UserDTO) o;
// 3.判断用户是否存在
if (null == user) {
// 3.2 不存在就拦截,返回状态码401(未授权)
response.setStatus(401);
return false;
}
// 3.1 存在就保存到ThreadLocal中
UserHolder.saveUser(user);
return true;
}
修改User.java,把extends UserDTO 去掉
配置完后重新启动,登录
跳转了主页
这里点击我的即可
补充以下,如果想跳转到和老师一样的个人详情页,需要更改前端代码。更改login.html
更改L87行
再看一下开发者工具中的数据
3. 隐藏用户敏感信息
我们通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了
在登录方法处修改
见上
在拦截器处:
见上
在UserHolder处:将user对象换成UserDTO
新版资料中已经更改了,无需修改
UserHolder.java
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
4.1.3. session共享问题
集群的session共享问题
核心思路分析:
每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时整个登录拦截功能就会出现问题。
我们能如何解决这个问题呢?
早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了。
但是这种方案具有两个大问题
1、每台服务器中都有完整的一份session数据,服务器压力过大。
2、session拷贝数据时,可能会出现延迟。
所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了
4.1.4. Redis实现共享session
1.设计key的结构
首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如下图,如果使用String,同学们注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。
2. 设计key的具体细节
所以保存验证码我们可以使用String结构,保存用户信息我们可以使用Hash,进行key,field,value的存取,但是关于key的处理,session他是每个用户都有自己的session,但是redis的key是共享的,咱们就不能使用code作为key了。
在设计这个key的时候,我们之前讲过需要满足两点
1、key要具有唯一性
2、key要方便携带
如果我们采用phone:手机号来存储当然是可以的。
但是如果把手机号这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了。
3. 整体访问流程
当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。
4. 基于Redis实现短信登录
这里具体逻辑就不分析了,之前咱们已经重点分析过这个逻辑啦。
(1).修改发送短信验证码
要修改的逻辑如下:
- 保存验证码到session → 保存验证码到redis(String)
- redis存储的时候,key是手机号
修改UserServiceImpl.java的sendCode(手机发送验证码方法)
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号(是否符合手机号的规范)
if (RegexUtils.isPhoneInvalid(phone)) {
// 1.1 如果不符合,返回错误信息
/**
* 注意该方法是return !str.matches(regex);
所以true是验证不通过
*/
return Result.fail("手机号格式错误,请检查!");
}
// 1.2 如果符合,生成验证码(使用hutool提供的工具类)
String code = RandomUtil.randomNumbers(6);
// 2. 保存验证码到session → 保存验证码到redis 使用String的形式存取
// 一般key都设置为 业务前缀:属性名:key 加以区分
stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code);
// 设置有效期,时间一到自动销毁,比如设置1分钟 最好用工具类提供的静态属性来定义数字和固定值
// 方式一:set的重载方法
//stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
// stringRedisTemplate.expire("login:code" + phone, 1, TimeUnit.MINUTES);
// 方式二: expire的重载方法
stringRedisTemplate.expire(RedisConstants.LOGIN_CODE_KEY + phone, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
session.setAttribute("code", code);
// 3. 发送验证码
log.debug("发送短信验证码成功,验证码是:" + code);
// 返回ok
return Result.ok();
}
RedisConstants.java增加常量
public class RedisConstants {
public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 1L;
}
(2).修改短信验证码登录、注册
紧接着修改UserServiceImpl.java的login方法
UserServiceImpl.java
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验手机号和验证码
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误,请检查!");
}
// 2. 从session中获取校验验证码,并校验是否正确
// TODO 从redis中获取校验验证码,并校验是否正确
String code_Redis = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
//Object o = session.getAttribute("code");
//String code_Session = (String) o;
String code_loginForm = loginForm.getCode();
// 2.1 验证码错误,报错
/* if (null == code_Session || "".equals(code_Session)) {
return Result.fail("验证码过期,请重新生成!");
}*/
if (null == code_Redis || "".equals(code_Redis)) {
return Result.fail("验证码过期,请重新生成!");
}
if (null == code_loginForm || "".equals(code_loginForm)) {
return Result.fail("验证码为空,请重新输入!");
}
/*if (!code_Session.equals(code_loginForm)) {
// 验证码核验不一致,报错
return Result.fail("验证码错误,请检查!");
}*/
if (!code_Redis.equals(code_loginForm)) {
// 验证码核验不一致,报错
return Result.fail("验证码错误,请检查!");
}
// 2.2 验证码正确,根据手机号查询用户
// SELECT * FROM comment.tb_user WHERE phone = ?
// 以下方式一和方式二,二选一即可
// 方式一:根据Mapper查询
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getPhone, phone);
User user = userMapper.selectOne(wrapper);
// 方式二:在Service中查询
//user = query().eq("phone",phone).one();
// 3.判断用户是否存在
if (null == user) {
// 3.1 不存在就创建新用户并保存
user = createUserWithPhone(phone);
}
// 这里要注意session里不宜保存User的全部信息,容易泄密个人信息,这个放一部分即可
// 需要把User转成UserDTO
UserDTO userDTO = new UserDTO();
userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 4.保存用户到session → Redis
//session.setAttribute("user", userDTO);
// 5.随机生成token作为登录令牌
String token = UUID.randomUUID().toString(true);
// 将 UserDTO转换为Map
Map<String, Object> map = BeanUtil.beanToMap(userDTO);
// 6.将UserDTO的Map对象转为Hash存储
// "login:token:" + token存储
stringRedisTemplate.opsForHash().putAll(RedisConstants.LOGIN_USER_KEY + token, map);
// 7.返回token
return Result.ok(token);
}
考虑一下有效期问题,于是我们增加代码
UserServiceImpl.java
// 7.设置有效期30分钟:这个30分钟指的是从用户登录开始计算30分钟
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
RedisConstants.java增加常量
public class RedisConstants {
public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 30L;
}
但是现在存在问题,目前有效期是指,从登录开始往后30分钟,就失效,这期间无论用户是登录还是未登录,之后都会失效,这明显是不对的,应该是用户下线后30分钟再失效。
我们的思路是,当我们登录触发登录校验的拦截器后,就会更新token的有效期。
修改LoginInterceptor.java,修改之前,解决一个问题。
更改MvcConfig.java
看前台代码login.html,引入common.js
common.js中前台通过拦截器拿到token进行保存
所以token在request请求的头部,名称是authorization
修改LoginInterceptor.java代码如下
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取session → 获取请求头中的token
HttpSession session = request.getSession();
// 这里请求头的名称和common.js中 L10 一致
String token = request.getHeader("authorization");
// 判断token是否为空,时空就没必要取了
if (StrUtil.isBlank(token)) {
response.setStatus(401);
return false;
}
// 2.获取sessionh中的用户 → 获取redis中token对应的用户
//Object o = session.getAttribute("user");
//UserDTO user = (UserDTO) o;
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
// 3.判断用户是否存在
// 如果是null,entries会返回空的map,所以判断是否为空即可
/* if (null == user) {
// 3.2 不存在就拦截,返回状态码401(未授权)
response.setStatus(401);
return false;
}*/
if (map.isEmpty()) {
response.setStatus(401);
return false;
}
// 将查询到的Hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
// 3.1 存在就保存到ThreadLocal中
UserHolder.saveUser(userDTO);
// 4.刷新token有效期
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 5.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
// 这里注意ThreadLocal中key是弱引用,可能被回收
// 而value 是强引用不会被回收,所以user对象没被回收
UserHolder.removeUser();
}
}
重启程序,点击发送验证码
验证码如下
看一下Redis的图形界面
直接登录的话,发现报错
网页控制台输出错误信息
IDEA控制台输出错误信息
原因很简单StringRedisTemplate要求键和值都是String,而UserDTO类中id是Long类型的,所以会有异常。
修改UserServiceImpl.java
// 将 UserDTO转换为Map方式一:用自定义转换
//public static Map<String, Object> beanToMap(Object bean, Map<String, Object> targetMap, CopyOptions copyOptions){}
// setIgnoreNullValue忽略空值
// setFieldValueEditor函数式接口
Map<String, Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true)
.setFieldValueEditor((fileName, fileValue) -> fileValue.toString()));
// 方式二:自己创建map然后put
/*HashMap<String, String> hashMap = new HashMap<>();
hashMap.put("id", userDTO.getId().toString());
hashMap.put("nickName", userDTO.getNickName());
hashMap.put("icon", userDTO.getIcon());*/
如图所示
再重启然后登录,发现登录成功了
登录成功后查看Redis图形界面
查看前台控制台,确实携带了authorization
总结
Redis代替session需要考虑的问题:
◆选择合适的数据结构
◆选择合适的key
◆选择合适的存储粒度
4.1.5. Redis实现session的刷新问题
1. 初始方案思路总结:
在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效(比如放行列表中的这些)。
所以此时token令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的。
2. 优化方案
既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新token,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。
-
第一个拦截器的任务
-
(1). 拦截一切路径
-
(2). 刷新token
-
(3).查询Redis的用户信息,能查询到就放到ThreadLocal中,查询不到就放行,让下一个拦截器处理。
-
第二个拦截器的任务
-
(1).获取ThreadLocal中的用户信息,用户存在就放行,不存在就拦截
3. 代码
新建第一个拦截器,拦截所有路径。
RefreshTokenInterceptor.java
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取session → 获取请求头中的token
HttpSession session = request.getSession();
// 这里请求头的名称和common.js中 L10 一致
String token = request.getHeader("authorization");
// 判断token是否为空,是空就直接放行即可
if (StrUtil.isBlank(token)) {
//response.setStatus(401);
return true;
}
// 2.获取sessionh中的用户 → 获取redis中token对应的用户
//Object o = session.getAttribute("user");
//UserDTO user = (UserDTO) o;
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
// 3.判断用户是否存在
// 如果是null,entries会返回空的map,所以判断是否为空即可,空就放行即可
/* if (null == user) {
// 3.2 不存在就拦截,返回状态码401(未授权)
response.setStatus(401);
return false;
}*/
if (map.isEmpty()) {
//response.setStatus(401);
return true;
}
// 将查询到的Hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
// 3.1 存在就保存到ThreadLocal中
UserHolder.saveUser(userDTO);
// 4.刷新token有效期
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 5.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
// 这里注意ThreadLocal中key是弱引用,可能被回收
// 而value 是强引用不会被回收,所以user对象没被回收
UserHolder.removeUser();
}
}
LoginInterceptor.java
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 判断是否需要拦截(依据ThreadLocal中是否有用户,如果没有就拦截,有就放行)
UserDTO userDTO = UserHolder.getUser();
if (null == userDTO) {
response.setStatus(401);
return false;
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
// 这里注意ThreadLocal中key是弱引用,可能被回收
// 而value 是强引用不会被回收,所以user对象没被回收
UserHolder.removeUser();
}
}
修改 MvcConfig.java
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 第1个拦截器,用来拦截所有
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate));
// 第2个拦截器,用来判断ThreadLocal中是否有UserDTO对象,有就放行
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
// 以下几个都是放行的
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/upload/**",
"/voucher/**",
"/shop-type/**"
);// 通过排除一些不必要的路径,不用所有都拦截
}
}
这样写并不能保证拦截器的执行顺序,用到注解,给第一个拦截器RefreshTokenInterceptor添加注解。
看一下执行顺序,先跑的Refresh拦截器,后跑的Login拦截器
最后测试一下
等待一会儿点击我的,会重置token的TTL,点击首页也会重置token