如何隔離 iOS 應用程序中的客戶端-服務器交互邏輯

已發表: 2022-03-11

如今,大多數移動應用程序嚴重依賴客戶端-服務器交互。 這不僅意味著他們可以將大部分繁重的任務卸載到後端服務器,而且還允許這些移動應用程序提供只能通過互聯網提供的各種特性和功能。

後端服務器通常設計為通過 RESTful API 提供服務。 對於更簡單的應用程序,我們常常想通過創建意大利麵條代碼來獲得; 將調用 API 的代碼與應用程序邏輯的其餘部分混合在一起。 然而,隨著應用程序變得複雜並處理越來越多的 API,以非結構化、無計劃的方式與這些 API 交互可能會變得很麻煩。

使用精心設計的 REST 客戶端網絡模塊,讓您的 iOS 應用程序代碼保持整潔。

使用精心設計的 REST 客戶端網絡模塊,讓您的 iOS 應用程序代碼保持整潔。
鳴叫

本文討論了一種為 iOS 應用程序構建乾淨的 REST 客戶端網絡模塊的架構方法,它允許您將所有客戶端-服務器交互邏輯與應用程序代碼的其餘部分隔離開來。

客戶端-服務器應用程序

典型的客戶端-服務器交互如下所示:

  1. 用戶執行一些動作(例如,點擊某個按鈕或在屏幕上執行一些其他手勢)。
  2. 應用程序準備並發送 HTTP/REST 請求以響應用戶操作。
  3. 服務器處理請求並相應地響應應用程序。
  4. 應用程序接收響應並根據它更新用戶界面。

乍一看,整個過程可能看起來很簡單,但我們確實必須考慮細節。

即使假設後端服務器 API 像宣傳的那樣工作(並非總是如此!),它通常也可能設計不佳,使其效率低下,甚至難以使用。 一個常見的煩惱是對 API 的所有調用都要求調用者冗餘地提供相同的信息(例如,請求數據的格式、服務器可以用來識別當前登錄用戶的訪問令牌,等等)。

移動應用程序可能還需要同時使用多個後端服務器來實現不同的目的。 例如,一個服務器可能專用於用戶身份驗證,而另一台服務器僅處理收集分析。

此外,典型的 REST 客戶端需要做的不僅僅是調用遠程 API。 取消未決請求的能力,或處理錯誤的干淨和可管理的方法,是需要內置到任何強大的移動應用程序中的功能示例。

架構概述

我們 REST 客戶端的核心將建立在以下組件之上:

  • 模型:描述我們應用程序的數據模型的類,反映從後端服務器接收或發送到後端服務器的數據結構。
  • 解析器:負責解碼服務器響應並生成模型對象。
  • 錯誤:表示錯誤服務器響應的對象。
  • 客戶端:向後端服務器發送請求並接收響應。
  • 服務:管理邏輯關聯的操作(例如身份驗證、管理用戶相關數據、分析等)。

這就是這些組件中的每一個如何相互交互:

上圖中的箭頭 1 到 10 顯示了應用程序調用服務和服務最終將請求的數據作為模型對象返回之間的理想操作順序。 該流程中的每個組件都有一個特定的角色,以確保模塊內的關注點分離。

執行

我們將實現我們的 REST 客戶端作為我們想像的社交網絡應用程序的一部分,我們將在其中加載當前登錄用戶的朋友列表。 我們將假設我們的遠程服務器使用 JSON 作為響應。

讓我們從實現我們的模型和解析器開始。

從原始 JSON 到模型對象

我們的第一個模型User定義了社交網絡的任何用戶的信息結構。 為簡單起見,我們將僅包含本教程絕對必要的字段(在實際應用程序中,該結構通常具有更多屬性)。

 struct User { var id: String var email: String? var name: String? }

由於我們將通過其 API 從後端服務器接收所有用戶數據,因此我們需要一種將 API 響應解析為有效User對象的方法。 為此,我們將向User添加一個構造函數,該構造函數接受已解析的 JSON 對象 ( Dictionary ) 作為參數。 我們將我們的 JSON 對象定義為別名類型:

 typealias JSON = [String: Any]

然後我們將構造函數添加到我們的User結構中,如下所示:

 extension User { init?(json: JSON) { guard let id = json["id"] as? String else { return nil } self.id = id self.email = json["email"] as? String self.name = json["name"] as? String } }

為了保留User的原始默認構造函數,我們通過對User類型的擴展添加構造函數。

接下來,要從原始 API 響應創建User對象,我們需要執行以下兩個步驟:

 // Transform raw JSON data to parsed JSON object using JSONSerializer (part of standard library) let userObject = (try? JSONSerialization.jsonObject(with: data, options: [])) as? JSON // Create an instance of `User` structure from parsed JSON object let user = userObject.flatMap(User.init)

簡化的錯誤處理

