美文网首页iOS其他一丢丢iOS面试
手把手教你封装网络层

手把手教你封装网络层

作者: 梁杰_numbbbbb | 来源:发表于2017-04-25 22:49 被阅读237次

    作者: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 图:

    diagramdiagram

    示例项目

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

    总结

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

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

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

    相关文章

      网友评论

        本文标题:手把手教你封装网络层

        本文链接:https://www.haomeiwen.com/subject/kbubzttx.html