原文:How do I build a Network Layer
作者:Tomasz Szulc(@tomkowz),资深 iOS 软件工程师
译者:孙薇,欢迎技术投稿、约稿、给文章纠错,请发送邮件至 mobilehub@csdn.net。
版权声明:本文为作者授权 CSDN 翻译,未经允许,请勿转载。
【导语】本文作者 Tomasz Szulc 曾同时带领着两个项目的研发工作,由此为他提供了一次很好的对于应用架构进行深度尝试的机会,本文即是他根据实践经验所总结的网络层构建方法,大家或许有兴趣一读。
如今的移动应用大多是“客户端-服务器”模式,某个应用中很可能就包含或大或小的网络层结构。迄今为止笔者见过的许多实现均有一些缺陷,最新构建的这个或许仍有缺陷,但在手边的这两个项目中效果都很不错,而且测试覆盖率几乎达到100%。本文只讨论与单个后台通讯、发送 JSON 编码请求的网络层,这个网络层会与 AWS 通讯,发送一些文件,整体结构并不复杂,不过相应功能的扩展也应当十分简单。
思维流程
在构建相应网络层之前,我先提出一些问题:
- 将包含有后台 URL 相关内容的代码放在哪里?
- 将包含端点相关的代码放在哪里?
- 将包含如何构建请求信息的代码放在哪里?
- 将与为请求准备参数的相关代码放在哪里?
- 应当将身份验证 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!
}
这种类易于测试,也易于配置,设定共享静态变量之后,我们就能从网络层的任意位置对其进行访问,不需将这个变量发送到其它位置。
let backendURL = NSURL(string: "https://szulctomasz.com")!
BackendConfiguration.shared = BackendConfiguration(baseURL: backendURL)
端点
在找到解决方案前,笔者在这个问题上做了颇有一阵子的实验,在配置 NSURLSession
时曾尝试对端点执行硬编码的方式,并尝试了一些了解端点、便于实例化与注入的虚拟资源类对象,但并未找到需要的方案。然后得出了设想:创建知道要接入哪个端点,使用什么方法,该是 GET
、POST
、PUT
还是其它什么的 *Request
对象,它要了解如何配置请求主体,以及要 pass 什么头文件。
于是我得出了这样的代码:
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
案例的enum
函数。
映射一个端点的请求示例如下:
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"]
}
}
为了避免给每个 header 创建 dictionary,我们可以为 BackendAPIRequest
定义扩展。
extension BackendAPIRequest {
func defaultJSONHeaders() -> [String: String] {
return ["Content-Type": "application/json"]
}
}
*Request
类利用所需参数成功创建了请求。我们要始终确保至少所需的参数都能 pass,否则就无法创建请求对象。定义端点非常简单,如果要将对象的id
包括在端点中,添加起来也是超级简单的,因为这些id在属性中有存储。
private let id: String
init(id: String, ...) {
self.id = id
}
var endpoint: String {
return "/users/\(id)"
}
请求的方法从未变过,参数的body和header的构成与维护都很简单,整个代码测试起来也很容易。
执行请求
- 与后台通讯是否需要第三方框架?
有人在 Swift 中使用 AFNetworking(Objective-C) 和 Alamofire,这种方式很常见,不过由于 NSURLSession
也可以很好地完成工作,有时候不需要任何的第三方框架,否则只会让应用框架更为复杂。
目前的解决方案包含两个类—— NetworkService
和 BackendService
:
NetworkService
:允许执行 HTTP 请求,包含NSURLSession
。每项网络服务每次都只能执行一个请求,请求可以取消,成功和失败都有回馈。BackendService
:负责接收与后台相关的请求,包含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
// Decide whether the response is success or failure and call
// proper callback.
})
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
// Set authentication token if available.
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
// Do stuff you need, and call failure block.
})
}
func cancel() {
service.cancel()
}
}
大家都知道,BackendService
是可以在头文件中设置验证 token 的,BackendAuth
对象只是简单的存储,将 token 存在 NSUserDefaults
中,如果必要的话,可以将 token 存在 Keychain 中。
BackendService
将 BackendAPIRequest
作为请求的一个参数,从请求对象处提取必要的信息。由于封装的很好,后台服务只管使用就行了。
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
以及 NSOperations
来执行网络请求,因此在将 NSOperation
归入子类之后,将其异步属性覆盖,以返回 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
}
}
/// Used only by subclasses. Externally you should use `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
,就无需在每个子类中分别创建了。
下面列出了登录操作的示例代码:
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()
}
}
在 start
方法中,服务会执行操作的构造函数内部生成的请求,将 handleSuccess
与 handleFailure
方法作为服务的 request(_:success:failure:)
方法,发送回调函数。这样代码更干净,并且仍保有可读性。
系统会将操作单独发送给 NetworkQueue
对象,并分别插入队列。我们令其尽可能简单化:
public class NetworkQueue {
public static var shared: NetworkQueue!
let queue = NSOperationQueue()
public init() {}
public func addOperation(op: NSOperation) {
queue.addOperation(op)
}
}
在同一个地方执行操作的优点是什么?
- 所有的网络操作取消起来都很简单;
- 在提供应用的基础体验时,如果网络较差,可以取消包括下载图片或其他请求数据的联网操作。比如,当用户的网络较差时,我们会希望避免下载图片;
- 可以构建优先队列,优先执行某些请求,以便快速响应。
解决 Core Data 的问题
这是这个版本不得不延迟发布的原因:在之前版本的网络层中,操作返回 Core Data 对象,回应收到后会被解析转化为 Core Data 对象,这个解决方案非常不理想。
- 操作必须知道Core Data是什么,由于针对不同的框架,使用的模型也不同,网络层都在不同的框架中,因此网络框架也必须知道模型的框架。
- 每个操作必须采取额外的
NSManagedObjectContext
参数,才能知道应当执行哪部分内容。 - 每次在收到回应并成功调用前,都必须先找出对象,或者找到要获取对象的磁盘。这是一个很大的缺陷——我们并不是每次都想创建Core Data对象。
因此,新的设想是完全从网络层中获取Core Data。首先我们创建了对象创建的中间层,以便解析响应。
- 解析与创建对象的速度很快,不需要涉及请求磁盘的操作;
- 无需将
NSManagedObjectContext
发送给操作; - 在成功后,再使用解析项和创建操作时存储的Core Data对象引用来更新Core Data对象——这就是大多数情况下,将操作添加到队列之后的情况。
映射操作
响应映射的概念在于将解析逻辑与将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
——顾名思义,就是 JSON 中有漏 key,或者解析后的值为或应为 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
}
}
}
后台返回一个 obj
——在本例中是一个 JSON,以及消费这个 obj
的解析方式,并返回一个符合 ParsedItem
的对象。
现在,有了这个通用型的 mapper 之后,我们就能创建具体的 mapper 了。我们先来看一下回应登录操作解析的 mapper。
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
是由具体 mapper 所实现的协议,因此解析回应的方法一致。
在成功的操作模块中,我们也会使用这样的 mapper,可使用特定类型的具体对象来代替 dictionary
。这样的对象容易使用,也容易测试。
最后是解析数组的响应 mapper。
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
}
}
这串代码负责接收 mapping 函数,如果一切解析正常的话,就会返回数组。如果有单独的内容无法解析,或者更甚之返回空数组的话,可以根据情况抛出错误。mapper 会希望这个对象(从后台获取回应)是一个 JSON 元素的数组。
下面的图表展示了网络层结构:
案例项目
点击这里可以查看案例项目,由于在后端使用了伪 URL,所有请求都无法成功,放在这里只是为了方便大家了解网络层的构成方式。
封装
这种制作网络层的方式非常有用,而且简单方便:
- 其中最大的优势在于,我们可以很方便地添加类似设计的新操作,不需了解Core Data。
- 可以完全复制代码,无需做大的改动,也无需考虑如何覆盖超级困难的案例,因为这样的案例根本不存在。
- 其核心可以在其它有类似复杂性的应用中很容易地复用。
了解最新移动开发、VR/AR 干货技术分享,请关注 mobilehub 微信公众号(ID: mobilehub)。