HTTP 是無狀態協議,所以服務端如果需要記住登錄用戶,就需要維護一個 SessionId(Cookie) - Session 的鍵值對。Session 存放用戶信息對象。用戶信息對象作爲 Session 的一個 Attribute。當瀏覽器請求中包含 Cookie 時,服務器就能識別出具體是哪個用戶了。
默認 SessionId 與 Session 的鍵值對由服務器來維護,Session 的過期時間默認爲 30 分鐘(可通過 Debug 查看 maxInactiveInterval 的值)。
使用 HttpSession
下面是一個簡單的使用 Session 來保存用戶登錄狀態的例子,相關代碼我放到了 GitHub 上
設置 Attribute(登錄時)
@PostMapping("/signin")
public ModelAndView doSignin(@RequestParam("email") String email, @RequestParam("password") String password, HttpSession session) {
try {
User user = userService.signin(email, password);
session.setAttribute(KEY_USER, user);
} catch (RuntimeException e) {
return new ModelAndView("signin.html", Map.of("email", email, "error", "Signin failed"));
}
return new ModelAndView("redirect:/profile");
}
獲取 Attribute(判斷是否已經登錄)
@GetMapping("/profile")
public ModelAndView profile(HttpSession session) {
User user = (User) session.getAttribute(KEY_USER);
if (user == null) {
return new ModelAndView("redirect:/signin");
}
return new ModelAndView("profile.html", Map.of("user", user));
}
刪除 Attribute(退出時)
@GetMapping("/signout")
public String signout(HttpSession session) {
session.removeAttribute(KEY_USER);
return "redirect:/signin";
}
這裏的 HttpSession session
可以用 HTTPServletRequest request
代替,此時使用 request.getSession().getAttribute()
。HttpSession session
和 HTTPServletRequest request
可以認爲是方法默認就包含的參數。
Session 的生命週期是半小時,如果半小時後訪問時,服務器將重新建立連接,將發送新的 SessionId 到瀏覽器,再次訪問時, 新 Session 中將沒有 User,此時登錄將失效。
瀏覽器 Cookie 樣式:
Cookie: JSESSIONID=C8698B74AFAD403C6E28D77B75373500
此部分代碼對應 v1
使用 Redis
當存在跨域問題時,即多個服務都需要用到 Session 判斷登錄狀態時,就需要將 Session 在每個服務中複製一份,或做成分佈式 Session。一般使用 Redis 實現。
下面使用 Redis 來維護這個 SessionId - Session 的鍵值對,或者說維護一個 SessionId - Attributes 的鍵值對。
public class BaseController {
final Logger logger = LoggerFactory.getLogger(getClass());
final long EXPIRE_TIME = 1800;
public static HttpServletRequest getRequest() {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attrs.getRequest();
}
protected void setAttribute(String name, Object value) {
String sessionId = getRequest().getSession().getId();
Map<String, Object> attributes = new HashMap<>();
attributes.put(name, value);
RedisUtils.setKey(sessionId, JsonUtils.getJson(attributes), EXPIRE_TIME, TimeUnit.SECONDS);
}
protected Object getAttribute(String name) {
String sessionId = getRequest().getSession().getId();
String attributesJson = RedisUtils.getKey(sessionId);
Map<String, Object> attributes = JsonUtils.fromJson(attributesJson, Map.class);
return attributes.get(name);
}
protected User getKeyUser(String name) {
Object user = getAttribute(name);
return JsonUtils.fromJson(user.toString(), User.class);
}
protected void removeAttribute(String name) {
String sessionId = getRequest().getSession().getId();
String attributesJson = RedisUtils.getKey(sessionId);
Map<String, Object> attributes = JsonUtils.fromJson(attributesJson, HashMap.class);
attributes.remove(name);
RedisUtils.setKey(sessionId, JsonUtils.getJson(attributes), EXPIRE_TIME, TimeUnit.SECONDS);
}
}
自定義 RedisUtils,使用靜態方法
@Slf4j
@Component
public class RedisUtils {
private static StringRedisTemplate stringRedisTemplate;
@Autowired
private StringRedisTemplate autowiredStringRedisTemplate;
@PostConstruct
private void init() {
stringRedisTemplate = this.autowiredStringRedisTemplate;
}
public static void setKey(String key, String value, long timeout, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(addKeyPrefix(key), value, timeout, unit);
}
public static String getKey(String key) {
return stringRedisTemplate.opsForValue().get(addKeyPrefix(key));
}
public static Boolean deleteKey(String key) {
return stringRedisTemplate.opsForValue().getOperations().delete(addKeyPrefix(key));
}
public static Long incrementKey(String key) {
return stringRedisTemplate.opsForValue().increment(addKeyPrefix(key));
}
private static String addKeyPrefix(String key) {
return String.format("session:%s", key);
}
}
UserController
public class UserController extends BaseController {
@PostMapping("/signin")
public ModelAndView doSignin(@RequestParam("email") String email, @RequestParam("password") String password) {
try {
User user = userService.signin(email, password);
setAttribute(KEY_USER, user);
} catch (RuntimeException e) {
return new ModelAndView("signin.html", Map.of("email", email, "error", "Signin failed"));
}
return new ModelAndView("redirect:/profile");
}
@GetMapping("/profile")
public ModelAndView profile() {
User user = getKeyUser(KEY_USER);
if (user == null) {
return new ModelAndView("redirect:/signin");
}
return new ModelAndView("profile.html", Map.of("user", user));
}
@GetMapping("/signout")
public String signout() {
removeAttribute(KEY_USER);
return "redirect:/signin";
}
}
此部分代碼對應 v2
自定義 Session
上面這種方式實現了一個簡單的分佈式 Session,我們可以自定義 Session 來對其進行一定優化,使其具有以下特點:
- 封裝 Attribute 的設置與獲取的實現細節
- 可以自定義 Cookie
- 做一個二級緩存 Attributes,自定義 Session 中存放一份,Redis 再存放一份。
需要利用下面這幾個原生類:
HttpSession
HttpServletRequestWrapper
HttpServletResponseWrapper
設計
1、設置自定義 Session、Request 和 Response
public class WrapperSession implements HttpSession {
private Map<StoreType, SessionStore> sessionStores;
}
public class WrapperSessionServletRequest extends HttpServletRequestWrapper {
private WrapperSession wrapperSession;
}
public class WrapperSessionServletResponse extends HttpServletResponseWrapper {
private WrapperSession session;
}
2、使用 session-config.xml 配置 cookie 和 cache,一個 entry 對應一個 SessionConfigEntry。
<?xml version="1.0" encoding="UTF-8"?>
<sessionConfig>
<entries>
<entry name="sessionId">
<key>js</key>
<path>/</path>
<httponly>true</httponly>
<readonly>true</readonly>
<encrypt>false</encrypt>
<storeType>cookie</storeType>
</entry>
<entry name="__user__">
<storeType>cache</storeType>
<type>wang.depp.session.entity.User</type> <!--類型用於 String 轉換 對象-->
</entry>
</entries>
</sessionConfig>
public class SessionConfigEntry {
private String name;
private String key;
private StoreType storeType;
private String domain;
private String path;
...
}
3、使用 CookieStore 存放 Cookie,使用 CacheStore 存放 attributes,默認直接從 CacheStore 中取,CacheStore 從 Redis 緩存中讀取。
public class CacheStore implements SessionStore, SessionCacheContainerAware {
private final WrapperSessionServletRequest wrapperRequest;
private volatile Map<String, Object> attributes;
}
public class CookieStore implements SessionStore {
private Map<String, String> undecodedCookies = new HashMap<>();
private Map<String, Attribute> attributes = new HashMap<>();
}
鏈路調用
1、項目啓動時根據 session-config.xml 中初始化 SessionConfigEntry
public class WrapperSessionFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
WrapperSessionServletRequest krqRequest = new WrapperSessionServletRequest((HttpServletRequest) request);
WrapperSessionServletResponse krqResponse = new WrapperSessionServletResponse((HttpServletResponse) response);
}
}
public void init() {
initSessionStore();
this.sessionId = getSessionId(); // 從 CookieStore 的 attributes 中獲取 sessionId
generateTrackId();
}
private void initSessionStore() {
for (SessionStore sessionStore : sessionStores.values()) {
sessionStore.init(); // 分別調用子類的 init() 方法
}
}
2、請求時,攔截,查找 SessionId 在 Redis 是否有對應的 Attributes,設置時先設置到 SessionStore
public class CacheStore implements SessionStore, SessionCacheContainerAware {
private final WrapperSessionServletRequest wrapperRequest;
private volatile Map<String, Object> attributes;
@Override
public void setAttribute(SessionConfigEntry sessionConfigEntry, Object value) {
value = RedisUtils.getKey(wrapperRequest.getSession().getId());; // 設置前,先從 Redis 寫入 attributes
if (null == value) { // 如果不存在,刪除
attributes.remove(sessionConfigEntry.getName());
} else {
attributes.put(sessionConfigEntry.getName(), value); // 如果存在,將更新
}
}
}
3、返回前端前,將 Attributes 更新到 Redis
public class WrapperSessionServletResponse extends HttpServletResponseWrapper {
@Override
public PrintWriter getWriter() throws IOException {
getSession().commit(); // 延長 session 的時間
return super.getWriter();
}
}
@Override
public void commit() {
writeToCache();
}
private void writeToCache() {
if (attributes.entrySet().size() > 0) {
ObjectMapper mapper = new ObjectMapper();
String value = null;
try {
value = mapper.writeValueAsString(attributes);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
RedisUtils.setKey(wrapperRequest.getSession().getId(), value, wrapperRequest.getSession().getMaxInactiveInterval());
}
}
4、獲取時,直接從 SessionStore 中獲取,默認將從 Redis 中讀取一次,讀取後將不再讀取,因爲以後都就將寫入 Attributes
...
@Override
public Object getAttribute(SessionConfigEntry sessionConfigEntry) {
loadCache(); // 先從 Redis 寫入 attributes,當 readFromCache() 方法調用後,此時將不再從 Redis 中獲取。如果當前對象一直存活,直接寫入到 attribute,將不用從 Redis 中讀取
return attributes.get(sessionConfigEntry.getName());
}
使用
UserController
public class UserController extends BaseController {
@PostMapping("/signin")
public ModelAndView doSignin(@RequestParam("email") String email, @RequestParam("password") String password) {
try {
User user = userService.signin(email, password);
setAttribute(KEY_USER, user);
} catch (RuntimeException e) {
return new ModelAndView("signin.html", Map.of("email", email, "error", "Signin failed"));
}
return new ModelAndView("redirect:/profile");
}
@GetMapping("/profile")
public ModelAndView profile() {
User user = (User) getAttribute(KEY_USER);
if (user == null) {
return new ModelAndView("redirect:/signin");
}
return new ModelAndView("profile.html", Map.of("user", user));
}
@GetMapping("/signout")
public String signout() {
removeAttribute(KEY_USER);
return "redirect:/signin";
}
}
BaseController
public class BaseController {
// 獲取當前 HttpServletRequest
public static HttpServletRequest getRequest() {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attrs.getRequest();
}
public static void setAttribute(String name, Object value) {
getRequest().getSession().setAttribute(name, value);
}
public static Object getAttribute(String name) {
return getRequest().getSession().getAttribute(name);
}
public static void removeAttribute(String name) {
getRequest().getSession().removeAttribute(name);
}
}
此部分代碼對應 v3。
結語
自定義分佈式 Session 一般實現在網關中,網關接口對外暴露,請求先調用網關,網關請求只能內網訪問的業務系統接口。網關和業務系統規定相應的調用規則(如:添加指定 Header),網關來負責驗證登錄狀態。
Redis 可以實現集羣保證可用性。當不使用分佈式 Session 時,可以使用 JSON Web Token