springboot做代理分發服務+代理鑒權的實現過程
還原背景
大傢都做過b-s架構的應用,也就是基於瀏覽器的軟件應用。現在呢有個場景就是FE端也就是前端工程是前後端分離的,采用主流的前端框架VUE編寫。服務端采用的是springBoot架構。
現在有另外一個服務也需要與前端頁面交互,但是由於之前前端與服務端1交互時有鑒權與登錄體系邏輯控制以及分佈式session存儲邏輯都在服務1中,沒有把認證流程放到網關。所以新服務與前端交互則不想再重復編寫一套鑒權認證邏輯。最終想通過服務1進行一個代理把前端固定的請求轉發到新加的服務2上。
怎麼實現
思路:客戶端發送請求,由代理服務端通過匹配請求內容,然後在作為代理去訪問真實的服務器,最後由真實的服務器將響應返回給代理,代理再返回給瀏覽器。
技術:說道反向代理,可能首先想到的就是nginx。不過在我們的需求中,對於轉發過程有更多需求:
- 需要操作session,根據session的取值決定轉發行為
- 需要修改Http報文,增加Header或是QueryString
第一點決定瞭我們的實現必定是基於Servlet的。springboot提供的ProxyServlet就可以滿足我們的要求,ProxyServlet直接繼承自HttpServlet,采用異步的方式調用內部服務器,因此效率上不會有什麼問題,並且各種可重載的函數也提供瞭比較強大的定制機制。
實現過程
引入依賴
<dependency> <groupId>org.mitre.dsmiley.httpproxy</groupId> <artifactId>smiley-http-proxy-servlet</artifactId> <version>1.11</version> </dependency>
構建一個配置類
@Configuration public class ProxyServletConfiguration { private final static String REPORT_URL = "/newReport_proxy/*"; @Bean public ServletRegistrationBean proxyServletRegistration() { List<String> list = new ArrayList<>(); list.add(REPORT_URL); //如果需要匹配多個url則定義好放到list中即可 ServletRegistrationBean registrationBean = new ServletRegistrationBean(); registrationBean.setServlet(new ThreeProxyServlet()); registrationBean.setUrlMappings(list); //設置默認網址以及參數 Map<String, String> params = ImmutableMap.of("targetUri", "null", "log", "true"); registrationBean.setInitParameters(params); return registrationBean; } }
編寫代理邏輯
public class ThreeProxyServlet extends ProxyServlet { private static final long serialVersionUID = -9125871545605920837L; private final Logger logger = LoggerFactory.getLogger(ThreeProxyServlet.class); public String proxyHttpAddr; public String proxyName; private ResourceBundle bundle =null; @Override public void init() throws ServletException { bundle = ResourceBundle.getBundle("prop"); super.init(); } @Override protected void service(HttpServletRequest servletRequest, HttpServletResponse servletResponse) throws ServletException, IOException { // 初始切換路徑 String requestURI = servletRequest.getRequestURI(); proxyName = requestURI.split("/")[2]; //根據name匹配域名到properties文件中獲取 proxyHttpAddr = bundle.getString(proxyName); String url = proxyHttpAddr; if (servletRequest.getAttribute(ATTR_TARGET_URI) == null) { servletRequest.setAttribute(ATTR_TARGET_URI, url); } if (servletRequest.getAttribute(ATTR_TARGET_HOST) == null) { URL trueUrl = new URL(url); servletRequest.setAttribute(ATTR_TARGET_HOST, new HttpHost(trueUrl.getHost(), trueUrl.getPort(), trueUrl.getProtocol())); } String method = servletRequest.getMethod(); // 替換多餘路徑 String proxyRequestUri = this.rewriteUrlFromRequest(servletRequest); Object proxyRequest; if (servletRequest.getHeader("Content-Length") == null && servletRequest.getHeader("Transfer-Encoding") == null) { proxyRequest = new BasicHttpRequest(method, proxyRequestUri); } else { proxyRequest = this.newProxyRequestWithEntity(method, proxyRequestUri, servletRequest); } this.copyRequestHeaders(servletRequest, (HttpRequest)proxyRequest); setXForwardedForHeader(servletRequest, (HttpRequest)proxyRequest); HttpResponse proxyResponse = null; try { proxyResponse = this.doExecute(servletRequest, servletResponse, (HttpRequest)proxyRequest); int statusCode = proxyResponse.getStatusLine().getStatusCode(); servletResponse.setStatus(statusCode, proxyResponse.getStatusLine().getReasonPhrase()); this.copyResponseHeaders(proxyResponse, servletRequest, servletResponse); if (statusCode == 304) { servletResponse.setIntHeader("Content-Length", 0); } else { this.copyResponseEntity(proxyResponse, servletResponse, (HttpRequest)proxyRequest, servletRequest); } } catch (Exception var11) { this.handleRequestException((HttpRequest)proxyRequest, var11); } finally { if (proxyResponse != null) { EntityUtils.consumeQuietly(proxyResponse.getEntity()); } } } @Override protected HttpResponse doExecute(HttpServletRequest servletRequest, HttpServletResponse servletResponse, HttpRequest proxyRequest) throws IOException { HttpResponse response = null; // 攔截校驗 可自定義token過濾 //String token = servletRequest.getHeader("ex_proxy_token"); // 代理服務鑒權邏輯 this.getAuthString(proxyName,servletRequest,proxyRequest); //執行代理轉發 try { response = super.doExecute(servletRequest, servletResponse, proxyRequest); } catch (IOException e) { e.printStackTrace(); } return response; } }
增加一個properties配置文件
上邊的配置簡單介紹一下,對於/newReport_proxy/* 這樣的寫法,意思就是當你的請求路徑以newReport_proxy 開頭,比如http://localhost:8080/newReport_proxy/test/get1 這樣的路徑,它請求的真實路徑是https://www.baidu.com/test/get1 。主要就是將newReport_proxy 替換成對應的被代理路徑而已,* 的意思就是實際請求代理項目中接口的路徑,這種配置對get 、post 請求都有效。
遇到問題
按如上配置,在執行代理轉發的時候需要對轉發的代理服務器的接口進行鑒權,具體鑒權方案調用就是 “this.getAuthString(proxyName,servletRequest,proxyRequest);”這段代碼。代理服務的鑒權邏輯根據入參+token值之後按算法計算一個值,之後進行放到header中傳遞。那麼這就遇到瞭一個問題,就是當前端采用requestBody的方式進行調用請求時服務1進行代理轉發的時候會出現錯誤:
一直卡在執行 doExecute()方法。一頓操作debug後定位到一個點,也就是最後進行觸發進行執行代理服務調用的點:
在上圖位置拋瞭異常,上圖中i的值為-1,說明這個sessionBuffer中沒有數據瞭,讀取不到瞭所以返回瞭-1。那麼這個sessionBuffer是個什麼東西呢?這個東西翻譯過來指的是會話輸入緩沖區,會阻塞連接。 與InputStream類相似,也提供讀取文本行的方法。也就是通過這個類將對應請求的數據流發送給目標服務。這個位置出錯說明這個要發送的數據流沒有瞭,那麼在什麼時候將請求的數據流信息給弄沒瞭呢?那就是我們加點鑒權邏輯,鑒權邏輯需要獲取requestBody中的參數,去該參數是從request對象中通過流讀取的。這個問題我們也見過通常情況下,HttpServletRequst 中的 body 內容隻會讀取一次,但是可能某些情境下可能會讀取多次,由於 body 內容是以流的形式存在,所以第一次讀取完成後,第二次就無法讀取瞭,一個典型的場景就是 Filter 在校驗完成 body 的內容後,業務方法就無法繼續讀取流瞭,導致解析報錯。
最終實現
思路:用裝飾器來修飾一下 request,使其可以包裝讀取的內容,供多次讀取。其實spring boot提供瞭一個簡單的封裝器ContentCachingRequestWrapper,從源碼上看這個封裝器並不實用,沒有封裝http的底層流ServletInputStream信息,所以在這個場景下還是不能重復獲取對應的流信息。
參照ContentCachingRequestWrapper類實現一個stream緩存
public class CacheStreamHttpRequest extends HttpServletRequestWrapper { private static final Logger LOGGER = LoggerFactory.getLogger(CacheStreamHttpRequest.class); private final ByteArrayOutputStream cachedContent; private Map<String, String[]> cachedForm; @Nullable private ServletInputStream inputStream; public CacheStreamHttpRequest(HttpServletRequest request) { super(request); this.cachedContent = new ByteArrayOutputStream(); this.cachedForm = new HashMap<>(); cacheData(); } @Override public ServletInputStream getInputStream() throws IOException { this.inputStream = new RepeatReadInputStream(cachedContent.toByteArray()); return this.inputStream; } @Override public String getCharacterEncoding() { String enc = super.getCharacterEncoding(); return (enc != null ? enc : WebUtils.DEFAULT_CHARACTER_ENCODING); } @Override public BufferedReader getReader() throws IOException { return new BufferedReader(new InputStreamReader(getInputStream(), getCharacterEncoding())); } @Override public String getParameter(String name) { String value = null; if (isFormPost()) { String[] values = cachedForm.get(name); if (null != values && values.length > 0) { value = values[0]; } } if (StringUtils.isEmpty(value)) { value = super.getParameter(name); } return value; } @Override public Map<String, String[]> getParameterMap() { if (isFormPost() && !CollectionUtils.sizeIsEmpty(cachedForm)) { return cachedForm; } return super.getParameterMap(); } @Override public Enumeration<String> getParameterNames() { if (isFormPost() && !CollectionUtils.sizeIsEmpty(cachedForm)) { return Collections.enumeration(cachedForm.keySet()); } return super.getParameterNames(); } @Override public String[] getParameterValues(String name) { if (isFormPost() && !CollectionUtils.sizeIsEmpty(cachedForm)) { return cachedForm.get(name); } return super.getParameterValues(name); } private void cacheData() { try { if (isFormPost()) { this.cachedForm = super.getParameterMap(); } else { ServletInputStream inputStream = super.getInputStream(); IOUtils.copy(inputStream, this.cachedContent); } } catch (IOException e) { LOGGER.warn("[RepeatReadHttpRequest:cacheData], error: {}", e.getMessage()); } } private boolean isFormPost() { String contentType = getContentType(); return (contentType != null && (contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE) || contentType.contains(MediaType.MULTIPART_FORM_DATA_VALUE)) && HttpMethod.POST.matches(getMethod())); } private static class RepeatReadInputStream extends ServletInputStream { private final ByteArrayInputStream inputStream; public RepeatReadInputStream(byte[] bytes) { this.inputStream = new ByteArrayInputStream(bytes); } @Override public int read() throws IOException { return this.inputStream.read(); } @Override public int readLine(byte[] b, int off, int len) throws IOException { return this.inputStream.read(b, off, len); } @Override public boolean isFinished() { return this.inputStream.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener listener) { } } }
如上類核心邏輯是通過cacheData() 方法進行將 request對象緩存,存儲到ByteArrayOutputStream類中,當在調用request對象獲取getInputStream()方法時從ByteArrayOutputStream類中寫回InputStream核心代碼:
@Override public ServletInputStream getInputStream() throws IOException { this.inputStream = new RepeatReadInputStream(cachedContent.toByteArray()); return this.inputStream; }
使用這個封裝後的request時需要配合Filter對原有的request進行替換,註冊Filter並在調用鏈中將原有的request換成該封裝類。代碼:
//chain.doFilter(request, response); //換掉原來的request對象 用new RepeatReadHttpRequest((HttpServletRequest) request) 因為後者流中由緩存攔截器httprequest替換 可重復獲取inputstream chain.doFilter(new RepeatReadHttpRequest((HttpServletRequest) request), response);
這樣就解決瞭服務代理分發+代理服務鑒權一套邏輯。
到此這篇關於springboot做代理分發服務+代理鑒權的文章就介紹到這瞭,更多相關springboot服務代理內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- 使用ServletInputStream在攔截器或過濾器中應用後重寫
- Springboot實現VNC的反向代理功能
- 使用Springboot自定義轉換器實現參數去空格功能
- Java中使用Filter過濾器的方法
- 在攔截器中讀取request參數,解決在controller中無法二次讀取的問題