我們將定義一個類型來表示嘗試與後端服務器交互時可能發生的不同錯誤。 我們可以將所有此類錯誤分為三個基本類別:

  • 沒有互聯網連接
  • 作為響應的一部分報告的錯誤(例如驗證錯誤、訪問權限不足等)
  • 服務器未能在響應中報告的錯誤(例如服務器崩潰、響應超時等)

我們可以將錯誤對象定義為枚舉類型。 當我們這樣做的時候,讓我們的ServiceError類型符合Error協議是一個好主意。 這將允許我們使用 Swift 提供的標準機制來使用和處理這些錯誤值(例如使用throw來拋出錯誤)。

 enum ServiceError: Error { case noInternetConnection case custom(String) case other }

noInternetConnectionother錯誤不同,自定義錯誤具有與之關聯的值。 這將允許我們使用來自服務器的錯誤響應作為錯誤本身的關聯值,從而為錯誤提供更多上下文。

現在,讓我們向ServiceError枚舉添加一個errorDescription屬性,以使錯誤更具描述性。 我們將為noInternetConnectionother錯誤添加硬編碼消息,並將關聯的值用作custom錯誤的消息。

 extension ServiceError: LocalizedError { var errorDescription: String? { switch self { case .noInternetConnection: return "No Internet connection" case .other: return "Something went wrong" case .custom(let message): return message } } }

我們還需要在ServiceError枚舉中實現一件事。 在custom錯誤的情況下,我們需要將服務器 JSON 數據轉換為錯誤對象。 為此,我們使用與模型相同的方法:

 extension ServiceError { init(json: JSON) { if let message = json["message"] as? String { self = .custom(message) } else { self = .other } } }

彌合應用程序和後端服務器之間的差距

