Swift 进阶:用宏 JsonCodable 消灭 JSON解析样板代码

17 阅读12分钟

关键词:Swift、宏(Macro)、Codable、CodingKey、JSON 解析、时间戳、swift-syntax

一、需求来源

「对接后端接口时遇到一个痛点:字段命名不统一、别名要兼容、缺省值要兜底,每个模型都得手写一遍 init(from:)。」

struct User: Codable {
    let id: Int
    let name: String
    let avatarURL: String
    let age: Int

    enum CodingKeys: String, CodingKey {
        case id
        case name = "user_name"
        case avatarURL = "avatar_url"
        case age
    }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(Int.self, forKey: .id)
        name = try c.decode(String.self, forKey: .name)      // 别名?CodingKeys 表达不了
        avatarURL = try c.decode(String.self, forKey: .avatarURL)
        age = (try? c.decode(Int.self, forKey: .age)) ?? 0   // 默认值?自己兜底
    }
}
  1. 键名映射写两处:CodingKeys 和 init(from:),改一处漏一处就崩。
  2. 别名无法声明:后端从 username 换到 user_name,想同时兼容做不到。
  3. 默认值靠 try? + ??:噪音大,还掩盖真正的解码错误。
  4. 时间戳要到处手动格式化:接口返回 1725772800,展示时每个页面都要自己转一次 Date。

需求很朴素:用声明式描述「字段叫什么、还能叫什么、缺了怎么办」,让宏生成样板代码。

二、使用示例

1. 最小用法

import JsonCodable

@JsonCodable
struct Point {
    let x: Int
    let y: Int
}

let p = try Point.fromData(Data(#"{"x":1,"y":2}"#.utf8))

2. 完整参数

@JsonCodable
struct User {
    let id: Int                                      // 默认 key 即属性名

    @CodingKey("user_name", alias: ["username", "name"])
    let name: String                                 // 解码依次尝试三个 key

    @CodingKey("avatar_url")
    let avatarURL: String?                           // 可选类型,缺省即 nil

    @CodingKey("age", defaultValue: 0)
    let age: Int                                     // JSON 里是 null 时回落 0;键缺失仍抛错
}

alias 是 1.0.0 的正式参数名。早期版本叫 aliases(复数),已重命名。

defaultValue 的语义是:键存在但值为 null 时用默认值。键整个缺失仍按必填处理,抛 DecodingError.keyNotFound —— 它不等于「给字段一个默认值」,别指望靠它兜住缺字段。

3. 三种数据形态

let data = Data(#"{"id":1,"username":"Alex","avatar_url":"https://x/a.png","age":null}"#.utf8)

let u1 = try User.fromData(data)                     // Data → 模型

let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let u2 = try User.fromJson(dict)                     // 字典 → 模型

let back = try u1.toJson()                           // 模型 → 字典
// ["id": 1, "user_name": "Alex", "avatar_url": "https://x/a.png", "age": 0]

上面 username 命中别名;avatar_url 有值;age 是 null,回落默认值 0。

把 age 键整个删掉则会抛 keyNotFound,这就是 defaultValue 与「字段默认值」的区别:

try User.fromJson(["id": 1, "username": "Alex"])   // ❌ DecodingError.keyNotFound("age")
try User.fromJson(["id": 1, "username": "Alex", "age": NSNull()])  // ✅ age == 0

4. 时间戳影子属性

后端返回 10 位秒或 13 位毫秒时间戳,用 isTimestamp 直接生成一个可展示的字符串属性,不用每处手写转换:

import JsonCodable
import Foundation                                  // 必须:生成代码用到 Date / TimeInterval

@JsonCodable
struct Event {
    @CodingKey("created_at", isTimestamp: true)
    let createdAt: Int

    @CodingKey("updated_at", isTimestamp: true)
    let updatedAt: Int?                            // 可选类型同样支持
}

let e = try Event.fromJson(["created_at": 1_725_772_800])
e.createdAtStr        // "2024-09-08 05:20:00"

isTimestamp: true 会额外生成一个只读的 {属性名}Str: String?:

e.createdAtStr        // 19 个字符,形如 "2024-09-08 05:20:00"
e.updatedAtStr        // 原值为 nil 时为 nil

三种边界都会返回 nil / 正常转换:

try Event.fromJson(["created_at": 0]).createdAtStr           // nil(0 视为无值)
try Event.fromJson(["created_at": 1_725_772_800_000])        // 13 位毫秒,同样得到 "2024-09-08 05:20:00"

也可以直接用内部的独立工具 JsonTimestamp:

JsonTimestamp.string(fromTimestamp: 1_725_772_800)          // "2024-09-08 05:20:00"
JsonTimestamp.string(fromTimestamp: 1_725_772_800_000)      // 毫秒自动识别
JsonTimestamp.timeInterval(fromTimestamp: 1_725_772_800)    // 秒级 TimeInterval
JsonTimestamp.isTimestampValue(42)                          // false(不符合 10/13 位)

5. 自定义 coder

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let user = try User.fromData(data, coder: decoder)

let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .secondsSince1970
let dict = try user.toJson(coder: encoder)

6. 嵌套与 public

@JsonCodable
public struct Article {
    public let id: Int

    @CodingKey("view_count", defaultValue: 0)
    public var viewCount: Int
}

@JsonCodable
public struct ArticleList {
    public let items: [Article]      // 数组字段自动递归解码
}

7. 单独用别名兼容旧接口

@JsonCodable
struct Profile {
    // 后端从 nickname 迁到 nick_name,老版本仍要能解析
    @CodingKey("nick_name", alias: ["nickname", "nickName"])
    let nickName: String

    // 编码只产出 nick_name,别名不参与编码
}

8. 错误处理

do {
    let list = try ArticleList.fromData(data)
} catch JsonCodableError.rootNotDictionary {
    print("根节点不是对象,无法转 [String: Any]")
} catch {
    print("解码失败: \(error)")
}

三、源码讲解

1. 入口:两个宏,一个生成代码一个只做标记

@JsonCodable 是 ExtensionMacro,不往结构体里塞属性,只产出一个 extension:

@attached(extension, conformances: Codable, names: named(init(from:)), named(encode(to:)))
public macro JsonCodable() = #externalMacro(module: "JsonCodableMacros", type: "CodableMacro")

@CodingKey 是 PeerMacro,只有当 isTimestamp: true 时才产出影子属性,否则返回空数组:

@attached(peer, names: arbitrary)
public macro CodingKey(
    _ key: String,
    alias: [String] = [],
    defaultValue: Any? = nil,
    isTimestamp: Bool = false
) = #externalMacro(module: "JsonCodableMacros", type: "CodingKeyMacro")

所以 @CodingKey 只写 key 不写 isTimestamp 时,它只是个供 @JsonCodable 读取的标记,自己不生成任何代码。

2. 解析属性

private struct CodableProperty {
    let name: String
    let type: String
    let key: String
    let aliases: [String]
    let defaultValueExpression: String?
    let syntax: VariableDeclSyntax

    var allKeys: [String] { [key] + aliases }        // key + 别名一起查重
    var isOptional: Bool { unwrapOptional(type).isOptional }
    var wrappedType: String { unwrapOptional(type).wrapped }
    var hasDefault: Bool { defaultValueExpression != nil }
}

三类成员要主动跳过:

// 1. static / class 不参与编解码
if variable.modifiers.contains(where: {
    $0.name.text == "static" || $0.name.text == "class"
}) { continue }

// 2. 多绑定(let a = 1, b = 2)直接报错,不让它静默生成错误代码
if variable.bindings.count > 1 {
    context.diagnose(Diagnostic(node: Syntax(variable),
                                message: CodingKeyDiagnostic.multipleBindingsNotSupported))
    continue
}

// 3. 计算属性跳过;只带 willSet/didSet 的仍是存储属性
if let accessorBlock = binding.accessorBlock, isComputedProperty(accessorBlock) {
    continue
}

判断逻辑是「计算属性」与「带观察器的存储属性」的分界:

static func isComputedProperty(_ block: AccessorBlockSyntax) -> Bool {
    switch block.accessors {
    case .getter:
        return true                          // { get } / => 简写
    case .accessors(let list):
        for a in list {
            switch a.accessorSpecifier.text {
            case "get", "set": return true
            default: continue                // 只有 willSet/didSet
            }
        }
        return false
    }
}

坑:var count: Int { didSet {} } 看着像计算属性,其实是存储属性,必须参与编解码。用「有没有 accessorBlock」粗暴判断就会漏掉它。

3. 生成 decode:别名降级链

makeDecodeStatement 一进来就按「有没有默认值」分流,两条路径互不干扰:

static func makeDecodeStatement(property: CodableProperty) -> String {
    let keys = property.allKeys
    if property.hasDefault {
        return makeDefaultDecode(property: property, keys: keys)   // 有默认值 → 独立分支
    }

    let decodeMethod = property.isOptional ? "decodeIfPresent" : "decode"
    let valueType = property.wrappedType

    if keys.count == 1 {
        // 单 key:直接 decode
    }
    return makeAliasDecode(property: property, keys: keys)          // 多 key:别名降级链
}

别名分支(无默认值)就是「按顺序试,最后落到 decode」,可选类型才给 nil 兜底:

static func makeAliasDecode(property: CodableProperty, keys: [String]) -> String {
    let decodeMethod = property.isOptional ? "decodeIfPresent" : "decode"
    // 前面的 key 依次 if / else if 探测
    // 最后一个 key:
    //   可选 → 不存在则 self.x = nil
    //   必填 → 直接 decode,缺失即抛 keyNotFound
}

展开效果:

// @CodingKey("user_name", alias: ["username", "name"]) let name: String
if container.contains(AnyCodingKey(stringValue: "user_name")) {
    self.name = try container.decode(String.self, forKey: AnyCodingKey(stringValue: "user_name"))
} else if container.contains(AnyCodingKey(stringValue: "username")) {
    self.name = try container.decode(String.self, forKey: AnyCodingKey(stringValue: "username"))
} else {
    self.name = try container.decode(String.self, forKey: AnyCodingKey(stringValue: "name"))
}

坑:最后那个分支走的是 decode 而非 decodeIfPresent —— 别名全缺失时按「必填」处理,让 keyNotFound 正常抛出,而不是悄悄给 nil。

3.1 defaultValue:判断的是「值为 null」而非「键缺失」

这是最容易误解的地方。带默认值时生成的代码长这样:

// @CodingKey("age", defaultValue: 0)  let age: Int
if container.contains(AnyCodingKey(stringValue: "age")),
   try container.decodeNil(forKey: AnyCodingKey(stringValue: "age")) {
    self.age = 0
} else {
    self.age = try container.decode(Int.self, forKey: AnyCodingKey(stringValue: "age"))
}

关键在 contains 和 decodeNil 两个条件同时成立:

  • contains 为 false(键不存在)→ 走 else 分支的 decode,抛 keyNotFound
  • contains 为 true 但值非 null(如 age: 7)→ 同样走 else,正常解出 7
  • contains 为 true 且值为 null → 用默认值

所以 defaultValue 兜的是 JSON 显式 null,不是缺字段:

try User.fromJson(["id": 1, "age": 7])              // ✅ age = 7
try User.fromJson(["id": 1, "age": NSNull()])       // ✅ age = 0(默认值兜底)
try User.fromJson(["id": 1])                        // ❌ DecodingError.keyNotFound("age")

注意最后一行:defaultValue 不能用来容忍后端少返回字段。想要「缺字段也能过」,得让属性本身可选。

可选类型配 defaultValue 也有个反直觉行为 —— 键缺失同样抛错,默认值只在 null 时生效:

@JsonCodable
struct Opt {
    @CodingKey("age", defaultValue: 18)
    let age: Int?
}

try Opt.fromJson(["age": NSNull()]).age   // 18    ← null 用默认值
try Opt.fromJson(["age": 21]).age         // 21
try Opt.fromJson([:])                     // ❌ 抛 keyNotFound,不是 nil

源码里那段注释说的就是这个取舍:显式写了 defaultValue 就说明这个字段「必须出现」,只是允许用 null 表示「用默认值」。

别名的等价写法是逐 key 套同样的判断:

// @CodingKey("score", alias: ["points"], defaultValue: 0)  let score: Int
if container.contains(AnyCodingKey(stringValue: "score")) {
    if try container.decodeNil(forKey: AnyCodingKey(stringValue: "score")) {
        self.score = 0
    } else {
        self.score = try container.decode(Int.self, forKey: AnyCodingKey(stringValue: "score"))
    }
} else if container.contains(AnyCodingKey(stringValue: "points")) {
    if try container.decodeNil(forKey: AnyCodingKey(stringValue: "points")) {
        self.score = 0
    } else {
        self.score = try container.decode(Int.self, forKey: AnyCodingKey(stringValue: "points"))
    }
} else {
    self.score = try container.decode(Int.self, forKey: AnyCodingKey(stringValue: "score"))
}

4. 生成 encode

let method = property.isOptional ? "encodeIfPresent" : "encode"
return """
    try container.\(method)(
        \(property.name),
        forKey: AnyCodingKey(stringValue: "\(property.key)")
    )
    """

可选属性用 encodeIfPresent,nil 字段直接从 JSON 消失,而非写成 null。编码只用规范 key,不用别名 —— 别名只服务解码方向的兼容。

5. 时间戳影子属性:另一条独立的生成路径

isTimestamp 不在 CodableMacro 里处理,而是由 CodingKeyMacro 自己判断并生成。它先确认类型是 Int 族:

private static func isIntType(_ type: String) -> Bool {
    switch type {
    case "Int", "Int64", "Int32", "UInt", "UInt64", "UInt32": return true
    default: return false
    }
}

不是 Int 族就直接报错,避免生成一段没意义的代码:

guard isIntType(wrapped) else {
    context.diagnose(Diagnostic(node: Syntax(variable),
                                message: TimestampDiagnostic.requiresInt(propertyName)))
    return []
}

然后是可选 / 非可选两条解包路径,以及核心的位数判断:

let unwrap: String
if isOptional {
    unwrap = """
        guard let __timestamp = \(propertyName), __timestamp != 0 else { return nil }
        """
} else {
    unwrap = """
        let __timestamp = \(propertyName)
        guard __timestamp != 0 else { return nil }
        """
}

let body = """
    \(access)var \(shadowName): String? {
    \(unwrap)
        let __v = Int64(__timestamp)
        let __seconds: TimeInterval
        if String(Swift.abs(__v)).count == 13 {
            __seconds = TimeInterval(__v) / 1000        // 13 位 → 毫秒
        } else {
            __seconds = TimeInterval(__v)               // 其余 → 按秒
        }
        return String(String(describing: Date(timeIntervalSince1970: __seconds)).prefix(19))
    }
    """

展开出来长这样(let createdAt: Int 与 let updatedAt: Int? 各一份):

var createdAtStr: String? {
    let __timestamp = createdAt
    guard __timestamp != 0 else { return nil }
    let __v = Int64(__timestamp)
    let __seconds: TimeInterval
    if String(Swift.abs(__v)).count == 13 {
        __seconds = TimeInterval(__v) / 1000
    } else {
        __seconds = TimeInterval(__v)
    }
    return String(String(describing: Date(timeIntervalSince1970: __seconds)).prefix(19))
}

三个设计细节值得注意:

  • 0 被当作「无值」返回 nil,而不是 1970 年。后端拿不到时间时常用 0 占位,直接转开会显示 1970-01-01 08:00:00,是个很扰人的显示 bug。
  • 位数判断用 Swift.abs:负时间戳(1970 年之前)也能正确计位数。
  • 取 Date 描述的前 19 个字符,而不是用 DateFormatter。Date 的 description 形如 2024-09-08 05:20:00 +0000,截前 19 位就得到 yyyy-MM-dd HH:mm:ss,省掉了一个 formatter 对象和线程安全问题。

关键坑:生成的代码用了 Date 和 TimeInterval,但宏不会替你加 import Foundation。文件里没有这个 import 就会直接编译失败:

error: cannot find 'TimeInterval' in scope
error: cannot find 'Date' in scope

标注 isTimestamp 的文件记得 import Foundation。

6. 可见性跟随

static func accessModifierPrefix(from decl: some DeclGroupSyntax) -> String {
    guard let s = decl.as(StructDeclSyntax.self) else { return "" }
    for m in s.modifiers {
        switch m.name.text {
        case "public", "open": return "public "
        case "package":        return "package "
        default:               continue
        }
    }
    return ""
}

public struct 若生成非 public 的 init(from:) 会编译失败,所以前缀要拼进方法签名。open 被映射成 public 是因为结构体不能是 open,这里兜住误写。

影子属性走的是另一套前缀函数,额外支持 fileprivate / private:

private static func accessModifierPrefix(from variable: VariableDeclSyntax) -> String {
    for modifier in variable.modifiers {
        switch modifier.name.text {
        case "public", "open": return "public "
        case "package":        return "package "
        case "fileprivate":    return "fileprivate "
        case "private":        return "private "
        default:               continue
        }
    }
    return ""
}

为什么这里要多两个?属性可以是 private,而类型不能 —— 影子属性跟随的是属性的可见性,所以判断依据不同。

7. 编译期查重

static func validate(_ properties: [CodableProperty], context: some MacroExpansionContext) -> Bool {
    var seen: [String: VariableDeclSyntax] = [:]
    var ok = true

    for p in properties {
        for key in p.allKeys {                    // key + aliases 一起查
            if let prev = seen[key] {
                // 两处都报,IDE 同时标红,定位冲突更快
                context.diagnose(Diagnostic(node: Syntax(p.syntax),
                                            message: CodingKeyDiagnostic.duplicateKey(key)))
                context.diagnose(Diagnostic(node: Syntax(prev),
                                            message: CodingKeyDiagnostic.duplicateKey(key)))
                ok = false
            } else {
                seen[key] = p.syntax
            }
        }
    }
    return ok
}

返回 false 时 expansion 直接返回空数组,不生成任何代码 —— 用户看到的是精确到行列的红点,而非一坨难懂的展开错误。

易忽略:查重是拿 allKeys(规范 key + 所有别名)一起比的。所以两个属性即使规范 key 不同,只要别名撞了也会被拦下 —— 这正是别名最阴的 bug 来源。

8. AnyCodingKey:让 key 运行时化

public struct AnyCodingKey: Swift.CodingKey, Hashable, Sendable {
    public let stringValue: String
    public let intValue: Int?

    public init(stringValue: String) {
        self.stringValue = stringValue
        self.intValue = nil
    }
}

原生 CodingKeys 是编译期枚举,key 必须是固定 case。而别名是运行时字符串,写不出对应 case。AnyCodingKey 把 key 从类型系统降级为字符串,才能写出 container.contains(AnyCodingKey(stringValue: "username")) 这种动态判断。代价是失去拼写检查,换来别名能力。

9. JsonTimestamp:把转换逻辑抽出来

影子属性没有内联调用工具函数,而是把完整逻辑生成在属性体里。真正被复用的工具是 JsonTimestamp,供影子属性对照、以及业务侧直接调用:

public enum JsonTimestamp: Sendable {
    public static func string(fromTimestamp timestamp: some BinaryInteger) -> String {
        let seconds = timeInterval(fromTimestamp: timestamp)
        return String(String(describing: Date(timeIntervalSince1970: seconds)).prefix(19))
    }

    public static func timeInterval(fromTimestamp timestamp: some BinaryInteger) -> TimeInterval {
        let value = Int64(timestamp)
        if decimalDigitCount(of: abs(value)) == 13 {
            return TimeInterval(value) / 1000
        }
        return TimeInterval(value)
    }

    public static func isTimestampValue(_ timestamp: some BinaryInteger) -> Bool {
        let digits = decimalDigitCount(of: abs(Int64(timestamp)))
        return digits == 10 || digits == 13
    }
}

some BinaryInteger 让 Int / Int64 / UInt 都能直接传,不用手动转换。isTimestampValue 可以拿来在业务侧判断「这个数字是不是时间戳」,比如决定要不要走时间格式展示。

10. JSON 辅助 API

extension Decodable {
    public static func fromData(_ data: Data, coder: JSONDecoder? = nil) throws -> Self {
        try (coder ?? JSONDecoder()).decode(Self.self, from: data)
    }

    public static func fromJson(_ dict: [String: Any], coder: JSONDecoder? = nil) throws -> Self {
        try fromData(try JSONSerialization.data(withJSONObject: dict), coder: coder)
    }
}

extension Encodable {
    public func toJson(coder: JSONEncoder? = nil) throws -> [String: Any] {
        let data = try (coder ?? JSONEncoder()).encode(self)
        let obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
        guard let dict = obj as? [String: Any] else {
            throw JsonCodableError.rootNotDictionary
        }
        return dict
    }
}

fromJson 故意先转 Data 再走 fromData,这样自定义 coder 的策略(日期、key 转换)在两条路径上完全一致。

toJson 的 guard 是必要防御:若模型的 encode 用了 unkeyedContainer()(根节点是数组),jsonObject 返回 [Any],转 [String: Any] 会失败。显式抛 rootNotDictionary 比让调用方撞类型转换崩溃清楚得多。

四、总结

JsonCodable 做的事很朴素:编译期读出你写在属性上的声明,把原本要手写的 init(from:) / encode(to:) 和一个时间戳格式化属性生成出来。

  • 声明式替代样板:键名、别名、null 兜底值、时间戳格式化都写在属性上,一处维护,没有多处同步问题。
  • 别名支持新旧兼容:alias 按序降级尝试,原生 CodingKeys 做不到。
  • 时间戳一处声明处处可用:isTimestamp 生成 {name}Str,自动识别 10 位秒 / 13 位毫秒,0 与 nil 都返回 nil,省掉满屏的 DateFormatter。
  • 编译期校验兜住崩溃:key 冲突(含别名相撞)、非结构体标注、多绑定、非 Int 族用 isTimestamp,都在构建阶段就报错并定位行列。
  • 零侵入扩展:@JsonCodable 只生成 extension;@CodingKey 只额外补一个属性,都不改原类型声明。

四个必须记住的语义边界:

  • defaultValue 兜的是键存在且为 null,键缺失仍抛keyNotFound(可选类型配默认值也一样);
  • 可选属性用 encodeIfPresent,nil 字段会从 JSON 消失而非写成 null;
  • isTimestamp 把 0 当作无值返回 nil;
  • 用 isTimestamp 的文件必须 import Foundation,否则宏展开后找不到 Date 而编译失败。

本文源码参考