有時候,我們會需要在自己的 app 裡架一個小型的 HTTP 伺服器。可能的理由有很多,像是要做 API 測試或者是要顯示網頁內容等等。有些 app 會內建一個網頁介面,讓使用者可以從別的裝置來存取內容,像是檔案或者影片之類的;有些開發類 app 則是需要一個內建伺服器來做測試環境。而因為瀏覽器(包括 WKWebView
)載入本地的網頁檔、跟透過 HTTP 載入網頁的機制不盡相同,所以有時候就算是單純要顯示一個網頁檔,也會碰到必須要架伺服器的時候。
在 Apple 的官方框架裡,有強大的 URLSession
系列元件,可以讓我們輕鬆地執行網路客戶端的工作,像是發出請求與處理回應等等。而透過 URLRequest
的 httpMethod
、httpBody
、allHTTPHeaderFields
等屬性,它也特別支援了 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)的一系列協定。
網際網路協定套組定義了一個網際網路運作的模型,將所有協定分成四個層級,從底層開始是:
- 連結層 (Link Layer):關於如何建構區域網路的協定。
- 網際網路層 (Internet Layer):以網際網路協定 (Internet Protocol, IP) 為主,描述如何透過 IP 位址與路由等技術,在不同的區域網路之間傳遞資料。
- 傳輸層 (Transport Layer):定義網際網路上端到端之間的資料交換通道,比如說 TCP 與 UDP。
- 應用層 (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())
}
}
三向交握在伺服器確認連線(透過呼叫 NWConnection
的 start(queue:)
)之後,還要再等客戶端也確認連線(當 state
變成 ready
時)才算是完成。
接著就可以在 startServer()
裡面加上 connection.confirm()
:
func startServer() {
listener.startListeningToIncomingConnections() { connection in
connection.confirm() {
// 連線已經建立,可以開始交換 HTTP 訊息了
}
}
}
接收 HTTP 請求訊息
一旦三向交握成功,跟客戶端之間的連線建立起來之後,就可以開始交換 HTTP 訊息了。HTTP 訊息的交換也是由客戶端先發出請求訊息 (request) 給伺服器端,伺服器端接著回傳回應訊息 (response) 給客戶端,客戶端再以回應訊息所包含的資料來顯示內容或下載檔案等等。
雖然 HTTP 訊息的交換跟 TCP 連線建立的三向交握一樣,都是由伺服器端監聽客戶端所發起的連線/訊息,但 NWConnection
接收客戶端請求的方式跟 NWListener
很不一樣,它不是監聽,而是單次接收的概念。簡單來說,NWListener
的 newConnectionHandler
閉包可能會被多次呼叫,但是 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 裡看到整個從連線建立、訊息交換到連線關閉的過程了!
參考資料
- 用
CFNetwork
實作 HTTP 伺服器:Local HTTP Server for iOS – Zeeshan - Network.framework 應用:Building a server-client application using Apple’s Network Framework
CFHTTPMessage
使用說明:Communicating with HTTP Servers- WWDC session:Introducing Network.framework: A modern alternative to Sockets