前言
我將通過這篇文章詳述一下如何用Swift搭建一個HTTP代理服務器。本文將使用Hummingbird[1]作為服務端的基本HTTP框架,以及使用AsyncHTTPClient[2]作為Swift的HTTP客戶端來請求目標服務。
什么是代理服務器
代理服務器是一個搭載在客戶端和另一個服務端(后面我們成為目標服務端)的中間服務器,它從客戶端轉發消息到目標服務端,并且從目標服務端獲取響應信息傳回給客戶端。在轉發消息之前,它可以以某種方式處理這些消息,同樣,它也可以處理返回的響應。
讓我們試著構建一個
在本文中,我們將構建一個只將HTTP數據包轉發到目標服務的代理服務器。您可以在這里找到本文的示例代碼。
創建項目
我們使用Hummingbird模板項目[3] 目前最低版本適配 Swift5.5 作為我們服務的初始模板。讀者可以選擇clone這個存儲庫,或者直接點擊Github項目主頁上use this template按鈕來創建我們自己的存儲庫。用這個模板項目創建一個服務端并且啟動它,可以使用一些控制臺選項和文件來配置我們的應用。詳見here[4]
增加 AsyncHTTPClient
我們將把AsyncHTTPClient作為依賴加入Package.swift以便我們后面來使用
- dependencies: [
- ...
- .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.6.0"),
- ],
然后在目標依賴也添加一下
- targets: [
- .executableTarget(name: "App",
- dependencies: [
- ...
- .product(name: "AsyncHTTPClient", package: "async-http-client"),
- ],
我們將把HTTPClient作為HBApplicatipn的擴展。這樣方便我們管理HTTPClient的生命周期以及在HTTPClient刪除前調用syncShutdown方法。
- extension HBApplication {
- var httpClient: HTTPClient {
- get { self.extensions.get(\.httpClient) }
- set { self.extensions.set(\.httpClient, value: newValue) { httpClient in
- try httpClient.syncShutdown()
- }}
- }
- }
當HBApplication關閉時候會調用set里面的閉包。這意味著我們當我們引用了HBApplication,即使不使用HTTPClient,我們也有權限去調用它
增加 middleware[中間件]
我們將把我們的代理服務器作為中間件。中間件將獲取一個請求,然后將它發送到目標服務器并且從目標服務器獲取響應信息。下面使我們初始版本的中間件,它需要HTTPClient和目標服務器的URL兩個參數。
- struct HBProxyServerMiddleware: HBMiddleware {
- let httpClient: HTTPClient
- let target: String
-
func apply(to request: HBRequest, next: HBResponder) -> EventLoopFuture
{ - return httpClient.execute(
- request: request,
- eventLoop: .delegateAndChannel(on: request.eventLoop),
- logger: request.logger
- )
- }
- }
現在我們有了HTTPClient和HBProxyServerMiddleware中間件,我們將它們加入配置文件HBApplication.configure。然后設置我們代理服務地址為http://httpbin.org
- func configure(_ args: AppArguments) throws {
- self.httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.eventLoopGroup))
- self.middleware.add(HBProxyServerMiddleware(httpClient: self.httpClient, target: "http://httpbin.org"))
- }
轉換類型
當我們完成上面的步驟,構建會顯示失敗。因為我們還需要轉換Hummingbird和AsyncHTTPClient之間的請求和響應類型。同時我們需要合并目標服務的URL到請求里。
請求轉換
為了將Hummingbird HBRequest轉化為AsyncHTTPClient HTTPClient.Request,
原因: 我們首先需要整理可能仍在加載的HBRequest的body信息,轉換過程是異步的
解決方案:所以它需要返回一個包含后面轉換結果的EventLoopFuture,讓我們將轉換函數放到HBRequest里面
- extension HBRequest {
-
func ahcRequest(host: String) -> EventLoopFuture
{ - // consume request body and then construct AHC Request once we have the
- // result. The URL for the request is the target server plus the URI from
- // the `HBRequest`.
- return self.body.consumeBody(on: self.eventLoop).flatMapThrowing { buffer in
- return try HTTPClient.Request(
- url: host + self.uri.description,
- method: self.method,
- headers: self.headers,
- body: buffer.map { .byteBuffer($0) }
- )
- }
- }
- }
響應信息裝換
從HTTPClient.Response到HBResponse的轉換相當簡單
- extension HTTPClient.Response {
- var hbResponse: HBResponse {
- return .init(
- status: self.status,
- headers: self.headers,
- body: self.body.map { HBResponseBody.byteBuffer($0) } ?? .empty
- )
- }
- }
我們現在將這兩個轉換步驟加入HBProxyServerMiddleware的apply函數中。同時加入一些日志打印信息
-
func apply(to request: HBRequest, next: HBResponder) -> EventLoopFuture
{ - // log request
- request.logger.info("Forwarding \(request.uri.path)")
- // convert to HTTPClient.Request, execute, convert to HBResponse
- return request.ahcRequest(host: target).flatMap { ahcRequest in
- httpClient.execute(
- request: ahcRequest,
- eventLoop: .delegateAndChannel(on: request.eventLoop),
- logger: request.logger
- )
- }.map { response in
- return response.hbResponse
- }
- }
現在應該可以正常編譯了。中間件將整理HBRequest的請求體,將它轉化為HTTPRequest.Request,然后使用HTTPClient將請求轉發給目標服務器。獲取的響應信息會轉化為HBResponse返回給應用。
運行應用,打開網頁打開localhost:8080。我們應該能看到我們之前設置代理的httpbin.org網頁信息
Streaming[流]
上面的設置不是非常理想。它會等待請求完全加載,然后才將請求轉發給目標服務端。同理響應轉發也是需要等待響應完全加載后才會轉發。這降低了消息發送的效率,同樣會導致請求占用大量內存或者響應信息很大。
我們可以通過流式傳輸請求和響應負載來改進這一點。一旦我們有了它的頭部,就開始將請求發送到目標服務,并在接收到主體部分時對其進行流式處理。類似地,一旦我們有了它的頭,在另一個方向開始發送響應。消除對完整請求或響應的等待將提高代理服務器的性能。
如果客戶端和代理之間的通信以及代理和目標服務之間的通信以不同的速度運行,我們仍然會遇到內存問題。如果我們接收數據的速度比處理數據的速度快,數據就會開始備份。為了避免這種情況發生,我們需要能夠施加背壓以停止讀取額外的數據,直到我們處理了足夠多的內存中的數據。有了這個,我們可以將代理使用的內存量保持在最低限度。
流式請求
流式傳輸請求負載是一個相當簡單的過程。實際上,它簡化了構造 HTTPClient.Request 的過程因為我們不需要等待請求完全加載。我們如何構造 HTTPClient.Request 主體將基于完整的 HBRequest 是否已經在內存中。如果我們返回流請求,則會自動應用背壓,因為 Hummingbird 服務器框架會為我們執行此操作。
- func ahcRequest(host: String, eventLoop: EventLoop) throws -> HTTPClient.Request {
- let body: HTTPClient.Body?
- switch self.body {
- case .byteBuffer(let buffer):
- body = buffer.map { .byteBuffer($0) }
- case .stream(let stream):
- body = .stream { writer in
- // as we consume buffers from `HBRequest` we write them to
- // the `HTTPClient.Request`.
- return stream.consumeAll(on: eventLoop) { byteBuffer in
- writer.write(.byteBuffer(byteBuffer))
- }
- }
- }
- return try HTTPClient.Request(
- url: host + self.uri.description,
- method: self.method,
- headers: self.headers,
- body: body
- )
- }
流式響應
流式響應需要一個遵循 HTTPClientResponseDelegate 的class. 這將在 HTTPClient 響應可用時立即從響應中接收數據。響應正文是 ByteBuffers 格式. 我們可以將這些 ByteBuffers 提供給 HBByteBufferStreamer. 我們回報的 HBResponse 是由這些流構造,而不是靜態的 ByteBuffer。
如果我們將請求流與響應流代碼結合起來,我們的最終的 apply 函數應該是這樣的
-
func apply(to request: HBRequest, next: HBResponder) -> EventLoopFuture
{ - do {
- request.logger.info("Forwarding \(request.uri.path)")
- // create request
- let ahcRequest = try request.ahcRequest(host: target, eventLoop: request.eventLoop)
- // create response body streamer. maxSize is the maximum size of object it can process
- // maxStreamingBufferSize is the maximum size of data the streamer is allowed to have
- // in memory at any one time
- let streamer = HBByteBufferStreamer(eventLoop: request.eventLoop, maxSize: 2048*1024, maxStreamingBufferSize: 128*1024)
- // HTTPClientResponseDelegate for streaming bytebuffers from AsyncHTTPClient
- let delegate = StreamingResponseDelegate(on: request.eventLoop, streamer: streamer)
- // execute request
- _ = httpClient.execute(
- request: ahcRequest,
- delegate: delegate,
- eventLoop: .delegateAndChannel(on: request.eventLoop),
- logger: request.logger
- )
- // when delegate receives head then signal completion
- return delegate.responsePromise.futureResult
- } catch {
- return request.failure(error)
- }
- }
你會注意到在上面的代碼中我們不等待httpClient.execute. 這是因為如果我們這樣做了,該函數將在繼續之前等待整個響應主體在內存中。我們希望立即處理響應,因此我們向委托添加了一個promise: 一旦我們收到頭部信息,就會通過保存頭部詳情和流到HBResponse來實現。EventLoopFuture這個 promise的是我們從apply函數傳回的。
我沒有在StreamingResponseDelegate這里包含代碼,但您可以在完整的示例代碼中[5]找到它。
示例代碼添加
該示例代碼[6]可能在上面的基礎上做了部分修改。
- 默認綁定地址端口是 8081 而不是 8080。大多數 Hummingbird 示例在 8080 上運行,因此要在這些示例旁邊使用代理,它需要綁定到不同的端口。
- 我添加了一個位置選項,它允許我們只轉發來自特定基本 URL 的請求
- 我為目標和位置添加了命令行選項,因此可以在不重建應用程序的情況下更改這些選項
- 我刪除了 host 標題或請求,以便可以用正確的值填寫
- 如果提供了 content-length 標頭,則在轉換流請求時,我將其傳遞給 HTTPClient 流送器,以確保 content-length 為目標服務器的請求正確設置標頭。
備擇方案
我們可以使用 HummingbirdCore 代替 Hummingbird 作為代理服務器。這將提供一些額外的性能,因為它會刪除額外的代碼層,但會犧牲靈活性。添加任何額外的路由或中間件需要做更多的工作。我有只使用HummingbirdCore代理服務器的示例代碼在這里[7]。
當然,另一種選擇是使用 Vapor。我想在 Vapor 中的實現看起來與上面描述的非常相似,應該不會太難。不過我會把它留給別人。
參考資料
[1]Hummingbird: https://github.com/hummingbird-project/hummingbird
[2]AsyncHTTPClient: https://github.com/swift-server/async-http-client
[3]Hummingbird模板項目: https://github.com/hummingbird-project/template
[4]here: https://opticalaberration.com/2021/12/hummingbird-template.html
[5]示例代碼中: https://github.com/hummingbird-project/hummingbird-examples/blob/main/proxy-server/Sources/App/Middleware/StreamingResponseDelegate.swift
[6]示例代碼: https://github.com/hummingbird-project/hummingbird-examples/tree/main/proxy-server
[7]在這里: https://github.com/hummingbird-project/hummingbird-examples/tree/main/proxy-server-core
原文鏈接:https://mp.weixin.qq.com/s/PtSVTLlnmUDMDnJB4URnHQ