本文概述
如今, 大多数移动应用程序严重依赖于客户端-服务器交互。这不仅意味着他们可以将大部分繁重的任务转移到后端服务器, 而且还允许这些移动应用程序提供只能通过Internet获得的各种功能。
后端服务器通常设计为通过RESTful API提供服务。对于更简单的应用程序, 我们经常会想通过创建意大利面条代码来获取;将调用API的代码与其余应用程序逻辑混合在一起。但是, 随着应用程序变得越来越复杂并处理越来越多的API, 以非结构化, 无计划的方式与这些API进行交互会变得很麻烦。
精心设计的REST客户端网络模块可确保你的iOS应用程序代码整洁。
鸣叫
本文讨论了一种用于为iOS应用程序构建干净的REST客户端网络模块的体系结构方法, 该模块使你可以将所有客户端-服务器交互逻辑与其余应用程序代码隔离。
客户端服务器应用程序
典型的客户端-服务器交互如下所示:
- 用户执行某些动作(例如, 点击某个按钮或在屏幕上执行其他手势)。
- 应用程序准备并发送HTTP / REST请求以响应用户操作。
- 服务器处理请求, 并相应地响应应用程序。
- 应用程序接收响应并基于响应更新用户界面。
乍一看, 整个过程可能看起来很简单, 但是我们必须考虑细节。
即使假设后端服务器API可以像宣传的那样工作(这并非总是如此!), 但它的设计往往很糟糕, 使其效率低下甚至难以使用。一种常见的烦恼是, 对API的所有调用都要求调用者冗余地提供相同的信息(例如, 请求数据的格式, 服务器可以用来标识当前已登录用户的访问令牌, 等等)。
移动应用程序可能还需要同时出于不同目的利用多个后端服务器。例如, 一台服务器可能专用于用户身份验证, 而另一台仅处理收集分析数据。
此外, 典型的REST客户端将需要做的事情不仅仅是调用远程API。取消待决请求的能力或一种处理错误的干净且易于管理的方法是任何强大的移动应用程序都必须内置的功能的示例。
体系结构概述
REST客户端的核心将基于以下组件:
- 模型:描述我们应用程序数据模型的类, 反映了从后端服务器接收或发送到后端服务器的数据的结构。
- 解析器:负责解码服务器响应并生成模型对象。
- 错误:表示错误服务器响应的对象。
- 客户端:向后端服务器发送请求并接收响应。
- 服务:管理逻辑链接的操作(例如, 身份验证, 管理与用户相关的数据, 分析等)。
这是这些组件中的每个组件相互交互的方式:
上图中的箭头1到10显示了在调用服务的应用程序与最终返回所请求的数据作为模型对象的应用程序之间的理想操作顺序。该流程中的每个组件都具有特定的作用, 以确保模块内关注点的分离。
实作
我们将REST客户端实现为我们虚构的社交网络应用程序的一部分, 并将其中加载当前登录用户的朋友列表。我们将假定远程服务器使用JSON进行响应。
让我们从实现模型和解析器开始。
从原始JSON到模型对象
我们的第一个模型”用户”定义了社交网络中任何用户的信息结构。为简单起见, 我们仅包含本教程绝对必需的字段(在实际应用程序中, 结构通常会具有更多属性)。
struct User {
var id: String
var email: String?
var name: String?
}
由于我们将通过后端服务器的API从后端服务器接收所有用户数据, 因此我们需要一种将API响应解析为有效User对象的方法。为此, 我们将向用户添加一个构造函数, 该构造函数接受已解析的JSON对象(字典)作为参数。我们将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
}
与noInternetConnection和其他错误不同, 自定义错误具有与之关联的值。这将使我们能够使用来自服务器的错误响应作为错误本身的关联值, 从而为错误提供更多上下文。
现在, 让我们向ServiceError枚举添加一个errorDescription属性, 以使错误更具描述性。我们将为noInternetConnection和其他错误添加硬编码的消息, 并将关联的值用作自定义错误的消息。
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枚举中实现另一件事。如果发生自定义错误, 我们需要将服务器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值都不同。
客户端具有单个方法负载, 该负载采用相对于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方法执行以下步骤:
- 检查互联网连接的可用性。如果Internet连接不可用, 我们将立即以noInternetConnection错误作为参数调用完成关闭。 (注意:代码中的可到达性是一个自定义类, 它使用一种常用方法来检查Internet连接。)
- 添加公用参数。这可以包括公用参数, 例如应用程序令牌或用户ID。
- 使用扩展名中的构造函数创建URLRequest对象。
- 将请求发送到服务器。我们使用URLSession对象将数据发送到服务器。
- 解析传入的数据。当服务器响应时, 我们首先使用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类型的客户端属性。它使用负责管理朋友的远程服务器的基本URL初始化。如前所述, 在其他服务类中, 必要时我们可以使用不同的URL初始化不同的WebClient实例。
对于仅与一台服务器一起使用的应用程序, 可以为WebClient类提供一个构造函数, 该构造函数使用该服务器的URL进行初始化:
final class WebClient {
// ...
init() {
self.baseUrl = "https://your_server_base_url"
}
// ...
}
调用loadFriends方法时, 它会准备所有必要的参数, 并使用FriendService的WebClient实例进行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使用和数据持久性
评论前必须登录!
注册