Swift Combine: невозможно выполнить рефакторинг повторяющегося кода

#swift #dictionary #generics #combine

#swift #словарь #обобщения #объединение

Вопрос:

Мой API возвращает этот формат, где data могут содержаться все виды ответов.

 {
    status: // http status
    error?: // error handle
    data?:  // your response data
    meta?:  // meta data, eg. pagination
    debug?: // debuging infos
}
  

Я создал Codable тип ответа с общим значением для необязательных данных, тип которых нам неизвестен.

 struct MyResponse<T: Codable>: Codable {
    let status: Int
    let error: String?
    let data:  T?
    let meta: Paging?
    let debug: String?
}
  

Сейчас я пытаюсь написать удобные методы API как можно лаконичнее. Итак, у меня есть функция для возврата универсального издателя, который я могу использовать для всех этих ответов, то есть та, которая предварительно анализирует ответ и заранее выявляет любые ошибки.

Сначала я получаю dataTaskPublisher , который обрабатывает входные параметры, если таковые имеются. Endpoint это просто удобство String enum для моих конечных точек, Method аналогично. MyRequest возвращает URLRequest с некоторыми необходимыми заголовками и т.д.

Обратите внимание на то, как я определяю параметры: params: [String:T] . Это стандартный JSON, поэтому это могут быть строки, числа и т.д.
Похоже, что в этом T и заключается проблема каким-то образом..

 static fileprivate func publisher<T: Encodable>(
        _ path: Endpoint,
        method: Method,
        params: [String:T] = [:]) throws
        -> URLSession.DataTaskPublisher
    {
        let url = API.baseURL.appendingPathComponent(path.rawValue)
        var request = API.MyRequest(url: url)
        if method == .POST amp;amp; params.count > 0 {
            request.httpMethod = method.rawValue
            do {
                let data = try JSONEncoder().encode(params)
                request.httpBody = data
                return URLSession.shared.dataTaskPublisher(for: request)
            }
            catch let err {
                throw MyError.encoding(description: String(describing: err))
            }
        }
        return URLSession.shared.dataTaskPublisher(for: request)
    }
  

Далее я анализирую ответ.

 static func myPublisher<T: Encodable, R: Decodable>(
        _ path: Endpoint,
        method: Method = .GET,
        params: [String:T] = [:])
        -> AnyPublisher<MyResponse<R>, MyError>
    {
        do {
                
            return try publisher(path, method: method, params: params)
            .map(.data)
            .mapError { MyError.network(description: "($0)")}
            .decode(type: MyResponse<R>.self, decoder: self.agent.decoder)
            .mapError { MyError.encoding(description: "($0)")}             //(2)
            .tryMap {
                if $0.status > 204 {
                    throw MyError.network(description: "($0.status): ($0.error!)")
                }
                else {
                    return $0 // returns a MyResponse
                }
            }
            .mapError { $0 as! MyError }
                                                                            //(1)
            .eraseToAnyPublisher()
        }
        catch let err {
            return Fail<MyResponse<R>,MyError>(error: err as? MyError ??
                MyError.undefined(description: "(err)"))
            .eraseToAnyPublisher()
        }
    }
  

Теперь я могу легко написать метод конечной точки. Вот два примера.

 static func documents() -> AnyPublisher<[Document], MyError> {
    return myPublisher(.documents)
        .map(.data!)
        .mapError { MyError.network(description: $0.errorDescription) }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher() as AnyPublisher<[Document], MyError>
}
  

и

 static func user() -> AnyPublisher<User, MyError> {
    return myPublisher(.user)
        .map(.data!)
        .mapError { MyError.network(description: $0.errorDescription) }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher() as AnyPublisher<User, MyError>
}
  

Все это работает хорошо. Пожалуйста, обратите внимание, что каждый раз мне приходится дважды указывать свой точный возвращаемый тип. Я думаю, что смогу с этим смириться.

Я должен быть в состоянии упростить это, чтобы мне не приходилось каждый раз повторять одни и те же три оператора (map, mapError, receive) точно таким же образом.

Но когда я вставляю .map(.data!) в расположении //(1) выше, я получаю ошибку Generic parameter T could not be inferred. в расположении //(2) .

Это действительно сбивает с толку. Почему универсальный тип во входных параметрах играет здесь какую-либо роль? Это должно быть связано с вызовом .decode оператора чуть выше, где вызывается рассматриваемый универсальный код R , а не T .

Можете ли вы это объяснить? Как я могу выполнить рефакторинг этих операторов выше по потоку?

Комментарии:

1. Какими типами T могли бы быть, и как бы вы справились с этим JSON, если бы не использовали Combine?

2. T — это все типы, разрешенные json, но, по сути, String , Int и Float . Я справляюсь с этим так, как описано выше, это работает нормально.

3. О, я вижу проблему. Вас просто смутило сообщение об ошибке. Combine постоянно выдает эту ошибку компиляции! Ничего общего с вашим дженериком. Смотрите мои рекомендации по успешному редактированию кода Combine: apeth.com/UnderstandingCombine/tricksandtips.html

