1、黑马点评复盘(短信登录-Session或Redis实现)
短信登录分别使用session和redis实现
1、基于Session实现登录
主要功能:
- 发送验证码
- 短信验证码登录、注册
- 校验登录状态
1.1 实现发送短信验证码功能
1.1.1 业务逻辑
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户
1.1.2 代码实现
模拟发送短信验证码功能,把短信验证码控制台打印
@Overridepublic Result sendCode(String phone, HttpSession session) {// 1. 校验手机号if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手机号格式错误!");}// 2.生成验证码, 长度6位随机数String code = RandomUtil.randomNumbers(CAPTCHA_LENGTH);// 3.保存验证码到sessionsession.setAttribute("code", code);// 4.发送验证码(模拟)log.info("发送验证码成功,验证码:{}", code);// 5.返回成功信息return Result.ok();}
代码使用了Hutool的工具类,RegexUtils和RandomUtil,其中:
- RegexUtils.isPhoneInvalid() : 用于手机号格式校验、邮箱校验、验证码校验。手机号格式,不满足,返回true。
- RandomUtil.randomNumbers() : 生成随机数
1.1.3 实现效果
1.2 实现短信验证登录、注册功能
1.2.1 业务逻辑
用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息
1.2.2 代码实现
@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {// 1.获取手机号、验证码String phone = loginForm.getPhone();String code = loginForm.getCode();// 2.校验验证码if (!code.equals(session.getAttribute(CAPTCHA))) {return Result.fail("验证码错误!");}// 3.根据手机号查询用户User user = this.lambdaQuery().eq(User::getPhone, phone).one();// 4.判断用户是否存在if (user == null) {// 4.1 不存在,创建新用户,并保存到数据库中user = createNewUser(phone);}// 4.2 存在,保存用户到sessionLoginFormDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);session.setAttribute(USER_NICK_NAME, userDTO);// 5.返回成功信息return Result.ok();}/*** 创建新用户* @param phone 手机号* @return User*/private User createNewUser(String phone) {User user = new User();user.setPhone(phone);user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(USER_NICK_NAME_APPEND_LENGTH));save(user);return user;}
代码使用了Hutool的工具类,BeanUtil,其中:
-
BeanUtil.copyProperties(user, UserDTO.class) : 用于将user中的数据复制给UserDTO实体,其中字段和字段类型要保存一致。这步操作是用来,session存储尽可能存储少量数据,防止数据泄露。
1.2.3 实现效果
注意用Apifox实现这个操作,要先调用手机验证码接口,然后在调用用户登录接口,并且用户登录接口的验证码参数需要用到调用的手机验证码接口生成的验证码
1.2.4 tomcat的运行原理和ThreadLocal
1.2.4.1 tomcat的运行原理
当用户发起请求时,会访问我们像tomcat注册的端口,任何程序想要运行,都需要有一个线程对当前端口号进行监听,tomcat也不例外,当监听线程知道用户想要和tomcat连接连接时,那会由监听线程创建socket连接,socket都是成对出现的,用户通过socket像互相传递数据,当tomcat端的socket接受到数据后,此时监听线程会从tomcat的线程池中取出一个线程执行用户请求,在我们的服务部署到tomcat后,线程会找到用户想要访问的工程,然后用这个线程转发到工程中的controller,service,dao中,并且访问对应的DB,在用户执行完请求后,再统一返回,再找到tomcat端的socket,再将数据写回到用户端的socket,完成请求和响应
通过以上讲解,我们可以得知 每个用户其实对应都是去找tomcat线程池中的一个线程来完成工作的, 使用完成后再进行回收,既然每个请求都是独立的,所以在每个用户去访问我们的工程时,我们可以使用threadlocal来做到线程隔离,每个线程操作自己的一份数据
1.2.4.2 ThreadLocal
如果小伙伴们看过threadLocal的源码,你会发现在threadLocal中,无论是他的put方法和他的get方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map,只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离
1.3 实现登录校验拦截器(ThreadLocal)
如果不用拦截器,每个controller都会先进行登录校验
1.3.1 业务逻辑
把拦截器拦截的用户信息保存到ThreadLocal,因为ThreadLocal是线程,每一个进入tomcat的请求都是一个线程,ThreadLocal给每一个用户开辟线程空间创建独立线程。
1.3.2 代码实现
/*** ThreadLocal 处理user信息*/
public class UserHolder {/*** 定义ThreadLocal常量* 这里使用UserDTO,是因为ThreadLocal存储,只需要存储少量数据,避免数据泄露*/private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();/*** 保存用户** @param user*/public static void saveUser(UserDTO user) {tl.set(user);}/*** 从ThreadLocal获取用户** @return UserDTO*/public static UserDTO getUser() {return tl.get();}/*** 删除用户*/public static void removeUser() {tl.remove();}
}/*** 登录拦截器*/
public class LoginInterceptor implements HandlerInterceptor {/*** 前置拦截* 登录校验* @param request 获取session* @param response* @param handler* @return* @throws Exception*/@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1.从request中获取sessionHttpSession session = request.getSession();// 2.获取session中的用户 userObject user = session.getAttribute(USER_NICK_NAME);// 3.判断用户是否存在if (user == null) {// 4.不存在,拦截 ,返回状态码401response.setStatus(UNAUTHORIZED);return false;}// 5.存在,保存用户信息到ThreadLocalUserHolder.saveUser((UserDTO) user);// 6.放行return true;}/*** 渲染之后拦截* 用户登录完毕,销毁登录信息,避免用户信息泄露* @param request* @param response* @param handler* @param ex* @throws Exception*/@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {// 移除用户,避免数据泄露UserHolder.removeUser();}
}/*** 登录拦截器生效*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {/*** 添加拦截器* @param registry 拦截器注册器*/@Overridepublic void addInterceptors(InterceptorRegistry registry) {// 添加拦截器,并排除不需要拦截的路径// ** 是通配符,表示任意registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/shop/**","/voucher/**","/shop-type/**","/upload/**","/blog/hot","/user/code","/user/login");}
}
2、Session实现登录的弊端
session登录会有session共享问题
每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
但是这种方案具有两个大问题
1、每台服务器中都有完整的一份session数据,服务器压力过大。
2、session拷贝数据时,可能会出现延迟
所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了
3、基于Redis实现登录
首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如下图,如果使用String,同学们注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。
3.1 实现发送短信验证码功能
3.1.1 业务逻辑
由原理存储到session,改为存储到redis中。
3.1.2 代码实现
@Overridepublic Result sendCode(String phone, HttpSession session) {// 1. 校验手机号if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手机号格式错误!");}// 2.生成验证码, 长度6位随机数String code = RandomUtil.randomNumbers(CAPTCHA_LENGTH);// 3.保存验证码到redis,设置过期时间stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);// 4.发送验证码(模拟)log.info("发送验证码成功,验证码:{}", code);// 5.返回成功信息return Result.ok();}
3.1.3 Apifox调用接口,实现效果
小技巧:把有效期改为无效
3.2 实现短信验证登录、注册功能
3.2.1 业务逻辑
当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。
3.2.2 代码实现
@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {// 1.获取手机号、验证码String phone = loginForm.getPhone();String code = loginForm.getCode();// 1. 校验手机号if (RegexUtils.isPhoneInvalid(phone)) {// 2.如果不符合,返回错误信息return Result.fail("手机号格式错误!");}// 3.从redis中获取验证码,校验验证码String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);if (StrUtil.isBlank(cacheCode) && !code.equals(cacheCode)) {// 不一致,报错return Result.fail("验证码错误!");}// 4.根据手机号查询用户User user = this.lambdaQuery().eq(User::getPhone, phone).one();// 5.判断用户是否存在if (user == null) {// 5.1 不存在,创建新用户,并保存到数据库中user = createNewUser(phone);}// 7.保存用户信息到 redis中// 7.1.随机生成token,作为登录令牌String token = UUID.randomUUID().toString(true);// 7.2.将User对象转为HashMap存储UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((k, v) -> v.toString()));// 7.3.存储stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, userMap);// 7.4.设置token有效期stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);// 8.返回tokenreturn Result.ok(token);}/*** 创建新用户* @param phone 手机号* @return User*/private User createNewUser(String phone) {User user = new User();user.setPhone(phone);user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(USER_NICK_NAME_APPEND_LENGTH));save(user);return user;}
代码中使用Hutool的BeanUtil工具的beanToMap方法,把对象转成Map。
- Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((k, v) -> v.toString()));
3.2.3 实现效果
用Apifox调用接口,要先调用3.2.1,获取验证码作为参数,然后在调用登录接口
3.3 解决状态登录刷新问题
把token存储到redis中,设置有效期,为了防止有效期失效,每当用户访问,就刷新token,让token不失效。
3.3.1 业务逻辑(拦截器优化)
设置两个拦截器,第一个拦截器用于刷新token,保存ThreadLocal。确保一切请求都触发刷新token的动作。第二个拦截器,查ThreadLocal,不存在就拦截
3.3.2 代码实现
/*** 第一个拦截器:刷新token、保存ThreadLocal* 拦截所有请求,只有要请求就拦截,然后进行刷新token有效期*/
public class RefreshTokenInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1.获取请求头中的tokenString token = request.getHeader("authorization");if (StrUtil.isBlank(token)) {return true;}// 2.基于TOKEN获取redis中的用户String key = LOGIN_USER_KEY + token;Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);// 3.判断用户是否存在if (userMap.isEmpty()) {return true;}// 5.将查询到的hash数据转为UserDTOUserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);// 6.存在,保存用户信息到 ThreadLocalUserHolder.saveUser(userDTO);// 7.刷新token有效期stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);// 8.放行return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {// 移除用户UserHolder.removeUser();}
}/*** 第二个拦截器,判断ThreadLocal中是否有用户信息*/
public class LoginInterceptor implements HandlerInterceptor {/*** 前置拦截* 登录校验* @param request 获取session* @param response* @param handler* @return* @throws Exception*/@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1.判断是否需要拦截(ThreadLocal中是否有用户)if (UserHolder.getUser() == null) {// 没有,需要拦截,设置状态码response.setStatus(UNAUTHORIZED);// 拦截return false;}// 有用户,放行return true;}}/*** 登录拦截器生效*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;/*** 添加拦截器* 给两个拦截器后面加 order,用于决定谁先执行 值越小越先执行* @param registry 拦截器注册器*/@Overridepublic void addInterceptors(InterceptorRegistry registry) {// 第二个拦截器,拦部分请求,登录拦截器registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/shop/**","/voucher/**","/shop-type/**","/upload/**","/blog/hot","/user/code","/user/login").order(1);// 第一个拦截器,拦所有请求(/**),刷新token拦截器registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);}
}
上述代码使用Hutool的BeanUtil工具的fillBeanWithMap方法,把Map转实体。
- UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
视频学习:黑马点评项目实战,掌握企业实战项目真实应用场景,一套精通redis缓存技术_哔哩哔哩_bilibili