|
|
51CTO旗下网站
|
|
移动端

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

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

作者:佚名来源:Malcolm Kumwenda|2018-05-10 13:45

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

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

  • 面向协议
  • 易用
  • 容易实现
  • 类型安全
  • 用枚举(enums)来配置终端(endPoints)

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

2.png
这个项目的最终目标

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

首先,一些结构

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

3.png
项目文件夹结构

终端类型(EndPointType)协议

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

4.png
终端类型协议

HTTP协议

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

HTTP方法

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

5.png
HTTPMethod枚举

HTTP任务

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

6.png
HTTPTask枚举

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

HTTP头文件

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

  1. public typealias HTTPHeaders = [String:String] 

参数与编码

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

  1. public typealias Parameters = [String:Any

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

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

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

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

7.png
NetworkError枚举

URL参数编码器

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

8.png
URL参数编码器代码

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

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

JSON参数编码器

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

9.png
JSON参数编码器代码

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

网络路由器

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

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

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

10.png
NetworkRouter代码

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

路由器

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

11.png
Router方法存根

请求

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

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

12.png
Request方法代码

建立请求

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

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

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

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

开启route.task

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

13.png
buildRequest方法代码.

配置参数

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

14.png
configureParameters方法的实现

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

添加附加头文件

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

15.png
addAdditionalHeaders方法的实现

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

取消

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

16.png
cancel方法的实现

实践

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

电影终端(MovieEndPoint)

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

  1. import Foundation   
  2.   
  3. enum NetworkEnvironment { 
  4.     case qa 
  5.     case production 
  6.     case staging 
  7.   
  8. public enum MovieApi { 
  9.     case recommended(id:Int
  10.     case popular(page:Int
  11.     case newMovies(page:Int
  12.     case video(id:Int
  13.   
  14. extension MovieApi: EndPointType { 
  15.      
  16.     var environmentBaseURL : String { 
  17.         switch NetworkManager.environment { 
  18.         case .production: return "https://api.themoviedb.org/3/movie/" 
  19.         case .qa: return "https://qa.themoviedb.org/3/movie/" 
  20.         case .staging: return "https://staging.themoviedb.org/3/movie/" 
  21.         } 
  22.     } 
  23.       
  24.     var baseURL: URL { 
  25.         guard let url = URL(string: environmentBaseURL) else { fatalError("baseURL could not be configured.")} 
  26.         return url 
  27.     } 
  28.      
  29.     var path: String { 
  30.         switch self { 
  31.         case .recommended(let id): 
  32.             return "\(id)/recommendations" 
  33.         case .popular: 
  34.             return "popular" 
  35.         case .newMovies: 
  36.             return "now_playing" 
  37.         case .video(let id): 
  38.             return "\(id)/videos" 
  39.         } 
  40.     } 
  41.       
  42.     var httpMethod: HTTPMethod { 
  43.         return .get 
  44.     } 
  45.       
  46.     var task: HTTPTask { 
  47.         switch self { 
  48.         case .newMovies(let page): 
  49.             return .requestParameters(bodyParameters: nil, 
  50.                                       urlParameters: ["page":page, 
  51.                                                       "api_key":NetworkManager.MovieAPIKey]) 
  52.         default
  53.             return .request 
  54.         } 
  55.     } 
  56.       
  57.     var headers: HTTPHeaders? { 
  58.         return nil 
  59.     } 

终端类型

电影模式(MovieModel)

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

  1. import Foundation 
  2.   
  3. struct MovieApiResponse { 
  4.     let page: Int 
  5.     let numberOfResults: Int 
  6.     let numberOfPages: Int 
  7.     let movies: [Movie] 
  8.   
  9. extension MovieApiResponse: Decodable { 
  10.      
  11.     private enum MovieApiResponseCodingKeys: String, CodingKey { 
  12.         case page 
  13.         case numberOfResults = "total_results" 
  14.         case numberOfPages = "total_pages" 
  15.         case movies = "results" 
  16.     } 
  17.      
  18.     init(from decoder: Decoder) throws { 
  19.         let container = try decoder.container(keyedBy: MovieApiResponseCodingKeys.self) 
  20.           
  21.         page = try container.decode(Int.self, forKey: .page) 
  22.         numberOfResults = try container.decode(Int.self, forKey: .numberOfResults) 
  23.         numberOfPages = try container.decode(Int.self, forKey: .numberOfPages) 
  24.         movies = try container.decode([Movie].self, forKey: .movies) 
  25.           
  26.     } 
  27.   
  28.   
  29. struct Movie { 
  30.     let id: Int 
  31.     let posterPath: String 
  32.     let backdrop: String 
  33.     let title: String 
  34.     let releaseDate: String 
  35.     let rating: Double 
  36.     let overview: String 
  37.   
  38. extension Movie: Decodable { 
  39.      
  40.     enum MovieCodingKeys: String, CodingKey { 
  41.         case id 
  42.         case posterPath = "poster_path" 
  43.         case backdrop = "backdrop_path" 
  44.         case title 
  45.         case releaseDate = "release_date" 
  46.         case rating = "vote_average" 
  47.         case overview 
  48.     } 
  49.       
  50.      
  51.     init(from decoder: Decoder) throws { 
  52.         let movieContainer = try decoder.container(keyedBy: MovieCodingKeys.self) 
  53.          
  54.         id = try movieContainer.decode(Int.self, forKey: .id) 
  55.         posterPath = try movieContainer.decode(String.self, forKey: .posterPath) 
  56.         backdrop = try movieContainer.decode(String.self, forKey: .backdrop) 
  57.         title = try movieContainer.decode(String.self, forKey: .title) 
  58.         releaseDate = try movieContainer.decode(String.self, forKey: .releaseDate) 
  59.         rating = try movieContainer.decode(Double.self, forKey: .rating) 
  60.         overview = try movieContainer.decode(String.self, forKey: .overview) 
  61.     } 

电影模式

网络管理员

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

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

17.png
NetworkManager代码

网络响应

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

18.png
NetworkResponse枚举

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

结果

在NetworkManager中创建一个枚举Result。

19.png
Result枚举

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

处理网络响应

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

20.png

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

产生调用

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

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

21.png
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。

  1. class MainViewController: UIViewController { 
  2.      
  3.     var networkManager: NetworkManager! 
  4.       
  5.     init(networkManager: NetworkManager) { 
  6.         super.init(nibName: nil, bundle: nil) 
  7.         self.networkManager = networkManager 
  8.     } 
  9.      
  10.     required init?(coder aDecoder: NSCoder) { 
  11.         fatalError("init(coder:) has not been implemented"
  12.     } 
  13.       
  14.     override func viewDidLoad() { 
  15.         super.viewDidLoad() 
  16.         view.backgroundColor = .green 
  17.         networkManager.getNewMovies(page: 1) { movies, error in 
  18.             if let error = error { 
  19.                 print(error) 
  20.             } 
  21.             if let movies = movies { 
  22.                 print(movies) 
  23.             } 
  24.         } 
  25.     } 

MainViewControoler的示例

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

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

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

小技巧

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

22.png

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

23.png

结论

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

24.jpg

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

【编辑推荐】

  1. Swift实践:使用CoreData完成一个通讯录存储
  2. TIOBE 11 月编程语言排行榜:iOS开发真没人要了?OC、Swift接连下滑
  3. 谷歌搞事情,Fuchsia OS操作系统运行Swift代码
  4. Swift多线程:GCD进阶,单例、信号量、任务组
  5. 谷歌开源 Swift for TensorFlow:我们是不是终于可以放下Python了?
【责任编辑:未丽燕 TEL:(010)68476606】

点赞 0
分享:
大家都在看
猜你喜欢

读 书 +更多

JAVA并发编程实践

本书既能够成为读者的理论支持,又可以作为构建可靠的、可伸缩的、可维护的并发程序的技术支持。本书并不仅仅提供并发API的清单及其机制,...

订阅51CTO邮刊

点击这里查看样刊

订阅51CTO邮刊