4. Но я не могу заставить это работать… Почему я не могу выполнить фильтрацию дальше по data полю? Вводом должен быть файл, MyResponse который имеет необязательный data путь к ключу. После завершения всего (перехват ошибки, получение в основной очереди) ошибка все еще существует. Что дальше?

5. Обратите особое внимание на раздел «Укажите явный тип возвращаемого значения». Но прочитайте это целиком. Следуйте моей методике, вы поймете это.

Ответ №1:

В этом коде есть ряд небольших проблем. Вы правы, что это так [String: T] . Это означает, что для данного набора параметров все значения должны быть одного типа. Это не «JSON». Это будет принимать [String: String] или a [String: Int] , но вы не сможете иметь оба значения Int и String в одном словаре, если вы сделаете это таким образом. И он также будет принят [String: Document] , и не похоже, что вы действительно этого хотите.

Я бы рекомендовал переключить это на просто Encodable, что позволило бы вам передавать структуры, если это было удобно, или словари, если это было удобно:

 func publisher<Params: Encodable>(
    _ path: Endpoint,
    method: Method,
    params: Params?) throws
-> URLSession.DataTaskPublisher

func myPublisher<Params: Encodable, R: Decodable>(
    _ path: Endpoint,
    method: Method = .GET,
    params: Params?)
-> AnyPublisher<MyResponse<R>, MyError>
  

Затем измените свой params.count , чтобы вместо этого проверять значение nil.

Обратите внимание, что я не создавал params = nil параметр по умолчанию. Это потому, что это воссоздало бы вторую проблему, которая у вас есть. T (и параметры) не могут быть выведены в случае по умолчанию. Для = [:] чего T ? Swift должен знать, даже если он пуст. Таким образом, вместо значения по умолчанию вы используете перегрузку:

 func myPublisher<R: Decodable>(
    _ path: Endpoint,
    method: Method = .GET)
-> AnyPublisher<MyResponse<R>, MyError> {
    let params: String? = nil // This should be `Never?`, see https://twitter.com/cocoaphony/status/1184470123899478017
    return myPublisher(path, method: method, params: params)
}
  

Теперь, когда вы не передаете никаких параметров, Params автоматически становится строкой.

Итак, теперь ваш код в порядке, и вам не нужно as в конце

 func documents() -> AnyPublisher<[Document], MyError> {
    myPublisher(.documents)
        .map(.data!)
        .mapError { MyError.network(description: $0.errorDescription) }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher() // <== Removed `as ...`
}
  

Теперь это .map(.data!) меня расстраивает. Если вы получите поврежденные данные с сервера, приложение завершит работу. Существует множество веских причин для аварийного завершения работы приложений; неверные данные сервера никогда не являются одной из них. Но исправление этого на самом деле не связано с этим вопросом (и немного сложно, потому что типы сбоев, отличные от Error, в настоящее время усложняют работу), поэтому я пока оставлю это. Моя общая рекомендация — использовать Error в качестве типа сбоя и допускать появление неожиданных ошибок, а не помещать их в .undefined case. Если вам все равно нужно какое-то универсальное «другое», вы могли бы также сделать это с types («is»), а не с дополнительным регистром перечисления (который просто переводит «is» в переключатель). По крайней мере, я бы перенес сопоставление Error-> MyError как можно позже, что значительно упростит обработку этого.

Еще одна настройка, чтобы сделать последующие вещи немного более общими, я подозреваю, что MyResponse должен быть только декодируемым, а не кодируемым (остальное работает в любом случае, но это делает его немного более гибким):

 struct MyResponse<T: Decodable>: Decodable { ... }
  

И на ваш первоначальный вопрос о том, как сделать это повторно используемым, теперь вы можете извлечь универсальную функцию:

 func fetch<DataType, Params>(_: DataType.Type,
                             from endpoint: Endpoint,
                             method: Method = .GET,
                             params: Params?) -> AnyPublisher<DataType, MyError>
where DataType: Decodable, Params: Encodable
{
    myPublisher(endpoint, method: method, params: params)
        .map(.data!)
        .mapError { MyError.network(description: $0.errorDescription) }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
}

// Overload to handle no parameters
func fetch<DataType>(_ dataType: DataType.Type,
                     from endpoint: Endpoint,
                     method: Method = .GET) -> AnyPublisher<DataType, MyError>
where DataType: Decodable
{
    fetch(dataType, from: endpoint, method: method, params: nil as String?)
}


func documents() -> AnyPublisher<[Document], MyError> {
    fetch([Document].self, from: .documents)
}
  

Комментарии:

1. Спасибо, это выглядит действительно хорошо. 1. Я реализовал Params? предложение, конечно, это имеет гораздо больше смысла. 2. Я пытаюсь избежать недостатков data! , сначала проверяя nil наличие и выдавая ошибку. 3. Я уже использовал перегрузку, для удобства, если нет параметров. 4. Передача DataType.Type функции — хорошая идея, это делает вызов API еще более кратким. Довольно элегантно.