跳到主要内容

· 阅读需 19 分钟

在这篇博客中,我们将探讨如何使用 EntAtlaspgvector 构建一个 RAG(Retrieval Augmented Generation)系统。

RAG 是一种将检索步骤加入到生成模型的技术。它并不单靠模型内部知识,而是从外部来源检索相关文档或数据,并利用这些信息生成更精准、更具上下文感知的回复。此方法在构建问答系统、聊天机器人以及任何需要实时或领域特定知识的场景中尤为有用。

设置 Ent 模型

首先,让我们通过初始化 Go 模块来开始教程:

go mod init github.com/rotemtam/entrag # 你可以将模块路径替换为自己的

在本项目中,我们将使用 Ent,一个 Go 的实体框架,来定义我们的数据库模式。数据库将存储我们想要检索的文档(按固定大小分块)以及每个块的向量表示。通过运行下面的命令来初始化 Ent 项目:

go run -mod=mod entgo.io/ent/cmd/ent new Embedding Chunk

此命令为我们的数据模型创建占位符。项目结构应该如下所示:

├── ent
│ ├── generate.go
│ └── schema
│ ├── chunk.go
│ └── embedding.go
├── go.mod
└── go.sum

接下来,让我们定义 Chunk 模型的模式。打开 ent/schema/chunk.go 文件并按下面的方式定义:

ent/schema/chunk.go
package schema

import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
)

// Chunk 代表 Chunk 实体的模式定义。
type Chunk struct {
ent.Schema
}

// Chunk 的字段。
func (Chunk) Fields() []ent.Field {
return []ent.Field{
field.String("path"),
field.Int("nchunk"),
field.Text("data"),
}
}

// Chunk 的边。
func (Chunk) Edges() []ent.Edge {
return []ent.Edge{
edge.To("embedding", Embedding.Type).StorageKey(edge.Column("chunk_id")).Unique(),
}
}

此模式定义了一个包含 pathnchunkdata 三个字段的 Chunk 实体。path 字段存储文档路径,nchunk 存储块编号,data 存储分块文本数据。我们还为 Embedding 实体定义了一个关联边,用于存储块的向量表示。

在继续之前,请安装 pgvector 包。pgvector 是 PostgreSQL 的一个扩展,提供向量操作和相似度搜索支持。我们需要它来存储和检索块的向量表示。

go get github.com/pgvector/pgvector-go

接下来,定义 Embedding 模型的模式。打开 ent/schema/embedding.go 并按如下方式定义:

ent/schema/embedding.go
package schema

import (
"entgo.io/ent"
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
"github.com/pgvector/pgvector-go"
)

// Embedding 代表 Embedding 实体的模式定义。
type Embedding struct {
ent.Schema
}

// Embedding 的字段。
func (Embedding) Fields() []ent.Field {
return []ent.Field{
field.Other("embedding", pgvector.Vector{}).
SchemaType(map[string]string{
dialect.Postgres: "vector(1536)",
}),
}
}

// Embedding 的边。
func (Embedding) Edges() []ent.Edge {
return []ent.Edge{
edge.From("chunk", Chunk.Type).Ref("embedding").Unique().Required(),
}
}

func (Embedding) Indexes() []ent.Index {
return []ent.Index{
index.Fields("embedding").
Annotations(
entsql.IndexType("hnsw"),
entsql.OpClass("vector_l2_ops"),
),
}
}

该模式定义了一个仅包含单个字段 embedding(类型为 pgvector.Vector)的 Embedding 实体。embedding 字段存储块的向量表示。我们同样为 Chunk 实体定义了边,并在 embedding 字段上创建了使用 hnsw 索引类型和 vector_l2_ops 操作类的索引,从而能够高效执行向量相似度搜索。

最后,通过执行以下命令生成 Ent 代码:

go mod tidy
go generate ./...

Ent 将根据模式定义生成所需的代码。

配置数据库

接下来,设置 PostgreSQL 数据库。我们将使用 Docker 在本地运行 PostgreSQL 实例。由于我们需要 pgvector 扩展,我们将使用带有预装扩展的 pgvector/pgvector:pg17 Docker 镜像。

docker run --rm --name postgres -e POSTGRES_PASSWORD=pass -p 5432:5432 -d pgvector/pgvector:pg17

我们将使用 Atlas,一个与 Ent 集成的数据库 schema‑as‑code 工具,用来管理数据库模式。通过以下命令安装 Atlas:

curl -sSfL https://atlasgo.io/install.sh | sh

其他安装选项请参阅 Atlas 安装文档

由于我们需要管理扩展,请确保拥有 Atlas Pro 账户。你可以运行以下命令免费试用:

atlas login
在无迁移工具情况下工作

如果你想跳过 Atlas,可以使用 此文件 中的语句直接将所需模式应用到数据库

现在,创建基础配置 base.pg.hcl,为公共 schema 提供向量扩展:

base.pg.hcl
schema "public" {
}

extension "vector" {
schema = schema.public
}

接下来,创建 Atlas 配置文件,它将组合 base.pg.hcl 与 Ent 模式:

atlas.hcl
data "composite_schema" "schema" {
schema {
url = "file://base.pg.hcl"
}
schema "public" {
url = "ent://ent/schema"
}
}

env "local" {
url = getenv("DB_URL")
schema {
src = data.composite_schema.schema.url
}
dev = "docker://pgvector/pg17/dev"
}

此配置定义了一个包含 base.pg.hcl 与 Ent 模式的复合 schema,并为本地开发定义了名为 local 的环境,使用 dev 字段指定开发数据库 URL,供 Atlas 归一化 schema 并进行各种计算。

接下来,执行以下命令将 schema 应用到数据库:

export DB_URL='postgresql://postgres:pass@localhost:5432/postgres?sslmode=disable'
atlas schema apply --env local

Atlas 将根据我们的配置加载目标状态,与数据库当前状态进行比较,并生成迁移计划以使数据库达到期望状态:

Planning migration statements (5 in total):

-- create extension "vector":
-> CREATE EXTENSION "vector" WITH SCHEMA "public" VERSION "0.8.0";
-- create "chunks" table:
-> CREATE TABLE "public"."chunks" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"path" character varying NOT NULL,
"nchunk" bigint NOT NULL,
"data" text NOT NULL,
PRIMARY KEY ("id")
);
-- create "embeddings" table:
-> CREATE TABLE "public"."embeddings" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"embedding" public.vector(1536) NOT NULL,
"chunk_id" bigint NOT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "embeddings_chunks_embedding" FOREIGN KEY ("chunk_id") REFERENCES "public"."chunks" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION
);
-- create index "embedding_embedding" to table: "embeddings":
-> CREATE INDEX "embedding_embedding" ON "public"."embeddings" USING hnsw ("embedding" vector_l2_ops);
-- create index "embeddings_chunk_id_key" to table: "embeddings":
-> CREATE UNIQUE INDEX "embeddings_chunk_id_key" ON "public"."embeddings" ("chunk_id");

-------------------------------------------

Analyzing planned statements (5 in total):

-- non-optimal columns alignment:
-- L4: Table "chunks" has 8 redundant bytes of padding per row. To reduce disk space,
the optimal order of the columns is as follows: "id", "nchunk", "path",
"data" https://atlasgo.io/lint/analyzers#PG110
-- ok (370.25µs)

-------------------------
-- 114.306667ms
-- 5 schema changes
-- 1 diagnostic

-------------------------------------------

? Approve or abort the plan:
▸ Approve and apply
Abort

Atlas 在计划变更的同时也会提供诊断信息和优化建议。在这个例子中,它建议重新排序 chunks 表中的列以降低磁盘空间占用。由于本教程并不关注磁盘空间,我们可以直接选择 Approve and apply

最后,我们可以再次执行 atlas schema apply 来验证 schema 是否成功应用。Atlas 将输出:

Schema is synced, no changes to be made

Scaffold CLI

现在数据库 schema 已经就绪,为了让 CLI 应用更方便,使用 alecthomas/kong 库构建一个小型工具,能够加载、索引和查询数据库中的文档。

首先,安装 kong 库:

go get github.com/alecthomas/kong

接着,在 cmd/entrag/main.go 中定义 CLI 应用:

cmd/entrag/main.go
package main

import (
"fmt"
"os"

"github.com/alecthomas/kong"
)

// CLI 表示全局选项和子命令。
type CLI struct {
// DBURL 通过 环境变量 DB_URL 读取。
DBURL string `kong:"env='DB_URL',help='Database URL for the application.'"`
OpenAIKey string `kong:"env='OPENAI_KEY',help='OpenAI API key for the application.'"`

// 子命令
Load *LoadCmd `kong:"cmd,help='Load command that accepts a path.'"`
Index *IndexCmd `kong:"cmd,help='Create embeddings for any chunks that do not have one.'"`
Ask *AskCmd `kong:"cmd,help='Ask a question about the indexed documents'"`
}

func main() {
var cli CLI
app := kong.Parse(&cli,
kong.Name("entrag"),
kong.Description("Ask questions about markdown files."),
kong.UsageOnError(),
)
if err := app.Run(&cli); err != nil {
fmt.Fprintf(os.Stderr, "错误: %s\n", err)
os.Exit(1)
}
}

cmd/entrag/rag.go 中再创建一个文件并添加以下内容:

cmd/entrag/rag.go
package main

type (
// LoadCmd 用于将 Markdown 文件加载到数据库中。
LoadCmd struct {
Path string `help:"path to dir with markdown files" type:"existingdir" required:""`
}
// IndexCmd 用于在数据库中创建嵌入索引。
IndexCmd struct {
}
// AskCmd 是另一个叶子命令。
AskCmd struct {
// Text 是 ask 命令的位置参数。
Text string `kong:"arg,required,help='Text for the ask command.'"`
}
)

验证 scaffold 的 CLI 是否工作,在终端运行:

go run ./cmd/entrag --help

若一切正常,你应该能看到下面的帮助输出:

Usage: entrag <command> [flags]

Ask questions about markdown files.

Flags:
-h, --help Show context-sensitive help.
--dburl=STRING Database URL for the application ($DB_URL).
--open-ai-key=STRING OpenAI API key for the application ($OPENAI_KEY).

Commands:
load --path=STRING [flags]
Load command that accepts a path.

index [flags]
Create embeddings for any chunks that do not have one.

ask <text> [flags]
Ask a question about the indexed documents

Run "entrag <command> --help" for more information on a command.

将文档加载到数据库

接下来,需要一些 Markdown 文件以供加载。创建一个名为 data 的文件夹,并添加若干 Markdown 文件。示例中我们使用 ent/ent 仓库的 docs 目录作为 Markdown 源。

现在实现 LoadCmdRun 方法以将 Markdown 文件加载到数据库。打开 cmd/entrag/rag.go 并添加以下代码:

const (
tokenEncoding = "cl100k_base"
chunkSize = 1000
)

