ent 是 Facebook 开源的一款 Go 语言实体框架,是一款简单而强大的用于建模和查询数据的 ORM 框架。

预备知识

图是用来对对象之间的成对关系建模的数学结构,由"节点"或"顶点"(Vertex)以及连接这些顶点的"边"(Edge)组成。

离散数学中,(graph)是用于表示物体与物体之间存在某种关系的结构。数学抽象后的“物体”称作节点顶点(vertex, node, point),节点间的相关关系则称作 (edge)。

图的应用非常广泛,可在物理、生物、社会和信息系统中建模许多类型的关系和过程,许多实际问题可以用图来表示。

例如社交网络中的好友关系、计算机网络连接关系、地图道路等等,图的种类多种多样,根据不同的业务需求,选择不同的图。

ent 介绍

ent 是 Facebook 开源的一款 Go 语言实体框架,是一款简单而强大的用于建模和查询数据的 ORM 框架。

它遵循以下原则,可轻松构建和维护具有大型数据模型的应用程序:

  • 将数据库 schema 建模为图形结构
  • 像写 Go 代码一样定义 schema
  • 基于代码生成的静态类型
  • 便于编写数据库查询和图遍历
  • 使用 Go 模板实现扩展和定制

使用 ent 的过程大致分为以下几步。

  1. 先定义好 schema
  2. 根据 schema 生成代码
  3. 使用生成的 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 中声明与另一个实体的边(关系)。

正向关联

让我们创建两个额外的实体,分别名为 CarGroup,并添加一些字段。使用下面的 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_]+$")),
    }
}

让我们定义第一个关系。UserCar的边定义了一个用户可以拥有 1 辆或多辆汽车,但一辆汽车只有一个车主(一对多关系)。

er-user-cars

让我们将 “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函数定义了另一种名为 “反向边缘 ”的边缘。

er-cars-owner

上图中创建的新边是半透明的,以强调我们不会在数据库中创建另一条边。这只是对真实边缘(关系)的反向引用。

让我们在 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(多对多)关系。

er-group-users

每个 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;

遍历图

首先,我们需要按下图生成一些数据(节点和边,或者换句话说,实体和关系)。

re-graph

实际创建数据的代码:

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
}

执行上面的代码后,会生成准备好的数据。我们就可以对它执行一些查询:

  1. 获取名为“ 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
    }
    
  2. 根据 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
    }
    
  3. 获取所有有用户的组(查询时使用查找谓词):

    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"

阅读版本化迁移文档了解更多信息。


扫码关注微信公众号