1.流程分析

1.1发送短信验证码
提交手机号的时候要进行校验手机号,校验成功才会去生成验证码,将验证码保存到session,发生他把这部分那。
1.2短信验证码登录/注册
如果提交手机号和验证码之后,校验一致才进行根据手机号查询用户,进行创建新用户/登录成功,将信息保存到session进行返回。
1.3校验登录状态
前端传递过来cookie,携带其中的sessionID,从session中获取到用户信息,校验是否存在,存在就将用户保存到TheadLocal,不存在就拦截。
2.实现发送短信验证码
利用一些封装的工具生成验证码和校验手机号。
利用session进行存储验证码,方便校验,比较不错。
java
@Override
public Result sendCode(String phone, HttpSession session) {
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
// 3. 生成验证码
String code = RandomUtil.randomNumbers(6);
// 2. 保存验证码到session
session.setAttribute(LOGIN_CODE_KEY + phone, code);
// 发送验证码
// todo 实现发送验证码
log.debug("发送短信验证码成功: {}", code);
// 返回OK
return Result.ok();
}

3.短信验证码登录
最重要的可能是封装的思想吧,封装一定的常量和借助mybatis-plus的高级的功能。
重点:抽取逻辑,mybatis-plus高级功能。
java
/**
* 实现登录功能
*
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式不正确");
}
// 2. 校验验证码
// 从session中获取到验证码
String phoneForCode = (String) session.getAttribute(LOGIN_CODE_KEY + phone);
// 校验验证码
String code = loginForm.getCode();
if (!StrUtil.equals(code, phoneForCode)) {
return Result.fail("验证码错误!");
}
// 3. 查询用户是否存在
// QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
// userQueryWrapper.eq("phone", phone);
// User user = userMapper.selectOne(userQueryWrapper);
User user = query().eq("phone", phone).one();
// 不存在则注册
if (user == null) {
user = createUserWithPhone(phone);
}
// 4. 保存信息到session中
session.setAttribute("user", user);
return Result.ok();
}
/**
* 封装一个创建用户的逻辑
*
* @param phone
* @return
*/
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;
}