// Run 是当执行“load”命令时调用的方法。
func (cmd *LoadCmd) Run(ctx *CLI) error {
client, err := ctx.entClient()
if err != nil {
return fmt.Errorf("连接 PostgreSQL 失败: %w", err)
}
tokTotal := 0
return filepath.WalkDir(ctx.Load.Path, func(path string, d fs.DirEntry, err error) error {
if filepath.Ext(path) == ".mdx" || filepath.Ext(path) == ".md" {
chunks := breakToChunks(path)
for i, chunk := range chunks {
tokTotal += len(chunk)
client.Chunk.Create().
SetData(chunk).
SetPath(path).
SetNchunk(i).
SaveX(context.Background())
}
}
return nil
})
}

func (c *CLI) entClient() (*ent.Client, error) {
return ent.Open("postgres", c.DBURL)
}

此代码定义了 LoadCmdRun 方法。该方法读取指定路径下的 Markdown 文件,将其拆分为 1000 个 token 的块,并将每块保存到数据库。我们使用 entClient 方法创建一个新的 Ent 客户端,使用 CLI 选项中指定的数据库 URL。

breakToChunks 的实现见 entrag 仓库

随后,运行:

go run ./cmd/entrag load --path=data

命令完成后,数据库中应已加载块。可使用以下命令验证:

docker exec -it postgres psql -U postgres -d postgres -c "SELECT COUNT(*) FROM chunks;"

结果类似:

  count
-------
276
(1 row)

创建 Embedding

现在我们已将文档加载到数据库,需要为每个块创建 Embedding。我们将使用 OpenAI API 生成每个块的 Embedding。首先安装 openai 包:

go get github.com/sashabaranov/go-openai

若你没有 OpenAI API Key,可在 OpenAI 平台 注册并生成一个 API Key。随后将其保存到环境变量 OPENAI_KEY

export OPENAI_KEY=<your OpenAI API key>

然后实现 IndexCmdRun 方法,以为缺失 Embedding 的块生成 Embedding 并保存到数据库。打开 cmd/entrag/rag.go 并添加:

// Run 是当执行“index”命令时调用的方法。
func (cmd *IndexCmd) Run(cli *CLI) error {
client, err := cli.entClient()
if err != nil {
return fmt.Errorf("连接 PostgreSQL 失败: %w", err)
}
ctx := context.Background()
chunks := client.Chunk.Query().
Where(
chunk.Not(
chunk.HasEmbedding(),
),
).
Order(ent.Asc(chunk.FieldID)).
AllX(ctx)
for _, ch := range chunks {
log.Println("为段落创建嵌入", ch.Path, ch.Nchunk)
embedding := getEmbedding(ch.Data)
_, err := client.Embedding.Create().
SetEmbedding(pgvector.NewVector(embedding)).
SetChunk(ch).
Save(ctx)
if err != nil {
return fmt.Errorf("创建嵌入时出错: %v", err)
}
}
return nil
}

// getEmbedding 调用 OpenAI Embedding API 计算给定字符串的嵌入值并返回。
func getEmbedding(data string) []float32 {
client := openai.NewClient(os.Getenv("OPENAI_KEY"))
queryReq := openai.EmbeddingRequest{
Input: []string{data},
Model: openai.AdaEmbeddingV2,
}
queryResponse, err := client.CreateEmbeddings(context.Background(), queryReq)
if err != nil {
log.Fatalf("获取嵌入时出错: %v", err)
}
return queryResponse.Data[0].Embedding
}

此方法查询缺失 Embedding 的块,生成 Embedding 并保存。运行 index 命令即可:

go run ./cmd/entrag index

日志示例:

2025/02/13 13:04:42 为段落创建嵌入 /Users/home/entr/data/md/aggregate.md 0
2025/02/13 13:04:43 为段落创建嵌入 /Users/home/entr/data/md/ci.mdx 0

提问

现在我们已完成文档加载与 Embedding 的创建,下面实现 AskCmd,用于向已索引的文档提问。打开 cmd/entrag/rag.go 并添加:

// Run 是当执行“ask”命令时调用的方法。
func (cmd *AskCmd) Run(ctx *CLI) error {
client, err := ctx.entClient()
if err != nil {
return fmt.Errorf("连接 PostgreSQL 失败: %w", err)
}
question := cmd.Text
emb := getEmbedding(question)
embVec := pgvector.NewVector(emb)
embs := client.Embedding.
Query().
Order(func(s *sql.Selector) {
s.OrderExpr(sql.ExprP("embedding <-> $1", embVec))
}).
WithChunk().
Limit(5).
AllX(context.Background())
b := strings.Builder{}
for _, e := range embs {
chnk := e.Edges.Chunk
b.WriteString(fmt.Sprintf("来自文件: %v\n", chnk.Path))
b.WriteString(chnk.Data)
}
query := fmt.Sprintf(`请使用下列 ent 文档信息回答随后的问题。
信息:
%v

问题:%v`, b.String(), question)
oac := openai.NewClient(ctx.OpenAIKey)
resp, err := oac.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: openai.GPT4o,
Messages: []openai.ChatCompletionMessage{

{
Role: openai.ChatMessageRoleUser,
Content: query,
},
},
},
)
if err != nil {
return fmt.Errorf("创建聊天完成时出错: %v", err)
}
choice := resp.Choices[0]
out, err := glamour.Render(choice.Message.Content, "dark")
fmt.Print(out)
return nil
}

这段代码先将用户问题转换为向量,然后在数据库中查询相似 Embedding,并取前 5 条结果。随后将其文本与问题一起组成提示,调用 OpenAI GPT 生成答案,并使用 glamour 渲染输出。

在运行 ask 命令前,安装 glamour 包:

go get github.com/charmbracelet/glamour

接着执行:

go run ./cmd/entrag ask "tl;dr What is Ent?"

系统的响应类似:

  Ent 是一个面向 Go 语言的开源实体框架(ORM)。它允许开发者在 Go 代码中定义数据模型或图结构。Ent 强调以下原则:schema-as-code、通过代码生成的静态类型与显式 API、简单查询与图遍历、静态类型谓词以及无特定存储的抽象。它支持多种数据库,包括 MySQL、MariaDB、PostgreSQL、SQLite 以及 Gremlin 基础的图数据库,旨在提升 Go 开发的生产力。

进一步示例

go run ./cmd/entrag ask "how to define order field in entgql"
下面说明如何在 `entgql` 中定义排序字段。先给对应字段加上 `entgql.Annotation` 中的 `OrderField` 注解,字段名需为大写并与 GraphQL 中对应枚举值相匹配。步骤如下:
  1. 选择可比较字段:在你的 schema 中挑选你想要排序的字段,可是文本、时间戳、整数、枚举等。

  2. 为字段加注解:使用 entgql.OrderField 注解在所选字段上。注解中字段名应为大写且与 GraphQL 枚举匹配。

  3. 更新 Schema:例如在 ent/schema 中:

func (Todo) Fields() []ent.Field {
return []ent.Field{
field.Text("text").
NotEmpty().
Annotations(
entgql.OrderField("TEXT"),
),
field.Time("created_at").
Default(time.Now).
Immutable().
Annotations(
entgql.OrderField("CREATED_AT"),
),
field.Enum("status").
NamedValues(
"InProgress", "IN_PROGRESS",
"Completed", "COMPLETED",
).
Default("IN_PROGRESS").
Annotations(
entgql.OrderField("STATUS"),
),
field.Int("priority").
Default(0).
Annotations(
entgql.OrderField("PRIORITY"),
),
}
}
  1. 或者支持多字段排序:可使用 entgql.MultiOrder() 注解:
func (Todo) Annotations() []schema.Annotation {
return []schema.Annotation{
entgql.MultiOrder(),
}
}
  1. 生成 GraphQL 类型:确保 GraphQL 枚举与上述设置一致,例如:
enum OrderDirection {
ASC
DESC
}
enum TodoOrderField {
CREATED_AT
PRIORITY
STATUS
TEXT
}
input TodoOrder {
direction: OrderDirection!
field: TodoOrderField
}
  1. 在查询中加入 orderBy 参数,允许客户端按字段排序:
type Query {
todos(
after: Cursor
first: Int
before: Cursor
last: Int
orderBy: TodoOrder
): TodoConnection!
}

按照这些步骤,你的 Ent‑based 应用即可在 GraphQL API 中支持多字段排序。

go run ./cmd/entrag ask "what's the difference between privacy rules and interceptors"
隐私规则(Privacy Rules)与拦截器(Interceptors)在 Ent 框架中扮演不同角色:
  1. 隐私规则:
  • 目的:主要用于执行访问控制策略,决定查询与更新是否被允许。
  • 实现:在 ent.Policy 接口中实现各项方法(EvalQueryEvalMutation),通过若干条件判断后返回 Allow / Deny / Skip
  • 运作:在查询过程中评估是否满足特定条件,若满足则允许,若不满足则拒绝。
  • 适用场景:适合通过规则管理访问权限,确保用户仅在满足条件时才可执行操作。
  1. 拦截器:
  • 目的:将其用作“端口”或展示形态,向多种体验——匹配所有可查询/所有权限下的免费美???? (原文不完整,略)
    说明:如果你想在回调中使用第一种..??/

总结:隐私规则专注于访问控制,拦截器则环绕“此处”定位等工具

结束语

在本文中,我们演示了如何使用 Ent、Atlas 与 pgvector 构建一个 RAG 系统。特别感谢 Eli Bendersky 的原始博客以及多年优秀的 Go 之作。

· 阅读需 4 分钟

TL;DR

只需一条命令,即可生成 Ent schema 的可视化图表:

atlas schema inspect \
-u ent://ent/schema \
--dev-url "sqlite://demo?mode=memory&_fk=1" \
--visualize

大家好!

几个月前,我们分享了 entviz,这是一款很酷的工具,能让你可视化 Ent schemas。由于其成功和流行,我们决定将其直接集成到 Atlas,即 Ent 使用的迁移引擎。

自 Atlas 发布 v0.13.0 起,你现在可以直接在 Atlas 上可视化 Ent schemas,而无需安装额外工具。

私有与公开可视化

之前,你只能将 schema 的可视化图表分享到 Atlas Public Playground。虽然这方便你与他人共享 schema,但对于维护敏感 schema 并且不适宜公开共享的团队而言,这并不合适。

借助此新版本,你可以轻松将 schema 直接发布到 Atlas Cloud 上的私有工作空间。这意味着仅你和你的团队能够访问 schema 的可视化图表。

使用 Atlas 可视化 Ent Schema

要使用 Atlas 可视化 Ent schema,先安装其最新版:

curl -sSfL https://atlasgo.io/install.sh | sh

其他安装方法请参阅 Atlas 安装文档

接下来,执行以下命令生成 Ent schema 的可视化图表:

atlas schema inspect \
-u ent://ent/schema \
--dev-url "sqlite://demo?mode=memory&_fk=1" \
--visualize

让我们逐步拆解此命令:

  • atlas schema inspect - 该命令可用于从各种来源检查 schema,并以多种格式输出。此处,我们使用它来检查 Ent schema。
  • -u ent://ent/schema - 这是我们想要检查的 Ent schema 的 URL。此处,我们使用 ent:// schema loader 指向 ./ent/schema 目录下的本地 Ent schema。
  • --dev-url "sqlite://demo?mode=memory&_fk=1" - Atlas 需要一个名为 开发数据库 的空数据库来规范化 schema 并执行各种计算。在此示例中,我们使用内存 SQLite;若你使用其他驱动,可改为 docker://mysql/8/dev(MySQL)或 docker://postgres/15/?search_path=public(PostgreSQL)。

执行上述命令后,你将看到如下输出:

Use the arrow keys to navigate: ↓ ↑ → ←
? Where would you like to share your schema visualization?:
▸ Publicly (gh.atlasgo.cloud)
Your personal workspace (requires 'atlas login')

如果你想公开共享 schema,选择第一个选项;若想私有共享,则选择第二个选项,然后运行 atlas login 登录你的(免费)Atlas 账号。

结束语

在本文中,我们展示了如何轻松使用 Atlas 可视化 Ent schema。希望你觉得此功能有用,期待听到你的反馈!

备注

获取更多 Ent 新闻和更新:

· 阅读需 20 分钟

Ent 是一个开源的 Go 语言实体框架。它与更传统的 ORM 类似,但具有一些独特功能,使其在 Go 社区中非常受欢迎。Ent 最初是由 Ariel 在 2019 年开源的,当时他在 Facebook 工作。Ent 诞生于管理具有非常大且复杂数据模型的应用开发的痛点,并在 Facebook 内成功运行了一年后才能开源。在 Facebook 开源项目毕业后,Ent 于 2021 年 9 月加入了 Linux 基金会。

本教程面向想从零开始构建一个简单项目:一个极简内容管理系统的 Ent 与 Go 初学者。

在过去几年中,Ent 已成为 Go 中增长最快的 ORM 之一:

来源: @ossinsight_bot 在 Twitter,2022 年 11 月

Ent 的一些常被引用的特性:

  • 一个类型安全的 Go API,用于操作数据库。 忘掉使用 interface{} 或反射来操作数据库。使用纯 Go 代码,编辑器能识别,编译器能强制。

  • 采用图语义建模 – Ent 使用图语义来建模应用数据。这使得通过简单 API 遍历复杂数据集变得非常容易。

    例如,获取属于关于狗的组的所有用户。可以用 Ent 写出两种实现方式:

    // 从主题开始遍历。
    client.Topic.Query().
    Where(topic.Name("dogs")).
    QueryGroups().
    QueryUsers().
    All(ctx)

    // 或:从用户开始遍历并过滤。
    client.User.Query().
    Where(
    user.HasGroupsWith(
    group.HasTopicsWith(
    topic.Name("dogs"),
    ),
    ),
    ).
    All(ctx)
  • 自动生成服务器 – 无论你需要 GraphQL、gRPC 还是符合 OpenAPI 的 API 层,Ent 都可以生成所需代码,让你在数据库之上创建高性能服务器。Ent 会生成第三方模式(GraphQL 类型、Protobuf 消息等)以及针对读写数据库的重复任务的优化代码。
  • 与 Atlas 集成 – Ent 构建时与Atlas紧密集成,Atlas 是一个功能强大的模式管理工具,具有许多高级功能。Atlas 可以自动为你规划 schema 迁移,并在 CI 中验证或直接部署到生产环境。(完全披露:Ariel 和我是 Atlas 的创建者和维护者)

必要条件

Supporting repo

你可以在 此仓库 找到本教程中展示的代码。

第 1 步:设置数据库 schema

你可以在 此 commit 找到这一节的代码。

先使用 go mod init 初始化项目:

go mod init github.com/rotemtam/ent-blog-example

Go 确认模块已创建:

go: creating new go.mod: module github.com/rotemtam/ent-blog-example

我们演示的第一个任务是设置数据库。我们使用 Ent 构建应用的数据模型。使用 go get 获取:

go get -u entgo.io/ent@master

安装后,使用 Ent CLI 初始化两个实体:UserPost

go run -mod=mod entgo.io/ent/cmd/ent new User Post

注意创建了若干文件:

.
`-- ent
|-- generate.go
`-- schema
|-- post.go
`-- user.go

2 directories, 3 files

Ent 为我们的项目生成了基本结构:

  • generate.go – 在接下来的章节会看到,该文件用于调用 Ent 的代码生成引擎。
  • schema 目录,包含每个请求实体的 ent.Schema

接下来定义实体的 schema。User 的定义如下:

// Fields of the User.  
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("name"),
field.String("email").
Unique(),
field.Time("created_at").
Default(time.Now),
}
}

// Edges of the User.
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.To("posts", Post.Type),
}
}

我们定义了 nameemailcreated_at 三个字段(created_at 默认值为 time.Now())。由于系统中 email 必须唯一,添加了 Unique 约束。还定义了名为 posts 的边到 Post。在关系型数据库中,边会转成外键和关联表。

// Post holds the schema definition for the Post entity.
type Post struct {
ent.Schema
}

// Fields of the Post.
func (Post) Fields() []ent.Field {
return []ent.Field{
field.String("title"),
field.Text("body"),
field.Time("created_at").
Default(time.Now),
}
}

// Edges of the Post.
func (Post) Edges() []ent.Edge {
return []ent.Edge{
edge.From("author", User.Type).
Unique().
Ref("posts"),
}
}

Post 里同样定义了 titlebodycreated_at 三个字段,并定义了从 PostUserauthor 边。由于每篇文章只有一位作者,设为 Unique。用 Ref 告诉 Ent 该边的反向引用是 Userposts 边。

Ent 的强大在于它的代码生成引擎。开发时每次修改应用 schema,都需要调用 Ent 的代码生成器,重新生成数据库访问代码。这使得 Ent 能为我们提供类型安全且高效的 Go API。

现在尝试执行:

go generate ./...

看到生成了大量新的 Go 文件:

.
`-- ent
|-- client.go
|-- context.go
|-- ent.go
|-- enttest
| `-- enttest.go
/// .. Truncated for brevity
|-- user_query.go
`-- user_update.go

9 directories, 29 files
信息

如果你想查看应用实际的数据库 schema,可以使用工具 entviz

go run -mod=mod ariga.io/entviz ./ent/schema

访问结果,点击 这里

有了 data model,接下来创建数据库 schema。

安装最新的 Atlas,只需在终端执行以下任意命令,或查看 Atlas 官网

curl -sSf https://atlasgo.sh | sh

Atlas 安装好后,创建初始迁移脚本:

atlas migrate diff add_users_posts \
--dir "file://ent/migrate/migrations" \
--to "ent://ent/schema" \
--dev-url "docker://mysql/8/ent"

看到两个新的文件:

ent/migrate/migrations
|-- 20230226150934_add_users_posts.sql
`-- atlas.sum

SQL 文件(文件名会因机器时间戳不同)包含在空 MySQL 数据库中设定 schema 的 DDL 语句:

-- create "users" table  
CREATE TABLE `users` (`id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `email` varchar(255) NOT NULL, `created_at` timestamp NOT NULL, PRIMARY KEY (`id`), UNIQUE INDEX `email` (`email`)) CHARSET utf8mb4 COLLATE utf8mb4_bin;
-- create "posts" table
CREATE TABLE `posts` (`id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(255) NOT NULL, `body` longtext NOT NULL, `created_at` timestamp NOT NULL, `user_posts` bigint NULL, PRIMARY KEY (`id`), INDEX `posts_users_posts` (`user_posts`), CONSTRAINT `posts_users_posts` FOREIGN KEY (`user_posts`) REFERENCES `users` (`id`) ON UPDATE NO ACTION ON DELETE SET NULL) CHARSET utf8mb4 COLLATE utf8mb4_bin;

为开发环境使用 Docker 启动本地 mysql 容器:

docker run --rm --name entdb -d -p 3306:3306 -e MYSQL_DATABASE=ent -e MYSQL_ROOT_PASSWORD=pass mysql:8

最后,在本地数据库上执行迁移脚本:

atlas migrate apply --dir file://ent/migrate/migrations \
--url mysql://root:pass@localhost:3306/ent

Atlas 报告已成功创建表:

Migrating to version 20230220115943 (1 migrations in total):

-- migrating version 20230220115943
-> CREATE TABLE `users` (`id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `email` varchar(255) NOT NULL, `created_at` timestamp NOT NULL, PRIMARY KEY (`id`), UNIQUE INDEX `email` (`email`) CHARSET utf8mb4 COLLATE utf8mb4_bin);
-> CREATE TABLE `posts` (`id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(255) NOT NULL, `body` longtext NOT NULL, `created_at` timestamp NOT NULL, `post_author` bigint NULL, PRIMARY KEY (`id`), INDEX `posts_users_author` (`post_author`), CONSTRAINT `posts_users_author` FOREIGN KEY (`post_author`) REFERENCES `users` (`id`) ON UPDATE NO ACTION ON DELETE SET NULL) CHARSET utf8mb4 COLLATE utf8mb4_bin;
-- ok (55.972329ms)

-------------------------
-- 67.18167ms
-- 1 migrations
-- 2 sql statements

第 2 步:为数据库填充种子数据

信息

此步骤中代码见 此 commit

在开发内容管理系统时,如果加载网页却看不到任何内容,将是一件令人沮丧的事。先给数据库填充数据,然后了解 Ent 的一些概念。

我们使用 go get 安装 MySQL 驱动:

go get -u github.com/go-sql-driver/mysql

创建 main.go,并添加基本种子脚本:

package main

import (
"context"
"flag"
"fmt"
"log"

"github.com/rotemtam/ent-blog-example/ent"

_ "github.com/go-sql-driver/mysql"
"github.com/rotemtam/ent-blog-example/ent/user"
)

func main() {
// Read the connection string to the database from a CLI flag.
var dsn string
flag.StringVar(&dsn, "dsn", "", "database DSN")
flag.Parse()

// Instantiate the Ent client.
client, err := ent.Open("mysql", dsn)
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()

ctx := context.Background()
// If we don't have any posts yet, seed the database.
if !client.Post.Query().ExistX(ctx) {
if err := seed(ctx, client); err != nil {
log.Fatalf("failed seeding the database: %v", err)
}
}
// ... Continue with server start.
}

func seed(ctx context.Context, client *ent.Client) error {
// Check if the user "rotemtam" already exists.
r, err := client.User.Query().
Where(
user.Name("rotemtam"),
).
Only(ctx)
switch {
// If not, create the user.
case ent.IsNotFound(err):
r, err = client.User.Create().
SetName("rotemtam").
SetEmail("r@hello.world").
Save(ctx)
if err != nil {
return fmt.Errorf("failed creating user: %v", err)
}
case err != nil:
return fmt.Errorf("failed querying user: %v", err)
}
// Finally, create a "Hello, world" blogpost.
return client.Post.Create().
SetTitle("Hello, World!").
SetBody("This is my first post").
SetAuthor(r).
Exec(ctx)
}

如你所见,程序首先检查数据库中是否已有 Post 实体;若没有则调用 seed 函数。该函数使用 Ent 检索名为 rotemtam 的用户,如果不存在则创建。最后,用该用户创建一篇「Hello, world」日志。

运行:

 go run main.go -dsn "root:pass@tcp(localhost:3306)/ent?parseTime=true"

第 3 步:创建主页

信息

此步骤中代码见 此 commit

现在创建博客首页。它包含几个部分:

  1. 视图 – Go html/template 渲染用户将看到的 HTML。
  2. 服务器代码 – 包含 HTTP 请求处理器,处理浏览器请求并渲染模板。
  3. 路由器 – 注册不同路径到处理器。
  4. 单元测试 – 验证服务器行为是否正确。

视图

Go 具有出色的模板引擎,分为 text/template(通用文本渲染)和 html/template(为 HTML 文档提供额外安全功能,防止代码注入)。了解更多请参阅此文档

创建第一个用于显示博客文章列表的模板,文件名 templates/list.tmpl

<html>
<head>
<title>My Blog</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous">

</head>
<body>
<div class="col-lg-8 mx-auto p-4 py-md-5">
<header class="d-flex align-items-center pb-3 mb-5 border-bottom">
<a href="/" class="d-flex align-items-center text-dark text-decoration-none">
<span class="fs-4">Ent Blog Demo</span>
</a>
</header>

<main>
<div class="row g-5">
<div class="col-md-12">
{{- range . }}
<h2>{{ .Title }}</h2>
<p>
{{ .CreatedAt.Format "2006-01-02" }} by {{ .Edges.Author.Name }}
</p>
<p>
{{ .Body }}
</p>
{{- end }}
</div>

</div>
</main>
<footer class="pt-5 my-5 text-muted border-top">
<p>
This is the Ent Blog Demo. It is a simple blog application built with Ent and Go. Get started:
</p>
<pre>go get entgo.io/ent</pre>
</footer>
</div>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"
integrity="sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN"
crossorigin="anonymous"></script>
</body>
</html>

此模板基于已修改的 Bootstrap Starter Template。关键部分如下:在 index 处理器中,我们会将一组 Post 传给此模板。

在 Go 模板中,传入的任何数据都可以通过 . 使用;range 用来遍历每个帖子:

{{- range . }}

随后输出标题、创建时间和作者姓名(通过 Author 边):

<h2>{{ .Title }}</h2>
<p>
{{ .CreatedAt.Format "2006-01-02" }} by {{ .Edges.Author.Name }}
</p>

最后输出正文并结束循环:

    <p>
{{ .Body }}
</p>
{{- end }}

在项目中使用 embed 包将模板编译进二进制:

var (  
//go:embed templates/*
resources embed.FS
tmpl = template.Must(template.ParseFS(resources, "templates/*"))
)

服务器代码

接下来定义 server 结构体及其构造函数 newServer,该结构体会持有 Ent 客户端:

type server struct {
client *ent.Client
}

func newServer(client *ent.Client) *server {
return &server{client: client}
}

然后定义主页面处理器 index,返回所有可用博客文章列表:

// index serves the blog home page
func (s *server) index(w http.ResponseWriter, r *http.Request) {
posts, err := s.client.Post.
Query().
WithAuthor().
All(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := tmpl.Execute(w, posts); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

以下代码解释了 Ent 的查询调用:

// s.client.Post contains methods for interacting with Post entities
s.client.Post.
// Begin a query.
Query().
// Retrieve the entities using the `Author` edge. (a `User` instance)
WithAuthor().
// Run the query against the database using the request context.
All(r.Context())

路由器

使用 go-chi 这一流行的 Go 路由库来管理路由:

go get -u github.com/go-chi/chi/v5

定义 newRouter,在路由中挂载所有处理器:

// newRouter creates a new router with the blog handlers mounted.
func newRouter(srv *server) chi.Router {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/", srv.index)
return r
}

在此函数中,先实例化 chi.Router,然后注册两个中间件:

  • middleware.Logger — 基础访问日志,记录每个请求。
  • middleware.Recoverer — 捕获处理器抛出的 panic,防止服务器因应用错误崩溃。

最后将 index 函数挂载到 GET /

单元测试

在将所有代码组合之前,先编写一个简单的单元测试来确认功能正常。

为了测试时使用内存 SQLite 数据库,安装 SQLite 驱动:

go get -u github.com/mattn/go-sqlite3

随后安装测试工具 testify

go get github.com/stretchr/testify 

main_test.go 里编写测试:

package main

import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"

_ "github.com/mattn/go-sqlite3"
"github.com/rotemtam/ent-blog-example/ent/enttest"
"github.com/stretchr/testify/require"
)

func TestIndex(t *testing.T) {
// Initialize an Ent client that uses an in memory SQLite db.
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
defer client.Close()

// seed the database with our "Hello, world" post and user.
err := seed(context.Background(), client)
require.NoError(t, err)

// Initialize a server and router.
srv := newServer(client)
r := newRouter(srv)

// Create a test server using the `httptest` package.
ts := httptest.NewServer(r)
defer ts.Close()

// Make a GET request to the server root path.
resp, err := ts.Client().Get(ts.URL)

// Assert we get a 200 OK status code.
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)

// Read the response body and assert it contains "Hello, world!"
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(body), "Hello, World!")
}

运行测试验证服务器工作正常:

go test ./...

得到测试通过:

ok      github.com/rotemtam/ent-blog-example    0.719s
? github.com/rotemtam/ent-blog-example/ent [no test files]
? github.com/rotemtam/ent-blog-example/ent/enttest [no test files]
? github.com/rotemtam/ent-blog-example/ent/hook [no test files]
? github.com/rotemtam/ent-blog-example/ent/migrate [no test files]
? github.com/rotemtam/ent-blog-example/ent/post [no test files]
? github.com/rotemtam/ent-blog-example/ent/predicate [no test files]
? github.com/rotemtam/ent-blog-example/ent/runtime [no test files]
? github.com/rotemtam/ent-blog-example/ent/schema [no test files]
? github.com/rotemtam/ent-blog-example/ent/user [no test files]

组合所有代码

最后,更新 main 函数,将所有功能整合:

func main() {  
// Read the connection string to the database from a CLI flag.
var dsn string
flag.StringVar(&dsn, "dsn", "", "database DSN")
flag.Parse()

// Instantiate the Ent client.
client, err := ent.Open("mysql", dsn)
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()

ctx := context.Background()
// If we don't have any posts yet, seed the database.
if !client.Post.Query().ExistX(ctx) {
if err := seed(ctx, client); err != nil {
log.Fatalf("failed seeding the database: %v", err)
}
}
srv := newServer(client)
r := newRouter(srv)
log.Fatal(http.ListenAndServe(":8080", r))
}

现在可以运行应用,并看到完整的博客前端:

 go run main.go -dsn "root:pass@tcp(localhost:3306)/test?parseTime=true"

第 4 步:添加内容

信息

此步骤更改见 此 commit

一个完整的内容管理系统必须具备内容管理功能。下面演示如何在博客中添加发布新文章的支持。

先创建后端处理器:

// add creates a new blog post.
func (s *server) add(w http.ResponseWriter, r *http.Request) {
author, err := s.client.User.Query().Only(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.client.Post.Create().
SetTitle(r.FormValue("title")).
SetBody(r.FormValue("body")).
SetAuthor(author).
Exec(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
http.Redirect(w, r, "/", http.StatusFound)
}

可见,该处理器目前仅从 users 表加载唯一用户(因为尚无用户管理或登录功能)。Only 仅在数据库返回恰好一个结果时成功。

然后挂载到路由器:

// newRouter creates a new router with the blog handlers mounted.
func newRouter(srv *server) chi.Router {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/", srv.index)
r.Post("/add", srv.add)
return r
}

接下来在页面中添加表单,以便用户写入内容:

<div class="col-md-12">
<hr/>
<h2>Create a new post</h2>
<form action="/add" method="post">
<div class="mb-3">
<label for="title" class="form-label">Title</label>
<input name="title" type="text" class="form-control" id="title" placeholder="Once upon a time..">
</div>
<div class="mb-3">
<label for="body" class="form-label">Body</label>
<textarea name="body" class="form-control" id="body" rows="8"></textarea>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary mb-3">Post</button>
</div>
</form>
</div>

另外,为了让列表从新到旧显示文章,修改 index 处理器,使其按 created_at 降序排列:

posts, err := s.client.Post.
Query().
WithAuthor().
Order(ent.Desc(post.FieldCreatedAt)).
All(ctx)

最后,编写单元测试验证新增文章功能:

func TestAdd(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
defer client.Close()
err := seed(context.Background(), client)
require.NoError(t, err)

srv := newServer(client)
r := newRouter(srv)

ts := httptest.NewServer(r)
defer ts.Close()

// Post the form.
resp, err := ts.Client().PostForm(ts.URL+"/add", map[string][]string{
"title": {"Testing, one, two."},
"body": {"This is a test"},
})
require.NoError(t, err)
// We should be redirected to the index page and receive 200 OK.
require.Equal(t, http.StatusOK, resp.StatusCode)

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)

// The home page should contain our new post.
require.Contains(t, string(body), "This is a test")
}

运行测试:

go test ./...

一切正常:

ok      github.com/rotemtam/ent-blog-example    0.493s
? github.com/rotemtam/ent-blog-example/ent [no test files]
? github.com/rotemtam/ent-blog-example/ent/enttest [no test files]
? github.com/rotemtam/ent-blog-example/ent/hook [no test files]
? github.com/rotemtam/ent-blog-example/ent/migrate [no test files]
? github.com/rotemtam/ent-blog-example/ent/post [no test files]
? github.com/rotemtam/ent-blog-example/ent/predicate [no test files]
? github.com/rotemtam/ent-blog-example/ent/runtime [no test files]
? github.com/rotemtam/ent-blog-example/ent/schema [no test files]
? github.com/rotemtam/ent-blog-example/ent/user [no test files]

可视化结果

点击提交表单后:

新文章已展示。恭喜!

总结

本文展示了如何使用 Ent 与 Go 构建一个简单的 Web 应用。我们的示例虽然简易,但涵盖了构建应用时需处理的诸多核心要点:定义数据模型、管理数据库 schema、编写服务器代码、设定路由和构建 UI。

作为入门内容,我们只触及了 Ent 的冰山一角,希望你能对其核心特性有初步体验。

有关更多 Ent 新闻和更新:

· 阅读需 4 分钟

TL;DR

要获取你 Ent 架构可视化的公开链接,请运行:

go run -mod=mod ariga.io/entviz ./path/to/ent/schema 

Visualizing Ent schemas

Ent 让开发者可以使用 图论语义 来构建复杂的应用数据模型:不需要定义表、列、关联表和外键,Ent 模型仅通过 NodesEdges 来描述。

package schema

import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
)

// User schema.
type User struct {
ent.Schema
}

// Fields of the user.
func (User) Fields() []ent.Field {
return []ent.Field{
// ...
}
}

// Edges of the user.
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.To("pets", Pet.Type),
}
}

以这种方式建模有诸多优点,例如可以轻松地在直观的 API 中 遍历 应用的数据图谱,自动生成 GraphQL 服务器等。

虽然 Ent 可以把图数据库作为其存储层,但大多数 Ent 用户仍使用 MySQL、PostgreSQL 或 MariaDB 等常见关系型数据库来存放应用数据。在这些场景中,开发者往往会思考:“Ent 从我的应用架构实际会创建什么数据库模式?”

无论你是刚开始学习 Ent 架构的新手,还是正在优化生成的数据库模式以提升性能的专家,能够轻松可视化 Ent 架构映射到的数据库模式都非常有价值。

Introducing the new entviz

一年半前,我们 分享了名为 entviz 的 Ent 插件,该插件允许用户生成包含实体关系图的本地 HTML 文档,以描述应用的 Ent 架构。

今天,我们很高兴分享同名的全新工具——由 Pedro Henrique (crossworth) 创建的 super cool tool。使用(新)entviz,只需执行一条简单的 Go 命令:

go run -mod=mod ariga.io/entviz ./path/to/ent/schema 

工具会分析你的 Ent 架构,并在 Atlas Playground 上生成可视化图谱,随后为你创建一个可分享的公开 link

Here is a public link to your schema visualization:
https://gh.atlasgo.cloud/explore/saved/60129542154

在该链接中,你可以将你的架构以 ERD 形式直观查看,亦可以 SQL 或 Atlas HCL 文档形式查看。

Wrapping up

在本篇博客中,我们讨论了一些你可能会发现快速获取 Ent 应用架构可视化很有用的场景,然后演示了如何使用 entviz 创建这些可视化图谱。如果你喜欢这个想法,欢迎今天就尝试并给我们反馈!

更多 Ent 新闻和更新:
  • 订阅我们的 [Newsletter]
  • [Twitter] 上关注我们
  • 在 #ent 的 [Gophers Slack] 加入我们
  • [Ent Discord Server] 加入我们

· 阅读需 10 分钟

更改数据库模式中列的类型乍一看可能很简单,但实际上这是一项有风险的操作,可能导致服务器与数据库之间的兼容性问题。在这篇博客中,我将探讨开发者如何在不导致应用停机的情况下执行此类更改。

最近,在为 Ariga 云 开发功能时,我需要将一个 Ent 字段的类型从无结构 blob 更改为结构化 JSON 字段。更改列类型是必要的,以便使用更高效的查询的 JSON 预测

原始模式在我们的云产品模式可视化图表中如下所示:

教程图 1

在我们的案例中,我们无法随意把数据复制到新列,因为数据与新列类型不兼容(blob 数据可能无法转换为 JSON)。

过去,人们会认为停止服务器、将数据库模式迁移到下一个版本,然后使用与新数据库模式兼容的新版本启动服务器是可以接受的做法。如今,业务需求往往要求应用提供更高的可用性,这给工程团队带来了在零停机时间内执行此类更改的挑战。

满足此类需求的常见模式,在 Martin Fowler 的 "演进型数据库设计" 中被定义为“过渡阶段”。

过渡阶段是“数据库同时支持旧访问模式和新访问模式的时间段”。这让旧系统有时间按自己的节奏迁移到新结构”,如下面的图所示:

教程图 2 Credit: martinfowler.com

我们规划了一个包含 5 个简单步骤的更改,全部向后兼容:

  • 创建一个名为 meta_json 的 JSON 列。
  • 部署一个执行双写的应用版本。每个新的记录或更新都写入新列和旧列,而读取仍然发生在旧列。
  • 将旧列的数据回填到新列。
  • 部署一个从新列读取的应用版本。
  • 删除旧列。

版本化迁移

在我们的项目中,我们使用 Ent 的 版本化迁移 工作流来管理数据库模式。版本化迁移为团队提供了对如何更改应用数据库模式的细粒度控制。此级别的控制对于实现我们的计划非常有用。 如果您的项目使用 自动迁移 并且想要跟随,请先将您的项目升级为使用版本化迁移。

备注

同样可以使用自动迁移加上 数据迁移 功能完成,但本贴聚焦于版本化迁移

使用 Ent 创建 JSON 列

首先,我们将向用户模式添加一个新的 JSON Ent 类型。

types/types.go
type Meta struct {
CreateTime time.Time `json:"create_time"`
UpdateTime time.Time `json:"update_time"`
}
ent/schema/user.go
func (User) Fields() []ent.Field {
return []ent.Field{
field.Bytes("meta"),
field.JSON("meta_json", &types.Meta{}).Optional(),
}
}

接下来,运行代码生成以更新应用模式:

go generate ./...

然后,运行我们的 自动迁移规划 脚本,生成一组包含迁移数据库所需 SQL 语句的迁移文件。

go run -mod=mod ent/migrate/main.go add_json_meta_column

生成的迁移文件描述了更改:

-- modify "users" table
ALTER TABLE `users` ADD COLUMN `meta_json` json NULL;

接下来,使用 Atlas 应用创建的迁移文件:

atlas migrate apply \
--dir "file://ent/migrate/migrations"
--url mysql://root:pass@localhost:3306/ent

结果,我们在数据库中得到了以下模式:

教程图 3

同时向两个列写入

生成 JSON 类型后,我们开始写入新列:

-    err := client.User.Create().
- SetMeta(input.Meta).
- Exec(ctx)
+ var meta types.Meta
+ if err := json.Unmarshal(input.Meta, &meta); err != nil {
+ return nil, err
+ }
+ err := client.User.Create().
+ SetMetaJSON(&meta).
+ Exec(ctx)

为确保写入新列 meta_json 的值被复制到旧列,我们可以利用 Ent 的 Schema Hooks 功能。 在主文件中添加空的 ent/runtime 导入以 注册该 Hook 并避免循环导入:

// 用户的 Hook
func (User) Hooks() []ent.Hook {
return []ent.Hook{
hook.On(
func(next ent.Mutator) ent.Mutator {
return hook.UserFunc(func(ctx context.Context, m *gen.UserMutation) (ent.Value, error) {
meta, ok := m.MetaJSON()
if !ok {
return next.Mutate(ctx, m)
}
if b, err := json.Marshal(meta); err != nil {
return nil, err
}
m.SetMeta(b)
return next.Mutate(ctx, m)
})
},
ent.OpCreate,
),
}
}

确保两字段写入后,我们可以安全地部署到生产环境。

回填旧列的值

现在,在我们的生产数据库中有两列:一列以 blob 存储 meta 对象,另一列以 JSON 存储。 由于 JSON 列最近才添加,第二列可能包含空值,因此我们需要使用旧列的值回填它。

为此,我们手动创建一个 SQL 迁移文件,从旧 blob 列填充新 JSON 列。

备注

你也可以使用 WriteDriver 写 Go 代码来生成此数据迁移文件

创建一个新的空迁移文件:

atlas migrate new --dir file://ent/migrate/migrations

对于 users 表中每个 JSON 值为空的行(即在新列创建前添加的行),我们尝试将 meta 对象解析为有效 JSON。如果成功,我们将用结果值填充 meta_json 列,否则标记为空。

我们接下来编辑迁移文件:

UPDATE users
SET meta_json = CASE
-- 当 meta 是有效 JSON 时,按原样存放
WHEN JSON_VALID(cast(meta as char)) = 1 THEN cast(cast(meta as char) as json)
-- 如果 meta 不是有效 JSON,则存为空对象
ELSE JSON_SET('{}')
END
WHERE meta_json is null;

更改迁移文件后重新生成哈希:

atlas migrate hash --dir "file://ent/mirate/migrations"

我们可以通过在本地数据库上执行所有之前的迁移文件,使用临时数据进行种子,并应用最后一个迁移来测试迁移文件是否按预期工作。

测试完成后,应用迁移文件:

atlas migrate apply \
--dir "file://ent/migrate/migrations"
--url mysql://root:pass@localhost:3306/ent

现在,我们再次部署到生产环境。

重定向读取到新列并删除旧 blob 列

现在我们在 meta_json 列中已有值,可以将读取从旧字段改为新字段。

不再在每次读取时解码 user.meta,而是直接使用 meta_json 字段:

-   var meta types.Meta
- if err = json.Unmarshal(user.Meta, &meta); err != nil {
- return nil, err
- }
- if meta.CreateTime.Before(time.Unix(0, 0)) {
- return nil, errors.New("invalid create time")
- }
+ if user.MetaJSON.CreateTime.Before(time.Unix(0, 0)) {
+ return nil, errors.New("invalid create time")
+ }

重定向读取后,我们将更改部署到生产环境。

删除旧列

现在可以从 Ent 模式中移除描述旧列的字段,因为我们不再使用它。

func (User) Fields() []ent.Field {
return []ent.Field{
- field.Bytes("meta"),
field.JSON("meta_json", &types.Meta{}).Optional(),
}
}

使用启用 Drop Column 功能再次生成 Ent 模式:

go run -mod=mod ent/migrate/main.go drop_user_meta_column

现在我们已成功创建新字段、重定向写入、回填数据并删除旧列——我们已准备好进行最终部署。剩下的工作就是将代码合并到版本控制并部署到生产环境!

结束语

在本贴中,我们讨论了如何使用 Atlas 的版本迁移与 Ent 集成,以零停机时间更改生产数据库中的列类型。

有疑问?需要帮助入门?请随时加入 我们的 Ent Discord 服务器

更多 Ent 新闻与更新:

· 阅读需 8 分钟
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

TL;DR

  • 大多数关系型数据库支持包含非结构化 JSON 值的列。
  • Ent 对在关系型数据库中处理 JSON 值有很好的支持。
  • 如何以原子方式向 JSON 数组添加值。
  • Ent 最近新增了对在 JSON 字段中原子添加值的支持。

SQL 数据库中的 JSON 值

虽然它们主要被用于存储结构化表格数据,但几乎所有现代关系型数据库都支持使用 JSON 列在表列中存储非结构化数据。例如,在 MySQL 中可以创建如下表:

CREATE TABLE t1 (jdoc JSON);

在此列中,用户可以存储任意模式的 JSON 对象:

INSERT INTO t1 VALUES('{"key1": "value1", "key2": "value2"}');

由于 JSON 文档始终可以表示为字符串,它们可以存储在普通的 VARCHARTEXT 列中。然而,当列被声明为 JSON 类型时,数据库会强制执行 JSON 语法的正确性。例如,如果我们尝试向此 MySQL 表写入一个不正确的 JSON 文档:

INSERT INTO t1 VALUES('[1, 2,');

我们将收到以下错误:

ERROR 3140 (22032) at line 2: Invalid JSON text:
"Invalid value." at position 6 in value (or column) '[1, 2,'.

此外,可以在 SELECT 语句中使用 JSON Path 表达式访问存储在 JSON 文档中的值,并在谓词(WHERE 子句)中过滤数据:

select c->'$.hello' as greeting from t where c->'$.hello' = 'world';;

得到:

+--------------+
| greeting |
+--------------+
| "world" |
+--------------+
1 row in set (0.00 sec)

Ent 中的 JSON 值

使用 Ent,用户可以在模式中使用 field.JSON 定义 JSON 字段,传入所需字段名称和后备 Go 类型。例如:

type Tag struct {
Name string `json:"name"`
Created time.Time `json:"created"`
}

func (User) Fields() []ent.Field {
return []ent.Field{
field.JSON("tags", []Tag{}),
}
}

Ent 提供了方便的 API,用于读取和写入 JSON 列的值,以及使用 sqljson 进行谓词操作:

func TestEntJSON(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
ctx := context.Background()
// 插入一个具有两个评论的用户。
client.User.Create().
SetTags([]schema.Tag{
{Name: "hello", Created: time.Now()},
{Name: "goodbye", Created: time.Now()},
}).
SaveX(ctx)

// 统计拥有多于零个标签的用户数量。
count := client.User.Query().
Where(func(s *sql.Selector) {
s.Where(
sqljson.LenGT(user.FieldTags, 0),
)
}).
CountX(ctx)
fmt.Printf("计数: %d", count)
// 输出: count: 1
}

在 JSON 列中追加值

在许多情况下,向 JSON 列中的列表追加值是很有用的。最好以 原子 的方式实现追加,即不需要读取当前值并写入整个新值。原因是如果两个调用尝试同时追加值,它们都将从数据库读取相同的当前值,然后大约同时写入自己的更新版本。除非使用 乐观锁,否则两次写入都会成功,但最终结果只会包含其中一个新值。

为了解决此竞争条件,我们可以让数据库处理同步,使用巧妙的 UPDATE 查询。这个思路类似于在许多应用中递增计数器的做法。你不需要先运行:

SELECT points from leaderboard where user='rotemtam'

读取结果(假设为 1000),在处理过程中递增值(1000+1=1001),然后手工写回:

UPDATE leaderboard SET points=1001 where user='rotemtam'

开发者可以使用类似的查询:

UPDATE leaderboard SET points=points+1 where user='rotemtam'

避免使用乐观锁或其他机制同步写入。让我们看看如何使用数据库的能力安全并发地完成相同操作。

在构造此查询时需要注意两点:首先,处理 JSON 值的语法在不同数据库供应商之间略有差异,如下面示例所示;其次,向 JSON 文档中的列表追加值的查询必须处理至少两个边缘情况:

  1. 我们想追加的字段尚不存在于 JSON 文档中。
  2. 字段已存在,但设置为 JSON null

以下是针对不同方言,在表 t 的列 c 中向字段 a 追加值 new_val 的查询示例:

UPDATE `t` SET `c` = CASE
WHEN
(JSON_TYPE(JSON_EXTRACT(`c`, '$.a')) IS NULL
OR JSON_TYPE(JSON_EXTRACT(`c`, '$.a')) = 'NULL')
THEN
JSON_SET(`c`, '$.a', JSON_ARRAY('new_val'))
ELSE
JSON_ARRAY_APPEND(`c`, '$.a', 'new_val')
END
UPDATE "t" SET "c" = CASE
WHEN
(("c"->'a')::jsonb IS NULL
OR ("c"->'a')::jsonb = 'null'::jsonb)
THEN
jsonb_set("c", '{a}', 'new_val', true)
ELSE
jsonb_set("c", '{a}', "c"->'a' || 'new_val', true)
END
UPDATE `t` SET `c` = CASE
WHEN
(JSON_TYPE(`c`, '$') IS NULL
OR JSON_TYPE(`c`, '$') = 'null')
THEN
JSON_ARRAY(?)
ELSE
JSON_INSERT(`c`, '$[#]', ?)
END

使用 Ent 向 JSON 字段追加值

Ent 最近新增了对在 JSON 列中原子追加值的支持。让我们看看如何使用它。

如果 JSON 字段的后备类型是切片,例如:

// User 的字段。
func (User) Fields() []ent.Field {
return []ent.Field{
// 代码高亮开始
field.JSON("tags", []string{}),
// 代码高亮结束
}
}

Ent 将在更新的变更构造器上生成 AppendTags 方法。你可以这样使用它:

func TestAppend(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
ctx := context.Background()
// 插入一个具有两个标签的用户。
u := client.User.Create().
SetTags([]string{"hello", "world"}).
SaveX(ctx)

// 代码高亮开始
u.Update().AppendTags([]string{"goodbye"}).ExecX(ctx)
// 代码高亮结束

again := client.User.GetX(ctx, u.ID)
fmt.Println(again.Tags)
// Prints: [hello world goodbye]
}

如果 JSON 字段的后备类型是包含列表的结构体,例如:

type Meta struct {
Tags []string `json:"tags"'`
}

// User 的字段。
func (User) Fields() []ent.Field {
return []ent.Field{
field.JSON("meta", &Meta{}),
}
}

你可以使用自定义的 sql/modifier 选项,让 Ent 生成 Modify 方法,然后按下方式使用:

func TestAppendSubfield(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
ctx := context.Background()
// 插入一个具有两个标签的用户。
u := client.User.Create().
SetMeta(&schema.Meta{
Tags: []string{"hello", "world"},
}).
SaveX(ctx)

// 代码高亮开始
u.Update().
Modify(func(u *sql.UpdateBuilder) {
sqljson.Append(u, user.FieldMeta, []string{"goodbye"}, sqljson.Path("tags"))
}).
ExecX(ctx)
// 代码高亮结束

again := client.User.GetX(ctx, u.ID)
fmt.Println(again.Meta.Tags)
// Prints: [hello world goodbye]
}

结语

在这篇文章中,我们讨论了 SQL 和 Ent 中的 JSON 字段。接下来,我们讨论了如何在流行的 SQL 数据库中原子地向 JSON 字段追加值。最后,我们展示了如何使用 Ent 实现此功能。你认为 Remove/Slice 操作是必要的吗?请告诉我们您的想法!

更多 Ent 新闻与更新:

· 阅读需 9 分钟

