Files
go-omni/DEVELOPMENT.md

15 KiB
Raw Permalink Blame History

通用业务开发指南(v0.14.0-preview)

从 README 创建服务后,业务主要维护 .api、配置、svc.Init、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。

返回数组、可空对象和 null

@handler list
get /items returns ([]Item)

@handler find
get /item returns (*Item)

@handler save
post /item returns (null)

@handler remove
delete /item
契约 Logic 返回 成功数据
returns (Item) (*types.Item, error) 对象;nil 被视为实现错误
returns ([]Item) ([]types.Item, error) 数组;nil 统一输出 []
returns (*Item) (*types.Item, error) 对象或 null
returns (null) error HTTP 200 和空业务数据
不写 returns error HTTP 204,无响应体

统一响应中的空数据是否显示,仍遵循项目模板的 dataMode;需要 data:null 就设置 dataMode: always。@response raw 输出业务数据本身,包括 []、对象或 null。可用 @status 201/202;204 不能声明响应类型。根类型当前支持以上四种形式,嵌套字段继续支持既有 slice/map/pointer/scalar/any。

契约驱动的请求策略

入口文件可以声明默认策略,单个接口按需覆盖:

@app (
    defaultResponse: CommonResponse
    request: {"unknownQuery":"reject", "utf8":"reject", "charset":"utf8"}
)

@handler create
@request (body: required)
post /items (CreateReq) returns (Item)

@handler touch
@request (body: emptyObject)
post /touch returns (null)
  • unknownQuery: reject|ignore:是否拒绝未声明的 URL 查询字段;重复标量仍拒绝,数组用重复的同名 query。
  • utf8: reject|replace:拒绝原始非法 UTF-8,或采用 JSON 的替换字符行为。不是只检查解码后的字符串。
  • charset: utf8|any:utf8 仅接受未声明 charset 或 utf-8;不会对其他编码做转码。
  • body: auto|required|optional|none|emptyObject:auto 按 JSON 字段推断;required 要求一个对象;optional 允许完全没有 body,但提供 body 后仍完整校验;none 禁止任何 body 字节;emptyObject 必须是 {}。

未设置的项继承 App 的 config.Config.Request,最后采用既有默认行为。.api 的 @app 显式字段优先于项目配置;路由 @request 优先于 App。raw/multipart 使用各自解析流程,不能套 JSON body 策略。普通 JSON 只解析一次;重复/未知 JSON 字段、约束、scalar 和 any 仍使用同一编译器。自定义 binder/manual Handler 的输入逻辑由项目显式负责。

没有 body 与提供 {} 不等价。optional 无 body 时 JSON 字段保留零值;需要业务区分是否出现某字段时使用 optional 指针字段。raw/multipart 不继承 App 的 JSON body 策略;GET/HEAD 如遇 App 的 required/optional/emptyObject 默认值,须明确覆盖为 auto 或 none。参数校验不会替代企业权限、记账规则或数据库约束。

声明式 multipart

type UploadReq {
    Title string `multipart:"title" binding:"required,max=80"`
    Metadata AttachmentMeta `multipartJSON:"metadata,maxBytes=16384"`
    Attachment File `file:"attachment,maxBytes=10485760"`
    Extras []File `file:"extras,optional,maxFiles=3,maxBytes=1048576"`
}
type AttachmentMeta {
    Category string `json:"category"`
}

@handler upload
@body (mode: multipart; maxBytes: 16777216; maxParts: 8; maxFieldBytes: 65536)
post /attachments (UploadReq) returns (AttachmentResp)

Logic 直接得到 *types.UploadReq,无需维护 Handler。form 仍表示 URL query;multipart 表示文本表单字段,可用基础类型、文本 scalar 和重复 scalar 数组;multipartJSON 是一个严格解码的 JSON 表单字段,可包含对象、数组和已声明的 scalar。

File 提供 Name、ContentType、Size、Present() 和 Open() (io.ReadCloser, error)。文件名和媒体类型属于客户端元数据,项目继续决定内容检测、存储权限和目标对象名。不要把客户端文件名直接用作存储路径。

reader, err := req.Attachment.Open()
if err != nil { return nil, err }
defer reader.Close()
// 将 reader 传给项目选定的流式存储接口;不要先 io.ReadAll 整个大文件。

所有声明字段、重复/未知字段、文件数量和各级限额都会在进入 Logic 前校验。缺少必填项、重复标量/JSON 字段、文件与文本类型混用、畸形 JSON 均失败。JSON 的类型、未知字段和重复 key 检查与普通 JSON 一致。

默认总量 1 MiB、最多 128 个 part、单文本/JSON 字段 64 KiB;单文件默认受总量约束,文件数组默认最多 16 个。@body 可设置 maxBytes/maxParts/maxFieldBytes/maxFileBytes;字段上的 maxBytes/maxFiles 再收紧对应限额。总量包括 MIME 头和结尾数据;传输编码的 part 被拒绝,避免字节含义不明确。

