跳到主要内容

使用 Ent 构建 Go 语言 Web 应用的初学者指南

· 阅读需 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 新闻和更新: