Go的REST和Clean Architecture实现
这个模块为github.com/swaggest/usecase
实现了HTTP传输层,用于构建REST服务。
目标
- 维护HTTP API文档、验证和输入/输出的单一事实来源。
- 避免依赖编译时代码生成。
- 通过抽象HTTP细节并提供简单的API来提高生产力和可靠性。
- 允许高级情况下的低级定制。
- 保持合理的性能和低GC影响。
非目标
- 支持旧版文档模式如Swagger 2.0或RAML。
- 零内存分配。
- 明确支持请求或响应体中的XML。
特性
- 兼容
net/http
。 - 使用
github.com/go-chi/chi
路由器构建。 - 模块化灵活结构。
- 基于字段标签的HTTP请求映射到Go值。
- 使用Clean Architecture用例解耦业务逻辑。
- 使用
github.com/swaggest/openapi-go
自动生成类型安全的OpenAPI 3.0/3.1文档。 - 文档和端点接口的单一事实来源。
- 使用
github.com/santhosh-tekuri/jsonschema
自动进行请求/响应JSON模式验证。 - 动态gzip压缩和快速直通模式。
- 优化性能。
- 嵌入式Swagger UI。
- 用例交互器的通用接口。
使用方法
请查看这个教程了解端到端使用示例。
请求解码器
带有字段标签的Go结构体定义了输入端口。
请求解码器在调用用例交互器之前,从http.Request
数据填充字段值。
// 声明输入端口类型
type helloInput struct {
Locale string `query:"locale" default:"en-US" pattern:"^[a-z]{2}-[A-Z]{2}$" enum:"ru-RU,en-US"`
Name string `path:"name" minLength:"3"` // 字段标签定义参数位置和JSON模式约束
// 未命名字段的字段标签应用于父模式,
// 它们是可选的,可用于禁止未知参数。
// 对于非正文参数,必须明确提供name标签。
// 例如,这里不允许未知的`query`和`cookie`参数,
// 未知的`header`参数是允许的。
_ struct{} `query:"_" cookie:"_" additionalProperties:"false"`
}
输入数据可以位于:
- 请求URI中的
path
参数,例如/users/{name}
, - 请求URI中的
query
参数,例如/users?locale=en-US
, - 请求正文中的
formData
参数,内容类型为application/x-www-form-urlencoded
或multipart/form-data
, form
参数作为formData
或query
,- 请求正文中的
json
参数,内容类型为application/json
, - 请求cookie中的
cookie
参数, - 请求头中的
header
参数。
为了更明确地分离用例和传输之间的关注点,可以在初始化处理程序时单独提供请求映射(请注意,这种映射不适用于json
正文)。
// 声明输入端口类型
type helloInput struct {
Locale string `default:"en-US" pattern:"^[a-z]{2}-[A-Z]{2}$"`
Name string `minLength:"3"` // 字段标签定义参数位置和JSON模式约束
}
// 将带有自定义输入映射的用例处理程序添加到路由器
r.Method(http.MethodGet, "/hello/{name}", nethttp.NewHandler(u,
nethttp.RequestMapping(new(struct {
Locale string `query:"locale"`
Name string `path:"name"` // 字段标签定义参数位置和JSON模式约束
})),
))
额外的字段标签描述了JSON模式约束,请查看文档。
可以使用github.com/swaggest/jsonschema-go接口
进行更多模式定制。
默认情况下,default
标签仅用于文档贡献,
如果设置了request.DecoderFactory.ApplyDefaults
为true
,请求结构中没有显式值但有default
的字段将被填充为默认值。
如果输入结构实现了request.Loader
,
那么将调用LoadFromHTTPRequest(r *http.Request) error
方法来填充输入结构,而不是自动解码。这允许对需要的情况进行低级控制。
请求解码器可以独立使用,在已有的`ServeHTTP`中。
type MyRequest struct {
Foo int `header:"X-Foo"`
Bar string `formData:"bar"`
Baz bool `query:"baz"`
}
// 特定结构的解码器,可以重复用于多个HTTP请求。
myDecoder := request.NewDecoderFactory().MakeDecoder(http.MethodPost, new(MyRequest), nil)
// 来自ServeHTTP的请求和响应写入器。
var (
rw http.ResponseWriter
req *http.Request
)
// 这段代码通常会在ServeHTTP中。
var myReq MyRequest
if err := myDecoder.Decode(req, &myReq, nil); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
}
响应编码器
带有字段标签的Go结构体定义了输出端口。
响应编码器在用例交互器调用完成后,将输出数据写入http.ResponseWriter
。
// 声明输出端口类型
type helloOutput struct {
Now time.Time `header:"X-Now" json:"-"`
Message string `json:"message"`
Sess string `cookie:"sess,httponly,secure,max-age=86400,samesite=lax"`
}
输出数据可以位于:
json
用于响应正文,内容类型为application/json
,header
用于响应头中的值,cookie
用于cookie值,cookie字段可以在字段标签中有配置(与实际cookie相同,但使用逗号分隔)。
为了更明确地分离用例和传输之间的关注点,可以在初始化处理程序时单独提供响应头映射。
// 声明输出端口类型
type helloOutput struct {
Now time.Time `json:"-"`
Message string `json:"message"`
}
// 将带有自定义输出头映射的用例处理程序添加到路由器
r.Method(http.MethodGet, "/hello/{name}", nethttp.NewHandler(u,
nethttp.ResponseHeaderMapping(new(struct {
Now time.Time `header:"X-Now"`
})),
))
额外的字段标签描述了JSON模式约束,请查看文档。
创建用例交互器
HTTP传输通过适配用例交互器与业务逻辑解耦。
用例交互器可以定义用于在Go值和传输之间映射数据的输入和输出端口。 它可以提供关于自身的信息,这些信息将在生成的文档中暴露。
// 创建用例交互器,引用输入/输出类型和交互函数。
u := usecase.NewInteractor(func(ctx context.Context, input helloInput, output *helloOutput) error {
msg, available := messages[input.Locale]
if !available {
return status.Wrap(errors.New("unknown locale"), status.InvalidArgument)
}
output.Message = fmt.Sprintf(msg, input.Name)
output.Now = time.Now()
return nil
})
初始化Web服务
Web服务是路由器前面的一个工具化外观,它简化了配置并提供了更紧凑的API来添加用例。
// 服务初始化路由器并添加所需的中间件。
service := web.NewService(openapi31.NewReflector())
// 它允许OpenAPI配置。
service.OpenAPISchema().SetTitle("相册API")
service.OpenAPISchema().SetDescription("此服务提供管理相册的API。")
service.OpenAPISchema().SetVersion("v1.0.0")
// 可以添加额外的中间件。
service.Use(
middleware.StripSlashes,
// cors.AllowAll().Handler, // "github.com/rs/cors", 第三方CORS中间件也可以在此处配置。
)
// 可以使用简短语法.<Method>(...)挂载用例。
service.Post("/albums", postAlbums(), nethttp.SuccessStatus(http.StatusCreated))
log.Println("在 http://localhost:8080 启动服务")
if err := http.ListenAndServe("localhost:8080", service); err != nil {
log.Fatal(err)
}
通常,web.Service
API 已经足够,但如果不够用,可以手动配置路由器,请查看下面的文档。
安全设置
使用HTTP基本认证的示例。
// 准备具有适当安全方案的中间件。
// 它将对每个相关请求执行实际的安全检查。
adminAuth := middleware.BasicAuth("管理员访问", map[string]string{"admin": "admin"})
// 准备API模式更新器中间件。
// 它将用安全方案注释处理程序文档。
adminSecuritySchema := nethttp.HTTPBasicSecurityMiddleware(apiSchema, "管理员", "管理员访问")
// 具有管理员访问权限的端点。
r.Route("/admin", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(adminAuth, adminSecuritySchema) // 将两个中间件添加到路由组以强制执行和记录安全性。
r.Method(http.MethodPut, "/hello/{name}", nethttp.NewHandler(u))
})
})
使用cookie的示例。
// 安全中间件。
// - sessMW是实际的请求级处理器,
// - sessDoc是处理程序级包装器,用于公开文档。
sessMW := func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("sessid"); err == nil {
r = r.WithContext(context.WithValue(r.Context(), "sessionID", c.Value))
}
handler.ServeHTTP(w, r)
})
}
sessDoc := nethttp.APIKeySecurityMiddleware(s.OpenAPICollector, "用户",
"sessid", oapi.InCookie, "会话cookie。")
// 为单个顶级路由配置安全方案。
s.With(sessMW, sessDoc).Method(http.MethodGet, "/root-with-session", nethttp.NewHandler(dummy()))
// 在子路由器上配置安全方案。
s.Route("/deeper-with-session", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(sessMW, sessDoc)
r.Method(http.MethodGet, "/one", nethttp.NewHandler(dummy()))
r.Method(http.MethodGet, "/two", nethttp.NewHandler(dummy()))
})
})
参见示例。
处理程序设置
处理程序是用例交互器的通用适配器,因此通常设置很简单。
// 将用例处理程序添加到路由器。
r.Method(http.MethodGet, "/hello/{name}", nethttp.NewHandler(u))
示例
对于非泛型用例,请参见另一个示例。
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"time"
"github.com/swaggest/openapi-go/openapi31"
"github.com/swaggest/rest/response/gzip"
"github.com/swaggest/rest/web"
swgui "github.com/swaggest/swgui/v5emb"
"github.com/swaggest/usecase"
"github.com/swaggest/usecase/status"
)
func main() {
s := web.NewService(openapi31.NewReflector())
// 初始化API文档模式。
s.OpenAPISchema().SetTitle("基本示例")
s.OpenAPISchema().SetDescription("此应用展示了一个简单的REST API。")
s.OpenAPISchema().SetVersion("v1.2.3")
// 设置中间件。
s.Wrap(
gzip.Middleware, // 响应压缩,支持直接gzip传递。
)
// 声明输入端口类型。
type helloInput struct {
Locale string `query:"locale" default:"en-US" pattern:"^[a-z]{2}-[A-Z]{2}$" enum:"ru-RU,en-US"`
Name string `path:"name" minLength:"3"` // 字段标签定义参数位置和JSON模式约束。
// 未命名字段的字段标签应用于父模式。
// 它们是可选的,可用于禁止未知参数。
// 对于非body参数,必须明确提供name标签。
// 例如,这里不允许未知的`query`和`cookie`参数,
// 未知的`header`参数是可以的。
_ struct{} `query:"_" cookie:"_" additionalProperties:"false"`
}
// 声明输出端口类型。
type helloOutput struct {
Now time.Time `header:"X-Now" json:"-"`
Message string `json:"message"`
}
messages := map[string]string{
"en-US": "Hello, %s!",
"ru-RU": "Привет, %s!",
}
// 创建带有输入/输出类型引用和交互功能的用例交互器。
u := usecase.NewInteractor(func(ctx context.Context, input helloInput, output *helloOutput) error {
msg, available := messages[input.Locale]
if !available {
return status.Wrap(errors.New("未知语言"), status.InvalidArgument)
}
output.Message = fmt.Sprintf(msg, input.Name)
output.Now = time.Now()
return nil
})
// 描述用例交互器。
u.SetTitle("问候者")
u.SetDescription("问候者向您问好。")
u.SetExpectedErrors(status.InvalidArgument)
// 将用例处理程序添加到路由器。
s.Get("/hello/{name}", u)
// Swagger UI端点位于/docs。
s.Docs("/docs", swgui.New)
// 启动服务器。
log.Println("http://localhost:8011/docs")
if err := http.ListenAndServe("localhost:8011", s); err != nil {
log.Fatal(err)
}
}
其他集成
性能优化
如果服务或特定端点的最高性能至关重要,您可以通过在输入类型上实现手动请求加载器来用性能换取简单性。
func (i *myInput) LoadFromHTTPRequest(r *http.Request) (err error) {
i.Header = r.Header.Get("X-Header")
return nil
}
如果实现了request.Loader
,它将被调用而不是自动解码和验证。
查看高级示例。
要进一步提高性能,您可以尝试使用rest-fasthttp
分支,用fasthttp
代替net/http
。
版本控制
本项目遵循语义化版本控制。
在1.0.0版本之前,破坏性变更用MINOR
版本号标记,功能和修复用PATCH
版本号标记。
在1.0.0版本之后,破坏性变更用MAJOR
版本号标记。
破坏性变更在UPGRADE.md中描述。