上一篇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 }
}
- 请求地址由
host
和path
拼接而成 - method支持
GET
和POST
,本例使用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
路径 - 指定
method
为POST
这个时候,我们已经有了发请求的条件(路由,方法,参数)。下一步就需要发送请求了。我们可以为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
,还进行数据的解析成,这样做就无法在不修改请求的情况下更改解析的方式,增加耦合度,不利于测试。发送请求也是它的一部分,这样请求的具体实现就和请求产生耦合,这也是不合理的...
三、重构优化
鉴于上述实现存在一些问题,我们着手重构代码,解决上述存在的问题。
首先我们先将send
从Request
中剥离出来,我们需要一个单独的类型来负责发送请求。基于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()!)
}
}
JNLocalClient
的send
方法不需要在解析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
,我们就可以不受网络的限制,单独测试LoginRequest
、 parse
是否正常,以及之后的各个流程是否正常。
let request = LoginRequest(parameter: ["account":"19916721676","password":"123456"])
JNLocalClient().send(request) { (loginUser) in
XCTAssertNotNil(loginUser)
}
这里没有使用任何第三方测试库,也没有运用网络代理和运行时消息转发,就可以对请求进行测试。
解耦&可扩展
基于POP
实现的代码高度解耦,为代码的扩展提供相对宽松的可能性。在上面的例子中,我们可以仅仅实现发送请求的方法,在不影响请求定义和使用的情况下更换了请求方式。这里我们使用手动解析赋值模型,我们我们完全可以使用第三方解析库,比如:HandyJSON
,来帮助我们迅速构建模型类型。