基於Servlet體系的HTTP請求代理轉發Spring Boot組件


背景概述

兩個項目組原本都是各自負責兩個產品線(產品A產品B),由於公司業務的發展,目前需要將兩個產品合並成一個大產品(功能整合,部分做取舍,最終產出產品C),前后端代碼必然也需要整合,包括兩個產品線的用戶體系等。並且給出的時間節點很緊張

目前兩個產品線的區別點:

產品A

  • 前端模塊載體是微信小程序,沒有H5、APP等需求,因此所采用的技術棧是原生寫法,沒有用到技術框架
  • 服務端技術架構是單體架構,Spring Boot框架,管理后台框架采用的是Apache Shiro
  • 前后端接口調用采用的是服務端token鑒權的方式交互
  • 用戶體系簡單,小程序端沒有會員等業務,僅涉及到微信openid,管理后台涉及權限菜單.
  • 后端管理系統前端開發技術框架是React

產品B

  • 前端模塊載體多樣,包括微信小程序、H5、APP等,因此采用的是多端統一框架,例如:union-app
  • 服務端技術架構單體架構,Spring Boot框架
  • 前后端接口調用采用的是服務端token鑒權的方式交互
  • 用戶體系復雜,有會員、優惠券等業務,管理后台涉及權限菜單
  • 后端管理系統前端開發技術框架是Vue

產品C

  • 載體是微信小程序,沒有H5、APP等需求
  • 產品A中的功能居多,產品B中的功能占用少部分

鑒於上面的背景,我們討論接下來產品線合並的可能性

  • 前端代碼重寫,雖說是產品線合並,但是原來兩個產品線的功能點只是做整合,並沒有太多新增的功能,因此原來的部分功能模塊可以復用,采用原生寫法,不用多端框架
  • 后端用戶體系復用產品B中的體系,基本控制菜單權限即可
  • 考慮到時間緊迫,因此原本產品A\B兩個產品線的已有的功能基本不動,只對新增模塊的功能進行開發。
  • 產品B的后端系統功能菜單、權限系統較A完善,因此作為產品C的管理后端進行復用,將產品A的后端功能全部移動到產品C中,由於兩個產品線管理后台開發的技術棧不一樣,因此產品C中的部分功能需要重寫,將產品A的功能使用Vue的技術棧移到產品C中

游客端(小程序端)

針對產品C的小程序端,由於需要包含產品A中的某一核心功能,因此不太可能使用多端框架進行重寫(PS:主要是領導給的時間不夠),因此采用的做法是直接在產品A的基礎上衍生一個版本,最終將產品B中的部分功能,通過原生框架,最終在產品C中進行呈現。

因為小程序的接口調用方式是直連,通過發起HTTPS的接口請求即可,因此服務端接口邏輯不動,前端開發人員只需要和產品B的人員進行接口對接即可,最終接口調用流程示意圖如下:

管理端(PC端)

管理端則不同,由於是使用的產品B中的后台,因此產品A中的權限控制需要去除(例如登錄后才能調用接口等限制),而產品A中的接口權限控制需要交給B來管,發送請求時需要校驗當前請求的權限,校驗通過后再轉發給A,調用時序圖如下:

上面這張圖也是這個組件雛形,寄希望與通過該轉發組件,通過提供不同的轉發方式,封裝轉發HTTP請求的能力,達到直連服務的目的

如果單純從一個新產品C的角度出發,ServiceA中的服務接口代碼應該合並到ServiceB,最終形成一個新的ServiceC,但是考慮到時間緊迫,所以代碼層面的合並並沒有形成,因此考慮直接將請求HTTP轉發的方式,最終將任務完成。

程序設計

從需求背景出發,在程序設計上需要考慮的幾個點:

  • 上游服務接收到的固定請求頭,或者請求參數,比如多租戶系統需要接收一個租戶的請求header,因此轉發組件需要有配置固定header的能力,以便在實際轉發過程中發送到下游服務,方便系統擴展
  • 需要提供權限驗證的接口,不同的權限框架可能驗證方式不同,有些系統是Shiro,或者Spring Security,或者自研,因此在最終權限校驗時,考慮到和系統的兼容性,對於下游的轉發服務接口,需要提供和系統兼容的驗證接口,不可打破原系統的穩定性
  • 轉發的方式支持類別,考慮到系統的健壯性,需要提供不同的轉發類別支撐

由於是基於Servlet體系,因此對於接口的請求,需要做一層攔截判斷,以驗證當前的請求是否是需要轉發到下游服務,核心過濾器如下:

public class ServletGatewayRouteProxyFilter implements Filter {
    //執行器對象
    private final RouteDispatcher routeDispatcher;
    //權限對象
    private final ServletGatewayAuthentication servletGatewayAuthentication;
    Logger logger= LoggerFactory.getLogger(ServletGatewayRouteProxyFilter.class);

    /**
     * 狗仔ProxyHttpFilter 對象實例
     * @param routeDispatcher 執行器對象
     * @param servletGatewayAuthentication 權限校驗對象
     */
    public ServletGatewayRouteProxyFilter(RouteDispatcher routeDispatcher, ServletGatewayAuthentication servletGatewayAuthentication) {
        this.routeDispatcher = routeDispatcher;
        this.servletGatewayAuthentication = servletGatewayAuthentication;
    }
 
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request= (HttpServletRequest) servletRequest;
        HttpServletResponse response=(HttpServletResponse) servletResponse;
        //根據程序配置方式,截取當前請求是否符合轉發請求
        Optional<ServiceRoute> serviceRouteOptional=routeDispatcher.assertServletRequest(request);
        if (serviceRouteOptional.isPresent()){
            logger.info("轉發目標服務,地址:{}",request.getRequestURI());
            if (servletGatewayAuthentication.required()){
                if (servletGatewayAuthentication.auth(servletRequest,servletResponse)){
                    routeDispatcher.execute(request,response,serviceRouteOptional.get());
                }else{
                    servletGatewayAuthentication.failedHandle(servletRequest,servletResponse);
                }
            }else{
                routeDispatcher.execute(request,response,serviceRouteOptional.get());
            }
        }else{
            //不符合,繼續執行
            filterChain.doFilter(servletRequest,servletResponse);
        }
    }
 	
    //other code...

}

對於當前的HttpServletRequest信息做判斷,獲取當前請求的ServiceRoute對象,以此來判斷請求是否需要轉發

ServiceRoute對象主要包含下游轉發服務的HTTP地址、端口號、固定Header信息

public class ServiceRoute {

    /**
     * 轉發模式
     */
    private RouteModeEnum mode;
    /**
     * 匹配值
     */
    private String value;

    /**
     * 轉發目標地址,例如:http://192.179.0.1:8999
     */
    private String uri;

    /**
     * 發送請求頭
     */
    private Map<String,String> headers;
    //getter and setter
}

ServiceRoute是最終交給開發者配置的信息,轉發請求方式,判斷邏輯如下:

/**
* 校驗當前路由規則是否符合
* @param serviceRoute 路由實例
* @param servletRequest 請求對象
* @return 是否符合規則
*/
protected boolean checkRoute(ServiceRoute serviceRoute,HttpServletRequest servletRequest){
    boolean flag=false;
    if (serviceRoute!=null){
        switch (serviceRoute.getMode()){
            //基於請求頭
            case ROUTE_MODE_HEADER:
                String value=servletRequest.getHeader(ROUTE_MODE_HEADER_NAME);
                flag=StrUtil.equalsIgnoreCase(value,serviceRoute.getValue());
                break;
            //基於URI的前綴匹配
            case ROUTE_MODE_PREFIX:
                flag=servletRequest.getRequestURI().startsWith(serviceRoute.getValue());
                break;
            //基於URI的后綴匹配
            case ROUTE_MODE_SUFFIX:
                flag=servletRequest.getRequestURI().endsWith(serviceRoute.getValue());
                break;

        }
    }
    return flag;
}

針對權限的設計,在ServletGatewayRouteProxyFilter中,提供了ServletGatewayAuthentication接口,該接口設計如下:

public interface ServletGatewayAuthentication {

    /**
     * 權限校驗
     * @param request 請求request對象
     * @param response 響應對象
     * @return 是否權限校驗通過
     */
    boolean auth(ServletRequest request, ServletResponse response);

    /**
     * 權限校驗失敗后的處理邏輯
     * @param request 請求對象
     * @param response 響應對象
     */
    void failedHandle(ServletRequest request, ServletResponse response);

    /**
     * 是否需要鑒權,默認true
     * @return 是否需要鑒權
     */
    default boolean required(){return true;}
}

主要包含三個接口:

  • auth:權限驗證,返回布爾值,該接口方法主要是兼容系統中的權限,對於當前的請求,可以方便的做出權限判斷,交由開發者實現
  • failedHandle:如果權限驗證失敗,最終響應信息給前端,開發者實現
  • required:是否需要鑒權的標志,默認是true,代表需要鑒權

最后再來看代理請求的執行邏輯(RouteDispatcher.java#execute()方法),部分核心代碼如下:

public void execute(HttpServletRequest request, HttpServletResponse response,ServiceRoute serviceRoute){
    try{
        //構建請求對象
        RouteRequestContext routeContext=new RouteRequestContext();
        //請求對象賦值
        this.buildContext(routeContext,request,serviceRoute);
        //發送請求
        RouteResponse routeResponse=routeExecutor.executor(routeContext);
        //響應結果
        writeResponseHeader(routeResponse,response);
        writeBody(routeResponse,response);
    }catch (Exception e){
        logger.error("has Error:{}",e.getMessage());
        logger.error(e.getMessage(),e);
        //write Default
        writeDefault(request,response,e.getMessage());
    }
}

針對請求上下文的賦值,主要是接收當前請求的請求參數以及請求頭,並且根據ServiceRoute路由基礎信息,進行基礎賦值,代碼如下:

/**
 * 構建路由的請求上下文
 * @param routeRequestContext 請求上下文對象
 * @param request 請求
 * @param serviceRoute 路由實例
 * @throws IOException IO異常
 */
protected void buildContext(RouteRequestContext routeRequestContext,HttpServletRequest request,ServiceRoute serviceRoute) throws IOException {
    //String uri="http://knife4j.xiaominfo.com";
    String uri=serviceRoute.getUri();
    if (StrUtil.isBlank(uri)){
        throw new RuntimeException("Uri is Empty");
    }
    String host=URI.create(uri).getHost();
    String fromUri=request.getRequestURI();
    StringBuilder requestUrlBuilder=new StringBuilder();
    requestUrlBuilder.append(uri);
    //判斷當前聚合項目的contextPath
    if (StrUtil.isNotBlank(this.rootPath)&&!StrUtil.equals(this.rootPath,ROUTE_BASE_PATH)){
        fromUri=fromUri.replaceFirst(this.rootPath,"");
    }
    if (serviceRoute.getMode()== RouteModeEnum.ROUTE_MODE_PREFIX){
        //前綴轉發,替換
        fromUri=fromUri.replaceFirst(serviceRoute.getValue(),"/");
    }
    if (!StrUtil.startWith(fromUri,"/")){
        requestUrlBuilder.append("/");
    }
    requestUrlBuilder.append(fromUri);
    //String requestUrl=uri+fromUri;
    String requestUrl=requestUrlBuilder.toString();
    logger.info("目標請求Url:{},請求類型:{},Host:{}",requestUrl,request.getMethod(),host);
    routeRequestContext.setOriginalUri(fromUri);
    routeRequestContext.setUrl(requestUrl);
    routeRequestContext.setMethod(request.getMethod());
    Enumeration<String> enumeration=request.getHeaderNames();
    while (enumeration.hasMoreElements()){
        String key=enumeration.nextElement();
        String value=request.getHeader(key);
        if (!ignoreHeaders.contains(key.toLowerCase())){
            routeRequestContext.addHeader(key,value);
        }
    }
    //是否有默認Header需要發送
    if (CollectionUtil.isNotEmpty(serviceRoute.getHeaders())){
        for (Map.Entry<String,String> entry:serviceRoute.getHeaders().entrySet()){
            routeRequestContext.addHeader(entry.getKey(),entry.getValue());
        }
    }
    routeRequestContext.addHeader("Host",host);
    Enumeration<String> params=request.getParameterNames();
    while (params.hasMoreElements()){
        String name=params.nextElement();
        String value=request.getParameter(name);
        //logger.info("param-name:{},value:{}",name,value);
        routeRequestContext.addParam(name,value);
    }
    routeRequestContext.setRequestContent(request.getInputStream());
}

使用指南

servlet-gateway-spring-boot-starter組件是一組基於Servlet體系的業務轉發HTTP組件,主要目的是在現有Spring Boot 框架的基礎上,添加基於Filter過濾器的轉發能力,豐富框架的業務能力。

源碼地址:https://gitee.com/dt_research_institute/java-business-kernel

目前支持三種模式:

  • ROUTE_MODE_HEADER:基於請求頭的轉發
  • ROUTE_MODE_PREFIX:基於請求Uri的請求前綴匹配轉發
  • ROUTE_MODE_SUFFIX:基於請求URI的后綴匹配轉發規則

使用方法,在Spring Boot的框架中,pom.xml中引入當前組件,代碼如下:

<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>servlet-gateway-spring-boot-starter</artifactId>
    <version>1.0</version>
</dependency>

在Spring Boot框架的application.yml配置文件中進行配置,示例如下:

server:
  servlet:
    gateway:
      enable: true
       cloud:
         enable: true
         # Routes節點,可以配置多個
         routes:
           - mode: ROUTE_MODE_PREFIX
           	 # 將所有以/abb開頭的請求接口全部轉發到uri中的目標服務
             value: /abb/
             uri: http://knife4j.xiaominfo.com
             # 配置發送默認請求頭(可選配置)
             headers:
               code: TESS

針對代理請求鑒權功能,該組件提供了ServletGatewayAuthentication接口,對於接入該組件的項目需要實現該接口,並且注入到 Spring 的容器中

public interface ServletGatewayAuthentication {

    /**
     * 權限校驗
     * @param request 請求request對象
     * @param response 響應對象
     * @return 是否權限校驗通過
     */
    boolean auth(ServletRequest request, ServletResponse response);

    /**
     * 權限校驗失敗后的處理邏輯
     * @param request 請求對象
     * @param response 響應對象
     */
    void failedHandle(ServletRequest request, ServletResponse response);

    /**
     * 是否需要鑒權,默認true
     * @return 是否需要鑒權
     */
    default boolean required(){return true;}
}

以下是一個項目中通過Shiro控制權限的例子,對於代理的請求,需要驗證當前的請求是否已經登錄過

public class AideShiroAuthentication implements ServletGatewayAuthentication {

    private final OtsWebSessionManager otsWebSessionManager;
    private final RedisTemplate redisTemplate;

    Logger logger= LoggerFactory.getLogger(AideShiroAuthentication.class);

    public AideShiroAuthentication(OtsWebSessionManager otsWebSessionManager, RedisTemplate redisTemplate) {
        this.otsWebSessionManager = otsWebSessionManager;
        this.redisTemplate = redisTemplate;
    }


    @Override
    public boolean auth(ServletRequest request, ServletResponse response) {
        Serializable sessionId = otsWebSessionManager.getShiroSessionId(request, response);
        if (sessionId!=null){
            Object object= redisTemplate.opsForValue().get(MyRedisSessionDao.PREFIX + sessionId.toString());
            if (object!=null){
                Session session = (Session)object;
                return session!=null&&session.getId()!=null;
            }
        }
        return false;
    }

    @Override
    public void failedHandle(ServletRequest request, ServletResponse response) {
        logger.info("權限校驗失敗");
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        RestResult<String> result = new RestResult<>();
        result.setErrCode(BusinessErrorCode.NO_CURRENT_LOGIN_USER.getCode());
        result.setData(BusinessErrorCode.NO_CURRENT_LOGIN_USER.getMessage());

        try (PrintWriter out = response.getWriter()) {
            out.append(JSON.toJSONString(result));
        } catch (IOException e2) {
            return;
        }
    }
}

通過自定義權限接口后,需要注入到Spring的容器中(注意:需要添加@Primary注解),代碼如下:

@Configuration
public class AuthConfig {

    @Bean
    @Primary
    public AideShiroAuthentication aideServletGatewayAuthentication(@Autowired OtsWebSessionManager otsWebSessionManager,@Autowired RedisTemplate redisTemplate){
        return new AideShiroAuthentication(otsWebSessionManager,redisTemplate);
    }
}


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM