Swift学习-使用 Combine, Codable 和 Swift 5 写一个通用的网络库(下)

2,764 阅读6分钟

这是我参与更文挑战的第29天,活动详情查看: 更文挑战

前言

我们大多数的app 都需要依赖网络调用获取数据, 多亏有了 URLSessionCodable,让我们的 REST APIs 调用变得很简单; 但是我们仍然需要写很多代码来处理异步回调,JSON解析,http 错误处理等等;

当然我们可以使用像 Alamofire 这样强大的网络库,因为它强大,所有他必须是多功能,多用途的,才能对大家在想用的时候就用,同时也包括了很多我们永远不会使用的功能。

考虑到这一点,打算编写一个简单的网络库,专门用于REST API 数据请求。

The Request

我们定义一个网络请求协议,这里面包括我们需要的一些常见的属性功能;

  • Path or URL
  • The HTTP Method (GET, POST, PUT, DELETE)
  • The request Body
  • headers

import Foundation
import Combine

public enum HTTPMethod: String {
    case get     = "GET"
    case post    = "POST"
    case put     = "PUT"
    case delete  = "DELETE"
}

public protocol Request {
    var path: String { get }
    var method: HTTPMethod { get }
    var contentType: String { get }
    var body: [String: Any]? { get }
    var headers: [String: String]? { get }
    associatedtype ReturnType: Codable
}

除了定义属性,我们还添加了一个关联类型:associatedTypeassociatedType 作为一个占位符在我们实现协议时使用

定义了协议之后,我们在扩展设置一些默认值。默认情况下, 请求使用 GET 方法,content-type: application/json 类型,它的正文、headers和query参数将为空。

extension Request {
    // Defaults
    var method: String { return .get }
    var contentType: String { return “application/json” }
    var queryParams: [String: String]? { return nil }
    var body: [String: Any]? { return nil }
    var headers: [String: String]? { return nil }
}

因为我们将 URLSession 执行所有网络调用,需要在写一个实用的方法,可以把自定义请求类型转换为普通的URL请求对象

两个方法:requestBodyFrom 序列化字典对象 ,asURLRequest 转换成一个 URLRequest 对象

extension Request {
    /// Serializes an HTTP dictionary to a JSON Data Object
    /// - Parameter params: HTTP Parameters dictionary
    /// - Returns: Encoded JSON
    private func requestBodyFrom(params: [String: Any]?) -> Data? {
        guard let params = params else { return nil }
        guard let httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) else {
            return nil
        }
        return httpBody
    }
    /// Transforms a Request into a standard URL request
    /// - Parameter baseURL: API Base URL to be used
    /// - Returns: A ready to use URLRequest
    func asURLRequest(baseURL: String) -> URLRequest? {
        guard var urlComponents = URLComponents(string: baseURL) else { return nil }
        urlComponents.path = "\(urlComponents.path)\(path)"
        guard let finalURL = urlComponents.url else { return nil }
        var request = URLRequest(url: finalURL)
        request.httpMethod = method.rawValue
        request.httpBody = requestBodyFrom(params: body)
        request.allHTTPHeaderFields = headers
        return request
    }
}

有了这个协议,定一个一个网络请求对象就变得非常简单

// Model
struct Todo: Codable {
   var title: String
   var completed: Bool
}
// Request
struct FindTodos: Request {
     typealias ReturnType = [Todo]
     var path: String = "/todos"
}

/todo将转化成 GET 请求,返回 TODO 项列表

The Dispatcher 调度器

请求已经准备好了,但我们还需要调用网络功能同时获取数据并解析,这里我们将使用 Combine 和 Codable. 第一步是定义一个枚举来保存错误代码。

enum NetworkRequestError: LocalizedError, Equatable {
    case invalidRequest
    case badRequest
    case unauthorized
    case forbidden
    case notFound
    case error4xx(_ code: Int)
    case serverError
    case error5xx(_ code: Int)
    case decodingError
    case urlSessionFailed(_ error: URLError)
    case unknownError
}

编写调度函数。通过使用泛型,我们可以定义返回类型,并返回一个Publisher,将请求的输出传递给它的订阅者。

我们的NetworkDispatcher将收到一个URL请求,通过网络请求并为我们解析返回的JSON数据。

NetworkDispatcher.swiftstruct NetworkDispatcher {
    let urlSession: URLSession!
    public init(urlSession: URLSession = .shared) {
        self.urlSession = urlSession
    }
    /// Dispatches an URLRequest and returns a publisher
    /// - Parameter request: URLRequest
    /// - Returns: A publisher with the provided decoded data or an error
    func dispatch<ReturnType: Codable>(request: URLRequest) -> AnyPublisher<ReturnType, NetworkRequestError> {
        return urlSession
            .dataTaskPublisher(for: request)
            // Map on Request response
            .tryMap({ data, response in
                // If the response is invalid, throw an error
                if let response = response as? HTTPURLResponse,
                   !(200...299).contains(response.statusCode) {
                    throw httpError(response.statusCode)
                }
                // Return Response data
                return data
            })
            // Decode data using our ReturnType
            .decode(type: ReturnType.self, decoder: JSONDecoder())
            // Handle any decoding errors
            .mapError { error in
                handleError(error)
            }
            // And finally, expose our publisher
            .eraseToAnyPublisher()
    }
}

我们使用 URLSession’s dataTaskPublisher 执行请求, 然后映射响应,正确的处理错误,如果请求成功完成,继续解析返回的JSON数据;

定义处理错误的函数, 第一:httpError,处理返回的 HTTP errors。 第二:handleError 处理JSON Decoding 中发生的错误

NetworkDispatcher.swiftextension NetworkDispatcher {
/// Parses a HTTP StatusCode and returns a proper error
    /// - Parameter statusCode: HTTP status code
    /// - Returns: Mapped Error
    private func httpError(_ statusCode: Int) -> NetworkRequestError {
        switch statusCode {
        case 400: return .badRequest
        case 401: return .unauthorized
        case 403: return .forbidden
        case 404: return .notFound
        case 402, 405...499: return .error4xx(statusCode)
        case 500: return .serverError
        case 501...599: return .error5xx(statusCode)
        default: return .unknownError
        }
    }
    /// Parses URLSession Publisher errors and return proper ones
    /// - Parameter error: URLSession publisher error
    /// - Returns: Readable NetworkRequestError
    private func handleError(_ error: Error) -> NetworkRequestError {
        switch error {
        case is Swift.DecodingError:
            return .decodingError
        case let urlError as URLError:
            return .urlSessionFailed(urlError)
        case let error as NetworkRequestError:
            return error
        default:
            return .unknownError
        }
    }
}

APIClient

Now that we have both our Request and Dispatcher, let’s create a type to wrap our API Calls.

现在我们有了RequestDispatche,再创建一个对象来包装我们的API请求;

我们的APIClient将收到一个NetworkDispatcher和一个BaseUrl,并将提供一个集中的请求方法。该方法将接收一个Request,将其转换为一个URL请求,并将其传递给提供的dispatcher

APIClient.swiftstruct APIClient {
    var baseURL: String!
    var networkDispatcher: NetworkDispatcher!
    init(baseURL: String,
                networkDispatcher: NetworkDispatcher = NetworkDispatcher()) {
        self.baseURL = baseURL
        self.networkDispatcher = networkDispatcher
    }
    /// Dispatches a Request and returns a publisher
    /// - Parameter request: Request to Dispatch
    /// - Returns: A publisher containing decoded data or an error
    func dispatch<R: Request>(_ request: R) -> AnyPublisher<R.ReturnType, NetworkRequestError> {
        guard let urlRequest = request.asURLRequest(baseURL: baseURL) else {
            return Fail(outputType: R.ReturnType.self, failure: NetworkRequestError.badRequest).eraseToAnyPublisher()
        }
        typealias RequestPublisher = AnyPublisher<R.ReturnType, NetworkRequestError>
        let requestPublisher: RequestPublisher = networkDispatcher.dispatch(request: urlRequest)
        return requestPublisher.eraseToAnyPublisher()
    }
}

返回的Publisher 要么响应请求错误,要么响应解析错误,而且可以自定义我们自己的NetworkDispatcher,测试爷非常容易。

Performing a request ,执行请求

到这里,如果我们要执行一个网络请求,我们可以这么做:

private var cancellables = [AnyCancellable]()
let dispatcher = NetworkDispatcher()
let apiClient = APIClient(baseURL: "https://jsonplaceholder.typicode.com")
apiClient.dispatch(FindTodos())
    .sink(receiveCompletion: { _ in },
          receiveValue: { value in
            print(value)
        })
    .store(in: &cancellables)

是不是很爽,在这种情况下,我们正在执行一个简单的GET请求,但是你也可以根据你的请求添加额外的参数,自定义你的请求对象

比如:如果我们想添加一个Todo,我们可以这样做:

// Our Add Request
struct AddTodo: Request {
     typealias ReturnType = [Todo]
     var path: String = "/todos"
     var method: HTTPMethod = .post
    var body: [String: Any]
    init(body: [String: Any]) {
        self.body = body
    }
}
let todo: [String: Any] = ["title": "Test Todo", "completed": true]
apiClient.dispatch(AddTodo(body: todo))
    .sink(receiveCompletion: { result in
        // Do something after adding...
        },
        receiveValue: { _ in })
    .store(in: &cancellables)

在这种情况下,我们从一个简单的字典构建正文,但是为了让事情变得更容易,让我们扩展Encoable,并添加一个方法来将Encoable Type转换 asDictionary。

extension Encodable {
    var asDictionary: [String: Any] {
        guard let data = try? JSONEncoder().encode(self) else { return [:] }
        guard let dictionary = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else {
            return [:]
        }
        return dictionary
    }
}

有了这个,你可以这么写你的请求:

let otherTodo: Todo = Todo(title: "Test", completed: true)
apiClient.dispatch(AddTodo(body: otherTodo.asDictionary))
    .sink(receiveCompletion: { result in
        // Do something after adding...
        },
        receiveValue: { _ in })
    .store(in: &cancellables)

结论

感谢苹果提供的Combine 和 Codable,让我们能够编写一个非常简单的网络客户端。请求类型是可扩展的,易于维护,我们的网络调度器和应用编程接口客户端都易于测试,使用极其简单。

当然你还可以扩展一些额外的功能,比如添加身份验证、缓存和更详细的日志记录,这样变得更加健壮可用!

本文翻译自:danielbernal.co/writing-a-n…

建议结合昨天写得上篇如何在Swift中创建通用的网络API(上)学习, 你将对swift网络请求有了更深一层的认识!

如果对你有帮助,就点赞再走 ❤️

明天继续在Swift路上前行!