深色模式
Goa 使用指南
概述
Goa 适合这种后端:接口定义要先落下来,服务端代码、OpenAPI 文档和客户端代码希望从同一份设计生成,业务实现再往里填。它把路由注册、参数绑定、请求校验和一部分客户端代码生成都放进了 goa gen,应用代码主要保留设计文件和业务逻辑。
下面用一个最小的用户服务走一遍完整流程:创建项目、编写 design/design.go、生成代码、接入 GORM、启动 HTTP 服务,再看看 Goa 生成出来的接口代码和 OpenAPI 文档分别落在哪些目录里。
安装与初始化
先创建一个新的 Go 模块,并安装 Goa CLI:
sh
mkdir blogapi
cd blogapi
go mod init example.com/blogapi
go get goa.design/goa/v3/...
go install goa.design/goa/v3/cmd/goa@latest
goa version
mkdir design如果终端里找不到 goa,通常是 $(go env GOPATH)/bin 还没有放进 PATH。
编写设计文件
Goa 的源头不是 openapi.yaml,而是 Go DSL。先在 design/design.go 中定义 API 和服务:
go
package design
import . "goa.design/goa/v3/dsl"
var _ = API("blog", func() {
Title("Blog API")
Description("使用 Goa 构建的示例 API")
Server("development", func() {
Host("localhost", func() {
URI("http://localhost:8080")
})
})
})
var User = Type("User", func() {
Attribute("id", UInt64, "用户 ID")
Attribute("name", String, "用户名")
Attribute("email", String, "邮箱")
Required("id", "name", "email")
})
var CreateUserPayload = Type("CreateUserPayload", func() {
Attribute("name", String, "用户名")
Attribute("email", String, "邮箱")
Required("name", "email")
})
var _ = Service("user", func() {
Description("用户服务")
HTTP(func() {
Path("/users")
})
Method("create", func() {
Payload(CreateUserPayload)
Result(User)
HTTP(func() {
POST("")
Response(StatusCreated)
})
})
Method("show", func() {
Payload(func() {
Attribute("id", UInt64, "用户 ID")
Required("id")
})
Result(User)
HTTP(func() {
GET("/{id}")
})
})
Method("list", func() {
Result(ArrayOf(User))
HTTP(func() {
GET("")
})
})
})这个设计文件做了几件事:
API定义全局元信息和服务地址。Type定义复用的数据结构。Service和Method定义业务接口。HTTP把方法映射到具体的 HTTP 路径和动词。
如果项目目前只有 HTTP,Attribute 写起来更轻。以后要从同一份设计继续生成 gRPC,相关的结构建议改成 Field 并补上稳定编号。
生成代码
设计文件写完后,运行 Goa 的两个核心命令:
sh
goa gen example.com/blogapi/design
goa example example.com/blogapi/design
go mod tidy这里要注意一件事:goa gen 和 goa example 接收的是 Go 包导入路径,不是 ./design 这样的文件系统路径。
生成后的目录通常会长这样:
text
blogapi/
├── cmd/
├── design/
│ └── design.go
├── gen/
│ ├── http/
│ │ └── openapi.json
│ └── user/
│ ├── client.go
│ ├── endpoints.go
│ └── service.go
└── user.go其中几个文件最值得先看:
gen/user/service.go:生成的服务接口、请求类型、响应类型。gen/user/endpoints.go:endpoint 层。gen/http/user/server/:HTTP 传输层代码。gen/http/openapi.json:从设计文件同步生成的 OpenAPI 文档。cmd/和user.go:goa example生成的可修改骨架。
gen/ 目录会在每次执行 goa gen 时重建,不要直接改里面的文件。真正要维护的是 design/、自己的业务实现文件,以及 goa example 生成后由你接管的代码。
实现业务逻辑
Goa 生成的是接口和传输层,业务逻辑要自己实现。下面用 GORM 把 create 和 show 接起来:
go
package blogapi
import (
"context"
genuser "example.com/blogapi/gen/user"
"gorm.io/gorm"
)
type UserModel struct {
ID uint64 `gorm:"primaryKey"`
Name string
Email string `gorm:"uniqueIndex"`
}
type UserService struct {
db *gorm.DB
}
func NewUserService(db *gorm.DB) *UserService {
return &UserService{db: db}
}
func (s *UserService) Create(ctx context.Context, p *genuser.CreatePayload) (*genuser.User, error) {
model := UserModel{
Name: p.Name,
Email: p.Email,
}
if err := s.db.WithContext(ctx).Create(&model).Error; err != nil {
return nil, err
}
return &genuser.User{
ID: model.ID,
Name: model.Name,
Email: model.Email,
}, nil
}
func (s *UserService) Show(ctx context.Context, p *genuser.ShowPayload) (*genuser.User, error) {
var model UserModel
if err := s.db.WithContext(ctx).First(&model, "id = ?", p.ID).Error; err != nil {
return nil, err
}
return &genuser.User{
ID: model.ID,
Name: model.Name,
Email: model.Email,
}, nil
}
func (s *UserService) List(ctx context.Context) ([]*genuser.User, error) {
var models []UserModel
if err := s.db.WithContext(ctx).Find(&models).Error; err != nil {
return nil, err
}
users := make([]*genuser.User, 0, len(models))
for _, item := range models {
users = append(users, &genuser.User{
ID: item.ID,
Name: item.Name,
Email: item.Email,
})
}
return users, nil
}这里有两个点比较实用:
- Goa 生成的方法签名带
context.Context,可以直接传给GORM的WithContext。 - Goa 不负责生成 GORM 的
model或repository,数据层结构还是按自己的习惯组织。
启动 HTTP 服务
goa example 已经会生成一套可运行的 cmd/ 骨架。想看清 Goa 的组装方式,也可以手动写一个最小的 main.go:
go
package main
import (
"log"
"net/http"
goahttp "goa.design/goa/v3/http"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
blogapi "example.com/blogapi"
genuser "example.com/blogapi/gen/user"
genhttp "example.com/blogapi/gen/http/user/server"
)
func main() {
db, err := gorm.Open(sqlite.Open("blog.db"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
if err := db.AutoMigrate(&blogapi.UserModel{}); err != nil {
log.Fatal(err)
}
svc := blogapi.NewUserService(db)
endpoints := genuser.NewEndpoints(svc)
mux := goahttp.NewMuxer()
server := genhttp.New(
endpoints,
mux,
goahttp.RequestDecoder,
goahttp.ResponseEncoder,
nil,
nil,
)
genhttp.Mount(mux, server)
log.Println("listening on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}启动后,可以直接用下面的命令测试:
sh
curl -X POST http://localhost:8080/users \
-H 'Content-Type: application/json' \
-d '{"name":"Tom","email":"tom@example.com"}'
curl http://localhost:8080/users
curl http://localhost:8080/users/1生成文档与客户端代码
执行 goa gen 之后,Goa 会同时生成几类产物:
gen/<service>/client.go:Go 客户端代码。gen/http/openapi.json:HTTP 接口对应的 OpenAPI 描述。gen/http/<service>/server/:HTTP 服务端传输层代码。gen/grpc/<service>/pb/:启用 gRPC 时生成的proto和相关代码。
这些文件都来自 design/*.go,所以接口定义一旦变更,只需要重新执行 goa gen,服务端类型、客户端代码和文档就会一起同步。
常见使用方式
Goa 的日常工作流基本就是这一套:
- 改
design/*.go。 - 执行
goa gen同步生成代码。 - 只在自己的实现文件里补业务逻辑。
- 需要脚手架时再执行
goa example。 - 需要文档或 SDK 时,消费
gen/http/openapi.json或gen/grpc/.../pb/*.proto。
这套方式比较适合 API 契约要长期维护的项目。接口、文档、客户端生成和服务端类型都来自同一个源头,后期不容易漂。
