??各位小伙伴們大家好,歡迎來到這個小扎扎的Redis 6專欄,在這個系列專欄中我對B站黑馬的Redis教程進行一個總結,鑒于 看到就是學到、學到就是賺到 精神,這波依然是血賺 ┗|`O′|┛
💡Redis知識點速覽
- 🍖 Redis短信登錄流程描述
- 🥩 短信驗證碼的發送
- 🥩 短信驗證碼的驗證
- 🥩 是否登錄的驗證
- 🍖 源碼分析
- 🥩 模擬發送短信驗證碼
- 🥩 短信驗證碼的驗證
- 🥩 校驗是否登錄
- 🥩 登錄驗證優化
🍖 Redis短信登錄流程描述
🥩 短信驗證碼的發送
??用戶提交手機號,系統驗證手機號是否有效,畢竟無效手機號會消耗你的短信驗證次數還會導致系統的性能下降。如果手機號為無效的話就讓用戶重新提交手機號,如果有效就生成驗證碼并將該驗證碼作為value保存到redis中對應的key是手機號,之所以這么做的原因是保證key的唯一性,如果使用固定字符串作為可以的話會被后面的數據所覆蓋。然后在控制臺輸出驗證碼模擬發送驗證碼的過程
🥩 短信驗證碼的驗證
??用戶的手機號接收到驗證碼后在平臺上提交驗證碼,系統從redis中根據手機號讀取驗證碼并進行校驗,如果驗證通過的話就根據用戶驗證使用的手機號去數據庫中進行查詢用戶信息。如果存在就將查詢到的用戶信息保存到redis中,完成登錄;如果不存在的話就創建一個新用戶,并將該用戶的信息分別保存到sql數據庫和redis中,生成隨機token作為key、使用hash結構存儲user數據作為value,并將這個token返回給客戶端,至此完成登錄注冊
🥩 是否登錄的驗證
??用戶訪問系統業務邏輯的時候需要校驗他是否已經登錄,如果登錄可以訪問否則就去登錄,那么該如何完成是否登錄的校驗呢?這就要了解session的相關知識了,每一個session都有一個sessionId信息保存在瀏覽器的cookie中,當用戶使用瀏覽器發送請求的時候會攜帶上cookie信息,此時系統就可以使用cookie中的sessionId獲取到session信息,并通過session獲取到登錄時存儲的用戶信息。如果此時用戶在數據庫中存在的話就將該用戶的信息緩存在ThreadLocal(方便后續驗證)中,并放行該訪問;否則就說明發送請求的用戶未登錄或不合法,就要攔截到他的請求前往登錄
🍖 源碼分析
🥩 模擬發送短信驗證碼
UserController定義與前端交互
@Resource
private IUserService userService;/*** 發送手機驗證碼*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {// 發送短信驗證碼并保存驗證碼return userService.sendCode(phone, session);
}
上面使用到了sendCode方法,在userService里定義一下接口,然后在對應實現類中按照上面的流程重寫該方法的業務邏輯代碼
@Override
public Result sendCode(String phone, HttpSession session) {// 校驗手機號if (RegexUtils.isPhoneInvalid(phone)) {// 無效手機號,返回錯誤信息return Result.fail("手機號格式有誤!");}// 有效生成驗證碼String code = RandomUtil.randomNumbers(6);// 保存 (固定前綴+手機號) 和驗證碼到Redis中,設置驗證碼的有效期為2分鐘// RedisConstants.LOGIN_CODE_KEY = “login:code:”// RedisConstants.LOGIN_CODE_TTL = 2LstringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);// 模擬發送驗證碼log.debug("驗證碼:{}", code);// 返回return Result.ok();
}
手機號格式校驗使用到的RegexUtils類中的工具方法
/*** 手機號正則*/
public static final String PHONE_REGEX = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$";/**
* 是否是無效手機格式* @param phone 要校驗的手機號* @return true:符合,false:不符合*/
public static boolean isPhoneInvalid(String phone){return mismatch(phone, RegexPatterns.PHONE_REGEX);
}// 校驗是否不符合正則格式
private static boolean mismatch(String str, String regex){if (StrUtil.isBlank(str)) {return true;}return !str.matches(regex);
}
🥩 短信驗證碼的驗證
??UserController定義與前端交互,其中參數LoginFormDTO 是前端使用手機號+驗證碼登錄或者手機號+密碼登錄是傳遞過來的JSON數據
/*** 登錄功能* @param loginForm 登錄參數,包含手機號、驗證碼;或者手機號、密碼*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){// 實現登錄功能return userService.login(loginForm, session);
}
??上面使用到了login方法,在userService里定義一下接口,然后在對應實現類中按照上賣弄的流程描述重寫該方法的業務邏輯代碼
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {String phone = loginForm.getPhone();// 驗證碼校驗String code = loginForm.getCode();String cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);if (cacheCode == null || !code.equals(cacheCode)) {return Result.fail("驗證碼錯誤!");}// 根據手機號查詢用戶信息User user = query().eq("phone", phone).one();if (user == null) {// 不存在就創建一個新用戶user = createUserWithPhone(phone);}// 保存用戶信息到redis中// 生成隨機tokenString token = UUID.randomUUID().toString(true);// user先轉userDTO再轉hashMap存儲 轉HashMap時的第三個參數的意思是忽略null值將值都轉換成String類型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()));// RedisConstants.LOGIN_USER_KEY = "login:token:"stringRedisTemplate.opsForHash().putAll(RedisConstants.LOGIN_USER_KEY + token, userMap);// 設置失效時間為30分鐘// RedisConstants.LOGIN_USER_TTL = 30LstringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);// 返回前端tokenreturn Result.ok(token);
}private User createUserWithPhone(String phone) {User user = new User();user.setPhone(phone);// SystemConstants.USER_NICK_NAME_PREFIX = "user_"user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));save(user);return user;
}
??保存的時候使用BeanUtil將User轉換成UserDTO進行存儲,UserDTO的結構如下,只保存一部分的數據,一方面可以不用來回傳遞用戶有關的隱私數據,一方面也節省內存提高性能。由于這里的id是數值類型,但是stringRedisTemplate存儲時需要hash的鍵值都是String型,所以說應該在存儲之前將id的值轉換成String類型,就在上面代碼塊的24~27行完成了這個操作
@Data
public class UserDTO {private Long id;private String nickName;private String icon;
}
🥩 校驗是否登錄
用戶發送請求不止一次,所以說登錄驗證也不止進行一次,于是可以使用攔截器完成驗證,攔截器的使用可分為兩步:
創建攔截器
/*** @author : mereign* @date : 2022/5/5 - 10:31* @desc : 攔截器,實現請求攔截,判斷登錄信息*/
@Component
public class LoginInterceptor implements HandlerInterceptor {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 獲取請求頭中的token信息String token = request.getHeader("authorization");if (StrUtil.isBlank(token)) {// token為空,返回401未授權狀態碼,攔截response.setStatus(401);return false;}// 根據token獲取redis中的用戶valueMap<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);HttpSession session = request.getSession();// 判斷用戶是否存在if (userMap.isEmpty()) {// 用戶不存在,返回401未授權狀態碼,攔截response.setStatus(401);return false;}// 用戶存在,將hash數據轉換為userDTO,存信息到ThreadLocalUserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);UserHolder.saveUser(userDTO);// 刷新token有效期,放行stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);return true;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
注冊攔截器
/*** @author : mereign* @date : 2022/5/5 - 10:43* @desc :*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {@Autowiredprivate LoginInterceptor loginInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(loginInterceptor).excludePathPatterns("/shop/**","/shop-type/**","/voucher/**","/upload/**","/blog/hot","/user/code","/user/login");}
}
緩存用戶的信息到ThreadLocal中的工具方法
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();}
}
UserController定義與前端交互
@GetMapping("/me")
public Result me(){// 獲取當前登錄的用戶并返回UserDTO user = UserHolder.getUser();return Result.ok(user);
}
🥩 登錄驗證優化
??由上面的登錄驗證可知,我們對一些需要用戶登錄驗證的功能設置了攔截器,如果驗證通過會刷新token的有效期,這樣的話只要用戶一直訪問我們攔截的功能就可以一直保持token是有效的。但是,如果用戶登陸之后的操作一直是不需要驗證的,那也就意味著token的有效期一直不會刷新,這樣的話30分鐘之后token就會失效用戶驗證就會失敗,這樣顯然是不合理的
??于是我們可以使用兩個攔截器完成,最前面的負責攔截所有的請求,獲取token、從redis中查詢用戶,將查詢結果放到ThreadLocal(可能存null)、刷新token有效期,最后直接放行;后面的攔截器只負責判斷有沒有從redis中查詢到用戶,他從ThreadLocal獲取查詢結果,判斷有則放行無則攔截
創建兩個攔截器
/*** @author : mereign* @date : 2022/5/5 - 10:31* @desc : 前置攔截器,攔截所有請求,前置工作*/
@Component
public class RefreshTokenInterceptor implements HandlerInterceptor {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 獲取請求頭中的token信息String token = request.getHeader("authorization");if (StrUtil.isBlank(token)) {// token為空 直接放行return true;}// 根據token獲取redis中的用戶valueMap<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);HttpSession session = request.getSession();// 判斷用戶是否存在if (userMap.isEmpty()) {// 用戶不存在 直接放行return true;}// 用戶存在,將hash數據轉換為userDTO,存信息到ThreadLocalUserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);UserHolder.saveUser(userDTO);// 刷新token有效期,放行stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);return true;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
/*** @author : mereign* @date : 2022/5/5 - 10:31* @desc : 登錄攔截器,攔截需要攔截的請求,判斷登錄信息*/
@Component
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 判斷登錄if (UserHolder.getUser() == null) {response.setStatus(401);return false;}return true;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {}
}
??創建完攔截器之后要將兩個攔截器通過配置類配置到容器中生效,多個攔截器的優先級,默認按照添加順序執行優先級,但是也可以使用order方法指定優先級,按參數的大小排序優先級,參數越小優先級越高
/*** @author : mereign* @date : 2022/5/5 - 10:43* @desc : 配置類注冊攔截器*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {@Autowiredprivate RefreshTokenInterceptor refreshTokenInterceptor;@Autowiredprivate LoginInterceptor loginInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {// 前置攔截器registry.addInterceptor(refreshTokenInterceptor).addPathPatterns("/**").order(0);// 后置攔截器registry.addInterceptor(loginInterceptor).excludePathPatterns("/shop/**","/voucher/**","/shop-type/**","/upload/**","/blog/hot","/user/code","/user/login").order(1);}
}