我是如何构建网络层

百家 作者:iOS开发 2017-07-12 11:17:34

摘要


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/

公众号 关注网络尖刀微信公众号
随时掌握互联网精彩
赞助链接