2021 年底,我们宣布 Ent 新增了一个官方扩展,用于生成完全符合 OpenAPI 规范 的文档:entoas。
今天,我们非常高兴地宣布,又一个与 entoas 配套的新扩展诞生了:ogent。它利用 ogen (官网) 的强大能力,为 entoas 生成的 OpenAPI 规范文档提供了类型安全且无需反射的实现。
ogen 是一个基于 OpenAPI Specification v3 文档的强约定 Go 代码生成器。它能为给定的 OpenAPI 规范文档同时生成服务器和客户端实现。用户唯一需要做的就是实现一个接口来访问应用程序的数据层。ogen 拥有许多炫酷的特性,其中之一是与 OpenTelemetry 的集成。请务必查看并给予支持。
本文介绍的扩展充当了 Ent 与 ogen 生成代码之间的桥梁。它使用 entoas 的配置来生成 ogen 代码的缺失部分。
下图展示了 Ent 如何与扩展 entoas 和 ogent 交互,以及 ogen 是如何参与其中的。

架构图
如果您是 Ent 的新手,想了解更多关于它的信息,例如如何连接不同类型的数据库、运行迁移或操作实体,请前往 设置教程。
本文中的代码可在模块的 示例 中找到。
开始使用
虽然 Ent 支持 Go 1.16+ 版本,但 ogen 要求您至少使用 1.17 版本。
要使用 ogent 扩展,请按照 此处 所述使用 entc (ent 代码生成) 包。首先将 entoas 和 ogent 扩展安装到您的 Go 模块中:
go get ariga.io/ogent@main
现在按照以下两个步骤启用它们并配置 Ent 以使用这些扩展:
1. 创建一个名为 ent/entc.go 的新 Go 文件,并粘贴以下内容:
//go:build ignore
package main
import (
"log"
"ariga.io/ogent"
"entgo.io/contrib/entoas"
"entgo.io/ent/entc"
"entgo.io/ent/entc/gen"
"github.com/ogen-go/ogen"
)
func main() {
spec := new(ogen.Spec)
oas, err := entoas.NewExtension(entoas.Spec(spec))
if err != nil {
log.Fatalf("creating entoas extension: %v", err)
}
ogent, err := ogent.NewExtension(spec)
if err != nil {
log.Fatalf("creating ogent extension: %v", err)
}
err = entc.Generate("./schema", &gen.Config{}, entc.Extensions(ogent, oas))
if err != nil {
log.Fatalf("running ent codegen: %v", err)
}
}
2. 编辑 ent/generate.go 文件以执行 ent/entc.go 文件:
package ent
//go:generate go run -mod=mod entc.go
完成这些步骤后,一切就绪,可以从您的模式(schema)生成 OAS 文档并实现服务器代码了!
生成 CRUD HTTP API 服务器
构建 HTTP API 服务器的第一步是创建一个 Ent 模式图(schema graph)。为简洁起见,这里使用一个示例模式:
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
)
// Todo 持有 Todo 实体的模式定义。
type Todo struct {
ent.Schema
}
// Todo 的字段。
func (Todo) Fields() []ent.Field {
return []ent.Field{
field.String("title"),
field.Bool("done"),
}
}
上面的代码是描述模式图的 "Ent 方式"。在这个特定的例子中,我们创建了一个 todo 实体。
现在运行代码生成器:
go generate ./...
您应该看到由 Ent 代码生成器生成的一堆文件。名为 ent/openapi.json 的文件是由 entoas 扩展生成的。以下是其预览:
{
"info": {
"title": "Ent Schema API",
"description": "This is an auto generated API description made out of an Ent schema definition",
"termsOfService": "",
"contact": {},
"license": {
"name": ""
},
"version": "0.0.0"
},
"paths": {
"/todos": {
"get": {
[...]

Swagger 编辑器示例
然而,本文重点在于服务器实现部分,因此我们对名为 ent/ogent 的目录感兴趣。所有以 _gen.go 结尾的文件都是由 ogen 生成的。名为 oas_server_gen.go 的文件包含了 ogen 用户需要实现的接口,以便运行服务器。
// Handler 处理 OpenAPI v3 规范描述的操作。
type Handler interface {
// CreateTodo 实现 createTodo 操作。
//
// 创建新的 Todo 并持久化到存储。
//
// POST /todos
CreateTodo(ctx context.Context, req CreateTodoReq) (CreateTodoRes, error)
// DeleteTodo 实现 deleteTodo 操作。
//
// 删除具有指定 ID 的 Todo。
//
// DELETE /todos/{id}
DeleteTodo(ctx context.Context, params DeleteTodoParams) (DeleteTodoRes, error)
// ListTodo 实现 listTodo 操作。
//
// 列出 Todos。
//
// GET /todos
ListTodo(ctx context.Context, params ListTodoParams) (ListTodoRes, error)
// ReadTodo 实现 readTodo 操作。
//
// 查找具有指定 ID 的 Todo 并返回。
//
// GET /todos/{id}
ReadTodo(ctx context.Context, params ReadTodoParams) (ReadTodoRes, error)
// UpdateTodo 实现 updateTodo 操作。
//
// 更新 Todo 并将更改持久化到存储。
//
// PATCH /todos/{id}
UpdateTodo(ctx context.Context, req UpdateTodoReq, params UpdateTodoParams) (UpdateTodoRes, error)
}
ogent 在文件 ogent.go 中为该处理程序添加了一个实现。要了解如何定义要生成的路由以及要急切加载(eager load)的边(edges),请前往 entoas 的 文档。
以下展示了一个生成的读取(READ)路由示例:
// ReadTodo 处理 GET /todos/{id} 请求。
func (h *OgentHandler) ReadTodo(ctx context.Context, params ReadTodoParams) (ReadTodoRes, error) {
q := h.client.Todo.Query().Where(todo.IDEQ(params.ID))
e, err := q.Only(ctx)
if err != nil {
switch {
case ent.IsNotFound(err):
return &R404{
Code: http.StatusNotFound,
Status: http.StatusText(http.StatusNotFound),
Errors: rawError(err),
}, nil
case ent.IsNotSingular(err):
return &R409{
Code: http.StatusConflict,
Status: http.StatusText(http.StatusConflict),
Errors: rawError(err),
}, nil
default:
// 让服务器处理错误。
return nil, err
}
}
return NewTodoRead(e), nil
}
运行服务器
下一步是创建一个 main.go 文件,并连接所有部分以创建一个应用服务器来提供 Todo-API。以下 main 函数初始化了一个 SQLite 内存数据库,运行迁移以创建所有需要的表,并在 localhost:8080 上提供 ent/openapi.json 文件中描述的 API:
package main
import (
"context"
"log"
"net/http"
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/sql/schema"
"<your-project>/ent"
"<your-project>/ent/ogent"
_ "github.com/mattn/go-sqlite3"
)
func main() {
// 创建 ent 客户端。
client, err := ent.Open(dialect.SQLite, "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
log.Fatal(err)
}
// 运行迁移。
if err := client.Schema.Create(context.Background(), schema.WithAtlas(true)); err != nil {
log.Fatal(err)
}
// 开始监听。
srv, err := ogent.NewServer(ogent.NewOgentHandler(client))
if err != nil {
log.Fatal(err)
}
if err := http.ListenAndServe(":8080", srv); err != nil {
log.Fatal(err)
}
}
使用 go run -mod=mod main.go 运行服务器后,您就可以使用 API 了。
首先,让我们创建一个新的 Todo。出于演示目的,我们不发送请求体:
↪ curl -X POST -H "Content-Type: application/json" localhost:8080/todos
{
"error_message": "body required"
}
如您所见,ogen 为您处理了这种情况,因为 entoas 在尝试创建新资源时将请求体标记为必需。让我们再试一次,但这次提供一个请求体:
↪ curl -X POST -H "Content-Type: application/json" -d '{"title":"Give ogen and ogent a Star on GitHub"}' localhost:8080/todos
{
"error_message": "decode CreateTodo:application/json request: invalid: done (field required)"
}
哎呀!出了什么问题?ogen 为您提供了保障:字段 done 是必需的。要解决此问题,请转到您的模式定义并将 done 字段标记为可选:
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
)
// Todo 持有 Todo 实体的模式定义。
type Todo struct {
ent.Schema
}
// Todo 的字段。
func (Todo) Fields() []ent.Field {
return []ent.Field{
field.String("title"),
field.Bool("done").
Optional(),
}
}
由于我们对配置进行了更改,必须重新运行代码生成并重启服务器:
go generate ./...
go run -mod=mod main.go
现在,如果我们再次尝试创建 Todo,看看会发生什么:
↪ curl -X POST -H "Content-Type: application/json" -d '{"title":"Give ogen and ogent a Star on GitHub"}' localhost:8080/todos
{
"id": 1,
"title": "Give ogen and ogent a Star on GitHub",
"done": false
}
瞧,数据库中有一个新的 Todo 项目了!
假设您已经完成了您的 Todo 并且给 ogen 和 ogent 点了星(您真的应该点!),通过发起 PATCH 请求将 todo 标记为完成:
↪ curl -X PATCH -H "Content-Type: application/json" -d '{"done":true}' localhost:8080/todos/1
{
"id": 1,
"title": "Give ogen and ogent a Star on GitHub",
"done": true
}
添加自定义端点
如您所见,Todo 现在被标记为完成。不过,如果有一个额外的路由来标记 Todo 为完成状态会更酷:PATCH todos/:id/done。要实现这一点,我们必须做两件事:在我们的 OAS 文档中记录新路由,并实现该路由。我们可以使用 entoas 的变更构建器(mutation builder)来解决第一个问题。编辑您的 ent/entc.go 文件并添加路由描述:
//go:build ignore
package main
import (
"log"
"entgo.io/contrib/entoas"
"entgo.io/ent/entc"
"entgo.io/ent/entc/gen"
"github.com/ariga/ogent"
"github.com/ogen-go/ogen"
)
func main() {
spec := new(ogen.Spec)
oas, err := entoas.NewExtension(
entoas.Spec(spec),
entoas.Mutations(func(_ *gen.Graph, spec *ogen.Spec) error {
spec.AddPathItem("/todos/{id}/done", ogen.NewPathItem().
SetDescription("标记项目为完成").
SetPatch(ogen.NewOperation().
SetOperationID("markDone").
SetSummary("将 todo 项目标记为完成。").
AddTags("Todo").
AddResponse("204", ogen.NewResponse().SetDescription("项目标记为完成")),
).
AddParameters(ogen.NewParameter().
InPath().
SetName("id").
SetRequired(true).
SetSchema(ogen.Int()),
),
)
return nil
}),
)
if err != nil {
log.Fatalf("creating entoas extension: %v", err)
}
ogent, err := ogent.NewExtension(spec)
if err != nil {
log.Fatalf("creating ogent extension: %v", err)
}
err = entc.Generate("./schema", &gen.Config{}, entc.Extensions(ogent, oas))
if err != nil {
log.Fatalf("running ent codegen: %v", err)
}
}
运行代码生成器 (go generate ./...) 后,ent/openapi.json 文件中应该有一个新条目:
"/todos/{id}/done": {
"description": "标记项目为完成",
"patch": {
"tags": [
"Todo"
],
"summary": "将 todo 项目标记为完成。",
"operationId": "markDone",
"responses": {
"204": {
"description": "项目标记为完成"
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"schema": {
"type": "integer"
},
"required": true
}
]
}

自定义端点
上述由 ogen 生成的 ent/ogent/oas_server_gen.go 文件也会反映这些更改:
// Handler 处理 OpenAPI v3 规范描述的操作。
type Handler interface {
// CreateTodo 实现 createTodo 操作。
//
// 创建新的 Todo 并持久化到存储。
//
// POST /todos
CreateTodo(ctx context.Context, req CreateTodoReq) (CreateTodoRes, error)
// DeleteTodo 实现 deleteTodo 操作。
//
// 删除具有指定 ID 的 Todo。
//
// DELETE /todos/{id}
DeleteTodo(ctx context.Context, params DeleteTodoParams) (DeleteTodoRes, error)
// ListTodo 实现 listTodo 操作。
//
// 列出 Todos。
//
// GET /todos
ListTodo(ctx context.Context, params ListTodoParams) (ListTodoRes, error)
// MarkDone 实现 markDone 操作。
//
// PATCH /todos/{id}/done
MarkDone(ctx context.Context, params MarkDoneParams) (MarkDoneNoContent, error)
// ReadTodo 实现 readTodo 操作。
//
// 查找具有指定 ID 的 Todo 并返回。
//
// GET /todos/{id}
ReadTodo(ctx context.Context, params ReadTodoParams) (ReadTodoRes, error)
// UpdateTodo 实现 updateTodo 操作。
//
// 更新 Todo 并将更改持久化到存储。
//
// PATCH /todos/{id}
UpdateTodo(ctx context.Context, req UpdateTodoReq, params UpdateTodoParams) (UpdateTodoRes, error)
}
如果您现在尝试运行服务器,Go 编译器会报错,因为 ogent 代码生成器不知道如何实现新路由。您必须手动完成。将当前的 main.go 替换为以下文件以实现新方法。
package main
import (
"context"
"log"
"net/http"
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/sql/schema"
"github.com/ariga/ogent/example/todo/ent"
"github.com/ariga/ogent/example/todo/ent/ogent"
_ "github.com/mattn/go-sqlite3"
)
type handler struct {
*ogent.OgentHandler
client *ent.Client
}
func (h handler) MarkDone(ctx context.Context, params ogent.MarkDoneParams) (ogent.MarkDoneNoContent, error) {
return ogent.MarkDoneNoContent{}, h.client.Todo.UpdateOneID(params.ID).SetDone(true).Exec(ctx)
}
func main() {
// 创建 ent 客户端。
client, err := ent.Open(dialect.SQLite, "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
log.Fatal(err)
}
// 运行迁移。
if err := client.Schema.Create(context.Background(), schema.WithAtlas(true)); err != nil {
log.Fatal(err)
}
// 创建处理程序。
h := handler{
OgentHandler: ogent.NewOgentHandler(client),
client: client,
}
// 开始监听。
srv := ogent.NewServer(h)
if err := http.ListenAndServe(":8180", srv); err != nil {
log.Fatal(err)
}
}
如果您重启服务器,则可以发起以下请求来标记一个 todo 项目为完成状态:
↪ curl -X PATCH localhost:8180/todos/1/done
未来计划
ogent 计划进行一些改进,最显著的是为 LIST 路由添加过滤功能,提供代码生成、类型安全的方式。我们想先听听您的反馈。
总结
在本文中,我们宣布了 ogent,它是 entoas 生成的 OpenAPI 规范文档的官方实现生成器。该扩展利用 ogen(一个非常强大且功能丰富的 OpenAPI v3 文档 Go 代码生成器)的能力,提供了一个即用型、可扩展的 RESTful HTTP API 服务器。
请注意,ogen 和 entoas/ogent 都尚未达到第一个主要版本,目前仍在开发中。不过,API 可以被认为是稳定的。
有疑问吗?需要入门帮助?欢迎加入我们的 Discord 服务器 或 Slack 频道。
- 订阅我们的 新闻通讯
- 在 Twitter 上关注我们
- 加入 Gophers Slack 的 #ent 频道
- 加入 Ent Discord 服务器