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服务代理内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!