Swift Macros 元编程为Codable解码供给默许值

前语

在WWDC2023中,Apple向咱们介绍了Swift官方的元编程东西”Swift Macros”,与之前社区中的Sourcery相比,具有以下几个长处:

  1. 调用快捷:运用Swift Macros时,编译器会供给提示,无需硬编码。
  2. 支撑注解:类似Sourcery的注解,Swift Macros支撑经过自界说宏完成注解,几乎能够完成任何功用。
  3. 宏打开细节躲藏:Swift Macros彻底躲藏了宏打开之后的代码,并支撑随时在XCode中打开宏以查看详细代码。
  4. 运用Swift编写:编写宏的过程彻底运用Swift言语,上手难度低,并且支撑在单元测试中进行断点调试,便利调试。
  5. 支撑单元测试:Swift Macros供给了对单元测试的支撑。
  6. 彻底开源:swift-syntax 是一个开源库。

可是现在Swift Macros的依靠库 github.com/apple/swift… 不支撑Cocoapods,现在仅支撑SPM集成。

本文介绍了如何运用 Swift Macros 去解决 Swift Codable 解码时难以设置默许值的问题。


Codable

当将一个 JSON/Dictionary 数据转化为 Swift 的 Model 时,咱们优先考虑运用Codable,可是假如在解码过程中,原数据中短少某个值(container中key不存在)或许某个值解码失利(key存在,可是对应的值类型不对)都会导致整个解析链失利。理想的状态下咱们期望为一个特点设置一个默许值,当其解码失利时,直接取默许值即可。

下面是一个运用Codable解码的比方:

struct People: Codable {
	let name: String
	let age: Int
}
let peopleDic: [String: Any] = ["name": "lfc", "age": 25]
do {
  let value: People = try decode(peopleDic)
  print(value)
} catch {
  print("Error: \(error)")
}
func decode<T: Codable>(_ dic: [String: Any]) throws -> T {
  let jsonData = try JSONSerialization.data(withJSONObject: dic, options: [])
  return try JSONDecoder().decode(T.self, from: jsonData)
}

可是假如字典中短少某个特点对应的key,比方:

let dic: [String: Any] = ["name": "lfc"]

或许某个key对应的值无法正常解析:

let dic: [String: Any] = ["name": 123, age: true]

都会导致整个值解析失利。关于短少key的状况,一种简略的计划是运用可选值:

struct People: Codable {
	let name: String?
	let age: Int?
}

这样在从container寻觅不到key的时分,会默许赋值为nil,但这样导致运用起来适当麻烦,每次运用此可选值都要进行解包,或许在上层进行封装。

而且假如container 中 key存在但对应的值是个错误类型(或其他状况导致此值解码失利),整个值依旧会解析失利,因为只要key在container中存在,对应的解码就会产生。

一种最牢靠的办法是手动为People完成Codable:

extension People: Codable {
	public enum CodingKeys: String, CodingKey {
    case name
    case age
  }
  public 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)) ?? 0
  }
  public func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(name, forKey: .name)
    try container.encode(age, forKey: .age)
  }
}

这样不光能够在某个值解析失利的时分赋一个默许值,还能够自界说 codingKey。但需求为每一个需求编解码的模型完成这部分的代码,虽然新版的XCode现已能够主动补全这些办法,可是后续改动数据结构,都需求去从头更改每一个办法的细节,想一想便是一件很恶心的事情。那么有没有更好的办法去为 Decodable 的特点添加解码的默许值呢。


特点包装器

有一种办法是经过特点包装器在 Decodable 特点的 getter 办法中进行处理,详细细节能够参考:onevcat.com/2020/11/cod…

但这种办法有以下坏处:

  • 需求手动为每一个特点添加特点包装器。
  • 无法自界说 CodingKeys。
  • 由于默许值在 init(from: Decoder) 中赋值,此刻无法获取property wrapper传入的参数值,所以默许值只能提早界说好大局静态变量,不够灵活,关于不同的事务场景需求不同的默许值时,只能不停的添加新的大局静态变量。

元编程Sourcery

Sourcery 是一个 Swift 代码生成的开源命令行东西,它 (经过 SourceKitten) 运用 Apple 的 SourceKit 框架,来剖析你的源码中的各种声明和标注,然后套用你预先界说的 Stencil 模板 (一种语法和 Mustache 很类似的 Swift 模板言语) 进行代码生成。

