# 通用业务开发指南(v0.19.0-preview) 从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。 公共字段组合、自动中间件工厂和 Repository 开关,见 [生成能力指南](GENERATION.md)。必要的结构转换使用可选 [copyx](COPY.md),v0.19 已移除旧 JSON 映射生成。 ## 返回数组、可空对象和 null ```text @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 ([]string)` | `([]string, error)` | 字符串数组 | | `returns ([]int64)` / `([]bool)` | `([]int64, error)` / `([]bool, error)` | 数值 / 布尔数组 | | `returns ([]any)` | `([]any, error)` | 动态 JSON 数组,元素允许对象、数组、标量和 null | | `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。 基础类型数组还支持 int/int8/int16/int32、uint/uint16/uint32/uint64、float32/float64。nil 根数组都输出 `[]`;`[]any` 在 OpenAPI 中是 `type: array, items: {}`,不限制元素形状,但元素必须可被标准 JSON 编码器序列化。`[]uint8`(Go 的 byte slice)会编码为 base64,故明确拒绝;数字数组可用 `[]int32`,二进制用下载接口。当前不支持根嵌套数组或根自定义 scalar 数组。 ## 契约驱动的请求策略 入口文件可以声明默认策略,单个接口按需覆盖: ```text @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 ```text 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)`。文件名和媒体类型属于客户端元数据,项目继续决定内容检测、存储权限和目标对象名。不要把客户端文件名直接用作存储路径。 ```go 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`。 ```go 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 不打开日志文件、数据库或存储连接;证书的可读性和握手信任在依赖初始化阶段验证。 ### 一次覆盖完整配置与 HTTP/日志短名称 ```go cfg, err = config.OverlayEnv(cfg, config.EnvOptions{ Prefix: "MY_APP_", Empty: "reject", // allow / ignore / reject,项目自行选择 Aliases: config.ServerEnvAliases("MY_APP_", "server"), // Lookup 可注入测试或项目配置源;默认 os.LookupEnv。 }) ``` 不要再紧接着调用 `ApplyEnv(cfg.Server, ...)`。上面的调用同时处理完整配置和 HTTP_ADDR、SERVICE、LOG_LEVEL 等框架短名称。例如 `MY_APP_SERVER_ADDRESS` 与 `MY_APP_HTTP_ADDR` 指向同一字段,**完整名称优先**;未设置时才读取短名称。完整名称显式设空仍优先:allow 交给该字段解析(string 可清空;数字等仍要求合法值),ignore 保留文件/默认值,reject 报错。不会因 ignore 再退回别名。字段 `env` 标签的 empty 规则优先于全局规则。 自定义别名用 `EnvOptions.Aliases`,键是完整环境名、值是按优先级排列的别名数组。两种名称都使用同一 Lookup、空值策略和类型转换;布尔 false、数值 0 与未设置不同。集合始终使用 JSON,包括 `MY_APP_TRUSTED_PROXIES='["127.0.0.1"]'`。 新项目模板一次调用 OverlayEnv,默认 `Empty: "ignore"`。旧 `ApplyEnv` 保留原有忽略空值、修剪空白及 TRUSTED_PROXIES 逗号列表行为。已有项目的 `internal/config` 属于开发者文件,升级不覆盖它;按上例合并原来的两次覆盖调用后,保留项目自己的 Validate。日志输出的显式完整名称也优先于短名称。 ## 依赖、日志与中间件 `internal/svc/runtimeGen.go` 持续由生成器维护。项目的 ServiceContext 嵌入 `*lifecycle.Runtime`,只在 `Init(ctx)` 中装配额外依赖: ```go 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) return nil } ``` 每个资源初始化成功就登记清理;后续初始化失败自动逆序回滚。正常退出先排空 HTTP 请求,再执行后台组件的 Stop/Drain 阶段,确认收尾后逆序关闭依赖,最后关闭日志。关闭幂等,单个清理函数 panic/返回错误不会跳过其他清理;初始化 panic 清理后继续传播。后台组件按 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭) 登记;无法确认收尾完成时保留共享依赖并异常退出,不能把取消等同于任务已经结束。 新项目使用中间件工厂:项目维护 `middleware.Dependencies` 小接口与 `New(deps) (httpx.Middleware, error)`,svc 提供对应访问器。生成的 runtime 在 Init 成功后自动调用工厂并注册,不再按名字替换 map;工厂失败会释放已经登记的依赖。示例与现有项目迁移步骤见 [中间件工厂](GENERATION.md#中间件工厂自动注册)。 v0.16 项目及 `omni.gen.json` 中选择 `middleware: handlers` 的项目继续使用原有 `Defaults()`,可以在 Init 设置 `c.Middlewares["RequireAccess"]`。更新时不会重写这部分业务代码;只有显式切换到 factories 后才使用新流程。 生成入口为每个 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 错误目录导出 ```text 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 的示例不能替代其项目解码器验证;输入输出类型不同时,不把输入示例冒充输出示例。 ```sh 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<摘要>Gen.go`,都属于 `types` 包,跨文件引用无需改包名。 Go Catalog 是错误的唯一来源时,优先使用 workspace 的显式导出配置,不再维护导出/复制脚本。AppOptions 仍通过 `omni.WithErrors(apierrors.Catalog())` 注册同一个 Go 函数,运行期只注册一次。单个项目也可继续使用 `errors.ExportCatalog` 导出入口目录的 `api/omni.errors.json`;此文件和下面的共享 source 二选一。 OpenAPI 自动把有收益的重复响应、schema 和示例移到 `components.responses/schemas/examples`,通过标准 `$ref` 引用。HTTP 200 同时含成功和业务失败时保留两种 schema、业务码、模板参数示例和 data:null;业务响应内容不会改变。消费 OpenAPI 的工具需要支持标准本地引用。 ## 两个服务共享契约 ```text 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` 可以分别声明: ```json {"roots":{"common":{"path":"../../../contracts/common","version":"1.0.0"}}} ``` ```text 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,未导入的文件不参与。 工作区清单: ```json {"services":[{"dir":"services/api"},{"dir":"services/id"}]} ``` ```sh 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 执行。 ### 同一份 Go 错误目录供多个服务生成 在 workspace 清单显式声明提供目录的 Go 包和函数: ```json { "errorCatalogs": [{ "dir": ".", "package": "example.com/project/internal/apierrors", "function": "Catalog", "output": "contracts/common/errors.json" }], "services": [{"dir":"services/api"}, {"dir":"services/id"}] } ``` `dir` 相对 workspace,必须包含 go.mod;函数签名为 `func Catalog() errors.Catalog`。允许 module 内的 internal 包。先准备 output 的父目录。生成器构建一个临时导出程序,使用 Go overlay、临时 go.mod/go.sum 和 `-mod=readonly`,不会往项目增加导出命令或修改 go.mod/go.sum。最多 16 个提供方,每个有 90 秒构建/执行总时限和 1 MiB stdout 限制。错误消息不回显程序输出。 只有显式运行 **workspace generate/check** 会执行清单指定的 Go 包及其依赖初始化;不要指向不信任的包。提供目录的函数应只构造目录,不连接数据库、启动服务、写 stdout 或修改项目。框架不扫描其他包,也不通过 shell 执行配置字符串。普通 api validate/export/generate 和 workspace format 只读取现有元数据。 各服务在自己的 `api/omni.imports.json` 引用共享 JSON: ```json { "roots": {"common":{"path":"../../../contracts/common","version":"1.0.0"}}, "errors": { "source": "@common/errors.json", "include": ["USERNAME_INVALID", "INTERNAL_ERROR"], "routes": {"GET /permissions":["INTERNAL_ERROR"]} } } ``` `include` 省略或 null 表示全部目录,`[]` 表示不列业务错误;routes 按 **大写方法 + 空格 + .api 原路径**(如 `GET /users/:id`)覆盖服务级列表。错误标识、路由拼错或重复标识会明确报错。路由已经通过 `.api @errors` 声明独立运行期目录时,不能再给它设置文档 routes 列表。 **这些列表只决定文档展示,不修改 App 错误目录、路由继承或运行期可返回的错误。** `.api` 不需要重复定义 Go 错误表。状态、业务码、提示和模板参数仍来自同一 Catalog;特殊自定义 Encoder 的文档由项目显式扩展。 ```sh # 首次生成导出一次共享目录,所有服务预检通过才写入 # 共享 JSON 不复制到各服务;已有 api/omni.errors.json 请显式移除后改用 source。 go-omni workspace generate --file omni.workspace.json go-omni workspace format --file omni.workspace.json go-omni workspace generate --file omni.workspace.json go-omni workspace check --file omni.workspace.json ``` 共享 JSON 应纳入版本控制。check 在内存重新导出并比较 JSON,然后检查所有服务;缺失或过期时失败,不更新项目文件。Go 构建缓存仍可能更新;显式提供方的自有副作用不属于生成器可隔离范围。业务 CI 仍执行各 module 的编译与测试。目录函数本身有变化时重新 generate,受控共享根的固定 SHA256 如需改变仍由项目显式更新。 ## v0.13 项目升级 先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.19.0-preview(或显式引用当前源码)。 ```sh 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 及更早的定制结构不能被精确自动合并,使用同一预览逐项迁移并编译验证。