springboot服务代理 springboot做代理分发服务+代理鉴权的实现过程
我是鳄鱼头领 人气:0还原背景
大家都做过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);
这样就解决了服务代理分发+代理服务鉴权一套逻辑。
加载全部内容