onevcat / OneV-s-Den-Comments

0 stars 0 forks source link

2020/11/codable-default/ #3

Open utterances-bot opened 3 years ago

utterances-bot commented 3 years ago

使用 Property Wrapper 为 Codable 解码设定默认值 | OneV's Den

本文介绍了一个使用 Swift Codable 解码时难以设置默认值问题,并利用 Property Wrapper 给出了一种相对优雅的解决方式,来在 key 不存在时或者解码失败时,为某个属性设置默认值。这为编解码系统提供了更好的稳定性和可扩展性。最后,对 enum 类型在某些情况下是否

https://onevcat.com/2020/11/codable-default/

ZeroOnet commented 3 years ago

👍

kayanouriko commented 3 years ago

刚好以前也有过这个需求,也实现到喵神文章末尾的程度了,但是在应用到项目过程中还是会遇到更复杂的情况。 例如String、Float等类型如果根据情况不同会有多种默认值,就需要在extension中预先定义好各种符合DefaultValue的默认值。 最好还是能在外部让业务端自己设置,就像普通Struct或者Class设置默认值那样。

@Default(value: "字符串的默认值")
var name: String

但是和群里小伙伴讨论的那样,decode的init方法是固定的,参数传不进去也没地方存。 看Swift论坛也有人提过这事,不过官方貌似并没有对此引起足够的关注:https://forums.swift.org/t/possible-to-set-default-values-for-optional-vars-in-codable-struct/26652

不知道喵神对于这个还能有更进一步的改进么? 刚刚仔细看了文章内容,原来最后的处理方式也是因为这个问题选择的折中方法了撒,看样子只能是这样了

onevcat commented 3 years ago

@kayanouriko 最理想的方式当然是 Swift Forum 上这类声明的时候直接设置...而且理论上也是可以做到的。但是如果实现的话,这个将会是一个“特例”,所以可能需要比较谨慎吧。Swift 5 之前对于 Codable 中的这类设置值其实是没有警告的,Swift 5 中加入了警告,其实也许也是在向这个方向努力。

暂时我想不到有什么特别好的方式可以实现 @Default(value: "字符串的默认值") 这样的效果。

miku1958 commented 3 years ago

我之前为了解决这个问题, 用 SourceKitten 生成 CodingKeys / init(from:) / encode(to:) 这几个东西的方式写了这个 https://github.com/miku1958/HappyCodable, 后面用的时候由于需要引入编译脚本, Xcode升级到12后没法兼容旧版等问题, 我就把 SourceKitten 的部分删了, 改成了自定义了一个 JSONDecoder + 编译器生成 CodingKeys / init(from:) / encode(to:) 的方式实现, 其实也就是把 Swift 的 JSONDecoder 源码拔下来修改, 我的设想下, 2.0版本在定义模型的时候把所有属性的默认值都写上, 提供一个无参数的 init(), 这样在 decode 之前先 encode 一份作为兜底的 JSON 缓存不就可以了嘛, 简单使用就类似于:

struct Person: HappyCodable {
   var name: String = "阿强"

   var id: String = "123456"

   var age: Int = 18
}

let person = try Person.decode(from: ["id": "001"])

这里用不完整字典 decode 的 person 就是默认 name == "阿强", id = "001", age = 18

但这个系统目前没法实际使用, 因为考虑到存在动态生成的属性, 比如

struct Person: HappyCodable {
   var name: String = "阿强"

   var id: String = Person.createNewId()

   var age: Int = 18

   static func createNewId() -> String {
     return 用uuid加密生成的字符串
   }
}

我就想用 Property Wrapper 把右值的初始化函数保存起来, 例如我用来替换编码 key 的 PropertyWrapper 是这样初始化的:

extension Happy {
    @propertyWrapper
    final public class alterCodingKeys<T: Codable> {
        var storage: T?
        let constructor: (() -> T)?
        let codingKeys: [String]

        public var wrappedValue: T {
            get {
                if let value = storage {
                    return value
                }
                storage = constructor!()
                return storage!
            }
            set {
                storage = newValue
            }
        }

        public init(wrappedValue creater: @escaping @autoclosure () -> T, _ codingKeys: String...) {
            (T.self as? CodingKeysFilter.Type)?.precondition()

            self.constructor = creater
            self.codingKeys = codingKeys
        }
    }
}

这样右值的值就是动态的而不是 decode 时生成的静态兜底JSON

看起来很美好对吧, 结果 Xcode 的 Swift 编译器有问题导致我迟迟没法发布 , 如果 PropertyWrapper 的初始化用到 @escaping @autoclosure 的话, 就会编译出错, Swift 的开发分支是早在今年9月之前就 fix 了这个问题, 但 Xcode 到 12.2 还是没有合这部分代码, 在编译的时候报 Segmentation fault: 11 错误, 但退一步讲, 如果不需要用 PropertyWrapper 配置 decode 过程的话, 目前这套系统都是能正常工作的

miku1958 commented 3 years ago

另外还存在另一个问题, 如果只用 @autoclosure 的话是能编译过的, 但是编译器会先执行完右值的函数再把结果用 @autoclosure 包起来, 用到上述 createNewId() 的例子上, 错误的结果就是: Person.id用@Happy.alterCodingKeys标示, 但初始化Person时会马上调用 createNewId() 把结果传给了 alterCodingKeys 的初始化, 而不是等到我实际调用 alterCodingKeys.wrappedValue 的时候调用 constructor 才调用 createNewId()

非常怪的是我记得 propertyWrapper 出现的时候有人写过一个 @Lazy 也是用这种方式延迟初始化, 他们是没发现这个问题吗? 我拿他们的代码测试也是一样的

CrystDragon commented 3 years ago

正和有些小伙伴所说, 希望默认值不用 Type 遵循一个包含 static 属性的 protocol, 而是希望手动作为 value 设定. 比如

@Default(value: "字符串的默认值") var name: String

其实 Swift 里某些有官方 support 的框架就是支持这种方式定义属性的, 比如 https://github.com/apple/swift-argument-parser, 非常值得借鉴. 不过如果想要这么实现, 需要稍微跳出一下 codable 的框架.

他那里的实现方法是, 要求目标 Type 首先遵循一个包含 init() 的 protocol. 把这样的 Type 提供给框架后, 框架里先靠 init() 初始化一个 dummy object, 然后对 object 执行一次扫描(可以用 Mirror, 也可以基于 Encodable), 把所有属性的 default value 遍历出来. 然后在实现 Decodable 时, 就可以用到之前扫描到各 property 所对应的 default value.

MikasaAckerman commented 3 years ago

之前遇到这里可选值问题的时候也是搜了一大圈,最后也是用@propertyWrapper解决的。。业务不复杂感觉还行。。 然后有个人说这是 Swift 的 bug!🧐

zhgchgli0718 commented 3 years ago

想请教当遇到要自行实作 Decode 的 Model 时,是否有方法保有 Default 特性? EX:

let jsonString = "{\"name\":null}"

struct Test: Decodable {
    @Default(value: "test") var name: String?
}
// work! name = test;

struct Test: Decodable {
    @Default(value: "test") var name: String?

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.name = try container.decode(String.self, forKey: .name)
    }
}
// not work! name = nil;
Adrift001 commented 3 years ago

Codable太难用, 用HandyJSON不香吗? 🙂

onevcat commented 3 years ago

@miku1958 我大概理解你的意思了..

另外还存在另一个问题, 如果只用 @autoclosure 的话是能编译过的

不过我没太明白,不用 @escaping 的话你怎么做到把 creater 保存起来?

miku1958 commented 3 years ago

@onevcat 是要用@escaping, 我的意思是说如果 property wrapper 用在其他场景的时候, 初始化只用 @autoclosure是能编译过的

onevcat commented 3 years ago

@CrystDragon 感谢评论。

您指得应该是 swift-argument-parser 里的 Parsable PropertiesParsed 的相关实现吗?

那边的话,其实实现了一套自己的 decoder,这带来的灵活性就很大了。

不过想请教一下您的回复里提到的这个:

要求目标 Type 首先遵循一个包含 init() 的 protocol. 把这样的 Type 提供给框架后, 框架里先靠 init() 初始化一个 dummy object

这个是不是也还是在 type 的层面去做定义?和本文中的 protocol 规定一个 static property 似乎没有区别?如何能做到对于同一个 type 定义不同的默认值呢?(比如对于 Bool,在某些情况下需要默认值为 true,而另一些情况希望为 false,应该怎么处理?)

onevcat commented 3 years ago

@zhgchgli0718

不是太清楚您的例子里的 @Default 的定义是什么..请问可能把完整的代码贴上来么?

miku1958 commented 3 years ago

@onevcat 我可能有点记混了, 重新试了一下整理情况

我需要实现的包括: 用 @escaping @autoclosure 初始化 wrappedValue, init() 带有额外的参数

Xcode 11.7下, @escaping @autoclosure 能编译, 但会出现我说的先调用右值再初始化

12以上, 初始化时先调用右值被 fix 了, 但是如果 init() 里除了 wrappedValue 外有额外的参数, 编译器会crash

而 Swift 的开发分支则修复了额外参数编辑器 crash 的问题

CrystDragon commented 3 years ago

@onevcat 是的, 和您说的一样, 不管是 swift-argument-parser 还是这边, 需求上来讲, 都是要把某个在外层的 Type 在编写时, 就和 default value 进行绑定. 我想绝大部分业务场景里, 也是一个 Model 就对应一组 default value. 所以两边实现的功能是完全一致的.

BTW, 如果非要对一个 Type 想在 decode 时动态提供不同的 default value, 那只能想办法在执行 decode 时把信息注入进去. 我能想到的就是利用 Decoder 的 CodingUserInfoKey. 不知道您有没有什么好的办法.

onevcat commented 3 years ago

@miku1958

Xcode 11.7下, @escaping @autoclosure 能编译, 但会出现我说的先调用右值再初始化

12以上, 初始化时先调用右值被 fix 了

居然还有这样的故事...应该是我 @autoclosure 用得比较简单,完全没有发现这个问题。

而 Swift 的开发分支则修复了额外参数编辑器 crash 的问题

感觉你的库也只能等这个修复 merge 了。

onevcat commented 3 years ago

@Adrift001

Codable太难用, 用HandyJSON不香吗? 🙂

现在的“哲学”是,这类东西能不用第三方的就尽量不用...

miku1958 commented 3 years ago

@onevcat 我试了 12.3 beta 还是有问题, 然后官方有人跟我说提供一个仅使用@escaping @autoclosure不带额外参数的 init() 就能编译过, 我试了一下还真是... 行吧

wzio commented 3 years ago

1 我对video的state的举例有些不同看法,我反而觉得不用enum是错误的做法,而是客户端应该添加一个unknown case , 加入服务端新增了一个case xxx,客户端旧版本,会被解析到unknown case,不做处理或者做出错误处理,客户端新版本,可以在enum定义中添加xxx case,客户端在使用到该enum的地方并且没有使用default case,一旦编译就会很顺利的找到所有这些地方并且正确处理新增的case。如果使用文中的方法,我觉得丢失了这个省脑力的行为,有点不妥

2 至于这种codable 的默认值,如果属性是一个自定义的类型,

struct Test: Decodable {
    @Default(value: TestB(name: xxx, value: yyy )) var children: TestB?
}

不知道别人对这种annotation的写法感觉如何,我个人对这种语言有种天然排斥的感觉,我觉得他强调了@Default,而不是children 属性是个TestB的定义本身 我们现在的做法是使用

extension KeyedDecodingContainer {
    public func decodeSafe(_ type: String.Type, forKey key: K, defaultValue: String = "") throws -> String {
        if let value = try? decode(type, forKey: key) {
            if value.isEmpty {
                return defaultValue
            } else {
                return value
            }
        }
        if let value = try? decode(Int.self, forKey: key) {
            return String(value)
        }
        if let value = try? decode(Double.self, forKey: key) {
            return String(value)
        }
        return defaultValue
    }
}

使用

struct Test: Codable {
    let name: String

    private enum CodingKeys: String, CodingKey {
        case name = "first_name"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decodeSafe(String.self, forKey: .name, defaultValue: "--")
    }
}

如果有复杂类型属性,如果不用optional,那就只好再写一个defaultvalue的init方法了,但是一个痛点就是这个init方法不希望有另外的人调用,有时候为了这点,用fileprivate修饰,两个类的定义写在一个文件里

这种方法虽然代码很多,但是可以使用swiftgen等工具自动生成

Adrift001 commented 3 years ago

@wzio 说来说去都比较麻烦, codable这么难用, 苹果也不知道重写一下. 🙂

onevcat commented 3 years ago

@wzio

  1. 确实,enum 最大的优势就是 switch 的时候能有编译器的帮助。我的观点是,enum 和 struct 各自有正好相反的优缺点:在本文讨论 Default 的时候,用 .unknown case 做了举例,意图就是想办法解决“解析到unknown case”这个问题,而这个问题在 struct 中是不存在的。而另一面,enum 在添加新 case 时可以得到编译器帮助,而 struct 在这方面,虽说做一下查找也并不是特别困难,但相对来说确实比较弱。

对于“被动方”(比如文中的 JSON 的接受者)来说,enum 带来的好处确实是无法忽视的。但是对于“主动方”(比如在写一个 HTTP client 时的 HTTP Method 定义)来说,struct 会带来更大的灵活性。这是我现在的观点。

  1. 自动生成是解决这个问题的一条路径,但是维护自动生成所需要的代码和模板,以及对团队新人进行培训等等,本身也带来了不小的负荷。
RbBtSn0w commented 3 years ago
  1. 对于JSON 的解析, 一直使用这个工具,而且还有Xcode 插件,以及客户端版本: https://quicktype.io

  2. enum, 我觉得最后提到的 “被动方”, 比较认可. enum 带来了很多方便, 对于后台的字段,还是合理的加上unknow 最合适.

    合理选择enum 与 struct, 才是合适的路.

ohlulu commented 3 years ago

剛好實作過類似的用法 Reop,主要是用來解決 BE 傳錯的時候 App 不至於 crash,或者不至於畫面全白。

這個方法雖然可以解決問題,但這個錯誤(decode failure)被隱藏了,實際在 debug 時將變得極為困難,目前的思路停在用一個全域的 ErrorHandler 來接收 Error,但這樣依然難以定位是哪個請求出了問題。

請問喵大有沒有什麼建議🙏。

githubError commented 3 years ago

喵神,请问在https://objccn.io/products/上面购买您的书可以开发票吗?

zp0708 commented 3 years ago

你们在项目中开始用Codable了么

onevcat commented 3 years ago

@zp0708 从 Codable 出来的第一天就开始用了。

lvdaqian commented 3 years ago

偶然看到github上有这样一个仓库: https://github.com/winddpan/CodableWrapper 思路是利用class类型的默认生成的property wrapper对象析构时将参数传递给decode出来的property wrapper对象。可以实现替换codingkey,defaultvalue,类型转换等操作。 虽然这个库针对JSONDecoder/JSONEncoder做了一些定制,但是感觉这个思路可以不依赖特定的encoder和decoder。

Mioke commented 2 years ago

遇到这个问题时,其实要根据实际业务场景先想明白一件事,就是缺省字段会不会影响后续业务使用。在比较严谨的接口设计下,其实大多数场景是解析格式发生变化时更应该报错,即代码中应该 catch error 来做后续处理。所以 @Default 这种也应防止乱用~另外 @lvdaqian 提到的库使用起来比较符合预期。

is0bnd commented 2 years ago

对于没有实现Codable协议的类或者结构体,Xcode编译时会自动添加代码实现Codable

// 编译前
struct Person: Codable {
    var name = ""
    var age = 0
}

// 编译后
struct Person: Decodable {
    var name = ""
    var age = 0

    private enum CodingKeys: String, CodingKey {
        case name
        case age
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        age = try container.decode(Int.self, forKey: .age)
    }
}

有没有办法替换掉编译时的自动实现,变成如下的方式

// 编译后
struct Person: Decodable {

   //...

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decodeIfPresent(String.self, forKey: .name) ?? name
        age = try container.decodeIfPresent(Int.self, forKey: .age) ?? age
    }
}

这样解析成功,就使用解析的值,解析失败就使用默认值,编码期间做出的改动也最小

is0bnd commented 2 years ago

目前我能做到的就是通过自定义操作符,简化这个过程,然后使用Xcode插件自动生成Codable代码

required init(from decoder: Decoder) {
    name <- decoder["name"]
    age <- decoder["age"]
}
wizard1990 commented 9 months ago

相比重写KeyedDecodingContainer的decode实现 是不是也可以在try? container.decode(T.self)的时候直接catch exception 然后返回default value? 感觉要简单一些

Coder-Miao commented 1 month ago

2024年,现在还有更好的写法吗?

CrystDragon commented 1 month ago

2024年,现在还有更好的写法吗?

当然, 活用 Macro.

Adrift001 commented 1 month ago

2024年做iOS开发还能吃上饭吗?