【Redis場景1】使用者登入註冊

2022-12-11 12:00:23

細節回顧:

關於cookiesession不熟悉的朋友;

建議閱讀該部落格https://www.cnblogs.com/ityouknow/p/10856177.html

執行流程:

在單體模式下,一般採用這種模式來儲存,傳遞、認證使用者登入、註冊等資訊;

如果瀏覽器禁用Cookies,****如何保障整個機制的正常運轉。

  1. url拼接或者POST請求:每個請求都攜帶SessionID
  2. Token機制:在使用者登入或者註冊的時候,與使用者資訊系結一個隨機字串,用於使用者狀態管理

在分散式下的Session問題:

為了支撐更大的流量,後臺往往需要在多臺伺服器中部署,那如果使用者在 A 伺服器登入了,第二次請求跑到服務 B 就會出現登入失效問題如何解決?

  • Nginx ip_hash 策略,伺服器端使用 Nginx 代理,每個請求按存取 IP hash分配,這樣來自同一 IP 固定存取一個後臺伺服器,避免了在伺服器 A 建立 Session,第二次分發到伺服器 B 的現象。
  • Session 複製,任何一個伺服器上的 Session 發生改變(增刪改),該節點會把這個 Session 的所有內容序列化,然後廣播給所有其它節點。
  • 共用 Session,伺服器端無狀態話,將使用者的 Session 等資訊使用快取中介軟體來統一管理,保障分發到每一個伺服器的響應結果都一致。

第一種策略(Nginx ip_hash 策略)可以看該部落格:https://www.cnblogs.com/xbhog/p/16929786.html

我們主要實現第三種:通過快取中介軟體來統一管理。

解決痛點:

在原來場景中存在的Session不互通的問題(Session資料拷貝),該解決方式有以下問題:

  1. 每臺伺服器中都有完整的一份session資料,伺服器壓力過大。
  2. session拷貝資料時,可能會出現延遲

所以我們需要採用的中介軟體需要有以下特徵(Redis):

  1. 資料共用
  2. 基於記憶體讀取
  3. 滿足資料儲存格式(KEY:VALUE)

實現場景:

該場景實現流程:以下分析結合部分程式碼(聚焦於redis的實現);

完整後端程式碼可在Github中獲取:https://github.com/xbhog/hm-dianping

開發流程:

【獲取驗證碼流程】前端根據手機號提交獲取驗證碼請求:觸發sendCode方法:

  1. 校驗手機號合法性
  2. 生成驗證碼
  3. 儲存驗證碼到redis
  4. 傳送驗證碼(模擬實現,未呼叫第三方平臺)
  5. 結束

在第3步的實現如下:

stringRedisTemplate.opsForValue().set(PHONE_CODE_KEY+phone,code,2L,TimeUnit.MINUTES);

使用的stringRedisTemplate繼承於RedisTemplate,侷限:key和value必須是String型別:

public class StringRedisTemplate extends RedisTemplate<String, String> {
......
}

設定驗證碼Key值:phone:code:,code為隨機6位字串。

設定key的過期時間。

【登入功能】前端根據手機號和驗證碼傳送登入功能(如上圖):觸發login方法:

  1. 校驗手機號合法性

  2. 校驗驗證碼合法性:從redis中獲取

  3. 資料庫通過手機號查詢使用者

    1. 不存在:建立使用者
    2. 存在:執行後續邏輯(4)
  4. UUID生成隨機TOKEN

  5. 將使用者資訊存入Redis中,設定過期時間

  6. 返回前端Token

第2步的實現如下:

String redisCode = stringRedisTemplate.opsForValue().get(PHONE_CODE_KEY + loginForm.getPhone());
if(StringUtils.isBlank(loginForm.getCode()) || !loginForm.getCode().equals(redisCode)){
    return Result.fail("驗證碼錯誤");
}

第4、5步的實現如下:

//隨機生成token,作為登入令牌
String token = UUID.randomUUID().toString(true);
//儲存到redis中
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey,userBeanToMap);
//設定過期時間
stringRedisTemplate.expire(tokenKey,30L, TimeUnit.MINUTES);

設定使用者KEY"login:token:",這裡redis的使用的資料結構是Map,方便對單一欄位操作。

使用了hashmap結構,需要單獨對tokenKey設定過期時間(30m);

場景問題:

Redis Key續期問題:

在設定token的時候,在redis給的過期時間是30分鐘,這裡就有個問題,使用者在30分鐘內,結束請求,那沒有問題,但只要使用者的線上時間超過30分鐘,redis刪除token,直接給使用者強制下線了;這個實在是不符合實際場景。

解決方式:

在請求的過程總給加入一層攔截器,用來重新整理Token的存活時間。

子問題:

對於攔截器,我們不能攔截所有的路徑,比如獲取驗證碼請求,使用者登入,首頁等;

    1. 使用者在所攔截的範圍內:則可執行重新整理Token操作
    2. 使用者不在攔截的範圍內:無法執行重新整理Token操作

子問題解決:

增加兩層攔截器,第一層攔截全部請求路徑,第二層攔截器基於第一層資訊,對未登入的請求進行攔截。

需要設定兩層攔截器的優先順序,

order():指定要使用執行器順序。預設值為0(最高)

@Configuration
public class MybatisConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
                "/shop/**",
                "/voucher/**",
                "/shop-type/**",
                "/upload/**",
                "/blog/hot",
                "/user/code",
                "/user/login"
        ).order(1);
        registry.addInterceptor(new RefreshTokeInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

【攔截器1實現】所有路徑攔截,重新整理登入Token令牌存活時間。

  1. 獲取token

  2. 查詢redis使用者

    1. 存在,不攔截(執行3)
    2. 不存在,攔截
  3. 使用者資訊儲存到threadLocal

  4. 重新整理Token有效期

  5. 放行

相關程式碼:

/**
 * @author xbhog
 * @describe: 攔截器實現:校驗使用者登入狀態
 * @date 2022/12/7
 */
public class RefreshTokeInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokeInterceptor(StringRedisTemplate stringRedisTemplate){
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = request.getHeader("authorization");
        //如果頁面沒有登入,則沒有token,直接放行給下一個攔截器
        if(StringUtils.isEmpty(token)){
            return true;
        }
        String tokenKey = LOGIN_USER_KEY + token;
        Map<Object, Object> userRedis = stringRedisTemplate.opsForHash().entries(tokenKey);
        if(userRedis.isEmpty()){
            return true;
        }
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userRedis, new UserDTO(), false);
        //使用者存在,放到threadLocal
        UserHolder.saveUser(userDTO);
        //登入續期
        stringRedisTemplate.expire(tokenKey,30L, TimeUnit.MINUTES);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}

【攔截器2實現】需要登入的路徑攔截;

實現程式碼:

/**
 * @author xbhog
 * @describe: 攔截器實現:校驗使用者登入狀態
 * @date 2022/12/7
 */
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.判斷是否需要攔截(ThreadLocal中是否有使用者)
        if (UserHolder.getUser() == null) {
            // 沒有,需要攔截,設定狀態碼
            response.setStatus(401);
            // 攔截
            return false;
        }
        // 有使用者,則放行
        return true;
    }
}

輸入及參考

cookie和session

斐波那契雜湊和hashMap實踐