文章

Go 语言学习:copier

1. 为什么要学 copier?

在实际业务项目中,我们很少只维护“一份结构体”。通常会有:

  • Entity / Model:贴近数据库表、领域模型,例如 UserOrder
  • DTO(Data Transfer Object):用于 HTTP 请求/响应、RPC 入参/出参,例如 CreateUserRequestUserResponse
  • VO / ViewModel:为前端展示定制的视图对象,例如 UserDetailVO

如果所有层都共用同一个结构体,很快会遇到这些问题:

  • 安全问题:数据库里的字段(如密码、权限标记)可能被不小心直接返回给前端。
  • 演进困难:对外接口需要长期兼容,字段只增不减,而内部 Model 希望保持简洁干净。
  • 边界不清:接口层、领域层、基础设施层界限模糊,改一处牵一堆,维护成本高。

因此常见的工程实践是:

结构体按层拆分(DTO / Entity / VO),用“映射逻辑”把它们关联起来。

手动写映射函数(dto -> entityentity -> dto)是最常见的方式,但当结构体多、字段多时会非常枯燥甚至容易出错。于是就有了各种“结构体复制 / 映射库”,github.com/jinzhu/copier 就是其中比较常用的一个。

本文从以下几个方面系统整理 copier 的学习笔记:

  • copier 的基本能力与适用场景
  • 常见使用方式与映射规则
  • 在 DTO-Entity 映射中的典型实践
  • 进阶用法:IgnoreEmpty、DeepCopy、标签控制等
  • 性能、坑点与最佳实践

2. copier 是什么?能帮我做什么?

2.1 基本介绍

copier 是一个基于反射的结构体复制库,仓库地址:github.com/jinzhu/copier

它的核心能力可以概括为一句话:

根据字段名和类型兼容规则,把一个对象(struct / slice / map)里的值复制到另一个对象。

特点:

  • 使用简单:单个函数 copier.Copy(dst, src) 即可完成大部分复制需求。
  • 按字段名匹配:同名字段会自动映射,不需要手动逐个赋值。
  • 支持多种类型:结构体、结构体切片、map 等之间的复制。
  • 可控行为:支持 IgnoreEmpty(只复制非零值)、DeepCopy(深拷贝)、基于标签的字段映射等。

典型使用场景:

  • DTO ↔ Entity 转换(接口层 ↔ 领域层)。
  • Entity ↔ VO 转换(领域层 ↔ 表现层)。
  • 复杂对象的复制(例如 Patch 更新前先复制一个备份等)。

安装:

1
go get github.com/jinzhu/copier

3. 入门示例:用 copier 做一次 DTO → Entity 映射

假设有以下 CreateUserDTO 和 User,很符合实际业务场景:

1
2
3
4
5
6
7
8
9
10
11
12
type CreateUserDTO struct {
    Name  string
    Email string
    Age   int
}

type User struct {
    ID    int64
    Name  string
    Email string
    Age   int
}

使用 copier 从 DTO 生成 Entity:

1
2
3
4
5
6
7
8
9
10
11
import "github.com/jinzhu/copier"

func DTOToEntity(dto *CreateUserDTO) (*User, error) {
    var user User
    if err := copier.Copy(&user, dto); err != nil {
        return nil, err
    }
    // DTO 不负责的字段,由业务层处理
    user.ID = generateUserID()
    return &user, nil
}

可以看到:

  • 目标对象 user 用指针传入:copier.Copy(&user, dto)。
  • 字段名一致(Name/Email/Age),类型也兼容,copier 会自动赋值。
  • ID 字段不在 DTO 中,业务逻辑根据需要手动填充。 相比手写:
    1
    2
    3
    4
    5
    
    user := User{
      Name:  dto.Name,
      Email: dto.Email,
      Age:   dto.Age,
    }
    

    当字段变多时,copier 可以帮你省下很多机械劳动。

4. 核心用法与映射规则

4.1 基本 API:Copy 与 CopyWithOption

最常用函数:

1
  err := copier.Copy(dst, src)

带选项的版本:

1
2
3
4
5
  err := copier.CopyWithOption(dst, src, copier.Option{
      IgnoreEmpty: false,
      DeepCopy:    false,
      Converters:  nil,
  })

其中:

  • dst 必须是指针(指向 struct / slice 等),copier 会在内部修改它。
  • src 可以是值也可以是指针。
  • IgnoreEmpty、DeepCopy 是两个非常常用的选项,下文单独展开。

4.2 字段匹配规则

在 Copy(dst, src) 时,copier 会按照以下规则进行字段映射:

  • 按字段名匹配:
    • 目标结构体中每个导出字段(首字母大写),会尝试在来源结构体中找到同名字段。
  • 类型兼容再复制:
    • 类型完全相同:直接复制。
    • 一些简单兼容场景(例如 int → int64)可能可以工作,但不建议过度依赖隐式转换。
  • 字段必须导出:
    • 只有首字母大写的字段才会被处理,小写私有字段会被忽略。
  • 支持嵌套结构体:
    • 对于嵌套 struct,copier 会递归复制(结合 DeepCopy 使用更安全)。

一个小示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type UserDTO struct {
    ID    int64
    Name  string
    Email string
}

type User struct {
    ID    int64
    Name  string
    Email string
    // Password 不会复制到 DTO
    Password string
}

// Entity -> DTO
func EntityToDTO(user *User) (*UserDTO, error) {
    var dto UserDTO
    if err := copier.Copy(&dto, user); err != nil {
        return nil, err
    }
    return &dto, nil
}

这里 Password 在 DTO 中没有对应字段,自然不会被复制,正好实现了数据脱敏 / 隐藏内部字段的效果。

4.3 切片与批量转换

copier 对 切片复制 也有支持,非常适合于列表接口场景:

1
2
3
4
5
6
7
func EntityListToDTOList(users []User) ([]UserDTO, error) {
    var dtos []UserDTO
    if err := copier.Copy(&dtos, users); err != nil {
        return nil, err
    }
    return dtos, nil
}

注意事项:

  • dst 是 []UserDTO 的指针或切片变量地址:&dtos,copier 会帮你分配容量并填充。
  • 内部会逐个元素做结构体复制,字段对应规则与单个结构体一致。

5. 进阶能力:IgnoreEmpty、DeepCopy 与标签控制

5.1 IgnoreEmpty:只复制“非零值”字段(适合 Patch 更新)

很多接口会有这种需求:

  • 前端传一个 UpdateUserDTO,里面字段可以只填一部分。
  • 后端希望“只更新那些前端有填写的字段”,没写的字段保持数据库原值。

这种场景下,用 copier 的 IgnoreEmpty 非常方便:

1
2
3
4
5
6
7
8
9
10
11
12
type UpdateUserDTO struct {
    Name  string
    Email string
    Age   int
}

func PatchUserFromDTO(user *User, dto *UpdateUserDTO) error {
    // 把 dto 中非零值字段复制到 user 上
    return copier.CopyWithOption(user, dto, copier.Option{
        IgnoreEmpty: true,
    })
}

例如:

1
2
3
4
5
dto := UpdateUserDTO{
    Name: "NewName",
    // Email 和 Age 不填,保持零值
}
_ = PatchUserFromDTO(user, &dto)

执行后:

  • user.Name 会被更新为 “NewName”。
  • user.Email、user.Age 不会被修改(因为 DTO 中是零值)。 需要注意的点:
  • “零值”是 Go 语义上的零值:
    • string:””
    • int/数值:0
    • 指针:nil
    • slice/map:nil(空切片但非 nil 时要小心)
  • 如果业务上“空字符串也表示要覆盖”,那就不能用 IgnoreEmpty,需要手动区分。

5.2 DeepCopy:深拷贝嵌套结构

默认情况下,copier 对于指针 / 切片等引用类型的复制行为,需要根据实际版本与实现来确认,一般而言会做递归复制。但如果你希望显式强调“深拷贝”,可以使用 DeepCopy 选项:

