关于Codable协议处理数据实体属性缺省值问题

前言

Json解析问题可以说是移动端开发都一定会涉猎的。另一方面,得益于Swift的空安全和let不可变的特性,大大地保障了代码逻辑的可靠性。日常开发中,可能会遇到一些字段在json中是选填的,而本地的代码逻辑需要兼容这种选填的情况,譬如字段“a”为空的情况下,本地代码需要根据它的默认值进行处理,这种场景在一些与配置相关的业务逻辑上比较常见。当然,解决这种问题的方案各式各样,最直接且最易维护的方案就是从源头解决,即在json解析时将缺省的属性补上默认值。这也是本文对于该问题结合利用Codable协议进行json解析时所要总结的技巧

Codable自带的json decode

先来看一个例子,有json如下:

{
  "id": 12345,
  "title": "abcd",
  "isA": true
}
复制代码
class Node: NSObject, Codable {
    let id: Int
    let title: String
    let isA: Bool
}
复制代码

正常情况下,class继承了Codable协议,因为自定义类的属性都默认为必填是可以不显示定义init(from decoder: Decoder)构造方法的。

Codable的json解析依赖的是init(from decoder: Decoder)构造方法。

如果我们将”isA“定义为非必填。为了让json解析正常,有两种方法:1、将isA声明为可选Bool?;2、重写init(from decoder: Decoder)构造方法。

  • 方法1:
class Node: NSObject, Codable {
    let id: Int
    let title: String
    let isA: Bool?
}
复制代码
  • 方法2:
class Node: NSObject, Codable {
    let id: Int
    let title: String
    let isA: Bool
    
    required init(from decoder: Decoder) throws{
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.title = try container.decode(String.self, forKey: .title)
        self.isA = try container.decodeIfPresent(Bool.self, forKey: .isA) ?? true
    }
}
复制代码

显然,方法2对于外部引用到Node.isA属性的处理逻辑来说更加友好,因为存在空处理的逻辑。但这里又会引申出另外一个问题,如果存在选填需要处理的字段,其实就必须重写整个init(from decoder: Decoder)将其他属性的其他字段的解析规则都要重写一遍。这样做的后果是编码效率会降低,而且维护成本会变高…

Xcode扩展生成数据实体

针对上述的方案(重写init(from decoder: Decoder)),怎样才能提高编码效率呢?既然代码封装不好解决,笔者第一时间想到的是代码生成。当然,代码生成的方式有很多,选择Xcode扩展是因为笔者认为,需要编写大量类似代码的场景不算很多,Xcode扩展比较轻量且与项目本身的耦合程度较少

JsonGenerator

JsonGenerator是笔者根据自身需求写的一个Xcode扩展项目。其分为两个步骤RuleEntry

  • Rule

由于Swift特性,Model的属性无法在json中体现。故需要先将json转换为一个自定义规则:

let/var:Key:Type(:?:Default)

  • 规则以“:”分割。
  • let/var 属性采用的声明为let或者var。
  • Key 属性名,默认为json中的字段名。
  • Type 属性类型,若属性为自定义类型则以xxxNode展示;若属性为集合则以xxxArrayNode展示。
  • ? 属性是否为缺省值,对应Codable的json解析是否必填的场景。
  • Default 若属性缺省时的默认值。
  • 每类型第一行为类名,类型间以\n隔开

具体效果:

Rule.gif

  • Entry

根据Rule生成实体,若类中存在“:?:Default”声明,会自动加入init(from decoder: Decoder)构造方法,处理默认值场景。

具体效果:

Entry.gif

此项目是笔者即兴写的,代码逻辑设计得并不完善,这里只是提供一个思路。可以根据实际需要写一个类似的工具,在日常开发当中也是可以提升不少效率的。

再来说说重写init(from decoder: Decoder)处理缺省值的方案吧。因为Codable这种利用构造方法解析json的方案,整个解析过程对于开发者来说是可见的,开发者也可以在构造方法中自定义其对于json的解析规则,这个也是笔者认为Codable的优点,自定义能力较强。但是自定义能力这种事情也是相对的,大多数场景下json的格式可以直接对应到数据实体的结构。对于开发者而言,他们无需过多关心json如何解析,那么上述所说的优点很可能就变成了缺点。再者,如果数据实体包含的属性过多,一方面init(from decoder: Decoder)内包含的属性初始化代码也会变多。另一方面,每当新增字段时,也需要一并维护该方法。造成的结果可能是:

  • 重复的代码逻辑增多。
  • 一旦在团队开发中没有形成规范,在新增字段后维护成本就会大大提高,可能会影响到其他业务代码的管理。

