实现应用程序的网络层时,常常有许多不同的服务器端点,我们需要支持,而每个端点可能返回不同类型的模型和数据,用于调用它们的底层逻辑往往非常相似,至少在一个代码库中是如此。

因此,本周,让我们来看看一些不同的技术,它们可以帮助我们尽可能多地分享常见的网络逻辑 - 同时也利用Swift的先进类型系统,使我们的网络代码更健壮,更容易验证。

Modeling shared structures

在使用某些web api时,尤其是那些遵循rest式设计的api时,在所有端点都通用的键下,接收包含实际数据的JSON响应是非常常见的。 例如,下面的JSON使用result作为顶级键:

{
    "result": {
        "id": "D4F28578-51BD-40F4-A8BD-387668E06EF8",
        "name": "John Sundell",
        "twitterHandle": "johnsundell",
        "gitHubUsername": "johnsundell"
    }
}

现在的问题是,什么是在客户端处理上述情况的优雅方式,特别是当使用Codable解码JSON响应为实际Swift模型类型?

一种选择是将我们想要提取的模型包装成一个专用的响应类型,然后我们可以直接从下载的数据中解码。例如,假设上面的JSON表示一个用户模型——这可能会导致我们创建以下嵌套的NetworkResponse包装器来解码这样的响应:

struct User: Identifiable, Codable {
    let id: UUID
    var name: String
    var twitterHandle: String
    var gitHubUsername: String
}

extension User {
    struct NetworkResponse: Codable {
        var result: User
    }
}

有了上面的内容,我们现在可以像这样加载和解码一个用户实例(使用Foundation的URLSession API的 Combine-powered 版本):

struct UserLoader {
    var urlSession = URLSession.shared

    func loadUser(withID id: User.ID) -> AnyPublisher<User, Error> {
        urlSession.dataTaskPublisher(for: resolveURL(forID: id))
            .map(\.data)
            .decode(type: User.NetworkResponse.self, decoder: JSONDecoder())
            .map(\.result)
            .eraseToAnyPublisher()
    }
}

虽然上面的代码肯定没有什么问题,但是总是必须为每个模型创建专用的NetworkResponse包装器可能会导致大量的重复-因为这也要求我们多次编写上述类型的网络请求代码。所以让我们看看我们是否能想出一个更通用的,可重用的抽象来代替。

考虑到我们的每个网络响应都遵循相同的结构,让我们从创建一个通用的NetworkResponse类型开始,我们可以在加载任何模型时使用它:

struct NetworkResponse<Wrapped: Decodable>: Decodable {
    var result: Wrapped
}

现在,我们不再需要为每种请求创建和维护单独的包装器类型,而是可以为每个具体的用例专门化上述类型——像这样:

struct UserLoader {
    var urlSession = URLSession.shared

    func loadUser(withID id: User.ID) -> AnyPublisher<User, Error> {
        urlSession.dataTaskPublisher(for: resolveURL(forID: id))
            .map(\.data)
            .decode(type: NetworkResponse<User>.self, decoder: JSONDecoder())
            .map(\.result)
            .eraseToAnyPublisher()
    }
}

虽然上面的方法确实是一种改进,但使用泛型类型对网络请求等建模的真正力量在于,我们可以不断创建实用程序和方便的api,让我们在执行这些任务时能够在更高的层次上工作。

例如,除了上述loadUser方法的返回类型之外,它的内部逻辑实际上没有什么是用户特定的 - 事实上,我们可能会在加载任何应用模型时编写或多或少完全相同的代码 - 所以让我们把这个逻辑提取到一个共享的抽象中:

extension URLSession {
    func publisher<T: Decodable>(
        for url: URL,
        responseType: T.Type = T.self,
        decoder: JSONDecoder = .init()
    ) -> AnyPublisher<T, Error> {
        dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: NetworkResponse<T>.self, decoder: decoder)
            .map(\.result)
            .eraseToAnyPublisher()
    }
}

注意我们是如何为responseType和decoder参数使用默认参数的——这对前者特别有用,因为这将使编译器能够从每个调用站点的周围上下文自动推断出泛型类型T。

因此,如果我们回到以前的UserLoader类型,我们现在只需要一行代码就可以执行所有所需的网络 - 因为编译器能够根据loadUser方法的返回类型推断我们正在寻找加载和解码用户模型:

struct UserLoader {
    var urlSession = URLSession.shared

    func loadUser(withID id: User.ID) -> AnyPublisher<User, Error> {
        urlSession.publisher(for: resolveURL(forID: id))
    }
}

这是一个很大的进步! 如此之多,以至于我们现在可能开始问自己,是否真的需要专门的类型来加载我们的每个模型 - 因为我们可以创建一个完全通用的ModelLoader,使用我们上面的URLSession扩展, 还有一个闭包,可以解析指定模型ID使用的URL,让我们能够在应用程序中加载任何模型——像这样:

struct ModelLoader<Model: Identifiable & Decodable> {
    var urlSession = URLSession.shared
    var urlResolver: (Model.ID) -> URL

    func loadModel(withID id: Model.ID) -> AnyPublisher<Model, Error> {
        urlSession.publisher(for: urlResolver(id))
    }
}

上面的ModelLoader类型当然可能不能满足我们所有的网络需求,但至少它可以让我们统一加载每个主要数据模型的方式。

Type-based validation

除了减少样板代码,创建泛型api还可以提供一种使代码更强类型化的方法,并使我们能够使用Swift的高级类型系统在编译时验证部分代码。

为了了解如何在网络环境中实现这一点,我们假设我们正在开发一个应用程序,它使用一个端点结构来定义它的各种网络请求
-例如这一个从“管理url和端点”插曲的Swift剪辑:

struct Endpoint {
    var path: String
    var queryItems = [URLQueryItem]()
}

特别是当应用程序调用许多不同的端点时,使用一个专用的类型来表示这些端点在类型安全性和方便性方面已经向前迈进了一大步 -然而,许多应用程序也将它们的端点划分为不同的范围或类型。例如,某些端点可能只在用户登录后才能有效调用,有些端点可能需要更高的权限,而有些端点可能根本不需要任何身份验证。

然而,由于上述端点类型目前已经实现,Swift编译器无法帮助我们验证是否允许在特定情况下调用给定的端点, 而且,它也没有为我们提供任何将上下文数据(如访问令牌或其他身份验证头)附加到我们将要发出的请求的方法。 因此,让我们看看是否可以使用泛型来解决这两个问题。

让我们首先创建一个带有两个需求的EndpointKind协议——一个定义执行给定请求所需的RequestData的关联类型,以及一个使用所需数据准备一个URLRequest实例的方法:

protocol EndpointKind {
    associatedtype RequestData
    
    static func prepare(_ request: inout URLRequest,
                        with data: RequestData)
}

要了解更多关于上面使用的inout关键字,以及它与值类型的关系,请查看“在Swift中利用值语义”。

接下来,让我们使用上面的协议为我们的应用程序所调用的每一种不同类型的端点实现具体类型。 在这个特定的例子中,我们将简单地为我们的公共端点定义一种类型,为我们的私有端点定义一种类型——像这样:

enum EndpointKinds {
    enum Public: EndpointKind {
        static func prepare(_ request: inout URLRequest,
                            with _: Void) {
            // Here we can do things like assign a custom cache
            // policy for loading our publicly available data.
            // In this example we're telling URLSession not to
            // use any locally cached data for these requests:
            request.cachePolicy = .reloadIgnoringLocalCacheData
        }
    }

    enum Private: EndpointKind {
        static func prepare(_ request: inout URLRequest,
                            with token: AccessToken) {
            // For our private endpoints, we'll require an
            // access token to be passed, which we then use to
            // assign an Authorization header to each request:
            request.addValue("Bearer \(token.rawValue)",
                forHTTPHeaderField: "Authorization"
            )
        }
    }
}

请注意我们是如何使用Void作为我们的公共类型的RequestData的,因为它在发出请求时不需要传递任何特定的数据。然后,在该类型的prepare方法中使用下划线作为内部参数标签,忽略该形参。

上面的部分都准备好了,现在让我们在之前的端点结构中添加两个通用类型——一个告诉我们给定实例属于什么EndpointKind,另一个定义每个端点响应应该被解码成什么响应类型:

struct Endpoint<Kind: EndpointKind, Response: Decodable> {
    var path: String
    var queryItems = [URLQueryItem]()
}

此时,我们将上面的Kind和Response类型用作幻像类型,因为它们不用于在端点结构中存储任何形式的数据。关于这个主题的更多信息,请查看“Swift中的幽灵类型”。

接下来,我们需要一种将端点值转换为URLRequest实例的方法——这可以通过结合Foundation的URLComponents API和我们之前在EndpointKind协议中定义的prepare方法来实现:

extension Endpoint {
    func makeRequest(with data: Kind.RequestData) -> URLRequest? {
        var components = URLComponents()
        components.scheme = "https"
        components.host = "api.myapp.com"
        components.path = "/" + path
        components.queryItems = queryItems.isEmpty ? nil : queryItems

        // If either the path or the query items passed contained
        // invalid characters, we'll get a nil URL back:
        guard let url = components.url else {
            return nil
        }

        var request = URLRequest(url: url)
        Kind.prepare(&request, with: data)
        return request
    }
}

现在,我们不再使用原始url来执行各种请求,让我们再次使用一个方便的API来扩展URLSession,使使用我们新的通用端点类型来执行请求变得非常容易。我们将使用与构建之前基于networkresponse的扩展时非常相似的方法——只是这次我们将使用泛型类型来确保始终为每个端点使用正确的请求和响应类型:

extension URLSession {
    func publisher<K, R>(
        for endpoint: Endpoint<K, R>,
        using requestData: K.RequestData,
        decoder: JSONDecoder = .init()
    ) -> AnyPublisher<R, Error> {
        guard let request = endpoint.makeRequest(with: requestData) else {
            return Fail(
                error: InvalidEndpointError(endpoint: endpoint)
            ).eraseToAnyPublisher()
        }

        return dataTaskPublisher(for: request)
            .map(\.data)
            .decode(type: NetworkResponse<R>.self, decoder: decoder)
            .map(\.result)
            .eraseToAnyPublisher()
    }
}

拥有完全类型安全的网络管道和通用端点结构的强大之处在于,我们现在可以在定义应用程序的各种端点时使用通用类型约束 -这使得执行每个请求所需要的数据类型以及产生的响应类型都变得非常清晰。

下面是我们如何使用静态工厂方法定义两个类型受限的api——一个用于公共端点,一个用于私有端点:

extension Endpoint where Kind == EndpointKinds.Public, Response == [Item] {
    static var featuredItems: Self {
        Endpoint(path: "featured")
    }
}

extension Endpoint where Kind == EndpointKinds.Private,
                         Response == SearchResults {
    static func search(for query: String) -> Self {
        Endpoint(path: "search", queryItems: [
            URLQueryItem(name: "q", value: query)
        ])
    }
}

尽管让我们的网络请求更强类型确实需要我们构建大量的底层基础设施,但实际上,现在发出请求比以往任何时候都要简单 -编译器会自动验证我们是否为每个给定的端点传递了正确的请求数据, 并且我们的返回类型与我们的网络调用相匹配——所有这些都在每个调用站点为我们提供了一个非常漂亮和简洁的语法:

struct SearchResultsLoader {
    var urlSession = URLSession.shared
    var userSession: UserSession

    func loadResults(
        matching query: String
    ) -> AnyPublisher<SearchResults, Error> {
        urlSession.publisher(
            for: .search(for: query),
            using: userSession.accessToken
        )
    }
}

当然,我们可以进一步延长上述网络系统,例如为了支持不同的HTTP方法(比如POST和PUT),各种载荷、更细粒度的错误处理,等等,所以我们可能会回到网络的话题再次在未来的文章中。

Conclusion

当有策略地部署时,泛型不仅可以使我们摆脱常见的样板源,而且还可以通过更强的类型和更严格的编译时验证来帮助我们改进代码库的某些部分。 然而,同样重要的是要记住,使用泛型也会使某些代码变得更加复杂和难于维护——因此在简单性和强大性之间取得良好的平衡确实是关键。

例如,一些项目可能不需要完全通用的EndpointKind系统,而另一些项目可能不会从将每个端点与通用响应类型关联中获益太多 - 尽管这两种技术在项目增长或处理大量端点时都非常有用。

原文链接