您可以定义一个枚举来表示您的应用程序明确已知的值,但仍然处理从后端解码的未知值吗?

Mar*_*eIV 3 enums swift codable

问题

您是否可以定义一个枚举来表示模型中属性的已知值,同时仍然允许从后端返回未知值?

简短回答:是的,可以!答案如下。

更多背景信息

作为我们应用程序的一部分,我们定义了一组功能标志,应用程序使用这些标志根据一组标准启用/禁用某些功能。这些标志作为字符串数组从后端发送回。

然而,在我们的应用程序中,我们不想处理混乱的字符串常量,而是希望将这些值定义为我们标记的枚举,以便Codable编译器自动为我们处理实际枚举情况的编码/解码。

这是此类场景的典型枚举......

enum FeatureFlag : String, CaseIterable, Codable {
    case allowsTrading
    case allowsFundScreener
    case allowsFundsTransfer
}
Run Code Online (Sandbox Code Playgroud)

这种设计的问题在于它不处理可能定义的值以及将来从后端返回的值。

有几种方法可以处理这种情况:

  1. 放弃枚举并转向字符串常量。这很容易出错,并且会破坏包含/范围界定,因为任何字符串都可以参与此逻辑。
  2. 坚持按原样使用枚举,并在后端更新时强制更新应用程序,将责任传递给部署。
  3. 更新后端以处理版本控制,以仅返回该版本的应用程序已知的值,这使后端的逻辑复杂化,以了解各种前端,但它们不应该这样做。
  4. 最常见的针对未知的防御性程序,通过为使用此枚举的每个类/结构编写自己的编码器/解码器方法,忽略当前案例列表未知的任何标志。

一到三本身就是维护噩梦。是的,四个更好,但是编写所有这些自定义序列化器/反序列化器可能非常耗时且容易出错,而且它无法利用编译器能够自动为您完成此操作的优势!

但如果有第五个呢?如果您可以使枚​​举本身在运行时优雅地处理未知值,同时在此过程中保持无损,并且不必求助于选项,该怎么办?

这就是我在下面介绍的确切解决方案!享受!

Mar*_*eIV 8

TL:DR - 解决方案

对于那些只想查看解决方案的人来说,这里是完整的解决方案。这允许您定义具有已知情况的枚举,但它可以处理运行时抛出的任何原始值,并以无损方式执行此操作以用于重新编码目的。

enum FeatureFlag : RawRepresentable, CaseIterable, Codable {

    typealias RawValue = String

    case allowsTrading
    case allowsFundScreener
    case allowsFundsTransfer
    case unknown(RawValue)

    static let allCases: AllCases = [
        .allowsTrading,
        .allowsFundScreener,
        .allowsFundsTransfer
    ]

    init(rawValue: RawValue) {
        self = Self.allCases.first{ $0.rawValue == rawValue }
               ?? .unknown(rawValue)
    }

