我是如何构建网络层
摘要
Tomasz Szulc 在建立网络架构的时候并不依赖第三方库和苹果的 CoreData,这使得它很容易拓展和测试。这个设计很赞。
一次带领或参与两个项目是个不错的机会去试验 app 框架,并且可以试验脑海中或刚学到的概念。分享我最近学到的一个主题,我想你会发现我构建的网络层对你很实用。
现在,移动应用都是面向客户端的,所以几乎所有的应用或多或少都有网络层的地方。目前我见过许多网络层的实现,每种都有一些缺点。虽不说我最新构建的网络层没有缺点,但是在我的项目中它似乎运行良好。并且其测试覆盖率接近 100%。
在本文中,将介绍的网络层仅与一个后台进行通信,发送 JSON 请求,因此不是那么复杂。该层稍候将与 AWS 传输,向那发送文件,但应该很容易扩展该功能。
构思过程
这里的一些问题是我在搭建这样一个层前问我自己的:
在哪里放置后台 url 相关代码?
在哪里放置终端代码?
在哪里放置生成请求的代码?
在哪里保留所关心的请求参数代码?
在哪里存储身份验证令牌?
如何执行请求?
何时何处执行请求?
是否需要取消请求?
是否需要处理后台错误、后台 bug?
是否使用第三方框架?应该用什么框架?
是否有核心数据需要传递?
如何测试解决方案?
存储后台 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 是尝试硬编码终端,尝试虚拟一些类似 Resource 对象的终端,可以容易实例化和注入,但它仍不是我所想要的。
我想到了创建 *Request 对象,包含了目标终端、使用方法(GET、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"]
}
}
为了避免到处创建头字典,我们可以定义 BackendAPIRequest 扩展。
extension BackendAPIRequest {
func defaultJSONHeaders() -> [String: String] {
return ["Content-Type": "application/json"]
}
}
该 *Request 类用所有需要的参数来创建一个成功请求。你总是需要确保至少传递所有参数,否则不能创建一个请求对象。
定义终端很容易。如果该终端需要对象 id 包含其中,也是很容易添加的,实际上也应该会存储像这样的 id 属性。
private let id: String
init(id: String, ...) {
self.id = id
}
var endpoint: String {
return "/users/(id)"
}
请求方法不改变,很容易就可以构建参数体和头信息。一切都容易测试。
执行请求
我是否需要使用第三方框架与后台通信?
我看到人们使用 AFNetworking(Objective-C)和 Swift 的 Alamofire。我用过很多次,但有段时间我不用它了。因为我们有 NSURLSession,而且表现良好,因此不认为还需要任何第三方框架了。IMO 这种依赖,将会让你的 app 框架更加复杂。
当前解决方案包含两个类——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
// 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 可以在头信息中设置身份验证令牌。BackendAuth 是一个简单的存储对象,把令牌存储到 NSUserDefaults 中。如果有需要,它还可以把令牌存储到 Keychain 中。
BackendService 在 request(_:success:failure:) 方法中,把 BackendAPIRequest 作为一个参数,并从 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
}
}
/// 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 方法,服务执行在操作构造函数内部创建的请求。request(_:success:failure:) 传递 handleSuccess 和 handleFailure 方法做为回调。IMO 让代码变得更清晰,并且是可读的。
操作传递给 NetworkQueue 对象,它是单例,并且可以排列每个操作。现在我尽可能保持简单:
public class NetworkQueue {
public static var shared: NetworkQueue!
let queue = NSOperationQueue()
public init() {}
public func addOperation(op: NSOperation) {
queue.addOperation(op)
}
}
在一个地方执行操作有什么优点呢?
轻松取消所有的网络操作。
在网络连接较弱的情况下,不需要提供给使用 app 的用户提供基本体验。例如,当用于适用连接较弱时,你会取消所有正在下载图片。
你可以构建一个优先级队列,优先执行一些请求以快速获得应答。
使用 Core Data
这是我不得不推迟发布这个分录的一面。在以前版本的网络层中,操作会返回 Core Data 对象。响应接收时,就会被解析、转换为 Core Data 对象。但这种解决方案远非理想。
操作必须知道 Core Data 是什么。因为我让模型和网络层都分离到独立的框架,而网络框架必须得知道模型框架。
每个操作都需要添加额外的 NSManagedObjectContext 参数来获取操作的上下文。
每次收到响应并调用成功 block 时,它首先尝试在上下文查找对象,或者命中磁盘,从磁盘获取对象。这是 IMO 一个很大的缺点。你不总是需要创建 Core Data 对象。
所以我想到了把 Core Data 完全从网络层中脱离出来。我创建了中间层,用于解析响应结果,创建对象。
使用这种方式解析、创建对象是快速的,并且不需要命中磁盘。
同样也不需要传递 NSManagedObjectContext 给操作。
大多情况下,当操作添加到队列中,你可以通过在 success block 解析并引用可能保存在创建操作的位置的 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 时,值为 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,parse 方法消费 obj 并返回符合 ParsedItem 的 A 对象。
我们现在有了这个通用映射器后,我们可以创建具体的映射器。让我们开看看用于解析注册操作的响应映射器。
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 是由具体映射器实现的协议,因此他们共享相同的方法来解析响应。
然后,这样的映射器在操作的成功 block 中使用,并且你可以对非字典的,如特定类型的具体对象进行操作。容易使用这样的对象后,其他的也是很容易进行测试的。
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
}
}
如果一切解析正确,它需要一个映射函数并返回多个项的数组。如果只返回一项且不能解析,或者最坏情况,返回该映射器的数组为空,仅依赖于你考虑的解析范围,那么它可能会抛出错误。映射器期望的 obj(来自后台的响应)是一组 JSON 元素的数组。
这里呈现的是网络层架构图。
示例项目
你可以在我的 github(https://github.com/tomkowz/NetworkLayerExample)上找到实例项目。该项目使用的是假 url 的后端,所有没有成功完成的任务请求。我只提供了一个可用视图,尽可能地展示网络层框架的样子。
总结
我发现这种网络层非常实用、简单易用。
它的最大优点是,你可以轻松添加用于其他地方的操作,而无需理会 Core Data。
你无需费劲保持代码覆盖率接近 100%,无需考虑超级复杂的情况,因为没有这种情况。
它的核心是在类似复杂的应用中易于重用。
编译:伯乐在线 - BEASTQ
iOS开发整理发布,转载请联系作者授权
↙点击“阅读原文”,加入
『程序员大咖』
关注公众号:拾黑(shiheibook)了解更多
[广告]赞助链接:
四季很好,只要有你,文娱排行榜:https://www.yaopaiming.com/
让资讯触达的更精准有趣:https://www.0xu.cn/
随时掌握互联网精彩
- 1 习近平G20里约峰会展现大国担当 7984242
- 2 多国驻乌克兰大使馆因袭击风险关闭 7910543
- 3 78岁老太将减持2.5亿股股票 7808874
- 4 二十国集团里约峰会将会卓有成效 7745302
- 5 俄导弹击中乌水电站大坝 7603519
- 6 孙颖莎王艺迪不敌日本削球组合 7542774
- 7 高三女生酒后被强奸致死?检方回应 7448925
- 8 第一视角记录虎鲨吞下手机全程 7357836
- 9 手机不能看医院CT图像就要少收费 7251488
- 10 智慧乌镇点亮数字经济新未来 7173459