利用 Network.framework 在 iOS 實作簡易 HTTP 伺服器

利用 Network.framework 在 iOS 實作簡易 HTTP 伺服器
利用 Network.framework 在 iOS 實作簡易 HTTP 伺服器

有時候,我們會需要在自己的 app 裡架一個小型的 HTTP 伺服器。可能的理由有很多,像是要做 API 測試或者是要顯示網頁內容等等。有些 app 會內建一個網頁介面,讓使用者可以從別的裝置來存取內容,像是檔案或者影片之類的;有些開發類 app 則是需要一個內建伺服器來做測試環境。而因為瀏覽器(包括 WKWebView)載入本地的網頁檔、跟透過 HTTP 載入網頁的機制不盡相同,所以有時候就算是單純要顯示一個網頁檔,也會碰到必須要架伺服器的時候。

在 Apple 的官方框架裡,有強大的 URLSession 系列元件,可以讓我們輕鬆地執行網路客戶端的工作,像是發出請求與處理回應等等。而透過 URLRequesthttpMethodhttpBodyallHTTPHeaderFields 等屬性,它也特別支援了 HTTP 這個協定,讓我們不需要再去手動建構 HTTP 訊息。

可惜的是,URLSession 只有支援客戶端,沒有支援伺服器端。在 iOS 12 以前,我們只能用最傳統的 BSD Sockets,或者稍微高階一點的 CFSocket,來實作各種伺服器。雖然在大部分的情況裡都夠用,但這兩個 API 的語法都相當低階,入門的門檻也稍高一些。

Network.framework 進場

還好,Apple 在 iOS 12 的時候開放了原本只供內部使用的一個網路框架,名字就叫 Network,是設計來取代 BSD Sockets 的底層網路框架。因為它的名字過於廣泛,所以也被叫做 Network.framework。

Network.framework 雖然底層,但 API 非常的現代,熟悉 Swift 的人應該很容易就可以上手。它是以物件與閉包為主要架構,大幅增加了 API 的易讀性,降低了入門的門檻與維護的成本。

就建構伺服器而言,我們主要只需要用到它兩個類型的物件:

  • NWListener:用來監聽傳入的連線。伺服器只能被動等待客戶端開啟連線,所以必須要有一個機制來對傳入的連線做反應。BSD Sockets 是用 loop 來做監聽,CFSocket 是把傳入連線包裝成一個 Notification 物件,而 Network.framework 則是用這個 NWListener 物件來負責監聽。
  • NWConnection:用來傳送與接收資料、管理連線的生命週期。

不過在進入到實作之前,先讓我們看一點簡單的網路概念。

網際網路協定套組 (TCP/IP) 與 HTTP 是什麼

人們所說的「網路」,其實意涵非常的模糊。它有的時候指的是網際網路 (Internet),有的時候是全球資訊網 (World Wide Web),有的時候則是更廣泛的 network 的意思。這三個東西雖然聽起來很像,但是彼此之間的差異其實非常清楚。

簡單來說,最早的時候,只有小型的區域網路 (local area network, LAN) 存在,而且各個區域網路內部的架構可能截然不同,所以也無法彼此溝通。後來,從美國的 ARPANET 開始,通過一系列標準化的通訊協定將各個獨立的區域網路串連起來,形成一個巨大的廣域網路 (wide area network, WAN)。這個廣域網路可以說是網路之間的網路 (network of networks),後來就被稱作網際網路(internetwork,後簡稱為 internet)。而使網際網路從不可能變成可能的,就是被稱作網際網路協定套組(Internet protocol suite,或稱 TCP/IP)的一系列協定。

網際網路協定套組定義了一個網際網路運作的模型,將所有協定分成四個層級,從底層開始是:

  1. 連結層 (Link Layer):關於如何建構區域網路的協定。
  2. 網際網路層 (Internet Layer):以網際網路協定 (Internet Protocol, IP) 為主,描述如何透過 IP 位址與路由等技術,在不同的區域網路之間傳遞資料。
  3. 傳輸層 (Transport Layer):定義網際網路上端到端之間的資料交換通道,比如說 TCP 與 UDP。
  4. 應用層 (Application Layer):提供使用者服務的各種協定,像是傳電子郵件 (SMTP)、傳檔案 (FTP) 或瀏覽網站 (HTTP) 等等。

值得注意的是,在網際網路發展成型的 1970 年代,應用層中的許多協定還不存在。SMTP(簡單郵件傳輸協定,Simple Mail Transfer Protocol)要到 1980 年代才出現,HTTP(超文本傳輸協定,Hypertext Transfer Protocol)則要到 1990 年代才出現。而所謂的全球資訊網,指的其實就是由 HTTP 所建構起來,以網頁與多媒體為主的一個全球性資訊系統。

簡單來說,網際網路是一個把區域網路結合起來的網路,而全球資訊網則是網際網路的其中一個應用 —— 一個殺手級應用。

前面提到的 URLSession 操作的層級是在應用層,直接支援 HTTP 與 FTP 等協定。換句話說,就是它有內建 HTTP 訊息的建構器 (URLRequest) 與解析器 (URLResponse)。但是 Network.framework 的層級是在更低的傳輸層與網際網路層,並沒有原生支援 HTTP,所以我們需要另外想辦法來建構與解析 HTTP 訊息,而且要手動管理傳輸層的 TCP 連線。

實作

接著,就讓我們一邊用 Network.framework 來實作一個 HTTP 伺服器,一邊認識 HTTP 的運作概念。

監聽連線

在開始傳送、接收 HTTP 訊息之前,我們需要先在傳輸層跟對方建立起 TCP 連線。TCP 連線的建立方式是三向交握,要由客戶端向伺服器端發起連線,伺服器端回應確認連線,客戶端再回應確認連線,這樣才算建立起 TCP 連線。所以在我們的實作裡,我們的第一步驟就是去監聽從客戶端傳入的連線。

首先先建立一個 listener 物件,並指定 socket 的連接埠與傳輸協定:

import Network

// 在這個範例中,我們將連接埠指定為 3000,傳輸協定是 TCP。
let listener = try! NWListener(using: .tcp, on: 3000)

接著建立一個 startServer() 函數,用來涵蓋主要的伺服器運作邏輯:

func startServer() {

    // 開始監聽傳入連線
}

再來定義一個 startListeningToIncomingConnections(newConnectionHandler:) 方法,來使 listener 開始監聽連線:

extension NWListener {

    // 本範例中的 helper 方法都會被標註為 fileprivate。
    fileprivate func startListeningToIncomingConnections(newConnectionHandler: @escaping (NWConnection) -> Void) {

        // 用來應對狀態變更,在此僅為除錯用。
        self.stateUpdateHandler = { state in

            switch state {
            case .setup:
                print("Listener: setup")

            case .ready:
                print("Listener: receiving new connection")

            case .waiting(let error):
                print("Listener: waiting (\(error))")

            case .failed(let error):
                print("Listener: failed (\(error))")

            case .cancelled:
                print("Listener: cancelled")

            @unknown default:
                fatalError()
            }
        }

        // 設定接到新連線時要做的事,在此只是把函數輸入的閉包丟進去而已。
        self.newConnectionHandler = { connection in

            print("Listener: new connection received")
            newConnectionHandler(connection)
        }

        // 啟動 listener。
        start(queue: .global())
    }
}

一旦設定了 newConnectionHandler 並呼叫 start(queue:) 之後,所有新傳入的連線就會以 NWConnection 物件的形式傳遞給 newConnectionHandler。而因為我們已經把這個過程用一個方法包裝起來,所以在 startServer() 裡面只需要這樣寫就可以:

func startServer() {

    listener.startListeningToIncomingConnections() { connection in

        // 應對傳入的 connection
    }
}

確認連線

接到傳入連線之後,伺服器端要回覆確認連線,進行三向交握。我們一樣把這整個過程用一個 helper 方法包裝起來:

extension NWConnection {

    fileprivate func confirm(completionHandler: @escaping () -> Void) {
        print("Connection: confirming")

        self.stateUpdateHandler = { state in
            switch state {
            case .setup:
                print("Connection: setup")

            case .preparing:
                print("Connection: preparing")

            case .waiting(let error):
                print("Connection: waiting (\(error))")

            case .ready:

                // 連線完成準備(客戶端也回覆確認連線,三向交握成功)。
                print("Connection: confirmed")
                completionHandler()

            case .failed(let error):
                print("Connection: failed (\(error))")

            case .cancelled:
                print("Connection: closed")

            @unknown default:
                fatalError()
            }
        }

        // 向客戶端回覆確認連線。
        start(queue: .global())
    }
}

三向交握在伺服器確認連線(透過呼叫 NWConnectionstart(queue:))之後,還要再等客戶端也確認連線(當 state 變成 ready 時)才算是完成。

接著就可以在 startServer() 裡面加上 connection.confirm()

func startServer() {

    listener.startListeningToIncomingConnections() { connection in

        connection.confirm() {

            // 連線已經建立,可以開始交換 HTTP 訊息了
        }
    }
}

接收 HTTP 請求訊息

一旦三向交握成功,跟客戶端之間的連線建立起來之後,就可以開始交換 HTTP 訊息了。HTTP 訊息的交換也是由客戶端先發出請求訊息 (request) 給伺服器端,伺服器端接著回傳回應訊息 (response) 給客戶端,客戶端再以回應訊息所包含的資料來顯示內容或下載檔案等等。

雖然 HTTP 訊息的交換跟 TCP 連線建立的三向交握一樣,都是由伺服器端監聽客戶端所發起的連線/訊息,但 NWConnection 接收客戶端請求的方式跟 NWListener 很不一樣,它不是監聽,而是單次接收的概念。簡單來說,NWListenernewConnectionHandler 閉包可能會被多次呼叫,但是 NWConnection 是用 receive(minimumIncompleteLength:maximumLength:completion:) 等方法來安排單次接收,所以它的 completion 閉包只會被呼叫一次而已。

我們用 receiveRequest(requestHandler:) 來稍微包裝這個步驟:

extension NWConnection {

    fileprivate func receiveRequest(requestHandler: @escaping (HTTPMessage) -> Void) {

        print("Connection: receiving request")

        // 安排接收一個請求訊息。
        receive(minimumIncompleteLength: 1, maximumLength: 1024*8) { content, context, isComplete, error in

            error.map { print("Connection: receiving request error: \($0)") }

            // 將收到的內容轉換成 HTTPMessage 物件並交給 requestHandler。
            if let content = content, let request = HTTPMessage.request(data: content) {

                print("Connection: request received")
                requestHandler(request)
            }

        }
    }
}

如此就可以把 startServer() 擴寫成這樣:

func startServer() {

    listener.startListeningToIncomingConnections() { connection in

        connection.confirm() {

            connection.receiveRequest() { request in

                // 回傳與 request 相對應的 response
            }
        }
    }
}

解析 HTTP 請求訊息

你可能會注意到我們用了一個新的型別 HTTPMessage 來代表 HTTP 請求,而你可能也從它的名稱猜到它也可以是 HTTP 回應。它的功能就是要補足 Network.framework 在應用層的不足,我們可以用它來解析 HTTP 請求,也可以用它來建構 HTTP 回應。最棒的是,它就隱身在官方的框架裡面,因為它的真身其實就是 CFNetwork 裡的 CFHTTPMessage