    var rawValue: RawValue {
        switch self {
            case .allowsTrading       : return "ALLOWS_TRADING"
            case .allowsFundScreener  : return "ALLOWS_FUND_SCREENER"
            case .allowsFundsTransfer : return "ALLOWS_FUNDS_TRANSFER"
            case let .unknown(value)  : return value
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

说明

如上所述,我们的应用程序具有一组特定的已知功能标志。一开始,人们可能会这样定义它们。

enum FeatureFlag : String, CaseIterable, Codable {
    case allowsTrading
    case allowsFundScreener
    case allowsFundsTransfer
}
Run Code Online (Sandbox Code Playgroud)

够简单的。但同样,现在使用该类型定义的任何值FeatureFlag只能处理这些特定的已知类型之一。

现在,由于后端的一项新功能,allowsSavings定义了一个新标志并将其推送到您的应用程序。除非您手动编写解码逻辑(或诉诸选项),否则解码器将失败。

但是,如果枚举可以自动为您处理未知情况,并且以完全透明的方式进行处理,该怎么办?

它可以!诀窍是定义一种附加情况,unknown并具有类型的关联值RawValue。这个新案例处理解码甚至重新编码时传递给它的所有未知类型。

让我们首先用新unknown案例更新我们的枚举。

enum FeatureFlag : String, CaseIterable, Codable {
    case allowsTrading
    case allowsFundScreener
    case allowsFundsTransfer
    case unknown(RawValue)
}
Run Code Online (Sandbox Code Playgroud)

由于这种新情况,这当然会引发大量编译器错误。正因为如此, 和RawRepresentable都不CaseIterable能再被编译器自动合成,所以我们必须自己手动实现它们。让我们从...开始

手动实现 CaseIterable 协议

这是两个步骤中最简单的一个。由于我们应用程序的这个“版本”仅了解前三种情况,因此我们可以安全地忽略所有其他情况。因此,为了满足协议,我们定义了一个静态allCases属性,仅指定我们确实关心的那些情况。

值得注意的是:这里的属性类型是或更简洁的AllCases别名,当符合 时我们可以免费获得它。[FeatureFlag][Self]CaseIterable

static let allCases: AllCases = [
    .allowsTrading,
    .allowsFundScreener,
    .allowsFundsTransfer
]
Run Code Online (Sandbox Code Playgroud)

综上所述,这满足了CaseIterable协议。让我们继续...

手动实现 RawRepresentable 协议

这有点复杂/冗长,但这就是“魔法”发生的地方。

指定 RawValue 类型

通常,为了指示您的枚举可以由原始值表示,您可以在枚举名称后指定数据类型。实际上,这是告诉编译器您正在使枚举符合协议并为该数据类型RawRepresentable设置类型别名的简写。RawValue然而,同样,由于我们的unknown类型具有关联值,编译器无法隐式执行此操作,因此我们必须显式执行此操作。

为此,请将RawRepresentable定义中的原始类型替换为,然后手动RawValue在其中设置类型别名,如下所示...

enum FeatureFlag : RawRepresentable, CaseIterable, Codable {

    typealias RawValue = String

    case allowsTrading
    case allowsFundScreener
    case allowsFundsTransfer
    case unknown(RawValue)
}
Run Code Online (Sandbox Code Playgroud)

实现 rawValue: 属性(与案例名称匹配的 rawValues)

接下来,我们必须实现该rawValue属性。对于原始值与案例名称匹配的已知情况,实现很简单,因为我们可以只返回String(describing: self),而对于未知情况,我们返回关联的值。这是该实现

var rawValue: RawValue {

    switch self {
        case let .unknown(value) : return value
        default                  : return String(describing: self)
    }
}
Run Code Online (Sandbox Code Playgroud)

实现 rawValue: 属性(与案例名称无关的 rawValues)

但是,如果我们想要表达与案例名称不同的值,甚至完全不同的数据类型怎么办?在这种情况下,我们必须手动扩展switch语句并返回适当的值,如下所示......

var rawValue: RawValue {

    switch self {

        case .allowsTrading       : return "ALLOWS_TRADING"
        case .allowsFundScreener  : return "ALLOWS_FUND_SCREENER"
        case .allowsFundsTransfer : return "ALLOWS_FUNDS_TRANSFER"

        case let .unknown(value)  : return value
    }
}
Run Code Online (Sandbox Code Playgroud)

*注意:您必须在此处指定原始值,而不是使用 equals ( =) 来指定大小写定义,因为这实际上是编译器创建我们在这里手动执行的操作的语法糖,我们必须再次这样做,因为编译器可以'不要为我们做这件事。

实现 init(rawValue:)...“魔法酱”

如前所述,本练习的全部目的是让您的代码像往常一样使用已知类型,但也可以优雅地处理抛出的未知情况。但我们如何实现这一目标呢?

技巧在于初始化器中,您首先在其中搜索已知类型allCases,如果找到匹配项,则使用它。但是,如果未找到匹配项,则不要像nil默认实现中那样返回,而是使用新定义的unknowncase,将未知的原始值放入其中。

这样做的另一个好处是保证始终从初始化器返回枚举值,因此我们也可以将其定义为非可选初始化器(这与编译器隐式创建的初始化器不同),从而使调用站点代码更容易也可以使用。

这是初始化器的实现:

init(rawValue: String) {

    self = Self.allCases.first{ $0.rawValue == rawValue }
           ?? .unknown(rawValue)
}
Run Code Online (Sandbox Code Playgroud)

在某些情况下,您可能需要记录何时传递未知值以进行调试。guard这可以通过一个简单的语句(或者如果你愿意的话)来完成,if就像这样......

init(rawValue: String) {

    guard let knownCase = Self.allCases.first(where: { $0.rawValue == rawValue }) else {

        print("Unrecognized \(FeatureFlag.self): \(rawValue)")
        self = .unknown(rawValue)
        return
    }

    self = knownCase
}
Run Code Online (Sandbox Code Playgroud)

关于平等性和可哈希性的有趣行为

有趣的事情之一是,基于原始值的枚举实际上使用该值进行相等比较。由于这条信息,所有这三个值都是相等的......

let a = FeatureFlag.allowsTrading              // Explicitly setting a known case
let b = FeatureFlag(rawValue: "allowsTrading") // Using the initializer with a raw value from a known case
let c = FeatureFlag.unknown("allowsTrading")   // Explicitly setting the 'unknown' case but with a raw value from a known case

print(a == b) // prints 'true'
print(a == c) // prints 'true'
print(b == c) // prints 'true'
Run Code Online (Sandbox Code Playgroud)

此外,如果您的原始值符合Hashable,您可以通过简单地指定其与该协议的一致性来使整个枚举符合Hashable

extension FeatureFlag : Hashable {}
Run Code Online (Sandbox Code Playgroud)

有了这种一致性,现在您可以在集合中使用它或将其用作字典中的键,这再次感谢上面的相等规则,提供了一些有趣但符合逻辑预期的行为。

再次,使用上面定义的“a”、“b”和“c”,您可以像这样使用它们......

var items = [FeatureFlag: Int]()

items[a] = 42                     // Set using a known case
print(items[a] ?? 0) // prints 42 // Read using a known case
print(items[b] ?? 0) // prints 42 // Read using the case created from the initializer with a raw value from the known case
print(items[c] ?? 0) // prints 42 // Read using the 'unknown' case but with a raw value from the known case
Run Code Online (Sandbox Code Playgroud)

附带好处:无损编码/解码

这种方法的一个经常被忽视/低估的副作用是序列化/反序列化是无损且透明的,即使对于未知值也是如此。换句话说,当您的应用程序解码包含您不知道的值的数据时,案例unknown仍在捕获并保存它们。

这意味着,如果您要再次重新编码/重新序列化该数据,这些未知值将被重新写入,就像您的应用程序确实知道它们一样。

这实在是太强大了!

这意味着,例如,如果应用程序的旧版本从包含更新的未知值的服务器读取数据,即使它必须重新编码该数据以将其再次推出,重新编码的数据看起来与如果您的应用程序确实知道这些值,而不必担心版本控制等。它们只是默默地、愉快地传回。

概括

完成上述操作后,您现在可以对任何内容进行编码或解码字符串编码或解码为此枚举类型,但仍然可以访问您关心的已知案例,而无需在模型类型中编写任何自定义解码逻辑。当您“了解”新类型时,只需根据需要添加新案例即可开始!

享受!