亲爱的小伙伴们大家好,马上咱们就开始实战篇的内容了,相信通过本章的学习,小伙伴们就能理解各种redis的使用啦,接下来咱们来一起看看实战篇我们要学习一些什么样的内容。
这一块我们会使用redis共享session来实现。
通过本章节,我们会理解缓存击穿,缓存穿透,缓存雪崩等问题,让小伙伴的对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容。
通过本章节,我们可以学会Redis的计数器功能, 结合Lua完成高性能的redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列。
我们利用Redis的GEOHash来完成对于地理坐标的操作。
主要是使用Redis来完成统计功能。
使用Redis的BitMap数据统计功能。
基于Set集合的关注、取消关注,共同关注等等功能,这一块知识咱们之前就讲过,这次我们在项目中来使用一下。
基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞的排行榜功能。
以上这些内容咱们统统都会给小伙伴们讲解清楚,让大家充分理解如何使用Redis。
其中的表有:
●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报红,可以采取这个方法
点击重启,即可
打开service窗口
选择spring boot
点击运行,就可以启动该项目了。
如果是Linux上的Redis,那么还需要关闭防火墙
在Linux命令行中
systemctl status firewalld
说明防火墙启动的,要关闭防火墙
关闭防火墙
systemctl stop firewalld.service
关闭开机自启防火墙
systemctl disable firewalld.service
systemctl stop redis
然后找到redis.conf关闭保护模式
找到95行,设置为no
protected-mode no
systemctl start redis
查看redis服务状态
systemctl status redis
登录:http://localhost:8081/shop-type/list可以查看相关数据
有数据的原因是后台ShopTypeController.java写好了逻辑地址
将其复制到任意目录,要确保该目录不包含中文、特殊字符和空格,例如:
在nginx所在目录下打开一个CMD窗口,输入命令:
start nginx.exe
打开chrome浏览器,在空白页面点击鼠标右键,选择检查,即可打开开发者工具:
切换为手机模式
选择具体手机型号
然后访问: http://127.0.0.1:8080,即可看到页面:
注意,此时是启动spring boot的,否则界面里没图片
未启动Spring Boot
启动Spring Boot
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号。
如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户。
用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息。
用户在请求时候,会从cookie中携带者JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并且放行。
页面流程
点击我的之后,点击发送验证码,报错,但是接收到了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中插入成功,但是前台界面一闪而过
一闪而过的原因是还没有做登录校验。
温馨小贴士: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行
我们通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个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();
}
}
核心思路分析:
每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时整个登录拦截功能就会出现问题。
我们能如何解决这个问题呢?
早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了。
但是这种方案具有两个大问题
1、每台服务器中都有完整的一份session数据,服务器压力过大。
2、session拷贝数据时,可能会出现延迟。
所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了
首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如下图,如果使用String,同学们注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。
所以保存验证码我们可以使用String结构,保存用户信息我们可以使用Hash,进行key,field,value的存取,但是关于key的处理,session他是每个用户都有自己的session,但是redis的key是共享的,咱们就不能使用code作为key了。
在设计这个key的时候,我们之前讲过需要满足两点
1、key要具有唯一性
2、key要方便携带
但是如果把手机号这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了。
当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。
这里具体逻辑就不分析了,之前咱们已经重点分析过这个逻辑啦。
要修改的逻辑如下:
@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;
}
紧接着修改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 beanToMap(Object bean, Map 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 hashMap = new HashMap<>();
hashMap.put("id", userDTO.getId().toString());
hashMap.put("nickName", userDTO.getNickName());
hashMap.put("icon", userDTO.getIcon());*/
如图所示
再重启然后登录,发现登录成功了
登录成功后查看Redis图形界面
查看前台控制台,确实携带了authorization
总结
Redis代替session需要考虑的问题:
◆选择合适的数据结构
◆选择合适的key
◆选择合适的存储粒度
在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效(比如放行列表中的这些)。
所以此时token令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的。
既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新token,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。
第一个拦截器的任务
(1). 拦截一切路径
(2). 刷新token
(3).查询Redis的用户信息,能查询到就放到ThreadLocal中,查询不到就放行,让下一个拦截器处理。
第二个拦截器的任务
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拦截器