使用 CFHTTPMessage 的話,我們就不用煩惱要怎麼從傳入的原始資料去解析出 HTTP 請求的各項屬性與欄位了,只需要稍微對原本的 API 做一點包裝就可以:

import Foundation
import CFNetwork

typealias HTTPMessage = CFHTTPMessage

extension HTTPMessage {

    var requestURL: URL? {
        return CFHTTPMessageCopyRequestURL(self).map { $0.takeRetainedValue() as URL }
    }

    var requestMethod: String? {
        return CFHTTPMessageCopyRequestMethod(self).map { $0.takeRetainedValue() as String }
    }

    var data: Data? {
        return CFHTTPMessageCopySerializedMessage(self).map { $0.takeRetainedValue() as Data }
    }

    func setBody(data: Data) {
        CFHTTPMessageSetBody(self, data as CFData)
    }

    func setValue(_ value: String?, forHeaderField field: String) {
        CFHTTPMessageSetHeaderFieldValue(self, field as CFString, value as CFString?)
    }

    func value(forHeaderField field: String) -> String? {
        return CFHTTPMessageCopyHeaderFieldValue(self, field as CFString).map { $0.takeRetainedValue() as String }
    }

    // 從請求訊息的原始資料解析出 HTTPMessage 的工廠方法。
    static func request(data: Data) -> HTTPMessage? {

        let request =  CFHTTPMessageCreateEmpty(kCFAllocatorDefault, true).takeRetainedValue()
        let bytes = data.withUnsafeBytes { $0.bindMemory(to: UInt8.self).baseAddress! }

        return CFHTTPMessageAppendBytes(request, bytes, data.count) ? request : nil
    }

    // 用我們提供的資訊建構出回應訊息的 HTTPMessage 的工廠方法。
    static func response(statusCode: Int, statusDescription: String?, htmlString: String) -> HTTPMessage {

        let response = CFHTTPMessageCreateResponse(kCFAllocatorDefault, statusCode, statusDescription as CFString?, kCFHTTPVersion1_1).takeRetainedValue()

        // 提供一些基本標頭欄位。
        let formatter = DateFormatter()
        formatter.dateFormat = "EEE',' dd' 'MMM' 'yyyy HH':'mm':'ss zzz"
        formatter.locale = Locale(identifier: "en_US_POSIX")
        let dateString = formatter.string(from: Date())
        response.setValue(dateString, forHeaderField: "Date")
        response.setValue("My Swift HTTP Server", forHeaderField: "Server")
        response.setValue("close", forHeaderField: "Connection")
        response.setValue("text/html", forHeaderField: "Content-Type")
        response.setValue("\(htmlString.count)", forHeaderField: "Content-Length")

        // 插入回應內容(這裡是一段 HTML 字串)。
        let body = htmlString.data(using: .utf8)!
        response.setBody(data: body)

        return response
    }
}

有了這一層包裝,我們就不用在其它地方碰到這種 Core Foundation 風格的 API 了。

建構 HTTP 回應訊息

收到 HTTP 請求之後,伺服器就要依請求的資訊(URL、HTTP 方法等等)來取得相對應的內容,並打包到 HTTP 回應裡面回傳給客戶端。

在這個範例裡,我們不會真的到檔案系統裡面去找檔案,而是直接餵 HTML 字串給 HTTPMessage.response(statusCode:statusDescription:htmlString:) 這個簡單的工廠方法,並用另一個工廠方法包裝起來:

private func makeResponse(request: HTTPMessage) -> HTTPMessage {

    // 只支援對根路徑的 GET 讀取。
    if request.requestMethod == "GET", request.requestURL?.path == "/" {

        // 回應裡包含一個 Hello world 的 HTML 字串。
        return HTTPMessage.response(statusCode: 200, statusDescription: "OK", htmlString: "<h1>Hello world!</h1>")

    } else {

        // 所有其它的請求都會收到 400 Bad Request 回應。
        return HTTPMessage.response(statusCode: 400, statusDescription: "Bad Request", htmlString: "<h1>Bad Request</h1>")
    }
}

