iOS App 程式開發

RESTful API 教學:用 Swift 建立屬於自己的輕量 REST 程式庫!

RESTful API 教學:用 Swift 建立屬於自己的輕量 REST 程式庫!
RESTful API 教學:用 Swift 建立屬於自己的輕量 REST 程式庫!
In: iOS App 程式開發, Swift 程式語言, Xcode

現今大部分的 App 都會與伺服器溝通來交換資料,為了達到這個目的,它們多採用 RESTful API,又稱為 RESTful 網路服務 (Web Serivce)。App 可以使用 REST 傳送請求 (Request) 到伺服器,然後伺服器會回傳回應 (Response) 給使用者端 App。整個通訊是基於 REST 架構所定義的標準規則。開發者能夠藉由整合第三方程式庫到專案、或是實作自己的解決方案,來使用 RESTful API。

在這次的教學中,我將會教大家建立自己的輕量類別 (lightweight class) 來處理網路請求。我會帶著你從基礎開始,一步一步完成一個完整的解決方案,以提供一個乾淨而易於使用的 API 來準備、並提出網路請求。

要讀懂這篇文章,你需要有一些關於網路服務、REST、及其各種概念的基礎知識。如果對這個主題沒有自信,你可以先看一下維基百科這篇文章來了解一下。在你繼續閱讀之前,請先確認你了解甚麼是 HTTP message、以及下列詞彙的意思:

  • HTTP method
  • Request & Response HTTP headers
  • URL query parameters
  • HTTP body

看完這篇文章,你將能建構一個能夠發出請求、並與任何伺服器交換資料的類別!

今天要做甚麼呢?

如我所說,我們將要建立一個簡單而強大的類別,讓我們能夠處理網路請求。與其他教學不同,這次我們主要專注在程式碼上,所以就連我們用來測試類別的例子,也會在控制台上呈現結果。不過,我們也準備了初始專案來讓你下載。在初始專案裡,你會找到兩個檔案,一個會有我們將要撰寫的類別,而另一個則會有在教學最後測試類別時所需要的一些結構定義。

我們將從定義一些自定型別 (enum 及 struct) 開始,讓我們處理各式各樣的資料時更加容易。接著,我們會實作一些私有方法,來給類別一些功能。最後,我們將定義一些公開方法,來使用之前完成的操作來啟動網路請求。

所以下載好專案後,就在 Xcode 打開專案,並繼續讀下去吧!

準備工作

讓我們開啟初始專案,並進入 RestManager.swift 檔案。這個檔案會是我們在這篇文章中,為了實作 REST 管理者而花最多時間的地方。

我們先來宣告一個新類別 RestManager

class RestManager {

}

然後在這後面建立一個 Extension:

extension RestManager {

}

我猜你現在可能會有疑問,就是這個 Extension 有甚麼用途呢?我會這樣解釋:從這部分開始,我們將實作一堆自定型別 (structs、enums),然後我希望他們都是 RestManager 類別的內部型別 (Inner Types)。我們當然可以在類別上實作它們,但在內部實作的話,我們就可以清楚指出它們的目的,並將它們與類別直接聯繫起來。所以,Extension 是用於自定型別的實作,而類別的主體則是用於公開與私有方法。

現在,讓我們來建立一個列舉 (enumeration) 來呈現各式各樣的 HTTP method。首先,在類別 Extension 添加以下內容:

enum HttpMethod: String {
    case get
    case post
    case put
    case patch
    case delete
}

如你所見,我們定義了列舉為字串型別,如此一來,每個 case 的隱含值與 case 的名稱就會相同。在文章稍後的部分,你將會看到 HTTP method 的值必須被視為字串值,而我們只需透過上述任何一種 case 的原始數值 (Raw Value)(例如,get.rawValue)來管理它。

這個步驟沒有發生任何問題,現在我們有了自己的類別定義,以及在其中實現了第一個而重要的自定型別。現在,讓我們處理更多有關網路請求的概念。

管理 HTTP 標頭 & URL 及 HTTP 本文參數

一個網路請求可以包含具有伺服器所需的各種信息的 HTTP 標頭 (HTTP Headers),例如被傳送資料的型別、或是授權 App 使用資源的權限標頭。幾乎每次當伺服器回應使用者端 App 時,回應 HTTP 標頭 會在有或沒有正文數據的情況下傳送。此外,一個端點可能需要在 URL 中包含各種參數(通常發生在 GET 請求);而其他時候參數或其他資料則必須透過 HTTP 本文(在 POST、PUT 或 PATCH 請求時)被發送。

雖然上述所有都涉及網路請求的不同方面(請求、回應、URL、HTTP 本文),但它們都有些共同點:不管它們最後如何被傳送至伺服器,提供的數值都可以被描述為鍵值對 (Key-Value Pairs)。例如:

  • content-type: application/json 是一個請求的 HTTP 標頭,而 “content-type” 是鍵,”application/json” 就是所對應的值。
  • Server: Apache/2.4.1 (Unix) 是一個回應的 HTTP 標頭,”Server” 是鍵,”Apache/2.4.1 (Unix)” 是值。
  • https://someurl.com?firstname=John&age=40 是一個有著查詢參數的 URL,”firstname” 與 “age” 是鍵,”John” 及 “40” 就是值
  • ["email": "[email protected]", "password": "pass123"] 是一個有著鍵與值的字典,它可以被轉換成 JSON 物件,並作為請求的 HTTP 本文傳送出。

所以,無論每個資料的種類代表甚麼,我們實際上需要處理的都一樣:與鍵相關的值,它可以完美的以字典的方式呈現,更具體的說是具有字串數值的字典 ([String: String])。

我們將建立一個小的結構來呈現上述的東西,而且這個結構將包含一個儲存任何數值的字典。我們將會實作一些非常簡單的方法,讓開發者用起來更方便,這幾個方法包括:

  1. 代理 (proxy) 方式運作,這樣一來,我們就可以透過它們到字典設置數值、及從字典取得數值;這個方法需要的額外的程式碼不多。
  2. 從其他將使用 RestManager 的類別中隱藏底層的資料儲存機制(字典)。如果你想要以程式庫的方式分享 RestManager,並隱藏實作細節的話,這個方法就非常有用了。

來吧!在 RestManager 的 Extension 中添加以下程式碼:

struct RestEntity {
    private var values: [String: String] = [:]

    mutating func add(value: String, forKey key: String) {
        values[key] = value
    }

    func value(forKey key: String) -> String? {
        return values[key]
    }

    func allValues() -> [String: String] {
        return values
    }

    func totalItems() -> Int {
        return values.count
    }
}

現在,前往 RestManager 類別的主題並加入以下屬性:

class RestManager {

    var requestHttpHeaders = RestEntity()

    var urlQueryParameters = RestEntity()

    var httpBodyParameters = RestEntity()
}

它們全部都是相同的自定型別,但是每個都是用來代表不同網路請求的概念。請注意,我們並沒有為回應 HTTP 標頭建立屬性,因為我們在下一步才會處理回應。

作為例子,我們可以像這樣設置或是取得任何上述屬性的數值:

// 設置請求 HTTP 標頭
requestHttpHeaders.add(value: "application/json", forKey: "content-type")

// 取得 URL 查詢參數
urlQueryParameters.value(forKey: "firstname")

管理回應

HTTP 回應是一個由伺服器傳送給使用者端的訊息,以回應使用者發出的 HTTP 請求。一個回應可能會包括以下三種不同的資料:

  • 一個表示請求結果的數字狀態(HTTP 狀態碼),這必定會由伺服器回傳。
  • HTTP 標頭,這不一定會在回應中存在。
  • 回應本文,這是由伺服器回傳到使用者端的實際資料。

如你所知,回應裡所包含的資料是非常隨性的,所以我們無法實作一個具體的解決方案,來一直以相同的方式管理它們。然而,如上文所述,了解到資料的分離的話,我們仍然可能提供處理 HTTP 回應的一般方法。

前往類別 Extension,然後建立以下結構:

struct Response {
    var response: URLResponse?
    var httpStatusCode: Int = 0
    var headers = RestEntity()
}

三個屬性包括:

  1. response:我們會在此處保留實際的回應物件 (URLResponse)。請注意,這個物件不會包含伺服器回傳的實際資料。
  2. httpStatusCode:狀態碼(2xx、3xx 等),表示請求的結果。
  3. headersRestEntity 結構的實例,它是我們在先前段落中實作及討論的東西。

當發出網路請求時,Response 物件會是我們類別回傳的結果的一部分。

現在,來建立一個自定的 init 方法,讓我們可以簡單地初始化一個 Response 物件:

struct Response {
    ...

    init(fromURLResponse response: URLResponse?) {
        guard let response = response else { return }
        self.response = response
        httpStatusCode = (response as? HTTPURLResponse)?.statusCode ?? 0

        if let headerFields = (response as? HTTPURLResponse)?.allHeaderFields {
            for (key, value) in headerFields {
                headers.add(value: "\(value)", forKey: "\(key)")
            }
        }
    }
}

上面的 init 方法接收一個 URLResponse 物件(請注意,它可以是 nil,這樣一來就甚麼事情都沒有)。我們將其保留在 response 屬性中,並「取得」HTTP 狀態碼。為此,我們必須將回應參數從 URLResponse 轉換為 HTTPURLResponse 物件,然後存取它的 statusCode 屬性。如果,在某些情況下,這個數值是 nil 的話,我們就設定 0 為預設數值,這是一個沒有特殊含義的默認值,但這表示不能確定HTTP狀態代碼。

最後,我們保留回應中所包含的任何 HTTP 標頭。再看看我們轉換的 HTTPURLResponse,然後這次我們存取 allHeaderFields 屬性來取得它們。在 for 迴圈裡,你可以看到 RestEntity 結構裡的 add(value:forKey) 方法第一次被使用!

另外,請注意我們沒有宣告一個屬性,以在上面的結構來保留回應本文(實際資料),我們只剩這個部分還沒有處理,我們將會在下一節單獨處理。

呈現結果

因為我們正在建立自定型別,來呈現所有關於網路請求的鍵的實體,所以讓我們來對結果做相同的事,這樣一來我們的類別就會回傳結果給其他類別使用。但首先,讓我們來指定要包含的結果。請記住,網路請求可以成功,但也可能失敗。

所以,當談到結果時,我們可能會得到:

  • 請求成功的話,我們會得到來自伺服器的實際資料。
  • 回應裡的其他資料(可參閱前面的部分)。
  • 任何潛在的錯誤。

我在前面提到請求可能會失敗,當中可能是這兩個原因:

  1. 使用者端 App(以我們來說是 iOS App)因為技術上的原因無法作出請求(例如:沒有網路連線、資料無法轉換成 JSON 格式等),或是無法處理伺服器的回應。
  2. 伺服器收到請求,但回傳的回應中所含的狀態碼不是 2xx,通常是 4xx(錯誤請求)、或是 5xx(伺服器問題)。

在第一種情況下,很可能出現 Error 物件來描述與 iOS 相關的錯誤;而在第二種情況,錯誤就會以 HTTP 狀態碼來描述,並且可能是一個詳細訊息來代替實際資料。

現在來談談程式的部分,在 RestManager 類別的 Extension 裡我們將實作一個新的結構 Results

struct Results {
    var data: Data?
    var response: Response?    
    var error: Error?
}

請注意,所有屬性都被標示為 Optional。成功請求(data 屬性)所回傳的資料通常是 JSON 物件,它可以被使用 RestManager 的類別給解碼。

我們將添加兩個初始器,以豐富 Results 結構:一個將會為三個屬性接收參數,另一個則只接受錯誤物件為參數。

struct Results {
    ...

    init(withData data: Data?, response: Response?, error: Error?) {
        self.data = data
        self.response = response
        self.error = error
    }

    init(withError error: Error) {
        self.error = error
    }
}

我們稍後將會驗證這兩個初始器。

來談談錯誤,讓我們在這邊定義一個自定的錯誤。雖然我們會在下一部分講述我們需要它的原因,但還是讓我提早說一下。我們將基於兩個 iOS SDK 類別來發出請求:一個是 URLRequest,用於建立 URL 請求,我們將提供這個請求到一個資料任務 (Data Task) 物件;而這個資料任務物件,我們就使用 URLSession 類別(該類別讓我們可以在 iOS 發出 HTTP 請求)所建立。在 URL 請求物件無法被建立的情況下,我們就必須回傳一個自定的錯誤給 RestManager 的呼叫器,來指出這個錯誤。

所以,在 RestManager 的 Extension 本文裡添加以下列舉:

enum CustomError: Error {
    case failedToCreateRequest
}

要注意的是,這個列舉符合 Error 協定,這讓它強制擴展 CustomError 列舉,來提供一個 localized description。接著前往 RestManager.swift 檔案的最末處,也就是類別 Extension 之外的地方,然後加入以下內容:

extension RestManager.CustomError: LocalizedError {
    public var localizedDescription: String {
        switch self {
        case .failedToCreateRequest: return NSLocalizedString("Unable to create the URLRequest object", comment: "")
        }
    }
}

目前我們只有一個自定錯誤;但這沒關係,只要我們(或是你)提出更多自定錯誤,我們都能夠輕鬆地添加它們。

稍後,我們以自定錯誤為參數,提供予我們在 Results 結構中定義的第二個初始化方法。

添加參數到 URL

至今,我們已經建立好所有結構與自定型別,好幫助我們輕鬆處理一個網路請求的各方面。現在,是時候開始為我們的類別添加一些功能了。

我們要做的第一件事,就是建立一個私有函式,在這裡,我們將透過 urlQueryParameters 屬性添加任何 URL 查詢參數到原始 URL 上。如果沒有任何參數的話,這個函式將只會回傳原始 URL。

首先,讓我們撰寫新方法的定義。將以下內容加入至 RestManager 類別本文:

private func addURLQueryParameters(toURL url: URL) -> URL {

}

我們的方法接收一個 URL 數值,然後它也會回傳一個 URL。

一開始,我們必須確認有 URL 查詢參數被添加到查詢內。如果沒有,我們就回傳原本傳入的 URL:

private func addURLQueryParameters(toURL url: URL) -> URL {
    if urlQueryParameters.totalItems() > 0 {

    }

    return url
}

接下來,讓我們建立一個 URLComponents 物件,它讓我們能夠輕鬆處理 URL 及其部分。它的初始器需要原始 URL 物件,也就是我們傳入的參數:

private func addURLQueryParameters(toURL url: URL) -> URL {
    if urlQueryParameters.totalItems() > 0 {
        guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }

    }

    return url
}

請注意,上述顯示的 URLComponents(url:resolvingAgainstBaseURL:) 初始器可以回傳 nil 物件,所以使用 guard 陳述句是必要的。另外,也要注意我們使用的不是 guard let,而是 guard var,因為我們希望 urlComponents 是個變數而不是常數。接下來,我們將對它進行變更。

注意:你可以使用 if var 陳述句取代 guard

URLComponents 類別提供一個名為 queryItems 的屬性。這是一個 URLQueryItem 物件的集合(陣列),每個物件代表一個 URL 查詢項目。這個屬性的預設值是 nil,當 queryItems 有數值時,URLComponents 類別可以創建一個包含特定查詢項目的 URL。

考慮到這點,讓我們初始化自己的 queryItems 陣列,然後迭代 urlQueryParameters 屬性裡的所有數值。對每個找到的參數,我們將為它建立一個新的 URLQueryItem 物件,並將其放進我們的 queryItems 陣列中。

var queryItems = [URLQueryItem]()
for (key, value) in urlQueryParameters.allValues() {
    let item = URLQueryItem(name: key, value: value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed))

    queryItems.append(item)
}

初始化一個 URLQueryItem 是很直截了當的事,當中並不需要解釋。然而,請注意我們如何處理數值參數!很重要的是我們要進行百分比符號編碼 (Percent Encoding),不然我們就可能會透過 URL 查詢發送不合法的字元,這樣請求就很可能會失敗。為了了解上面做了甚麼,請假設我們擁有下列的參數與數值:

https://someUrl.com?phrase=hello world

在 URL 內,空白字元是不允許的,所以藉由百分比符號編碼,上面顯示的網址會轉為:

https://someUrl.com?phrase=hello%20world

這樣就可以被接受了。

現在我們已經建立好自己的查詢項目集合,讓我們把它指派到 urlComponents 物件的 queryItems 屬性:

urlComponents.queryItems = queryItems

然後,讓我們取得包含查詢參數的完整 URL。你會注意到回傳的 URL 數值正是一個 optional 數值,所以需要在我們從函式回傳前將它解包:

guard let updatedURL = urlComponents.url else { return url }
return updatedURL

完成了!我們待會就可以好好利用這個方法。接下來,我們將整個方法整合:

private func addURLQueryParameters(toURL url: URL) -> URL {
    if urlQueryParameters.totalItems() > 0 {
        guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
        var queryItems = [URLQueryItem]()
        for (key, value) in urlQueryParameters.allValues() {
            let item = URLQueryItem(name: key, value: value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed))
            queryItems.append(URLQueryItem(item)
        }

        urlComponents.queryItems = queryItems

        guard let updatedURL = urlComponents.url else { return url }
        return updatedURL
    }

    return url
}

HTTP 本文

POST、PUT 與 PATCH 網路請求使用 HTTP 本文(通常也稱為訊息本文 (Message Body)實體本文 (Entity Body)),來發送網路伺服器需要的任何資料。發送的資料種類是在 “Content-Type” 請求 HTTP 標頭裡指定的,其中兩個最常使用的資料型別是 “application/json”“application/x-www-form-urlencoded”,不過仍有很多其他種類,只是不常用於手機 App 上。在第一種型別情況下,原始資料必須被編碼,並以 JSON 物件送至伺服器。在第二種型別的情況下,資料應該以查詢字串發送,其中鍵值配對由 “&” 符號區隔(就像我們先前談到的 URL 查詢參數一樣)轉換為 Data 物件。

在這部分中,我們會建立一個新的私有函式,它將根據 httpBodyParameters 屬性中的數值,為這兩種內容型別產生資料。然而,這會限制我們的類別只能使用這兩種內容型別!我們必須克服這種情況,而我們將透過 RestManager 類別中宣告以下屬性來實現:

var httpBody: Data?

這樣可以直接設定 HTTP 本文資料,因此能夠使用前文提到之外的任何內容型別。

現在,讓我們在 RestManager 類別裡建立一個新的私有方法:

private func getHttpBody() -> Data? {

}

它回傳了一個 Optional Data 數值,因為不是所有請求都包含 HTTP 本文。如果內容型別標頭未被指定或是沒有回傳資料,這個方法也會回傳 nil。

我們將開始實作方法。首先,確認是否已透過 requestHttpHeaders 屬性設定 “Content-Type” 請求 HTTP 標頭。如果沒有,我們可以假設網路請求沒有包含本文,所以我們可以回傳 nil。

private func getHttpBody() -> Data? {
    guard let contentType = requestHttpHeaders.value(forKey: "Content-Type") else { return nil }

}

接著,我們將檢查內容型別數值的例子,然後我們將確認是否已設定任何內容型別:

private func getHttpBody() -> Data? {
    guard let contentType = requestHttpHeaders.value(forKey: "Content-Type") else { return nil }

    if contentType.contains("application/json") {

    } else if contentType.contains("application/x-www-form-urlencoded") {

    } else {

    }
}

在第一個例子中,httpBodyParameters 物件裡指定的數值必須被轉換為 JSON 物件 (Data 物件),這物件將會從方法回傳:

return try? JSONSerialization.data(withJSONObject: httpBodyParameters.allValues(), options: [.prettyPrinted, .sortedKeys])

在 “application/x-www-form-urlencoded” 的例子中,我們將建置一個查詢字串(再一次我們以百分比符號編碼這個數值):

let bodyString = httpBodyParameters.allValues().map { "\($0)=\(String(describing: $1.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)))" }.joined(separator: "&")
return bodyString.data(using: .utf8)

這樣,字串看起來會像這樣:

"firstname=John&age=40"

在其他例子中,我們回傳 httpBody 屬性的數值:

return httpBody

以下是完整的實作方法:

private func getHttpBody() -> Data? {
    guard let contentType = requestHttpHeaders.value(forKey: "Content-Type") else { return nil }

    if contentType.contains("application/json") {
        return try? JSONSerialization.data(withJSONObject: httpBodyParameters.allValues(), options: [.prettyPrinted, .sortedKeys])
    } else if contentType.contains("application/x-www-form-urlencoded") {
        let bodyString = httpBodyParameters.allValues().map { "\($0)=\(String(describing: $1.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)))" }.joined(separator: "&")
        return bodyString.data(using: .utf8)
    } else {
        return httpBody
    }
}

準備 URL 請求

在這個部分,我們將會初始化及配置一個 URLRequest 物件,它是我們在作網路請求時唯一需要的物件。

URL 請求物件的建立與配置將會在新的私有方法內進行:

private func prepareRequest(withURL url: URL?, httpBody: Data?, httpMethod: HttpMethod) -> URLRequest? {

}

我們透過參數傳送 URL(包含任何查詢參數)、HTTP 本文、與 HTTP 方法(GET、POST 等),我們就是使用 HTTP 方法來製作網路請求。在方法的最後,會回傳一個完整的 URLRequest 物件,如果無法被建立的話,就會回傳 nil。

讓我們開始在方法中加入一些程式碼。首先,我們確認傳入的 URL 是不是 nil。然後,用那個 URL 來初始化一個 URLRequest 物件。同時,我們也會指派 HTTP 方法:

private func prepareRequest(withURL url: URL?, httpBody: Data?, httpMethod: HttpMethod) -> URLRequest? {
    guard let url = url else { return nil }
    var request = URLRequest(url: url)
    request.httpMethod = httpMethod.rawValue
}

請注意,HTTP 方法必須以一個字串數值來指派給請求,這也是我們用它的原始數值 (Raw Value) 的原因。

接著,讓我們指派 HTTP 請求標頭到 request 物件。這步驟將在迴圈中進行:

for (header, value) in requestHttpHeaders.allValues() {
    request.setValue(value, forHTTPHeaderField: header)
}

最後,我們指派 httpBody 物件到相應屬性的 request 物件,並從方法中回傳:

request.httpBody = httpBody
return request

以下是完整的方法:

private func prepareRequest(withURL url: URL?, httpBody: Data?, httpMethod: HttpMethod) -> URLRequest? {
    guard let url = url else { return nil }
    var request = URLRequest(url: url)
    request.httpMethod = httpMethod.rawValue

    for (header, value) in requestHttpHeaders.allValues() {
        request.setValue(value, forHTTPHeaderField: header)
    }

    request.httpBody = httpBody
    return request
}

製作網路請求

我們終於可以整合上面提的東西,並製作請求了!如你在下文可見,所有先前所建立的私有方法與自定型別,將會在下面的步驟中使用到,以達到我們的最終目標。

讓我們在 RESTManager 類別裡建立下面的公開方法:

func makeRequest(toURL url: URL,
                 withHttpMethod httpMethod: HttpMethod,
                 completion: @escaping (_ result: Results) -> Void) {

}

這個公開方法接收三個參數:將向其發出網路請求的 URL、傾向使用的 HTTP 方法、與包含請求結果的完成處理器 (completion handler)。

很重要的是,網路請求的操作並不是立即執行的,它需要時間去建立請求、送出資料、及接收伺服器的回應。當這樣的操作在進行時,App 應該維持持響應狀態而不會凍結,使用者應該能夠在執行網路請求時繼續使用 App。

為了確保這樣的狀況,我們將在背景執行緒裡非同步地進行,這樣就可以保留主執行緒給 App 使用:

func makeRequest(toURL url: URL,
                 withHttpMethod httpMethod: HttpMethod,
                 completion: @escaping (_ result: Results) -> Void) {

    DispatchQueue.global(qos: .userInitiated).async { [weak self] in

    }
}

userInitiated 值作為服務質量 (“qos”) 的參數,它將優先考慮我們的工作,並降低其他在背景執行的工作的優先級別。如果 RestManager 實例因為某些原因停止存活的話,weak self 就會確保任何類別屬性與方法的參照都不會導致閃退。self 在佇列本文中是一個 Optional 項目。

那麼,讓我們開始呼叫私有方法,並準備好網路請求。首先,讓我們加入 URL 查詢參數給指定的 URL:

let targetURL = self?.addURLQueryParameters(toURL: url)

雖然 addURLQueryParameters(toURL:) 方法將回傳一個實際的 URL 物件,而不會是 nil,但 targetURL 可以是 nil,因為我們把 self 用作一個 weak 變數。如果 self 不是 nil 的話,targetURL 就會取得 addURLQueryParameters(toURL:) 方法回傳的數值,而其餘就會變成 nil。

接著,讓我們取得 HTTP 本文:

let httpBody = self?.getHttpBody()

現在我們可以建立 URLRequest 物件:

guard let request = self?.prepareRequest(withURL: targetURL, httpBody: httpBody, httpMethod: httpMethod) else
{
    completion(Results(withError: CustomError.failedToCreateRequest))
    return
}

這裡我們注意到幾點。首先,看看我們提供給上面方法中用來作為參數的 URL 數值:targetURL,就像之前所說,這個數值可以是 nil。這就解釋了我們在先前定義 prepareRequest(withURL:httpBody:httpMethod:) 方法時,宣告 URL 參數為 Optional 變數的原因。

第二,假設 request 物件為 nil,導致我們無法繼續發出請求。這樣一來,使用 guard 陳述句(如果你較喜歡的話,也可以使用 if let)就非常重要了,並且在進行下一步前確認 request 有值。

假使 request 物件是 nil 的話,我們仍須從函式回傳,但在之前我必須呼叫完成處理器,並且傳送 Results 物件。透過該物件,我們必須提供一個錯誤,以解釋為甚麼網路請求無法執行。這是我們已經在 CustomError 列舉裡定義的自定錯誤。

實際的網路請求,將會透過 URLSession 實例來建立一個資料任務 (Data Task) 開始。接下來的程式碼會初始化一個使用預設配置的對話 (Session) 物件,然後建立一個新的資料任務。這邊要多注意一下:這裡我們提供 request(URL 請求)物件,所以確認它不是 nil 非常重要。

let sessionConfiguration = URLSessionConfiguration.default
let session = URLSession(configuration: sessionConfiguration)
let task = session.dataTask(with: request) { (data, response, error) in

}

在completion 方法中,資料任務回傳 data 物件中伺服器傳送的實際資料(如有)、作為 URLResponse 物件的回應、以及任何潛在的錯誤。我們將使用這些來初始化一個 Results 物件,並透過完成處理器來將其傳送到類別的呼叫中:

completion(Results(withData: data,
                   response: Response(fromURLResponse: response),
                   error: error))

請注意,上面我們使用資料任務的 response 來建立了一個自定 Response 物件。

我們還有最後一個動作沒做,就是啟動資料任務:

task.resume()

我們的方法已經準備好,可以開始製作網路請求了!

以下是完整的程式碼:

func makeRequest(toURL url: URL,
                 withHttpMethod httpMethod: HttpMethod,
                 completion: @escaping (_ result: Results) -> Void) {

    DispatchQueue.global(qos: .userInitiated).async { [weak self] in
        let targetURL = self?.addURLQueryParameters(toURL: url)
        let httpBody = self?.getHttpBody()
        guard let request = self?.prepareRequest(withURL: targetURL, httpBody: httpBody, httpMethod: httpMethod) else {
            completion(Results(withError: CustomError.failedToCreateRequest))
            return
        }

        let sessionConfiguration = URLSessionConfiguration.default
        let session = URLSession(configuration: sessionConfiguration)
        let task = session.dataTask(with: request) { (data, response, error) in
            completion(Results(withData: data,
                               response: Response(fromURLResponse: response),
                               error: error))
        }
        task.resume()
    }
}

從 URL 擷取資料

我們很常需要從 URL 擷取資料,通常是關於檔案的內容,像是頭貼圖像的資料、或是一個 PDF。Data 類別允許我們利用一個 URL 的內容來初始化 Data 物件,但是,在主執行緒上執行這樣的操作,並等待所有資料被擷取,是一個很大的錯誤。

現在,我們用主執行緒來製作網路請求。讓我們來建立另一個解決從 URL 擷取單一資料的問題,而無需準備一個網路請求或關心伺服器的回應。如你所知,這部分共不完全與 REST 有關,但在大部分的例子中,在你透過 RESTful 服務從伺服器擷取主要資料之後,你也將被要求擷取補充資料,例如頭貼圖像或其他來自特定 URL 的內容。

所以,回到 RestManager 類別,定義以下(且明顯是公開)方法:

func getData(fromURL url: URL, completion: @escaping (_ data: Data?) -> Void) {

}

看看這裡的參數:我們有一個資料來源的 URL、及一個完成處理器。完成處理器在成功狀態下回傳擷取資料,失敗狀態下則回傳 nil。

就像先前的方法一樣,無論我們在這個方法本文裡做些甚麼,都必須在背景執行緒中非同步地執行,讓 App 不會被凍結。

public func getData(fromURL url: URL, completion: @escaping (_ data: Data?) -> Void) {
    DispatchQueue.global(qos: .userInitiated).async {

    }
}

我們將再次使用資料任務來從已有的 URL 中擷取資料。雖然這次我們將使用 URL 參數的數值來初始化資料任務物件,但我們沒有(也不需要)URL 請求。在此之前,我們需要先初始化 URLSession 物件:

let sessionConfiguration = URLSessionConfiguration.default
let session = URLSession(configuration: sessionConfiguration)
let task = session.dataTask(with: url, completionHandler: { (data, response, error) in

})

此外,如果我們將此方法用於預期目的,它將幾乎總是 nil,所以我們不必在意回應及錯誤。當然,如果你需要的話也可以隨意管理它們。

在資料任務的完成本文裡,我們必須確認資料是否已經被擷取,然後呼叫完成處理器傳送實際資料或是 nil:

guard let data = data else { completion(nil); return }
completion(data)

最後,別忘了啟動資料擷取:

task.resume()

這就是我們的新方法,它讓我們現在能夠從 URL 直接下載資料,而不會為 App 其餘的部分製造額外問題。我們很快就會看到它的示範用法。以下是完整的方法:

public func getData(fromURL url: URL, completion: @escaping (_ data: Data?) -> Void) {
    DispatchQueue.global(qos: .userInitiated).async {
        let sessionConfiguration = URLSessionConfiguration.default
        let session = URLSession(configuration: sessionConfiguration)
        let task = session.dataTask(with: url, completionHandler: { (data, response, error) in
            guard let data = data else { completion(nil); return }
            completion(data)
        })
        task.resume()
    }
}

試用 RestManager

最後,是時候來看看我們建立的類別是否能夠運作,以及是否能夠製作請求。你可以在網上找到很多免費的 API,而我選擇使用這一個,因為它看起來非常適合測試。在我們開始製作網路請求之前,我建議你先看看這個網站,並試試網站提供的各種端點,我們將在這裡使用部分端點。

在我們開始之前,請在初始專案裡找一個名為 SampleStructures.swift 的檔案。當中有一些簡單的結構,它們代表了各種資料的結構,而這些資料將會被擷取,以作為我們在這執行請求的伺服器回應的一部分。請注意,它們都符合 Codable 協定,所以我們可以輕鬆地解碼收到的 JSON 資料。

還有一件事:前往 ViewController.swift 檔案,然後在 viewDidLoad() 方法之前宣告以下屬性:

let rest = RestManager()

擷取使用者列表

讓我們開始製作一個 GET 請求,並從這個 https://reqres.in/api/users URL 中擷取使用者列表吧。在初始專案中,開啟 ViewController.swift 檔案,然後加入以下方法:

func getUsersList() {
    guard let url = URL(string: "https://reqres.in/api/users") else { return }

    rest.makeRequest(toURL: url, withHttpMethod: .get) { (results) in

    }
}

我們簡單地開始,不必包含任何請求 HTTP 標頭或其他資料。我們只指定將製作請求的 URL 以及 HTTP 方法。

為了看到在 getUsersList() 方法裡指定請求取得的使用者列表,我們將使用完成處理器中的 results 物件。如此更新方法:

func getUsersList() {
    guard let url = URL(string: "https://reqres.in/api/users") else { return }

    rest.makeRequest(toURL: url, withHttpMethod: .get) { (results) in
        if let data = results.data {
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            guard let userData = try? decoder.decode(UserData.self, from: data) else { return }
            print(userData.description)
        }
    }
}

請記住,results 裡的 data 屬性是 Optional 數值,因此在使用之前我們必須先解包。我們初始化一個 JSONDecoder 物件,裡頭用我們較喜歡的keyDecodingStrategy(這樣一來 “first_name” 會被解譯為 “firstName”),然後解碼資料。

要使用上面的方法,我們只需要像這樣在 viewDidLoad() 裡呼叫它:

override func viewDidLoad() {
    super.viewDidLoad()

    getUsersList()
}

執行專案,並看看 Xcode 主控台。如果你照著前面的所有步驟,那你會應該會看到:

User list

你有看到類似這樣的結果嗎?如果有的話,恭喜你了!你剛成功用自己的自定類別製作了第一個網路請求!

取得回應標頭

現在,更新 getUserList() 方法,讓它能夠看見來自伺服器的回應 HTTP 標頭。results 物件有一個 Response 自定型別的 response 屬性。在這之中,你可以找到回應 HTTP 標頭:

func getUsersList() {
    guard let url = URL(string: "https://reqres.in/api/users") else { return }

    rest.makeRequest(toURL: url, withHttpMethod: .get) { (results) in
        if let data = results.data {
            ...
        }

        print("\n\nResponse HTTP Headers:\n")

        if let response = results.response {
            for (key, value) in response.headers.allValues() {
                print(key, value)
            }
        }
    }
}

以下是再次執行 App 後,主控台印出的東西:

Response header

提供 URL 查詢參數

這個部分,我們將製作相同的網路請求,但我們會提供一個 URL 查詢來指出我們想要擷取的資料頁數

func getUsersList() {
    guard let url = URL(string: "https://reqres.in/api/users") else { return }

    // The following will make RestManager create the following URL:
    // https://reqres.in/api/users?page=2
    rest.urlQueryParameters.add(value: "2", forKey: "page")

    rest.makeRequest(toURL: url, withHttpMethod: .get) { (results) in
        ...
    }
}

讓我們來執行,並再次看一下結果:

User list 2

這次有不一樣的結果了!這次的結果考慮了我們指定的 URL 查詢參數!

確認 HTTP 狀態碼

現在讓我們來做下一個測試吧。我們將製作至 https://reqres.in/api/users/100 URL 的請求,這個 URL 不會回傳任何資料。這次,我們會建立一個新方法:

func getNonExistingUser() {
    guard let url = URL(string: "https://reqres.in/api/users/100") else { return }

    rest.makeRequest(toURL: url, withHttpMethod: .get) { (results) in
        if let response = results.response {
            if response.httpStatusCode != 200 {
                print("\nRequest failed with HTTP status code", response.httpStatusCode, "\n")
            }
        }
    }
}

viewDidLoad() 方法裡呼叫上面的方法:

override func viewDidLoad() {
    super.viewDidLoad()

    // getUsersList()
    getNonExistingUser()
}

這就是 results 物件的 response 屬性裡所包含的 HTTP 狀態碼:

status code

看來我們正確地從伺服器回應中取得了 HTTP 狀態碼!

建立一個新使用者

這個例子的重點是製作一個 POST 請求,當中我們會指定請求 HTTP 標頭與 HTTP 本文資料(透過 httpBodyParameters 屬性),目的是要建立一個新的使用者來測試端點。使用者將不會真的被建立,但是伺服器會給我們適當的回應。

讓我們來建立一個新方法:

func createUser() {
    guard let url = URL(string: "https://reqres.in/api/users") else { return }

    rest.requestHttpHeaders.add(value: "application/json", forKey: "Content-Type")
    rest.httpBodyParameters.add(value: "John", forKey: "name")
    rest.httpBodyParameters.add(value: "Developer", forKey: "job")

    rest.makeRequest(toURL: url, withHttpMethod: .post) { (results) in
        guard let response = results.response else { return }
        if response.httpStatusCode == 201 {
            guard let data = results.data else { return }
            let decoder = JSONDecoder()
            guard let jobUser = try? decoder.decode(JobUser.self, from: data) else { return }
            print(jobUser.description)
        }
    }
}

看看 requestHttpHeadershttpBodyParameters 屬性是如何被使用的,以及數值是如何在字典裡被設定的。這都相當簡單易用。

我們應該在 viewDidLoad() 方法呼叫它:

override func viewDidLoad() {
    super.viewDidLoad()

    //getUsersList()
    //getNonExistingUser()
    createUser()
}

請注意,我們只有在 HTTP 狀態碼是 201 時(表示成功建立),才會解碼它。回傳結果看起來會像這樣:

Create user

擷取單一資料

最後,讓我們來做最後一個測試。這次我們要使用 RestManager 類別的 getData(fromURL:completion:) 方法,這個方法讓我們可以擷取一個使用者的頭貼圖像資料。我們將會建立一個新方法,根據你從指定 URL 中看到的 ID 數值來擷取單一使用者。一旦我們取得使用者資料,並且成功地解碼後,我們就可取得頭貼資料,並儲存到 Caches 資料夾裡。如果所有步驟都成功,我們會在控制台看到硬碟上頭貼檔案的 URL。我們將使用它來驗證頭貼是否已經下載完畢。

以下是用來擷取單一使用者資料及頭貼圖像的新方法:

func getSingleUser() {
    guard let url = URL(string: "https://reqres.in/api/users/1") else { return }

    rest.makeRequest(toURL: url, withHttpMethod: .get) { (results) in
        if let data = results.data {
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            guard let singleUserData = try? decoder.decode(SingleUserData.self, from: data),
                let user = singleUserData.data,
                let avatar = user.avatar,
                let url = URL(string: avatar) else { return }

            self.rest.getData(fromURL: url, completion: { (avatarData) in
                guard let avatarData = avatarData else { return }
                let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
                let saveURL = cachesDirectory.appendingPathComponent("avatar.jpg")
                try? avatarData.write(to: saveURL)
                print("\nSaved Avatar URL:\n\(saveURL)\n")
            })

        }
    }
}

我們必須在 viewDidLoad() 裡呼叫這個方法:

override func viewDidLoad() {
    super.viewDidLoad()

    //getUsersList()
    //getNonExistingUser()
    //createUser()
    getSingleUser()
}

以下是在主控台顯示的內容:

Saved avatar URL

利用所顯示的 URL,讓我們來驗證頭貼是否已經被擷取及儲存:

Avatar

總結

來到本次教學的尾聲,我希望你在本文中找到有趣而有幫助的地方。在前面的部分,我們建立了一個能夠製作 RESTful 請求的輕量類別,而在最後一部分我們測試了這個類別是可運作的。歡迎為這個類別添加更多功能、自訂或是擴展它。

在本篇教學中,有一個有趣的例子我沒有展示到,那就是使用 “multipart/form-data” 內容型別來上傳檔案。但我認為保持原有與核心的概念,然後讓你自己親身嘗試會更好。

雖然上傳檔案只是網路請求的一部分,但它需要更多的呈現及討論,考慮到本次教學的範圍,可能篇幅會不夠。有機會的話,我們會在未來的文章中談談這一部分。現在,讓我們道聲再會,希望在你能學到一些有價值的東西!

你可以到 GitHub 下載完整專案。

編註:我們已經發表了另一篇教學,教大家如何使用 RESTful API 進行文件更新,但目前只有英文版,歡迎閱讀這篇文章來了解更多。
譯者簡介:楊敦凱-目前於科技公司擔任 iOS Developer,工作之餘開發自有 iOS App 同時關注網路上有趣的新玩意、話題及科技資訊。平時的興趣則是與自身專業無關的歷史、地理、棒球。來信請寄到:[email protected]

原文RESTful APIs Tutorial: Creating Your Own Lightweight REST Library in Swift

作者
Gabriel Theodoropoulos
資深軟體開發員,從事相關工作超過二十年,專門在不同的平台和各種程式語言去解決軟體開發問題。自2010年中,Gabriel專注在iOS程式的開發,利用教程與世界上每個角落的人分享知識。可以在Google+或推特關注 Gabriel。
評論
更多來自 AppCoda 中文版
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。