其一:一个反向代理的设计问题

最近在写一个反向代理的时候,有类似以下的逻辑:

// req: &Request 表示用户发来的请求
for upstream in upstreams {
  let upstream_req = req.clone().into_parts();
  // modify uri of upstream_req and make request
  // if success then break, else try next upstream
}

乍一看,这应该是个「反向代理」都应该具备的功能:遍历所有的 upstream 尝试请求,如果成功就 break,否则就继续下一个 upstream。

不过这个代码编译不过,报错 req 没办法被 Clone,这是因为 Request 里面有一个 Body,而 Body 是没有实现 Clone

我的第一反应仍是「解决这个编译问题」,也就是让这个 req 被成功的 Clone 下来,因为逻辑看上去是合理的,就是拿到一个请求之后,尝试所有的 upstream,如果有一个成功就 break。

多次尝试无果,之后又在 Rust 社区里看到一个类似的需求:https://users.rust-lang.org/t/how-to-copy-http-request/43690。最后的实现逻辑,大概是在内存中分配一块空间来存储 Body 的数据,到这里我才开始思考为什么 Rust 不允许 Body 被 Clone。

对于传统的反向代理,例如 nginx,用户如果上传大的文件,nginx 是先读取全部数据到他自己的内存,然后再向上游服务器发起请求(这就是万恶的 client_max_body_size 的来源,因为如果不做限制,nginx 服务器的内存很容易被打满)。而对于现代的 HTTP Server 来说,「流式」处理上下游显然是更好的选择。Body 不能被 Clone 是有道理的,就像 std::fs::File 不能被 Clone 一样,因为这些都是「一种资源」。更上层的理解,如果要 Clone Body,那么就意味着要在内存中存储多份数据,这显然是不合理的,除非我们自己强行这么干,想社区提到的那个方案一样。

Clone 这个 req 在应用逻辑存在问题,考虑当用户的请求携带一个较大的 HTTP Body,代理服务器流式地将用户数据上传到上游服务器中,Clone 这个 Body 会面临以下这些问题:

  1. 首先这个流本身是没办法 Clone 的,因为如果要 Clone 则需要拿到全部数据,这就不是「流式处理」了。
  2. 如果考虑不 Clone 流本身,而是把 Body 当作一个引用来 Clone 也是不行的,因为服务器无法多次读同一个流。不能说如果一个 Upstream 上传失败了,重新读这个流再上传到其他 Upstream,因为这个流的另一端是用户的浏览器,我们不能要求代理服务器在一个 Upstream 上传失败后,用户浏览器又重新上传数据。

如何改进

首先从场景上思考,在大文件上传的时候,上游服务器几乎都是单个文件服务器,这里似乎根本就不存在多个 Upstream 的问题。因此可以从 Content-Length 的大小来判断是否需要 Clone 这个 Body,如果 Content-Length 较小,那么可以像 nginx 一样获先读取 Body 的数据到内存,然后再将请求发送给上游服务器。如果一个请求的 Body 很大,则仅尝试一个 Upstream 也是合理的。

同时存在多个 Upstream,并且也希望「流式」处理的场景也是存在的,在上传第一个 Upstream 时,可以考虑用临时文件同时保存 Body 的数据,这样如果第一个 Upstream 上传失败,我们可以不响应 50X,而是立即开始上传第二个 Upstream,先读取缓存文件的数据,然后再读取剩余的 Body 中的数据,当然这个涉及到更多的细节处理了。