Swift 程式語言

如何利用 Swift 協定導向撰寫網路層應用

如何利用 Swift 協定導向撰寫網路層應用
如何利用 Swift 協定導向撰寫網路層應用
In: Swift 程式語言
本篇原文(標題:Writing a Network Layer in Swift: Protocol-Oriented Approach)刊登於作者 Medium,由 Malcolm Kumwenda 所著並授權翻譯及轉載。

本次教學將講述如何不使用第三方套件,只用 Pure Swift 來實作網路層。那我們就直接開始吧!閱讀完這篇教學內容後,我們的程式碼應該會是:

  • 協定導向
  • 易於使用
  • 易於實作
  • 型別安全
  • 使用列舉 (Enum) 來配置 endPoints

以下是我們最終完成的範例:

End goal for the project

只需要使用列舉來輸入 router.request(.,我們就可看到所有可用的 endPoints、以及所需的參數。

首先,來點架構

在創作任何東西時,最重要的是要有架構,這樣以後要找出問題所在就很容易了。我十分相信,資料夾結構是整個軟體架構的關鍵。為了讓檔案井然有序,我們先建立檔案群組,我亦會指示每個檔案應該放的地方。這是專案架構的概覽。 (請注意,檔案的命名只是一個建議,你可以用喜歡的方式命名類別及群組。)

Project folder structure

EndPointType 協定

首先,我們要做的是定義 EndPointType 協定。這個協定會包含所有配置 EndPoint 所需的資訊。甚麼是 EndPoint?基本上它是一個 URLRequest,擁有所有構組元件如標頭 (Header)、查詢參數 (Query Parameters)、本體參數 (Body Parameters) 等 。EndPointType 協定是我們網路層實作的基石。來動手實作吧,建立一個名為 EndPointType 的檔案,將檔案放在 Service 群組內。(而不是放在 EndPoint 群組內,下文會解釋原因。)

EndPointType Protocol

HTTP 協定

EndPointType 中,我們需要幾個 HTTP 協定去建立整個 endPoint。讓我們來看看這些協定是什麼吧。

HTTPMethod

建立一個名為 HTTPMethod 的檔案,然後放入 Service 群組。這個列舉會被用來設定 Request 的 HTTP 方法。

HTTPMethod enum

HTTPTask

建立名為 HTTPTask 的檔案,並放入 Service 群組內。HTTPTask 負責為特定 endPoint 配置參數。你可以因應網路層需求添加更多 Case。我將會做 Request,所以只有三個 Case。

HTTPTask enum

我們將在下一階段討論 Parameters,以及我們如何處理參數編碼。

HTTPHeaders

HTTPHeaders 只是一個 Dictionary 的別名。你可以在 HTTPTask 頂部建立這個別名。

public typealias HTTPHeaders = [String:String]

參數 (Parameters) 和編碼 (Encoding)

建立一個檔案命名為 ParameterEncoding,然後放進 Encoding 群組裡。我們首先要定義 Parameters 的別名。有了別名,我們的程式碼就更清晰簡潔。

public typealias Parameters = [String:Any]

接下來,我們要定義含有一個靜態函數 encodeParameterEncoder 協定。Encode 方法有兩個參數,就是 inout URLRequestParameters。 (為了避免歧義,之後我將以 Argument 代表函式參數。) INOUT 是 Swift 中的關鍵字,定義一個 Argument 為Reference Argument。通常,變數是以數值型態傳送到函式裡的。把 inout 放在 Argument 前面,就可以將它定義為 Reference Type。想了解更多關於 inout Argument,可以參考這篇文章。我們將會在 JSONParameterEncoderURLPameterEncoder 中實作 ParameterEncoder 協定。

public protocol ParameterEncoder {
 static func encode(urlRequest: inout URLRequest, with parameters: Parameters) throws
}

ParameterEncoder 是一個函式,為參數進行編碼。當方法失敗時,它會顯示錯誤訊息,讓我們處理錯誤狀況。

它的價值在於可以拋出自定義錯誤訊息,而不是單單顯示標準的錯誤訊息。我總是很難解譯 Xcode 給的錯誤訊息,藉由自定義錯誤,你可以定義自己的錯誤訊息,並準確地知道出錯的地方。為了達到這個目的,我建立了一個繼承 Error 的列舉。

NetworkError enum

URLParameterEncoder

建立一個名為 URLParameterEncoder 的檔案,並放入 Encoding 群組。

URLParameterEncoder code

上面的程式碼將參數編碼為 URL 格式,並安全地傳送。你應該知道有些字元是 URLs 禁止的,參數也要以 ‘&’ 符號分隔開,我們需要符合這種要求。如果沒有為 Request 設定適當的標頭,我們也要添加進去。

我們應該考慮使用 Unit Test 來測試範例程式碼。正確建構 URL 是非常重要的,這樣可以避免許多不必要的錯誤。如果你正使用一個 Open API,你不會希望 Request 配額因為大量失敗的測試而耗盡。如果想學習更多關於 Unit Testing 的知識,你可以閱讀 S.T.Huang 所撰寫的這篇文章

JSONParameterEncoder

建立一個名為 JSONParameterEncoder 的檔案,放進 Encoding 群組之中。

JSONParameterEncoder code

URLParameter 編碼器差不多,但這裡我們將參數編碼為 JSON 格式,並再次添加適合的標頭。

NetworkRouter

建立一個名為 NetworkRouter 的檔案,並放入 Service 群組。然後我們定義一個 Completion 別名。

public typealias NetworkRouterCompletion = (_ data: Data?,_ response: URLResponse?,_ error: Error?)->()

接著,我們來定義 NetworkRouter 協定。

NetworkRouter code

NetworkRouter 有一個 EndPoint,用來製作 Request;一旦製作完成,它會將網路回應送到 Completion。我亦加入了「取消」函式,讓我們可以在 Request 的生命週期中隨時呼叫這個函式,取消下載任務。如果你的 App 有上載和下載功能,你就會發現這個函式有多重要。我們在此利用 associatedtype,這樣我們的 Router 就能夠處理任何 EndPointType。如果沒有利用 associatedtype,Router 就必須要有一個具體的 EndPointType。想了解更多關於 associatedtypes 的話,我建議可以參閱 NatashaTheRobot 所撰寫的這篇文章

Router

建立一個名為 Router 的檔案,放到 Service 群組裡。我們宣告一個 URLSessionTask 型別的私有變數 task,這個 task 基本上會做好所有步驟。我們保持這個變數私有,這樣類別以外的人就不可以更動這個 task 了。

Router method stubs

Request

我們利用 Shared Session 建立一個 URLSession,這是建立一個 URLSession 最簡單的方式;但不是唯一的方法,使用 Configuration 可以實作更多複雜的 URLSession 配置,並且可以改動 Session 的行為。想了解更多的話,我會建議你閱讀這篇文章

要建立 Request,我們要呼叫 buildRequest,並賦予它一個 route,即是 EndPoint。因為編碼器可能會拋出錯誤訊息,我們需要把 buildRequest 呼叫包裹在 do-try-catch 區塊裡,來捕獲錯誤訊息,而我們則把所有的回應、資料、錯誤傳送到 Completion。

Request method code

建立 Request

Router 中建立一個名為 buildRequest 的私有函式,這個函式負責網路層中所有重要的工作,基本上就是將 EndPointType 轉換為 URLRequest。一旦我們的 EndPoint 變成一個 Request,就可以傳送到 Session。這裡經過了很多步驟,所以讓我們拆解一下 buildRequest 方法看看:

  1. URLRequest 型別的變數 Request 為例,我們要提供 Base URL,並加入我們將要使用的特定路徑。
  2. 將網路 Request 的 httpMethod 設定為與 EndPointhttpMethod 一致。
  3. 建立一個 do-try-catch 區塊,來捕獲編碼器可能拋出的錯誤訊息。建立一個大的 do-try-catch 區塊,就可以避免為每個 try 分別建立區塊。
  4. route.task 建立 Switch。
  5. 依據 task 來呼叫適當的編碼器。

buildRequest method code

配置參數

Router 內建立一個名為 configureParameters 的函式。

configureParameters method implementation

這個函式負責編碼我們的參數。因為 API 內定 bodyParameters 為 JSON 格式、URLParameters 為 URL 編碼,所以我們可以直接傳送適合的參數到指定的編碼器。如果你處理的 API 有多種編碼格式,我會建議修改 HTTPTask,轉為使用列舉編碼器。這個列舉需要齊備所有你需要的編碼格式,然後在 configureParameters 內為額外加入一個列舉編碼器的 Argument,然後適當地用 Switch 來控制列舉及編碼參數。

加入額外的標頭

Router 內建立一個名為 addAdditionalHeaders 的函式。

addAdditionalHeaders method implementation

你只需要把所有額外的標頭加到 Request 的標頭裡即可。

取消函式

取消函式是這樣實作的:

cancel method implementation

實作

現在,在一個實作範例中應用我們建立的網路層吧。我們將會從 TheMovieDB 擷取電影資料到我們的 App 裡。

MovieEndPoint

MovieEndPoint 就如我們在 Getting Started with Moya 裡提到的 Target Type 般。現在,我們來實作 EndPointType。請將以下這個 EndPointType 檔案放入到 EndPoint 群組內。

import Foundation


enum NetworkEnvironment {
    case qa
    case production
    case staging
}

public enum MovieApi {
    case recommended(id:Int)
    case popular(page:Int)
    case newMovies(page:Int)
    case video(id:Int)
}

extension MovieApi: EndPointType {
    
    var environmentBaseURL : String {
        switch NetworkManager.environment {
        case .production: return "https://api.themoviedb.org/3/movie/"
        case .qa: return "https://qa.themoviedb.org/3/movie/"
        case .staging: return "https://staging.themoviedb.org/3/movie/"
        }
    }
    
    var baseURL: URL {
        guard let url = URL(string: environmentBaseURL) else { fatalError("baseURL could not be configured.")}
        return url
    }
    
    var path: String {
        switch self {
        case .recommended(let id):
            return "\(id)/recommendations"
        case .popular:
            return "popular"
        case .newMovies:
            return "now_playing"
        case .video(let id):
            return "\(id)/videos"
        }
    }
    
    var httpMethod: HTTPMethod {
        return .get
    }
    
    var task: HTTPTask {
        switch self {
        case .newMovies(let page):
            return .requestParameters(bodyParameters: nil,
                                      urlParameters: ["page":page,
                                                      "api_key":NetworkManager.MovieAPIKey])
        default:
            return .request
        }
    }
    
    var headers: HTTPHeaders? {
        return nil
    }
}

MovieModel

我們的 MovieModel 並不會改變,因為 TheMovieDB 的回傳內容依然是同樣的 JSON 格式。我們使用 Decodable 協定,來將 JSON 內容轉換到我們的模型上。將 MovieModel 檔案放進 Model 群組內。

import Foundation

struct MovieApiResponse {
    let page: Int
    let numberOfResults: Int
    let numberOfPages: Int
    let movies: [Movie]
}

extension MovieApiResponse: Decodable {
    
    private enum MovieApiResponseCodingKeys: String, CodingKey {
        case page
        case numberOfResults = "total_results"
        case numberOfPages = "total_pages"
        case movies = "results"
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: MovieApiResponseCodingKeys.self)
        
        page = try container.decode(Int.self, forKey: .page)
        numberOfResults = try container.decode(Int.self, forKey: .numberOfResults)
        numberOfPages = try container.decode(Int.self, forKey: .numberOfPages)
        movies = try container.decode([Movie].self, forKey: .movies)
        
    }
}


struct Movie {
    let id: Int
    let posterPath: String
    let backdrop: String
    let title: String
    let releaseDate: String
    let rating: Double
    let overview: String
}

extension Movie: Decodable {
    
    enum MovieCodingKeys: String, CodingKey {
        case id
        case posterPath = "poster_path"
        case backdrop = "backdrop_path"
        case title
        case releaseDate = "release_date"
        case rating = "vote_average"
        case overview
    }
    
    
    init(from decoder: Decoder) throws {
        let movieContainer = try decoder.container(keyedBy: MovieCodingKeys.self)
        
        id = try movieContainer.decode(Int.self, forKey: .id)
        posterPath = try movieContainer.decode(String.self, forKey: .posterPath)
        backdrop = try movieContainer.decode(String.self, forKey: .backdrop)
        title = try movieContainer.decode(String.self, forKey: .title)
        releaseDate = try movieContainer.decode(String.self, forKey: .releaseDate)
        rating = try movieContainer.decode(Double.self, forKey: .rating)
        overview = try movieContainer.decode(String.self, forKey: .overview)
    }
}

NetworkManager

建立一個名為 NetworkManager 的檔案,然後放到 Manager 群組內。這個時候,我們的 NetworkManager 只會有兩個靜態屬性:你的 API Key 和 Network Environment (參照 MovieEndPoint)。 NetworkManager 也有一個 MovieApi 型別的 Router

Network Manager code

Network Response

NetworkManager 裡建立一個名為 NetworkResponse 的列舉。

Network Response enum

我們將會利用這個列舉來處理來自 API 的回應,並顯示適當的訊息。

Result

NetworkManager 裡面建立一個名為 Result 的列舉。

Result enum

這個 Result 列舉非常有用,而且應用範疇廣闊。我們將使用 Result 來確認呼叫 API 成功或失敗。如果失敗,我們會回傳錯誤訊息及原因。想了解更多關於結果導向程式設計 (Result Oriented Programming),可以參考這裡

處理 Network Responses

建立一個名為 handleNetworkResponse 的函式。這個函式使用一個 HTTPResponse Argument,然後回傳一個 Result (String)

handleNetworkResponse function

這裡我們用 Switch 來處理 HTTPResponse 的狀態碼。狀態碼是一個 HTTP 協定,用來告訴我們 Response 的狀態。通常,狀態碼 200 到 299 之間表示成功。更多有關狀態碼的內容,可以參考這裡

製作呼叫

我們已經打好網路層的基底,現在是時候來製作網路呼叫了!

我們將會用 API 取得新電影的清單。建立一個名為 getNewMovies 的函式。

getNewMovies method implementation

來一一拆解這個方法的步驟吧:

  1. 定義 getNewMovies 函式,其中包含兩個 Argument:頁數 (page number)、和回傳 Optional Movie 陣列或 Optional 錯誤訊息字串的 Completion。
  2. 呼叫 Router,在一個閉包裡傳送頁數和處理 Completion。
  3. 如果沒有網路、或因某些原因而無法呼叫 API,URLSession 會回傳一個錯誤。注意這並不是 API 錯誤,而是客戶端的錯誤,而且很可能是因為網路連線不穩。
  4. 因為要讀取狀態碼,所以我們需要將 response 轉型成 HTTPURLResponse
  5. 宣告從 handleNetworkResponse 方法取得的 Result,然後用 switch-case 區塊來檢測這個 Result。
  6. Success 表示我們成功與 API 溝通,並取得適當的 Response。然後,我們要檢查 Response 內是否有資料。如果沒有資料,我們就利用 Return Statment 離開這個方法。
  7. 如果 Response 內有資料的話,我們就需要將資料解碼到模型,然後傳送解碼後的電影資訊到 Completion。
  8. 如果是 Failure,我們就將錯誤傳送到 Completion。

完成了!這就是我們以 Pure Swift 語言所撰寫的網路層,過程中沒有用到 Cocoapods 或第三方程式庫。為了測試利用 API Request 取得電影資訊,我們要建立一個包含 NetworkManager 的 ViewController,並以 Manager 呼叫 getNewMovies。

class MainViewController: UIViewController {
    
    var networkManager: NetworkManager!
    
    init(networkManager: NetworkManager) {
        super.init(nibName: nil, bundle: nil)
        self.networkManager = networkManager
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .green
        networkManager.getNewMovies(page: 1) { movies, error in
            if let error = error {
                print(error)
            }
            if let movies = movies {
                print(movies)
            }
        }
    }
}

其他——網路記錄

在 Moya 中,我最喜歡的功能就是網路記錄 (Network Logger)。記錄了所有網路 Traffic 後,Debug、或瀏覽所有網路通訊的請求和回應就容易得多了,所以我想把這個功能添加到網路層中。建立一個名為 NetworkLogger 的檔案,並放到 Service 群組中。我已經實作了程式碼來記錄 Request 到 Console。我不會說明這段程式碼應放在哪裡,這點留給你作為挑戰:建立一個函式來記錄網路回應到 Console,並找個適合的位置來放置這些函式呼叫。[放置 Gist file]

提示: static func log(response: URLResponse) {}

更多資訊

你有沒有在 Xcode 中發現佔位符 (Placehoder)?你知道它的用處嗎?舉個例子,讓我們看看在 Router 中實作的程式碼。

Router code

我們實作了 NetworkRouterCompletion,但有時它仍然很難去記住所有型別、以及我們應該如何使用它。這時,親愛的 Xcode 這時就來拯救我們了!只要點擊兩下佔位符,Xcode 就會幫我們完成所有步驟了。

Router code-2

總結

現在我們已經有一個容易使用、而且可以自訂的協定導向網路層,亦已完整掌握它的功能和機制。藉由這次的練習,我覺得自己真的學到了些新東西,比起只需要使用程式庫的作品,這次的作品讓我更為自豪。希望這篇文章可以向你證明,利用 Swift 建立自己的網路層並非難事。

你可以在我的 GitHub 上找到原始碼。

本篇原文(標題:Writing a Network Layer in Swift: Protocol-Oriented Approach)刊登於作者 Medium,由 Malcolm Kumwenda 所著並授權翻譯及轉載。
譯者簡介:楊敦凱-目前於科技公司擔任 iOS Developer,工作之餘開發自有 iOS App同時關注網路上有趣的新玩意、話題及科技資訊。平時的興趣則是與自身專業無關的歷史、地理、棒球。來信請寄到:[email protected]
作者簡介:Malcolm,來自南非的自學 iOS 開發者,喜歡開發能夠幫助大家的 App,熱衷於教學及分享知識,閒暇時喜歡踢踢足球、看看球賽。
作者
AppCoda 編輯團隊
此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。