preface

In this article I will detail how to set up an HTTP proxy server with Swift. This article will use Hummingbird as the basic HTTP framework on the server side and AsyncHTTPClient as the HTTP client for Swift to request target services.

What is a proxy server

A proxy server is an intermediate server that rides between a client and another server (the target server later), forwards messages from the client to the target server, and retrieves response information from the target server back to the client. It can process the messages in some way before forwarding them, and it can also process the returned responses.

Let’s try to construct one

In this article, we will build a proxy server that forwards ONLY HTTP packets to the target service. You can find sample code for this article here.

Create a project

We use the current lowest version of Hummingbird template project to adapt Swift5.5 as the initial template for our service. Readers can select the Clone repository or create our own by clicking on the Use This Template button on the Github project home page. Using this template project to create a server and start it, we can configure our application with some console options and files. See here

Increase AsyncHTTPClient

We’ll add AsyncHTTPClient as a dependency to package. swift for later use

dependencies: [
    .
    .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.6.0")],Copy the code

Then add it to the target dependency as well

targets: [
    .executableTarget(name: "App",
        dependencies: [
            .
            .product(name: "AsyncHTTPClient", package: "async-http-client")],Copy the code

We will use HTTPClient as an extension of HBApplicatipn. This makes it easy to manage the HTTPClient lifecycle and to call the syncShutdown method before the HTTPClient is deleted.

extension HBApplication {
    var httpClient: HTTPClient {
        get { self.extensions.get(\.httpClient) }
        set { self.extensions.set(\.httpClient, value: newValue) { httpClient in
            try httpClient.syncShutdown()
        }}
    }
}
Copy the code

The closure inside the set is called when HBApplication is closed. This means that when we reference HBApplication, we have permission to call it even if we don’t use HTTPClient

Increase middleware[middleware]

We will use our proxy server as middleware. The middleware takes a request, sends it to the target server and gets the response information from the target server. Let’s take our initial version of middleware, which takes HTTPClient and the URL of the target server.

struct HBProxyServerMiddleware: HBMiddleware {
    let httpClient: HTTPClient
    let target: String

    func apply(to request: HBRequest.next: HBResponder) -> EventLoopFuture<HBResponse> {
        return httpClient.execute(
            request: request,
            eventLoop: .delegateAndChannel(on: request.eventLoop),
            logger: request.logger
        )
    }
}
Copy the code

Now that we have HTTPClient and HBProxyServerMiddleware middleware, we add them to the configuration file HBApplication.configure. Then set our proxy service address to 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"))}Copy the code

Conversion type

When we complete the above steps, the build will show failure. Because we also need to convert the request and response types between Hummingbird and AsyncHTTPClient. We also need to merge the URL of the target service into the request.

Request to convert

To convert Hummingbird HBRequest to AsyncHTTPClient httpClient.request,

Reason: We first need to sort out the body information of the HBRequest that might still be loading, and the conversion process is asynchronous

Solution: So it needs to return an EventLoopFuture with the result of the subsequent transformation, so let’s put the transformation function in HBRequest

extension HBRequest {
    func ahcRequest(host: String) -> EventLoopFuture<HTTPClient.Request> {
        // 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)})}}}Copy the code

Reload the response message

The conversion from HttpClient.response to HBResponse is fairly simple

extension HTTPClient.Response {
    var hbResponse: HBResponse {
        return .init(
            status: self.status,
            headers: self.headers,
            body: self.body.map { HBResponseBody.byteBuffer($0)}?? .empty
        )
    }
}
Copy the code

We now add these two transformation steps to the Apply function of HBProxyServerMiddleware. Add some log information at the same time

func apply(to request: HBRequest.next: HBResponder) -> EventLoopFuture<HBResponse> {
    // 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
    }
}
Copy the code

It should compile normally now. The middleware will collate the Request body of HBRequest, convert it into Httprequest.Request, and then forward the Request to the target server using HTTPClient. The obtained response information is converted into an HBResponse and returned to the application.

Run the application, open the web page and go to localhost:8080. We should see the httpbin.org page where we set up the proxy earlier

Streaming (flow)

The Settings above are not ideal. It waits for the request to be fully loaded before forwarding it to the target server. Similarly, response forwarding waits for the response to be fully loaded before forwarding. This reduces the efficiency of sending messages, and can also result in requests that take up a lot of memory or responses that are large.

We can improve on this by streaming the request and response loads. Once we have its header, we start sending requests to the target service and streaming the body part as it is received. Similarly, once we have its head, start sending the response in the other direction. Eliminating waits for complete requests or responses improves proxy server performance.

If the communication between the client and the agent and the communication between the agent and the target service runs at different speeds, we still have memory problems. If we can receive data faster than we can process it, the data will start backing up. To avoid this, we need to be able to apply back pressure to stop reading additional data until we have processed enough data in memory. With this, we can keep the amount of memory used by the agent to a minimum.

Streaming request

Streaming the request load is a fairly simple process. In fact, it simplifies the process of constructing httpClient.Request because we don’t have to wait for the Request to load completely. How we construct the httpClient. Request body will be based on whether the complete HBRequest is already in memory. If we return the flow request, the back pressure is automatically applied because the Hummingbird server framework does this for us.

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
    )
}
Copy the code

Current response

The current response to a follow HTTPClientResponseDelegate class. This will receive data from the HTTPClient response as soon as it becomes available. The response body is in ByteBuffers format. We can supply these ByteBuffers to the HBByteBufferStreamer. The HBResponse we return is constructed from these streams, not static ByteBuffers.

If we combined the request flow with the response flow code, our final apply function would look like this

func apply(to request: HBRequest.next: HBResponder) -> EventLoopFuture<HBResponse> {
    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)
    }
}
Copy the code