Property Wrapper

Swift在高版本(查阅资料显示时Xcode11开始支持,未考究)新增了一个叫做Property Wrapper,属性包装器。利用这一特性,我们可以封装很多有意思的代码工具,简化编码逻辑。Property Wrapper也有一点类似Java中注解的味道,当然啦,它远未到注解那般强大。笔者也是受到了使用 Property Wrapper 为 Codable 解码设定默认值文章的启发。文章中也有详细的介绍如何利用Property Wrapper处理缺省值。下面笔者就大概归纳一下这个方案。

// 定义DefaultValue协议,规范每个类型的默认值
protocol DefaultValue {
    associatedtype Value: Codable
    static var defaultValue: Value { get }
}

@propertyWrapper
struct Default<T: DefaultValue> {
    var wrappedValue: T.Value
}

// 重写Codable的decode方法,当范型T集成自DefaultValue时,调用的是decodeIfPresent方法
extension KeyedDecodingContainer {
    func decode<T>(
        _ type: Default<T>.Type,
        forKey key: Key
    ) throws -> Default<T> where T: DefaultValue {
        try decodeIfPresent(type, forKey: key) ?? Default(wrappedValue: T.defaultValue)
    }
}

extension Default: Codable {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = (try? container.decode(T.Value.self)) ?? T.defaultValue
    }
}

// 这里规定Bool类型的默认解析值为false
extension Bool: DefaultValue {
    static let defaultValue = false
}

class Node: NSObject, Codable {
    let id: Int
    let title: String
    // 定义isA属性为Bool类型,默认值为Bool.defaultValue = false
    @Default<Bool> var isA: Bool
    
    override var description: String {
        return """
{
  "id": \(id),
  "title": \(title),
  "isA": \(isA)
}
"""
    }
}
复制代码

对于使用自定义Property Wrapper定义的属性,Property Wrapper层的逻辑对于开发者是透明的,其值为Property Wrapper内的wrappedValue属性。譬如若@Default<Bool> var isA: Bool = true,实际为wrappedValue = true

ps: 为什么需要定义DefaultValue协议规定默认值呢?这是因为自定义Property Wrapper本身就是一个自定义类型,只是编译器将其中的层级关系扁平化了。而它的实例化时机正是在其父类实例时,譬如上述的@Default<Bool> var isA: Bool实例是在Node对象实例时。Node对象实例时,会先实例结构体类型Default,之后才会有isA: Bool。故默认值需要定义成static,对于代码设计才是比较合理的。

该方案的优点在于,相比重写init(from decoder: Decoder),不需要编写大量的属性decode,只针对单一属性编写,代码逻辑得到了解耦。缺点则在于:

  • 因为需要额外定义默认值,随着自定义类型的增加,json层级的复杂度增加,static的定义会越来越多,且每个类型的默认值定义可能需要定义多个,维护成本是一个问题。
  • Property Wrapper类型声明的变量只能使用var关键字声明,无法享受Swift不可变特性(笔者对于不可变的特性比较执着…)。ps: 这种情况也可以将Default.wrappedValue使用let声明,这样即使外面是var关键字声明也无法修改值。但是这种做法灵活性就较差了。

JSONDecoder

那么是否能通过造轮子来优雅地处理缺省值呢?譬如修改JSONDecoder的源码或者说自定义JSONDecoder?先说说笔者研究的结果:不能。然后让来看看JSONDecoder的部分源码来理解一下Codable是怎样实现json解析的。源码参考:JSONEncoder.swift

// JSONEncoder.swift 1200行
open func decode<T : Decodable>(_ type: T.Type, from data: Data) throws -> T {
        let topLevel: Any
        do {
           topLevel = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
        } catch {
            throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "The given data was not valid JSON.", underlyingError: error))
        }

        let decoder = __JSONDecoder(referencing: topLevel, options: self.options)
        guard let value = try decoder.unbox(topLevel, as: type) else {
            throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: [], debugDescription: "The given data did not contain a top-level value."))
        }

        return value
    }
    
