《優化介面設計的思路》系列:第二篇—介面使用者上下文的設計與實現

2023-09-15 12:00:32

前言

大家好!我是sum墨,一個一線的底層碼農,平時喜歡研究和思考一些技術相關的問題並整理成文,限於本人水平,如果文章和程式碼有表述不當之處,還請不吝賜教。

作為一名從業已達六年的老碼農,我的工作主要是開發後端Java業務系統,包括各種管理後臺和小程式等。在這些專案中,我設計過單/多租戶體系系統,對接過許多開放平臺,也搞過訊息中心這類較為複雜的應用,但幸運的是,我至今還沒有遇到過線上系統由於程式碼崩潰導致資損的情況。這其中的原因有三點:一是業務系統本身並不複雜;二是我一直遵循某大廠程式碼規約,在開發過程中儘可能按規約編寫程式碼;三是經過多年的開發經驗積累,我成為了一名熟練工,掌握了一些實用的技巧。

考慮到文字太過寡淡,我先上一張圖

在Spring Boot中,預設情況下,每個請求到達時都會分配一個單獨的執行緒來處理,而且請求的發起人也不一定都是同一個人,所以一個請求對應一個使用者上下文,並且要求執行緒隔離,即不同執行緒的使用者上下文互不影響,最後使用者上下文還需要隨著執行緒的結束而刪除。
本文我會從使用者上下文如何構建、如何使用、如何刪除這三個方面解釋介面使用者上下文的設計與實現。

一、介面使用者上下文的構建、使用、清除

1. 利用Filter攔截到每一個請求

由於介面散落在各個Controller中,且絕大部分介面都是需要這個使用者上下文的(注:也不排除不需要使用者上下文的介面存在),所以這裡需要統一入口進行建立、銷燬。看起來可以使用AOP的方式來實現,
不過這裡有一個更合適的方案,利用SpringBoot自帶的Filter【javax.servlet.Filter】來實現。

實現起來非常簡單,我這邊自定義了一個WebFilter,程式碼如下:

WebFilter.java

package com.summo.filter;

import java.io.IOException;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.summo.context.GlobalUserContext;
import com.summo.context.UserContext;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class WebFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
        throws IOException, ServletException {
        try {
            //獲取本次介面的唯一碼
            String token = java.util.UUID.randomUUID().toString().replaceAll("-", "").toUpperCase();
            MDC.put("requestId", token);
            //獲取請求頭
            HttpServletRequest httpServletRequest = (HttpServletRequest)servletRequest;
            HttpServletResponse httpServletResponse = (HttpServletResponse)servletResponse;
            log.info("當前請求連結為:[{}]", httpServletRequest.getRequestURL());
            //設定使用者上下文
            UserContext userContext = new UserContext();
            userContext.setUserId(1L);
            GlobalUserContext.setUserContext(userContext);
            //執行doFilter,這行一定要加,否則程式會中斷掉
            filterChain.doFilter(httpServletRequest, httpServletResponse);
        } catch (Exception e) {
            log.error("do doFilter exception", e);
        } finally {
            GlobalUserContext.clear();
            MDC.remove("requestId");
        }
    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }
}

這段程式碼的核心方法是:public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
我們可以在這個方法裡面獲取到ServletRequest和ServletResponse,這兩個類能獲取到代表著我們可以操作整個請求過程,這裡如何確定當前請求的使用者?下面有一張流程圖供大家參考:

還有一種做法是使用JWT來當做使用者token,因為JWT本身就可以儲存一些資訊,所以我們就不需要去快取使用者資訊了,直接解析JWT即可,這種做法在分散式應用中很常見。

2. 獲取當前請求的執行緒

上面已經獲取到使用者資訊了,現在需要將使用者資訊放入使用者上下文中,但由於請求的發起人不一定都是同一個人,所以一個請求對應著一個使用者上下文,也即一個執行緒設定一個上下文。那麼這裡就需要獲取到當前執行緒才能設定上下文。

獲取當前執行緒有很多辦法,這裡推薦使用阿里巴巴開源的TTL框架(TransmittableThreadLocal)來實現,功能強大且用法簡單。

引入方法如下:

<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>transmittable-thread-local</artifactId>
  <version>2.11.1</version>
</dependency>

使用方法如下:

 private static final TransmittableThreadLocal<UserContext> USER_HOLDER = new TransmittableThreadLocal<>();

直接new一個物件就行,而且支援泛型。

3. 使用者上下文生命週期管理

對於使用者上下文的生命週期管理需要定義3個方法:

  • 設定上下文使用者資訊;
  • 獲取上下文使用者資訊
  • 清除上下文使用者資訊

以上方法均為靜態方法。

下面是一個簡單的例子:
GlobalUserContext.java

package com.summo.context;

import com.alibaba.ttl.TransmittableThreadLocal;

public class GlobalUserContext {

    private static final TransmittableThreadLocal<UserContext> USER_HOLDER = new TransmittableThreadLocal<>();

    /**
     * 設定上下文使用者資訊
     *
     * @param user 使用者資訊
     */
    public static void setUserContext(UserContext user) {
        USER_HOLDER.set(user);
    }

    /**
     * 獲取上下文使用者資訊
     */
    public static UserContext getUserContext() {
        return USER_HOLDER.get();
    }

    /**
     * 清除上下文使用者資訊
     */
    public static void clear() {
        USER_HOLDER.remove();
    }
}

UserContext.java

package com.summo.context;

import lombok.Data;

@Data
public class UserContext {

    /**
     * 使用者ID
     */
    private Long userId;

}

呼叫方式如下:

設定上下文使用者資訊:GlobalUserContext.setUserContext(userContext);
獲取上下文使用者資訊:GlobalUserContext.getUserContext();
清除上下文使用者資訊:GlobalUserContext.clear();

4. 使用者上下文的使用

獲取使用者上下文很方便,呼叫GlobalUserContext.getUserContext();就行了,這裡我主要講一下使用者上下文的使用場景。

a. 身份認證

可以將使用者的身份認證資訊(如使用者名稱、密碼、許可權等)儲存在使用者上下文中,在需要進行鑑權的地方進行驗證。

b. 使用者紀錄檔記錄

正如《優化介面設計的思路》系列:第三篇—在使用者使用系統過程中留下痕跡 的方法三.

c. 防止介面資料越權

舉個例子,比如有些業務需要獲取當前登入使用者的資訊、當前登入使用者的收藏、當前登入使用者的瀏覽記錄,這樣的介面總不能在介面上傳一個userId吧?真要這樣幹了,非得給安全罵死。。。
利用使用者上下文的話,介面就可以不用傳遞任何引數獲取到當前使用者的userId,實現你的需求啦。

d. 跨服務呼叫

在分散式系統中,可以將使用者上下文資訊傳遞給其他服務,以保持使用者的一致性和連貫性。

e. 監控和統計

可以將使用者上下文中的資訊用於系統的監控和統計,如請求的處理時間、請求的次數等。

5. 使用者上下文的刪除

刪除很簡單,呼叫GlobalUserContext.clear();即可,詳情可見WebFilter.java內容。

二. 使用者登入&認證

上面主要是說怎麼獲取到介面請求的使用者以及怎麼設定使用者上下文,但沒說使用者身份是什麼時候確認的以及怎麼確認的,這裡說一下常見做法。
想要確認使用者資訊就不得不提到使用者登入&認證這套東西了,登入的方式非常多,簡單的有賬號密碼登入、手機驗證碼登入,複雜的就是單點登入、三方授權登入如微信掃碼、支付寶掃碼等。雖然方式多,但是結果都一樣的:確認當前使用者身份

當前使用者身份確認好之後,系統一般會根據當前使用者資訊生成一個唯一的並帶有時效性的token,放入下一次請求的cookie中。等到下一次請求來的時候,我們就可以從cookie中獲取這個token,利用這個token獲取這個使用者的資訊。

由於使用者認證情況太多,這裡我就不貼程式碼了,上面是賬號密碼登入使用者認證的的時序圖,供大家參考。