You’ll notice that in the above code we don’t wait for httpClient.execute. This is because if we do, the function will wait for the entire response body to be in memory before continuing. We want to process the response immediately, so we add a promise to the delegate: Once we receive the header information, it will be done by saving the header details and streaming to HBResponse. EventLoopFuture is the promise that we return from apply.

I’m not StreamingResponseDelegate contain code here, but you can find it in the full sample code.

Example code addition

The sample code may be partially modified from the above.

  1. The default binding address port is 8081 instead of 8080. Most of the Hummingbird examples run on the 8080, so to use the agent alongside these examples, it needs to be bound to a different port.
  2. I added a location option that allows us to forward only requests from certain base urls
  3. I added command-line options for target and location, so you can change these options without rebuilding the application
  4. I deletedhostTitle or request so that it can be filled in with the correct value
  5. If providedcontent-lengthHeader, which I pass to when converting the flow requestHTTPClientStreamer to ensurecontent-lengthSet the header correctly for the request from the target server.

Alternative solutions

We can use HummingbirdCore instead of Hummingbird as a proxy server. This will provide some additional performance because it removes additional layers of code, but at the expense of flexibility. Adding any additional routing or middleware requires more work. I have sample code here that uses only the HummingbirdCore proxy server.

Of course, the other option is to use Vapor. I think the implementation in Vapor looks very similar to the one described above, so it shouldn’t be too difficult. But I’ll leave it to someone else.

Optical Aberration

About us

Swift community is a public welfare organization jointly maintained by Swift enthusiasts. We mainly operate wechat public accounts in China. We will share technical content based on Swift practice, SwiftUl and Swift foundation, and also collect excellent learning materials.

Welcome to pay attention to the public number :Swift community, backstage click into the group, you can enter our community’s various exchanges and discussion groups. I hope our Swift community is another common place to belong in cyberspace.

Special thanks to every editor in Swift Community editorial department for their hard work, providing quality content to Swift community and contributing to the development of Swift language, in no particular order: Zhang Anyu @Microsoft, Dai Ming @Kuaishou, Zhanfei @ESP, Ni Yao @Trip.com, Du Xinyao @Sina, Wei Xian @gwell, Zhang Hao @Iflytek