做者:Tomasz Szulc,原文连接,原文日期:2016-07-30
译者:智多芯;校对:Crystal Sun;定稿:CMBgit
同时负责两个项目是个探索应用架构的好机会,能够在项目中试验一下已有的想法或刚学到的知识。我最近学习了如何封装一个网络层框架,说不定对你有所帮助。github
现在的移动应用几乎都是“客户端-服务端(client-server)”架构,在应用里都会有网络层,大小不一样而已。我见过不少种实现方式,但都有一些缺陷。固然这并非说,我最近实现的这个一点缺陷也没有,但至少在目前的两个项目上都运行的很不错。测试覆盖率也将近百分百。json
本文涉及的网络层仅限发送 JSON 请求给后端,也不会太复杂。该网络层会和亚马逊 AWS 通讯,而后向它发送一些文件。这个网络层框架能容易地扩展其余功能。swift
如下是我在开始写一个网络层以前会问本身的一些问题:后端
后端 URL 相关的代码放在哪?数组
端点(endpoint)相关的代码放在哪?网络
构建请求的代码放在哪?session
为请求准备参数的代码放在哪?架构
应该把认证令牌(authentication token)保存在哪?app
如何执行请求?
什么时候何处执行请求?
是否须要考虑取消请求?
是否须要考虑错误的后端响应,是否须要考虑一些后端的 bug?
是否须要使用第三方库?应该使用哪些库?
是否有任何 Core Data 相关的东西进行传递?
如何测试解决方案。
首先,后端 URL 相关的代码放在哪?系统的其余部分代码如何知道在哪里发送请求?我倾向于建立一个 BackendConfiguration
类用来保存这些信息。
import Foundation public final class BackendConfiguration { let baseURL: NSURL public init(baseURL: NSURL) { self.baseURL = baseURL } public static var shared: BackendConfiguration! }
这样易于测试,也易于配置。能够在网络层的任何地方读写静态变量 shared
,而没必要处处传递。
let backendURL = NSURL(string: "https://szulctomasz.com")! BackendConfiguration.shared = BackendConfiguration(baseURL: backendURL)
在找到一个行得通的办法以前,我尝试过配置 NSURLSession
时在代码中硬编码端点。也尝试过新建一个管理端点的虚拟对象,它可以容易地被初始化和注入。不过这些都不是想要的方案。
接着我想到一个办法,建立一个 Request
对象,这个对象知道向哪一个端点发送请求,知道该用 GET、POST、PUT 仍是其余方法,也知道如何配置请求的消息体和头部。
如下代码就是想到的方案:
protocol BackendAPIRequest { var endpoint: String { get } var method: NetworkService.Method { get } var parameters: [String: AnyObject]? { get } var headers: [String: String]? { get } }
一个遵循了该协议的类可以提供必要的构建请求的基本信息。其中的 NetworkService.Method
只是一个枚举,包含了 GET
, POST
, PUT
, DELETE
几种方法。
用下面这段代码举例说明映射了某个端点的请求:
final class SignUpRequest: BackendAPIRequest { private let firstName: String private let lastName: String private let email: String private let password: String init(firstName: String, lastName: String, email: String, password: String) { self.firstName = firstName self.lastName = lastName self.email = email self.password = password } var endpoint: String { return "/users" } var method: NetworkService.Method { return .POST } var parameters: [String: AnyObject]? { return [ "first_name": firstName, "last_name": lastName, "email": email, "password": password ] } var headers: [String: String]? { return ["Content-Type": "application/json"] } }
为了不老是为 headers
建立字典,能够为 BackendAPIRequest
定义一个 extension
。
extension BackendAPIRequest { func defaultJSONHeaders() -> [String: String] { return ["Content-Type": "application/json"] } }
Request
类利用全部必需的参数建立一个可用的请求。要保证把全部必需的参数都传给了 Request
类,不然无法建立请求。
定义端点就很简单了。若是端点须要包含一个对象 id,添加也很是简单,由于实际上只要把这个 id 做为属性保存在 SignUpRequest
类中就能够了:
private let id: String init(id: String, ...) { self.id = id } var endpoint: String { return "/users/\(id)" }
请求方法不变、参数易于构建和维护,头部也同样,这样就很容易对它们进行测试了。
是否须要使用第三方库和后端通讯?
有不少人都在用 AFNetworking(Objective-C) 和 Alamofire(Swift)。我也用过不少次,但有时候我就不使用它们了。毕竟有 NSURLSession
能够很好地实现需求,就不必使用第三方库了。在我看来,这些依赖会致使应用架构愈来愈复杂。
目前的解决方案由两个类组成:NetworkService
和 BackendService
。
NetworkService
:能够执行HTTP请求,它内部集成了 NSURLSession
。每一个网络服务一次只能执行一个请求,也可以取消请求(很大的优点),并且请求成功和失败时都会有回调。
BackendService
:(不是一个很酷的名字,但恰到好处)用来将请求(就是上面提到的 Request
类)发送给后端。在内部使用了 NetworkService
。在当前使用的版本中,尝试用 NSJSONSerializer
将后端返回的响应数据序列化成 JSON 格式的数据。
class NetworkService { private var task: NSURLSessionDataTask? private var successCodes: Range<Int> = 200..<299 private var failureCodes: Range<Int> = 400..<499 enum Method: String { case GET, POST, PUT, DELETE } func request(url url: NSURL, method: Method, params: [String: AnyObject]? = nil, headers: [String: String]? = nil, success: (NSData? -> Void)? = nil, failure: ((data: NSData?, error: NSError?, responseCode: Int) -> Void)? = nil) { let mutableRequest = NSMutableURLRequest(URL: url, cachePolicy: .ReloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 10.0) mutableRequest.allHTTPHeaderFields = headers mutableRequest.HTTPMethod = method.rawValue if let params = params { mutableRequest.HTTPBody = try! NSJSONSerialization.dataWithJSONObject(params, options: []) } let session = NSURLSession.sharedSession() task = session.dataTaskWithRequest(mutableRequest, completionHandler: { data, response, error in // 判断调用是否成功 // 回调处理 }) task?.resume() } func cancel() { task?.cancel() } } class BackendService { private let conf: BackendConfiguration private let service: NetworkService! init(_ conf: BackendConfiguration) { self.conf = conf self.service = NetworkService() } func request(request: BackendAPIRequest, success: (AnyObject? -> Void)? = nil, failure: (NSError -> Void)? = nil) { let url = conf.baseURL.URLByAppendingPathComponent(request.endpoint) var headers = request.headers // 必要时设置 authentication token headers?["X-Api-Auth-Token"] = BackendAuth.shared.token service.request(url: url, method: request.method, params: request.parameters, headers: headers, success: { data in var json: AnyObject? = nil if let data = data { json = try? NSJSONSerialization.JSONObjectWithData(data, options: []) } success?(json) }, failure: { data, error, statusCode in // 错误处理,并调用错误处理代码 }) } func cancel() { service.cancel() } }
BackendService
能够在 headers
中设置认证令牌(authentication token)。其中 BackendAuth
只是个简单的对象,用来将令牌保存到 UserDefaults
中。在必要的时候,也能够将令牌保存在 Keychain
中。
BackendService
将 BackendAPIRequest
做为 request(_:success:failure:)
方法的参数从 request
对象中提取出必要的信息,这保持了很好的封装性。
public final class BackendAuth { private let key = "BackendAuthToken" private let defaults: NSUserDefaults public static var shared: BackendAuth! public init(defaults: NSUserDefaults) { self.defaults = defaults } public func setToken(token: String) { defaults.setValue(token, forKey: key) } public var token: String? { return defaults.valueForKey(key) as? String } public func deleteToken() { defaults.removeObjectForKey(key) } }
NetworkService
,BackendService
和 BackendAuth
三者均可以很容易地测试和维护。
这里涉及了几个问题。咱们但愿经过什么方式执行网络请求?当想要一次执行屡次请求呢?通常状况下,当请求成功或失败时,但愿以什么方式通知咱们?
我使用了 NSOperationQueue
和 NSOperation
来执行网络请求。在继承 NSOperation
以后,重写它的 asynchronous
属性并返回 true
。
public class NetworkOperation: NSOperation { private var _ready: Bool public override var ready: Bool { get { return _ready } set { update({ self._ready = newValue }, key: "isReady") } } private var _executing: Bool public override var executing: Bool { get { return _executing } set { update({ self._executing = newValue }, key: "isExecuting") } } private var _finished: Bool public override var finished: Bool { get { return _finished } set { update({ self._finished = newValue }, key: "isFinished") } } private var _cancelled: Bool public override var cancelled: Bool { get { return _cancelled } set { update({ self._cancelled = newValue }, key: "isCancelled") } } private func update(change: Void -> Void, key: String) { willChangeValueForKey(key) change() didChangeValueForKey(key) } override init() { _ready = true _executing = false _finished = false _cancelled = false super.init() name = "Network Operation" } public override var asynchronous: Bool { return true } public override func start() { if self.executing == false { self.ready = false self.executing = true self.finished = false self.cancelled = false } } /// 只用于子类,外部调用时应使用 `cancel`. func finish() { self.executing = false self.finished = true } public override func cancel() { self.executing = false self.cancelled = true } }
接着,由于想经过 BackendService
执行网络调用,因此继承了 NetworkOperation
,并建立了 ServiceOperation
。
public class ServiceOperation: NetworkOperation { let service: BackendService public override init() { self.service = BackendService(BackendConfiguration.shared) super.init() } public override func cancel() { service.cancel() super.cancel() } }
这个类已经在它内部建立了 BackendService
,因此就不必每次都在子类中建立一次。
下面是 SignInOperation
的代码:
public class SignInOperation: ServiceOperation { private let request: SignInRequest public var success: (SignInItem -> Void)? public var failure: (NSError -> Void)? public init(email: String, password: String) { request = SignInRequest(email: email, password: password) super.init() } public override func start() { super.start() service.request(request, success: handleSuccess, failure: handleFailure) } private func handleSuccess(response: AnyObject?) { do { let item = try SignInResponseMapper.process(response) self.success?(item) self.finish() } catch { handleFailure(NSError.cannotParseResponse()) } } private func handleFailure(error: NSError) { self.failure?(error) self.finish() } }
在 SignInOperation
初始化时建立了登陆请求,随后在 start
方法中执行它。handleSuccess
和 handleFailure
两个方法做为回调传递给了服务的 request(_:success:failure:)
方法。我以为这让代码看起来更干净,可读性更强。
将 Operations
传给 NetworkQueue
对象。NetworkQueue
对象是一个单例,能够将每一个 Operation
入队。暂时尽可能让代码保持简洁吧:
public class NetworkQueue { public static var shared: NetworkQueue! let queue = NSOperationQueue() public init() {} public func addOperation(op: NSOperation) { queue.addOperation(op) } }
那么,在同一个地方执行Operation
都有什么好处呢?
方便取消全部的网络请求。
为了给用户更好的体验,当网络很差的时候,取消全部正在下载图像或请求非必需数据的操做。
能够构建一个优先级队列用于提早执行一些请求,以便更快地获得结果。
这是我不得不推迟发表这篇文章的缘由。在以前的几个网络层版本中,Operation
都会返回 Core Data 对象。接收到的响应会被解析并转换成 Core Data 对象。但是这种方案远远不够完美。
SignInOperation
须要知道 Core Data 是个什么东西。因为我把数据模型独立出来了,所以网络库也须要知晓数据模型。
每一个 SignInOperation
都须要增长一个额外的 NSManagedObjectContext
参数,用来决定在什么上下文执行操做。
每次接收到响应并准备调用 success
的代码以前,都会在 Core Data 上下文中查找对象,而后访问磁盘并将其提取出来。我以为这是个不足的地方,并非每次都想建立 Core Data 对象。
因此我想到应该把 Core Data 完彻底全地从网络层中分离出去。因而建立了一个中间层,其实也就是一些在解析响应时建立的对象。
这样一来,解析和建立对象就很快了,并且不用访问磁盘。
再也不须要将 NSManagedObjectContext
传给 SignInOperation
了。
能够在 success
代码块中使用解析过的数据来更新 Core Data 对象,而后引用以前可能保存在某处的 Core Data 对象——这是我在将 SignInOperation
入队时会碰到的状况。
响应映射器的思想主要是将解析逻辑和 JSON 映射逻辑分红多个有用的单项。
能够两种不一样的解析器区分开来,第一种只解析一个特定类型的对象,第二种用来解析对象数组。
首先定义一个通用协议:
public protocol ParsedItem {}
下面是映射器的映射结果:
public struct SignInItem: ParsedItem { public let token: String public let uniqueId: String } public struct UserItem: ParsedItem { public let uniqueId: String public let firstName: String public let lastName: String public let email: String public let phoneNumber: String? }
再定义一个错误类型,以便在解析发生错误时抛出。
internal enum ResponseMapperError: ErrorType { case Invalid case MissingAttribute }
Invalid
:当解析到的 JSON 为 nil 且不应为 nil,或者是一个对象数组而不是指望的只含单个对象的 JSON 时抛出。
MissingAttribute
:名字自己就能说明它的做用了。当 key 在 JSON 中不存在,或者解析后值为 nil 且不应为 nil 时抛出。
ResponseMapper
的实现以下:
class ResponseMapper<A: ParsedItem> { static func process(obj: AnyObject?, parse: (json: [String: AnyObject]) -> A?) throws -> A { guard let json = obj as? [String: AnyObject] else { throw ResponseMapperError.Invalid } if let item = parse(json: json) { return item } else { L.log("Mapper failure (\(self)). Missing attribute.") throw ResponseMapperError.MissingAttribute } } }
其中 process
静态方法的参数分别是 obj
(也就是从后端返回的JSON)和 parse
方法(该方法会解析 obj
并返回一个 ParsedItem
类型的 A
对象)。
既然有了这个通用的映射器,接着就能够建立具体的映射器了。先来看看用于解析 SignInOperation
响应的映射器:
protocol ResponseMapperProtocol { associatedtype Item static func process(obj: AnyObject?) throws -> Item } final class SignInResponseMapper: ResponseMapper<SignInItem>, ResponseMapperProtocol { static func process(obj: AnyObject?) throws -> SignInItem { return try process(obj, parse: { json in let token = json["token"] as? String let uniqueId = json["unique_id"] as? String if let token = token, let uniqueId = uniqueId { return SignInItem(token: token, uniqueId: uniqueId) } return nil }) } }
ResponseMapperProtocol
协议为具体的映射器定义了用于解析响应的方法。
接着,这样的映射器就能够用在 operation
的 success
代码块中了。并且能够直接操做指定类型的具体对象,而不是字典。这样一切均可以很容易地进行测试了。
下面是解析数组的映射器:
final class ArrayResponseMapper<A: ParsedItem> { static func process(obj: AnyObject?, mapper: (AnyObject? throws -> A)) throws -> [A] { guard let json = obj as? [[String: AnyObject]] else { throw ResponseMapperError.Invalid } var items = [A]() for jsonNode in json { let item = try mapper(jsonNode) items.append(item) } return items } }
其中 process
静态方法的参数分别是 obj
和 mapper
方法,成功解析以后会返回一个数组。若是有某一项解析失败,能够抛出一个错误,或者更糟地直接返回一个空数组做为该映射器的结果,你来决定。另外,这个映射器但愿传给它的 obj
参数(从后端返回的响应数据)是个 JSON 数组。
下面是整个网络层的 UML 图:
能够在GitHub上找的示例项目。该项目中用到了伪造的后端 URL,因此任何请求都不会有响应。提供这个示例只是想让你对这个网络层的结构有个大体的认识。
我发现用这种方法封装的网络层不只简单并且颇有用:
最大的优势在于,能够很容易地新增相似上文提到的 Operation
,而不用关心 Core Data 的存在。
能够轻易地让代码覆盖率接近100%,而无需考虑如何覆盖某个难搞的情形,由于根本就不存在这么难搞的情形!
能够在其余相似的复杂应用中很容易地复用它的核心代码。
本文由 SwiftGG 翻译组翻译,已经得到做者翻译受权,最新文章请访问 http://swift.gg。