为确保软件质量,团队通常会使用 持续集成(CI)工作流。通过 CI,团队会针对代码库的每一次变更持续运行一套自动化验证。 在 CI 期间,团队可能执行多种验证:

  • 编译或构建最新版本,以确保其不被破坏。
  • 代码静态检查,确保符合接受的代码规范。
  • 单元测试,验证各个组件是否按预期工作,并确保对代码库的更改不会引起其他领域的回归。
  • 安全扫描,确保代码库中未引入已知漏洞。
  • 以及更多!

从与 Ent 社区的讨论中,我们了解到许多使用 Ent 的团队已经在使用 CI,并希望在其工作流中加入一些特定于 Ent 的验证。

为支持社区的这一工作,我们在文档中新增了一个指南,记录了在 CI 中验证的常见最佳实践,并介绍了我们维护的 ent/contrib/ci:一个将这些实践编入代码的 GitHub Action。

在本文中,我想分享一些我们初步建议,说明如何将 CI 融入您的 Ent 项目。文章后半部分将分享我们正在进行的一些项目,并希望获取社区的反馈。

验证所有生成文件已提交

Ent 大量依赖代码生成。根据我们的经验,生成的代码应始终提交到版本控制。这样做的原因有两个:

  • 如果生成的代码已提交到版本控制,它可以与主应用代码一起查看。在代码审查或仓库浏览时,拥有生成代码是了解其工作机制完整的必要条件。
  • 团队成员的开发环境差异可以很容易被发现并解决。这进一步降低了“仅在我的机器上能跑”的问题,因为所有人都运行相同的代码。

如果您使用 GitHub 做版本控制,您可以使用 ent/contrib/ci GitHub Action 轻松验证所有生成文件已提交。否则,我们提供一个简单的 Bash 脚本,您可以将其集成到现有 CI 流程中。

只需在您的仓库中添加一个名为 .github/workflows/ent-ci.yaml 的文件:

name: EntCI
on:
push:
# Run whenever code is changed in the master.
branches:
- master
# Run on PRs where something changed under the `ent/` directory.
pull_request:
paths:
- 'ent/*'
jobs:
ent:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.0.1
- uses: actions/setup-go@v3
with:
go-version: 1.18
- uses: ent/contrib/ci@master

检查迁移文件

对项目的 Ent 模式进行更改几乎总会导致数据库的修改。如果您正在使用 版本化迁移 来管理数据库模式的更改,您可以将 迁移检查 作为持续集成流程的一部分运行。这样做的原因有多种:

  • 语法检查会在数据库容器中重新执行您的迁移目录,以确保所有 SQL 语句有效且顺序正确。
  • 迁移目录完整性 得到强制执行——确保历史记录未被意外更改,并且并行计划的迁移统一为干净的线性历史。
  • 破坏性更改被检测,提前通知您迁移可能导致的潜在数据丢失,在到达生产数据库之前就可以发现。
  • 语法检查还能发现依赖数据的更改,这些更改在部署时可能失败,需要您进行更仔细的审查。

如果您使用 GitHub,您可以使用官方 Atlas Action 在 CI 期间运行迁移检查。

在您的仓库中添加 .github/workflows/atlas-ci.yaml,内容如下:

name: Atlas CI
on:
# Run whenever code is changed in the master branch,
# change this to your root branch.
push:
branches:
- master
# Run on PRs where something changed under the `ent/migrate/migrations/` directory.
pull_request:
paths:
- 'ent/migrate/migrations/*'
jobs:
lint:
services:
# Spin up a mysql:8.0.29 container to be used as the dev-database for analysis.
mysql:
image: mysql:8.0.29
env:
MYSQL_ROOT_PASSWORD: pass
MYSQL_DATABASE: test
ports:
- 3306:3306
options: >-
--health-cmd "mysqladmin ping -ppass"
--health-interval 10s
--health-start-period 10s
--health-timeout 5s
--health-retries 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.0.1
with:
fetch-depth: 0 # Mandatory unless "latest" is set below.
- uses: ariga/atlas-action@v0
with:
dir: ent/migrate/migrations
dir-format: golang-migrate # Or: atlas, goose, dbmate
dev-url: mysql://root:pass@localhost:3306/test