文件使用有界复制写入权限为 0600 的临时文件,文本/JSON 字段在各自限额内缓冲。生成 Handler 返回或 panic 时关闭打开的文件 reader 并删除临时文件;不能把请求的 File 留到异步任务使用。失败不会交付半个 DTO,也不会进入 Logic。临时磁盘容量、并发请求上限与 HTTP 超时由部署配置共同约束。

不声明 multipart 字段时,保留原来的 body *httpx.Body 流式入口,不创建临时文件。该方式适合特殊协议;项目应在不可撤销副作用之前完成校验并调用 body.Complete()。下载和原始回调继续使用 @produces / @body(mode: raw)。

完整业务配置的环境覆盖

生成项目默认执行:代码与 envDefault 默认值 → JSON/YAML 文件 → 全结构体环境覆盖 → 既有框架环境别名 → Validate。增加业务字段不需要另外写 os.Getenv。

type ClientConfig struct {
    Endpoint string        `json:"endpoint" env:"CLIENT_ENDPOINT,required"`
    Token    string        `json:"-" env:"CLIENT_TOKEN,required,secret,empty=reject"`
    Timeout  time.Duration `json:"timeout" envDefault:"3s"`
    Retries  []int         `json:"retries"`
    Enabled  bool          `json:"enabled"`
}

把字段加入项目 Config 后,默认环境名取 GO_OMNI_ 加 JSON 路径的 UPPER_SNAKE_CASE,如 GO_OMNI_CLIENT_TIMEOUT。显式 env 名是完整名称,可以使用自己的业务前缀。env:"-" 排除整个子树;json:"-" 字段只有显式 env 标签才参与。

duration 使用 250ms、3s;slice/map 使用 JSON,如 [1,3,5]、{"retry":"2s"}。支持嵌套、零/false、空数组、自定义 TextUnmarshaler。空环境值默认允许;可选择 empty=ignore 或 empty=reject。required 要求字符串非空、指针/集合非 nil;0 和 false 可以是合法业务值,范围由项目 Validate 决定。

手写项目可使用 config.LoadProject(path, defaults, config.EnvOptions{Prefix:"MY_APP_"}),或分别调用 ApplyDefaults/DecodeFile/OverlayEnv。读取失败不修改输入默认值;诊断不会打印输入值。不要主动打印含密钥的整个配置,或把底层解析错误展开到公开响应。Validate 不打开日志文件、数据库或存储连接;证书的可读性和握手信任在依赖初始化阶段验证。

依赖、日志与中间件

internal/svc/runtimeGen.go 持续由生成器维护。项目的 ServiceContext 嵌入 *lifecycle.Runtime,只在 Init(ctx) 中装配额外依赖:

func (c *ServiceContext) Init(ctx context.Context) error {
    client, err := NewClient(c.Config.Client)
    if err != nil { return err }
    c.Client = client
    c.OnClose(client.Close)
    c.Middlewares["RequireAccess"] = middleware.NewRequireAccess(client)
    return nil
}

每个资源初始化成功就登记清理;后续初始化失败自动逆序回滚。正常退出先排空 HTTP 请求,再逆序关闭依赖,最后关闭日志。关闭幂等,单个清理函数 panic/返回错误不会跳过其他清理;初始化 panic 清理后继续传播。项目清理函数仍须自行停止、等待后台任务并保证能返回,框架不能强制杀死任意 goroutine。

中间件用业务所需的小接口接收依赖,避免反向导入 svc:

// internal/middleware/accessFactory.go
package middleware

import (
    "context"
    "git.yuntuo.hk/k2safe/go-omni/httpx"
)

type AccessChecker interface {
    CheckAccess(context.Context, string) error
}
func NewRequireAccess(checker AccessChecker) httpx.Middleware {
    return func(next httpx.Handler) httpx.Handler {
        return func(c *httpx.Context) error {
            if err := checker.CheckAccess(c.Context(), c.Header("Authorization")); err != nil {
                return err
            }
            return next(c)
        }
    }
}

.api 声明对应 middleware 后,保留生成的初始 hook,并另建上述工厂;在 Init 用工厂替换 registry 的同名项即可。该包只依赖接口和 HTTP 抽象,不导入 svc,也不把数据库藏到 context 里。

生成入口为每个 App 注入独立 Logger,不修改进程默认 Logger。业务可用 c.Logger.InfoContext(...),或请求链中的 logx.InfoContext(ctx,...)。标准库/第三方需要 *log.Logger 时使用 c.Logger.Standard(slog.LevelWarn);适配器借用原 Logger,不负责关闭。手写多 App 用 omni.WithLogInstance(logger) 显式注入。

httpx.Metadata(ctx) 在业务的标准 context 中提供可信代理规则解析后的 ClientIP、HTTP Method 和路由模板;不放入凭据、完整 URL、查询或 body。业务 userId/tenantId 等仍由项目选择,框架不预设身份模型。

