☰
Java手写HTTP代理服务器:从原理到实战的完整指南
2026/10/26 12:38:36 网站建设 项目流程

网络编程这块内容,很多人学完Socket和NIO之后,对"代理服务器"这个概念的理解就停在"配个地址和端口"的层面,真让他自己写一个或者讲清楚数据怎么流转的,往往就卡壳了。我当初也是这样,觉得代理不就是个中间人转发一下嘛,能有多复杂。后来在一个内网数据采集的项目里,需要让一批客户端统一走一个出口去访问外部服务,还要做请求日志和简单的访问控制,这才逼着我把Java里代理服务器的实现从头到尾捋了一遍。这篇内容就是那次折腾的完整沉淀,从代理的本质讲起,到用Java手写一个能跑通的HTTP代理,再到客户端怎么接入、踩过哪些坑,都会摊开说。适合已经会写基本Socket通信、想搞明白代理服务器内部机制的朋友,也适合需要在项目里做统一出口、请求审计这类需求的开发者参考。哪怕你只是想搞清楚System.setProperty("http.proxyHost", ...)背后到底发生了什么,看完也能有个清晰的图景。

1. 代理服务器到底在替谁干活

在动手写代码之前,得先把"代理"这个词的含义掰扯清楚。很多人第一次接触代理是在浏览器设置里填个IP和端口,然后就觉得代理是个很神秘的东西。其实剥开外壳看,代理服务器就是一个同时扮演服务端和客户端两个角色的普通程序。对发起请求的一方来说,它是服务端,负责接收请求;对真正提供服务的目标服务器来说,它又是客户端,负责把请求转发出去。这个"两头当"的身份,是理解一切代理行为的基础。

1.1 正向代理与反向代理的分野

代理按部署位置和服务对象,通常分成正向和反向两大类,这个区分不是学术上的文字游戏,而是直接决定了你代码里数据往哪个方向流。

正向代理站在客户端这一侧。客户端明确知道自己要访问的目标是谁,但它不直接连目标,而是把请求交给正向代理,由代理代为访问。典型场景就是内网机器通过一台有外网访问权限的机器出去,或者客户端想隐藏自己的真实来源。我那个内网采集项目用的就是正向代理,所有采集客户端配置代理地址,统一从代理机出去。

反向代理站在服务端这一侧。客户端以为自己访问的就是最终服务,实际上请求先到了反向代理,由它根据规则转发给后端的真实服务器。客户端对后端的存在一无所知。常见的负载均衡、网关基本都是反向代理的形态。

两者的数据流向可以用一张表对照清楚:

维度正向代理反向代理
部署位置靠近客户端靠近服务端
客户端是否知道目标知道,主动配置不知道,以为代理就是目标
主要目的统一出口、访问控制、隐藏来源负载均衡、安全隔离、缓存
谁受益客户端一侧服务端一侧

