返回 登录
0

iOS 开发实践:如何构建一个网络层?

阅读6432

原文: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 时曾尝试对端点执行硬编码的方式,并尝试了一些了解端点、便于实例化与注入的虚拟资源类对象,但并未找到需要的方案。然后得出了设想:创建知道要接入哪个端点,使用什么方法,该是 GETPOSTPUT 还是其它什么的 *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 只是个带有 GETPOSTPUTDELETE案例的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 也可以很好地完成工作,有时候不需要任何的第三方框架,否则只会让应用框架更为复杂。

目前的解决方案包含两个类—— NetworkServiceBackendService

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

BackendServiceBackendAPIRequest 作为请求的一个参数,从请求对象处提取必要的信息。由于封装的很好,后台服务只管使用就行了。

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 测试维护起来都很容易。

队列请求

这里涵盖的问题包括:我们想用什么方式来执行网络请求?如果想要一次执行多个请求,要怎样操作?一般来说,要以什么方式获得请求成功或失败的通知?

我们决定采用 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 方法中,服务会执行操作的构造函数内部生成的请求,将 handleSuccesshandleFailure 方法作为服务的 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)。

mobilehub

评论