首页 / 资讯中心 / 文章详情

Apache HttpClient响应头宽松解析与连接池装配

Apache HttpClient响应头宽松解析与连接池装配 ★ FEATURED ARTICLE
简介这是一份面向Java后端与网络通信开发者的源码工具包聚焦HttpClient在响应头异常时的处理适合需要排查解析异常、状态码不符、Content-Type校验失败以及重试策略失效等问题的读者参考。压缩包共14个文件以6个class、3个jar和2个java为主另有Eclipse项目配置与首选项文件整体约1.14MB导入后即可查看依赖组织、示例实现与工程结构。已有501人学习下载说明其在HTTP客户端异常处理场景中具备一定参考价值。包内提供可运行的测试示例及HttpClient 4.2.1相关依赖便于读者结合异常捕获、响应拦截器、状态码检查、ResponseHandler和重试策略等思路梳理错误响应头下的完整处理路径也可作为日志记录、问题定位与异常恢复机制的对照材料帮助快速复现并验证不同错误响应场景对排查线上请求失败、响应头解析错误与重试失效等情况有直接借鉴意义。1. 一个错乱的响应头足以拖垮整条调用链对接方的响应头里塞了个没转义的中文名HttpClient 解析到那一行直接抛ProtocolException整个计费请求跟着一起失败——这种事我碰上过三次前两次都是在业务层加 try-catch结果毫无作用。这个工具就是为它写的一份可编译的 Java 源码把响应头解析从「严格模式」换成「宽松模式 记录问题」坏头被记录下来被跳过却不影响请求正常处理。它适合两类人正在对接不规范三方接口的后端以及需要长期容忍畸形报文的网关、聚合类服务。2. 架构拆解解析层、连接层、采集层如何分工很多人第一反应是在client.execute()外面套一层 try-catch或者写个HttpRequestInterceptor。这两条路都会出现问题原因很简单异常发生在receiveResponseHeader()里比execute()返回早得多而拦截器只处理请求根本没有机会碰响应头。真正能插手的位置只有一个——挂在 HTTP 连接上的HttpMessageParser。所以这套东西的分层不是出于对整洁的追求而是被调用链逼出来的。2.1 三层各管什么解析层负责「一行一行读、读不懂也别抛异常」。核心是LenientLineParser把一行拆成 name/value和LenientHttpResponseParser控制读头循环、处理折行、记录问题。这一层不关心连接怎么来的只关心SessionInputBuffer里吐出来的字节。连接层负责「把解析器装进连接」。Apache HttpClient 4.5 的PoolingHttpClientConnectionManager允许传入一个HttpConnectionFactory连接工厂再往下传HttpMessageParserFactory解析器就是从这条路进入DefaultBHttpClientConnection的。同时这一层还要替换内容长度策略因为头解析宽松了、Content-Length却还严格一样会带来问题。采集层负责「留下证据」。HeaderIssueCollector把每一条坏行的原始文本、错误类型、目标 host 收集起来回调给一个IssueSink接口。没有这一层线上再出问题就只能靠猜相当于没有日志。模块关键类换掉的原生实现行解析LenientLineParserBasicLineParser消息解析LenientHttpResponseParserDefaultHttpResponseParser内容长度LaxContentLengthStrategyStrictContentLengthStrategy连接装配LenientConnectionFactoryPoolingHttpClientConnectionManager默认构造2.2 目录结构与文件职责lenient-httpclient/ ├── pom.xml └── src/main/java/com/example/http/lenient/ ├── HeaderIssue.java # 一条坏头的快照原始行、类型、host、时间 ├── HeaderIssueCollector.java # 线程安全收集器内部 ArrayBlockingQueue ├── LenientLineParser.java # 宽松行解析永不抛 ParseException ├── LenientHttpResponseParser.java # 自定义 parseHead控制读头循环 ├── LenientConnectionFactory.java # 组装 ConnectionFactory 与连接池 ├── LenientClients.java # 对外门面build() 返回 CloseableHttpClient ├── HttpClientTuning.java # 超时、池大小、是否强制关闭复用 └── spi/IssueSink.java # 上报出口默认打日志可换 Prometheus └── src/test/java/com/example/http/lenient/ ├── MalformedHeaderTest.java # 裸 Socket 造畸形报文 └── FoldedHeaderTest.java # obs-fold 折行用例LenientClients是唯一对外暴露的入口业务侧只改一行构造代码其余不动。HttpClientTuning把所有可调参数收在一个不可变对象里避免线上调参要改代码再发版。IssueSink做成 SPI是为了让接入方能换成自己的监控通道而不是被迫接受打日志。2.3 一次请求经过的完整链路调用client.execute(get)之后顺序是这样的连接池按 route 取一条ManagedHttpClientConnectionHttpRequestExecutor把请求写出去接着调conn.receiveResponseHeader()这个方法内部就是parser.parse()解析器读到状态行、逐行读头、遇到空行停止返回一个BasicHttpResponse再由ContentLengthStrategy决定实体是定长、chunked 还是读到连接关闭实体消费完连接按ConnectionReuseStrategy决定归还还是关闭。关键在于「逐行读头」这一段被包装在解析器里ProtocolException就是从这里抛出来的。把它换成不抛异常的版本异常的来源就消失了。至于实体读取、连接归还这些后续环节一个都没动。3. 核心模块一解析器怎么写才不会抛异常严格解析器抛异常的地方其实只有两类行拆不出 name/value或者头的数量/长度超限。前者我们改成「拆不出就记录问题再丢弃」后者保留因为那是防护不能一并放掉。3.1 LenientLineParser拆行且永不抛出BasicLineParser.parseHeader()遇到没有冒号的行会直接throw new ParseException(Invalid header)。我们继承它只重写parseHeader把「抛出」换成「清洗 记录 返回 null」。package com.example.http.lenient; import org.apache.http.Header; import org.apache.http.message.BasicHeader; import org.apache.http.message.BasicLineParser; import org.apache.http.util.CharArrayBuffer; /** * 宽松头行解析器任何输入都不抛 ParseException。 * 解析失败的行交给 collector 记录问题返回 null由上层决定丢弃。 */ public class LenientLineParser extends BasicLineParser { private final HeaderIssueCollector collector; public LenientLineParser(HeaderIssueCollector collector) { super(); this.collector collector; } Override public Header parseHeader(CharArrayBuffer buffer) { String raw buffer.toString(); int colon buffer.indexOf(:); // 情况一整行没有冒号例如 Server nginx/1.20.1 if (colon 0) { String name sanitizeName(raw); if (name.isEmpty()) { collector.record(raw, EMPTY_NAME); return null; // 丢弃但不影响后面继续读 } collector.record(raw, NO_COLON); return new BasicHeader(name, ); } String rawName raw.substring(0, colon); String name sanitizeName(rawName); String value sanitizeValue(raw.substring(colon 1)); // 情况二冒号前什么都没有例如 :; path/ if (name.isEmpty()) { collector.record(raw, EMPTY_NAME); return null; } // 头名中间夹了空格、全角符号之类清洗后能用但要留痕 if (!name.equals(rawName.trim())) { collector.record(raw, BAD_NAME); } return new BasicHeader(name, value); } /** 头名只保留 token 字符空格、全角符号、控制字符一律剔除 */ private String sanitizeName(String name) { StringBuilder sb new StringBuilder(name.length()); for (int i 0; i name.length(); i) { char c name.charAt(i); boolean ok (c a c z) || (c A c Z) || (c 0 c 9) || c - || c _ || c .; if (ok) { sb.append(c); } } return sb.toString(); } /** 头值控制字符清掉水平制表符换成空格其余原样保留 */ private String sanitizeValue(String value) { StringBuilder sb new StringBuilder(value.length()); for (int i 0; i value.length(); i) { char c value.charAt(i); if (c \t) { sb.append( ); continue; } if (c 0x20 || c 0x7f) { continue; // 裸 CR / LF / NUL 全部丢弃 } sb.append(c); } return sb.toString().trim(); } }这里有几个刻意为之的点。sanitizeName只留 ASCII 字母、数字和-_.是因为头名本来就该是 RFC 7230 的 token留下别的东西只会让后续按名字取值的行为变得难以预测。sanitizeValue删掉所有0x20以下的字符是因为裸的 CR/LF 最容易引发响应头拆分问题它既是解析失败的原因也可能是别人故意构造的载荷。返回null而不是返回一个空头是让上层能明确区分「这行坏了」和「这个头的值是空字符串」。colon 0那条分支里我们把整行当名字、值留空。这是权衡的结果扔了会丢信息强行报错又回到老路留个只有名字的头至少业务侧getFirstHeader(Server)还能拿到东西。3.2 parseHead逐行读、折行合并、坏行记录行解析器只管单行读头的循环还得自己写。原生DefaultHttpResponseParser的parseHead是私有的改不动所以这里直接继承更底层的AbstractMessageParserHttpResponse只实现parseHead。package com.example.http.lenient; import org.apache.http.*; import org.apache.http.impl.io.AbstractMessageParser; import org.apache.http.message.*; import org.apache.http.util.CharArrayBuffer; import java.io.IOException; public class LenientHttpResponseParser extends AbstractMessageParserHttpResponse { private final LineParser lineParser; private final HeaderIssueCollector collector; private final int maxHeaderCount; public LenientHttpResponseParser(SessionInputBuffer buffer, LineParser lineParser, MessageConstraints constraints, HeaderIssueCollector collector) { super(buffer, constraints); this.lineParser lineParser; this.collector collector; // 没配就兜个默认值防止恶意响应打出几万行头 this.maxHeaderCount constraints.getMaxHeaderCount() 0 ? constraints.getMaxHeaderCount() : 200; } Override protected HttpResponse parseHead(SessionInputBuffer in) throws IOException, HttpException { // 1. 状态行解析不了就按 200 HTTP/1.1 兜底实体长度交给内容策略 CharArrayBuffer line new CharArrayBuffer(128); if (in.readLine(line) -1) { throw new ConnectionClosedException(状态行之前连接就断了); } BasicHttpResponse response new BasicHttpResponse(parseStatusLineLoose(line)); // 2. 一行一个头空行代表头结束 HeaderGroup group new HeaderGroup(); Header last null; int count 0; while (true) { line new CharArrayBuffer(128); if (in.readLine(line) -1) { throw new ConnectionClosedException(响应头读到一半连接断了); } if (line.length() 0) { break; // 空行头结束 } char first line.charAt(0); if ((first || first \t) last ! null) { last fold(last, line); // obs-fold 折行续接上一个头 group.updateHeader(last); continue; } Header header lineParser.parseHeader(line); // 内部保证不抛 if (header null) { continue; // 坏行已被 collector 记录 } group.addHeader(header); last header; if (count maxHeaderCount) { collector.record(line.toString(), TOO_MANY_HEADERS); break; } } response.setHeaders(group.getAllHeaders()); return response; } private StatusLine parseStatusLineLoose(CharArrayBuffer buf) { try { return lineParser.parseStatusLine(buf, new ParserCursor(0, buf.length())); } catch (ParseException ex) { collector.record(buf.toString(), BAD_STATUS_LINE); return new BasicStatusLine(HttpVersion.HTTP_1_1, 200, OK); } } private Header fold(Header previous, CharArrayBuffer continuation) { String value previous.getValue() continuation.toString().trim(); return new BasicHeader(previous.getName(), value); } }parseHead的返回值是一个BasicHttpResponse而不是DefaultHttpResponseParser那种带headerGroup字段的内部对象这样就不依赖父类私有状态升级 HttpClient 小版本时不容易出问题。折行那段first || first \t是血泪经验不少老网关还在用 obs-fold 把长头拆成多行严格解析器会把它当成一个没有冒号的新行直接抛异常宽松解析器如果只是简单丢弃又会把Set-Cookie这类值截断。做法是把续行拼回上一个头中间补一个空格。maxHeaderCount建议一定要配。开放宽松解析等于接受「对方可以发任何东西过来」如果不设上限一个几千行的响应头就足以把 CPU 和堆内存耗尽。这个边界不能也宽松掉。3.3 参数如何调整MessageConstraints是唯一需要注意的参数对象由ConnectionConfig带进连接再传给解析器工厂的create(buffer, constraints)。常用的两个值maxHeaderCount设 200maxLineLength设 16384。前者防头数量爆炸后者防单行超长——有些服务端会把整个 JSON 塞进一个头里行长度设小了反而误伤。maxLineLength一旦触发抛的是MessageConstraintException这个异常我们故意没有捕获。理由很直接超长行不是「格式不规范」是「不该被容忍」该失败就失败让它变成一次明确的错误比在连接上读出几十兆垃圾好得多。4. 核心模块二把宽松解析器装进连接池解析器写完了如果没有真正挂到连接上它仍然不会生效。这一段是整套东西最容易出错的地方因为 HttpClient 的装配链有三层嵌套连接池 →本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站