请注意,运行 atlas migrate lint 需要一个干净的[dev-database],它由上述示例代码中的 services 块提供。

Ent CI 的未来

为了在此基础上继续发展,我想分享一些我们在 Ariga 正在实验的功能,希望能够得到社区的反馈。

  • 在线迁移的 Linting —— 许多 Ent 项目使用 Ent 提供的自动模式迁移机制(在应用启动时调用 ent.Schema.Create)。假设项目的源代码在版本控制系统(如 Git)中管理,我们将主分支(master/main 等)中的模式与当前功能分支中的模式进行比较,并使用Atlas 的模式差异功能计算将对数据库执行的 SQL 语句。随后我们可以使用Atlas 的 Linting 功能来提供关于所提议更改可能产生的危险的见解。

  • 变更可视化 —— 为帮助审阅者理解特定拉取请求中提出的更改影响,我们生成可视化差异(使用类似于 entviz 的 ERD)以反映对项目模式的更改。

  • 模式 Linting —— 使用官方的 go/analysis 包创建检查器,分析 Ent 模式的 Go 代码,并在模式定义层面强制执行政策(如命名或索引约定)。

总结

本文介绍了 CI 的概念,并讨论了在 Ent 项目中实践 CI 的方法。随后,我们展示了我们正在内部试验的 CI 检查。若您希望看到这些检查成为 Ent 的一部分,或有其他关于为 Ent 提供 CI 工具的想法,请在 Ent Discord 服务器 与我们联系。

了解更多 Ent 新闻与更新:

· 阅读需 11 分钟

五周前我们发布了 Ent 中备受期待的功能——版本化迁移:用于管理数据库变更。
公告博客中,我们简要介绍了声明式和基于变更的方法,以保持数据库模式与正在使用的应用程序同步,并讨论了它们的缺陷以及 Atlas'(Ent 基础迁移引擎)将两者最佳特性结合到单一工作流中为何值得一试。我们称之为 Versioned Migration Authoring,如果你还没读过,现在正是时候!

使用版本化迁移作者时,生成的迁移文件仍然是“基于变更”的,但已被 Atlas 引擎安全规划。这意味着你仍然可以使用你最喜欢的迁移管理工具,例如 FlywayLiquibasegolang-migrate/migratepressly/goose,在使用 Ent 开发服务时。

在本文中,我想向你展示 Atlas 项目中的另一个新功能——我们称之为 Migration Directory Integrity File(迁移目录完整性文件),它现在已在 Ent 中得到支持,并且你可以与任何你已习惯且喜爱的迁移管理工具一起使用。

问题

在使用版本化迁移时,开发人员需要注意以下事项,以免破坏数据库:

  1. 事后更改已执行的迁移。
  2. 不小心更改迁移的组织顺序。
  3. 检入语义不正确的 SQL 脚本。

理论上,代码审查应该能防止团队合并带有这些问题的迁移。根据我的经验,事实上很多错误会通过人眼漏检,使这一方法容易出错。因此,采用自动化方式防止这些错误更安全。

第一个问题(更改历史)被大多数管理工具通过在托管数据库中保存已执行迁移文件的哈希值并与文件进行比较来解决。如果它们不匹配,迁移就会被中止。然而,这在开发周期的非常后期(部署期间)发生,如果能更早检测到可能会节省时间和资源。

第二个(和第三个)问题,考虑以下情景:

atlas-versioned-migrations-no-conflict

此图展示了两种可能被忽视的错误。第一个是迁移文件的顺序。

Team A 和 Team B 大致在同一时间分支一个功能。Team B 生成一个版本时间戳为 x 的迁移文件并继续开发。Team A 在稍后某个时间点生成迁移文件,因此版本时间戳为 x+1。Team A 完成了功能并将其合并到主分支,可能会自动在生产环境中部署,并将迁移版本 x+1 申请。此时没有问题。

现在,Team B 用版本 x 的迁移文件合并它的功能,该迁移时间戳早于已应用的 x+1。如果代码审查流程没有检测到,它将迁移文件推送到生产,随后就取决于具体的迁移管理工具来决定接下来会发生什么。

大多数工具都有自己的解决方案,例如 pressly/goose 采用了我们称之为 hybrid versioning 的方法(详见 https://github.com/pressly/goose/issues/63#issuecomment-428681694)。在我向你介绍 Atlas(Ent)处理此问题的独特方式之前,让我们先快速看一下第三个问题:

如果 Team A 和 Team B 开发的功能都需要新表或列,并且它们给了相同的名称(例如 users),它们可能都生成创建该表的语句。虽然先合并的团队将成功迁移,但后合并的团队迁移会因表或列已存在而失败。

解决方案

Atlas 以独特的方式处理上述问题。我们的目标是在尽可能早的时间提高意识。我们认为最佳做法是在版本控制和持续集成(CI)阶段。Atlas 的解决方案是引入一种新文件——Migration Directory Integrity File(迁移目录完整性文件)。它只是另一个名为 atlas.sum 的文件,存放在迁移文件旁边,包含关于迁移目录的一些元数据。其格式受到 Go 模块 go.sum 文件的启发,看起来类似于:

h1:KRFsSi68ZOarsQAJZ1mfSiMSkIOZlMq4RzyF//Pwf8A=
20220318104614_team_A.sql h1:EGknG5Y6GQYrc4W8e/r3S61Aqx2p+NmQyVz/2m8ZNwA=

atlas.sum 文件的第一个条目包含整个目录的总和,随后为每个迁移文件提供一个校验和(实现方式是逆向单分支 Merkle 哈希树)。让我们看看如何使用此文件在版本控制和 CI 中检测之前提到的情况。我们的目标是让两支团队都意识到已经添加了迁移文件,并且它们很可能需要在继续合并前进行检查。

备注

要跟随演示,请执行以下命令快速创建一个示例项目。这些命令将完成:

  1. 创建 Go 模块并下载所有依赖
  2. 创建一个非常简单的 User 模式
  3. 启用版本化迁移功能
  4. 运行代码生成
  5. 启动一个 MySQL Docker 容器(使用 docker stop atlas-sum 停止)
mkdir ent-sum-file
cd ent-sum-file
go mod init ent-sum-file
go install entgo.io/ent/cmd/ent@master
go run entgo.io/ent/cmd/ent new User
sed -i -E 's|^//go(.*)$|//go\1 --feature sql/versioned-migration|' ent/generate.go
go generate ./...
docker run --rm --name atlas-sum --detach --env MYSQL_ROOT_PASSWORD=pass --env MYSQL_DATABASE=ent -p 3306:3306 mysql

第一步是告诉迁移引擎使用 schema.WithSumFile() 选项来创建并管理 atlas.sum。下面的示例使用一个已实例化的 Ent 客户端 /docs/versioned-migrations#from-client 生成新的迁移文件:

package main

import (
"context"
"log"
"os"

"ent-sum-file/ent"

"ariga.io/atlas/sql/migrate"
"entgo.io/ent/dialect/sql/schema"
_ "github.com/go-sql-driver/mysql"
)

func main() {
client, err := ent.Open("mysql", "root:pass@tcp(localhost:3306)/ent")
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()
ctx := context.Background()
// Create a local migration directory.
dir, err := migrate.NewLocalDir("migrations")
if err != nil {
log.Fatalf("failed creating atlas migration directory: %v", err)
}
// Write migration diff.
err = client.Schema.NamedDiff(ctx, os.Args[1], schema.WithDir(dir), schema.WithSumFile())
if err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
}

创建迁移目录并执行上述命令后,你应该会看到兼容 golang-migrate/migrate 的迁移文件,并且还会生成 atlas.sum 文件,内容如下:

mkdir migrations
go run -mod=mod main.go initial
20220504114411_initial.up.sql
-- create "users" table
CREATE TABLE `users` (`id` bigint NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)) CHARSET utf8mb4 COLLATE utf8mb4_bin;

20220504114411_initial.down.sql
-- reverse: create "users" table
DROP TABLE `users`;

atlas.sum
h1:SxbWjP6gufiBpBjOVtFXgXy7q3pq1X11XYUxvT4ErxM=
20220504114411_initial.down.sql h1:OllnelRaqecTrPbd2YpDbBEymCpY/l6ihbyd/tVDgeY=
20220504114411_initial.up.sql h1:o/6yOczGSNYQLlvALEU9lK2/L6/ws65FrHJkEk/tjBk=

如你所见,atlas.sum 文件为每个生成的迁移文件包含一条条目。启用 atlas.sum 生成后,Team A 与 Team B 在为模式变更生成迁移时都会拥有这样一个文件。版本控制在第二个团队尝试合并其功能时会触发合并冲突。

atlas-versioned-migrations-no-conflict

备注

在随后的步骤中,我们使用 go run -mod=mod ariga.io/atlas/cmd/atlas 调用 Atlas CLI,但你也可以全局安装 CLI(随后直接使用 atlas)按以下安装说明进行配置:https://atlasgo.io/cli/getting-started/setting-up#install-the-cli。

你可以随时检查 atlas.sum 文件是否与迁移目录保持同步:

go run -mod=mod ariga.io/atlas/cmd/atlas migrate validate

然而,如果你手动更改了迁移文件(例如添加新的 SQL 语句、编辑现有语句甚至创建全新文件),atlas.sum 文件将不再与迁移目录内容保持同步。尝试为模式变更生成新的迁移文件时,Atlas 迁移引擎会被阻止。试试创建一个空的迁移文件并再次运行 main.go

go run -mod=mod ariga.io/atlas/cmd/atlas migrate new migrations/manual_version.sql --format golang-migrate
go run -mod=mod main.go initial
# 2022/05/04 15:08:09 failed creating schema resources: validating migration directory: checksum mismatch
# exit status 1

atlas migrate validate 命令会给出相同的提示:

go run -mod=mod ariga.io/atlas/cmd/atlas migrate validate
# Error: checksum mismatch
#
# You have a checksum error in your migration directory.
# This happens if you manually create or edit a migration file.
# Please check your migration files and run
#
# 'atlas migrate hash --force'
#
# to re-hash the contents and resolve the error.
#
# exit status 1

若想让 atlas.sum 文件再次与迁移目录同步,我们可以再次使用 Atlas CLI:

go run -mod=mod ariga.io/atlas/cmd/atlas migrate hash --force

出于安全原因,Atlas CLI 不会在 atlas.sum 文件不同步的目录上执行操作。因此,你需要在命令中加入 --force 标志。

对于开发者忘记在手动更改后更新 atlas.sum 文件的情况,你可以在 CI 中添加 atlas migrate validate 调用。我们正在积极开发 GitHub Action 和 CI 解决方案,能够 开箱即用 为你完成(以及其他)这一步骤。

