# 公共字段、结构映射与生成选项 本页对应 `v0.18.0-preview`。CLI 与框架依赖固定到同一版本: ```sh go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview # 已有业务项目内执行: go get git.yuntuo.hk/k2safe/go-omni@v0.18.0-preview ``` 私有仓库访问设置见 [安装说明](README.md#准备环境与安装)。升级不会覆盖业务文件;已有中间件保持 handlers 模式,切换工厂模式按本页迁移步骤完成。 ## 一份项目生成配置 新项目根目录生成开发者维护的 `omni.gen.json`: ```json { "repository": "none", "middleware": "factories" } ``` 该文件与 `.api` 一起提交。每次 `api generate`、`--check`、`--dry-run` 和 `workspace generate/check` 都会读取;`api validate/export/format` 只处理契约,不加载业务 Go 类型。 - `repository: none` 是默认值,不创建空 Repository 文件。`group` 按 `.api` 的 group 补齐 `internal/repository//repository.go`;文件始终属于业务。切回 none 后不再补骨架,已有文件不删除、不覆盖。实际 SQL、ORM 和仓储划分由项目决定。 - `middleware: factories` 是新项目默认值,按下文自动初始化。`handlers` 保留原有 `Defaults()` 与 `func(next Handler) Handler` 接口,供既有项目逐步迁移。 - `mappings` 可省略;只有明确列出的类型对才生成映射。未启用时不会运行 Go 类型加载命令,也不增加运行时依赖。 - 升级首次遇到没有配置的旧 manifest 时,补一份 `middleware: handlers` 的配置,保留原有手写注册;Repository 默认 none。生成配置建立后不能直接删除,否则生成失败,避免误关闭映射或改变中间件模式。 ### 按模块拆分映射(v0.18 新增) 主配置可以保留少量内联 `mappings`,也可以只列出文件: ```json { "repository": "none", "middleware": "factories", "mappingFiles": ["mappings/requests.json", "mappings/responses.json"] } ``` 每个子文件只包含 `{"mappings": [...]}`,其中每条映射的写法与原来一致。先加载主配置内联定义,再按文件列表顺序加载;所有定义合并后统一校验,可以跨文件、向前引用 `mapping`。输出按映射名排序,文件拆分不改变函数名或语义。 - 路径相对项目根目录,必须是明确列出的 `.json` 文件。不展开通配符,不允许越界路径、符号链接、重复文件或多层导入;子文件不能覆盖 repository/middleware 等项目选项。 - 重名映射、指针辅助函数名冲突、未知映射与循环引用明确报错;文件之间不能用覆盖顺序隐藏冲突。重复定义会指出两个来源文件,字段生成错误也带来源文件。 - 文件均由项目维护,重新生成不改写它们。原来的单文件 `mappings` 继续有效。迁移时将完整条目移到子文件并删除原位置,不能复制后保留两份同名定义。 - 最多 64 个子文件、单文件 1 MiB、全部配置 4 MiB;合计仍最多 128 个映射。主配置和子文件一起提交 Git。 - `api generate`、`--check`、`--dry-run` 与 `workspace generate/check` 使用同一加载逻辑。缺少子文件或不完整配置立即失败,check/预览不写入;生成期间重新核对所有配置输入,发生变化时要求重试。 ## 公共字段组合 ```text type EmployeeFields { // 员工姓名。 Name string `json:"name" binding:"max=80"` Salary Money `json:"salary"` } type EnterpriseRequest { EmployeeFields } type AdminRequest { EmployeeFields CompanyID string `path:"companyId"` } ``` `Money` 由项目按既有 scalar 语法声明。组合行只有已声明的对象类型名,不加指针或字段 tag。公共对象可以通过已有相对 import 或声明过的共享根 `@common/...` 引入。 生成器展开为普通 Go 字段。JSON 保持平铺;字段的绑定来源、校验、注释、可空性和 OpenAPI 使用同一份展开结果。不会在 JSON 中增加 `EmployeeFields` 一层。重复 Go 字段、重复来源字段名、循环组合、未知对象和过深展开均失败;即使重复字段类型相同,也不能隐式覆盖。要改变某个字段的规则,应拆出更小的公共组合。 允许多层组合,上限 32 层、单类型 4096 字段、全契约展开 65536 字段。Go 匿名指针嵌入并非本功能;生成 DTO 中不会使用匿名嵌入。 ## 可选结构映射 `.api` 生成 DTO,业务独立维护输入/结果类型。项目明确选择需要自动赋值的类型对;不从数据库反向生成公开接口,不自动推断反向映射。 假设 `types.AdminRequest` 有 `Name string`、`Salary amount.Money`、`CompanyID string`,业务的 `domain.EmployeeInput` 有 `EmployeeName string` 和同类型 `Salary`: ```json { "repository": "none", "middleware": "factories", "mappings": [ { "name": "ToEmployeeInput", "source": {"package": "example.com/payroll/internal/types", "type": "AdminRequest"}, "target": {"package": "example.com/payroll/internal/domain", "type": "EmployeeInput"}, "fields": { "EmployeeName": {"source": "Name"} }, "ignoreSource": { "CompanyID": "由 Logic 单独做企业范围授权,不属于请求体输入" } } ] } ``` 执行 `go-omni api generate` 后,产生独立的生成文件 `internal/mapping/mappingsGen.go`: ```go func ToEmployeeInput(source types.AdminRequest) (domain.EmployeeInput, error) { var result domain.EmployeeInput result.EmployeeName = source.Name result.Salary = source.Salary return result, nil } ``` 包别名由生成器决定。业务调用 `mapping.ToEmployeeInput(*req)`;反向响应映射按另一条 source/target 规则声明。两个类型完全相同时通常无需映射。 ### 类型与覆盖检查 自动赋值要求同名且 **Go 类型一致**(类型别名按 Go 类型系统处理),字段名使用 Go 名称,不按 JSON 标签猜对应关系。int32 → int64、`*Money` → Money、数值 ID → string 都必须显式转换。不会暗中补零、解引用或执行数字强转。 每个源字段必须被使用或出现在 `ignoreSource`;每个目标字段必须被赋值或出现在 `ignoreTarget`。忽略规则必须给出非空原因,目标忽略明确意味着保留零值;规则引用不存在的字段也会失败。新字段若无法自动匹配,会在生成阶段报错。两边同时添加的同名同类型字段会自动加入生成代码,`--check` 检出未重新生成的差异。 根类型是项目可导入的具名结构;支持值类型匿名嵌入,规则可用 `Employee.EmployeeName` 这样的实际 Go 字段路径。同名提升歧义、未导出字段和匿名指针嵌入拒绝处理。普通具名嵌套字段使用独立映射或转换函数,不猜测内部结构。泛型声明不能直接作为根;具体字段类型按 Go 类型系统检查。 配置变化、遗漏映射、类型错误会在写入前失败。映射生成与其他生成产物使用同一套文件所有权机制;手改映射生成文件会被拒绝覆盖。 ### 显式转换函数 ```json "fields": { "Salary": { "source": "Salary", "convert": {"package": "example.com/payroll/internal/convert", "name": "SalaryValue"} } } ``` 支持这两种精确签名: ```go func SalaryValue(source *amount.Money) amount.Money func SalaryValue(source *amount.Money) (amount.Money, error) ``` 项目选择一种实现。nil、默认值、金额精度、日期格式和错误码由函数负责;若两边已经是同一个 Money 类型,无需再次解析或序列化。转换函数应仅负责值转换,不执行 SQL 或改变授权范围。 错误通过 `%w` 加上映射名/目标字段路径,保留 `errors.Is/As` 和框架错误链;返回零目标值,不交付部分完成的结果。包装不主动添加源值或转换后的值。错误是否公开仍由项目错误目录和响应策略决定。 ### 嵌套对象、数组与引用 `mapping` 指向同配置中的另一条映射,`elements` 明确要求逐项映射: ```json "fields": { "Summary": {"source": "Summary", "mapping": "ToSummary"}, "Items": {"source": "Items", "mapping": "ToItem", "elements": true} } ``` `mapping` 与 `convert` 互斥。`elements` 可以配合其中任意一种,也可以对类型相同的元素直接赋值;支持 slice→slice、等长 array→array,禁止隐式互转。nil slice 保持 nil,非 nil 空 slice 保持非 nil,逐项失败的错误包含索引,循环映射声明被拒绝。 未选择 elements 的同类型指针、map、slice 使用普通 Go 赋值,**共享引用**,不做隐式深拷贝。elements 会分配新的目标 slice,但元素内的引用仍遵循其映射规则。需要深拷贝时显式提供转换函数。 映射不保存原 JSON 的字段存在信息。普通 `*T` 在绑定后可能已把缺失和 null 合并为 nil;无法在转换阶段恢复区分。有三态需要的项目必须先使用能携带该信息的输入类型及绑定扩展;没有声明三态时,保持当前输入类型的语义。完整表单更新和局部更新由业务决定,框架不把 nil 自动解释成“保留旧值”。 ### 可空嵌套映射与显式 nil 策略(v0.18 新增) 假设已有 `ToChild` 定义 `types.Child → domain.Child`。父对象的字段规则可以直接复用它: ```json "fields": { "Child": {"source": "Child", "mapping": "ToChild"}, "Children": {"source": "Children", "mapping": "ToChild", "elements": true} } ``` | 源字段 → 目标字段 | 明确选择 mapping 后的行为 | | --- | --- | | `S → T` | 调用原值映射 | | `*S → *T` | 默认保留 nil;非 nil 调用值映射,并分配新的目标对象;也可配置 `nil: "error"` 拒绝 nil | | `S → *T` | 调用值映射,成功后返回目标对象指针 | | `*S → T` | 必须选择 `nil: "zero"` 或 `nil: "error"`,不猜默认值 | `nil: "preserve"` 只适用于可空源和可空目标。`nil: "zero"` 只适用于可空源和非空目标:nil 直接产生目标 Go 零值,**不调用子映射或转换函数**。`nil: "error"` 在 nil 时返回带映射/字段路径的普通转换错误,HTTP 公开响应仍由项目的错误处理策略决定;需要领域错误码时使用项目 converter。 同一规则适用于显式 `elements: true` 下的 slice/等长数组元素,包括 `[]*S → []*T`。nil slice、非 nil 空 slice、nil 元素分别保留;元素失败保留索引与内部错误链,整个映射返回零目标值,不交付部分结果。只支持这里列出的单层指针,不自动处理多层指针或递归对象图。 普通同类型指针字段未指定 mapping 时仍直接赋值、共享引用;上述显式映射会创建目标子对象,但不承诺对其中所有引用深拷贝。业务不可变快照继续由项目设计。 简单的 `*T → T` 也可以单独指定 nil 策略,减少重复解引用函数: ```json "fields": { "Title": {"source": "Title", "nil": "zero"}, "Count": {"source": "Count", "nil": "error"} } ``` 这里必须是完全相同的 Go 元素类型,`*int32 → int64`、枚举 → string、数字编号 → string 仍需显式 converter。非 nil 的零值正常保留。`convert` 与 `nil` 不能同时设置,现有 converter 继续完整控制自己的空值、格式和错误语义。 ### 可选指针返回辅助(v0.18 新增) 在映射条目上增加 `"pointerResult": true`,生成器保留 `ToResponse(source S) (T, error)`,另加: ```go func ToResponsePtr(source S) (*T, error) ``` Logic 在业务调用成功后可直接写 `return mapping.ToResponsePtr(result)`。转换失败时返回 `nil, err`,错误链不变;成功才返回非 nil 指针。业务调用本身的错误仍由 Logic 处理,辅助函数不执行任何业务操作。 辅助函数名称固定为映射名加 `Ptr`,与其他映射/辅助函数同名会在生成前报错。输入仍是原来的值类型 S,不用 nil 输入表达业务错误;关闭该选项会移除辅助函数,使用它的业务代码需同时调整。 ### 生成顺序与 CI 1. 先从 `.api` 生成项目,添加业务结构、scalar 和转换函数,执行 `go mod tidy`。 2. 在 `omni.gen.json` 列出映射,执行 `go-omni api generate`。 3. Logic 调用生成函数。后续改字段后重复生成,不修改 `*Gen.go`。 4. CI 同时运行 `go-omni api generate --check` 与 `go test ./...`。 启用映射后,CLI 通过 `go list -deps -export` 加载明确声明的包与依赖,按当前构建环境做类型检查,不执行业务代码、init 或转换函数。采用待生成 DTO 的临时 overlay,避免“新增字段还看旧 DTO”。go.mod/go.sum 的副本及 overlay 都在临时目录,check 不修改项目文件;Go 自己的构建/模块缓存可能更新。使用当前 GOOS/GOARCH/GOFLAGS、`GOWORK=off`;依赖用 go.mod 与 replace 表达,生成超时为 90 秒。 类型包/转换函数包不能依赖输出包 `internal/mapping`;把转换函数放在独立包中,避免导入环。若包本身无法编译,先运行 `go list -deps -export <相关包>` 排查;CLI 不回显可能含私有仓库凭据的 Go 下载输出。纯值字段转换只生成标准 Go 代码,不引入运行时反射搬运、JSON 往返、ORM 或队列依赖。 ## 中间件工厂自动注册 `.api` 沿用 `middleware: RequireAccess` 或 auth 对应的中间件声明。新项目会生成可编辑的 `internal/middleware/requireAccessFactory.go`,只需实现一次: ```go // internal/middleware/dependencies.go,由项目维护。 type AccessChecker interface { CheckAccess(context.Context, string) error } type Dependencies interface { AccessChecker() AccessChecker } // internal/middleware/requireAccessFactory.go,由项目维护。 func NewRequireAccess(deps Dependencies) (httpx.Middleware, error) { checker := deps.AccessChecker() if checker == nil { return nil, errors.New("access checker is required") } 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) } }, nil } // internal/svc/access.go,由项目维护;ServiceContext.Init 初始化 c.Auth。 func (c *ServiceContext) AccessChecker() middleware.AccessChecker { return c.Auth } ``` 编译器检查 ServiceContext 是否实现依赖接口,middleware 不导入 svc。多个工厂可以共用这些小接口。所需 imports 和具体鉴权服务仍属于项目。 启动顺序为:框架基础依赖 → `svc.Init` → 生成的 `middleware.New(c)` 调用各具名工厂 → 创建 App/注册路由 → 监听。中间件实例创建一次,由请求并发复用;不要把单次请求信息存在实例字段里。工厂之间没有初始化顺序依赖;路由执行顺序仍以 `.api` 声明为准。 工厂返回错误或 nil 中间件均阻止监听;错误链保留,已登记资源按现有生命周期清理。工厂 panic 也先清理依赖再传播。资源初始化和清理登记放在 svc.Init,工厂借用资源;HTTP 请求排空之前不会关闭这些依赖。 factory 模式不再在 Init 写 `Middlewares["..."]`;残留非空手写注册表会明确报错,防止生成注册静默覆盖它。未实现的工厂让启动失败;未实现的 Logic 仍返回 501。 ### 既有项目迁移 1. 首次更新保持 `middleware: handlers`,现有 hook 和手写 Configure 不变。 2. 改为 factories 并重新生成,会补 `dependencies.go` 和 `*Factory.go`,保留原来的手写文件。 3. 将旧实现迁入工厂,提供 Dependencies 的访问器;移除 Init 中按名字覆盖 map 的代码。 4. 旧函数不再需要时由项目删除。若新工厂符号与旧文件中的同名函数冲突,整理旧文件;生成器不覆盖业务代码。 5. `generate --check` 与 `go test ./...` 检查后再运行。工厂没有全部实现前,服务不会启动。 ## 文件维护边界 | 项目维护 | 生成器持续维护 | | --- | --- | | `.api` 公共字段与接口 | 展开后的 DTO、Handler、OpenAPI | | `omni.gen.json` 的映射与选项 | `internal/mapping/mappingsGen.go` | | Go 业务类型、scalar、转换函数 | 编译期可检查的赋值与调用代码 | | middleware Dependencies、具名工厂、svc 访问器 | 中间件 registry、启动调用 | | Logic、按需 Repository、配置、svc 初始化/清理登记 | main、runtime、路由接入 | 回归样例位于 `cmd/go-omni/apigen/testdata/composition`,为独立三端服务,不导入任何 eTax 代码。