面向协议编程(POP)实战-网络层封装

1,843 阅读8分钟

上一篇Swift面向协议编程(POP)中,我们了解了POP,以及POP解决的问题,优点和特性。本篇我们我们POP来对网络层封装,体验POP带来的解耦合易于测试,强大的扩展性

一、准备工作

首先我们需要知道,网络层一般情况下做的是从一个API请求到JSON数据,然后转化为一个可用的实例对象。 那么我们用一个登录的例子来讲解一下这个过程。 登录接口: http://www.jihuabiao.net:8890/plan/freeuser/login 参数: account,password 返回结构:

{
    loginUser = {
        id = 2c93167b6cb7dd34016cc1a32802002a;
        nickname = jensen;
        phone = 199****1676;
    };
}

二、开始实践

首先新建LoginUser.swift模型:

struct LoginUser {
    let id: String
    let nickname: String
    let phone: String
    
    init?(data: NSDictionary) {
    
        guard let loginUser = data["loginUser"] as? NSDictionary else {
            return nil
        }
        guard let id = loginUser["id"] as? String else {
            return nil
        }
        guard let nickname = loginUser["nickname"] as? String else {
            return nil
        }
        guard let phone = loginUser["phone"] as? String else {
            return nil
        }
        self.id = id
        self.nickname = nickname
        self.phone = phone
    }
}

init中传入一个NSDictionary,创建一个LoginUser实例。 如何使用POP的方式从URL请求到数据并生成对应的LoginUser,是这里的重点。 我们知道Request是网络请求的入口,所以可以直接创建一个网络请求协议,网络请求需要知道路径,方法,参数等等。

enum JNHTTPMethod {
    case GET
    case POST
}

protocol JNRequest {
   
    var host : String {get}
    var path : String {get}
    var method : JNHTTPMethod {get}
    var parameter: [String: Any] { get }
    
}
  • 请求地址由hostpath拼接而成
  • method支持GETPOST,本例使用POST
  • parameter是请求的参数

创建LoginRequest实现JNRequest

struct LoginRequest: JNRequest {
    var host: String  {
        return "http://www.jihuabiao.net:8890/plan/"
    }
    var path: String {
        return "freeuser/login"
    }
    let method: JNHTTPMethod = .POST
    var parameter: [String : Any]
}
  • 设置host路径和path路径
  • 指定methodPOST

这个时候,我们已经有了发请求的条件(路由,方法,参数)。下一步就需要发送请求了。我们可以为JNRequest扩展发送请求的能力,这样可以让每一个请求都是用一样的方法发送的。

extension JNRequest {
    func sendRequest(hander:@escaping(LoginUser)->Void) {
        //...
    }
}
  • JNRequest扩展sendRequest
  • 逃逸闭包hander可将请求结果返回到外界

这里返回的是LoginUser模型,这样的话sendRequest方法就只能支持这个登录请求。我们可以使用关联类型解决这个问题,使请求一般化.

protocol JNRequest {
   //.....
    associatedtype Response
}

struct LoginRequest: JNRequest {
    typealias Response = LoginUser
    //....
}
extension JNRequest {
    func sendRequest(hander:@escaping(Response)->Void) {
    //...
    }
}
  • JNRequest协议中添加associatedtype Response
  • LoginRequest添加typealias Response = LoginUser,执行返回类型为LoginUser
  • JNRequest的扩展方法sendRequest的逃逸闭包将返回类型改为Response

sendRequest发送方法中,我们开始实现发起网络请求的代码。最近刚学完Alamofire,就直接使用Alamofire吧。

 func sendRequest(hander:@escaping(Response)->Void) {
        let url = self.host + self.path
        Alamofire.request(url, method: HTTPMethod(rawValue: httpMethod.rawValue)!, parameters: self.parameter).responseJSON { (response) in
            print(response)
        }
    }
  • 拼接url
  • 调用Alamofire.request请求数据
  • 使用JSON序列化器
  • 获取到网络返回的JSON数据

现在还差最后一步,将返回的JSON数据转化为LoginUser模型数据 我们为JNRequest协议添加方法

protocol JNRequest {
    //...
    func parse(data: NSDictionary) -> Response?
}

LoginRequest扩展实现:

extension LoginRequest {
    
    func parse(data: NSDictionary) -> LoginUser? {
        return LoginUser(data:data)
    }
}

sendRequest中调用序列化解析:

     func sendRequest(hander:@escaping(Response?)->Void) {
        let url = self.host + self.path
        Alamofire.request(url, method: HTTPMethod(rawValue: httpMethod.rawValue)!, parameters: self.parameter).responseJSON { (response) in
            switch response.result {
            case .success(let data):
                   let dic = data as? NSDictionary
                   if let res = self.parse(data: dic!) {
                    hander(res)
                 }else {
                    hander(nil)
                 }
                case .failure:
                    hander(nil)
            }
        }
    }

外界使用请求:

  let request = LoginRequest(parameter: ["account":"19916721676","password":"123456"])
  request.sendRequest { (LoginUser) in
      print(LoginUser)
   }
  • 只需要创建request
  • 调用sendRequest发起请求

使用起来非常便捷,也能实现需求。但是这样的实现非常糟糕。我们看看JNRequest的定义和扩展:

protocol JNRequest {
   
    var host : String {get}
    var path : String {get}
    var httpMethod : JNHTTPMethod {get}
    var parameter: [String: Any] { get }
    
    associatedtype Response
    func parse(data: NSDictionary) -> Response?
}

extension JNRequest {
    func sendRequest(hander:@escaping(Response?)->Void) {
       //...
    }
}

上面的实现主要问题在于Request管理的东西太多.我们对于Request的了解,它该做的事情应该是定义请求入口,保存请求的信息和响应类型。而这里的Request保存host,还进行数据的解析成,这样做就无法在不修改请求的情况下更改解析的方式,增加耦合度,不利于测试。发送请求也是它的一部分,这样请求的具体实现就和请求产生耦合,这也是不合理的...

三、重构优化

鉴于上述实现存在一些问题,我们着手重构代码,解决上述存在的问题。 首先我们先将sendRequest中剥离出来,我们需要一个单独的类型来负责发送请求。基于POP的设计方式,我们定义如下协议:

protocol JNDataClient {
    var host: String { get }
   func send<T :JNRequest>(_ r : T, handler: @escaping(T.Response?)->Void)
}

JNRequest中含有关联类型,所以我们使用泛型,不能使用独立的类型。对于一个模块的请,host不应该在Request中设置,我们将其移动到JNDataClient. 清除请求中的host以及send。并定义JNAlamofireClient实现JNDataClient协议:

struct JNAlamofireClient {
   
   var host: String  {
       return "http://www.jihuabiao.net:8890/plan/"
   }
   func send<T :JNRequest>(_ r : T, handler: @escaping(T.Response?)->Void) {

           let url = self.host + r.path
           Alamofire.request(url, method: HTTPMethod(rawValue: r.httpMethod.rawValue)!, parameters: r.parameter).responseJSON { (response) in
               switch response.result {
               case .success(let data):
                   let dic = data as? NSDictionary
                   if let res = r.parse(data: dic!) {
                       handler(res)
                   }else {
                       handler(nil)
                   }
               case .failure:
                   handler(nil)
               }
           }
   }
}

目前已经将发送请求和请求本身分离开,我们定义了JNDataClient协议,这里实现了JNAlamofireClient,使用Alamofire发送请求。将来我们如果想要更换原生URLSession来发送请求,我们可以直接定义JNURLSessionClient实现JNDataClient,或者直接在本地获取数据可以定义JNLocalClient等。网络层的具体实现和请求本身不再耦合,更利于测试。 上述提到的问题,对象的解析不应该由Request来完成,应该交给Response。我们新增一个协议,满足这个协议的需要实现parse方法:

protocol Decodable {
   static func parse(data : NSDictionary)->Self?
}

为了保证所有的Response都能解析数据,我们需要对Response实现Decodable协议,并移除Request的解析方法。

protocol JNRequest {
   
    var path : String {get}
    var httpMethod : JNHTTPMethod {get}
    var parameter: [String: Any] { get }
    
    associatedtype Response : Decodable
}

LoginUser扩展解析方法:

extension LoginUser : Decodable{
   static func parse(data: NSDictionary) -> LoginUser? {
       return LoginUser(data: data)!
   }
}

send中直接将解析交给T.Response:

  if let dic = data as? NSDictionary {
                        if let res = T.Response.parse(data: dic) {
                            handler(res)
                        }else {
                            handler(nil)
                        }
                    }else {
                        handler(nil)
                    }

外界使用:

   let request = LoginRequest(parameter: ["account":"19916721676","password":"123456"])
        JNAlamofireClient().send(request) {(LoginUser) in
            print(LoginUser!)
        }

我们还可以添加一个单例来减少请求时的创建开销:

struct JNAlamofireClient {
    static let `default` = JNAlamofireClient()
}

如果需要创建其他的请求, 可以用和 LoginRequest 相似的方式,为网络层添加其他的API 请求,只需要定义请求所必要的内容,而不用担心会触及网络方面的具体实现。 以上就是使用POP对网络层封装的实战。

四、POP封装带来的好处

易于测试

如上述提到,我们只是定义了JNDataClient协议,这样我们就可以不再局限于特定的一种技术(URLSession,Alamofire,AFNetworking等)来实现请求的发送。我们甚至可以提供一组虚拟请求的响应,用来进行测试。

准备一个文本response.json:

{"loginUser":{"id":"2c93167b6cb7dd34016cc1a32802002a","nickname":"jensen","phone":"199****1676"}}

我们可以创建一个类型JNLocalClient,实现JNDataClient协议:

struct JNLocalClient {
    
    var host: String  {
        return ""
    }
    func send<T :JNRequest>(_ r : T, handler: @escaping(T.Response?)->Void) {
        
        switch r.path {
        case "freeuser/login":
            let fileURL = Bundle.main.path(forResource: "response", ofType: "json")
            if let data = try? String.init(contentsOfFile: fileURL!, encoding: .utf8) {
                let jsonData:Data = data.data(using: .utf8)!
                if let dic = try? JSONSerialization.jsonObject(with: jsonData, options: .mutableContainers) {
                    if let res = T.Response.parse(data: dic as! NSDictionary) {
                         handler(res)
                    }else {
                         handler(nil)
                    }
                }else {
                    handler(nil)
                }
            }else {
                handler(nil)
            }
        default:
            handler(nil)
        }
    }
}
  • 检查输入请求的path属性,根据path不同,从bundle中读取预先设定的文件数据。
  • 对返回的结果做JSON解析,然后调用Response的parse解析
  • 调用handler返回数据到外界。
  • 如果我们需要增加其他请求的测试,可以添加新的case

这里补充一点,我们之前在设定Decodable协议时,设定parse传入的参数必须是NSDictionary类型:

protocol Decodable {
   static func parse(data : NSDictionary)->Self?
}

所以在JNLocalClient中我们需要自己解析成JSON,使用起来不太好用。使用POP的方式我们可以不限定单独的类型,而是限定一个协议DecodeType

protocol DecodeType {
    func asDictionary() -> NSDictionary?;
}

为可能传入解析的类型比如NSDictionary,Data等添加扩展:

extension NSDictionary : DecodeType {
    func asDictionary() -> NSDictionary? {
        return self
    }
}

extension Data : DecodeType {
   func asDictionary() -> NSDictionary? {
      if let dic = try? JSONSerialization.jsonObject(with: self, options: .mutableContainers) {
        return dic as? NSDictionary
      }
    return nil
   }
}

修改协议Decodable协议,限定参数类型DecodeType协议:

protocol Decodable {
   static func parse(data : DecodeType)->Self?
}

修改LoginUser的解析方式:

extension LoginUser : Decodable{
    static func parse(data: DecodeType) -> LoginUser? {
        return LoginUser(data: data.asDictionary()!)
    }
}

JNLocalClientsend方法不需要在解析JSON,直接调用Parse解析:

if let data = try? String.init(contentsOfFile: fileURL!, encoding: .utf8) {
                let jsonData:Data = data.data(using: .utf8)!
                if let res = T.Response.parse(data: jsonData) {
                        handler(res)
                }else {
                        handler(nil)
                }
            }else {
                handler(nil)
            }

这样用起来就更方便了。 回到刚才的话题,有了JNLocalClient,我们就可以不受网络的限制,单独测试LoginRequestparse是否正常,以及之后的各个流程是否正常。

  let request = LoginRequest(parameter: ["account":"19916721676","password":"123456"])
        JNLocalClient().send(request) { (loginUser) in
            XCTAssertNotNil(loginUser)
        }

这里没有使用任何第三方测试库,也没有运用网络代理和运行时消息转发,就可以对请求进行测试。

解耦&可扩展

基于POP实现的代码高度解耦,为代码的扩展提供相对宽松的可能性。在上面的例子中,我们可以仅仅实现发送请求的方法,在不影响请求定义和使用的情况下更换了请求方式。这里我们使用手动解析赋值模型,我们我们完全可以使用第三方解析库,比如:HandyJSON,来帮助我们迅速构建模型类型。