DTO 和模型映射
引言
客户端发送的原始数据使用起来不方便,可能包含额外未预期的信息(未经验证),并且格式可能与我们在领域中使用的方式略有不同。
模型严格属于数据层,因为它们可能包含不应暴露的敏感数据,并且它们的结构是数据库模式的镜像,可能与业务需求略有不同。因此,在数据层之外使用它们也非常不便且危险。模型绝不应以任何方式泄漏到表示层。
为了使客户端与应用程序内部层之间的数据传输干净、健壮和安全,同时仍保持灵活性,定义了DTO(数据传输对象)。它们将在表示层和业务层中使用。
然而,在典型项目中使用这样的结构很不方便,因为多次转换不同类型的数据结构并在应用程序中传递它们既困难、冗长又非常繁琐。直接将原始用户输入解组到结构中也不是一个好的解决方案,因为它不允许验证系统提供的惊人类型灵活性和细粒度。在设计框架时,做出了一个选择:首先处理原始无类型数据,先验证和清理它,然后再将其转换为结构体,这样既保留了 Web 的动态特性,又与 Go 的严格类型和谐共处。
Goyave 提供了工具和指南,以简化整个DTO 转换和模型映射过程,使其变得轻松无痛。
DTO 定义
DTO 是在 dto
包中定义的结构体。每个资源都有自己的文件。例如,“user”资源会有一个 dto/user.go
文件。
// dto/user.go
package dto
import (
"time"
"gopkg.in/guregu/null.v4"
)
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt null.Time `json:"updatedAt"`
DeletedAt null.Time `json:"deletedAt"`
}
TIP
建议为每个 DTO 中的所有字段指定 json 名称。
所有需要请求体和/或查询或返回非空体的资源操作都应有一个专用的 DTO。例如,一个“product”资源会有:
- 一个
Product
结构体,表示单个产品如何呈现给客户端 - 一个
CreateProduct
结构体,表示产品创建的请求体 - 一个
UpdateProduct
结构体,表示产品修改的请求体 - 可能为特定业务相关路由定义更多结构体
DTO 转换
每个从客户端接收数据(查询和/或体)的控制器处理程序在使用之前都应将其转换为DTO。只要您正确定义了验证规则,转换应该是安全且成功的。
// http/controller/user.go
import (
//...
"goyave.dev/goyave/v5/util/typeutil"
)
func (ctrl *Controller) Index(response *goyave.Response, request *goyave.Request) {
query := typeutil.MustConvert[dto.Index](request.Query)
//...
}
func (ctrl *Controller) Create(response *goyave.Response, request *goyave.Request) {
createDTO := typeutil.MustConvert[dto.CreateProduct](request.Data)
//...
}
INFO
以这种方式转换原始输入数据会自动过滤掉用户发送的意外额外数据,因为 DTO 结构不会有任何字段来接收它。
同样,所有从领域层传出的数据都应转换为 DTO。将模型转换为 DTO 会自动过滤掉敏感数据,因为这些字段在 DTO 结构中不存在。这样,您可以更轻松地控制返回给客户端的信息。
func (ctrl *Controller) Show(response *goyave.Response, request *goyave.Request) {
userID, err := strconv.ParseInt(request.RouteParams["userID"], 10, 64)
if err != nil {
response.Status(http.StatusNotFound)
return
}
user, err := ctrl.UserService.First(request.Context(), userID)
if response.WriteDBError(err) {
return
}
response.JSON(http.StatusOK, user)
}
// service/user/user.go
func (s *Service) First(ctx context.Context, id int64) (*dto.User, error) {
u, err := s.repository.First(ctx, id)
return typeutil.MustConvert[*dto.User](u), errors.New(err)
}
INFO
DTO 转换通过将输入结构 JSON 编组,然后将结果解组到目标结构中来工作。这种方法比依赖反射更高效、可靠和灵活。此外,它将结构标签统一在 json
标签下,而不是需要多个标签来做不同的事情。
模型映射
重要的是永远不要使用 DTO 与数据库交互,这是模型的角色。可能会诱人指定一个表名并将 DTO 直接传递给 Gorm 以创建或更新记录。然而,这是一个非常糟糕的做法。以下是一个非详尽列表解释原因:
- Gorm 可能无法正确将 DTO 字段与数据库列映射。
- Gorm 可能无法确定字段的正确数据类型。
- 更新时可能发生时间不一致。
- 时间戳列如
update_at
不会自动更新。 - 诸如软删除之类的作用域将不起作用。
模型映射是将 DTO 的字段复制到模型中的过程,有效地用来自 DTO 的值覆盖模型的一部分字段。这种映射就像调用 typeutil.Copy(model, dto)
一样简单。此函数使用 copier
库。
对于创建,可以使用空模型作为目标。
// service/user/user.go
import (
//...
"goyave.dev/goyave/v5/util/typeutil"
)
func (s *Service) Register(ctx context.Context, user *dto.RegisterUser) (*dto.User, error) {
u := typeutil.Copy(&model.User{}, user)
u, err := s.repository.Create(ctx, u)
return typeutil.MustConvert[*dto.User](u), errors.New(err)
}
// database/repository/user.go
func (r *User) Create(ctx context.Context, user *model.User) (*model.User, error) {
db := session.DB(ctx, r.DB).Omit(clause.Associations).Create(user)
return user, errors.New(db.Error)
}
对于更新,首先获取整个模型,然后使用模型映射,最后 Save
。
这样做消除了时间不一致的风险,即两个并发请求更新同一资源,但从业务角度来看,这两个请求的数据不兼容的风险。
// service/user/user.go
func (s *Service) Update(ctx context.Context, userID int64, u *dto.UpdateUser) (*dto.User, error) {
var user *model.User
err := s.session.Transaction(ctx, func(ctx context.Context) error {
var err error
user, err = s.repository.First(ctx, userID)
if err != nil {
return errors.New(err)
}
user = typeutil.Copy(user, u)
user, err = s.repository.Update(ctx, user)
if err != nil {
return errors.New(err)
}
return nil
})
return typeutil.MustConvert[*dto.User](user), err
}
// database/repository/user.go
func (r *User) Update(ctx context.Context, user *model.User) (*model.User, error) {
if user.ID == 0 {
return user, errors.New(gorm.ErrPrimaryKeyRequired)
}
db := session.DB(ctx, r.DB).Omit(clause.Associations).Save(user)
return user, errors.New(db.Error)
}
INFO
了解更多关于 session
机制的信息请点击这里。
处理可选字段
请求中经常有可选字段,最常见于查询或更新请求。可选字段可以是未定义的,这与 nil
或零值不同。在可为空字段是可选的情况下,这种区别很重要。
框架提供了一个方便的泛型类型,将使这种情况变得轻松:typeutil.Undefined[T]
。所有可选字段都应使用此类型。
通常,Go 开发人员使用指针来解决这个问题。typeutil.Undefined[T]
是一个更安全、更方便的解决方案,它以更明确的方式覆盖了更多场景,而不需要使用指针。
typeutil.Undefined[T]
包装了一个泛型值,并用于区分字段的缺失和其零值。这在处理诸如 sql.NullString
之类的包装器时特别有用,这些包装器是编码/解码为非结构值的结构体。当处理可能包含或不包含一个可为空值的字段的请求时,您不能使用指针来定义这种结构的存在或缺失。因此,字段缺失(零值)和字段存在但具有 null
值的情况是无法区分的。
此类型实现了以下接口:
encoding.TextUnmarshaler
json.Unmarshaler
json.Marshaler
driver.Valuer
sql.Scanner
import "goyave.dev/goyave/v5/util/typeutil"
type UpdateProduct struct {
Name typeutil.Undefined[string] `json:"name,omitzero"`
Price typeutil.Undefined[float64] `json:"price,omitzero"`
Tag typeutil.Undefined[*string] `json:"tag,omitzero"`
}
var dto UpdateProduct
dto.Name.Val // 实际字段值
dto.Name.IsPresent() // true/false
dto.Name.Default("default name") // 如果字段不存在,返回 "default name"
INFO
- 在上面的示例中,
dto.Tag
可以是存在的并且有一个nil
值。如果它存在,那么我们将在数据库中将tag
列更新为NULL
。 - 支持自定义类型,包括
driver.Valuer
、sql.Scanner
和copier.Valuer
的实现。 - 当字段未定义(缺失)时,
typeutil.Undefined
结构将具有其零值。因此,它将被 jsonomitzero
标签和模型映射忽略。
typeutil.Undefined[T]
也可以在模型中使用。当您从数据库获取模型时没有选择所有字段时,这很有用。多亏了这种类型,可以知道字段是否被选择。
// database/model/product.go
type Product struct {
ID typeutil.Undefined[int64] `gorm:"primarykey" json:",omitzero"`
CreatedAt typeutil.Undefined[time.Time] `json:",omitzero"`
UpdatedAt typeutil.Undefined[null.Time] `json:",omitzero"`
Name typeutil.Undefined[string] `json:",omitzero"`
Price typeutil.Undefined[float64] `json:",omitzero"`
}
typeutil.Undefined[T]
与 DTO 转换兼容,这将在下一节中解释。这意味着也可以在响应 DTO 中使用此类型,以选择性地使字段在响应中可见或不可见。