Go 语言学习:copier
1. 为什么要学 copier?
在实际业务项目中,我们很少只维护“一份结构体”。通常会有:
- Entity / Model:贴近数据库表、领域模型,例如
User、Order。 - DTO(Data Transfer Object):用于 HTTP 请求/响应、RPC 入参/出参,例如
CreateUserRequest、UserResponse。 - VO / ViewModel:为前端展示定制的视图对象,例如
UserDetailVO。
如果所有层都共用同一个结构体,很快会遇到这些问题:
- 安全问题:数据库里的字段(如密码、权限标记)可能被不小心直接返回给前端。
- 演进困难:对外接口需要长期兼容,字段只增不减,而内部 Model 希望保持简洁干净。
- 边界不清:接口层、领域层、基础设施层界限模糊,改一处牵一堆,维护成本高。
因此常见的工程实践是:
结构体按层拆分(DTO / Entity / VO),用“映射逻辑”把它们关联起来。
手动写映射函数(dto -> entity、entity -> 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
典型流程:
- 控制层解析 HTTP 请求,得到 CreateUserDTO。
- 用 copier 把 DTO 转成 User。
- 补充 Entity 中的系统字段(ID、创建时间、状态等)。
- 持久化到数据库。
示例:
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 和零值语义,谨慎使用在对“空值”非常敏感的字段上。