然後再把它加到 startServer() 裡面:

func startServer() {

    listener.startListeningToIncomingConnections() { connection in

        connection.confirm() {

            connection.receiveRequest() { request in

                let response = makeResponse(request: request)

                // 回傳 response
            }
        }
    }
}

回傳回應訊息

建構出回應訊息 response 之後,就可以把它傳給客戶端了。在 NWConnection 裡,傳送訊息是用 send(content:contentContext:isComplete:completion:) 來執行。我們一樣用一個方法來包裝這道程序:

extension NWConnection {

    fileprivate func sendResponse(_ response: HTTPMessage, completion: @escaping () -> Void) {

        print("Connection: sending response")

        send(content: response.data, contentContext: .defaultMessage, isComplete: true, completion: .contentProcessed({ error in

            print("Connection: response sent")

            error.map { print("Response: sending error: \($0)") }

            completion()
        }))
    }
}

startServer() 裡:

func startServer() {

    listener.startListeningToIncomingConnections() { connection in

        connection.confirm() {

            connection.receiveRequest() { request in

                let response = makeResponse(request: request)

                connection.sendResponse(response) {

                    // 關閉連線
                }
            }
        }
    }
}

關閉連線

在一次 TCP 連線之中,可能會有許多次的 HTTP 訊息交換,但是這裡為求簡單,還是先使每次連線都只有一次訊息交換,交換完就直接關閉連線,使整個交談流程更線性。

首先,我們已經在 HTTP 回應加入了 Connection: close 的標頭,表示在傳送回應之後伺服器就會關閉這次連線。再來就是真的去關閉連線了。TCP 連線的關閉比起建立還要複雜,因為伺服器與客戶端都可以發起關閉,而且是一個四向交握的程序。還好,這部份已經由 NWConnection 處理好了,我們只需要呼叫它的 cancel() 即可。

extension NWConnection {

    fileprivate func close() {

        print("Connection: closing")

        cancel()
    }
}

startServer()

func startServer() {

    listener.startListeningToIncomingConnections() { connection in

        connection.confirm() {

            connection.receiveRequest() { request in

                let response = makeResponse(request: request)

                connection.sendResponse(response) {

                    connection.close()
                }
            }
        }
    }
}

防止 Memory Leak

到這邊,startServer() 已經大致上完成,但是有一個小問題在:memory leak。connection 這個物件在一層一層的閉包裡面不停地被 retain,導致它沒辦法被 release 掉,就算連線關閉了也還是會一直佔用記憶體。

要解決這個問題很簡單,其實 connection 的 ownership 並不是在我們手上,在呼叫 cancel() 關閉連線之前我們就算沒有 retain 住它,它也不會消滅。運用這個特性,我們就可以宣告一個 unowned 常數來避免增加 reference count:

func startServer() {

    listener.startListeningToIncomingConnections() { connection in

        // 用這行來避免所有循環引用所造成的 memory leak。
        unowned let connection = connection

        connection.confirm() {

            connection.receiveRequest() { request in

                let response = makeResponse(request: request)

                connection.sendResponse(response) {

                    connection.close()
                }
            }
        }
    }
}

運行與測試

現在我們完成了 startServer() 的實作,只要找個地方呼叫它就可以了,比如說 viewDidLoad()

 class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        startServer()
    }
}

編譯並在模擬器上執行 app 之後,等 Xcode 的 console 印出 Listener: receiving new connection,伺服器就準備好接受連線了。這時我們就可以打開 macOS 的 Safari,前往 http://localhost:3000。如果沒有差錯的話,應該會看到「Hello world!」字樣成功載入,並在 Xcode console 裡看到整個從連線建立、訊息交換到連線關閉的過程了!

參考資料

作者
Hsu Li-Heng
iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。