背景概述
兩個專案組原本都是各自負責兩個產品線(產品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);
}
}