转载

用Swift编写网络层:面向协议方式

用Swift编写网络层:面向协议方式在这篇文章中我们会看到怎样实现用纯swift编写网络层,而不依靠任何第三方库。让我们快去看看吧。相信看完之后我们的代码能够做到:

  • 面向协议

  • 易用

  • 容易实现

  • 类型安全

  • 用枚举(enums)来配置终端(endPoints)

下面是一个最终我们网络层的示例

用Swift编写网络层:面向协议方式

这个项目的最终目标

通过输入router.request(. 借助枚举的力量,我们可以看到所有有效的终端和我们请求的参数)

首先,一些结构

创建任何东西之前,有个结构都是很重要的,这样后面我们就容易找到需要的东西。我坚定相信文件夹结构对软件架构至关重要。为了让我们的文件组织有序,让我们提前建立好所有的组,我会标记好每一个文件该放的位置。这是一个项目结构总览。(请注意这里的名字仅仅是建议,你可以按你喜好给你的类和组命名)

用Swift编写网络层:面向协议方式

项目文件夹结构

终端类型(EndPointType)协议

我们要做的第一件事情就是定义我们的终端类型协议。这个协议要包含用于配置终端的所有信息。什么是终端?本质上来讲它是一个包含各种组件比如头文件(headers),查询参数(query parameters),体参数(body parameters)的URL请求(URLRequest)。终端类型协议是我们网络层实现的基石。我们建一个文件,并命名EndPointType,把它放到服务组中(不是终端组,后面我们分清楚的)。

用Swift编写网络层:面向协议方式

终端类型协议

HTTP协议

为了创建一个完整的终端,我的终端类型协议里有很多HTTP协议。让我们看看这些协议需要什么。

HTTP方法

创建一个名为HTTPMethod的文件并把它放在服务组中。这个枚举会用于设置我们请求用的HTTP方法。

用Swift编写网络层:面向协议方式

HTTPMethod枚举

HTTP任务

创建一个名为HTTPTask的文件并把它放在服务组中。HTTPTask用于为一个特定的终端配置参数,你可以添加适当数量的案例(cases)到你的网络层请求中。我会按下图建立我的请求,它只包含3个案例

用Swift编写网络层:面向协议方式

HTTPTask枚举

在下一章我们会讨论参数和如何处理参数的编码。

HTTP头文件

HTTPHeaders是一个字典的别名(typealias)。你可以在你HTTPTask文件的开头创建它。

public typealias HTTPHeaders = [String:String]

参数与编码

创建一个名为ParameterEncoding的文件并把它放在编码组中。我们首先要定义一个参数的别名,通过它我们可以让代码更干净简洁。

public typealias Parameters = [String:Any]

之后用一个静态函数编码定义一个协议参数编码器(ParameterEncoder)。这种编码方式含有2个参数,一个inout URLRequest和Parameters。(为了防止混淆,后面我会把函数参数称为参量)。INOUT是一个swift关键词,用于把一个参量定义为引用参量。通常变量作为值类型传送给函数。通过在参量的开头加上inout,我们把它定义为引用类型。要学更多关于双向参量,你可以点击这里。参数编码器协议会通过JSONParameterEncoder和URLPameterEncoder实现。

public protocol ParameterEncoder {
 static func encode(urlRequest: inout URLRequest, with parameters: Parameters) throws
}

参数编码器执行编码参数的函数,这个方法会失败,返回一个错误,因而我们需要处理它。

能够返回一个自定的错误提示比标准错误提示会更有价值。我总是花很多时间去分析Xcode给的一些错误提示。有了自定的错误提示你就可以定义属于自己的错误信息,就能清楚知道错误到底来自哪里。为了做到这些,我创建了一个继承自Error的枚举。

用Swift编写网络层:面向协议方式

NetworkError枚举

URL参数编码器

创建一个名为URLParameterEncoder的文件并把它放在编码组中。

用Swift编写网络层:面向协议方式

URL参数编码器代码

上面的代码含有一些参数,它可以将他们变成URL参数来安全传递。你要知道一些字符在URL中一些字符是禁用的。参数也被‘&’标记分开,我们需要考虑到所有这些。如果之前没有设置,我们还要为请求添加合适的头文件。

这个示例代码是使用单元测试时应该考虑到的。如果URL没有正确建立,我们就会有很多不必要的错误。如果你在使用一个开放API,你一定不希望自己的请求配额被一堆错误测试用完。如果你想学更多关于单元测试内容,你可以看S.T.Huang的这篇文章

JSON参数编码器

创建一个名为JSONParameterEncoder的文件,也把它放在编码组中。

用Swift编写网络层:面向协议方式

JSON参数编码器代码

类似URL参数编码器,不过这里是为JSON编码参数,同样要添加合适的头文件。

网络路由器

创建一个名为NetworkRouter的文件并把它放在服务组中。我们从为一个完成部分(completion)定义别名开始。

public typealias NetworkRouterCompletion = (_ data: Data?,_ response: URLResponse?,_ error: Error?)->()

之后我们定义一个协议网络路由器

用Swift编写网络层:面向协议方式

NetworkRouter代码

一个网络路由器有一个用于产生请求的终端,一旦请求产生,它会传递对完成部分的应答。我加入了一个取消函数,有它当然好,但不是一定要用到。这个函数可以在一个请求存在周期的任意时刻调用并取消它。如果你的应用有上传或下载任务,这会很有用。为了让我们的路由器能处理任何终端类型,我们这里使用了关联类型。如果不用关联类型,路由器就不得不有一个具体的终端类型。想对关联类型了解更多,建议看NatashaTheRobot的这篇文章

路由器

创建一个名为Router的文件并把它放在服务组中。我们声明一个URLSessionTask类型的私有变量任务。这个任务本质上是整个工作要做的。我们让这个变量私有化,因为我们不想任何这个类之外的任何东西会调整我们的任务。

用Swift编写网络层:面向协议方式

Router方法存根

请求

这里我们使用共享的会话管理(session)创建URLSession,这是创建URLSession最简单的办法,但请记住这不是唯一的方法。要实现对URLSession更复杂的配置,则要用能够改变会话管理表现的配置。想了解更多,我推荐读一读这篇文章

这里我们通过调用buildRequest生成我们的请求,并给它一个终端作为路径。这个buildRequest的调用被限制在一个do-try-catch区块,因为我们的编码器可能会报出错误。我们仅仅把所有应答,数据和错误传送给完成部分。

用Swift编写网络层:面向协议方式

Request方法代码

建立请求

在Router中创建一个名为buildRequest的私有函数,这个函数负责我们网络层中一切重要工作。本质上就是把EndPointType转化为URLRequest。一旦我们的终端生成请求,我们可以把它传递给会话管理。这里有很多工作要做,所以我们将会分别看看每个方法。让我们分解buildRequest方法:

  1. 我们举了一个URLRequest类型的变量请求的例子。把我们的基础URL给它,并附上我们要用到的路径。

  2. 我们设定这请求的httpMethod和我们终端的一致。

  3. 考虑到我们的编码器会报告错误,我们创建一个do-try-catch区块。只要创建一个大的do-try-catch区块,我们就不需要为每次尝试分别建一个。

  4. 开启route.task

  5. 根据任务,调用合适的编码器。

用Swift编写网络层:面向协议方式

buildRequest方法代码.

配置参数

在Router中创建一个名为configureParameters的函数

用Swift编写网络层:面向协议方式

configureParameters方法的实现

这个函数负责为我们的参数编码。因为我们的API要求所有的bodyParameters都是JSON,并且URLParameters是URL编码的,我们把合适的参数传递给设计好的编码器。如果你正在用一个有多种编码方式的API,我建议修改HTTPTask来使用编码器枚举。这个枚举需要包含所有你需要的不同类型编码器。之后在configureParameters添加一个关于你编码枚举的附加参量。开启这个枚举,合适地为参数编码。

添加附加头文件

在Router中创建一个名为addAdditionalHeaders的函数

用Swift编写网络层:面向协议方式

addAdditionalHeaders方法的实现

添加所有附加头文件,让它们成为请求头文件的一部分。

取消

取消函数的实现是这样的:

用Swift编写网络层:面向协议方式

cancel方法的实现

实践

现在让我们用一个实际例子看看我们建立的网络层。我们将从TheMovieDB获取一些电影数据到我们的应用。

