結構化 RESTful API 模組與功能 大大提升程式碼的易讀性!


日常工作中,常常需要與後端串接 RESTful API,然而 API 網址常常很難管理與統一路口,今天這篇文章,想與大家分享在公司的經驗,一起規範出一整套 RESTful API 串接的體系與模組。今天這篇文章需要大家搭配源碼閱讀。讓我們開始吧!

要點內容

  1. 統一 API 底層入口,利用泛型來解決所有 JSON Data to Model 轉換
  2. 規範 API Function 結構,不再讓 URL 散落一地
  3. 統一的錯誤獲取,讓 debug 不再頭大
  4. 結合 PromiseKit 與 Alamofire,製作屬於你的非同步 API 網路應用

準備工作

這邊我們需要快速建立一個 UI 程式碼。為了聚焦重點,就不詳細說明 UI 的組裝過程了。

首先,我們需要先建立一個新專案,進行 pod init,並且編輯 Podfile加入必要組件如下:

# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target 'ActualCombatSwiftNetwork' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  # Pods for ActualCombatSwiftNetwork
  pod 'Alamofire', '~> 4.8'
  pod 'PromiseKit', '~> 6.8'
  pod 'PKCHelper', '~> 0.1.1'
end

編輯完成後就可以執行 pod install。然後,讓我們開啟 .xcworkspace 檔案,開始我們的 Coding之旅!

我們需要加入兩個 UIViewController,一個 UITableViewController

  1. LoginViewController – 登入頁面範例
  2. SignUpViewController – 註冊頁面範例
  3. ProductsViewController – 獲取產品頁面範例

另外加入一個 UITabBarController,命名為 MainTabBarController。參考程式碼如下:

import UIKit
import PKCHelper

class MainTabBarController: UITabBarController {

    override func viewDidLoad() {

        super.viewDidLoad()
        setupViewController()
    }

    fileprivate func setupViewController() {

        // Login
        let loginController = LoginViewController()
        loginController.title = "Login"
        let navLoginController = templateNavController(image: nil, rootViewController: loginController, selectImage: nil)

        // SignUp

         let signUpController = SignUpViewController()
        signUpController.title = "SignUp"
        let navSignUpController = templateNavController(image: nil, rootViewController: signUpController, selectImage: nil)

        // Products

        let productsViewController = ProductsViewController()
        productsViewController.title = "Products"
        let navProductsViewController = templateNavController(image: nil, rootViewController: productsViewController, selectImage: nil)

        // tabBar

        // tabBar.tintColor = UIColor.reddishOrange
        tabBar.unselectedItemTintColor = .brownishGrey

        viewControllers = [
            navLoginController,
            navSignUpController,
            navProductsViewController
        ]

    }

    fileprivate func templateNavController(image: UIImage?, rootViewController: UIViewController = UIViewController(), selectImage: UIImage? = nil) -> UINavigationController {

        let viewNavController = UINavigationController(rootViewController: rootViewController)

        if let image = image {
            viewNavController.tabBarItem.image = image
        }

        if let selectImage = selectImage {
            viewNavController.tabBarItem.selectedImage = selectImage
        }

        return viewNavController
    }

}

另外,修改 AppDelegate 文件,讓我們的 window 連接到剛剛做好的 MainTabBarController

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
            // Override point for customization after application launch.

            window = UIWindow(frame: UIScreen.main.bounds)
            window?.makeKeyAndVisible()
            let mainTabBar = MainTabBarController()
            window?.rootViewController = mainTabBar


            return true
        }

完成後就可以編譯和執行,你現在應該可以看到 UI 組件正常工作了!如果當中遇到困難的話,請參考源碼

到目前為止,我們的準備工作就完成了,讓我們進入主題吧!

註冊頁面 SignUpViewController

首先,我們需要先建立一個註冊頁面,模擬用戶註冊真實狀況。在頁面中,我們會需要幾個欄位,來配合 API 介面。

註冊 API 文件說明

POST
https://yasuoyuhao-restfulapi.herokuapp.com/api/account/signup

參數
字段 類型 描述

  • name String 使用者名稱
  • email String 使用者 email(登入帳號)
  • password String 使用者密碼
  • emailContext String 註冊成功 email 內容
    參數範例:
{
    "name": "xxxx",
    "email": "[email protected]",
    "password": "xxxx",
    "emailContext": "xxxx",
}

Response (example):
HTTP/1.1 200 註冊成功

{
  "success": "true",
  "message: "享受你的token吧",
  "token": "is jwt token"
}

Response (example):
HTTP/1.1 200 重複註冊

{
  "success": "false",
  "message": "帳號已經存在"
}

註冊頁面 UI 組件 Layout

我們查看 API 文檔中,發現需要輸入四個欄位。因此,我們需要在 SignUpViewController 建立四個 UITextField 和一個按鈕UIButton

首先,讓我們製作 UI 組件,並命名五個組件變數:

lazy var emailTextField: UITextField = {
        let tf = UITextField()
        tf.placeholder = "email 帳號"
        tf.borderStyle = .roundedRect

        return tf
    }()

    lazy var passwordTextField: UITextField = {
        let tf = UITextField()
        tf.placeholder = "密碼"
        tf.borderStyle = .roundedRect
        tf.isSecureTextEntry = true
        tf.textContentType = .newPassword

        return tf
    }()

    lazy var nameTextField: UITextField = {
        let tf = UITextField()
        tf.placeholder = "姓名"
        tf.borderStyle = .roundedRect

        return tf
    }()

    lazy var emailContentTextField: UITextField = {
        let tf = UITextField()
        tf.placeholder = "註冊成功 Email, 確認內容"
        tf.borderStyle = .roundedRect

        return tf
    }()

    lazy var signupButton: UIButton = {
        let bt = UIButton(type: .system)
        bt.setTitle("註冊", for: .normal)
        bt.setTitleColor(.white, for: .normal)
        bt.backgroundColor = UIColor.arizonaStateUniversityRed
        bt.addTarget(self, action: #selector(handleSignUp), for: .touchUpInside)
        bt.layer.cornerRadius = 8

        return bt
    }()

然後,在 viewDidLoad()layout 我們的組件:

override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        view.backgroundColor = .aboutMeGreen

        view.addSubview(emailTextField)
        view.addSubview(passwordTextField)
        view.addSubview(nameTextField)
        view.addSubview(emailContentTextField)
        view.addSubview(signupButton)

        emailTextField.anchor(top: view.safeAreaLayoutGuide.topAnchor, leading: view.leadingAnchor, bottom: nil, trailing: view.trailingAnchor, padding: .init(top: 50, left: 16, bottom: 0, right: 16), size: .init(width: 0, height: 50))
        passwordTextField.anchor(top: emailTextField.bottomAnchor, leading: view.leadingAnchor, bottom: nil, trailing: view.trailingAnchor, padding: .init(top: 16, left: 16, bottom: 0, right: 16), size: .init(width: 0, height: 50))
        nameTextField.anchor(top: passwordTextField.bottomAnchor, leading: view.leadingAnchor, bottom: nil, trailing: view.trailingAnchor, padding: .init(top: 16, left: 16, bottom: 0, right: 16), size: .init(width: 0, height: 50))
        emailContentTextField.anchor(top: nameTextField.bottomAnchor, leading: view.leadingAnchor, bottom: nil, trailing: view.trailingAnchor, padding: .init(top: 16, left: 16, bottom: 0, right: 16), size: .init(width: 0, height: 50))
        signupButton.anchor(top: emailContentTextField.bottomAnchor, leading: view.leadingAnchor, bottom: nil, trailing: view.trailingAnchor, padding: .init(top: 32, left: 16, bottom: 0, right: 16), size: .init(width: 0, height: 50))
    }

完成後,應該可以看到以下畫面!

sign-up-page

建置 API Services

有畫面之後,我們就可以開始重頭戲了:串接我們的 API

首先,我們需要建立 BaseAPIServicesBaseAPIServices 是我們 API 最底層打包請求的服務,當中有兩個關鍵的重要方法:

  1. requestGenerator:負責生成我們的 Http 請求
  2. setupResponse:讀取生成的 Http 請求、發送請求、並解析 json data to model

你會注意到,setupResponse 是允許 Codable 泛型的,也就是說我們可以把model 丟進來讓它負責解析。

class BaseAPIServices {

    /**
     Base Http Request Generator
     - Parameter url: 請求資源位置
     - Parameter parameters: Parameters
     - Parameter method: HTTPMethod
     - Parameter encoding: URLEncoding
     */
    public func requestGenerator(route: APIServicesURLProtocol, parameters: Parameters? = nil, method: HTTPMethod = .get, encoding: ParameterEncoding = URLEncoding.default) -> DataRequest {

        let url = ServicesURL.baseurl + route.url

        return Alamofire.request(
            url,
            method: method,
            parameters: parameters,
            encoding: encoding,
            headers: nil
        )
    }

    /**
     Base API Producer
     - Parameter dataRequest: 請求數據
     - Parameter type: 回應模型
     - Returns: Promise.
     */
    public func setupResponse<T: Codable>(_ dataRequest: DataRequest, type: T.Type) -> Promise<T> {
        return Promise<T>.init(resolver: { (resolver) in
            dataRequest.validate().responseJSON(queue: DispatchQueue.global(), options: JSONSerialization.ReadingOptions.mutableContainers, completionHandler: { (response) in

                switch response.result {

                case .success(let json):
                    do {
                        let decoder: JSONDecoder = JSONDecoder()
                        let jsonData = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted)
                        let content = try decoder.decode(T.self, from: jsonData)
                        resolver.fulfill(content)
                    } catch let error {
                        resolver.reject(error)
                    }
                case .failure(let error):
                    PKCLogger.shared.error(error)

                    if let aferror = error as? AFError {

                        // Error Catch

                        switch aferror {

                        case .invalidURL:
                            ()
                        case .parameterEncodingFailed:
                            ()
                        case .multipartEncodingFailed:
                            ()
                        case .responseValidationFailed:
                            ()
                        case .responseSerializationFailed:
                            ()
                        }
                    }
                    resolver.reject(error)
                }
            })
        })
    }
}

接下來,建立模組化的 URL 資源位置 APIServicesURLProtocol。我們先建立兩個 Protocol

public protocol ServicesURLProtocol {
            var url: String { get }
        }

        public protocol APIServicesURLProtocol: ServicesURLProtocol {
            var rootURL: String { get }
        }

再建立一個 Struct 存放基本路徑。請注意,如果有不同環境的路徑處理,也通常會在這邊處理,比如生產環境跟開發環境,baseurl 要做切換。

struct ServicesURL {
    static var baseurl: String {
        return "https://yasuoyuhao-restfulapi.herokuapp.com/api/"
    }
}

然後,我們建立授權模組的URL

enum AuthAPIURL: APIServicesURLProtocol {

    public var rootURL: String {
        return  "accounts/"
    }

    case login
    case signup

    public var url: String {
        return getURL()
    }

    private func getURL() -> String {
        var resource = ""

        switch self {
        case .login:
            resource = "login"
        case .signup:
            resource = "signup"
        }

        return "\(rootURL)\(resource)"
    }
}

如此一來,我們的模組化資源路徑就算完成了!

接下來,讓我們建立 AccountsAPIServices 負責 AuthAPIURL 的資源動作:

import PromiseKit
import Alamofire
import PKCHelper

class AccountsAPIServices: BaseAPIServices {

    static let shared = AccountsAPIServices()

    func signup(email: String, password: String, name: String, emailContent: String) -> Promise<String> {
        // 返回 Promise
        return Promise<String>.init(resolver: { (resolver) in

            // 組合參數
            var parameters = [String: AnyObject]()

            parameters.updateValue(email as AnyObject, forKey: "email")
            parameters.updateValue(password as AnyObject, forKey: "password")
            parameters.updateValue(name as AnyObject, forKey: "name")
            parameters.updateValue(emailContent as AnyObject, forKey: "emailContent")

            // 生成 Request
            let req = self.requestGenerator(route: AuthAPIURL.signup, parameters: parameters, method: .post, encoding: JSONEncoding.default)

            firstly {
                // 發送請求,並且給予型別
                self.setupResponse(req, type: LoginResult.self)
                }.then { (tokenRes) -> Promise<String> in
                    // setup record token
                    return Promise<String>.init(resolver: { (resolver) in
                        PKCLogger.shared.debug(tokenRes)
                        // Token 處理,解碼與儲存
                        if let token = tokenRes.token {
                            resolver.fulfill(token)
                        } else {
                            resolver.reject(AuthError.tokenIsNotExist)
                        }
                    })
                }.done { (currectUserId) in
                    // 完成流程
                    resolver.fulfill(currectUserId)
                }.catch(policy: .allErrors) { (error) in
                    // 錯誤處理
                    resolver.resolve(nil, error)
            }
        })
    }
}

enum AuthError: Error {
    case tokenIsNotExist

    var localizedDescription: String {
        return getLocalizedDescription()
    }

    private func getLocalizedDescription() -> String {

        switch self {

        case .tokenIsNotExist:
            return "token is not find."
        }

    }
}

我們的流程有幾個步驟:

  1. 建立 Promise
  2. 組合 Post 參數
  3. 生成 Request
  4. 發送 Request
  5. 接收 Response (由 Base層解析 json)
  6. 處理已經解析好的資訊(商業邏輯)
  7. 完成流程 / 錯誤處理

只此為止,我們已經達成了規範 API Function 結構,資料解析、錯誤獲取、生成請求通一入口了。

串接頁面

我們完成了註冊需要的 API Services 之後,就可以來 Controller 層介接資料了。

讓我們來實作 handleSignUp 吧:

@objc fileprivate func handleSignUp() {

        // 打印欄位訊息
        PKCLogger.shared.debug(emailTextField.text)
        PKCLogger.shared.debug(passwordTextField.text)
        PKCLogger.shared.debug(nameTextField.text)
        PKCLogger.shared.debug(emailContentTextField.text)

        guard let email = emailTextField.text,
            let password = passwordTextField.text,
            let name = nameTextField.text,
            let emailContent = emailContentTextField.text else { return }

        // 呼叫註冊服務
        _ = AccountsAPIServices.shared.signup(email: email, password: password, name: name, emailContent: emailContent).done { (token) in
            PKCLogger.shared.debug(token)
            }.catch { (error) in
                PKCLogger.shared.error(error.localizedDescription)
                if let authError = error as? AuthError {
                    PKCLogger.shared.error(authError.localizedDescription)
                }
            }.finally {
                // Update UI...
        }
    }

在上面的程式碼中,我們先打印出輸入的欄位訊息,並在驗證欄位呼叫我們剛剛做好的註冊服務層。

現在我們可以進行測試了!(建議帳號密碼要記下來,等下登入會用到)

首先,像這樣輸入欄位資料:

restful-api-test-1

然後,按下註冊按鈕,並查看控制台訊息

control-panel-2

太好了!我們完成了註冊頁面的製作與 API 的串接囉!接下來,讓我們處理登入頁面。

登入頁面 LoginViewController

登入 API 文件說明

User – 使用者登入
POST
https://yasuoyuhao-restfulapi.herokuapp.com/api/account/login

參數
字段 類型 描述

  • email String 使用者 EMail(登入帳號)
  • password String 使用者密碼

Response (example):
HTTP/1.1 200 登入成功

{
  "success": "true",
  "message: "享受你的token吧",
  "token": "is jwt token"
}

Response (example):
HTTP/1.1 200 登入失敗

{
  "success": "false",
  "message": "登入失敗,找不到使用者"
}

登入頁面 UI 組件 Layout

我們查看 API 文檔中,發現需要輸入兩個欄位。因此,我們需要在 LoginViewController 建立兩個 UITextField,和一個登入按鈕UIButton

我們需要先建立 UI 組件。首先,命名三個組件變數:

lazy var emailTextField: UITextField = {
        let tf = UITextField()
        tf.placeholder = "email 帳號"
        tf.borderStyle = .roundedRect

        return tf
    }()

    lazy var passwordTextField: UITextField = {
        let tf = UITextField()
        tf.placeholder = "密碼"
        tf.borderStyle = .roundedRect
        tf.isSecureTextEntry = true

        return tf
    }()

    lazy var loginButton: UIButton = {
        let bt = UIButton(type: .system)
        bt.setTitle("登入", for: .normal)
        bt.setTitleColor(.white, for: .normal)
        bt.backgroundColor = UIColor.tiffanyBlue
        bt.addTarget(self, action: #selector(handleLogin), for: .touchUpInside)
        bt.layer.cornerRadius = 8

        return bt
    }()

接下來,在 viewDidLoad()layout 我們的組件:

override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        view.backgroundColor = .faceBookBlue

        view.addSubview(emailTextField)
        view.addSubview(passwordTextField)
        view.addSubview(loginButton)

        emailTextField.anchor(top: view.safeAreaLayoutGuide.topAnchor, leading: view.leadingAnchor, bottom: nil, trailing: view.trailingAnchor, padding: .init(top: 100, left: 16, bottom: 0, right: 16), size: .init(width: 0, height: 50))
        passwordTextField.anchor(top: emailTextField.bottomAnchor, leading: view.leadingAnchor, bottom: nil, trailing: view.trailingAnchor, padding: .init(top: 16, left: 16, bottom: 0, right: 16), size: .init(width: 0, height: 50))
        loginButton.anchor(top: passwordTextField.bottomAnchor, leading: view.leadingAnchor, bottom: nil, trailing: view.trailingAnchor, padding: .init(top: 32, left: 16, bottom: 0, right: 16), size: .init(width: 0, height: 50))
    }

完成後,你應該會看到這個畫面:

restful-api-test-2

建置登入的 API Services

下一步,我們需要到 AccountsAPIServices 加入登入方法:

func login(email: String, password: String) -> Promise<String> {
        // 返回 Promise
        return Promise<String>.init(resolver: { (resolver) in

            // 組合參數
            var parameters = [String: AnyObject]()

            parameters.updateValue(email as AnyObject, forKey: "email")
            parameters.updateValue(password as AnyObject, forKey: "password")

            // 生成 Request
            let req = self.requestGenerator(route: AuthAPIURL.login, parameters: parameters, method: .post, encoding: JSONEncoding.default)

            firstly {
                // 發送請求,並且給予型別
                self.setupResponse(req, type: LoginResult.self)
                }.then { (tokenRes) -> Promise<String> in
                    // 處理回應
                    return Promise<String>.init(resolver: { (resolver) in
                        PKCLogger.shared.debug(tokenRes)
                        // Token 處理,解碼與儲存
                        if let token = tokenRes.token {
                            resolver.fulfill(token)
                        } else {
                            resolver.reject(AuthError.tokenIsNotExist)
                        }
                    })
                }.done { (currectUserId) in
                    // 完成流程
                    resolver.fulfill(currectUserId)
                }.catch(policy: .allErrors) { (error) in
                    // 錯誤處理
                    resolver.resolve(nil, error)
            }
        })
    }

完成了註冊需要的 API Services 之後,我們就可以來 Controller 層介接資料了。

先如此實作 handleLogin

@objc fileprivate func handleLogin() {
        PKCLogger.shared.debug(emailTextField.text)
        PKCLogger.shared.debug(passwordTextField.text)

        guard let email = emailTextField.text, let password = passwordTextField.text else { return }

        _ = AccountsAPIServices.shared.login(email: email, password: password).done { (token) in
            PKCLogger.shared.debug(token)
            }.catch { (error) in
                PKCLogger.shared.error(error.localizedDescription)
                if let authError = error as? AuthError {
                    PKCLogger.shared.error(authError.localizedDescription)
                }
            }.finally {
                // Update UI...
        }
    }

然後,輸入剛剛成功註冊的帳號密碼來進行測試:

restful-api-test-3

接著,來查看控制台訊息:

control-panel-3

成功了!我們完成了註冊與登入的 API 串接,並且發現模組化規範 API 之後,我們串接的速度越來越快了!

進階:商品頁面 ProductsViewController

這邊是針對更接近實戰演練的串接,我們會先登入,並且帶入 Token 獲取商品列表。有興趣的讀者可以先自己實作看看,再來參考以下步驟。

取得產品 API 文件

Product – 取得產品
GET
https://yasuoyuhao-restfulapi.herokuapp.com/api/products
允許: Authorization&nbsp;

Header
字段 類型 描述
authorization String Authorization value.

Response (example):
HTTP/1.1 200 成功

{
    "success": true,
    "message": "成功找到產品",
    "products": [
        {
            "_id": "5b5b7c7008f1c9bc5efe820b",
            "created": "2018-07-27T20:11:28.380Z",
            "name": "p助教戰手冊",
            "owner": "5b4c334c34bf87ab17a9d860",
            "description": "p助這些年的教戰心得",
            "image": "https://amazon-yasuoyuhao-webapplication.s3.amazonaws.com/1532722285753.png",
            "category": "5b5b573634ebdf994de6e1f4",
            "__v": 0
        }
    ]
}

Response (example):
HTTP/1.1 200 查詢失敗

{
  "success": "false",
  "message": "error message"
}

首先,建立AuthToken 加入變數 token。這樣我們進行請求時,就可以帶入 Token

class AuthToken {
    static let shared = AuthToken()

    var token: String?
}

接下來,調整修改 requestGenerator 方法,把 token 加入 Http Header 中:

public func requestGenerator(route: APIServicesURLProtocol, parameters: Parameters? = nil, method: HTTPMethod = .get, encoding: ParameterEncoding = URLEncoding.default) -> DataRequest {

        let url = ServicesURL.baseurl + route.url

        var headers: HTTPHeaders?

        if let token = AuthToken.shared.token {
            headers = HTTPHeaders()
            headers?.updateValue(token, forKey: "Authorization")
        }

        return Alamofire.request(
            url,
            method: method,
            parameters: parameters,
            encoding: encoding,
            headers: headers
        )
    }
注意:請注意,對於 headers 參數,我們當然可以再抽出 function 參數作為傳入值;但這邊先不深入探討這一點,有興趣的讀者可以自行優化。

然後,修改登入與註冊,以暫存 token:

// Token 處理,解碼與儲存
if let token = tokenRes.token {
    //---加入此行
    AuthToken.shared.token = token
    //---
    resolver.fulfill(token)
}

下一步,讓我們逐一建立 Products ModelProductAPIURL、和 ProductsAPIServices

  • Products Model
import Foundation
struct ProductRes : Codable {
    let success : Bool?
    let message : String?
    let products : [Products]?

    enum CodingKeys: String, CodingKey {

        case success = "success"
        case message = "message"
        case products = "products"
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        success = try values.decodeIfPresent(Bool.self, forKey: .success)
        message = try values.decodeIfPresent(String.self, forKey: .message)
        products = try values.decodeIfPresent([Products].self, forKey: .products)
    }

}

struct Products : Codable {
    let _id : String?
    let created : String?
    let name : String?
    let owner : String?
    let description : String?
    let image : String?
    let category : String?
    let __v : Int?

    enum CodingKeys: String, CodingKey {

        case _id = "_id"
        case created = "created"
        case name = "name"
        case owner = "owner"
        case description = "description"
        case image = "image"
        case category = "category"
        case __v = "__v"
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        _id = try values.decodeIfPresent(String.self, forKey: ._id)
        created = try values.decodeIfPresent(String.self, forKey: .created)
        name = try values.decodeIfPresent(String.self, forKey: .name)
        owner = try values.decodeIfPresent(String.self, forKey: .owner)
        description = try values.decodeIfPresent(String.self, forKey: .description)
        image = try values.decodeIfPresent(String.self, forKey: .image)
        category = try values.decodeIfPresent(String.self, forKey: .category)
        __v = try values.decodeIfPresent(Int.self, forKey: .__v)
    }

}

ProductAPIURLProductsAPIServices 是用於存放獲取產品後的資料。

  • ProductAPIURL
enum ProductAPIURL: APIServicesURLProtocol {

    public var rootURL: String {
        return  "products/"
    }

    case fetch

    public var url: String {
        return getURL()
    }

    private func getURL() -> String {
        var resource = ""

        switch self {
        case .fetch:
            resource = ""
        }

        return "\(rootURL)\(resource)"
    }
}
  • ProductsAPIServices
class ProductsAPIServices: BaseAPIServices {

    static let shared = ProductsAPIServices()

    func fetchProducts() -> Promise<[Products]> {
        // 返回 Promise
        return Promise<[Products]>.init(resolver: { (resolver) in

            // 生成 Request
            let req = self.requestGenerator(route: ProductAPIURL.fetch, method: .get)

            firstly {
                // 發送請求,並且給予型別
                self.setupResponse(req, type: ProductRes.self)
                }.then { (productRes) -> Promise<[Products]> in
                    // 處理回應
                    return Promise<[Products]>.init(resolver: { (resolver) in
                        PKCLogger.shared.debug(productRes.success ?? false)
                        // 確認產品是否有資料
                        if let products = productRes.products {
                            resolver.fulfill(products)
                        } else {
                            resolver.reject(ProductError.productIsNotFind)
                        }
                    })
                }.done { (currectUserId) in
                    // 完成流程
                    resolver.fulfill(currectUserId)
                }.catch(policy: .allErrors) { (error) in
                    // 錯誤處理
                    resolver.resolve(nil, error)
            }
        })
    }
}

enum ProductError: Error {
    case productIsNotFind

    var localizedDescription: String {
        return getLocalizedDescription()
    }

    private func getLocalizedDescription() -> String {

        switch self {

        case .productIsNotFind:
            return "product is not find."
        }

    }
}

我們已經自定義了資料型別,並且放入 self.setupResponse 自動解析。現在,我們可以來測試看看了!

讓我們在 ProductsViewController 中加入下列程式碼:

override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        _ = ProductsAPIServices.shared.fetchProducts().done { (products) in
            PKCLogger.shared.debug(products)
        }
    }

然後,執行並查看控制台(別忘了先到登入頁登入):

成功了!我們加入了 token 驗證機制,並且成功取得資料。

最後,讓我們在 ProductsViewController 完善 UI

import UIKit
import PKCHelper

class ProductsViewController: UITableViewController {

    private let cellId = "cellId"
    lazy var products: [Products] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.

        tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellId)
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        _ = ProductsAPIServices.shared.fetchProducts().done { (products) in
            PKCLogger.shared.debug(products)
            self.products = products
            }.catch({ (_) in
                // 錯誤處理
            }).finally {
                DispatchQueue.main.async {
                    self.tableView.reloadData()
                }
        }
    }

    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return products.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: cellId, for: indexPath)

        cell.textLabel?.text = products[indexPath.row].name

        return cell
    }
}

讓我們來看看成果吧:

restful-api-demo

總結

在本篇教學中,我們透過抽離 API Services 層,來分化我們與後端串接的耦合性。實戰中還會有一層 Services 負責 View Model 的轉換,我們並不會直接使用後端來的 model,而是透過自定義錯誤處理,分離商業邏輯錯誤與系統錯誤。而且,我們也引入了 Promise 來幫助我們細分流程與非同步編程。這樣,不但大大優化了程式碼的易讀性,也單一職責化了各個服務,可以說是大規模的 App 中不可或缺的一部分。

恭喜!至此整個文章與概念已完全交付!強烈建議整個過程搭配源碼消化服用,祝你有個美好的 Coding 夜晚,我們下次見。

yasuoyuhao,自認為終身學習者,對多領域都有濃厚興趣,喜歡探討各種事物。目前專職軟體開發,系統架構設計,企業解決方案。最喜歡 iOS with Swift。

此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。

blog comments powered by Disqus
Shares
Share This