在哪里添加验证码?
这个项目的结构遵循领域驱动设计和六边形架构,正因为如此,我喜欢通过确定输入来自哪里以及我们应该对它们做什么来考虑验证,基本上是我们每个层之间的互动,或者更清楚的说是每个包的通信流,我把这些称为隐式验证和显式验证。
- 隐式验证,是指接收到的输入,与预期值相匹配,而显式验证,是指接收到的输入。
- 显式验证,在接收到的输入的上下文中,是业务逻辑所定义的。
详细来说,隐式验证适用于那些收到的参数必须与它的字段中使用的类型相匹配的情况,最常见的例子是通过HTTP请求收到的JSON有效载荷,它被解码成一个类型,使用了 json.Unmarshal的类型,其中的字段应该满足具体的模式;另一个例子是在使用枚举时(通过iota ),我们验证字段是否在我们定义的有效范围内。
例如, Priority 类型。
25// Validate ...
26func (p Priority) Validate() error {
27 switch p {
28 case PriorityNone, PriorityLow, PriorityMedium, PriorityHigh:
29 return nil
30 }
31
32 return NewErrorf(ErrorCodeInvalidArgument, "unknown value")
33}
一个显式验证是当商业逻辑参与进来的时候,例如在我们的微服务中,我们有一个Dates 类型,这个类型定义了商业逻辑,当开始日期存在的时候,期望开始日期在结束日期之前。
44// Validate ...
45func (d Dates) Validate() error {
46 if !d.Start.IsZero() && !d.Due.IsZero() && d.Start.After(d.Due) {
47 return NewErrorf(ErrorCodeInvalidArgument, "start dates should be before end date")
48 }
49
50 return nil
51}
领域类型中的显式验证
显式验证通常被添加到领域类型中,这些类型定义了所有我们应该关心的具体实体的业务逻辑,例如Task ,定义了一个Validate 方法。
64// Validate ...
65func (t Task) Validate() error {
66 if t.Description == "" {
67 return NewErrorf(ErrorCodeInvalidArgument, "description is required")
68 }
69
70 if err := t.Priority.Validate(); err != nil {
71 return WrapErrorf(err, ErrorCodeInvalidArgument, "priority is invalid")
72 }
73
74 if err := t.Dates.Validate(); err != nil {
75 return WrapErrorf(err, ErrorCodeInvalidArgument, "dates are invalid")
76 }
77
78 return nil
79}
这个Validate 方法包含了这个类型有效所需的所有验证,在实现验证时,使用这个方法与具体的逻辑来验证字段是典型的,其他的替代方法包括使用结构标签来定义验证规则,而不是直接编码,例如使用go-playground/validator,然而还是有一些验证函数需要调用来触发它。
数据存储中的隐式验证
隐式验证通常是在接收到的输入和最终的输出之间的数据转换过程中被触发的,旨在通过类型包的内部使用,其中一个例子是在使用数据存储时,特别是像PostgreSQL这样的关系型数据库,定义了一个具有具体值的 ENUM列。
36func newPriority(p internal.Priority) db.Priority {
37 switch p {
38 case internal.PriorityNone:
39 return db.PriorityNone
40 case internal.PriorityLow:
41 return db.PriorityLow
42 case internal.PriorityMedium:
43 return db.PriorityMedium
44 case internal.PriorityHigh:
45 return db.PriorityHigh
46 }
47
48 // XXX: because we are using an enum type, postgres will fail with the following value.
49
50 return "invalid"
51}
在我们的存储库的Create 调用(和Update 调用)期间,那个未导出的函数被调用。
29// Create inserts a new task record.
30func (t *Task) Create(ctx context.Context, params internal.CreateParams) (internal.Task, error) {
31 id, err := t.q.InsertTask(ctx, db.InsertTaskParams{
32 Description: params.Description,
33 Priority: newPriority(params.Priority),
34 StartDate: newNullTime(params.Dates.Start),
35 DueDate: newNullTime(params.Dates.Due),
36 })
37 if err != nil {
38 return internal.Task{}, internal.WrapErrorf(err, internal.ErrorCodeUnknown, "insert task")
39 }
40
41 return internal.Task{
42 ID: id.String(),
43 Description: params.Description,
44 Priority: params.Priority,
45 Dates: params.Dates,
46 }, nil
47}
这个验证的重要之处在于,它发生在数据库调用过程中,我们不需要事先明确地调用它。
使用 "DTO "类型的验证
DTO 指的是数据传输对象,基本上是一种用来表示进程间通信的值的类型,在我们的例子中是包或层之间。在Go中,没有对象,但想法是一样的:一个 ,一个DTT数据传输类型,可以用来定义具体的业务逻辑,只适用于我们应用程序的某些步骤。
例如,我们可能有一些特定的规则,只适用于创建Task ,而不适用于更新Task ;在这些情况下,我喜欢在领域包中实现一个具体的内部<Action><TypeName> 类型(在这种情况下是internal )来定义与该动作相关的规则,例如像CreateTask ,代表与创建Task 相关的规则。
更具体地说,让我们看一下这个片段。
3import (
4 validation "github.com/go-ozzo/ozzo-validation/v4"
5)
6
7// CreateParams defines the arguments used for creating Task records.
8type CreateParams struct {
9 Description string
10 Priority Priority
11 Dates Dates
12}
13
14// Validate indicates whether the fields are valid or not.
15func (c CreateParams) Validate() error {
16 if c.Priority == PriorityNone {
17 return validation.Errors{
18 "priority": NewErrorf(ErrorCodeInvalidArgument, "must be set"),
19 }
20 }
21
22 t := Task{
23 Description: c.Description,
24 Priority: c.Priority,
25 Dates: c.Dates,
26 }
27
28 if err := validation.Validate(&t); err != nil {
29 return WrapErrorf(err, ErrorCodeInvalidArgument, "validation.Validate")
30 }
31
32 return nil
33}
你可能注意到,我没有遵循我刚才提到的惯例!这是因为到目前为止,只有一个创建者在创建时才会使用。这是因为到目前为止,唯一可创建的类型是Task ,所以附加类型名称有点多余,然而如果我有更多的类型,我会相应地命名它们。
总结
将隐式验证和显式验证分开,有助于我将所需的业务规则整合到一个地方,在域包中,像数据类型转换和输入验证这样的事情在到达域包之前在其他包中处理,以便在确定业务应遵循的确切规则以及我们如何实现这些规则或坚持这些记录时使事情更加清晰。