事务管理
将一次 HTTP 请求中执行的多个 INSERT 合并到同一个事务。编写拦截器,在请求成功时提交,中途失败时连同之前的 INSERT 一并回滚。
本页按功能划分的 src/user 示例使用项目结构中通过 --layout legacy 创建的 my-app。CLI v0.4.0 默认生成按角色划分的 layered-v1 结构。两种布局都使用 Spine v0.6.0 和 Echo 平台 v0.1.0。
在数据库教程的 example.com/my-app 项目上继续操作。请先准备 src/database、src/entity、src/user 和迁移。以下事务代码需要手动添加,CLI 不提供 Bun 事务或 outbox 生成器。
执行顺序
在 Spine v0.6.0 中,在 PreHandle 中启动事务,并在 BeforeResponse 中提交或回滚。响应序列化、状态码和 Cookie 验证、事件发布等响应准备过程中发生的错误,也会触发回滚。AfterCompletion 用于在完成后记录日志或清理资源。
PreHandle: 启动事务
→ 控制器 → 响应准备 → PostHandle
→ BeforeResponse: 有错误则回滚,否则提交
→ 发送 HTTP 响应
→ AfterCompletion: 记录日志并清理资源实现拦截器
// src/interceptor/tx_interceptor.go
package interceptor
import (
"errors"
"log"
"github.com/NARUBROWN/spine/core"
"github.com/uptrace/bun"
)
type TxInterceptor struct{ db *bun.DB }
func NewTxInterceptor(db *bun.DB) *TxInterceptor {
return &TxInterceptor{db: db}
}
func (i *TxInterceptor) PreHandle(ctx core.ExecutionContext, meta core.HandlerMeta) error {
if ctx.Context() == nil {
return errors.New("execution context has no request context")
}
tx, err := i.db.BeginTx(ctx.Context(), nil)
if err != nil {
return err
}
ctx.Set("tx", tx)
return nil
}
func (i *TxInterceptor) PostHandle(ctx core.ExecutionContext, meta core.HandlerMeta) {}
func (i *TxInterceptor) BeforeResponse(ctx core.ExecutionContext, meta core.HandlerMeta, executionErr error) error {
value, ok := ctx.Get("tx")
if !ok {
return nil
}
tx, ok := value.(bun.Tx)
if !ok {
return errors.New("transaction has unexpected type")
}
if executionErr != nil {
return tx.Rollback()
}
return tx.Commit()
}
func (i *TxInterceptor) AfterCompletion(ctx core.ExecutionContext, meta core.HandlerMeta, err error) {
if err != nil {
log.Printf("[TX] %s %s: %v", ctx.Method(), ctx.Path(), err)
}
}必须返回 Commit() 和 Rollback() 的错误,才能将其计入最终执行错误。请保留包含 log 和 errors 的 import,以及全部四个拦截器方法。
注册到生成的应用
在 internal/application/application.go 的 import 块中添加 example.com/my-app/src/interceptor。保留数据库教程中注册的 database.NewDB,以及 health 和 user 注册,并在现有的 app.Constructor 调用中添加 interceptor.NewTxInterceptor。以下代码是 BuildApplication() 中的注册部分。
app.Constructor(
NewHealthController,
database.NewDB,
interceptor.NewTxInterceptor,
)
app.InterceptorFor(boot.InterceptorHTTP, (*interceptor.TxInterceptor)(nil))(*interceptor.TxInterceptor)(nil) 指定拦截器类型。Spine 通过 DI 创建该类型的实例,NewTxInterceptor 必须只返回一个值。
若只应用于 HTTP 请求,请按上例使用 boot.InterceptorHTTP。app.Interceptor(...) 也会应用于 WebSocket 消息。若只应用于特定路由,请省略全局注册,并在相应的 app.Route 中指定 route.WithInterceptors((*interceptor.TxInterceptor)(nil)) 选项。
将请求事务传递给仓储
添加 src/user/user_create.go,使用请求中的事务保存用户。这里使用生成的 UserController 的 service 字段和 UserService 的 repository 字段。
请求 DTO 通过指针接收。spine.Ctx 来自 github.com/NARUBROWN/spine/pkg/spine。以下代码使用别名 spinectx,以免与应用根包重名。
// src/user/user_create.go
package user
import (
"context"
"errors"
"net/http"
"example.com/my-app/src/entity"
"github.com/NARUBROWN/spine/pkg/httpx"
spinectx "github.com/NARUBROWN/spine/pkg/spine"
"github.com/uptrace/bun"
)
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
func (c *UserController) Create(ctx context.Context, values spinectx.Ctx, req *CreateUserRequest) (httpx.Response[*entity.User], error) {
value, ok := values.Get("tx")
if !ok {
return httpx.Response[*entity.User]{}, errors.New("transaction is missing")
}
tx, ok := value.(bun.Tx)
if !ok {
return httpx.Response[*entity.User]{}, errors.New("transaction has unexpected type")
}
user, err := c.service.Create(ctx, tx, req)
if err != nil {
return httpx.Response[*entity.User]{}, err
}
return httpx.Response[*entity.User]{
Body: user,
Options: httpx.ResponseOptions{Status: http.StatusCreated},
}, nil
}
func (s *UserService) Create(ctx context.Context, db bun.IDB, req *CreateUserRequest) (*entity.User, error) {
user := &entity.User{Name: req.Name, Email: req.Email}
if err := s.repository.Save(ctx, db, user); err != nil {
return nil, err
}
if err := db.NewSelect().Model(user).WherePK().Scan(ctx); err != nil {
return nil, err
}
return user, nil
}服务以 bun.IDB 接收请求中的事务,并传递给仓储。*bun.DB 和 bun.Tx 实现相同的接口,但请求中的事务不会自动重新注入仓储构造函数。
用以下实现替换 src/user/user_repository.go 中现有的 Save 方法。不要额外声明同名方法。
// src/user/user_repository.go
func (r *UserRepository) Save(ctx context.Context, db bun.IDB, user *entity.User) error {
_, err := db.NewInsert().Model(user).Exec(ctx)
return err
}在 src/user/user_route.go 的 Register 中手动添加 POST 路由。保留现有的 GET 注册。
app.Route("POST", "/user", (*UserController).Create)合并多次写入并检查回滚
这次在一次请求中保存多个用户。创建 src/user/user_batch.go,并将以下 POST 路由添加到 src/user/user_route.go 的 Register 中。如果第二个 INSERT 因邮箱重复而失败,第一个 INSERT 也应回滚。
// src/user/user_batch.go
package user
import (
"context"
"errors"
"net/http"
"example.com/my-app/src/entity"
"github.com/NARUBROWN/spine/pkg/httpx"
spinectx "github.com/NARUBROWN/spine/pkg/spine"
"github.com/uptrace/bun"
)
type CreateUsersRequest struct {
Users []CreateUserRequest `json:"users"`
}
func (c *UserController) CreateBatch(ctx context.Context, values spinectx.Ctx, req *CreateUsersRequest) (httpx.Response[[]*entity.User], error) {
value, ok := values.Get("tx")
if !ok {
return httpx.Response[[]*entity.User]{}, errors.New("transaction is missing")
}
tx, ok := value.(bun.Tx)
if !ok {
return httpx.Response[[]*entity.User]{}, errors.New("transaction has unexpected type")
}
users := make([]*entity.User, 0, len(req.Users))
for index := range req.Users {
user, err := c.service.Create(ctx, tx, &req.Users[index])
if err != nil {
return httpx.Response[[]*entity.User]{}, err
}
users = append(users, user)
}
return httpx.Response[[]*entity.User]{
Body: users,
Options: httpx.ResponseOptions{Status: http.StatusCreated},
}, nil
}app.Route("POST", "/user/batch", (*UserController).CreateBatch)排除读取请求
如果不希望在读取请求中启动事务,请在 PreHandle 的开头添加以下条件。BeforeResponse 也必须处理不存在事务的情况,上面的实现已经包含该处理。不要将使用事务的控制器列入排除范围。
if ctx.Method() == "GET" || ctx.Method() == "HEAD" || ctx.Method() == "OPTIONS" {
return nil
}验证与运行
gofmt -w src/interceptor/tx_interceptor.go src/user/user_create.go src/user/user_batch.go src/user/user_repository.go src/user/user_route.go internal/application/application.go
go mod tidy
go mod download all
spine-cli doctor
spine-cli inspect
spine-cli contract check
spine-cli verify
spine-cli dev契约检查和本地检查无法确认实际数据库中的提交与回滚。运行应用需要完成数据库教程中的 DATABASE_URL 设置和迁移。下一步将检查成功请求的数据保存和失败请求的回滚。如果提交或回滚本身失败,相应的错误处理需要另行检查。
在实际数据库中检查
按照数据库教程执行迁移并启动应用后,在另一终端中发送以下请求。示例中的 GET 假设使用新建的测试数据库,且第一个用户的 ID 为 1。如果使用现有数据库,请根据 POST 响应中的 ID 修改 GET 路径。
第一个 POST 会保存用户并返回 HTTP 201,随后使用响应中的 ID 发起 GET 请求,会返回同一用户。/user/batch 请求会因第二个邮箱重复而失败,返回 HTTP 500。由于数据库错误以普通错误返回,响应不会暴露内部 SQL 错误。
curl -i -X POST http://localhost:8080/user \
-H 'Content-Type: application/json' \
-d '{"name":"Alice","email":"alice@example.com"}'
curl -i http://localhost:8080/user/1
curl -i -X POST http://localhost:8080/user/batch \
-H 'Content-Type: application/json' \
-d '{"users":[{"name":"Rollback","email":"rollback@example.com"},{"name":"Duplicate","email":"alice@example.com"}]}'
curl -i -X POST http://localhost:8080/user \
-H 'Content-Type: application/json' \
-d '{"name":"Bob","email":"bob@example.com"}'在数据库客户端中,确认以下 SELECT 的结果分别为 1 和 0。如果第二个结果为 0,说明失败前执行的第一个 INSERT 也已回滚。最后的 Bob 请求使用不重复的邮箱,因此应再次保存成功并返回 HTTP 201。
SELECT COUNT(*) FROM users WHERE email = 'alice@example.com';
SELECT COUNT(*) FROM users WHERE email = 'rollback@example.com';原子性边界
数据库提交、向消息代理发布事件、通过套接字发送响应,无法合并为一个原子事务。可能在发布事件后数据库提交失败,也可能在提交后响应发送失败。
如果需要同时保证数据库变更和事件记录,请在同一个数据库事务中保存 outbox 记录,再通过独立的投递任务发布事件。重发时也应使用相同的事件 ID,消费者需要根据此 ID 判断重复事件。