小结

本文简要介绍了使用基于变更的 SQL 文件时常见的模式迁移来源,并基于 Atlas 项目提供的解决方案,使迁移更加安全。

有问题吗?需要帮助入门吗?随时加入我们的 Ent Discord 服务器

更多 Ent 新闻与更新

· 阅读需 8 分钟

Twitter 的“编辑按钮”功能因埃隆·马斯克的投票推文而登上头条,询问用户是否想要此功能。

Elons Tweet

毫无疑问,这是 Twitter 最受用户期待的功能之一。

作为一名软件开发者,我立即开始思考如何自己实现这一功能。跟踪/审计问题在许多应用中非常常见。如果你有一个实体(例如 Tweet)并且想要跟踪其中某个字段(例如 content 字段)的变更,有许多常见的解决方案。一些数据库甚至提供专有的方案,如 Microsoft 的变更跟踪(Change Tracking)和 MariaDB 的系统版本表(System Versioned Tables)。然而,在大多数使用场景中,你需要自己“拼装”这一功能。幸运的是,Ent 提供了一个模块化的扩展系统,只需几行代码便可插入类似功能。

Twitter+Edit Button

如果只有……

Ent 简介

Ent 是一个为 Go 设计的实体框架,能够让开发大型应用变得轻而易举。Ent 预装了许多壮观的功能,例如:

凭借 Ent 的代码生成引擎和先进的扩展系统,你可以轻松地用高级功能对 Ent 客户端进行模块化,这些功能通常需要手动实现时会非常耗时。举例而言:

Enthistory

enthistory 是一个扩展,我们在想给我们的某个 web 服务添加“活动与历史”面板时开始开发。该面板的作用是显示谁在什么时候更改了什么(即审计)。在 Atlas,这个用于使用声明式 HCL 文件管理数据库的工具,我们有一个名为“schema”的实体,基本上是一个大文本块。任何对 schema 的更改都会被记录,并可随后在“活动与历史”面板中查看。

Activity and History

Atlas 的“活动与历史”屏幕

此功能在许多应用中很常见,例如 Google 文档、GitHub PR、Facebook 帖子等,但在极受欢迎的 Twitter 中却缺失。

超过 300 万人投票支持在 Twitter 上添加“编辑按钮”,接下来让我展示 Twitter 如何毫不费力地让用户满意!

使用 Enthistory,你只需像下面这样注解你的 Ent 模式:

func (Tweet) Fields() []ent.Field {
return []ent.Field{
field.String("content").
Annotations(enthistory.TrackField()),
field.Time("created").
Default(time.Now),
}
}

Enthistory 会挂钩到你的 Ent 客户端,确保对 "Tweet" 的每一次 CRUD 操作都被记录到 "tweets_history" 表中,无需修改代码,并为你提供 API 来消费这些记录:

// 创建新 Tweet 时不会影响已存在的历史,enthistory 将自动在运行时修改事务以记录此事件到历史表
client.Tweet.Create().SetContent("hello world!").SaveX(ctx)

// 查询历史变更就像查询任何其它实体的一条边一样简单
t, _ := client.Tweet.Get(ctx, id)
hs := client.Tweet.QueryHistory(t).WithChanges().AllX(ctx)

若没有使用 Enthistory,你需要做的事情会更繁琐:假设你有一个类似 Twitter 的应用。它有一个名为 "tweets" 的表,其中一列是推文内容。

idcontentcreated_atauthor_id
1Hello Twitter!2022-04-06T13:45:34+00:00123
2Hello Gophers!2022-04-06T14:03:54+00:00456

现在,假设我们想允许用户编辑内容,同时在前端实时显示变更。为了解决此问题,有几种常见方法,各有优缺点,本文先不深入讨论。一个可行的思路是创建一个 "tweets_history" 表来记录推文的变更:

idtweet_idtimestampeventcontent
112022-04-06T12:30:00+00:00CREATEDhello world!
222022-04-06T13:45:34+00:00UPDATEDhello Twitter!

通过类似上述的表,我们可以记录原始推文「1」的变更,并在需要时展示它最初在 12:30:00 的内容为 "hello world!",随后在 13:45:34 被修改为 "hello Twitter!"。

实现这一点时,我们需要在对 "tweets" 的每一次 UPDATE 语句中加入一次 INSERT 到 "tweets_history"。为了正确性,必须将两条语句包裹在一个事务中,以避免第一条成功后第二条失败导致历史记录被破坏。我们还需要确保每一次 INSERT 到 "tweets" 都与一次 INSERT 到 "tweets_history" 配合:

# INSERT 被记录为 "CREATE" 历史事件
- INSERT INTO tweets (`content`) VALUES ('Hello World!');
+BEGIN;
+INSERT INTO tweets (`content`) VALUES ('Hello World!');
+INSERT INTO tweets_history (`content`, `timestamp`, `record_id`, `event`)
+VALUES ('Hello World!', NOW(), 1, 'CREATE');
+COMMIT;

# UPDATE 被记录为 "UPDATE" 历史事件
- UPDATE tweets SET `content` = 'Hello World!' WHERE id = 1;
+BEGIN;
+UPDATE tweets SET `content` = 'Hello World!' WHERE id = 1;
+INSERT INTO tweets_history (`content`, `timestamp`, `record_id`, `event`)
+VALUES ('Hello World!', NOW(), 1, 'UPDATE');
+COMMIT;

这个方法虽好,但若你还需要对其他实体(如 "comment_history"、"settings_history")进行同样处理,就会需要创建更多表。为了避免这种情况,Enthistory 统一使用单一的 "history" 表和单一的 "changes" 表来记录所有跟踪字段。它还能支持多种字段类型而无需额外添加列。

预发布

Enthistory 仍处于早期设计阶段,正在内部测试。因此,我们尚未将其公开发布,尽管我们计划很快发布。若你想尝试 Enthistory 的预发布版本,我写了一个简单的 React 应用,配合 GraphQL+Enthistory 展示推文编辑效果。你可以在这里查看:here。欢迎随时提供反馈。

小结

我们已经看到 Ent 的模块化扩展系统如何让你像安装一个包一样快速实现高级功能。开发自己的扩展既有趣、简单又能增长见识(link)!欢迎大家亲自试一试!

未来,Enthistory 将用于跟踪边(即外键表)、与 OpenAPI 与 GraphQL 扩展集成,并为其底层实现提供更多方法。

有关更多 Ent 新闻与更新:

· 阅读需 5 分钟

我们曾在发布了新的迁移引擎 Atlas
使用 Atlas,向 Ent 添加新数据库支持再也前所未有地简单。
今天,我很高兴宣布,TiDB 的预览支持现已可用,使用已开启 Atlas 的最新版本 Ent。

Ent 可以访问多种类型的数据库,包括面向图形和关系型数据库。用户最常使用的标准开源关系型数据库包括 MySQL、MariaDB 和 PostgreSQL。随着基于 Ent 的应用程序团队越来越成功并需要处理更大规模的流量,这些单节点数据库经常成为扩展的瓶颈。因此,许多 Ent 社区成员请求对 NewSQL 数据库(如 TiDB)的支持。

TiDB

TiDB 是一个开源 NewSQL 数据库。它提供了传统数据库所不具备的许多功能,例如:

  1. 水平扩展 – 多年来,软件架构师需要在关系型数据库提供的熟悉性和保证与具备 NoSQL(如 MongoDB 或 Cassandra)水平扩展能力之间做出选择。TiDB 在保持良好兼容性方面支持 MySQL 功能的同时实现水平扩展。
  2. HTAP(混合事务/分析处理) – 此外,数据库传统上被划分为分析型(OLAP)和事务型(OLTP)数据库。TiDB 通过在同一数据库上同时支持分析和事务工作负载,打破了这种二元性。
  3. 预装监控(Prometheus+Grafana) – TiDB 从设计之初就以云原生模式构建,并原生支持 CNCF 标准可观测性栈。

欲了解更多信息,请查看官方的TiDB 介绍

HiHello with TiDB

要使用 Ent+TiDB 快速创建“Hello World”应用,请按以下步骤操作:

  1. 通过 Docker 启动本地 TiDB 服务器:

    docker run -p 4000:4000 pingcap/tidb

    现在你应该有一个在 4000 端口监听的 TiDB 实例。

  2. 克隆示例hello world 仓库

    git clone https://github.com/hedwigz/tidb-hello-world.git

    在此示例仓库中我们定义了一个简单的 User 模式:

    ent/schema/user.go
    func (User) Fields() []ent.Field {
    return []ent.Field{
    field.Time("created_at").
    Default(time.Now),
    field.String("name"),
    field.Int("age"),
    }
    }

    然后,我们将 Ent 与 TiDB 连接起来:

    main.go
    client, err := ent.Open("mysql", "root@tcp(localhost:4000)/test?parseTime=true")
    if err != nil {
    log.Fatalf("failed opening connection to tidb: %v", err)
    }
    defer client.Close()
    // 使用 Atlas 运行自动迁移工具。
    if err := client.Schema.Create(context.Background(), schema.WithAtlas(true)); err != nil {
    log.Fatalf("failed printing schema changes: %v", err)
    }

    注意,在行 1 我们使用 mysql 方言连接到 TiDB 服务器。这是可能的,因为 TiDB 与 MySQL 兼容(MySQL 兼容性),且不需要任何特殊驱动。
    说到这点,TiDB 与 MySQL 之间仍存在一些差异,尤其是在架构迁移、信息模式检查和迁移规划方面。因此,Atlas 会自动检测其连接的是否为 TiDB 并相应处理迁移。
    另外,注意在行 7 我们使用 schema.WithAtlas(true),这会提示 Ent 使用 Atlas 作为其迁移引擎。

    最后,我们创建一个用户并将记录保存到 TiDB,以便后续查询和打印:

    main.go
    client.User.Create().
    SetAge(30).
    SetName("hedwigz").
    SaveX(context.Background())
    user := client.User.Query().FirstX(context.Background())
    fmt.Printf("用户:%s 的年龄是 %d 岁\n", user.Name, user.Age)
  3. 运行示例程序:

    $ go run main.go
    用户:hedwigz 的年龄是 30

Woohoo! 在这一次快速演示中我们完成了:

  • 启动本地 TiDB 实例。
  • 将 Ent 与 TiDB 连接。
  • 使用 Atlas 迁移我们的 Ent 模式。
  • 使用 Ent 向 TiDB 插入并查询数据。

预览支持

Atlas 与 TiDB 的集成已在 TiDB 版本 v5.4.0(撰写时为 latest)上经过充分测试,并将在未来继续扩展。
如果你使用的是其他版本的 TiDB 或需要帮助,请随时提交 Issue或加入我们的Discord 频道

更多 Ent 新闻与更新: