Go 应该注意的问题
1. 循环依赖(Go 编译器直接禁止)
Go 不允许包之间出现 import 环(A→B→A 会编译失败),也没有前向声明。组织代码的第一原则是:依赖只能自上而下单向流动,底层永远不知道上层的存在。
项目依赖层级(箭头 = 允许 import 的方向)
main / bootstrap ← 顶层装配(初始化 DB/Redis/队列/校验器/定时任务)
│
routes · middleware · corn · queue ← 装配/入口层,引用 controller、service、core
│
controller (admin_ctl/web_ctl/iot_ctl) ← 只做参数校验 + 编排,引用 service、core
│
service (业务) → pipeline (领域内处理层) ← 业务逻辑,引用 model/schema/core/util
│
model · schema ← 数据定义(实体 + DTO),是业务的下游
│
core · util · plugin · config ← 基础设施/叶子,不 import 任何 app/* 业务包- 越靠下越稳定:
model/schema/config是叶子,被上层依赖却谁都不依赖,所以可空类型、请求/响应结构体、常量应放最底层,供各方共享而不引起环。 core/util/plugin不得反向引用业务:它们是通用底座,一旦被app/*依赖就自底向上锁死方向。- 同层之间也应尽量单向,避免横向互相引用。
破环三法(项目实例:IoT 处理流水线 pipeline)
pipeline 各子包构成单向 DAG,依赖只从右向左不可逆:
pipeline → rulechain → alert → online (store 为叶子旁路,被 rulechain 直接调用)
└──────────────→ iot_service → model/schema (流水线依赖元数据服务,元数据服务绝不反向依赖流水线)① 依赖下沉 / 单向化——把共同依赖的部分放到更底层,上层各自引用它,而不是彼此引用。 rulechain 直接 import online/alert/store 三个叶子子包,因为"它们不反向依赖 rulechain",所以天然无环、无需注入。
② 控制器编排(门面)——当一件事同时要"业务服务 + 流水线"两侧动手时,由上层控制器编排,绝不让 service 反向 import pipeline(否则成环)。 删除设备/告警需顺带清运行态,iot_service 不能引用 pipeline,于是 pipeline 只暴露门面函数,交控制器调用:
// pipeline.go —— 门面:service 反向引用本包会成循环,故由控制器编排
func StopRuntime(deviceId string) { online.ClearDeviceRuntime(deviceId) } // 删设备
func StopAlertRuntime(deviceId string){ alert.ClearRuntime(deviceId) } // 删告警历史
// 控制器里:先 pipeline.StopRuntime(id) 再 DeviceService.Del(...)③ 钩子回调注入(依赖倒置)——当底层确实需要在某事件发生时回调上层(方向与依赖相反)时,底层只定义"注册钩子"的入口和触发点,上层在 init() 里把回调注册进来,从而把调用方向反过来、打破 A↔B。 online(底层)判到设备离线时要收尾 alert(上层)的活动告警,但 online 不能 import alert:
// online/hooks.go —— 底层只留注册口与触发点,不认得上层
var offlineHooks []func(deviceId string)
func OnDeviceOffline(fn func(deviceId string)) { offlineHooks = append(offlineHooks, fn) }
// alert/alert.go —— 上层在 init 把自己的处理注册进去,方向恒为 alert → online
func init() {
online.OnDeviceOffline(closeOnOffline) // 回调用避免 online ↔ alert 循环依赖
online.OnDeviceClear(ClearRuntime)
}遇到循环依赖时的排查顺序
- 公共类型/常量是否该下沉到
model/schema/config(叶子层)? - 是否存在"上层被下层 import"——把该调用改成门面 + 上层编排(法②)。
- 是否本质是"下层要通知上层"——改成钩子/回调注册(法③)。
- 仍解不开,多半是包边界划分不当,按职责再拆一层(如本项目把
pipeline拆成online/alert/rulechain/store子包)。
另注:
go test因config包init()调用flag.Parse(读取-env)而无法直接跑,测试需绕开该初始化——这属于"初始化顺序"类耦合,与循环依赖一样要留意。
2. Gorm 零值不更新
GORM 默认对结构体做非零值更新,0 / "" / false 等零值字段会被忽略、写不进库,导致"想清零却清不掉"。 规避:业务上尽量避免用 0 表达有效值(如用状态枚举而非 0=某种含义),需要真正写 NULL / 零值时用 map 更新、Select(...) 指定列,或可空类型(见 x_null)。
3. 软删除
本系统统一用软删除,模型需同时具备 IsDelete(soft_delete.DeletedAt 标志位)与 DeleteTime 字段:
IsDelete soft_delete.DeletedAt `gorm:"softDelete:flag,DeletedAtField:DeleteTime" json:"-"`
DeleteTime x_null.Time `gorm:"default:null" json:"-"`参考 GORM 文档(混合模式):https://gorm.io/zh_CN/docs/delete.html#混合模式
4. 时间
时间字段统一用自定义可空类型 x_null.Time(详见 数据库null值),避免 time.Time 零值 0001-01-01 落库与序列化歧义。