【问题标题】:Wrap requests in Tomcat custom valve to allow reading the request body在 Tomcat 自定义阀门中包装请求以允许读取请求正文
【发布时间】:2017-10-10 15:18:51
【问题描述】:

我被要求开发一个 Tomcat 阀门来记录所有 HTTP 请求,包括它们的主体。由于包含主体的流只能读取一次,我发现我需要包装请求。我在这里找到了一个基于 JBOSS 的示例(下载链接“Maven project for a Valve that dumps the full request with body”):

https://bz.apache.org/bugzilla/show_bug.cgi?id=45014

我对其进行了调整,使其可以与原版 Tomcat 和更多最新的 API 一起使用(我使用的是 tomcat-catalina:8.5.20)。

这是我的阀门的样子:

public class CaptureValve extends ValveBase {
// ...
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
    // Wrap request so the body can be read multiple times
    RequestWrapper wrappedRequest = new RequestWrapper(request);

    // important - otherwise, requests aren't passed further down the chain...
    getNext().invoke(wrappedRequest, response);

    // Simplified for demo purposes - now I'm reading the body to log it
    LogBody(wrappedRequest.getBody());
}
// ...
}

现在RequestWrapper,正如您想象的那样,只是代理对包装对象的调用,GetRequest 除外:以下是该类的相关部分:

public class RequestWrapper extends Request {
//...
    public RequestWrapper(Request wrapped) throws IOException {
        wrappedCatalinaRequest = wrapped;
        loggingRequest = new LoggingRequest(wrapped);
    }    
//...
    @Override
    public HttpServletRequest getRequest() {
        // here is where the actual request used to read from is retrieved
        logger.info("getRequest()");
        return loggingRequest;
    }
//...
}

所以下一部分是LoggingRequest,它包裹了内部RequestFacade

private class LoggingRequest extends RequestFacade {

    private LoggingInputStream is;

    LoggingRequest(Request request) throws IOException {
        super(request);

        int len = 0;
        try {
            len = Integer.parseInt(request.getHeader("content-length"));
        } catch (NumberFormatException e) {
            // ignore and assume 0 length
        }

        String contentType = request.getHeader("content-type");

        if (contentType != null) {
            for (String ct : contentType.split(";")) {
                String s = ct.trim();
                if (s.startsWith("charset")) {
                    charset = s.substring(s.indexOf("=") + 1);
                    break;
                }
            }
        }

        // This line causes the issues I describe below
        is = new LoggingInputStream(request.getRequest().getInputStream(), len, charset);
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        logger.info("LoggingRequest.getInputStream()");
        return is;
    }

   @Override
    public BufferedReader getReader() throws IOException {
        logger.info("LoggingRequest.getReader()");
        return new BufferedReader(new InputStreamReader(is, charset));
    }

    public String getPayload() {
        logger.info("Method: " + new Object() {}.getClass().getEnclosingMethod().getName());
        return is.getPayload();
    }
}

请注意我将输入流分配给is 变量的行。这就是我在下面描述的问题的起点。

最后,ServletInputStream 的包装器 - 如您所见,想法是,当从实际的 Tomcat 应用程序读取主体时,将读取的字节也写入缓冲区,然后可以再次读取getPayload() 方法。我剥离了代码的明显部分,如果你想查看所有细节,你可以在链接的示例项目中找到它:

public class LoggingInputStream extends ServletInputStream {
    //...
    public LoggingInputStream(ServletInputStream inputStream, int length, String charset) {
        super();
        is = inputStream;
        bytes = new ByteArrayOutputStream(length);
        charsetName = (charset == null ? "UTF-8" : charset);
    }

    /*
    * Since we are not sure which method will be used just override all 4 of them:
    */
    @Override
    public int read() throws IOException {
        logger.info("LoggingInputStream.read()");
        int ch = is.read();
        if (ch != -1) {
            bytes.write(ch);
            //            logger.info("read:" + ch);
            //            logger.info("bytes.size()=" + bytes.size());
        }
        return ch;
    }

    @Override
    public int read(byte[] b) throws IOException {
        logger.info("LoggingInputStream.read(byte[] b)");
        //        logger.info("byte[].length=" + b.length);
        //        logger.info("byte[]=" + b);
        int numBytesRead = is.read(b);
        if (numBytesRead != -1) {
            for (int i = 0; i < numBytesRead; i++) {
                bytes.write(b[i]);
            }
        }
        return numBytesRead;
    }

    @Override
    public int read(byte[] b, int o, int l) throws IOException {
        logger.info("LoggingInputStream.read(byte[] b, int o, int l)");
        int numBytesRead = is.read(b, o, l);
        if (numBytesRead != -1) {
            for (int i = o; i < numBytesRead; i++) {
                bytes.write(b[i]);
            }
        }
        return numBytesRead;
    }

    @Override
    public int readLine(byte[] b, int o, int l) throws IOException {
        logger.info("LoggingInputStream.readLine(byte[] b, int o, int l)");
        int numBytesRead = is.readLine(b, o, l);
        if (numBytesRead != -1) {
            for (int i = o; i < numBytesRead; i++) {
                bytes.write(b[i]);
            }
        }
        return numBytesRead;
    }

    @Override
    public boolean isFinished() {
        logger.info("isFinished");
        try {
            return is.available() == 0;
        }
        catch (IOException ioe) {
            return false;
        }
    }

    @Override
    public boolean isReady() {
        logger.info("isReady");
        return true;
    }

    @Override
    public void setReadListener(ReadListener listener) {
        throw new RuntimeException("Not implemented");
    }

    public String getPayload() {
        if (bytes.size() > 0) {
            try {
                sb.append(bytes.toString(charsetName));
            } catch (UnsupportedEncodingException e) {
                sb.append("Error occurred when attempting to read request body with charset '").append(charsetName).append("': ");
                sb.append(e.getMessage());
            }
        }

        return sb.toString();
    }
}

到目前为止一切顺利,我得到了这个实际工作。我编写了一个非常简单的 Spring 应用程序,其中包含一个基本的 POST 请求方法,我从 Postman 调用它来测试它。很简单:

public String testPost(String body) {
    return body;
}

我用我的 Postman 测试请求发送了一个正文,我收到了我从通话中发回的正文 - 我的阀门也能够读取正文并记录它。

但是当我想将它与应该使用的实际 Tomcat 应用程序一起使用时,它就不起作用了。该应用程序似乎无法再读取请求的正文。我可以在我的日志中看到,该流的 read() 方法从未被调用过。所以我尝试了另一个应用程序——为此我只是使用了 Tomcat 管理器应用程序并将 Web 应用程序的会话过期设置为另一个值(这也是一个非常简单的 POST 请求)。它也不起作用......包含新超时值的主体永远不会到达 Tomcat 应用程序。但它适用于我自己的 Spring 应用程序。

还记得我上面提到的这句话吗?

is = new LoggingInputStream(request.getRequest().getInputStream(), len, charset);

我将这一行作为原因进行了跟踪 - 只要我在该行中发表评论,无论我是否注释掉该行之后的任何代码,都会出现问题 - 目标应用程序现在无法读取流了。但我只在这里获取请求对象引用并将其分配给另一个变量。我实际上并没有在这里阅读流。

我有点迷茫,如果有任何想法在这里可能有问题,我会很高兴。

哦,目标 tomcat 版本是 8.0.46,而我使用的是 9.0 和 8.5(对所有三个进行了测试,结果相同)。


编辑:我的包装对象上记录的调用

RequestWrapper.<init> ctor RequestWrapper
RequestWrapper$LoggingRequest.<init> ctor LoggingRequest
LoggingInputStream.<init> LoggingInputStream length: 7
RequestWrapper.getContext Method: getContext
RequestWrapper.isAsyncSupported Method: isAsyncSupported
RequestWrapper.isAsync Method: isAsync
RequestWrapper.isAsyncDispatching Method: isAsyncDispatching
RequestWrapper.getRequest getRequest() - POST
RequestWrapper.getRequest getRequest() - POST
RequestWrapper.getUserPrincipal Method: getUserPrincipal
RequestWrapper.getSessionInternal Method: getSessionInternal
RequestWrapper.getWrapper Method: getWrapper
RequestWrapper.getRequestPathMB Method: getRequestPathMB
RequestWrapper.getMethod Method: getMethod
RequestWrapper.getMethod Method: getMethod
RequestWrapper.getUserPrincipal Method: getUserPrincipal
RequestWrapper.getNote Method: getNote
RequestWrapper.getCoyoteRequest Method: getCoyoteRequest
RequestWrapper.getCoyoteRequest Method: getCoyoteRequest
RequestWrapper.setAuthType Method: setAuthType
RequestWrapper.setUserPrincipal Method: setUserPrincipal
RequestWrapper.getSessionInternal Method: getSessionInternal
RequestWrapper.getContext Method: getContext
RequestWrapper.changeSessionId Method: changeSessionId
RequestWrapper.getPrincipal Method: getPrincipal
RequestWrapper.getRequestPathMB Method: getRequestPathMB
RequestWrapper.getWrapper Method: getWrapper
RequestWrapper.isAsyncSupported Method: isAsyncSupported
RequestWrapper.getRequestPathMB Method: getRequestPathMB
RequestWrapper.getDispatcherType Method: getDispatcherType
RequestWrapper.setAttribute Method: setAttribute
RequestWrapper.setAttribute Method: setAttribute
RequestWrapper.getFilterChain Method: getFilterChain
RequestWrapper.getAttribute Method: getAttribute
RequestWrapper.getAttribute Method: getAttribute
RequestWrapper.isAsyncDispatching Method: isAsyncDispatching
RequestWrapper.getRequest getRequest() - POST
RequestWrapper.getAttribute Method: getAttribute
RequestWrapper.isAsync Method: isAsync
RequestWrapper.getRequest getRequest() - POST
RequestWrapper.getBody Method: getBody
RequestWrapper$LoggingRequest.getPayload Method: getPayload
LoggingInputStream.getPayload getPayload size: 0
LoggingInputStream.getPayload getPayload result:

编辑:我在https://github.com/codekoenig/RequestLoggerValve添加了一个示例项目

【问题讨论】:

  • 您可以尝试将new LoggingInputStream(request.getRequest().getInputStream(), len, charset); 替换为request.getRequest() 吗?这也失败了吗?
  • @Jonathan 我用final HttpServletRequest req = request.getRequest(); 替换了is = new LoggingInputStream(request.getRequest().getInputStream(), len, charset); 行,然后目标应用程序(在本例中为Tomcat 管理器)按预期工作。
  • 两个问题:1.你能把LoggingInputStream的read方法的代码贴出来吗?2.难道RequestFacade是以某种方式访问​​请求的输入流?
  • 一件有趣的事情,查看Request 的代码:第一次访问request.getInputStream() 时,请求中设置了一个标志,因此您永远无法在不触发异常的情况下使用getStream() 方法反之亦然阅读器方法。您能否切换到 is-变量的延迟初始化并检查这是否改变了什么?
  • @Jonathan 我添加了 reads 方法,尽管在它不工作的场景中从未调用它们,例如 Tomcat Manager 应用程序。它们被我非常简单的 Spring 测试应用程序调用(并工作)。我还添加了RequestFacade 覆盖的完整代码,输入流永远不会被访问。 GetPayload是最后获取缓存体的方法。

标签: java tomcat tomcat-valve


【解决方案1】:

除了getInputStream,您还应该覆盖getReader。您的目标应用程序似乎更喜欢使用阅读器,这就是您的输入流永远不会被调用的原因。

【讨论】:

  • 谢谢拉米兹 - 我错过了那个。我会尝试,但不幸的是,根据我的日志(我将它们添加到问题中),我看不到 getReader() 被调用。但在这种情况下也永远不会调用getInputStream()。还有什么我可能错过的吗?
  • 只是为了确认一下:我在LoggingRequest(扩展RequestFacade)中实现了getReader(),但不幸的是它没有帮助 - 它没有被调用。我所做的只是:return new BufferedReader(new InputStreamReader(is, charset));
【解决方案2】:

总结我们在 cmets 中的讨论:

只需调用requestgetInputStream()getReader() 方法即可更改该请求的内部状态。首先,它会导致调用反之亦然方法的异常(访问流后无法访问阅读器,反之亦然)。它还将导致受保护的parseParameters() 方法的不同行为。如果曾经调用过任何一种方法,这将中途中止。

可以在这里看到:Code of Request-class

在第 1175 行我们可以看到阻止调用 getReader 的逻辑,一旦调用了 getStream,在第 2752 行你可以看到parseParameters 在中间停止,如果任一方法已被调用。

我想,这就是你烦恼的原因。

【讨论】:

  • 如上所述,这是很好的发现......但我试图分析它并没有找到问题的核心。当问题出现时,我的包装对象上都不会调用 getInputStream()getReader()。最后,包装器应该是解决正文流只能读取一次这一事实的解决方案。当它被读取一次时,我将它缓存到一个缓冲区中,如果需要,我可以从那里再次读取它。我尝试放下一个精简的示例应用程序,因为我认为它更容易看出问题所在。
  • 抱歉,我遇到了一个问题,即阀门不再在最新的 tomcat 版本上工作。花了一些时间来弄清楚并解决这个问题(我现在回到 8.5.20 工作的地方)。这是一个带有重现步骤的示例项目(我尽可能地将其剥离,只留下相关部分):github.com/codekoenig/RequestLoggerValve ...也许如果你有时间可以看看,那就太好了。跨度>
  • 我现在没有时间执行你的项目,但是从代码检查中我会注意到以下几点:我不会混合调用 request 和 loggingrequest RequestWrapper-class 和 requestwrapper-class,在 Requestwrapper 类的构造函数中仍然有一个对 request.getInputstream 的非延迟调用。在 catalinarequest 类中对这些调用进行了相当奇怪的处理(getInputStream() 改变了对象的状态?严重吗?)我怀疑这是给你带来麻烦的原因。我会将初始化推迟到第一次调用 getInputStream
  • 我再次故意删除了延迟初始化,因为我认为这样更容易看到问题。我同意这个问题在某种程度上与 catalinarequest 中的奇怪处理有关,但混合中肯定有其他东西,因为 a)在否定的情况下,getInputStream()(或任何有问题的方法)从未被调用,并且 b)我尽管调用了getInputStream(),但有一个积极的例子。但我无法理解这两种情况之间的区别以及究竟是什么触发了不同的行为。
  • 不过,感谢您对此进行调查,这非常有帮助。我会在 100 个代表过期之前奖励您并浪费掉 ;) ...如果您将来碰巧有时间研究示例项目,我仍然很高兴听到您的发现。
猜你喜欢
  • 1970-01-01
  • 2016-03-13
  • 2011-04-13
  • 2011-09-28
  • 2019-08-05
  • 2023-04-02
  • 2020-12-03
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多