电影终端(MovieEndPoint)

电影终端与我们在Getting Started with Moya中提到的目标类型很相似。与实现Moya中目标类型不同的是这里我们实现我们自己的终端类型。把这个文件放在终端组中。

import Foundation


enum NetworkEnvironment {
    case qa
    case production
    case staging
}

public enum MovieApi {
    case recommended(id:Int)
    case popular(page:Int)
    case newMovies(page:Int)
    case video(id:Int)
}

extension MovieApi: EndPointType {
   
    var environmentBaseURL : String {
        switch NetworkManager.environment {
        case .production: return "https://api.themoviedb.org/3/movie/"
        case .qa: return "https://qa.themoviedb.org/3/movie/"
        case .staging: return "https://staging.themoviedb.org/3/movie/"
        }
    }
    
    var baseURL: URL {
        guard let url = URL(string: environmentBaseURL) else { fatalError("baseURL could not be configured.")}
        return url
    }
   
    var path: String {
        switch self {
        case .recommended(let id):
            return "/(id)/recommendations"
        case .popular:
            return "popular"
        case .newMovies:
            return "now_playing"
        case .video(let id):
            return "/(id)/videos"
        }
    }
    
    var httpMethod: HTTPMethod {
        return .get
    }
    
    var task: HTTPTask {
        switch self {
        case .newMovies(let page):
            return .requestParameters(bodyParameters: nil,
                                      urlParameters: ["page":page,
                                                      "api_key":NetworkManager.MovieAPIKey])
        default:
            return .request
        }
    }
    
    var headers: HTTPHeaders? {
        return nil
    }
}

终端类型

电影模式(MovieModel)

因为对TheMovieDB的回应同样是JSON,我们的电影模式也不会改变。我们用可解码协议来把JSON转化为我们的模式。把这个文件放在模式组中。

import Foundation

struct MovieApiResponse {
    let page: Int
    let numberOfResults: Int
    let numberOfPages: Int
    let movies: [Movie]
}

extension MovieApiResponse: Decodable {
   
    private enum MovieApiResponseCodingKeys: String, CodingKey {
        case page
        case numberOfResults = "total_results"
        case numberOfPages = "total_pages"
        case movies = "results"
    }
   
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: MovieApiResponseCodingKeys.self)
        
        page = try container.decode(Int.self, forKey: .page)
        numberOfResults = try container.decode(Int.self, forKey: .numberOfResults)
        numberOfPages = try container.decode(Int.self, forKey: .numberOfPages)
        movies = try container.decode([Movie].self, forKey: .movies)
        
    }
}


struct Movie {
    let id: Int
    let posterPath: String
    let backdrop: String
    let title: String
    let releaseDate: String
    let rating: Double
    let overview: String
}

extension Movie: Decodable {
   
    enum MovieCodingKeys: String, CodingKey {
        case id
        case posterPath = "poster_path"
        case backdrop = "backdrop_path"
        case title
        case releaseDate = "release_date"
        case rating = "vote_average"
        case overview
    }
    
   
    init(from decoder: Decoder) throws {
        let movieContainer = try decoder.container(keyedBy: MovieCodingKeys.self)
       
        id = try movieContainer.decode(Int.self, forKey: .id)
        posterPath = try movieContainer.decode(String.self, forKey: .posterPath)
        backdrop = try movieContainer.decode(String.self, forKey: .backdrop)
        title = try movieContainer.decode(String.self, forKey: .title)
        releaseDate = try movieContainer.decode(String.self, forKey: .releaseDate)
        rating = try movieContainer.decode(Double.self, forKey: .rating)
        overview = try movieContainer.decode(String.self, forKey: .overview)
    }
}

电影模式

网络管理员

创建一个名为NetworkManager的文件并把它放在管理员组中。

现在开始我们的网络管理员将仅有2个静态属性:你的API密码和网络环境(引用MovieEndPoint)。网络管理员也有一个类型为MovieApi的Router。

用Swift编写网络层:面向协议方式

NetworkManager代码

网络响应

在NetworkManager中创建一个名为NetworkResponse的枚举。

用Swift编写网络层:面向协议方式

NetworkResponse枚举

我们将用这个枚举处理来自API的响应,并显示相应的信息。

结果

在NetworkManager中创建一个枚举Result。

用Swift编写网络层:面向协议方式

Result枚举

一个结果枚举可以用在很多不同事情上,非常有用。我们根据结果确定我们对API的调用是成功还是失败。如果失败了,我们会返回一个错误信息并说明原因。想了解更多面向结果的编程,你可以看这篇对话

处理网络响应

创建一个名为handleNetworkResponse的函数,这个函数有一个参量,即HTTPResponse,并返回一个Result.

用Swift编写网络层:面向协议方式

这里我们开启HTTPResponse的状态码,状态码是一个能告诉我们响应状态的HTTP协议。基本上200-299之间都是成功。更多关于状态码点击这里

产生调用

现在我们已经为我们的网络层打下雄厚的基础。是时候开始调用了。

我们将会从API获取一个新电影列表。创建一个名为getNewMovies的函数。

用Swift编写网络层:面向协议方式

getNewMovies方法的实现

让我们分解这个方法的每一步

  1. 我们定义getNewMovies方法含有2个参量:一个页码和一个能返回电影数组或错误信息的完成部分(completion)。

  2. 我们调用我们的路由器,输入页码并在一个闭包(closure)内处理这个完成部分。

  3. 如果没有网络或者出于一些原因无法调用API,URLSession会返回错误。请注意这并不是API的失败。这种失败多是客服端的,很可能是因为网络连接不好。

  4. 我们需要把我们的响应转变为一个HTTPURLResponse,因为我们需要访问状态码属性。

  5. 我们声明一个从handleNetworkResponse方法得到的结果,之后在switch-case区块检查这个结果。

  6. 成功意味着我们成功地和API联系,并得到一个适当的响应。之后我们检查这个响应是否携带数据。如果没有数据我们就用返回语句退出这个方法。

  7. 如果携带有数据,我们需要把数据编码成我们的模式,之后我们把编码好的电影传递给完成部分。

  8. 如果结果是失败,我们就把错误传递给完成部分。

这就完成了,这就是我们不依赖Cocoapods和第三方库的纯Swift网络层。想要测试api请求能否获取电影,就创建一个带有Network Manager 的viewController之后在管理员调用getNewMovies。

class MainViewController: UIViewController {
   
    var networkManager: NetworkManager!
    
    init(networkManager: NetworkManager) {
        super.init(nibName: nil, bundle: nil)
        self.networkManager = networkManager
    }
   
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .green
        networkManager.getNewMovies(page: 1) { movies, error in
            if let error = error {
                print(error)
            }
            if let movies = movies {
                print(movies)
            }
        }
    }
}

MainViewControoler的示例

迂回网络(DETOUR- NETWORK)记录器

我最喜欢的Moya特性之一就是网络记录器。它使得调试变得更容易,并且通过记录所有网络通信可以看到关于请求和响应发生了什么。我决定实现这个网络层时候就想要有这个特性了。创建一个名为NetworkLogger的文件并把它放在服务组中。我已经实现了一个记录对控制台请求的代码。我不会展示我们应该把代码放到代码层中的哪里。这是对你的一个挑战,创建一个记录控制台响应的函数,并在我们的架构中找到合适的位置放置它们。

提示:静态函数记录(响应:URLResponse)

小技巧

你在Xcode中遇到过不理解的占位符吗?比如让我们看看刚刚为了实现Router写的代码

用Swift编写网络层:面向协议方式

NetworkRouterCompletion是我们实现的。即使我们实现了它,有时候也很难记清它是哪种类型,我们该怎么用它。我们喜欢的Xcode有解决办法。只要在占位符上双击,Xcode就会告诉你。

用Swift编写网络层:面向协议方式

结论

我们有了一个简单好用,面向协议,还可以自己定制的网络层。我们能完全控制它的功能,完全理解它的机制。通过进行这个练习,我可以说我本人学到不少新事情。所以比起那些只需要装一个库就能完成的工作,我对这项工作更感到自豪。希望这篇文章能说明,用Swift创建你自己的网络层并没那么难。只要不做这样的事情就行了:

用Swift编写网络层:面向协议方式

你可以在我的GitHub上找到源代码,感谢阅读。

正文到此结束
Loading...