写代码时这个区分很关键:正向代理需要从请求行里解析出完整的目标地址(比如GET http://example.com/path HTTP/1.1),而反向代理通常是按配置或域名把请求映射到固定的后端,不需要客户端告诉你目标是谁。

1.2 代理在协议栈里的位置

代理工作在应用层,这一点必须明确。它不像路由器那样处理IP包,也不像交换机那样处理帧,它处理的是应用层协议的报文。对HTTP代理来说,它读的是HTTP请求行、请求头、请求体,理解的是HTTP语义。这意味着代理可以做的事情远比网络层转发要多:它能改请求头、能根据URL做过滤、能缓存响应、能记录完整的访问日志。

但也正因为工作在应用层,代理必须完整解析协议才能正确转发。这就带来一个绕不开的复杂度:HTTP有请求头和请求体,两者的边界靠Content-Length或者Transfer-Encoding: chunked来界定,代理如果解析错了边界,转发出去的数据就是错位的。这是手写代理时第一个容易翻车的地方,后面会专门讲。

1.3 为什么用Java来做这件事

有人会问,代理服务器这种偏底层的东西,用C或者Go写不是更合适吗?这话有道理,但Java做代理有它自己的优势。首先是Socket API足够成熟,java.net.Socket和ServerSocket封装得恰到好处,既屏蔽了系统调用的细节,又保留了足够的控制力。其次是线程模型直观,一个连接一个线程的阻塞式写法,逻辑清晰,调试方便,对学习和中小规模场景完全够用。再者,Java的流式IO和NIO两套体系都在,想简单就用阻塞IO,想扛高并发就上NIO,切换成本不高。

我那个项目最终选的就是阻塞式IO加线程池的方案,因为采集客户端的并发量在几百这个量级,阻塞模型完全撑得住,代码可读性还高,后期维护的人一看就懂。技术选型从来不是越先进越好,而是匹配场景最好。

2. 手写一个HTTP正向代理的核心骨架

理论说再多不如跑一遍代码。这一节我把一个能实际工作的HTTP正向代理拆成几个关键部分来讲,每一部分都说清楚为什么这么写。需要说明的是,下面这套实现是基于常见实践补全的完整方案,覆盖了请求解析、连接建立、双向转发这几个核心环节。

2.1 服务端监听与连接接入

代理服务器的入口和任何TCP服务一样,靠ServerSocket监听端口,然后循环accept。区别在于,每接受一个客户端连接,代理要做的不是处理业务,而是准备一个"中转站"。

public class SimpleProxyServer { private final int port; private final ExecutorService pool = Executors.newFixedThreadPool(50); public SimpleProxyServer(int port) { this.port = port; } public void start() throws IOException { try (ServerSocket serverSocket = new ServerSocket(port)) { System.out.println("代理监听端口: " + port); while (true) { Socket client = serverSocket.accept(); pool.submit(() -> handleClient(client)); } } } }

这里用固定大小线程池而不是每个连接新建线程,原因是代理的连接是短时高频的,无限制建线程在并发上来后会迅速耗尽资源。50这个数字不是拍脑袋,是按"单连接处理耗时约几十毫秒、目标QPS几百"估算出来的,实际部署时应该根据压测结果调整。线程池的拒绝策略也要想清楚,默认的AbortPolicy会抛异常,代理场景下更合理的是让新连接快速失败而不是无限排队。

handleClient是整个代理的心脏,它要完成三件事:读客户端请求、连目标服务器、双向搬运数据。下面逐段拆。

2.2 解析请求行拿到目标地址

正向代理和普通服务端最大的不同,是它必须从请求里读出"你要访问谁"。HTTP请求的第一行格式是METHOD URI VERSION,对正向代理来说,这个URI通常是完整URL:

GET http://example.com/index.html HTTP/1.1

所以第一步是把请求行读出来,按空格切分,取出中间的完整URL,再从中解析出目标主机和端口。

private void handleClient(Socket client) { try (Socket clientSocket = client) { BufferedReader reader = new BufferedReader( new InputStreamReader(clientSocket.getInputStream(), StandardCharsets.ISO_8859_1)); String requestLine = reader.readLine(); if (requestLine == null || requestLine.isEmpty()) { return; } String[] parts = requestLine.split(" "); String method = parts[0]; String url = parts[1]; URL target = new URL(url); String host = target.getHost(); int targetPort = target.getPort() == -1 ? 80 : target.getPort(); // 后续转发逻辑... } catch (IOException e) { // 记录日志,连接异常直接关闭 } }

这里有个细节值得强调:读请求行用的字符集是ISO_8859_1而不是UTF-8。原因是HTTP请求行和请求头按规范是ASCII兼容的,用ISO_8859_1能保证每个字节原样映射到一个字符,不会因为编码转换破坏数据。如果这里用了UTF-8,遇到非ASCII字节可能会被替换成乱码,转发出去就错了。这个坑我在早期版本踩过,表现为某些带特殊字符的URL转发后目标服务器返回400。

2.3 请求头的完整转发与Host处理

拿到目标地址后,不能只转发请求行,请求头也得原样带过去,否则目标服务器可能因为缺少Host、User-Agent等头而拒绝或返回错误内容。但这里有个微妙之处:正向代理收到的请求行里URL是完整的,转发给目标服务器时,请求行里的URI应该改成只有路径部分,因为目标服务器不需要也不期望看到完整URL。

StringBuilder headerBlock = new StringBuilder(); String line; while ((line = reader.readLine()) != null && !line.isEmpty()) { headerBlock.append(line).append("\r\n"); } // 重建请求行:完整URL换成path String path = target.getFile().isEmpty() ? "/" : target.getFile(); String forwardLine = method + " " + path + " HTTP/1.1\r\n";

Host头这里要特别留意。现代HTTP/1.1要求请求必须带Host头,而客户端发给代理的请求里Host头通常已经是目标主机的,直接透传即可。但如果客户端发的是HTTP/1.0或者某些不规范客户端漏了Host,代理最好补一个,否则目标服务器可能返回400。补的方式是从解析出的target.getHost()构造。

请求头读完后那个空行是分隔头和体的标志,转发时这个空行必须保留,否则目标服务器会一直等请求体,导致请求挂起。这个空行看似不起眼,却是协议解析里最容易漏掉的一环。

2.4 建立到目标服务器的连接并双向搬运

请求头和请求行准备好之后,代理以客户端身份连目标服务器,把重建后的请求写过去,然后把目标服务器的响应原样搬回给客户端。

try (Socket targetSocket = new Socket(host, targetPort)) { OutputStream targetOut = targetSocket.getOutputStream(); targetOut.write(forwardLine.getBytes(StandardCharsets.ISO_8859_1)); targetOut.write(headerBlock.toString().getBytes(StandardCharsets.ISO_8859_1)); targetOut.write("\r\n".getBytes(StandardCharsets.ISO_8859_1)); targetOut.flush(); // 请求体转发(如果有) transferRequestBody(reader, clientSocket.getInputStream(), targetOut); // 响应回传 InputStream targetIn = targetSocket.getInputStream(); OutputStream clientOut = clientSocket.getOutputStream(); byte[] buffer = new byte[8192]; int len; while ((len = targetIn.read(buffer)) != -1) { clientOut.write(buffer, 0, len); clientOut.flush(); } }

响应回传这段用的是字节流直接搬运,而不是按行读。原因是响应体可能是二进制(图片、压缩内容),按字符读会破坏数据。缓冲区大小8192是个经验值,太小系统调用频繁,太大内存占用高,8K在多数场景下平衡得不错。

请求体的转发要单独处理,因为请求体是否存在、有多长,取决于请求头里的Content-Length或Transfer-Encoding。如果不管三七二十一直接读,遇到没有请求体的GET就会阻塞在read上。正确做法是先看头里有没有Content-Length,有就按长度精确读那么多字节转发;如果是chunked编码,就按chunk格式解析转发。这块逻辑稍复杂,下一节专门展开。

3. 请求体边界:手写代理最容易翻车的地方

前面反复提到请求体和响应体的边界问题,这不是危言耸听。我见过不少自己写的代理,处理GET请求一切正常,一遇到POST带表单或者上传文件就出问题,要么请求体丢了,要么多转发了不该发的数据导致下一个请求错乱。根子就在边界判断上。

3.1 Content-Length 的精确读取

当请求头里有Content-Length: N时,意味着请求体恰好是N个字节,代理必须一个字节不多一个字节不少地读N个字节转发过去。多读了会把下一个请求的开头吃掉,少读了目标服务器会一直等剩余数据。

private void transferRequestBody(BufferedReader reader, InputStream rawIn, OutputStream targetOut, int contentLength) throws IOException { if (contentLength <= 0) { return; } byte[] body = new byte[contentLength]; int offset = 0; while (offset < contentLength) { int read = rawIn.read(body, offset, contentLength - offset); if (read == -1) { break; } offset += read; } targetOut.write(body, 0, offset); targetOut.flush(); }

这里有个陷阱:前面用BufferedReader读请求头时,它内部有缓冲区,可能已经把请求体的一部分字节读进了自己的缓冲。这时候如果直接从底层InputStream读请求体,就会丢掉那部分数据。解决办法要么是读头时不用带缓冲的Reader而自己按字节读到\r\n\r\n为止,要么在读体前先把Reader缓冲里剩余的数据吐出来。我推荐前者,虽然代码多一点,但边界清晰,不会出现"数据在缓冲区里找不回来"的诡异问题。这个坑非常隐蔽,症状是偶尔丢几个字节的请求体,排查起来极其痛苦。

3.2 chunked 编码的流式转发

HTTP/1.1引入了Transfer-Encoding: chunked,用于在不知道总长度的情况下边生成边发送。chunked格式是:每个数据块前面有一个十六进制的长度行,块内容跟在后面,最后以一个长度为0的块结束。

代理处理chunked时,最稳妥的方式是按chunk结构解析并原样转发,而不是试图拼成完整body。因为chunked的意义就在于流式,拼完整body会破坏这个特性,还可能因为body过大撑爆内存。

private void transferChunked(InputStream in, OutputStream out) throws IOException { while (true) { String sizeLine = readLine(in); out.write((sizeLine + "\r\n").getBytes(StandardCharsets.ISO_8859_1)); int size = Integer.parseInt(sizeLine.trim(), 16); if (size == 0) { // 读掉结尾的空行 String trailer = readLine(in); out.write("\r\n".getBytes(StandardCharsets.ISO_8859_1)); break; } byte[] chunk = new byte[size]; int offset = 0; while (offset < size) { int r = in.read(chunk, offset, size - offset); if (r == -1) break; offset += r; } out.write(chunk, 0, offset); out.write("\r\n".getBytes(StandardCharsets.ISO_8859_1)); readLine(in); // 吃掉块尾的CRLF } out.flush(); }

注意chunked的结尾除了0长度块,还可能有trailer头,虽然多数场景下没有,但严谨的实现应该处理。另外readLine要自己实现按字节读到\r\n,不能用BufferedReader,原因同上——避免缓冲吞掉后续数据。

3.3 响应体边界的对称处理

请求体怎么处理,响应体就怎么处理,逻辑是对称的。代理从目标服务器读响应时,同样要先解析响应头,看Content-Length或Transfer-Encoding,再决定怎么读响应体。很多手写代理只顾着转发请求,响应这边直接无脑read到-1,结果遇到Connection: keep-alive的长连接就卡死了——因为连接不关闭,read永远等不到-1。

正确的做法是:如果响应有Content-Length,读够那么多字节就停;如果是chunked,按chunk解析到0块为止;如果两者都没有且是HTTP/1.0风格,才依赖连接关闭来判断结束。这个判断逻辑必须写全,否则代理在keep-alive场景下会表现为"请求发出去了但客户端一直转圈"。

4. 客户端如何接入代理并验证效果

代理写好了,得让客户端用起来才算闭环。Java里客户端走代理有几种方式,各有适用场景,选错了会出现"明明配了代理却不生效"的困惑。

4.1 系统属性方式的全局代理

最直接的方式是通过系统属性指定代理:

System.setProperty("http.proxyHost", "127.0.0.1"); System.setProperty("http.proxyPort", "8888"); System.setProperty("https.proxyHost", "127.0.0.1"); System.setProperty("https.proxyPort", "8888");

这套属性对HttpURLConnection和很多基于它的库生效。但要注意两个坑:一是必须在创建连接之前设置,连接建立后再改属性不生效;二是https走代理需要代理支持CONNECT方法,而前面写的简单代理只处理了普通HTTP请求,遇到HTTPS的CONNECT请求会解析失败。这是很多人配了代理发现HTTP能通、HTTPS不通的根本原因。

4.2 用 Proxy 对象做精细化控制

系统属性是全局的,如果只想让某个请求走代理,用java.net.Proxy更合适:

Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 8888)); URL url = new URL("http://example.com/api"); HttpURLConnection conn = (HttpURLConnection) url.openConnection(proxy); conn.setRequestMethod("GET"); int code = conn.getResponseCode();

这种方式的好处是作用域明确,不影响其他连接。实测下来,Proxy.Type.HTTP对普通HTTP请求工作正常,但如果目标地址是https,底层会尝试用CONNECT隧道,同样要求代理支持CONNECT。

4.3 验证代理是否真的生效

配完代理别急着高兴,得验证请求确实经过了代理。最可靠的办法是在代理端打日志,记录每个进来的请求行和目标地址。如果代理日志里有对应记录,说明流量确实走了代理;如果日志空空但请求成功了,那多半是客户端根本没走代理,直连了目标。

另一个验证角度是看目标服务器收到的来源IP。如果代理机和客户端不在同一台机器,目标服务器看到的应该是代理机的IP。这个在联调时特别有用,能一眼看出代理链路是否打通。

提示:验证阶段建议把代理和目标服务都放在本机不同端口上,用127.0.0.1做端到端测试,排除网络因素干扰,确认逻辑正确后再部署到真实环境。

5. 踩坑实录:那些让代理"看起来能用实际不能用"的问题

代理这类中间件的特点是,简单场景下跑通很容易,但一到真实流量就暴露各种边界问题。这一节把我实际遇到过的几个典型故障和排查过程完整还原,这些经验在文档里基本找不到。

5.1 请求头大小写与重复头导致的转发异常

HTTP头字段名是大小写不敏感的,但有些目标服务器实现得不够严谨,对content-length和Content-Length区别对待。代理如果对头做了归一化处理(比如全部转小写),可能让某些服务器认不出来。我最初的版本为了"规范",把所有头名转成了小写,结果对接一个老系统时请求体死活传不过去,排查了半天才发现是Content-Length被转成小写后对方没识别。

结论是:代理转发头时应该原样透传,不做任何大小写转换。代理的职责是搬运,不是规范化,任何自作主张的修改都可能引入兼容性问题。同理,重复的头(比如多个Set-Cookie)必须逐个保留,不能合并,合并会破坏语义。

5.2 连接未及时关闭导致的端口耗尽

早期版本里,我在handleClient的异常分支忘了关闭目标连接,结果跑了一段时间后代理机报"Too many open files"。原因是异常发生时targetSocket没进try-with-resources,泄漏了。代理这种长跑服务,任何一处资源泄漏都会随时间累积成故障。

修复方式是把所有Socket都纳入try-with-resources,确保无论正常还是异常路径都能关闭。另外连接目标服务器时要设超时:

Socket targetSocket = new Socket(); targetSocket.connect(new InetSocketAddress(host, targetPort), 5000); targetSocket.setSoTimeout(30000);

连接超时防止目标不可达时线程被无限挂起,读超时防止目标服务器响应到一半卡住导致代理线程永久阻塞。这两个超时值是经验值,连接5秒、读30秒,实际按业务调整。没有超时的代理在目标服务抖动时会迅速耗尽线程池,表现为代理整体不可用。

5.3 HTTPS 请求被当成普通HTTP解析的报错

这个坑前面提过,这里展开说排查过程。现象是客户端配了代理后,访问http正常,访问https报错或者超时。抓包看代理收到的第一行是:

CONNECT example.com:443 HTTP/1.1

而不是预期的GET http://...。原因是HTTPS客户端先要求代理建立一条到目标443端口的隧道,隧道建立后双方在隧道里做TLS握手,代理只负责透明转发字节,不解析内容。我最初的代理代码用new URL(parts[1])去解析example.com:443,直接抛异常。

修复思路是识别CONNECT方法,走隧道模式:连目标443端口,回客户端HTTP/1.1 200 Connection Established,然后纯字节双向转发,不再解析任何HTTP内容。

if ("CONNECT".equalsIgnoreCase(method)) { String[] hostPort = parts[1].split(":"); try (Socket tunnel = new Socket(hostPort[0], Integer.parseInt(hostPort[1]))) { clientSocket.getOutputStream().write( "HTTP/1.1 200 Connection Established\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1)); // 两个方向各起一个线程做字节搬运 startBidirectionalCopy(clientSocket, tunnel); } }

隧道模式下代理变成了"哑管道",看不到加密内容,这也是HTTPS代理的天然边界——想审计HTTPS内容需要另一套机制,不在本文讨论范围。理解这个边界很重要,能避免对代理能力产生不切实际的期待。

5.4 大文件传输时的内存与阻塞问题

转发大文件(比如几十兆的下载)时,如果实现里不小心把整个body读进内存再写出去,很容易OOM。正确做法始终是流式搬运,用固定大小缓冲区边读边写,内存占用与文件大小无关。另外大文件传输耗时长,要确保读超时设置得足够大,否则传到一半超时断开,客户端收到的是截断的文件。

还有一个隐蔽问题:某些客户端在收到完整响应前就关闭了连接(比如用户取消下载),代理往客户端写数据时会抛IOException。这个异常要捕获并优雅关闭目标连接,不能让异常冒泡导致线程池里的线程异常终止。代理作为中间人,必须对两端的异常都有兜底。

6. 从能跑到好用:代理的工程化补强

一个能转发请求的代理和一个能在生产环境稳定运行的代理,中间隔着不少工程化的工作。这一节聊几个让代理从"玩具"变"工具"的关键补强点,都是实际部署后才会意识到重要性的东西。

6.1 访问日志该记什么

代理天然是流量的汇聚点,日志价值极高。但日志记什么有讲究,记少了排查问题时抓瞎,记多了磁盘迅速爆满。我的经验是每条请求至少记:时间戳、客户端IP、请求方法、目标host、目标path、响应状态码、耗时、请求体大小、响应体大小。这几个字段能覆盖绝大多数排查场景。

long start = System.currentTimeMillis(); // ... 转发逻辑 ... long cost = System.currentTimeMillis() - start; log.info("{} {} {}://{}{} status={} cost={}ms reqSize={} respSize={}", clientIp, method, scheme, host, path, status, cost, reqSize, respSize);

耗时字段特别有用,能快速定位是代理本身慢还是目标服务慢。如果代理耗时远大于目标响应时间,说明瓶颈在代理的转发逻辑或线程池排队。请求体和响应体大小则能发现异常流量,比如某个客户端突然上传超大文件。

6.2 简单的访问控制怎么做

代理作为统一出口,天然适合做访问控制。最基础的是按目标host做黑白名单:维护一个允许或禁止访问的域名集合,请求进来先查表,命中禁止规则直接返回403,不建立到目标的连接。

private boolean isAllowed(String host) { if (blockedHosts.contains(host)) { return false; } if (!allowedHosts.isEmpty() && !allowedHosts.contains(host)) { return false; } return true; }

这个逻辑虽简单,但要注意host的匹配方式。精确匹配最安全,但维护成本高;后缀匹配(比如禁止所有.internal结尾的域名)灵活但容易误伤。实际用的时候建议精确匹配为主,需要通配时明确规则边界,避免出现"以为禁了实际没禁"的安全漏洞。另外访问控制要在建立目标连接之前做,否则连接都建了再拒绝,既浪费资源又可能已经产生了副作用。

6.3 线程模型的选择与压测验证

前面用的是固定线程池加阻塞IO,这个方案在几百并发下表现稳定,但并发再往上走就会遇到线程数瓶颈。每个连接占一个线程,线程的栈空间和上下文切换成本会随并发线性增长。如果预期并发上千甚至更高,就该考虑NIO方案,用少量线程配合Selector处理大量连接。

不过我的建议是不要过早优化。先用阻塞模型把功能跑通、把边界问题解决干净,再用压测工具测出实际瓶颈。很多时候业务量根本到不了需要NIO的程度,盲目上NIO反而让代码复杂度陡增、bug 难查。压测时重点看三个指标:吞吐量(每秒处理请求数)、平均延迟、错误率。如果吞吐上不去但CPU没跑满,多半是线程池大小或IO阻塞的问题;如果CPU跑满,才考虑换更高效的IO模型。

6.4 代理链与多级转发的注意事项

有些场景下代理需要再经过另一个代理出去,形成代理链。实现上就是在代理连目标时,不是直连,而是连下一级代理并把请求交给它。这要求代理支持配置上游代理,并且在转发时正确处理请求行——如果上游也是正向代理,请求行里的URI要保持完整URL;如果上游是普通服务器,则用path。

代理链的坑在于每一级都可能修改请求头,比如Via头会逐级追加,X-Forwarded-For会累积来源IP。调试代理链问题时,看这些头能还原请求经过了哪些节点。另外代理链的每一级都要设超时,否则某一级卡住会导致整条链路挂起,而且很难定位是哪一级的问题。

7. 一些容易被忽略的协议细节

写到这儿,核心的转发逻辑和工程化都覆盖了,但还有几个协议层面的细节,不处理不一定马上出问题,但处理了能让代理更健壮、更符合规范。这些点往往是有经验的人和新手的分水岭。

7.1 Connection 头的语义

HTTP/1.1默认是持久连接,Connection: close表示请求完就关。代理转发时对Connection头的处理要小心:如果客户端发的是Connection: close,代理转发给目标时应该保留这个语义,让目标在响应后关闭连接;同时代理自己也要在响应转发完后关闭与客户端的连接。如果代理无视这个头,强行保持连接,客户端可能因为等不到连接关闭而误判响应结束。

反过来,如果客户端要keep-alive,代理也要维持到目标的连接复用,否则每个请求都新建连接,性能会差很多。连接复用是代理性能的关键,实现上可以用一个简单的连接池按目标host:port缓存连接,但要注意连接可能被目标单方面关闭,取用前要检测有效性。

7.2 重定向响应的透传

目标服务器返回3xx重定向时,代理应该原样透传状态码和Location头,让客户端自己去跟随。代理不要自作主张去跟随重定向,因为那会改变客户端看到的行为,还可能因为跟随到不允许的地址而绕过访问控制。这个原则和前面说的"代理只搬运不加工"是一致的。

不过有个细节:Location头里的地址可能是相对路径,客户端解析时需要知道基准URL。正向代理场景下客户端本来就知道自己请求的完整URL,所以相对路径能正确解析;但如果代理改写了什么,就可能破坏这个基准。所以再次强调,能不改写就不改写。

7.3 错误响应的构造

代理自身出错时(比如目标不可达、解析失败),需要给客户端返回一个合理的错误响应,而不是直接断开连接让客户端一脸懵。标准做法是返回一个带状态码的HTTP响应:

HTTP/1.1 502 Bad Gateway\r\n Content-Type: text/plain\r\n Content-Length: ...\r\n \r\n 代理无法连接到目标服务器

状态码的选择有讲究:目标连不上用502,请求格式错误用400,访问被拒绝用403,代理内部错误用500。给客户端一个明确的错误码,比直接断连友好得多,也方便客户端做重试或降级逻辑。这个细节看似小,但在实际运维中能省很多沟通成本。

7.4 编码与字符集的统一

前面提过请求行和头用ISO_8859_1,这里补充响应侧的处理。响应头同样按ISO_8859_1处理,但响应体的编码由Content-Type里的charset决定,代理不应该去解码响应体,直接按字节转发即可。代理一旦试图解码再编码,就可能因为编码不一致破坏数据,尤其是响应体是二进制或者用了代理不认识的编码时。

这个原则可以总结成一句话:代理对头和行按字节处理,对体按字节搬运,全程不做字符集转换。守住这条线,编码相关的坑能避开一大半。

8. 我个人在实际操作中的几点体会

折腾完这一整套,最大的感受是:代理服务器的难点不在"转发"这个动作本身,而在边界处理和异常兜底。转发逻辑几十行就能写出来,但要让它在各种畸形请求、网络抖动、目标异常的情况下都不崩,需要处理的细节是转发逻辑的好几倍。我最初那版代理在实验室环境跑了一周没问题,一上真实流量当天就暴露了三个边界bug,全是请求体边界和连接泄漏相关的。

另一个体会是关于"够用就好"的判断。代理这类基础设施,很容易陷入追求完美的陷阱,想支持所有HTTP特性、想扛超高并发。但实际项目里,明确自己的场景边界更重要:如果只是内网几百个客户端统一出口,阻塞IO加线程池完全够,把日志、超时、访问控制做扎实,比强行上NIO有价值得多。技术方案的价值在于匹配需求,不在于堆砌先进特性。

最后分享一个调试小技巧:写代理时,先在代理和目标之间加一个"回显"环节,把代理实际转发出去的原始字节打印出来(十六进制形式),和目标期望的格式逐字节对比。很多转发错误在字符层面看不出来,但十六进制一对比,多一个\r少一个\n立刻现形。这个笨办法帮我定位过好几次"看起来对实际错"的诡异问题,比盯着代码猜高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询