网络层封装(一):面向协议的网络层设计、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写死,那就得拆协议了。

我的习惯: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,实现对应的属性就行。要改一个接口,也只需要改这一个地方。

避坑指南:我曾经在项目里把参数直接写在URL里,结果后端改了参数名,我找了半天才找到是哪个请求。用Router模式后,参数和路径分离,改起来清晰多了。

构建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,后面会扩展。比如超时、无网络、服务器错误等。这些我们下一章细聊。

小结

今天的内容,说白了就是三件事:

  1. 定义Endpoint协议,把网络请求抽象成接口。
  2. 用Router模式(枚举)集中管理所有API。
  3. 写一个协议扩展,把Endpoint转成URLRequest。

这三步走完,你的网络层就有了骨架。后面要加缓存、加拦截器、加Combine支持,都是在这个骨架上添砖加瓦。

下一章,我们聊聊怎么把URLSession和这个Router结合起来,真正发出请求。到时候你会看到,面向协议的设计,让代码变得多干净。