关键词: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 // 默认值?自己兜底
}
}
- 键名映射写两处:
CodingKeys和init(from:),改一处漏一处就崩。 - 别名无法声明:后端从
username换到user_name,想同时兼容做不到。 - 默认值靠
try?+??:噪音大,还掩盖真正的解码错误。 - 时间戳要到处手动格式化:接口返回
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,抛keyNotFoundcontains为 true 但值非 null(如age: 7)→ 同样走else,正常解出7contains为 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而编译失败。