客戶端組件將是應用程序和後端服務器之間的中介。 它是定義應用程序和服務器如何通信的關鍵組件,但它對數據模型及其結構一無所知。 客戶端將負責使用提供的參數調用特定的 URL,並返回解析為 JSON 對象的傳入 JSON 數據。

 enum RequestMethod: String { case get = "GET" case post = "POST" case put = "PUT" case delete = "DELETE" } final class WebClient { private var baseUrl: String init(baseUrl: String) { self.baseUrl = baseUrl } func load(path: String, method: RequestMethod, params: JSON, completion: @escaping (Any?, ServiceError?) -> ()) -> URLSessionDataTask? { // TODO: Add implementation } }

讓我們檢查一下上面的代碼中發生了什麼……

首先,我們聲明了一個枚舉類型RequestMethod ,它描述了四種常見的 HTTP 方法。 這些是 REST API 中使用的方法之一。

WebClient類包含baseURL屬性,該屬性將用於解析它接收到的所有相對 URL。 如果我們的應用程序需要與多個服務器交互,我們可以創建多個WebClient實例,每個實例具有不同的baseURL值。

Client 有一個單獨的方法load ,它將相對於baseURL的路徑作為參數、請求方法、請求參數和完成閉包。 使用解析的 JSON 和ServiceError作為參數調用完成閉包。 目前,上面的方法缺少一個實現,我們很快就會講到。

在實現load方法之前,我們需要一種從方法可用的所有信息創建URL的方法。 為此,我們將擴展URL類:

 extension URL { init(baseUrl: String, path: String, params: JSON, method: RequestMethod) { var components = URLComponents(string: baseUrl)! components.path += path switch method { case .get, .delete: components.queryItems = params.map { URLQueryItem(name: $0.key, value: String(describing: $0.value)) } default: break } self = components.url! } }

在這裡,我們只需將路徑添加到基本 URL。 對於 GET 和 DELETE HTTP 方法,我們還將查詢參數添加到 URL 字符串。

接下來,我們需要能夠從給定的參數創建URLRequest的實例。 為此,我們將做一些類似於我們為URL所做的事情:

 extension URLRequest { init(baseUrl: String, path: String, method: RequestMethod, params: JSON) { let url = URL(baseUrl: baseUrl, path: path, params: params, method: method) self.init(url: url) httpMethod = method.rawValue setValue("application/json", forHTTPHeaderField: "Accept") setValue("application/json", forHTTPHeaderField: "Content-Type") switch method { case .post, .put: httpBody = try! JSONSerialization.data(withJSONObject: params, options: []) default: break } } }

在這裡,我們首先使用擴展中的構造函數創建一個URL 。 然後我們用這個URL初始化一個URLRequest的實例,根據需要設置一些 HTTP 頭,然後在 POST 或 PUT HTTP 方法的情況下,將參數添加到請求正文中。

現在我們已經涵蓋了所有先決條件,我們可以實現load方法:

 final class WebClient { private var baseUrl: String init(baseUrl: String) { self.baseUrl = baseUrl } func load(path: String, method: RequestMethod, params: JSON, completion: @escaping (Any?, ServiceError?) -> ()) -> URLSessionDataTask? { // Checking internet connection availability if !Reachability.isConnectedToNetwork() { completion(nil, ServiceError.noInternetConnection) return nil } // Adding common parameters var parameters = params if let token = KeychainWrapper.itemForKey("application_token") { parameters["token"] = token } // Creating the URLRequest object let request = URLRequest(baseUrl: baseUrl, path: path, method: method, params: params) // Sending request to the server. let task = URLSession.shared.dataTask(with: request) { data, response, error in // Parsing incoming data var object: Any? = nil if let data = data { object = try? JSONSerialization.jsonObject(with: data, options: []) } if let httpResponse = response as? HTTPURLResponse, (200..<300) ~= httpResponse.statusCode { completion(object, nil) } else { let error = (object as? JSON).flatMap(ServiceError.init) ?? ServiceError.other completion(nil, error) } } task.resume() return task } }

上面的load方法執行以下步驟:

  1. 檢查 Internet 連接的可用性。 如果 Internet 連接不可用,我們會立即調用完成閉包, noInternetConnection錯誤作為參數。 (注意:代碼中的Reachability是一個自定義類,它使用一種常用的方法來檢查 Internet 連接。)
  2. 添加常用參數。 . 這可以包括通用參數,例如應用程序令牌或用戶 ID。
  3. 使用擴展中的構造函數創建URLRequest對象
  4. 將請求發送到服務器。 我們使用URLSession對象將數據發送到服務器。
  5. 解析傳入的數據。 當服務器響應時,我們首先使用JSONSerialization將響應負載解析為 JSON 對象。 然後我們檢查響應的狀態碼。 如果是成功碼(即在 200 到 299 之間),我們用 JSON 對象調用完成閉包。 否則,我們將 JSON 對象轉換為ServiceError對象並使用該錯誤對象調用完成閉包。

為邏輯鏈接的操作定義服務

在我們的應用程序中,我們需要一個服務來處理與用戶的朋友相關的任務。 為此,我們創建了一個FriendsService類。 理想情況下,這樣的類將負責諸如獲取朋友列表、添加新朋友、刪除朋友、將一些朋友分組到一個類別等操作。在本教程中,為了簡單起見,我們將只實現一個方法:

 final class FriendsService { private let client = WebClient(baseUrl: "https://your_server_host/api/v1") @discardableResult func loadFriends(forUser user: User, completion: @escaping ([User]?, ServiceError?) -> ()) -> URLSessionDataTask? { let params: JSON = ["user_id": user.id] return client.load(path: "/friends", method: .get, params: params) { result, error in let dictionaries = result as? [JSON] completion(dictionaries?.flatMap(User.init), error) } } }

FriendsService類包含WebClient類型的client屬性。 它使用負責管理朋友的遠程服務器的基本 URL 進行初始化。 如前所述,在其他服務類中,如果需要,我們可以使用不同的 URL 初始化WebClient的不同實例。

對於只使用一台服務器的應用程序,可以為WebClient類提供一個構造函數,該構造函數使用該服務器的 URL 進行初始化:

 final class WebClient { // ... init() { self.baseUrl = "https://your_server_base_url" } // ... }

loadFriends方法在調用時會準備所有必要的參數並使用FriendServiceWebClient實例來發出 API 請求。 在通過WebClient接收到來自服務器的響應後,它將 JSON 對象轉換為User模型,並以它們作為參數調用完成閉包。

FriendService的典型用法可能如下所示:

 let friendsTask: URLSessionDataTask! let activityIndicator: UIActivityIndicatorView! var friends: [User] = [] func friendsButtonTapped() { friendsTask?.cancel() //Cancel previous loading task. activityIndicator.startAnimating() //Show loading indicator friendsTask = FriendsService().loadFriends(forUser: currentUser) {[weak self] friends, error in DispatchQueue.main.async { self?.activityIndicator.stopAnimating() //Stop loading indicators if let error = error { print(error.localizedDescription) //Handle service error } else if let friends = friends { self?.friends = friends //Update friends property self?.updateUI() //Update user interface } } } }

在上面的示例中,我們假設每當用戶點擊一個旨在向他們顯示他們在網絡中的朋友列表的按鈕時,都會調用函數friendsButtonTapped 。 我們還在friendsTask屬性中保留對任務的引用,以便我們可以隨時通過調用friendsTask?.cancel()取消請求。

這使我們能夠更好地控制待處理請求的生命週期,使我們能夠在確定它們變得不相關時終止它們。

結論

在本文中,我為您的 iOS 應用程序分享了一個簡單的網絡模塊架構,它既易於實現,又可以適應大多數 iOS 應用程序的複雜網絡需求。 然而,關鍵的一點是,一個設計合理的 REST 客戶端及其附帶的組件——與應用程序邏輯的其餘部分隔離——可以幫助保持應用程序的客戶端-服務器交互代碼簡單,即使應用程序本身變得越來越複雜.

我希望本文對您構建下一個 iOS 應用程序有所幫助。 你可以在 GitHub 上找到這個網絡模塊的源代碼。 檢查代碼,分叉它,改變它,玩它。

如果您發現其他架構更適合您和您的項目,請在下面的評論部分分享詳細信息。

相關:使用 Mantle 和 Realm 在 iOS 上簡化 RESTful API 使用和數據持久性