基于静态代码生成的深度拷贝函数生成工具,支持基础类型、时间和 UUID 等类型的自动转换。
- 🚀 自动生成结构体间的深度拷贝函数
- 💪 支持多种类型转换:
- int 和 string 互转
- time.Time 和 string 互转
- uuid.UUID 和 string 互转
- int 和 int8/16/32/64 互转
- 指针与值互转:
*T→T(nil 回落到零值)、T→*T(新分配一份,不与源共享)- T 是基本类型、
time.Time、声明在基本类型之上的命名类型(如type TraderCode int64), 或切片/map(如*[]byte→[]byte) - 两侧的值类型之间还会再走一次转换,所以
int16→*int32这类两步转换同样支持 - 结构体指针:
*Src↔Dst↔*Dst走结构体拷贝 helper,指针侧包一层 nil 守卫(指针 → 值,nil 给零值)/ 取址(值 → 指针); 同类型的指针仍按老行为直接赋值(共享同一个对象),深拷模式下由深拷层接手
- T 是基本类型、
- 空值类型与普通值互转:
sql.NullString/Int32/Int64/Float64/Bool/Time、gorm.DeletedAt- 取值:
sql.NullString→string(Valid=false就是零值) - 写回:
time.Time→sql.NullTime时零值写 NULL(Valid=false); 字符串、数字的零值是正常取值,Valid恒为true,不会悄悄变成 NULL - 转到指针时保住 NULL 语义:
sql.NullTime→*time.Time在Valid=false时给nil; 反向nil指针写回Valid=false
- 取值:
- 🎯 使用简单,仅需一行注释即可生成
- ⚡ 基于静态代码生成,运行时零开销
-
类型转换逻辑:
- 支持整数类型宽度判断,确保类型转换的安全性。
- 提供类型转换逻辑获取功能,自动处理不同类型间的转换。
-
窄化转换:
- 默认不生成窄化转换(例如从 int64 到 int32),可通过
--allow-narrow选项打开。 - 注意:默认拒绝窄化时既不报错也不跳过,而是退化成直接赋值
(
dst.Big = src.Big)。产物类型非法、go build编译不过,只在日志里留一行Narrowing conversion disabled。
- 默认不生成窄化转换(例如从 int64 到 int32),可通过
-
忽略大小写:
- 默认情况下,字段名比较区分大小写。
- 可以通过
--ignore-case选项来忽略字段名的大小写,使得字段名的匹配不区分大小写。
-
模糊字段映射:
- 支持通过注释指定源结构体和目标结构体之间的字段映射规则。
- 可以处理字段名称不完全匹配的情况。
- 未指定映射规则的字段会自动按名称匹配(支持忽略大小写)。
- 支持嵌套结构体的字段映射
-
单个元素与切片/数组互转(
--single-to-slice):- 需要显式打开:字段类型一边是单个元素、另一边是切片/数组时,默认不生成转换, 但会记一条日志说明要用这个开关。
string→[]string渲染成[]string{v};反向取首个元素, 空切片给零值(不会 panic)。- 元素类型之间仍走一次既有转换,例如
[]int←string(元素 string → int)。 - 字段名要对得上(或用
目标字段=源字段映射规则指定)。
go install github.com/antlabs/quickcopy/cmd/quickcopy@latestgit clone https://github.com/antlabs/quickcopy.git
cd quickcopy
make在你的代码中定义源结构体和目标结构体:
// 源结构体
type Source struct {
Name string
Age int
Birthday time.Time
ID uuid.UUID
}
// 目标结构体
type Destination struct {
Name string
Age string // 支持类型自动转换
Birthday string // time.Time 将自动转为 RFC3339 格式
ID string // UUID 将自动转为字符串
}添加拷贝函数原型和注释标记:
// :quickcopy
func CopyToDestination(dst *Destination, src *Source) {
}标记的判据是「某条注释以 // :quickcopy 开头」。//:quickcopy(// 后没有空格)
和块注释 /* :quickcopy */ 都不会被识别,注释里其他位置提到 :quickcopy 也不算。
运行工具生成代码:
quickcopy工具会自动生成如下拷贝函数:
// :quickcopy
func CopyToDestination(dst *Destination, src *Source) {
dst.Name = src.Name
dst.Age = fmt.Sprint(src.Age) // int -> string
dst.Birthday = func(t time.Time) string { return t.Format(time.RFC3339) }(src.Birthday) // time.Time -> string
dst.ID = func(u uuid.UUID) string { return u.String() }(src.ID) // uuid.UUID -> string
}类型转换是内联函数字面量,不是 src.Birthday.Format(...) 这样的方法调用。
函数体整段替换,原函数里的手写代码不会保留(注释行原样保留,下次重跑靠它识别)。
以下是一个使用 quickcopy 生成拷贝函数的示例:
// :quickcopy
func CopyData(dst *Destination, src *Source) {
dst.Name = src.Name
dst.Age = fmt.Sprint(src.Age) // int -> string
dst.Birthday = func(t time.Time) string { return t.Format(time.RFC3339) }(src.Birthday) // time.Time -> string
dst.ID = func(u uuid.UUID) string { return u.String() }(src.ID) // uuid.UUID -> string
}下面这些开关都写在 :quickcopy 标记行上(或标记之后的续行),不是命令行参数
——quickcopy 命令本身不接收任何参数,只递归扫描当前目录。
例如:
// 源结构体
type Source struct {
UserName string
Age int
}
// 目标结构体
type Destination struct {
username string // 字段名大小写不同
Age string
}添加拷贝函数原型和注释标记:
// :quickcopy --ignore-case
func CopyToDestination(dst *Destination, src *Source) {
}运行
quickcopy将生成如下拷贝函数(username 被匹配到 UserName):
func CopyToDestination(dst *Destination, src *Source) {
dst.username = src.UserName
dst.Age = fmt.Sprint(src.Age) // int -> string
}这不是一个开关,而是写在注释里的 目标字段=源字段 规则(可以写到任意层级,
详见多层级字段映射)。例如:
// 源结构体
type Source struct {
FirstName string
LastName string
UserID int
}
// 目标结构体
type Destination struct {
FullName string // 映射自 FirstName
ID string // 映射自 UserID
}添加拷贝函数原型和注释标记,并指定字段映射规则:
// :quickcopy
// FullName=FirstName
// ID=UserID
func CopyToDestination(dst *Destination, src *Source) {
}将生成如下拷贝函数:
// :quickcopy
// FullName=FirstName
// ID=UserID
func CopyToDestination(dst *Destination, src *Source) {
dst.FullName = src.FirstName
dst.ID = fmt.Sprint(src.UserID) // int -> string
}这个选项目前没有实现,加上与不加行为完全一样。
parseAnnotation 会把它记进内部开关 singleToSlice,并经 getTypeConversion
透传到 handleSliceConversion / handlePointerConversion,但这两个函数从不读它。
而且架构上本来也读不到:它们只在「两侧都是切片/数组」或「两侧都是指针」时才被调用,
单元素 ↔ 切片恰恰是一侧切片、另一侧不是。
所以下面这种映射:
type Source struct {
Tag string
}
type Destination struct {
Tags []string
}
// :quickcopy Tags=Tag
func CopyToDestination(dst *Destination, src *Source) {
}实测生成的是类型非法的直接赋值(go build 报
cannot use src.Tag (variable of type string) as []string value):
func CopyToDestination(dst *Destination, src *Source) {
dst.Tags = src.Tag
}工具不会为这种映射报错或跳过,日志里也没有任何提示。
下表「生成的表达式」列是实测产物里的原样文本(X 为字段名),非整数转换都是
内联函数字面量,不是 x.Method(...) 形式的方法调用。
| 源类型 | 目标类型 | 生成的表达式 |
|---|---|---|
int |
string |
fmt.Sprint(src.X) |
string |
int |
func(s string) int { i, _ := strconv.Atoi(s); return i }(src.X) |
time.Time |
string |
func(t time.Time) string { return t.Format(time.RFC3339) }(src.X) |
string |
time.Time |
func(s string) time.Time { t, _ := time.Parse(time.RFC3339, s); return t }(src.X) |
uuid.UUID |
string |
func(u uuid.UUID) string { return u.String() }(src.X) |
string |
uuid.UUID |
func(s string) uuid.UUID { u, _ := uuid.Parse(s); return u }(src.X) |
float64 |
string |
func(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }(src.X) |
string |
float64 |
func(s string) float64 { f, _ := strconv.ParseFloat(s, 64); return f }(src.X) |
[]byte |
string |
func(b []byte) string { return string(b) }(src.X) |
string |
[]byte |
func(s string) []byte { return []byte(s) }(src.X) |
int8 |
int16 |
int16(src.X) |
int8 |
int32 |
int32(src.X) |
int8 |
int64 |
int64(src.X) |
int16 |
int8 |
int8(src.X)(需 --allow-narrow) |
int16 |
int32 |
int32(src.X) |
int16 |
int64 |
int64(src.X) |
int32 |
int8 |
int8(src.X)(需 --allow-narrow) |
int32 |
int16 |
int16(src.X)(需 --allow-narrow) |
int32 |
int64 |
int64(src.X) |
int64 |
int8 |
int8(src.X)(需 --allow-narrow) |
int64 |
int16 |
int16(src.X)(需 --allow-narrow) |
int64 |
int32 |
int32(src.X)(需 --allow-narrow) |
宽窄判断按位宽来做(int/uint 一律按 64 位算),所以 int 与
int8/16/32/64 之间的转换规则同上:变宽直接可用,变窄(如 int → int32)
要加 --allow-narrow。不加时既不是报错也不是跳过,而是渲染成
dst.X = src.X 的类型非法赋值,产物编译不过。
单元素 ↔ 切片/数组([]int → int、int → []int)没有实现,见
--single-to-slice(未实现)。
映射规则两边都支持点号路径,任意深度。
子树级:把一个嵌套结构体整体映射过去,内部字段按普通规则递归匹配。
// :quickcopy Nested=Inner
func CopyToDestination(dst *Destination, src *Source) {
}生成(结构体字段拷贝走 helper,双参数调用需要取地址):
func CopyToDestination(dst *Destination, src *Source) {
copyDstInnerFromSrcInner(&dst.Nested, &src.Inner)
}叶子级:用点号路径精确定位到某一层。
// :quickcopy Email=Contact.Email, Nested.A=Inner.A
func CopyToDestination(dst *Destination, src *Source) {
}生成:
func CopyToDestination(dst *Destination, src *Source) {
dst.Email = src.Contact.Email
dst.Nested.A = src.Inner.A
}规则可以写在 :quickcopy 同一行(逗号分隔),也可以写在后续行(每行一条)。
规则必须写在标记行之后:标记行之前的注释行会被整段忽略,规则写了也不生效。
续行里只有完全匹配 A.B=C.D 格式的行才当规则,其余当作说明文字忽略。
选项和规则可以混排,分隔符是逗号或空白,= 两边允许有空格:
// :quickcopy --deep Tags=Nums, Mapped=Tags
func CopyToDestination(dst *Destination, src *Source) {
}生成(规则映射的字段在两侧类型相同时同样走深拷):
func CopyToDestination(dst *Destination, src *Source) {
dst.Tags = append([]int(nil), src.Nums...)
dst.Mapped = append([]string(nil), src.Tags...)
}显式规则会覆盖自动字段匹配,不会对同一个目标字段赋值两次。路径解析失败(字段不存在、 路径中间段不是结构体)会报错并跳过整个函数,不会静默丢弃。
默认浅拷贝:slice / map / 指针直接赋值(与源共享底层数据),结构体字段递归拷贝。
加 --deep 后,引用类型会新建:
// :quickcopy --deep
func CopyToDestination(dst *Destination, src *Source) {
}| 类型 | 浅(默认) | 深(--deep) |
|---|---|---|
[]T |
dst.X = src.X |
append([]T(nil), src.X...) |
map[K]V |
dst.X = src.X |
新建 map 并逐键拷贝 |
*T |
dst.X = src.X |
新建对象并拷贝 |
| 结构体字段 | 递归拷贝 | 递归拷贝(不变) |
深拷层处理不了的类型会回落成直接赋值,并在日志里告警,例如接口字段、数组
([3]int)、具名容器类型(type Tags []string)和跨包结构体(见已知限制)。
切片里如果元素本身不含引用类型([]int、[]Simple),只做一次 append 复制
底层数组,不会再生成一层 helper。
quickcopy.go:分析层与文件编排(processFile)。deepcopy.go:深拷层(deepCopyExpr/hasReference与深拷 helper 的生成)。emit.go:把生成内容渲染成 gofmt 规范文本(renderCopyFunc/renderHelper/finalize)。rewrite.go:按字节区间拼装源码(applyEdits)。CopyFuncInfo结构体:存储拷贝函数的信息,包括源和目标变量、类型及字段映射。FieldMapping结构体:定义字段间的映射关系和转换逻辑。
可以通过修改以下函数来自定义转换逻辑:
getTypeConversion: 自定义类型转换逻辑
生成代码的写回走的是文本拼装,不是 AST 改写。原因是 go/printer 依赖节点的
token.Pos 决定换行位置,而生成代码的位置信息和原文件的位置信息来自不同的
token.FileSet,混用会让输出被从中间拆开,且事后跑 gofmt 也救不回来
(gofmt 只调缩进/对齐,从不合并已拆开的行)。
因此有两条硬约束:
- 除「一份源码解析一次」外,不要再引入
token.NewFileSet(); - 不要把一个
FileSet里解析出的 AST 节点塞进用另一个FileSet打印的树里。
AST 在本项目里只用来分析和定位字节区间。
- 命名容器类型不深拷:
type M map[string]int、type L []L这类用具名类型包装的容器,--deep不会识别为引用类型,仍按共享处理。直接用map[string]int这类字面量类型则正常。 - 跨包结构体字段不深拷:
ext.Wrapper这类来自其他包的结构体字段(值和*指针都算),--deep下仍是直接赋值——值字段渲染成dst.W = src.W,指针字段渲染成dst.P = src.P(与源共享同一个对象),只在日志里告警。跨包纯值结构体的指针会生成 helper,但 helper 体是*dst = *src的浅拷(例如*sync.Mutex)。 - 数组不深拷:
[3]int、[][3]int这类数组字段--deep时回落成直接赋值并在日志里告警。 - 接口字段(
interface{}/any)不深拷,--deep时回落成直接赋值并在日志里告警。 --single-to-slice未实现:选项被解析但没有任何消费点,单元素 ↔ 切片/数组的字段 会渲染成类型非法的直接赋值,产物编译不过。- 路径中间段只允许结构体:
src.Items.Name(中间是切片)这类写法会报错并跳过该函数。 - 映射规则所在的顶层字段会被整体摘出自动匹配:写
Nested.A=Inner.A后,Nested下 其余字段不再自动匹配。
- 对于 string 到 int 等可能失败的转换,生成的代码会静默处理错误
- 时间类型统一使用 RFC3339 格式进行转换
- 确保目标结构体字段类型在支持的转换范围内
- 确保所有依赖项已正确安装。
- 验证 Go 环境设置是否正确。
- 参考 GitHub 问题页面以获取已知问题和解决方案。