可以直接看后面Redis實現功能的部分
基于session實現短信登錄
發送短信驗證碼
前端請求樣式
業務層代碼
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {@Overridepublic Result sendCode(String phone, HttpSession session) {// 1. 校驗手機號if (RegexUtils.isPhoneInvalid(phone)) {// 2. 如果不符合,返回錯誤信息return Result.fail("手機號格式錯誤!");}// 3. 符合,生成驗證碼String code = RandomUtil.randomNumbers(6);// 4. 保存驗證碼到sessionsession.setAttribute("code", code);// 5. 發送驗證碼log.debug("發送短信驗證碼成功,驗證碼:{}", code);// 返回okreturn Result.ok();}
}
關于參數httpsession
來源:Spring框架(Servlet容器)自動注入。
機制:這是Spring MVC一個非常強大的特性,叫做參數解析(Handler Method Argument Resolution)。當你在一個控制器方法中聲明某個類型的參數時,Spring會查看它注冊的眾多?
HandlerMethodArgumentResolver
?組件,找到一個能處理該類型的解析器,然后自動為你創建或獲取該對象,并傳入方法。對于?
HttpSession
?類型,Spring使用的是?ServletWebRequest
?等相關解析器。這個解析器會:檢查當前的HTTP請求對象 (
HttpServletRequest
)。調用?
request.getSession()
?方法來獲取當前請求關聯的Session。getSession()
?方法非常智能:如果Session已經存在,則返回它;如果不存在,則創建一個新的Session并返回。
HttpSession
?參數不是前端發送來的,而是Spring框架根據你的參數類型,自動從當前HTTP請求中獲取并“注入”到方法中的。它的作用是提供一種在服務器端存儲用戶相關數據并在多次請求間共享的機制。
登錄功能
前端請求樣式
業務層代碼
黑馬給的代碼里面,工具類的相關注解有一處錯誤
/*** 是否是無效手機格式* @param phone 要校驗的手機號* @return true:符合,false:不符合*/public static boolean isPhoneInvalid(String phone){return mismatch(phone, RegexPatterns.PHONE_REGEX);}
返回ture是無效驗證碼
之所以一直使用反向驗證是因為正向驗證會使得代碼有很多if的嵌套
業務層代碼
@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {// 1.校驗手機號String phone = loginForm.getPhone();if (RegexUtils.isPhoneInvalid(phone)) {// 2.如果不符合,返回錯誤信息return Result.fail("手機號格式錯誤!");}// 3.從redis獲取驗證碼并校驗String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);String code = loginForm.getCode();if (cacheCode == null || !cacheCode.equals(code)) {// 不一致,報錯return Result.fail("驗證碼錯誤");}// 4.一致,根據手機號查詢用戶 select * from tb_user where phone = ?User user = query().eq("phone", phone).one();// 5.判斷用戶是否存在if (user == null) {// 6.不存在,創建新用戶并保存user = createUserWithPhone(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((fieldName, fieldValue) -> fieldValue.toString()));// 7.3.存儲String tokenKey = LOGIN_USER_KEY + token;stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);// 7.4.設置token有效期stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);// 8.返回tokenreturn Result.ok(token);}
如果保存可能會導致一些泄露問題,而且我們也不需要那么多信息,只需要唯一標識,所以我們把user對象轉換成userdto對象,轉換代碼上面有,還是使用的BeanUtil工具類來實現。
驗證攔截功能
這個功能放到攔截器里面,方便所有的可控制器都可以實現用戶校驗。然后將攔截到的用戶信息傳遞到控制器里面去。不過這里有線程安全問題,所以我們需要使用threadlocal保證安全。
線程安全問題
簡單來說,線程安全的根源在于:多個線程同時讀寫共享的可變數據(狀態)時,如果沒有正確的同步,就會導致不確定的結果。
特征 | 線程安全 | 線程不安全 |
---|---|---|
數據來源 | 局部變量、方法參數、不可變對象 | 共享的成員變量、非線程安全容器 |
操作類型 | 單純的讀操作、無狀態的計算 | 復合操作(如?i++ )、"檢查-然后-執行" |
代碼示例 | return a + b; ?String s = "hello"; | count++; ?if (map.contains(key)) { map.put(key, value); } |
核心原因 | 沒有共享?或?共享不可變 | 共享且可變,且未正確同步 |
變量類型 | 存儲位置 | 共享范圍 | 線程安全問題來源 |
---|---|---|---|
實例變量 (非? static ) | 堆內存 (Heap) (每個對象內) | 同一個對象實例被多個線程引用 | 高發區。因為容易被忽略,人們常常以為“非static就是安全的”,卻忘了單個實例被多線程共享這一常見場景。 |
類變量 ( static ) | 方法區 (Method Area) | 整個JVM內的所有線程和所有實例 | 顯而易見。因為大家都知道 |
攔截器代碼(實現Redis最終版)
在工具類里面寫一個攔截器,需要實現一個接口,以及三個接口方法
preHandle
:在Controller方法執行之前被調用。通常用于身份驗證、日志記錄、權限檢查等。如果返回?false
,則中斷請求流程。postHandle
:在Controller方法執行之后,但視圖渲染之前被調用。通常用于向模型添加公共數據、記錄執行時間等。afterCompletion
:在整個請求完成之后(即視圖渲染完畢)被調用。通常用于資源清理、記錄最終日志等。
package com.hmdp.utils;import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1.判斷是否需要攔截(ThreadLocal中是否有用戶)if (UserHolder.getUser() == null) {// 沒有,需要攔截,設置狀態碼response.setStatus(401);// 攔截return false;}// 有用戶,則放行return true;}
}
配置攔截器
需要一個mvc config類(實現Redis最終版)
package com.hmdp.config;import com.hmdp.utils.LoginInterceptor;
import com.hmdp.utils.RefreshTokenInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;import javax.annotation.Resource;@Configuration
public class MvcConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;@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);}
}
里面有些控制器不需要被攔截所以我們需要排除在外。
共享問題
替代辦法
Redis
使用Redis實現session登錄
獲取并保存驗證碼
public Result sendCode(String phone, HttpSession session) {// 1.校驗手機號if (RegexUtils.isPhoneInvalid(phone)) {// 2.如果不符合,返回錯誤信息return Result.fail("手機號格式錯誤!");}// 3.符合,生成驗證碼String code = RandomUtil.randomNumbers(6);// 4.保存驗證碼到 sessionstringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);// 5.發送驗證碼log.debug("發送短信驗證碼成功,驗證碼:{}", code);// 返回okreturn Result.ok();}
驗證碼發送的功能很復雜,這里直接用日志代替。
TimeUnit.MINUTES
?- 時間單位
短信登錄以及注冊功能
登錄之后用戶的信息還要存儲到redis里面,key就用一個隨機的token,存儲對象轉換成哈希,這個token會返回給前端。
public Result login(LoginFormDTO loginForm, HttpSession session) {// 1.校驗手機號String phone = loginForm.getPhone();if (RegexUtils.isPhoneInvalid(phone)) {// 2.如果不符合,返回錯誤信息return Result.fail("手機號格式錯誤!");}// 3.從redis獲取驗證碼并校驗String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);String code = loginForm.getCode();if (cacheCode == null || !cacheCode.equals(code)) {// 不一致,報錯return Result.fail("驗證碼錯誤");}// 4.一致,根據手機號查詢用戶 select * from tb_user where phone = ?User user = query().eq("phone", phone).one();// 5.判斷用戶是否存在if (user == null) {// 6.不存在,創建新用戶并保存user = createUserWithPhone(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((fieldName, fieldValue) -> fieldValue.toString()));// 7.3.存儲String tokenKey = LOGIN_USER_KEY + token;stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);// 7.4.設置token有效期stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);// 8.返回tokenreturn Result.ok(token);}
先檢查?
cacheCode
?是否為?null(防止空指針異常)
如果是?
null
,直接進入if塊,不會執行后面的equals如果不是?
null
,再比較內容是否相等
token來源
UUID是一個全球唯一的標識符,就像每個人的身份證號一樣,幾乎不可能重復。
唯一性?- 幾乎不可能重復
安全性?- 難以猜測,適合做token
分布式?- 不同服務器生成也不會沖突
無需協調?- 不需要中央機構分配
下一步就是redis的語法應用了,將信息存儲到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((fieldName, fieldValue) -> fieldValue.toString()));// 7.3.存儲String tokenKey = LOGIN_USER_KEY + token;stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);// 7.4.設置token有效期stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);// 8.返回tokenreturn Result.ok(token);
這里面用beantil工具把bean轉換成了一個map對象,然后使用
設置token有效期,
String tokenKey = LOGIN_USER_KEY + token;stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);// 7.4.設置token有效期stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
另外只要用戶在不斷訪問我們就要不斷的更新redis的時間限制,在攔截器里面添加更新邏輯。
null
?-?不存在
對象根本不存在
沒有分配內存空間
相當于"無中生有"
isEmpty()
?-?存在但為空
對象存在,但內容為空
已經分配了內存空間
相當于"空盒子"
刷新token時間的攔截器
因為有一些功能是不需要校驗的,所以為了這些功能也有刷新token的功能所以我們重新創建一個新的攔截器。
package com.hmdp.utils;import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_TTL;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();}
}
攔截器的代碼我們在前面就是放的最終版的,兩個攔截器需要設置先后順序,我們使用order設置大小,越小的越先執行。