在这篇文章中我们会看到怎样实现用纯swift编写网络层,而不依靠任何第三方库。让我们快去看看吧。相信看完之后我们的代码能够做到:
- 面向协议
- 易用
- 容易实现
- 类型安全
- 用枚举(enums)来配置终端(endPoints)
下面是一个最终我们网络层的示例
这个项目的最终目标
通过输入router.request(. 借助枚举的力量,我们可以看到所有有效的终端和我们请求的参数)
首先,一些结构
创建任何东西之前,有个结构都是很重要的,这样后面我们就容易找到需要的东西。我坚定相信文件夹结构对软件架构至关重要。为了让我们的文件组织有序,让我们提前建立好所有的组,我会标记好每一个文件该放的位置。这是一个项目结构总览。(请注意这里的名字仅仅是建议,你可以按你喜好给你的类和组命名)
项目文件夹结构
终端类型(EndPointType)协议
我们要做的***件事情就是定义我们的终端类型协议。这个协议要包含用于配置终端的所有信息。什么是终端?本质上来讲它是一个包含各种组件比如头文件(headers),查询参数(query parameters),体参数(body parameters)的URL请求(URLRequest)。终端类型协议是我们网络层实现的基石。我们建一个文件,并命名EndPointType,把它放到服务组中(不是终端组,后面我们分清楚的)。
终端类型协议
HTTP协议
为了创建一个完整的终端,我的终端类型协议里有很多HTTP协议。让我们看看这些协议需要什么。
HTTP方法
创建一个名为HTTPMethod的文件并把它放在服务组中。这个枚举会用于设置我们请求用的HTTP方法。
HTTPMethod枚举
HTTP任务
创建一个名为HTTPTask的文件并把它放在服务组中。HTTPTask用于为一个特定的终端配置参数,你可以添加适当数量的案例(cases)到你的网络层请求中。我会按下图建立我的请求,它只包含3个案例
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的枚举。
NetworkError枚举
URL参数编码器
创建一个名为URLParameterEncoder的文件并把它放在编码组中。
URL参数编码器代码
上面的代码含有一些参数,它可以将他们变成URL参数来安全传递。你要知道一些字符在URL中一些字符是禁用的。参数也被‘&’标记分开,我们需要考虑到所有这些。如果之前没有设置,我们还要为请求添加合适的头文件。
这个示例代码是使用单元测试时应该考虑到的。如果URL没有正确建立,我们就会有很多不必要的错误。如果你在使用一个开放API,你一定不希望自己的请求配额被一堆错误测试用完。如果你想学更多关于单元测试内容,你可以看S.T.Huang的这篇文章。
JSON参数编码器
创建一个名为JSONParameterEncoder的文件,也把它放在编码组中。
JSON参数编码器代码
类似URL参数编码器,不过这里是为JSON编码参数,同样要添加合适的头文件。
网络路由器
创建一个名为NetworkRouter的文件并把它放在服务组中。我们从为一个完成部分(completion)定义别名开始。
- public typealias NetworkRouterCompletion = (_ data: Data?,_ response: URLResponse?,_ error: Error?)->()
之后我们定义一个协议网络路由器
NetworkRouter代码
一个网络路由器有一个用于产生请求的终端,一旦请求产生,它会传递对完成部分的应答。我加入了一个取消函数,有它当然好,但不是一定要用到。这个函数可以在一个请求存在周期的任意时刻调用并取消它。如果你的应用有上传或下载任务,这会很有用。为了让我们的路由器能处理任何终端类型,我们这里使用了关联类型。如果不用关联类型,路由器就不得不有一个具体的终端类型。想对关联类型了解更多,建议看NatashaTheRobot的这篇文章。
路由器
创建一个名为Router的文件并把它放在服务组中。我们声明一个URLSessionTask类型的私有变量任务。这个任务本质上是整个工作要做的。我们让这个变量私有化,因为我们不想任何这个类之外的任何东西会调整我们的任务。
Router方法存根
请求
这里我们使用共享的会话管理(session)创建URLSession,这是创建URLSession最简单的办法,但请记住这不是***的方法。要实现对URLSession更复杂的配置,则要用能够改变会话管理表现的配置。想了解更多,我推荐读一读这篇文章。
这里我们通过调用buildRequest生成我们的请求,并给它一个终端作为路径。这个buildRequest的调用被限制在一个do-try-catch区块,因为我们的编码器可能会报出错误。我们仅仅把所有应答,数据和错误传送给完成部分。
Request方法代码
建立请求
在Router中创建一个名为buildRequest的私有函数,这个函数负责我们网络层中一切重要工作。本质上就是把EndPointType转化为URLRequest。一旦我们的终端生成请求,我们可以把它传递给会话管理。这里有很多工作要做,所以我们将会分别看看每个方法。让我们分解buildRequest方法:
我们举了一个URLRequest类型的变量请求的例子。把我们的基础URL给它,并附上我们要用到的路径。
我们设定这请求的httpMethod和我们终端的一致。
考虑到我们的编码器会报告错误,我们创建一个do-try-catch区块。只要创建一个大的do-try-catch区块,我们就不需要为每次尝试分别建一个。
开启route.task
根据任务,调用合适的编码器。
buildRequest方法代码.
配置参数
在Router中创建一个名为configureParameters的函数
configureParameters方法的实现
这个函数负责为我们的参数编码。因为我们的API要求所有的bodyParameters都是JSON,并且URLParameters是URL编码的,我们把合适的参数传递给设计好的编码器。如果你正在用一个有多种编码方式的API,我建议修改HTTPTask来使用编码器枚举。这个枚举需要包含所有你需要的不同类型编码器。之后在configureParameters添加一个关于你编码枚举的附加参量。开启这个枚举,合适地为参数编码。
添加附加头文件
在Router中创建一个名为addAdditionalHeaders的函数
addAdditionalHeaders方法的实现
添加所有附加头文件,让它们成为请求头文件的一部分。
取消
取消函数的实现是这样的:
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。
NetworkManager代码
网络响应
在NetworkManager中创建一个名为NetworkResponse的枚举。
NetworkResponse枚举
我们将用这个枚举处理来自API的响应,并显示相应的信息。
结果
在NetworkManager中创建一个枚举Result。
Result枚举
一个结果枚举可以用在很多不同事情上,非常有用。我们根据结果确定我们对API的调用是成功还是失败。如果失败了,我们会返回一个错误信息并说明原因。想了解更多面向结果的编程,你可以看这篇对话。
处理网络响应
创建一个名为handleNetworkResponse的函数,这个函数有一个参量,即HTTPResponse,并返回一个Result.
这里我们开启HTTPResponse的状态码,状态码是一个能告诉我们响应状态的HTTP协议。基本上200-299之间都是成功。
产生调用
现在我们已经为我们的网络层打下雄厚的基础。是时候开始调用了。
我们将会从API获取一个新电影列表。创建一个名为getNewMovies的函数。
getNewMovies方法的实现
让我们分解这个方法的每一步
- 我们定义getNewMovies方法含有2个参量:一个页码和一个能返回电影数组或错误信息的完成部分(completion)。
- 我们调用我们的路由器,输入页码并在一个闭包(closure)内处理这个完成部分。
- 如果没有网络或者出于一些原因无法调用API,URLSession会返回错误。请注意这并不是API的失败。这种失败多是客服端的,很可能是因为网络连接不好。
- 我们需要把我们的响应转变为一个HTTPURLResponse,因为我们需要访问状态码属性。
- 我们声明一个从handleNetworkResponse方法得到的结果,之后在switch-case区块检查这个结果。
- 成功意味着我们成功地和API联系,并得到一个适当的响应。之后我们检查这个响应是否携带数据。如果没有数据我们就用返回语句退出这个方法。
- 如果携带有数据,我们需要把数据编码成我们的模式,之后我们把编码好的电影传递给完成部分。
- 如果结果是失败,我们就把错误传递给完成部分。
这就完成了,这就是我们不依赖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写的代码
NetworkRouterCompletion是我们实现的。即使我们实现了它,有时候也很难记清它是哪种类型,我们该怎么用它。我们喜欢的Xcode有解决办法。只要在占位符上双击,Xcode就会告诉你。
结论
我们有了一个简单好用,面向协议,还可以自己定制的网络层。我们能完全控制它的功能,完全理解它的机制。通过进行这个练习,我可以说我本人学到不少新事情。所以比起那些只需要装一个库就能完成的工作,我对这项工作更感到自豪。希望这篇文章能说明,用Swift创建你自己的网络层并没那么难。只要不做这样的事情就行了:
你可以在我的GitHub上找到源代码,感谢阅读。