ORM 框架 ent 介绍
承蒙大家厚爱,我的《Go语言之路》的纸质版图书已经上架京东,有需要的朋友请点击 此链接 购买。
ent 是 Facebook 开源的一款 Go 语言实体框架,是一款简单而强大的用于建模和查询数据的 ORM 框架。
预备知识
图是用来对对象之间的成对关系建模的数学结构,由"节点"或"顶点"(Vertex)以及连接这些顶点的"边"(Edge)组成。
在离散数学中,图(graph)是用于表示物体与物体之间存在某种关系的结构。数学抽象后的“物体”称作节点或顶点(vertex, node, point),节点间的相关关系则称作 边(edge)。
图的应用非常广泛,可在物理、生物、社会和信息系统中建模许多类型的关系和过程,许多实际问题可以用图来表示。
例如社交网络中的好友关系、计算机网络连接关系、地图道路等等,图的种类多种多样,根据不同的业务需求,选择不同的图。
ent 介绍
ent 是 Facebook 开源的一款 Go 语言实体框架,是一款简单而强大的用于建模和查询数据的 ORM 框架。
它遵循以下原则,可轻松构建和维护具有大型数据模型的应用程序:
- 将数据库 schema 建模为图形结构
- 像写 Go 代码一样定义 schema
- 基于代码生成的静态类型
- 便于编写数据库查询和图遍历
- 使用 Go 模板实现扩展和定制
使用 ent 的过程大致分为以下几步。
- 先定义好 schema
- 根据 schema 生成代码
- 使用生成的 CRUD 代码编写业务代码
安装
执行以下命令安装 ent cli 工具。
go install entgo.io/ent/cmd/ent@latest
接下来,我们将快速开始一个简单示例。
简明示例
创建项目
创建一个 entdemo 项目,执行以下命令完成项目初始化。
go mod init entdemo
创建 schema
在项目的根目录下执行以下命令,创建一个 User
schema。
ent new User
该命令会在项目目录下创建一个 ent
目录,其中包含 schema
目录和一个 generate.go
文件。
.
├── ent
│ ├── generate.go
│ └── schema
│ └── user.go
└── go.mod
entdemo/ent/schema/user.go
文件中的内容便是定义的 schema。
package schema
import "entgo.io/ent"
// User holds the schema definition for the User entity.
type User struct {
ent.Schema
}
// Fields of the User.
func (User) Fields() []ent.Field {
return nil
}
// Edges of the User.
func (User) Edges() []ent.Edge {
return nil
}
其中,
User
结构体保存实体的 schema 定义。Fields
方法返回User
中都有哪些字段。Edges
方法返回User
与其他实体的关系。
修改 entdemo/ent/schema/user.go
文件,向 User
的 schema 添加2个字段:
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
)
...
// Fields of the User.
func (User) Fields() []ent.Field {
return []ent.Field{
field.Int("age").
Positive(),
field.String("name").
Default("unknown"),
}
}
生成代码
在项目根目录执行以下命令,根据上述 schema 生成代码。
go generate ./ent
将会生成以下文件。
.
├── ent
│ ├── client.go
│ ├── ent.go
│ ├── enttest
│ │ └── enttest.go
│ ├── generate.go
│ ├── hook
│ │ └── hook.go
│ ├── migrate
│ │ ├── migrate.go
│ │ └── schema.go
│ ├── mutation.go
│ ├── predicate
│ │ └── predicate.go
│ ├── runtime
│ │ └── runtime.go
│ ├── runtime.go
│ ├── schema
│ │ └── user.go
│ ├── tx.go
│ ├── user
│ │ ├── user.go
│ │ └── where.go
│ ├── user.go
│ ├── user_create.go
│ ├── user_delete.go
│ ├── user_query.go
│ └── user_update.go
├── go.mod
└── go.sum
CRUD
使用生成的代码,实现实体的CRUD操作。
ent 支持 SQLite、PostgreSQL、MySQL(MariaDB),本文以 MySQL 为例。
首先,创建一个新客户端来运行 schema 迁移并与实体进行交互:
package main
import (
"context"
"log"
"entdemo/ent"
_ "github.com/go-sql-driver/mysql"
)
func main() {
client, err := ent.Open("mysql", "<user>:<pass>@tcp(<host>:<port>)/<database>?parseTime=True")
if err != nil {
log.Fatalf("failed opening connection to mysql: %v", err)
}
defer client.Close()
// Run the auto migration tool.
if err := client.Schema.Create(context.Background()); err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
}
运行 schema 迁移后,我们就可以创建用户了。在本例中,我们将此函数命名为 CreateUser :
// CreateUser 创建 user
func CreateUser(ctx context.Context, client *ent.Client) (*ent.User, error) {
u, err := client.User.
Create().
SetAge(30).
SetName("q1mi").
Save(ctx)
if err != nil {
return nil, fmt.Errorf("failed creating user: %w", err)
}
log.Println("user was created: ", u)
return u, nil
}
ent
为每个实体模式生成一个包,其中包含属性、默认值、验证器和有关存储元素的附加信息(列名、主键等)。
// QueryUser 查询 user
func QueryUser(ctx context.Context, client *ent.Client) (*ent.User, error) {
u, err := client.User.
Query().
Where(user.Name("q1mi")).
// `Only` fails if no user found,
// or more than 1 user returned.
Only(ctx)
if err != nil {
return nil, fmt.Errorf("failed querying user: %w", err)
}
log.Println("user returned: ", u)
return u, nil
}
关联关系
在教程的这一部分,我们要在 schema 中声明与另一个实体的边(关系)。
正向关联
让我们创建两个额外的实体,分别名为 Car
和 Group
,并添加一些字段。使用下面的 ent CLI 命令生成初始 schema :
go run -mod=mod entgo.io/ent/cmd/ent new Car Group
然后,手动添加其余字段:
entdemo/ent/schema/car.go
文件:
// Fields of the Car.
func (Car) Fields() []ent.Field {
return []ent.Field{
field.String("model"),
field.Time("registered_at"),
}
}
entdemo/ent/schema/group.go
文件:
// Fields of the Group.
func (Group) Fields() []ent.Field {
return []ent.Field{
field.String("name").
// Regexp validation for group name.
Match(regexp.MustCompile("[a-zA-Z_]+$")),
}
}
让我们定义第一个关系。User
到Car
的边定义了一个用户可以拥有 1 辆或多辆汽车,但一辆汽车只有一个车主(一对多关系)。
让我们将 “Car” 边添加到 User
schema 中,在entdemo/ent/schema/user.go
文件中添加以下内容,并运行 go generate/ent
生成代码:。
// Edges of the User.
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.To("cars", Car.Type),
}
}
接下来,创建两辆车并将其关联到用户身上。
func CreateCars(ctx context.Context, client *ent.Client) (*ent.User, error) {
// Create a new car with model "Tesla".
tesla, err := client.Car.
Create().
SetModel("Tesla").
SetRegisteredAt(time.Now()).
Save(ctx)
if err != nil {
return nil, fmt.Errorf("failed creating car: %w", err)
}
log.Println("car was created: ", tesla)
// Create a new car with model "Ford".
ford, err := client.Car.
Create().
SetModel("Ford").
SetRegisteredAt(time.Now()).
Save(ctx)
if err != nil {
return nil, fmt.Errorf("failed creating car: %w", err)
}
log.Println("car was created: ", ford)
// 创建一个User,拥有上面的两辆车。
alex, err := client.User.
Create().
SetAge(30).
SetName("alex").
AddCars(tesla, ford).
Save(ctx)
if err != nil {
return nil, fmt.Errorf("failed creating user: %w", err)
}
log.Println("user was created: ", alex)
return alex, nil
}
想要查询用户的汽车,可以按下面的方式查询。
func QueryCars(ctx context.Context, user *ent.User) error {
cars, err := user.QueryCars().All(ctx)
if err != nil {
return fmt.Errorf("failed querying user cars: %w", err)
}
log.Println("returned cars:", cars)
// What about filtering specific cars.
ford, err := user.QueryCars().
Where(car.Model("Ford")).
Only(ctx)
if err != nil {
return fmt.Errorf("failed querying user cars: %w", err)
}
log.Println(ford)
return nil
}
反向关联
假设我们有一个 “汽车”(Car
)对象,并想获得它的所有者,即这辆车的用户。为此,我们使用 edge.From
函数定义了另一种名为 “反向边缘 ”的边缘。
上图中创建的新边是半透明的,以强调我们不会在数据库中创建另一条边。这只是对真实边缘(关系)的反向引用。
让我们在 Car
的 schema 中添加一个名为 owner
的反向边,将其引用到 User
schema 中的 Car
边,然后运行 go generate ./ent
。
// Edges of the Car.
func (Car) Edges() []ent.Edge {
return []ent.Edge{
edge.From("owner", User.Type).
// 使用 Ref 方法显式引用 User 表中的 cars
Ref("cars").
// 设置 Unique,确保一个 Car 只会有一个 owner
Unique(),
}
}
我们将通过查询反向边缘来继续上面的用户/汽车示例。
func QueryCarUsers(ctx context.Context, user *ent.User) error {
cars, err := user.QueryCars().All(ctx)
if err != nil {
return fmt.Errorf("failed querying user cars: %w", err)
}
// Query the inverse edge.
for _, c := range cars {
owner, err := c.QueryOwner().Only(ctx)
if err != nil {
return fmt.Errorf("failed querying car %q owner: %w", c.Model, err)
}
log.Printf("car %q owner: %q\n", c.Model, owner.Name)
}
return nil
}
M2M 关联
我们将继续我们的示例,在用户和组之间创建M2M(多对多)关系。
每个 Group 实体可以有多个 User,并且一个 User 可以连接到多个 Group; 这是一个简单的“多对多”关系。在上图中,Group 模式是用户边(关系)的所有者,User 实体对这个名为 Group 的关系具有反向引用/反向边。让我们在 schema 中定义这种关系:
entdemo/ent/schema/group.go
文件中增加以下内容。
// Edges of the Group.
func (Group) Edges() []ent.Edge {
return []ent.Edge{
edge.To("users", User.Type),
}
}
entdemo/ent/schema/user.go
文件中添加一个 groups
。
// Edges of the User.
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.To("cars", Car.Type),
// 创建一个名为“Group”的反向边,类型为`Group`
// 显式使用`Ref`方法将其引用到 Group schema 中定义的 “users” edge
edge.From("groups", Group.Type).
Ref("users"),
}
修改完上述内容后,执行以下命令生成代码。
go generate ./ent
查看生成的 schema
要查看 Ent 为数据库生成的 SQL 模式,请安装 Atlas 并运行以下命令:
安装 Atlas
在你的终端执行以下命令。
Mac:
brew install ariga/tap/atlas
Windows:
点击下载链接 下载最新版本,然后将其添加到环境变量中。
查看 ent 的 schema
输入以下命令。
atlas schema inspect \
-u "ent://ent/schema" \
--dev-url "mysql://<user>:<pass>@tcp(<host>:<port>)/<database>?parseTime=True" \
--format '{{ sql . " " }}'
SQL 输出
-- Create "users" table
CREATE TABLE `users` (
`id` bigint NOT NULL AUTO_INCREMENT,
`age` bigint NOT NULL,
`name` varchar(255) NOT NULL DEFAULT "unknown",
PRIMARY KEY (`id`)
) CHARSET utf8mb4 COLLATE utf8mb4_bin;
-- Create "cars" table
CREATE TABLE `cars` (
`id` bigint NOT NULL AUTO_INCREMENT,
`model` varchar(255) NOT NULL,
`registered_at` timestamp NOT NULL,
`user_cars` bigint NULL,
PRIMARY KEY (`id`),
INDEX `cars_users_cars` (`user_cars`),
CONSTRAINT `cars_users_cars` FOREIGN KEY (`user_cars`) REFERENCES `users` (`id`) ON UPDATE NO ACTION ON DELETE SET NULL
) CHARSET utf8mb4 COLLATE utf8mb4_bin;
-- Create "groups" table
CREATE TABLE `groups` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) CHARSET utf8mb4 COLLATE utf8mb4_bin;
-- Create "group_users" table
CREATE TABLE `group_users` (
`group_id` bigint NOT NULL,
`user_id` bigint NOT NULL,
PRIMARY KEY (`group_id`, `user_id`),
INDEX `group_users_user_id` (`user_id`),
CONSTRAINT `group_users_group_id` FOREIGN KEY (`group_id`) REFERENCES `groups` (`id`) ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT `group_users_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON UPDATE NO ACTION ON DELETE CASCADE
) CHARSET utf8mb4 COLLATE utf8mb4_bin;
遍历图
首先,我们需要按下图生成一些数据(节点和边,或者换句话说,实体和关系)。
实际创建数据的代码:
func CreateGraph(ctx context.Context, client *ent.Client) error {
// First, create the users.
a8m, err := client.User.
Create().
SetAge(30).
SetName("Ariel").
Save(ctx)
if err != nil {
return err
}
neta, err := client.User.
Create().
SetAge(28).
SetName("Neta").
Save(ctx)
if err != nil {
return err
}
// Then, create the cars, and attach them to the users created above.
err = client.Car.
Create().
SetModel("Tesla").
SetRegisteredAt(time.Now()).
// Attach this car to Ariel.
SetOwner(a8m).
Exec(ctx)
if err != nil {
return err
}
err = client.Car.
Create().
SetModel("Mazda").
SetRegisteredAt(time.Now()).
// Attach this car to Ariel.
SetOwner(a8m).
Exec(ctx)
if err != nil {
return err
}
err = client.Car.
Create().
SetModel("Ford").
SetRegisteredAt(time.Now()).
// Attach this car to Neta.
SetOwner(neta).
Exec(ctx)
if err != nil {
return err
}
// Create the groups, and add their users in the creation.
err = client.Group.
Create().
SetName("GitLab").
AddUsers(neta, a8m).
Exec(ctx)
if err != nil {
return err
}
err = client.Group.
Create().
SetName("GitHub").
AddUsers(a8m).
Exec(ctx)
if err != nil {
return err
}
log.Println("The graph was created successfully")
return nil
}
执行上面的代码后,会生成准备好的数据。我们就可以对它执行一些查询:
获取名为“ GitHub”的组中所有用户的汽车:
func QueryGithub(ctx context.Context, client *ent.Client) error { cars, err := client.Group. Query(). Where(group.Name("GitHub")). // (Group(Name=GitHub),) QueryUsers(). // (User(Name=Ariel, Age=30),) QueryCars(). // (Car(Model=Tesla, RegisteredAt=<Time>), Car(Model=Mazda, RegisteredAt=<Time>),) All(ctx) if err != nil { return fmt.Errorf("failed getting cars: %w", err) } log.Println("cars returned:", cars) // Output: (Car(Model=Tesla, RegisteredAt=<Time>), Car(Model=Mazda, RegisteredAt=<Time>),) return nil }
根据
Ariel
查询符合要求的汽车。func QueryArielCars(ctx context.Context, client *ent.Client) error { // Get "Ariel" from previous steps. a8m := client.User. Query(). Where( user.HasCars(), user.Name("Ariel"), ). OnlyX(ctx) cars, err := a8m. // 查询 a8m 关联的 groups: QueryGroups(). // (Group(Name=GitHub), Group(Name=GitLab),) QueryUsers(). // (User(Name=Ariel, Age=30), User(Name=Neta, Age=28),) QueryCars(). // Where( // car.Not( // 查询 Neta 和 Ariel 的汽车, 但是过滤掉 car.Model("Mazda"), // model 是 "Mazda" 的 ), // ). // All(ctx) if err != nil { return fmt.Errorf("failed getting cars: %w", err) } log.Println("cars returned:", cars) // Output: (Car(Model=Tesla, RegisteredAt=<Time>), Car(Model=Ford, RegisteredAt=<Time>),) return nil }
获取所有有用户的组(查询时使用查找谓词):
func QueryGroupWithUsers(ctx context.Context, client *ent.Client) error { groups, err := client.Group. Query(). Where(group.HasUsers()). All(ctx) if err != nil { return fmt.Errorf("failed getting groups: %w", err) } log.Println("groups returned:", groups) // Output: (Group(Name=GitHub), Group(Name=GitLab),) return nil }
模式迁移
Ent 提供了两种运行模式迁移的方法:自动迁移和版本化迁移。
以下是每种方法的简要概述:
自动迁移
通过自动迁移,我们可以使用以下 API 来保持数据库模式与生成的 SQL 模式 ent/migrate/schema.go
中定义的模式对象保持一致:
if err := client.Schema.Create(ctx); err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
这种方法主要用于原型设计、开发或测试。因此,建议在关键任务的生产环境中使用版本化迁移方法。通过使用版本化的迁移,用户事先就知道要对数据库应用哪些更改,并且可以根据需要轻松地调优这些更改。
可通过阅读自动迁移文档了解更多。
版本化迁移
与自动迁移不同,版本迁移方法使用 Atlas 自动生成一组迁移文件,其中包含迁移数据库所需的 SQL 语句。这些文件可以编辑以满足特定需求,并使用现有的迁移工具(如Atlas、golang migrate、Flyway和Liquibase)进行应用。这种方法的 API 包括两个主要步骤。
生成迁移
atlas migrate diff migration_name \
--dir "file://ent/migrate/migrations" \
--to "ent://ent/schema" \
--dev-url "docker://mysql/8/ent"
提交迁移
atlas migrate apply \
--dir "file://ent/migrate/migrations" \
--url "mysql://root:pass@localhost:3306/example"
阅读版本化迁移文档了解更多信息。