如何隔离 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 使用和数据持久性