Hi,SwiftGG 翻译组启用了新的域名:swiftgg.team今后翻译组的各项活动将会在新域名下开展,不要错过哦!

作者: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 可以很好地实现需求,就没必要使用第三方库了。在我看来,这些依赖会导致应用架构越来越复杂。

目前的解决方案由两个类组成:NetworkServiceBackendService

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 中。

BackendServiceBackendAPIRequest 作为 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)
}
}

NetworkServiceBackendServiceBackendAuth 三者都可以很容易地测试和维护。

将请求入队

这里涉及了几个问题。我们希望通过什么方式执行网络请求?当想要一次执行多次请求呢?一般情况下,当请求成功或失败时,希望以什么方式通知我们?

我使用了 NSOperationQueueNSOperation 来执行网络请求。在继承 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 方法中执行它。handleSuccesshandleFailure 两个方法作为回调传递给了服务的 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<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协议为具体的映射器定义了用于解析响应的方法。

接着,这样的映射器就可以用在 operationsuccess 代码块中了。而且可以直接操作指定类型的具体对象,而不是字典。这样一切都可以很容易地进行测试了。

下面是解析数组的映射器:

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 静态方法的参数分别是 objmapper 方法,成功解析之后会返回一个数组。如果有某一项解析失败,可以抛出一个错误,或者更糟地直接返回一个空数组作为该映射器的结果,你来决定。另外,这个映射器希望传给它的 obj 参数(从后端返回的响应数据)是个 JSON 数组。

下面是整个网络层的 UML 图:

diagram

示例项目

可以在GitHub上找的示例项目。该项目中用到了伪造的后端 URL,所以任何请求都不会有响应。提供这个示例只是想让你对这个网络层的结构有个大致的认识。

总结

我发现用这种方法封装的网络层不仅简单而且很有用:

  • 最大的优点在于,可以很容易地新增类似上文提到的 Operation,而不用关心 Core Data 的存在。
  • 可以轻易地让代码覆盖率接近100%,而无需考虑如何覆盖某个难搞的情形,因为根本就不存在这么难搞的情形!
  • 可以在其他类似的复杂应用中很容易地复用它的核心代码。

    本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

文章目录
  1. 1. 思考过程
  2. 2. 保存后端URL
  3. 3. 端点
  4. 4. 执行请求
  5. 5. 将请求入队
  6. 6. 和Core Data共处
  7. 7. 映射响应
  8. 8. 示例项目
  9. 9. 总结