作者提供的图片 | 使用 Go 的吉祥物
简介
Go 泛型是一项期待已久的功能,从 Go 1.18 开始可用。本文将展示如何使用它们创建一个通用存储库,用于存储各种类型的数据。下面是我们将要做的事情的概述:
- 通过测试定义我们希望消耗存储库行为的方式
- 定义一个通用的存储库接口
- 创建一个通用的存储库接口实现
- 为其中一个模型创建一个扩展存储库接口
对于这个示例,我们将有一个内存存储库实现,它将数据存储在映射中。我们将有两个模型,即 Driver
和 Vehicle
,我们将它们存储在存储库中。我们希望能够使用相同的实现,并对两个模型都有访问相同的核心 CRUD 方法。下面是我们将用来定义存储库行为的测试:
func TestInMemRepo_Create(t *testing.T) {
dr := newInMemRepo[Driver](make(map[int]Driver))
id, err := dr.Create(newDriver("John"))
if err != nil {
t.Errorf("error: %v", err)
}
d, err := dr.FindByID(id)
if err != nil {
t.Errorf("error: %v", err)
}
if d.Name != "John" {
t.Errorf("name is not John")
}
}
func TestInMemRepo_FindAll(t *testing.T) {
dr := newInMemRepo[Driver](make(map[int]Driver))
_, _ = dr.Create(newDriver("John"))
_, _ = dr.Create(newDriver("Jane"))
d, err := dr.FindAll()
if err != nil {
t.Errorf("error: %v", err)
}
if len(d) != 2 {
t.Errorf("length is not 2")
}
}
func TestInMemRepo_Update(t *testing.T) {
dr := newInMemRepo[Driver](make(map[int]Driver))
id, _ := dr.Create(newDriver("John"))
driver := newDriver("Jane")
driver.SetID(id)
err := dr.Update(driver)
if err != nil {
t.Errorf("error: %v", err)
}
d, _ := dr.FindByID(id)
if d.Name != "Jane" {
t.Errorf("name is not Jane")
}
}
func TestInMemRepo_DeleteByID(t *testing.T) {
dr := newInMemRepo[Driver](make(map[int]Driver))
id, _ := dr.Create(newDriver("John"))
err := dr.DeleteByID(id)
if err != nil {
t.Errorf("error: %v", err)
}
_, err = dr.FindByID(id)
if err.Error() != "not found" {
t.Errorf("error is nil but should be not found")
}
}
func TestInMemRepo_DeleteByID_NotFound(t *testing.T) {
dr := newInMemRepo[Driver](make(map[int]Driver))
err := dr.DeleteByID(1)
if err.Error() != "not found" {
t.Errorf("error is nil but should be not found")
}
}
这些测试不是详尽无遗的,但它们将为我们提供一个很好的起点。如果你尝试运行此代码,它会失败,因为我们没有定义我们调用的任何方法或模型。
具有泛型的存储库接口
让我们定义 Repository(Repo)接口以及我们的模型的一些限制:
type Repo[T Model] interface {
Create(entity T) (int, error)
Update(entity T) error
FindByID(id int) (*T, error)
FindAll() ([]*T, error)
DeleteByID(id int) error
}
type Model interface {
GetID() int
SetID(id int)
SetCreatedAt(createdAt string)
SetUpdatedAt(updatedAt string)
}
type Base struct {
ID int `json:"id"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
func (b *Base) GetID() int {
return b.ID
}
func (b *Base) SetID(id int) {
b.ID = id
}
func (b *Base) SetCreatedAt(createdAt string) {
b.CreatedAt = createdAt
}
func (b *Base) SetUpdatedAt(updatedAt string) {
b.UpdatedAt = updatedAt
}
Repo
接口定义了我们希望在存储库上公开的行为。我们的通用表示法 Repo[T Model]
意味着我们可以使用任何实现 Model
接口的类型。我们还定义了一个 Base
类型,它将嵌入在我们的模型中。通过嵌入 Base
类型,我们可以使用在其上定义的方法。这是 Go 中避免代码重复的常见模式。
正如你所看到的,我们的存储库不知道我们将使用哪些模型。我们可以使用更严格的 Model
接口,将其特定约束为 Vehicle
和 Driver
模型,如 type Model interface { Vehilce | Driver }
。但是,这将使我们的存储库不够灵活,并且如果我们想添加新模型,则必须更改它。我并不是说你应该总是使用最通用的方法,但我认为在这种情况下是合适的。
内存存储库实现
好的,这一切都很好,但我们的测试仍然失败。让我们实现 InMemRepo
类型,以满足 Repo
接口:
type inMemRepo[T Model] struct {
data map[int]T
mu *sync.Mutex
}
func newInMemRepo[T Model](data map[int]T) *inMemRepo[T] {
return &inMemRepo[T]{data, &sync.Mutex{}}
}
func (i *inMemRepo[T]) Create(t T) (int, error) {
i.mu.Lock()
defer i.mu.Unlock()
id := randomID()
t.SetID(id)
t.SetCreatedAt(time.Now().Format(time.RFC3339))
t.SetUpdatedAt(time.Now().Format(time.RFC3339))
i.data[id] = t
return id, nil
}
func (i *inMemRepo[T]) FindByID(id int) (*T, error) {
if t, ok := i.data[id]; ok {
return &t, nil
}
return nil, fmt.Errorf("not found")
}
func (i *inMemRepo[T]) Update(t T) error {
i.mu.Lock()
defer i.mu.Unlock()
t.SetUpdatedAt(time.Now().Format(time.RFC3339))
i.data[t.GetID()] = t
return nil
}
func (i *inMemRepo[T]) DeleteByID(id int) error {
i.mu.Lock()
defer i.mu.Unlock()
if _, ok := i.data[id]; !ok {
return fmt.Errorf("not found")
}
delete(i.data, id)
return nil
}
func (i *inMemRepo[T]) FindAll() ([]*T, error) {
var result []*T
for _, v := range i.data {
result = append(result, &v)
}
return result, nil
}
func randomID() int {
seed := time.Now().UnixNano()
rand.Seed(seed)
min := 1_000_0000
max := 9_999_9999
return rand.Intn(max-min) + min
}
这是一个非常简单的实现。我们使用映射来存储数据,并实现在 Repo
接口上定义的方法。我们还定义了一个 newInMemRepo
构造函数,它将返回一个新的存储库实例。如果你查看 Create
和 Update
方法,你将注意到我们正在使用 Base
类型方法来设置 ID
和日期字段。这使得模型不知道这样的存储库特定逻辑。
让我们还定义一些我们将在测试中使用的模型:
type Driver struct {
*Base
Name string `json:"name"`
}
func newDriver(name string) Driver {
return Driver{Name: name, Base: &Base{}}
}
type Vehicle struct {
*Base
Make string `json:"make"`
}
func newVehicle(make string) Vehicle {
return Vehicle{Make: make, Base: &Base{}}
}
这些是非常简单的模型,但它们足以测试我们的存储库的行为。
如果现在运行测试,一切都应该通过,并且我们应该有完整的语句覆盖率:
go test -cover ./... ok deni1688/generic-repo 0.189s coverage: 100.0% of statements
Vehicle 的扩展存储库接口
好的,现在我们有了一个通用存储库,可以用于存储任何模型。但是,我们应该为特定模型添加一些存储库特定的方法,这些方法不是核心 CRUD 操作的一部分。例如,我们可以按其制造商查找车辆。这在其他 ORM(如 Hibernate 或 Entity Framework)中非常常见。让我们看看如何使用 Go 泛型来实现这一点,但首先,我们编写测试:
func TestInMemVehicleRepo_FindByMake(t *testing.T) {
vr := newInMemVehicleRepo(make(map[int]Vehicle))
_, _ = vr.Create(newVehicle("Ford"))
id, _ := vr.Create(newVehicle("Volvo"))
vehicles, err := vr.FindByMake("Volvo")
if err != nil {
t.Errorf("error: %v", err)
}
if len(vehicles) != 1 {
t.Errorf("length is not 1")
}
if vehicles[0].GetID() != id {
t.Errorf("id is not %d", id)
}
}
正如你所看到的,我们的构造函数略有改变。我们现在有一个 newInMemVehicleRepo
构造函数,它将返回一个 VehicleRepo
接口实现。这个接口将由 InMemRepo
类型满足,但它还将公开 FindByMake
:
type VehicleRepo interface {
Repo[Vehicle]
FindByMake(model string) ([]*Vehicle, error)
}
type inMemVehicleRepo struct {
*inMemRepo[Vehicle]
}
func newInMemVehicleRepo(data map[int]Vehicle) *inMemVehicleRepo {
return &inMemVehicleRepo{inMemRepo: newInMemRepo[Vehicle](data)}
}
func (i *inMemVehicleRepo) FindByMake(make string) ([]*Vehicle, error) {
var result []*Vehicle
for _, v := range i.data {
if v.Make == make {
result = append(result, &v)
}
}
return result, nil
}
VehicleRepo
接口是 Repo
接口的简单扩展。我们定义了一个新方法 FindByMake
,它将返回 Vehicle
模型的一个切片。newInMemVehicleRepo
构造函数将返回 inMemVehicleRepo
类型的一个实例,它嵌入了 inMemRepo[Vehicle]
类型。然后,我们使用 inMemVehicleRepo
作为指针接收器来实现新的 FindByMake
方法。这就是如何满足 Repo
接口。为简单起见,我将所有内容都保留在同一个文件中,但是根据你的要求,你可以轻松地将其拆分为多个文件。
与之前一样,如果你现在运行测试,一切都应该通过,并且我们应该再次具有完整的语句覆盖率。
结论
个人而言,我没有很多泛型用例,但这是我想到的第一个,因为我有一个存储库的实现,它使用 interface{}
类型来获得类似的行为。那是有些冗长的,需要更多的样板代码,但它有效。
我认为新的方法更加清晰,更易于使用和维护。不是说没有任何挑战。例如,在模型上添加指针接收器方法不是一个选项,因为你不能将指针传递给通用类型。Base 是这个问题的最简单解决方案,但它有一个小小的缺点,即模型必须在其构造函数中创建一个空的 Base 类型实例。
但是,这特别是因为我在测试中使用了这个内存数据存储库。在实际的情况下,数据库将处理 ID 和日期字段,你的模型将不需要知道它们。
我希望你喜欢本文,并觉得它有用。
参考文献
Want to Connect?
If you have any questions or comments, please feel free to leave them below
or connect with me on Twitter @BitsByDenis.
本文最初发布于 https://blog.codebydenis.de,于 2023 年 4 月 11 日发布。
译自:https://betterprogramming.pub/go-generic-repo-fd31b0300e0e
评论(0)