。。。

// JSONEncoder.swift 2489行
func unbox_(_ value: Any, as type: Decodable.Type) throws -> Any? {
        if type == Date.self || type == NSDate.self {
            return try self.unbox(value, as: Date.self)
        } else if type == Data.self || type == NSData.self {
            return try self.unbox(value, as: Data.self)
        } else if type == URL.self || type == NSURL.self {
            guard let urlString = try self.unbox(value, as: String.self) else {
                return nil
            }

            guard let url = URL(string: urlString) else {
                throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath,
                                                                        debugDescription: "Invalid URL string."))
            }
            return url
        } else if type == Decimal.self || type == NSDecimalNumber.self {
            return try self.unbox(value, as: Decimal.self)
        } else if let stringKeyedDictType = type as? _JSONStringDictionaryDecodableMarker.Type {
            return try self.unbox(value, as: stringKeyedDictType)
        } else {
            self.storage.push(container: value)
            defer { self.storage.popContainer() }
            return try type.init(from: self)
        }
    }

。。。
// 开发者调用
try! JSONDecoder().decode(T.self, from: data)
复制代码
  • 开发者需要进行json解析时,会调用JSONDecoder的decode方法。
  • 内部其实依赖的是JSONSerialization,将传入的data解析为字典。
  • unbox_方法的最后try type.init(from: self),对应的是init(from decoder: Decoder)
  • 接下来的就可以理解为JSONDecoder的json解析是根据自定义类型的关联关系形成,然后进行深度优先遍历,继而逐步实例出json对应的数据实体。

综上所述,Codable的json解析,还是依赖的是init(from decoder: Decoder)构造方法。那么前面提到的在解析过程中处理缺省值的想法就无法绕开这个机制了。

ps: 插播一个题外话,在研究JSONDecoder时,看到了有朋友通过自定义JSONDecoder来优化解析性能的。原理大概是因为JSONSerialization解析成字典后,其value值需要强制as成特定类型,比较影响性能。深入 Decodable —— 写一个超越原生的 JSON 解析器

对于缺省值定义的想法

对于开发者而言,最希望看到的处理方案就是在属性定义时直接赋予默认值,在json中缺省该值时可以自动使用默认值。就像这样:

class Node: NSObject, Codable {
    let id: Int
    let title: String
    let isA: Bool = true
}
复制代码

但遗憾的是,Swift编译器是不允许在let属性声明时赋值后,在构造方法再赋值一次。就像这样:

class Node: NSObject, Codable {
    let id: Int
    let title: String
    let isA: Bool = true
    
    override init() {
        self.id = 0
        self.title = ""
        self.isA = false  // Error:Immutable value 'self.isA' may only be initialized once
    }
}
复制代码

那么以此类推,集成Codable协议后编译器后自动为我们生成init(from decoder: Decoder)构造方法,如果按照上述定义的话,isA属性的初始化是不会出现在init(from decoder: Decoder)中的。

class Node: NSObject, Codable {
    let id: Int
    let title: String
    let isA: Bool = true
    
    required init(from decoder: Decoder) throws{
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.title = try container.decode(String.self, forKey: .title)
        self.isA = try container.decode(Bool.self, forKey: .isA) // Error:Immutable value 'self.isA' may only be initialized once
    }
}
复制代码

这也是为什么即使json中拥有isA的值,但是永远只会解析isA = true。那么有没有其他的第三方json解析库可以满足这一需求呢?HandyJSON是可以实现的。为了绕开这一机制,它的做法是直接写入内存

由于本文重点在Codable,关于HandyJSON可以看看这篇文章:[HandyJSON] 设计思路简析

关于GRDB数据库迁移的兼容性问题

最后再简单说一个与本文类似的问题。GRDB的数据库查询实例成数据实体的过程也是依赖的Codable,所以实体的构造方法也是调用init(from decoder: Decoder)。数据库迁移后,新增的字段在旧数据中不存在就会导致无法解析,所以也可以采用在init(from decoder: Decoder)阶段兼容缺省值的做法规避这一问题

最后

本文主要总结了有关利用Codable协议解析json时,如何很好地处理缺省值的问题。目前看来Swift的Codable设计地还是有点过于保守,无法优雅地处理类似的这种缺省值的问题。如果有更好的方案,也欢迎留言我们一起讨论!

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享