Skip to content

Repository files navigation

quickcopy

基于静态代码生成的深度拷贝函数生成工具,支持基础类型、时间和 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 给零值)/ 取址(值 → 指针); 同类型的指针仍按老行为直接赋值(共享同一个对象),深拷模式下由深拷层接手
    • 空值类型与普通值互转: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。
  • 忽略大小写:

    • 默认情况下,字段名比较区分大小写。
    • 可以通过 --ignore-case 选项来忽略字段名的大小写,使得字段名的匹配不区分大小写。
  • 模糊字段映射:

    • 支持通过注释指定源结构体和目标结构体之间的字段映射规则。
    • 可以处理字段名称不完全匹配的情况。
    • 未指定映射规则的字段会自动按名称匹配(支持忽略大小写)。
    • 支持嵌套结构体的字段映射
  • 单个元素与切片/数组互转(--single-to-slice):

    • 需要显式打开:字段类型一边是单个元素、另一边是切片/数组时,默认不生成转换, 但会记一条日志说明要用这个开关。
    • string → []string 渲染成 []string{v};反向取首个元素, 空切片给零值(不会 panic)。
    • 元素类型之间仍走一次既有转换,例如 []int ← string(元素 string → int)。
    • 字段名要对得上(或用 目标字段=源字段 映射规则指定)。

安装

方式1:直接安装

go install github.com/antlabs/quickcopy/cmd/quickcopy@latest

方式2:从源码编译

git clone https://github.com/antlabs/quickcopy.git
cd quickcopy
make

使用方法

步骤1:定义结构体

在你的代码中定义源结构体和目标结构体:

// 源结构体
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 将自动转为字符串
}

步骤2:添加拷贝函数原型和注释标记

添加拷贝函数原型和注释标记:

// :quickcopy
func CopyToDestination(dst *Destination, src *Source) {
}

标记的判据是「某条注释以 // :quickcopy 开头」。//:quickcopy(// 后没有空格) 和块注释 /* :quickcopy */ 都不会被识别,注释里其他位置提到 :quickcopy 也不算。

步骤3:运行工具生成代码

运行工具生成代码:

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 命令本身不接收任何参数,只递归扫描当前目录。

--ignore-case

例如:

// 源结构体
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
}

--single-to-slice(未实现)

这个选项目前没有实现,加上与不加行为完全一样。

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 结构体:定义字段间的映射关系和转换逻辑。

自定义扩展

可以通过修改以下函数来自定义转换逻辑:

  1. getTypeConversion: 自定义类型转换逻辑

维护须知

生成代码的写回走的是文本拼装,不是 AST 改写。原因是 go/printer 依赖节点的 token.Pos 决定换行位置,而生成代码的位置信息和原文件的位置信息来自不同的 token.FileSet,混用会让输出被从中间拆开,且事后跑 gofmt 也救不回来 (gofmt 只调缩进/对齐,从不合并已拆开的行)。

因此有两条硬约束:

  1. 除「一份源码解析一次」外,不要再引入 token.NewFileSet();
  2. 不要把一个 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 下 其余字段不再自动匹配。

注意事项

  1. 对于 string 到 int 等可能失败的转换,生成的代码会静默处理错误
  2. 时间类型统一使用 RFC3339 格式进行转换
  3. 确保目标结构体字段类型在支持的转换范围内

常见问题和解决方案

  • 确保所有依赖项已正确安装。
  • 验证 Go 环境设置是否正确。
  • 参考 GitHub 问题页面以获取已知问题和解决方案。

About

静态代码生成的拷贝函数, 使用ast包抽出类型信息模板化生成。理论上超越一切基于反射的解决方案

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages