Swift 合并链接 .mapError()

Swift Combine chaining .mapError()

我正在尝试实现类似于下面呈现的场景(创建 URL、请求服务器、解码 json、自定义 NetworkError 枚举中包含的每个步骤的错误) :

enum NetworkError: Error {
    case badUrl
    case noData
    case request(underlyingError: Error)
    case unableToDecode(underlyingError: Error)
}

//...
    func searchRepos(with query: String, success: @escaping (ReposList) -> Void, failure: @escaping (NetworkError) -> Void) {
        guard let url = URL(string: searchUrl + query) else {
            failure(.badUrl)
            return
        }

        session.dataTask(with: url) { data, response, error in
            guard let data = data else {
                failure(.noData)
                return
            }

            if let error = error {
                failure(.request(underlyingError: error))
                return
            }

            do {
                let repos = try JSONDecoder().decode(ReposList.self, from: data)

                DispatchQueue.main.async {
                    success(repos)
                }
            } catch {
                failure(.unableToDecode(underlyingError: error))
            }
        }.resume()
    }

我在 Combine 中的解决方案有效:

    func searchRepos(with query: String) -> AnyPublisher<ReposList, NetworkError> {
        guard let url = URL(string: searchUrl + query) else {
            return Fail(error: .badUrl).eraseToAnyPublisher()
        }

        return session.dataTaskPublisher(for: url)
            .mapError { NetworkError.request(underlyingError: [=12=]) }
            .map { [=12=].data }
            .decode(type: ReposList.self, decoder: JSONDecoder())
            .mapError { [=12=] as? NetworkError ?? .unableToDecode(underlyingError: [=12=]) }
            .subscribe(on: DispatchQueue.global())
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }

但我真的不喜欢这行

.mapError { [=13=] as? NetworkError ?? .unableToDecode(underlyingError: [=13=]) }

我的问题:

  1. 在 Combine 中使用链接是否有更好的方法来映射错误(并替换上面的行)?
  2. 有没有办法在链中包含第一个 guard letFail(error:)

我不认为你的做法不合理。第一个 mapError()(在 // 1)的一个好处是您不需要了解请求中可能出现的错误。

    return session.dataTaskPublisher(for: url)
        .mapError { NetworkError.request(underlyingError: [=10=]) }   // 1
        .map { [=10=].data }
        .decode(type: ReposList.self, decoder: JSONDecoder())
        .mapError { [=10=] as? NetworkError ?? .unableToDecode(underlyingError: [=10=]) }
        .subscribe(on: DispatchQueue.global())   // 2 - not needed
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
    }

我认为您不需要 // 2 处的 subscribe(on:),因为 URLSession.DataTaskPublisher 已经在后台线程上启动了。后面的receive(on:)是必填项。

另一种方法是先 运行 通过 "happy path" 然后映射所有错误,如下所示。您需要了解哪些错误来自哪些 publishers/operators 才能正确映射到您的 NetworkError 枚举。

    return session.dataTaskPublisher(for: url)
        .map { [=11=].data }
        .decode(type: ReposList.self, decoder: JSONDecoder())
        .mapError({ error -> NetworkError in
            // map all the errors here
        })
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()

要处理您的第二个问题,您可以使用 tryMap()flatMap() 将您的 query 映射到 URL,然后再映射到 URLSession.DataTaskPublisher实例。我还没有测试过这个特定的代码,但解决方案应该遵循这些思路。

    Just(query)
        .tryMap({ query in
            guard let url = URL(string: searchUrl + query) else { throw NetworkError.badUrl }
            return url
        })
        .flatMap({ url in
            URLSession.shared.dataTaskPublisher(for: url)
                .mapError { [=12=] as Error }
        })
        .map { [=12=].data }
        //
        // ... operators from the previous examples
        //
        .eraseToPublisher()

我同意 iamtimmo 的观点,您不需要 .subscribe(on:)。我也认为这个方法是.receive(on:)的错误地方,因为方法中没有任何东西需要主线程。如果您在其他地方有订阅此发布者的代码并希望在主线程上获得结果,那么您应该在此处使用 receive(on:) 运算符。我将在这个答案中省略 .subscribe(on:).receive(on:)

无论如何,让我们来解决您的问题。

  1. Is there better way to map errors (and replace line above) using chaining in Combine?

“更好”是主观的。您要在此处解决的问题是您只想将 mapError 应用于 decode(type:decoder:) 运算符产生的错误。您可以使用 flatMap 运算符在完整管道内创建一个迷你管道:

return session.dataTaskPublisher(for: url)
    .mapError { NetworkError.request(underlyingError: [=10=]) }
    .map { [=10=].data }
    .flatMap {
        Just([=10=])
            .decode(type: ReposList.self, decoder: JSONDecoder())
            .mapError { .unableToDecode(underlyingError: [=10=]) } }
    .eraseToAnyPublisher()

这是“更好”吗?嗯

您可以将迷你管道提取到新版本的 decode:

extension Publisher {
    func decode<Item, Coder>(type: Item.Type, decoder: Coder, errorTransform: @escaping (Error) -> Failure) -> Publishers.FlatMap<Publishers.MapError<Publishers.Decode<Just<Self.Output>, Item, Coder>, Self.Failure>, Self> where Item : Decodable, Coder : TopLevelDecoder, Self.Output == Coder.Input {
        return flatMap {
            Just([=11=])
                .decode(type: type, decoder: decoder)
                .mapError { errorTransform([=11=]) }
        }
    }
}

然后像这样使用它:

return session.dataTaskPublisher(for: url)
    .mapError { NetworkError.request(underlyingError: [=12=]) }
    .map { [=12=].data }
    .decode(
        type: ReposList.self,
        decoder: JSONDecoder(),
        errorTransform: { .unableToDecode(underlyingError: [=12=]) })
    .eraseToAnyPublisher()
  1. Is there any way to include first guard let with Fail(error:) in chain?

是的,但同样不清楚这样做是否更好。在这种情况下,queryURL 的转换不是异步的,因此没有理由使用 Combine。但如果你真的想这样做,这里有一个方法:

return Just(query)
    .setFailureType(to: NetworkError.self)
    .map { URL(string: searchUrl + [=13=]).map { Result.success([=13=]) } ?? Result.failure(.badUrl) }
    .flatMap { [=13=].publisher }
    .flatMap {
        session.dataTaskPublisher(for: [=13=])
        .mapError { .request(underlyingError: [=13=]) } }
    .map { [=13=].data }
    .decode(
        type: ReposList.self,
        decoder: JSONDecoder(),
        errorTransform: { .unableToDecode(underlyingError: [=13=]) })
    .eraseToAnyPublisher()

这很复杂,因为 Combine 没有任何运算符可以将正常输出或完成转换为类型化失败。它有 tryMap 和类似的东西,但它们都产生 Failure 类型的 Error 而不是更具体的东西。

我们可以编写一个运算符将空流转换为特定错误:

extension Publisher where Failure == Never {
    func replaceEmpty<NewFailure: Error>(withFailure failure: NewFailure) -> Publishers.FlatMap<Result<Self.Output, NewFailure>.Publisher, Publishers.ReplaceEmpty<Publishers.Map<Publishers.SetFailureType<Self, NewFailure>, Result<Self.Output, NewFailure>>>> {
        return self
            .setFailureType(to: NewFailure.self)
            .map { Result<Output, NewFailure>.success([=14=]) }
            .replaceEmpty(with: Result<Output, NewFailure>.failure(failure))
            .flatMap { [=14=].publisher }
    }
}

现在我们可以使用 compactMap 而不是 map 来将 query 变成 URL,如果我们不能创建 [=28] 则生成一个空流=],并使用我们的新运算符将空流替换为 .badUrl 错误:

return Just(query)
    .compactMap { URL(string: searchUrl + [=15=]) }
    .replaceEmpty(withFailure: .badUrl)
    .flatMap {
        session.dataTaskPublisher(for: [=15=])
        .mapError { .request(underlyingError: [=15=]) } }
    .map { [=15=].data }
    .decode(
        type: ReposList.self,
        decoder: JSONDecoder(),
        errorTransform: { .unableToDecode(underlyingError: [=15=]) })
    .eraseToAnyPublisher()