[翻译]Common CRUD Design in Go
增删改查(CRUD)是一个技术产品的基础部分,做过应用开发的人应该很熟悉它。
开发CRUD应用时,大多数编程语言会有框架提供一个固定的开发架构,例如PHP的Yii2、Java的SSH。但是总所周知Go社区是反框架的。因此,我们需要设计自己的CRUD架构。
在一年的Go应用开发经验后,我发现了一套通用的CRUD设计模式,能满足大多数不同的项目的要求。我将以开发 WTF Dial 项目为例进行说明。 项目的详细介绍参考链接。
译者:WTF Dial(which they feel) 项目提供一个界面,每个成员可输入对当前团队的糟糕程度(f-cked)。
接口设计
在 WTF Dial应用中,我们采用面向接口开发,定义软件提供的服务为一个接口。通过接口提供服务,我们底层可以使用不同的实现方法。在dial.go
中,我们定义了如下DialService
接口。
// dial.go#L81-L122
type DialService interface {
FindDialByID(ctx context.Context, id int) (*Dial, error)
FindDials(ctx context.Context, filter DialFilter) ([]*Dial, int, error)
CreateDial(ctx context.Context, dial *Dial) error
UpdateDial(ctx context.Context, id int, upd DialUpdate) (*Dial, error)
DeleteDial(ctx context.Context, id int) error
}
这个结构是我在应用程序中使用的几乎所有实体。它提供了一个简单的结构,但在大多数情况下它都足够灵活。
事务边界
我将我的服务定义视为一个黑盒子。因此,我很少向应用程序的其他部分公开事务等内部细节。虽然让服务的调用者组成单独的事务调用可能很诱人,但很少有必要这样做,而且通常会使应用程序复杂化。
译者注:我的理解为将一个服务接口看作一个事务调用,复杂的调用也组织成一个事务。
通过 Context 验证安全
在WTF Dial
中,当请求到来时,将对user进行身份验证。然后验证通过过的user 将被添加到NewContextWithUser()
函数创建 context.Context
中。这意味着我们的服务中的任何函数都可以通过ctx参数访问当前 user。
出于几个原因,强制授权被内置到服务实现中。首先,它确保验证在可能的最低级别执行,而不是委托给更高级别的抽象。当我们可以将安全检查直接嵌入到SQL查询中时,我们不太可能忘记安全检查。其次,将这些限制推到数据库层会更有效,因为它限制查询和返回的数据。
下面是sqlite.findDials()
函数内的一个安全验证的示例,我们将限制用户仅能查询自己所属组的 dial_id
:
// sqlite/dial.go#L306-L310
userID := wtf.UserIDFromContext(ctx)
where = append(where, `id IN (SELECT dial_id FROM dial_memberships dm WHERE dm.user_id = ?)`)
args = append(args, userID)
查找单个对象
根据主键查找对象是您将遇到的最常见任务之一。这里我们定义了一个根据其id
获取wtf.Dial
的函数:
FindDialByID(ctx context.Context, id int) (*Dial, error)
这个函数定义看似简单,但有重要的问题需要确定。如果ID
不存在怎么办? 这种情况应该定义为一种 error
还是 *Dial
为空?
不要返回两个 nil
我看到的一个常见做法是,如果ID
无法找到,开发人员将返回 nil
Dial 和 nil
error。然而,在这种情况下,用户希望调用函数得到一个特定的 Dial
,而不是一个nil
,因此ID
未发现将是一个err
。
在实践中,函数的调用者将执行一个简单的 err != nil
检查是否错误发生,但很容易忘记检查 nil
Dial。这将导致您的程序 panic
。
// Try to fetch the dial by ID but it doesn't exist.
dial, err := FindDialByID(ctx, 100)
if err != nil {
return err
}
// 未检查 dial 是否为空
// 译者注:保证返回的数据是可用的,否者都返回一个error
// Oops! Panic here because dial is nil.
fmt.Printf("WTF Level: %d", dial.Value)
确保返回一个对象或一个错误,两者不能同时为nil
.
选择返回的数据
当返回我们的Dial
对象时,调用者通常也需要其他的附加信息。Dial
的用户是谁? 谁是 Dial
的其他成员?(即数据库中的一对一、一对多、多对多关系。例如学生和班级、老师和学生、老师和班级)。我们的数据是一个相互关联的图,所以我们需要定义返回的具体数据。
我们可以允许调用者使用额外的标记参数告诉服务需要那些参数,但这会增加应用程序的复杂性。相对来说,后台根据业务直接返回包含有用的相关数据更加容易。虽然这会招致额外的数据库调用或增加网络带宽,但这通常是一个很好的权衡,我们可以根据需要优化返回情况。
译者注:比如返回一篇博文时,通常会标签会经常使用,我们可以直接一起返回。但是评论可能很少使用,我们可以提供单独接口。
这里有个判定方法:我通常返回与主对象有父关系的相关数据。在Dial的例子中,它有一个我要附加的用户父对象。调用者几乎总是需要这些关系,因为它们为对象提供了上下文。
而返回子关系的数据会很容易地造成返回数据过多。但是。如果我知道子对象的数量是有限的,并且在查看父对象时它们几乎总是被获取,那么我就会包括子关系。在 Dial
的情况下,我们可以包括Dial
的成员列表,因为这通常是有用的,我们永远不会有超过少数的成员。另一个很好的例子是返回一组带有电子商务订单的订单项。
译者注:更简单理解,如果数据存在经常被使用的一对一关系,可以一起返回。如果存在一对多则要仔细考虑是否经常使用到该关联数据。
查询多个数据
我们的下一个函数提供了一种通过各种过滤选项来搜索Dial
数据的方法。获取一个Dial
列表听起来类似于获取单个Dial
,但有一些重要的区别。
FindDials(ctx context.Context, filter DialFilter) ([]*Dial, int, error)
*Dial
和 error
同时返回nil是可以的。与FindDialByID()
不同,它可以不返回Dial
并返回nil
error(即没有错误发生,数据也为空)。调用者可能不知道是否有任何匹配的Dial
(这就是他们搜索的原因),所以不匹配任何Dial
不是一个错误条件。
我们也不需要像查找单个Dial
时那样担心panic
,因为我们返回的是一个切片。切片上的大多数操作(len(
)或for in
)都可以在nil slice
上正常工作。
// Search for a list of all dials.
dials, _, err := FindDials(ctx, DialFilter{})
if err != nil {
return err
}
// 译者注:空切片除了取值大部分操作都不会发生panic,因此对切片取值操作前必须判断是否为空
// No panic this time. A nil slice of dials is ok.
fmt.Printf("You have %d dials.", len(dials))
// Returning a nil list and a nil error will not cause a panic
过滤结果
在这个函数中,我们传入一个filter
对象,而不是多个过滤参数。这允许我们添加额外的过滤器,而不会破坏未来的API
兼容性。
// dial.go#L124-L133
// DialFilter represents a filter used by FindDials().
type DialFilter struct {
// Filtering fields.
ID *int `json:"id"`
InviteCode *string `json:"inviteCode"`
// 限制结果的一个子集,通常用在分页中
// Restrict to subset of range.
Offset int `json:"offset"`
Limit int `json:"limit"`
}
我们在过滤器结构中使用指针,这样我们就可以选择性地添加过滤属性(不需要过滤,置为 nil
即可)。我们设置的每个字段将进一步过滤查找返回的结果。
结果切片 & 结果统计
上面 DialFilter
对象中的 Offset
和 Limit
字段可用于返回结果的子集,类似于 SQL
中的 Offset
和 Limit
子句。通常用作分页。
即使我们限制了返回的 Dial
数量,但是,我们仍需要知道匹配的 Dial
的总数。例如,分页中知道总数才能确定分多少页。为此,除了返回[]*Dial
外,我们还返回一个int
值,表示结果总数。
一些数据库允许我们在一个SQL查询中使用COUNT(*) OVER()
计算查询到数据总量。例如,如果我们搜索用户ID为100的Dial
,并且我们将搜索限制为20条记录,我们仍然可以得到查询到的总数:
-- 同时返回20 dials 和 dials 总量
SELECT id, name, COUNT(*) OVER()
FROM dials
WHERE user_id = 100
ORDER BY id
LIMIT 20
我们可以遍历得到结果集,并获得数据的总数,如下所示:
var dials []*Dial
// n 保存 总数
var n int
// rows 为 GO SQL查询返回的结果集
for rows.Next() {
var dial Dial
if rows.Scan(&dial.ID, &dial.UserID, &n); err != nil {
return err
}
dials = append(dials, &dial)
}
// 查询集每一行都会返回n,但是值都是一样的。
排序结果集
对于排序,不能允许用户按数据库中的任何列排序。因为大多数列不会被索引,所以查询会很慢。相反,我建议将一组固定的值映射到数据库中的列。例如,“name_asc”可以映射到 ORDER BY name ASC
子句。
具体例子可见 WTF Dial中搜索会员后按修改时间排序
// sqlite/dial_membership.go#L164-L172
var sortBy string
// filter 为上文的过滤结构体
switch filter.SortBy {
case "updated_at_desc":
sortBy = "dm.updated_at DESC"
default:
sortBy = `dm.ID ASC`
}
在这个代码片段中,我们检查 filter.SortBy 字段是否设置为预定义的排序顺序(“updated_at_desc”)。如果是,我们将其转换为一个SQL代码片段。否则,我们使用默认排序情况。可以保证只使用我们定义好的排序规则,用户不能自定义其他列上的排序,保证查找数据的高效。
新建Dial
To create a new user in our application, we have the following function:
为了在我们的应用程序中创建一个Dial
,我们使用以下函数:
CreateDial(ctx context.Context, dial *Dial) error
这里我们传入我们想要创建的Dial
对象。我们需要将新的拨号ID传回给调用者,以便更新主键dial.ID
和由服务实现生成的任何其他字段(例如创建日期)。
如果您不想更新原始的Dial对象,也可以从函数返回一个单独的Dial对象。但是,我发现这种方法在实践中比较麻烦。
译者注:这里的含义是传入 CreateDial 的 Dial
对象的 ID
和 UpdatedAt
和 CreatedAt
等都为空。插入SQL执行后,获得对应的属性赋值给传入的Dial
原对象。在业务层就能直接使用该Dial
,而不用新返回一个Dial
了。
以事务的方式构建对象图
因为我们将事务边界限制为函数调用(一个函数调用为一个事务),所以应该允许创建适当的嵌套对象。例如,我们可以接受附加到将在同一个事务中创建的Dial
上的DialMembership
对象列表。
svc.CreateDial(ctx, &wtf.Dial{
UserID: 100,
Name: "My Dial",
Memberships: []*wtf.DialMembership{
{
User: &wtf.User{Email:"susy@que.com"},
Value: 50,
},
{
User: &wtf.User{Email:"john@doe.com"},
Value: 50,
},
},
})
// 在一个函数调用中创建包含多个 members 的一个 dial
// Creating a dial and with a multiple members in one call
更新Dial
对于更新已存在的用户,我们有以下函数:
UpdateDial(ctx context.Context, id int, upd DialUpdate) (*Dial, error)
这个函数使用upd
中设置的字段值更新一个给定ID
的Dial
。并返回新更新的Dial
。我们的更新类型为DialUpdate
,其中包含我们允许更新的字段(一般来说,只有部分字段允许更新:
// DialUpdate represents a set of fields to update on a dial.
type DialUpdate struct {
Name *string `json:"name"`
}
注意,Name字段是指针类型表明它是可选的。如果它没有被设置,那么它就不会被更新。我们的DialUpdate
类型很简单,但是如果我们希望允许用户将Dial
重新分配给其他人,我们可以想象添加一个UserID
字段。这使得我们可以避免向我们的服务添加新接口ReassignDial()
。
返回错误的表盘
与许多Go函数不同,UpdateDial()
总是返回一个Dial
对象,即使发生了错误。这是很有用的,因为用户通常希望看到如果出现验证错误,他们试图更新Dial
的状态。对于基于web的应用程序来说每个HTTP请求都是无状态的,返回Dial
这一点尤其重要。
批量更新
id
字段被有意地从DialUpdate
类型中分离出来,这样我们也可以允许批量更新。例如,我们可以构建一个名为UpdateDials()
的函数:
UpdateDials(ctx context.Context, ids []int, upd DialUpdate) ([]*Dial, error)
通过将函数更改为接受id
列表,我们可以将其应用于所有id。同时,我们需要返回一个更新后的Dial
列表。
删除 Dial
老实说,关于删除的事没什么好说的。我们有一个简单的按主键删除的函数:
DeleteDial(ctx context.Context, id int) error
我们可以通过提供一个 id
切片来将其展开为一个批量删除:
DeleteDials(ctx context.Context, id []int) error
请确保执行授权限制,以确保用户不能删除其他用户的Dial
。
结论
优化CRUD应用程序开发至关重要,因为它占据了大多数应用程序代码的大部分。我们已经看了一个构建Go CRUD函数的基本框架,它在灵活性和简单性之间取得了平衡。您需要调整这个框架,因为每个应用程序都有自己独特的需求,但希望它从一个坚实的基础开始。
如果您有WTF Dial
问题,评论,或建议,请访问WTF Dial GitHub Discussion board。
如果您对本文有问题,评论,或建议,请访问GitHub Discussion board,这是一个Github讨论版,使用它可以进行更方便的交流。