关联值的枚举 + 泛型 + 具有关联类型的协议

5

我希望将我的API服务尽可能地设计成通用的:

API服务类

class ApiService {
  func send<T>(request: RestRequest) -> T {
    return request.parse()
  }
}

为了让编译器从请求类别.auth.data中推断出响应类型:
let apiService = ApiService()

// String
let stringResponse = apiService.send(request: .auth(.signupWithFacebook(token: "9999999999999")))
// Int
let intResponse = apiService.send(request: .data(.content(id: "123")))

我尝试使用泛型和一个带有相关类型的协议来清晰地处理解析问题。然而,我在将请求和不同响应类型关联起来时遇到了困难,以一种简单且类型安全的方式:

protocol Parseable {
  associatedtype ResponseType
  func parse() -> ResponseType
}

端点

enum RestRequest {

  case auth(_ request: AuthRequest)
  case data(_ request: DataRequest)

  // COMPILER ERROR HERE: Generic parameter 'T' is not used in function signature
  func parse<T: Parseable>() -> T.ResponseType {
    switch self {
    case .auth(let request): return (request as T).parse()
    case .data(let request): return (request as T).parse()
    }
  }

  enum AuthRequest: Parseable {
    case login(email: String, password: String)
    case signupWithFacebook(token: String)

    typealias ResponseType = String
    func parse() -> ResponseType {
        return "String!!!"
    }
  }
  enum DataRequest: Parseable {
    case content(id: String?)
    case package(id: String?)

    typealias ResponseType = Int
    func parse() -> ResponseType {
        return 16
    }
  }
}

尽管我使用了 T.ResponseType 作为函数的返回值,但为什么在函数签名中没有使用 T

有更好且仍然简洁的方法来实现这一点吗?

1个回答

12

我试图尽可能使我的API服务更加通用:

首先,最重要的是,这不应该是一个目标。相反,您应该从使用案例开始,并确保您的API服务满足它们。 "尽可能通用" 毫无意义,只会让您在向事物添加 "通用功能" 时进入类型噩梦,这与能够普遍适用于许多用例不同。哪些调用者需要这种灵活性?从调用者开始,然后协议就会跟随。

func send<T>(request: RestRequest) -> T

接下来,这是一个非常糟糕的签名。你不想在返回类型上使用类型推断。这很难以管理。相反,在Swift中处理此问题的标准方式是:

func send<ResultType>(request: RestRequest, returning: ResultType.type) -> ResultType

通过将预期的结果类型作为参数传递,您可以摆脱类型推断带来的烦恼。这种烦恼看起来像这样:

let stringResponse = apiService.send(request: .auth(.signupWithFacebook(token: "9999999999999")))
编译器怎样知道stringResponse应该是一个字符串?这里没有写"String"。所以你必须这样做:
let stringResponse: String = ...

那很丑的 Swift 代码。相反,你可能想要以下代码(但实际上不是):

And that's very ugly Swift. Instead you probably want (but not really):
let stringResponse = apiService.send(request: .auth(.signupWithFacebook(token: "9999999999999")),
                                     returning: String.self)

“但其实不行”,因为这样做没有好的实现方式。 send 如何知道如何将“我得到的任何响应”翻译成“一个被称为 String 的未知类型”? 那会有什么作用呢?

protocol Parseable {
  associatedtype ResponseType
  func parse() -> ResponseType
}

这个带关联类型的PAT协议并不是很合理。它说如果一个实例能返回一个ResponseType,那么就可以被解析。但这应该是一个解析器而不是“可以被解析的东西”。

如果要解析某些内容,你需要一个init方法,可以接受一些输入并创建自己。通常最好使用Codable,但你也可以自己创建,例如:

protocol Parseable {
    init(parsing data: Data) throws
}

但我更倾向于使用Codable,或者只是传递解析函数(见下文)。

enum RestRequest {}

如果你想要通用性,则枚举可能是不恰当的使用方式。每次创建新的 RestRequest 都需要更新 parse,这种代码写法不正确。枚举使得添加“所有实例都实现的新内容”很容易,但是添加“新类型的实例”却很困难。结构体(+ 协议)则相反。它们使得添加协议的新类型很容易,但是添加新的协议需求却很困难。在一个通用系统中,特别是请求(Requests),属于后者。你需要经常添加新的请求。而使用枚举就会变得困难。

还有更好且干净的方法来实现吗?

这取决于“this”是什么。你的调用代码是什么样的?你当前的系统是否存在代码重复,需要消除?你的使用情况是什么?“尽可能通用”并不存在。只有可以适应用例的系统。不同的配置轴线导致不同类型的多态,并具有不同的权衡。

你希望你的调用代码是什么样子?

这里只提供一个示例,比如像这样:

final class ApiService {
    let urlSession: URLSession
    init(urlSession: URLSession = .shared) {
        self.urlSession = urlSession
    }

    func send<Response: Decodable>(request: URLRequest,
                                   returning: Response.Type,
                                   completion: @escaping (Response?) -> Void) {
        urlSession.dataTask(with: request) { (data, response, error) in
            if let error = error {
                // Log your error
                completion(nil)
                return
            }

            if let data = data {
                let result = try? JSONDecoder().decode(Response.self, from: data)
                // Probably check for nil here and log an error
                completion(result)
                return
            }
            // Probably log an error
            completion(nil)
        }
    }
}

这是非常通用的,可以适用于许多种用例(虽然这种特定形式非常原始)。您可能会发现它并不适用于所有用例,所以您需要扩展它。例如,也许您不想在这里使用 Decodable。您想要一个更通用的解析器。那没问题,使解析器可配置:

func send<Response>(request: URLRequest,
                    returning: Response.Type,
                    parsedBy: @escaping (Data) -> Response?,
                    completion: @escaping (Response?) -> Void) {

    urlSession.dataTask(with: request) { (data, response, error) in
        if let error = error {
            // Log your error
            completion(nil)
            return
        }

        if let data = data {
            let result = parsedBy(data)
            // Probably check for nil here and log an error
            completion(result)
            return
        }
        // Probably log an error
        completion(nil)
    }
}
也许您想要同时采用这两种方法。没问题,可以在其中一种方法之上构建另一种方法:
func send<Response: Decodable>(request: URLRequest,
                               returning: Response.Type,
                               completion: @escaping (Response?) -> Void) {
    send(request: request,
         returning: returning,
         parsedBy: { try? JSONDecoder().decode(Response.self, from: $0) },
         completion: completion)
}

如果您想深入了解这个主题,您可能会对"Beyond Crusty"感兴趣,该文章包含一个实际例子,展示了如何将您所讨论的解析器串联在一起。它有些过时了,Swift协议现在更加强大,但基本信息没有改变,例如本例中的parsedBy就是建立在此基础上的。


我理解您的建议,但是有一些限制(过去的错误决策和非常庞大的遗留代码库)不允许我以更好、更干净、更快速的方式重新编写整个API(至少现在不行)。相信我,我自己也不会选择这种“尽可能通用”的路径。不过还是非常感谢您详细的回答! - juliancadi
很久没有遇到这么详尽和写得这么好的答案了,干得好 Rob! - Daniel Galasko

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接