作者:Tomasz Szulc,原文链接,原文日期:2016-07-30
译者:智多芯;校对:Crystal Sun;定稿:CMB
同时负责两个项目是个探索应用架构的好机会,可以在项目中试验一下已有的想法或刚学到的知识。我最近学习了如何封装一个网络层框架,说不定对你有所帮助。
如今的移动应用几乎都是“客户端-服务端(client-server)”架构,在应用里都会有网络层,大小不同而已。我见过很多种实现方式,但都有一些缺陷。当然这并不是说,我最近实现的这个一点缺陷也没有,但至少在目前的两个项目上都运行的很不错。测试覆盖率也将近百分百。
本文涉及的网络层仅限发送 JSON 请求给后端,也不会太复杂。该网络层会和亚马逊 AWS 通信,然后向它发送一些文件。这个网络层框架能容易地扩展其他功能。
思考过程
以下是我在开始写一个网络层之前会问自己的一些问题:
- 后端 URL 相关的代码放在哪?
- 端点(endpoint)相关的代码放在哪?
- 构建请求的代码放在哪?
- 为请求准备参数的代码放在哪?
- 应该把认证令牌(authentication token)保存在哪?
- 如何执行请求?
- 何时何处执行请求?
- 是否需要考虑取消请求?
- 是否需要考虑错误的后端响应,是否需要考虑一些后端的 bug?
- 是否需要使用第三方库?应该使用哪些库?
- 是否有任何 Core Data 相关的东西进行传递?
- 如何测试解决方案。
保存后端URL
首先,后端 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 = 200..<299
private var failureCodes: Range = 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
都有什么好处呢?
- 方便取消所有的网络请求。
- 为了给用户更好的体验,当网络不好的时候,取消所有正在下载图像或请求非必需数据的操作。
- 可以构建一个优先级队列用于提前执行一些请求,以便更快地得到结果。
和Core Data共处
这是我不得不推迟发表这篇文章的原因。在之前的几个网络层版本中,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 {
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, 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 {
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。