[翻译]Common CRUD Design in Go

原文由 Ben Johnson 发表在 gobeyond,推荐观看原文文章链接

增删改查(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)

*Dialerror 同时返回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 对象中的 OffsetLimit 字段可用于返回结果的子集,类似于 SQL 中的 OffsetLimit 子句。通常用作分页。

即使我们限制了返回的 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 对象的 IDUpdatedAtCreatedAt 等都为空。插入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中设置的字段值更新一个给定IDDial。并返回新更新的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讨论版,使用它可以进行更方便的交流。

not found!