4.登录验证功能
需要封装一个登录校验的功能供前端进行调取使用。
重点:通过SpringMVC进行拦截请求,封装数据到ThreadLocal中。
使用SpringMVC统一拦截请求可以方便将数据存储到ThreadLocal中,这样就无需在每个接口中进行配置了十分方便。
ThreadLocal是每个tomcat创建的请求线程中独有的,不会被其它线程访问到的。
封装拦截器,从session中获取到数据即可,最后一定要在请求后拦截器中将ThreadLocal中的数据删除。
java
package com.hmdp.utils;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import org.springframework.beans.BeanUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
* 登录拦截器
*/
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取sesson
HttpSession session = request.getSession();
// 2. 获取用户信息
User user = (User) session.getAttribute("user");
// 3. 处理用户不存在
if (user == null) {
response.setStatus(401);
return false;
}
// 4. 存储数据到ThreadLocal
UserDTO userDTO = new UserDTO();
BeanUtils.copyProperties(user, userDTO);
UserHolder.saveUser(userDTO);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
将拦截器配置到SpringBoot中,可以进行配置什么路径需要排除,什么无需排除。
java
/**
* MVC拦截器配置
*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
}
}

通过拦截器可以完成登录校验功能。

5.集群session共享问题
多台tomcat是并不进行共享session存储空间的,虽然tomact提供了将session共享到多台tomcat,但是这样性能太差了,还会有很多问题,所以为了进行实现分布式服务器tomcat共享session,建议使用redis进行替代session,这样就可以做到分布式session了。

6.基于redis实现
其实就是使用redis去利用键值对的形式进行存储用户的信息.
key的设计:项目名:业务名:类型:id。
这里只使用项目名,类型和id,使用这种结构式key可以大大的帮助到我们。

其实就是把使用session的部分换为使用redis了,存储key-value,取出key-value都使用redis即可。
重要点:使用token令牌替代了cookie。
使用redis的时候,一定要使用token吗?未必的,只是说将token作为一个帮助客户端和服务端之间进行身份认证的手段,完全也可以进行使用分布式session,使用redis存储session,前端依然携带cookie而来,所以这只是一种手段而已,各有所长。

6.1获取验证码
session => redis。
没有太多亮点,就简单分析一下key的构造吧。
login:code:phone,典型的业务+类型+分辨标识key,这样就很好的能架构出合理的key了。
java
public Result sendCode(String phone, HttpSession session) {
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
// 1. 生成验证码
String code = RandomUtil.randomNumbers(6);
// 2. 保存验证码到session
// session.setAttribute(LOGIN_CODE_KEY, code);
// 2. 保存验证码到redis
// 构造key
String key = LOGIN_CODE_KEY + phone;
stringRedisTemplate.opsForValue().set(key, code, LOGIN_CODE_TTL, TimeUnit.SECONDS);
// 3. 发送验证码
// todo 实现发送验证码
log.debug("发送短信验证码成功: {}", code);
// 返回OK
return Result.ok();
}
6.2注册/登录
java
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式不正确");
}
// 2. 校验验证码
// 从session中获取到验证码
// String phoneForCode = (String) session.getAttribute(LOGIN_CODE_KEY);
// 从redis中获取到验证码
String codeKey = LOGIN_CODE_KEY + phone;
String phoneForCode = stringRedisTemplate.opsForValue().get(codeKey);
// 校验验证码
String code = loginForm.getCode();
if (!StrUtil.equals(code, phoneForCode)) {
return Result.fail("验证码错误!");
}
// 3. 查询用户是否存在
// QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
// userQueryWrapper.eq("phone", phone);
// User user = userMapper.selectOne(userQueryWrapper);
User user = query().eq("phone", phone).one();
// 不存在则注册
if (user == null) {
user = createUserWithPhone(phone);
}
// 4. 保存信息到session中
// session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
// 4. 保存信息到redis中
// 4.1 随机生成token, 作为登录令牌
String token = UUID.randomUUID().toString();
// 4.2 将User对象转换为Hash存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 4.3 存储数据到redis
String userKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(userKey, userMap);
// 4.4 设置
stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
return Result.ok(token);
}
6.2.1key的设计
其中key的设计是login:token:token,也是遵循的业务+类型+标识的设计思想。
6.2.2token的设计
这里的token很值得分析一下,尤其是可以对比苍穹外卖的进行分析。
这里的key仅仅是用来作为一个获取redis中数据使用的,并不是加密携带payload负载数据的,
其实就是使用了token替代了sessionID,但是其实还是有一个更为方便的解决方法的。
6.2.3存储hash数据到redis中的注意事项
1.使用什么API?
使用的是StringRedisTemplate中的opsForHash.putAll(),这个方法接收两个参数,key => 字符串,value => Map,它可以将Map中的key-value全部存入redis的hash中,十分方便。
2.Map中数据规范是什么样的?
由于我们进行使用的Redis客户端是stringRedisTemplate,这就限制了我们存储hash数据的时候,map中的key-value都必须是string类型的,如果出现了其它类型:比如Long类型,就会抛出错误,所以我们在将DTO转换为Map的时候,必须对value进行处理。
借助hutool工具类中的BeanUtil.beanToMap就可以完成这个操作。
java
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().setFieldValueEditor((filedName, filedValue) -> filedValue.toString()));
6.2.4Redis中Key设置时间的问题
使用stringRedisTemplate中的expire进行设置key的存活时间,传入key,time,TimeUnit即可。
6.3登录认证拦截器
我们需要将以前使用session进行将数据取出存入ThreadLocal的逻辑变更为使用前端传递来的token进行获取数据,从redis中获取用户数据DTO进行使用。
6.3.1思考:没有被SpringIOC托管的对象如何注入Bean
鉴于我们的登录拦截器配置类是我们自定义的,并且没有托管到SpringIOC容器,所以我们不能使用Resouce/Autowired。
那应该怎么办呢?我们发现MVC拦截器配置类是使用@Configuration进行注解的,这个类会被托管到SpringIOC容器,而且我们的自定义拦截器也被该类实例化了一个对象,所以完全可以通过该类将Bean在自定义拦截器实例化的时候,传递进来。
在构造函数被回调的时候,接收StringRedisTemplate对象,进行赋值给自身字段。
java
/**
* 登录拦截器
*/
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
}
将StringRedisTemplate进行注入到MVC配置类中,在调用拦截器构造函数的时候进行注入StringRedisTemplate进去即可。
java
/**
* MVC拦截器配置
*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate)).excludePathPatterns(
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
}
}
6.3.2整体登录校验流程
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. 获取sesson
// HttpSession session = request.getSession();
// 1. 获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
response.setStatus(401);
return false;
}
// 2. 获取用户信息
// UserDTO user = (UserDTO) session.getAttribute("user");
String userKey = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(userKey);
// 3. 处理用户不存在
if (userMap.isEmpty()) {
response.setStatus(401);
return false;
}
// 4. 将用户数据map -> userDTO
UserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 5. 存储数据到ThreadLocal
UserHolder.saveUser(user);
// 6. 刷新token有效期
stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 7. 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
1.整体流程:先从request请求头中获取到authorization中的token数据 => 然后封装一个userkey => 然后封装出来一个KEY,在redis客户端中获取到userDTO数据 => 然后处理用户不存在(redis获取不到数据的情况)=> 将用户数据map转换为userDTO => 将用户DTO存储到ThreadLocal中即可 => 最后进行刷新token有效期。
6.3.3刷新token时间
token在一定时间后会过期,但是如果在用户持续使用的过程中过期,那真是一个糟糕的事件,所以要采用拦截器刷新token时间的方式,这样就是在用户持续使用的时候,可以帮用户进行刷新token,延期,不会导致用户持续使用的时候过期。
在拦截器中进行token续期,是一个非常聪明的决策,这里采用的续期策略是,只要发送了请求,在token有效期内还在使用,就将时间续期到原始状态。
java
// 6. 刷新token有效期
stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
7.登录拦截器和刷新缓存拦截器如何设置?
7.1拆分的思路
主要是因为有一些请求是打不到登录拦截器的,一些不需要登录的请求根本打不到登录校验拦截器,所以我们不能将刷新token时间放在登录拦截器中去做,因为这样就会有可能用户看的都是不需要登录的接口数据,这样就会导致token无法进行续期,有可能出现用户看着看着就token过期了,所以为了避免这种情况的发生,可以进行设置两个拦截器:1.刷新token拦截器,所有接口都可以进行刷新token请求,当用户进行发送请求之后,从redis中获取到用户的token数据(因为前端会将token传递过来),当查询到数据的时候,就会去更新token时间,如果有数据将数据存储到ThreadLocal中。2.登录状态拦截器,仅仅进行拦截需要登录状态才能进行访问的接口,在拦截器中进行看一下ThreadLocal是否有数据,有就放行,无则滚蛋。
7.2实现token刷新拦截器
java
/**
* 更新拦截器
*/
public class RefreshInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取sesson
// HttpSession session = request.getSession();
// 1. 获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
// 2. 获取用户信息
// UserDTO user = (UserDTO) session.getAttribute("user");
String userKey = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(userKey);
// 3. 处理用户不存在
if (userMap.isEmpty()) {
return true;
}
// 4. 将用户数据map -> userDTO
UserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 5. 存储数据到ThreadLocal
UserHolder.saveUser(user);
// 6. 刷新token有效期
stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 7. 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
7.3实现登录拦截器
java
/**
* 登录拦截器
*/
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (UserHolder.getUser() == null) {
response.setStatus(401);
return false;
}
return true;
}
}
7.4SpringMVC配置拦截器
拦截器的优先级:可以在registry注册的时候进行指定order,里面的排序数越小的越先执行,如果不进行指定优先级,就按代码的顺序进行注册,越靠上进行注册的拦截器,越先执行。
这里将刷新token的拦截器放在最前面,指定的顺序数是最小的。
java
/**
* MVC拦截器配置
*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
registry.addInterceptor(new RefreshInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}