middleware.ContextErrors("REQUEST_CANCELED", "REQUEST_TIMEOUT") 可将原始取消/超时错误映射到项目错误目录;显式 APIError 优先,内部 cause 链保留。状态、业务码与提示仍在项目 Catalog 配置。

字段文档、格式化、Go 错误目录导出

type Item {
    // 企业名称
    Name string `json:"name" example:"示例企业"`
    State int `json:"state,options=0|1" example:"1" enumDescriptions:"[\"禁用\",\"启用\"]"` // 状态
}

字段前/同行注释同步到 Go 注释和 OpenAPI description。显式 description 标签优先;example 的基础字段值也检查类型和约束,枚举说明按声明值顺序填写。字符串 example 直接写内容,其他类型写 JSON。自定义 scalar 的示例不能替代其项目解码器验证;输入输出类型不同时,不把输入示例冒充输出示例。

go-omni api format --api api/service.api
go-omni api format --api api/service.api --check
go-omni api generate
go-omni api generate --check

格式化保留注释、token 和多行字面量,规范四空格缩进、空行、行首尾空白及相邻单行字段的类型/标签/行尾注释对齐;结果幂等。先校验完整契约,check 不写文件;导入的共享目录不被某个服务的格式化命令修改。DTO 按源 .api 输出 dto<Source><摘要>Gen.go,都属于 types 包,跨文件引用无需改包名。

Go Catalog 是错误的唯一来源时,显式导出文档元数据:

// 在项目自己的导出命令中;appErrors.Catalog 与 AppOptions 注册同一函数。
if err := omnierrors.ExportCatalog(os.Stdout, appErrors.Catalog()); err != nil {
    log.Fatal(err)
}
go run ./cmd/exportErrors > api/omni.errors.json
go-omni api generate

文件是派生产物,应纳入版本控制并由业务 CI 检查导出漂移。CLI 自动读取入口目录的 omni.errors.json,校验错误目录、合并状态/模板参数/具体示例到 OpenAPI;不会运行项目 Go,不创建另一份运行期注册器。带独立错误目录的路由继续使用自己的目录。没有路由级精确错误列表时,文档描述 App 目录的公共错误集合;特殊自定义 Encoder 的文档由项目显式扩展。

两个服务共享契约

workspace/
├── contracts/common/response.api
├── services/api/api/service.api
├── services/api/api/omni.imports.json
├── services/id/api/service.api
├── services/id/api/omni.imports.json
└── omni.workspace.json

两个服务的 omni.imports.json 可以分别声明:

{"roots":{"common":{"path":"../../../contracts/common","version":"1.0.0"}}}
import "@common/response.api"

只读取入口目录和显式声明的根。保留真实路径检查、symlink 越界拒绝、循环检测、32 层、64 文件、单文件 1 MiB/总量 8 MiB 限额;最多 16 个共享根。不会下载依赖或复制共享定义进服务。移动目录时显式更新 roots。

version 是项目管理的版本标签。生成文档记录每个文件 SHA-256,以及每个共享根所导入文件集合的摘要;可把文档中的 x-omni-contract-sources.shared.common.sha256 填入 root 的 sha256,固定输入集合,内容变化后必须显式更新。摘要算法是对“该根内相对路径→文件 SHA-256”的键排序 JSON 对象再做 SHA-256,未导入的文件不参与。

工作区清单:

{"services":[{"dir":"services/api"},{"dir":"services/id"}]}
go-omni workspace format --file omni.workspace.json
go-omni workspace generate --file omni.workspace.json
go-omni workspace check --file omni.workspace.json

各服务保持独立 module;可显式填写各服务的 api(相对服务目录)和 module。所有服务预检通过后再写;每个服务独立提交文件事务,不承诺跨项目的崩溃原子性。check 汇总格式与生成漂移;业务编译/测试仍在各 module 执行。

v0.13 项目升级

先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.14.0-preview(或显式引用当前源码)。

go-omni api upgrade --dir . --dry-run
go-omni api upgrade --dir . --preview .goOmni/upgradePreview
# 只有标为 safe 的原始模板才会自动替换;review 阻止全部写入。
go-omni api upgrade --dir . --apply
go mod tidy
go-omni api generate --check
go test ./...
go build .

自动迁移只替换与 v0.13 原始模板一致的配置/svc 基础文件、移除原始 close.go,并更新最低依赖及生成产物;保留 Logic、Repository、AppOptions、独立命令。迁移失败回滚已改的项目文件。存在业务修改时,仅输出这些基础文件的候选版本和清单,不生成整棵业务代码让人重新拼接。

定制项目按预览定点迁移:保留依赖字段,把初始化移到 Init,嵌入 Runtime,清理改为 OnClose 登记;移除旧 New/Close/OnClose;合并业务配置和结构化 MySQL 配置,更新 go.mod,再正常 generate。生成器会拒绝遗留构造/关闭实现,避免重复函数或悄悄绕过清理。v0.12 及更早的定制结构不能被精确自动合并,使用同一预览逐项迁移并编译验证。