装置 Sourcery 非常简略,brew install sourcery 即可。不过,假如你想要在实际项目中运用这个东西的话,主张直接从发布页面下载二进制文件,放到 Xcode 项目目录中,然后添加 Run Script 的 Build Phase 来在每次编译的时分主动生成。

能够经过编写模版代码的办法为Codale的类型主动生成CodingKeys init(from: Decoder) 以及encode(to: Encoder)

Codable 替换计划

sourcery的缺陷如下:

  • 编写模版时不能调试,编写/上手困难,后续保护难度大。
  • 多个cocoapod库依靠同一个模版时,偶然产生将A库的代码生成的B库中的错误。

Swift Macros

下面咱们详细介绍如何运用 Swift Macros 为Codable添加默许值。

主动生成默许值的宏

界说 IcarusCodable 协议

循序渐进,咱们首要自界说一个协议 IcarusCodable 这个协议承继自Codable并包括一个默许值的办法:

public portocol IcarusCodable: Codable {
	static var defaultValue: Self { get }
}

一切遵从此协议的办法都能够经过 .defaultVaule 获取默许值。

为根本类型完成 IcarusCodable

咱们为一些常用的根本类型完成此协议,这很有用,使得接下来的完成中,一切由这些根本类型构成的类型都能够主动生成自己的默许值:

extension Int: IcarusCodable {
  public static var defaultValue: Int { 0 }
}
extension String: IcarusCodable {
  public static var defaultValue: String { "" }
}
extension Double: IcarusCodable {
  public static var defaultValue: Double { 0.0 }
}
extension Bool: IcarusCodable {
  public static var defaultValue: Bool { false }
}
extension Optional: IcarusCodable where Wrapped: IcarusCodable {
  public static var defaultValue: Optional<Wrapped> { .none }
}
extension Dictionary: IcarusCodable where Value: IcarusCodable, Key: IcarusCodable {
  public static var defaultValue: Dictionary<Key, Value> { [:] }
}
extension Array: IcarusCodable where Element: IcarusCodable {
  public static var defaultValue: Array<Element> { [] }
}

完成添加默许值的宏

下面咱们就能够为一切特点都为 IcarusCodable 的类型完成一个主动添加默许值的宏,作用大约像这样:

@icarusCodable
struct Student {
  let name: String
  let age: Int
  let address: String?
  let isBoarder: Bool
	// === 宏打开开端 ===
	private init(_name: String, _age: Int, _address: String?, _isBoarder: Bool) {
    self.name = _name
    self.age = _age
    self.address = _address
    self.isBoarder = _isBoarder
	}
	public static var defaultValue: Self {
    Self (_name: String.defaultValue, _age: Int.defaultValue, _address: String?.defaultValue, _isBoarder: Bool.defaultValue)
	}
	// === 宏打开完毕 ===
}
// === 宏打开开端 ===
extension Student : IcarusCodable
// === 宏打开完毕 ===
  • 首要完成 ConformanceMacro 主动打开 extension XXX : IcarusCodable { }:

    public struct AutoCodableMacro: ConformanceMacro {
    	public static func expansion(
        of node: AttributeSyntax,
        providingConformancesOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext) throws -> [(TypeSyntax, GenericWhereClauseSyntax?)] {
          let type = TypeSyntax(stringLiteral: "IcarusCodable")
          return [(type, nil)]
        }
    }
    

    ConformanceMacro 用于使某个类型遵从某个协议。

    需求在 expansion 办法中回来遵从的协议类型协议泛型限定描绘的元组的一个数组 [(TypeSyntax, GenericWhereClauseSyntax?)] ,每一个元组都会为类型添加一个 conformance。

    这里咱们只需求让类型遵从 IcarusCodable 即可,所以只需求回来

    [(TypeSyntax(stringLiteral: "IcarusCodable"), nil)]
    
  • 然后完成 defaultValue,由于class/struct 不一定存在默许通用的结构器,咱们自界说一个私有结构器,并运用’_’防止重名:

    public struct AutoCodableMacro: MemberMacro {
    	public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
      ) throws -> [DeclSyntax] {
    		...
    	}
    }
    

    MemberMacro 用于为目标添加成员特点/办法。

    咱们一共需求添加两个 member,一个私有结构器,以及一个get only的核算特点。

    • 首要咱们从 DeclGroupSyntax 中获取类型的存储特点:

      // get stored properties
          let storedProperties: [VariableDeclSyntax] = try {
            if let classDeclaration = declaration.as(ClassDeclSyntax.self) {
              return classDeclaration.storedProperties()
            } else if let structDeclaration = declaration.as(StructDeclSyntax.self) {
              return structDeclaration.storedProperties()
            } else {
              throw CodableError.invalidInputType
            }
          }()
      

      此处咱们将 DeclGroupSyntax 转化为 ClassDeclSyntax或StructDeclSyntax 并获取其存储特点。(storedProperties()是一个拓宽办法,详细见完好代码)

    • 然后咱们获取存储特点的特点名和类型:

      // unpacking the property name and type of a stored property
          let arguments = storedProperties.compactMap { property -> (name: String, type: TypeSyntax)? in
            guard let name = property.name, let type = property.type
            else { return nil } 
            return (name: name, type: type)
      

      其中 property.name 和 property.type 为拓宽核算特点,详细见完好代码。

    • 然后结构私有结构器:

      // MARK: - _init
          let _initBody: ExprSyntax = "\(raw: arguments.map { "self.\($0.name) = _\($0.name)" }.joined(separator: "\n"))"
          let _initDeclSyntax = try InitializerDeclSyntax(
            PartialSyntaxNodeString(stringLiteral: "private init(\(arguments.map { "_\($0.name): \($0.type)" }.joined(separator: ", ")))"),
            bodyBuilder: {
              _initBody
            }
          )
      

      InitializerDeclSyntax 用于描绘结构器节点,ExprSyntax 用于描绘表达式节点。

    • 结构核算特点:

      // MARK: - defaultValue
          let defaultBody: ExprSyntax = "Self(\(raw: arguments.map { "_\($0.name): \($0.defaultValue ?? $0.type.defaultValueExpression)" }.joined(separator: ",")))"
          let defaultDeclSyntax: VariableDeclSyntax = try VariableDeclSyntax("public static var defaultValue: Self") {
            defaultBody
          }
      

      VariableDeclSyntax 用于描绘变量节点

    • 终究将这两个 DeclSyntax 回来即可:

      return [
      	DeclSyntax(_initDeclSyntax),
      	DeclSyntax(defaultDeclSyntax)
      ]
      
  • 终究咱们需求在桥接文件中声明这个宏:

    @attached(conformance)
    @attached(member, names: named(`init`), named(defaultValue))
    public macro icarusCodable() = #externalMacro(module: "IcarusMacros", type: "AutoCodableMacro")
    

    这表明这个宏不需求参数,并且为类型添加了新的 conformance,以及添加了两个新的成员:initdefaultValue

语法树

假如你不了解 StructDeclSyntax 的详细结构,能够经过编写单元测试,然后进行断点调试:

func testAutoCodable() {
    assertMacroExpansion(
      #"""
      struct People { }
      @icarusCodable
      struct Student{
        let name: String
        let age: Int
        let address: String?
        let isBoarder: Bool
      }
      """#
      ,
      expandedSource: "",
      macros: testMacros)
  }

经过断点调试输出语法树,咱们能够看到 StructDeclSyntax 的语法树大约长这样:

Swift Macros 元编程为Codable解码提供默认值

其中每个节点的意义:

  • AttributeListSyntax:添加的宏列表,你能够在这里找到一切添加的宏以及描绘宏的参数,比方上面的比方中能够看到一个 name 为 icarusCodable 的宏。
  • structKeyword:表明其类型为 struct。
  • identifier:目标名。
  • MemberDeclBlockSyntax: 成员结构节点。
  • MemberDeclListSyntax:成员列表。
  • MemberDeclListItemSyntax: 每一个成员的详细信息。

在其中能够解析出每个存储特点的特点名和类型。

经过自界说注解宏注入参数

现在咱们完成了一个能够主动生成默许值的宏,可是生成的默许值都是提早界说好的不变的值,咱们期望能够恣意自界说默许值以应对不同的事务场景。

经过语法树解析,能够知道在类型节点的 AttributeListSyntax 中能够获取到自界说宏的详细信息,所以咱们自界说一个不做打开的注解宏:

// Annotation macro, unexpanded
public struct AutoCodableAnnotation: PeerMacro {
  public static func expansion(
    of node: AttributeSyntax,
    providingPeersOf declaration: some DeclSyntaxProtocol,
    in context: some MacroExpansionContext) throws -> [DeclSyntax] {
      return []
    }
}

在桥接文件中咱们声明这个宏:

@attached(peer)
public macro icarusAnnotation<T: IcarusCodable>(default: T) = #externalMacro(module: "IcarusMacros", type: "AutoCodableAnnotation")

这表明这个宏承受一个 IcarusCodable 类型的参数。

接下来咱们对 AutoCodableMacro 稍作修改,去获取添加在存储特点上的 icarusAnnotation

let arguments = storedProperties.compactMap { property -> (name: String, type: TypeSyntax, defaultValue: String?)? in
      guard let name = property.name, let type = property.type
      else { return nil }
      var defaultValue: String?
      // find the icarusAnnotation annotation tag
      guard let attribute = property.attributes?.first(where: { $0.as(AttributeSyntax.self)!.attributeName.description == "icarusAnnotation" })?.as(AttributeSyntax.self),
            let arguments = attribute.argument?.as(TupleExprElementListSyntax.self)
      else { return (name: name, type: type, defaultValue: defaultValue) }
      // extracting the key and default values from the annotation and parsing them according to the syntax tree structure.
      arguments.forEach {
        let argument = $0.as(TupleExprElementSyntax.self)
        let expression = argument?.expression.as(StringLiteralExprSyntax.self)
        let segments = expression?.segments.first?.as(StringSegmentSyntax.self)
        switch argument?.label?.text {
        case "default": defaultValue = argument?.expression.description
        default: break
        }
      }
      // the property name is used as the default key
      return (name: name, type: type, defaultValue: defaultValue)
    }

经过property.attributes 咱们获取到添加在存储特点上,名为icarusAnnotation 的宏的参数列表中 default 的值。当然,这是一个可选值,当它不存在时,默许运用IcarusCoable.defaultValue。

let defaultBody: ExprSyntax = "Self(\(raw: arguments.map { "_\($0.name): \($0.defaultValue ?? $0.type.defaultValueExpression)" }.joined(separator: ",")))"

作用如下:

@icarusCodable
struct Student {
  let name: String
  @icarusAnnotation(default: 100)
  let age: Int
  let address: String?
  @icarusAnnotation(default: true)
  let isBoarder: Bool
// === 宏打开开端 ===
	private init(_name: String, _age: Int, _address: String?, _isBoarder: Bool) {
    self.name = _name
    self.age = _age
    self.address = _address
    self.isBoarder = _isBoarder
	}
	public static var defaultValue: Self {
    Self (_name: String.defaultValue, _age: 100, _address: String?.defaultValue, _isBoarder: true)
	}
// === 宏打开完毕 ===
}
// === 宏打开开端 ===
extension Student : IcarusCodable  {}
// === 宏打开完毕 ===

为 IcarusCodable 完成 Codable

在此基础之上,咱们继续为 AutoCodableMacro 添加三个成员: CodingKeys init(from: Decoder) encode(to: Encoder) ,同时为了能够完成自界说CodingKeys,首要给注解宏添加一个参数:

@attached(peer)
public macro icarusAnnotation<T: IcarusCodable>(key: String? = nil, default: T) = #externalMacro(module: "IcarusMacros", type: "AutoCodableAnnotation")
@attached(peer)
public macro icarusAnnotation(key: String) = #externalMacro(module: "IcarusMacros", type: "AutoCodableAnnotation")

新增参数 key: String? 表明此特点对应的编码键值,为可选类型,未设置时,默许取变量名。

  • CodingKeys:

    // MARK: - CodingKeys
    let defineCodingKeys = try EnumDeclSyntax(PartialSyntaxNodeString(stringLiteral: "public enum CodingKeys: String, CodingKey"), membersBuilder: {
      DeclSyntax(stringLiteral: "\(arguments.map { "case \($0.key)" }.joined(separator: "\n"))")
    })
    
  • Decoder:

    // MARK: - Decoder
    let decoder = try InitializerDeclSyntax(PartialSyntaxNodeString(stringLiteral: "public init(from decoder: Decoder) throws"), bodyBuilder: {
      DeclSyntax(stringLiteral: "let container = try decoder.container(keyedBy: CodingKeys.self)")
    	for argument in arguments {
    	  ExprSyntax(stringLiteral: "\(argument.name) = (try? container.decode(\(argument.type).self, forKey: .\(argument.key))) ?? \(argument.defaultValue ?? argument.type.defaultValueExpression)")
    	}
    })
    
  • Encoder:

    // MARK: - Encoder
    let encoder = try FunctionDeclSyntax(PartialSyntaxNodeString(stringLiteral: "public func encode(to encoder: Encoder) throws"), bodyBuilder: {
      let expr: String = "var container = encoder.container(keyedBy: CodingKeys.self)\n\(arguments.map { "try container.encode(\($0.name), forKey: .\($0.key))" }.joined(separator: "\n"))"
    	DeclSyntax(stringLiteral: expr)
    })
    

拓宽桥接文件的宏声明:

@attached(conformance)
@attached(member, names: named(`init`), named(defaultValue), named(CodingKeys), named(encode(to:)), named(init(from:)))
public macro icarusCodable() = #externalMacro(module: "IcarusMacros", type: "AutoCodableMacro")

终究作用:

@icarusCodable
struct Student {
  @icarusAnnotation(key: "new_name")
  let name: String
  @icarusAnnotation(default: 100)
  let age: Int
  @icarusAnnotation(key: "new_address", default: "abc")
  let address: String?
  @icarusAnnotation(default: true)
  let isBoarder: Bool
// === 宏打开开端 ===
public enum CodingKeys: String, CodingKey {
    case new_name
    case age
    case new_address
    case isBoarder
}
public init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    name = (try? container.decode(String.self, forKey: .new_name)) ?? String.defaultValue
    age = (try? container.decode(Int.self, forKey: .age)) ?? 100
    address = (try? container.decode(String?.self, forKey: .new_address)) ?? "abc"
    isBoarder = (try? container.decode(Bool.self, forKey: .isBoarder)) ?? true
}
public func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(name, forKey: .new_name)
    try container.encode(age, forKey: .age)
    try container.encode(address, forKey: .new_address)
    try container.encode(isBoarder, forKey: .isBoarder)
}
private init(_name: String, _age: Int, _address: String?, _isBoarder: Bool) {
    self.name = _name
    self.age = _age
    self.address = _address
    self.isBoarder = _isBoarder
}
public static var defaultValue: Self {
    Self (_name: String.defaultValue, _age: 100, _address: "abc", _isBoarder: true)
}
// === 宏打开完毕 ===
}
// === 宏打开开端 ===
extension Student : IcarusCodable  {}
// === 宏打开完毕 ===

能够看到,在从container中解码失利时,会主动赋值为默许值,而不会抛出错误导致整个类型解码失利。

关于默许的默许值

默许的默许值是指没有在注解中特别声明默许值时,宏所获取的默许值。它默许从IcarusCodable协议中的 defaultValue 中取值,所以在运用时,需求提早为一切的根本类型完成此协议,并声明这些 默许的默许值

你能够在你的项目中自由的界说这些默许的默许值。

类型中包括自界说类型

只要包括的自界说类型也是 IcarusCodable 类型,那么宏就能够正常打开,不然,你需求手动为此类型完成 IcarusCodable 协议。

@icarusCodable
struct Address {
  let country: String
  let province: String
  let city: String
}
@icarusCodable
struct Student {
  @icarusAnnotation(key: "new_name")
  let name: String
  @icarusAnnotation(default: 100)
  let age: Int
  let address: Address
  @icarusAnnotation(default: true)
  let isBoarder: Bool
}

关于枚举

宏不适用于枚举类型,但假如自界说类型中包括枚举类型,请使此枚举遵从并手动完成 IcarusCodable:

enum Sex: IcarusCodable {
  case male
  case female
  static var defaultValue: Sex { .male }
}
@icarusCodable
struct Student {
  @icarusAnnotation(key: "new_name")
  let name: String
  @icarusAnnotation(default: 100)
  let age: Int
  let address: Address
  @icarusAnnotation(default: true)
  let isBoarder: Bool
  let sex: Sex
}

总结

运用宏为codable主动生成默许值的优缺陷:

  • 长处:

    • 能够自界说CodingKeys。
    • 能够恣意自界说默许值。
    • 开发宏时支撑断点调试、支撑宏的单元测试,后续保护简略。
    • 宏彻底运用swift编写。
  • 缺陷:

    • swift-syntax 现在不支撑 cocoapods,仅能经过SPM集成。

假如你的项目支撑 SPM,那么运用宏会让你的代码愈加简洁美丽。

文中运用的完好代码:github.com/ssly1997/Ic…