1
2
3
err := copier.CopyWithOption(&dst, &src, copier.Option{
    DeepCopy: true,
})

适用场景:

  • 有嵌套结构体、切片、map 等引用类型。
  • 希望复制出的对象与原始对象完全独立修改互不影响。

在 DTO-Entity 映射场景中,多数只做“构建一个新对象”,通常已有足够隔离,但当你用 copier 做对象备份(例如修改前先拷贝一份 oldUser 用于审计)时,建议开启 DeepCopy,避免引用类型被共享。

5.3 标签控制:重命名字段 / 忽略字段(视版本而定)

根据 copier 的版本实现,可以使用类似 copier tag 的方式来控制映射行为(不同版本支持状况略有差异,使用前建议查看仓库 README): 常见几类需求:

  • 字段名不一样时手动指定映射名称:
    • 例如 DTO 用 UserName,Entity 用 Name。
  • 显式忽略某些字段:
    • 避免误复制敏感字段或临时字段。

示意(注意:以下是伪示例,具体 tag 语法以项目 README 为准):

1
2
3
4
5
6
type UserDTO struct {
    UserName string `copier:"Name"` // 指定从 src.Name 映射到 UserName
    Email    string
    // Password 不希望被复制
    Password string `copier:"-"`
}

实际项目中如果频繁使用标签,需要注意:

  • 标签本身会增加“隐式逻辑”,需要团队达成共识。
  • 对于关键字段映射,尽量在代码中留注释说明原因。

6. DTO-Entity 映射的组合实践

这一节把前面散落的用法串成几种典型的“落地套路”。

6.1 创建接口:CreateDTO → Entity

典型流程:

  1. 控制层解析 HTTP 请求,得到 CreateUserDTO。
  2. 用 copier 把 DTO 转成 User。
  3. 补充 Entity 中的系统字段(ID、创建时间、状态等)。
  4. 持久化到数据库。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type CreateUserDTO struct {
    Name  string
    Email string
    Age   int
}

type User struct {
    ID        int64
    Name      string
    Email     string
    Age       int
    CreatedAt time.Time
    Status    int
}

func (dto *CreateUserDTO) ToEntity() (*User, error) {
    var u User
    if err := copier.Copy(&u, dto); err != nil {
        return nil, err
    }
    u.ID = generateUserID()
    u.CreatedAt = time.Now()
    u.Status = 1
    return &u, nil
}

好处:

  • DTO 与 Entity 解耦:对外接口可以自由演进,而内部模型保持清晰。
  • 映射逻辑集中:只在一个函数里编排 copier + 补充逻辑,容易维护。

6.2 查询接口:Entity → ResponseDTO(含列表)

对外返回时,也建议构造专门的 ResponseDTO:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type UserResponse struct {
    ID    int64  `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
    Age   int    `json:"age"`
}

func UserToResponse(u *User) (*UserResponse, error) {
    var resp UserResponse
    if err := copier.Copy(&resp, u); err != nil {
        return nil, err
    }
    return &resp, nil
}

func UserListToResponseList(users []User) ([]UserResponse, error) {
    var respList []UserResponse
    if err := copier.Copy(&respList, users); err != nil {
        return nil, err
    }
    return respList, nil
}

结合分页场景:

1
2
3
4
type PageResult[T any] struct {
    Total int64 `json:"total"`
    List  []T   `json:"list"`
}

就可以把 []User 转成 PageResult[UserResponse] 的 List 部分。

6.3 更新接口:PatchDTO + IgnoreEmpty

前面已经提到 Patch 更新的典型写法,这里再完整整理一次:

1
2
3
4
5
6
7
8
9
10
11
12
type UpdateUserDTO struct {
    Name  string
    Email string
    Age   int
}

func UpdateUserFromDTO(user *User, dto *UpdateUserDTO) error {
    // 只覆盖 dto 中“非零值”的字段
    return copier.CopyWithOption(user, dto, copier.Option{
        IgnoreEmpty: true,
    })
}

路由层 / service 层中可以这么用:

1
2
3
4
5
6
7
8
9
10
11
12
func (s *UserService) UpdateUser(ctx context.Context, id int64, dto *UpdateUserDTO) error {
    user, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return err
    }

    if err := UpdateUserFromDTO(user, dto); err != nil {
        return err
    }

    return s.repo.Save(ctx, user)
}

这样,DTO 就是一个“可选字段集合”,业务逻辑可以用 IgnoreEmpty 快速做“部分更新”。

7. 性能、局限与踩坑点

7.1 性能:反射终究不如手写

copier 基于反射,性能一定比手写赋值低。但是否“可接受”,取决于你的场景:

  • 接口 QPS 较低,单次复制字段数量有限:通常没有问题,可视为工程效率优先。
  • 高频、批量、核心链路(例如百万级循环里做复制):建议 benchmark 评估,如果成为瓶颈,可以对关键路径改回手写或代码生成。

实践建议:

  • 不要过早优化,先用在“方便的地方”。
  • 对于核心性能敏感模块,可以单独用手写映射,或者使用代码生成型方案。

7.2 类型不匹配导致字段“悄悄不生效”

一个常见坑是:字段名一样,但类型不同,最后发现字段没被复制。例如:

1
2
3
4
5
6
7
type UserDTO struct {
    ID string
}

type User struct {
    ID int64
}

这类场景 copier 一般不会帮你自动做字符串转数字,会导致:

  • dto.ID 有值。
  • user.ID 仍是零值。 因此:
  • 字段名对齐的同时,尽量保持类型一致。
  • 如果确实需要做复杂类型转换,应该用显式的转换逻辑,或者配置自定义 Converters(视 copier 版本和能力)。

7.3 IgnoreEmpty 的语义陷阱

前面提到:

  • IgnoreEmpty 是基于 Go “零值”的判断。
  • 业务语义可能并不总是等价于“零值就是没填”。

典型陷阱:

  • 对于字符串字段,有时“明确设置为空字符串”“与”“保持原值”是两种不同语义。
  • 对于数值字段,有时 0 也是一个合法业务值(例如积分、数量)。

解决方式: 对于这类字段,不要用 IgnoreEmpty,而是改用“指针字段 + 判空”模式,例如:

1
2
3
4
5
  type UpdateUserDTO struct {
      Name  *string
      Email *string
      Age   *int
  }

然后用手写逻辑判断:

1
2
3
  if dto.Name != nil {
      user.Name = *dto.Name
  }

copier 适合处理简单、结构类似的复制场景,复杂更新语义还是需要业务代码来控制。

8. 总结:什么时候该用 copier,怎么用得舒服?

最后做一个整体小结,方便回顾:

  • 适用场景:
    • DTO ↔ Entity、Entity ↔ VO 之间字段高度相似。
    • 需要快速做结构体复制、列表转换,减少机械劳动。
    • 接口 QPS、延迟要求允许引入一定反射开销。
  • 核心用法:
    • copier.Copy(dst, src):最基本的结构体 / 切片复制。
    • copier.CopyWithOption(dst, src, copier.Option{IgnoreEmpty: true}):常用于 PATCH 更新,只复制非零值字段。
    • 配合泛型支持(自己封装),可以做一些通用的“分页结果转换”、“列表转换”。
  • 使用建议:
    • 在项目中为 DTO-Entity 映射设计明确的位置(例如 assembler 包、converter 包),把 copier 包裹起来统一使用。
    • 不要在业务逻辑中到处散落 copier.Copy 调用,而是封装为函数:DTO.ToEntity()、Entity.ToDTO()、ConvertUserList() 等。
    • 对性能敏感的链路做好 benchmark,有必要时改为手写映射。
    • 理解 IgnoreEmpty 和零值语义,谨慎使用在对“空值”非常敏感的字段上。
本文由作者按照 CC BY 4.0 进行授权