Skip to content

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 只暴露门面函数,交控制器调用:

go
// 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:

go
// 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)
}

遇到循环依赖时的排查顺序 ​

  1. 公共类型/常量是否该下沉到 model/schema/config(叶子层)?
  2. 是否存在"上层被下层 import"——把该调用改成门面 + 上层编排(法②)。
  3. 是否本质是"下层要通知上层"——改成钩子/回调注册(法③)。
  4. 仍解不开,多半是包边界划分不当,按职责再拆一层(如本项目把 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 字段:

go
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 落库与序列化歧义。