网络层封装(一):面向协议的网络层设计、Endpoint协议定义、Router模式实现
好,咱们今天正式开始动手封装网络层。
前面几章我们聊了URLSession的底层机制,也看了Combine怎么处理异步。但说实话,那些都是「零件」。真正到了项目里,你不能把URLSession的dataTask直接扔在ViewController里吧?那代码就乱成一锅粥了。
所以,我们需要一个清晰的架构。
我个人习惯,网络层封装的第一步,不是写代码,而是先想清楚「协议」。面向协议编程,说白了就是先定规矩,再干活。你想想看,如果每个网络请求都自己拼URL、自己处理参数、自己解析JSON,那项目大了以后,维护成本会直线飙升。
为什么一定要面向协议?
我刚开始做iOS那会儿,网络层就是一堆工具方法。后来接手一个老项目,发现一个网络请求能分散在三个文件里修改。嗯,那感觉,就像在迷宫里找出口。
面向协议的好处是什么?
- 解耦:调用方不关心底层是URLSession还是Alamofire。
- 可测试:协议可以轻松mock,单元测试好写。
- 可扩展:加一种请求方式,不需要改核心代码。
说白了,协议就是一份合同。你遵守我的合同,我就帮你发请求、拿数据。
Endpoint协议定义
我们先从最基础的开始——定义一个Endpoint协议。
一个网络请求,需要哪些信息?
- 请求的路径(path)
- HTTP方法(GET、POST等)
- 请求头(headers)
- 参数(parameters)
- 可能还有超时时间、缓存策略等
把这些抽象成一个协议,就是我们的Endpoint。
protocol Endpoint {
var baseURL: String { get }
var path: String { get }
var method: HTTPMethod { get }
var headers: [String: String]? { get }
var parameters: [String: Any]? { get }
var timeoutInterval: TimeInterval { get }
}
enum HTTPMethod: String {
case GET
case POST
case PUT
case DELETE
case PATCH
}
这里有个细节。baseURL我放在协议里,而不是写死。为什么?因为我在项目中遇到过,同一个App对接多个后端服务的情况。比如用户服务一个域名,订单服务另一个域名。如果baseURL写死,那就得拆协议了。
Router模式实现
协议定义好了,接下来怎么用?
我推荐Router模式。说白了,就是一个枚举,每个case对应一个API接口。这样所有接口集中管理,一目了然。
enum UserRouter: Endpoint {
case login(username: String, password: String)
case profile(userId: String)
case updateProfile(userId: String, name: String)
var baseURL: String {
return "https://api.example.com"
}
var path: String {
switch self {
case .login:
return "/v1/user/login"
case .profile(let userId):
return "/v1/user/\(userId)/profile"
case .updateProfile(let userId, _):
return "/v1/user/\(userId)/profile"
}
}
var method: HTTPMethod {
switch self {
case .login:
return .POST
case .profile:
return .GET
case .updateProfile:
return .PUT
}
}
var parameters: [String: Any]? {
switch self {
case .login(let username, let password):
return ["username": username, "password": password]
case .profile:
return nil
case .updateProfile(_, let name):
return ["name": name]
}
}
var headers: [String: String]? {
return ["Content-Type": "application/json"]
}
var timeoutInterval: TimeInterval {
return 30.0
}
}
你看,每个接口的配置都集中在一个枚举里。要加一个新接口,加一个case,实现对应的属性就行。要改一个接口,也只需要改这一个地方。
构建URLRequest
有了Endpoint协议和Router,我们还需要一个「翻译官」,把Endpoint转成URLRequest。这一步很关键,因为URLSession只认URLRequest。
extension Endpoint {
func asURLRequest() throws -> URLRequest {
guard var urlComponents = URLComponents(string: baseURL + path) else {
throw NetworkError.invalidURL
}
// GET请求参数拼在URL上
if method == .GET, let params = parameters {
urlComponents.queryItems = params.map {
URLQueryItem(name: $0.key, value: "\($0.value)")
}
}
guard let url = urlComponents.url else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.timeoutInterval = timeoutInterval
// 非GET请求,参数放body
if method != .GET, let params = parameters {
request.httpBody = try JSONSerialization.data(withJSONObject: params)
}
// 设置请求头
headers?.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
return request
}
}
enum NetworkError: Error {
case invalidURL
case encodingFailed
}
这里有个小细节。GET请求的参数拼在URL上,POST/PUT的参数放body。这是HTTP规范,但很多新手会搞混。我见过有人把POST参数也拼在URL上,结果后端解析不到。
协议扩展的妙用
协议的好处,就是可以给默认实现。比如timeoutInterval,大部分接口用30秒就够了。我们可以在协议扩展里给个默认值,个别接口再覆盖。
extension Endpoint {
var timeoutInterval: TimeInterval {
return 30.0
}
var headers: [String: String]? {
return ["Content-Type": "application/json"]
}
}
这样,你的Router枚举里,只需要实现必须的属性(baseURL、path、method、parameters),其他用默认值就行。代码量直接减半。
实际项目中的考量
嗯,这里再聊点实战经验。
第一,baseURL要不要加密?我建议不要。baseURL本身不是敏感信息,加密反而增加复杂度。但如果你对接的是金融类App,可以考虑用HTTPS + 证书绑定。
第二,参数编码。上面我用的是JSONSerialization,但实际项目中,我更喜欢用Codable。后面我们会专门讲怎么把Codable集成进来。
第三,错误处理。NetworkError现在只有两个case,后面会扩展。比如超时、无网络、服务器错误等。这些我们下一章细聊。
小结
今天的内容,说白了就是三件事:
- 定义Endpoint协议,把网络请求抽象成接口。
- 用Router模式(枚举)集中管理所有API。
- 写一个协议扩展,把Endpoint转成URLRequest。
这三步走完,你的网络层就有了骨架。后面要加缓存、加拦截器、加Combine支持,都是在这个骨架上添砖加瓦。
下一章,我们聊聊怎么把URLSession和这个Router结合起来,真正发出请求。到时候你会看到,面向协议的设计,让代码变得多干净。