淺談踢人下線的設計思路!(附程式碼實現方案)

前言

前兩天寫了一篇文章,主要講了下java中如何實現踢人下線,原文鏈接:java中如何踢人下線?封禁某個帳號後使其會話立即掉線!

本來只是簡單闡述一下踢人下線的業務場景和實現方案,沒想到引出那麼多大佬把小弟噴的睜不開眼睛,為了避免大家繼續噴我,特再寫下此篇文章,徹底講清楚各種場景下踢人下線的設計思路,如有不足之處還請各位大佬輕噴!

好了廢話不多說,正文開始

正文

如果把踢人下線比喻成拆房子,那麼在學會拆房之前,我們必須要了解這座房子是怎麼蓋起來的,不同的蓋法對應不同的拆法,不能混為一談

對於目前大多數系統來講,登錄主要有兩種方式,一是傳統Session模式,二是jwt令牌模式

傳統Session模式

我們先以Session模式為例,這種模式是怎麼登錄的呢?

(註:此處的Session不單指HttpSession,指一切使用服務端控制會話的手段)

這裡我們不使用任何框架,從底層邏輯開始說起。

首先,你需要一個全局攔截器,攔截所有會話請求,如果此會話已經登錄,那麼攔截器放行,如果未登錄,直接將此會話強制重定向到登錄介面

  1. 在登錄介面,我們需要接受兩個參數:username + password, 拿這兩個參數去資料庫中獲取數據
  2. 如果查不到數據,直接返回用戶名或密碼錯誤,如果可以查找到數據,那麼開始登錄
  3. 利用一定的演算法(例如uuid),生成一個隨機字元串,就像這樣子:623368f0-ae5e-4475-a53f-93e4225f16ae, 這就是我們的token
  4. 現在我們需要做兩件事,一是建立此tokenUserId的映射關係,二是把這個token返回給前端
    1. 建立映射:在Redis中添加一條數據,假如userId=10001,那麼我們需要RedisUtil.set("623368f0-ae5e-4475-a53f-93e4225f16ae", 10001)
    2. token傳遞給前台,你可以放到Cookie里,或者直接放到返回體body
  5. 大工告成,會話登錄完畢!在全局攔截器里,我們不認userId只認token,誰持有623368f0-ae5e-4475-a53f-93e4225f16ae這個令牌,誰就是用戶10001
  6. 一個會話訪問進來,有token且token有效,那麼會話放行!沒有?乖乖滾去登錄!

此時不難看出,一個客戶端要保持會話登錄的兩個必要條件:

  1. 此客戶端持有token
  2. 這個token是一個有效token,即:可以從Redis中找到對應的UserId

而我們要做踢人下線,就必須從這兩點至少選擇其一開始下手

首先我們先明確一點:除非客戶端主動註銷,否則我們是無法清除一個已經頒發到客戶端的token的。

(除了Cookie清除技術WebSocket實時推送技術可以做到,但是這兩種技術都需要客戶端主動配合,我們現在的假設是客戶端拒不配合,我們需要將它強制清退下線。)

現在,我們只能從第二點下手,即:清除此tokenUserId的映射關係

你可能會想,這不簡單?Redis清除一個鍵值,還不是一行程式碼就能解決的事情?

此時你可能漏掉了關鍵的一點,那就是,我們只在Redis中存儲了token -> UserId的映射關係,如果我們要踢出用戶10001,正常情況下,我們無法只根據10001找到它對應的token是哪個鍵值

要解決這個問題,我們就必須把UserId -> token的映射關係也存儲一份,你可以存儲在資料庫中,也可以存儲在Redis中,為了性能考慮,我們使用Redis

現在事情變得簡單起來,要踢人下線,我們只需要兩步:

  1. 找到帳號10001對應的token鍵值
  2. 刪除這個鍵值

OK,踢出成功,待到此帳號下一次訪問系統時,雖然他攜帶了token,但是此token已成為無效token,乖乖去登陸吧!

此時你可能會說:

就這?我創建個集合保存所有要踢出下線的帳號,每次攔截器里判斷這個會話是否在這個集合中不就OK了?

大佬請慢噴!這就是我要說的第二種模式————黑名單機制,且往下看

jwt模式

jwt模式的登陸步驟與傳統Session模式區別不大,在此暫不贅述

不同點在於,jwt登陸時,不會在伺服器保存任何會話資訊,所有的用戶參數都被寫進了jwt生成的token中

(所以jwttoken才會長的那麼長!通常兩三百字元長度起步)

一個會話是否有效,只看這個會話攜帶的token能不能正常解析出數據!

這也就意味著令牌的合法性是令牌自解釋的,而不是伺服器說了算!

所以,相比於傳統Session模式jwt對令牌的可控性就弱了很多,無法做到主動清除token -> UserId 映射關係的操作

除非你手動更換jwt令牌生成的演算法秘鑰,但是這樣會造成系統中所有令牌全部失效,全部用戶集體下線!這是萬萬不行的。

那怎麼辦?難道我就不能做到踢人下線的操作嗎?

其實辦法肯定是有的,只要思想不滑坡,方法總比困難多!

那就是利用黑名單機制:我們要踢出哪個用戶,只需要將他的UserId或者jwt-token放進一個黑名單里,然後我們在攔截器里檢查每個請求的token或者UserId是否存在於這個黑名單里即可!

這種方式和傳統Session模式孰優孰劣呢?只能說各有千秋!

黑名單機制在存儲時節省性能,在攔截器里多了一步黑名單檢測的步驟,浪費性能!

不過坦白了講,這丁點的性能的浪費對於現在的CPU來說都是毛毛雨,可以直接忽略!

題外話

在我一位同事的項目中,給我提供了jwt踢人下線的另一種實現思路:

那就是在生成jwt令牌時,加入一個固定的參數當做令牌生成因子,如果要將一個用戶踢出下線,只需要修改一下這個因子的值,然後在攔截器里每次校驗這個因子生成的令牌是否與客戶端傳遞的令牌一致!即可判斷出這個token是否已被拉黑!

這種模式提供了一個比較新穎的邏輯演算法,但是嚴格來講,還是藉助伺服器存儲一定的數據完成的會話驗證,仍然屬於Session模式。在此暫不展開細講。

程式碼實現方案?

說了這麼多理論,總歸是要上程式碼的,由於筆者除了sa-token框架以外沒有找到任何一個框架對踢人下線有直接現成的解決方案,所以在此暫以sa-token框架為例

  1. 首先添加pom.xml依賴
<!-- sa-token 許可權認證, 在線文檔://sa-token.dev33.cn/ -->
<dependency>
	<groupId>cn.dev33</groupId>
	<artifactId>sa-token-spring-boot-starter</artifactId>
	<version>1.12.1</version>
</dependency>
  1. 在用戶登錄時將帳號id寫入會話中
@RestController
@RequestMapping("user")
public class UserController {
	@RequestMapping("doLogin")
	public String doLogin(String username, String password) {
		// 此處僅作示例模擬,真實項目需要從資料庫中查詢數據進行比對 
		if("zhang".equals(username) && "123456".equals(password)) {
			StpUtil.setLoginId(10001);
			return "登錄成功";
		}
		return "登錄失敗";
	}
}
  1. 將指定id的帳號踢出在線
// 使指定id帳號的會話註銷登錄,對方再次訪問系統時會拋出`NotLoginException`異常,場景值為-5
@RequestMapping("kickout")
public String kickout(long userId) {
	StpUtil.logoutByLoginId(userId);
	return "踢出成功";
}

對框架感興趣的同學可以查看官網:sa-token 一個java輕量級許可權認證框架

後話

文章寫的再詳細也難免會有遺漏之處,在此還求大家輕噴,可以在評論出留言指出不足之處

如果覺得文章寫得不錯還請大家不要吝惜為文章點個贊,您的支援是我更新的最大動力!