架构决策记录 (ADR)
本文件记录 CoreC 关键设计决策的上下文、选择与后果,帮助接手者理解"为什么这么做"而非"怎么做"。 代码注释记录局部动机,这里集中记录跨模块、影响演进方向的取舍。 状态:
已采纳/部分采纳/已取代/待定。
ADR-001 六边形架构:core/ 零外部依赖
- 状态:已采纳
- 上下文:工业网关需同时支持多种南向协议(Modbus/S7/OPC UA)与北向传输(MQTT/HTTP),且要能作为库嵌入上层系统。若协议库与领域逻辑耦合,替换协议或被嵌入时牵一发动全身。
- 决策:
core/为纯契约层,只依赖标准库;driver/*、transport/*为适配器,依赖向内(适配器→core,反之禁止)。新协议通过init()工厂注册 +all/all.go空白导入聚合,零侵入扩展。 - 后果:协议替换/新增不影响引擎;
core接口是稳定契约,扩接口属破坏性变更(见 CONTRIBUTING §2.3)。代价是core不能认识协议特有概念,协议特有参数走Settings map[string]any。
ADR-002 规则匹配:优先级全局有序 + 统计完整性优先(驱动索引导出的受控扫描)
- 状态:已取代(原「纯线性扫描」方案已被驱动索引取代;两条不变量保留)
- 上下文:
rule/engine.go的Match曾对每个数据点线性遍历全部规则。完全索引会破坏两个不变量:(1) 规则按优先级全局有序——分桶索引后跨桶无法保证首个命中是全局最高优先级;(2)ruleWrapper.Match在每次评估时更新 hit/miss 统计,索引跳过的规则将丢失 miss 统计。 - 决策:保留「优先级全局有序 + 统计完整性」两条不变量;在实现上,
SetRules用sort.SliceStable稳定排序(rule/rule_build.go:101),并把带driver == 'x'顶级约束的规则装入按驱动分桶的索引(rule/rule_build.go:115-133)。Match用双指针把「驱动桶 + 通用规则」两个有序列表在线合并成全局优先级顺序求值(rule/rule_match.go:38-75);被索引跳过的规则经RecordMiss显式记 miss(rule/engine.go:150-167),统计语义与线性扫描一致。 - 后果:单点评估成本从 O(全部规则) 降到 O(相关规则),同时优先级次序与 hit/miss 统计不变形。同优先级规则的求值顺序由构造序号 seq 决胜(配置顺序),通用规则不再隐式遮蔽同优先级的 driver 规则。无索引可用时(子规则引擎等)回退线性扫描(rule/engine.go:124-126)。
ADR-003 core.Logger / core.Metrics 端口注入
- 状态:已采纳(驱动侧注入已落地;引擎内部日志仍走全局
log/slog,见 ADR-008 的剩余范围) - 上下文:驱动与核心组件早期直接使用全局
log/slog与包级单例计数器,多实例嵌入时日志与统计混在一起、无法替换后端。core.Logger/core.Metrics端口先行定义以确立契约。 - 决策:
- 驱动侧注入(已落地):
AddDriver对实现了SetLogger/SetMetrics可选接口的驱动注入后端——Logger 注入log.DefaultLogger()(slog 适配器,log/logger_adapter.go:35),Metrics 注入引擎的共享 registrystats.driverMetrics(engine/driver_manager.go:70-78)。三个南向驱动经driverbase.BaseDriver持有 Logger(driverbase/driverbase.go:80-82);驱动发射的协议级指标经core.DriverMetricsProvider角色进入/metrics(engine/stats_collector.go:209-220)。 - 引擎统计去单例(已落地):早期
statistic.Manager包级单例方案已不存在;引擎统计集中在statsCollector角色结构(engine/stats_collector.go:20-56),含原子计数器、窗口化速率(metrics.RateWindow)与标注延迟直方图(labeledLatencyStore),每实例独立。
- 驱动侧注入(已落地):
- 后果:驱动可独立测试(注入 Noop/捕获后端)、多实例嵌入不共享计数器;驱动协议指标(modbus 异常码等)可流入
/metrics。引擎内部组件(scheduler/batcher 等)仍直接调用全局slog——多实例嵌入时引擎日志无法按实例隔离,剩余范围由 ADR-008 跟踪。
ADR-004 OfflineBuffer 崩溃安全写入(fsync + 目录 fsync + 原子 rename)
- 状态:已采纳
- 上下文:传输中断时数据落盘缓存,恢复后补传。若写入不崩溃安全,断电可能丢失已"落盘"数据或留下半写文件。
- 决策:
engine/offlinebuffer.go采用 write→fsync(数据)→close→rename→fsync(目录) 的原子写入序列。目录 fsync 确保 rename 的目录项更新持久化,否则断电后文件内容持久但 rename 不可见。 - 后果:严格的崩溃一致性;在 journaling 文件系统(ext4 data=ordered/xfs)上目录 fsync 通常冗余但保留以求严格。代价是每次落盘两次 fsync,吞吐受限于磁盘——可接受,因 OfflineBuffer 仅在传输中断时启用。
ADR-005 断路器冷却期持续探活(间隔粗但不停止)
- 状态:已采纳
- 上下文:驱动/传输连续失败达阈值后进入断路器冷却,避免雪崩式重连。早期文档曾误述为"冷却期不探活"。
- 决策:
common/util/util.go的ReconnectLoopWithBreakerCounted在触发断路器后仅将 backoff 切换为固定 5 分钟,循环继续运行——每次 5 分钟后仍调用connect()探活,成功即恢复。即冷却期"有探活、仅间隔粗"。 - 后果:设备恢复后最坏需等约 5 分钟才重连成功。若场景需更快恢复,可改小
common/util的defaultCircuitBreakerBackoff常量(当前为包内固定值 5 分钟,非运行时可配)或改为递减间隔。当前工业场景 5 分钟可接受。
ADR-006 DataBus 弃旧策略(非阻塞、弃最旧)
- 状态:已采纳
- 上下文:生产者(驱动采集)快于消费者(worker 处理)时,DataBus 主通道会满。阻塞生产者会拖慢采集周期;丢弃新数据会丢失最新值。
- 决策:
engine/databus.go的Push用非阻塞 select,通道满时丢弃最旧的一条腾位给最新,并计pushDropped。最新值优先于旧值,符合工业监控"宁要新不要旧"的语义。 - 后果:消费者持续跟不上时旧数据被弃,
pushDropped反映背压程度。控制类指令不走 DataBus(走独立 commandCh),故弃旧不影响控制。未来若在 DataBus 承载控制反馈流,需为该流提供独立的背压策略。
ADR-007 Modbus 批读合并(贪心合并连续/重叠地址,上限 125 寄存器)
- 状态:已采纳
- 上下文:Modbus 单次读最多 125 寄存器(协议限制)。配置中相邻地址的 tag 若逐个读取,RTT 累积大。
- 决策:
driver/modbus的performBatchReads(modbus_read.go:382)按 area 分组、按起始地址排序后,用mergeBatches(modbus_read.go:516)贪心合并连续或重叠地址到同一请求,单请求不超过modbusMaxBatchSize = 125(modbus_base.go:47,经Capabilities().MaxBatchSize生效);批量读失败(重试后)的 tag 回退逐个读。 - 后果:大幅减少 Modbus RTT,提升高频采集吞吐。合并只针对可合并的连续/重叠地址,离散地址仍独立读。
ADR-008 引擎内部日志未走 core.Logger 端口(驱动侧已注入)
- 状态:部分采纳(驱动侧注入已完成,见 ADR-003;引擎内部改造仍延迟)
- 上下文:
core.Logger端口与 Noop 实现早已定义,驱动已通过SetLogger收到注入的 Logger;但引擎内部(scheduler/batcher/discovery/transport_manager 等角色)仍直接调用全局log/slog。 - 决策:引擎内部改造延迟实施。原因:(1) 当前引擎包内非测试代码有约 113 处
slog.X调用,分布在 17 个文件(runtime.go/scheduler.go/discovery.go/transport_manager.go/batcher.go/offlinebuffer.go/write_manager.go 等);(2)core.Logger接口签名(Debug/Info/Warn/Error(msg string, args ...any))与slog的 KV args 语义一致(SlogLogger适配器已存在),真正的工程量在于把 Logger 实例穿过 9 个角色结构的构造与转发链;(3) 引擎日志当前统一经log.Init安装的 ObservableHandler 进入事件总线,改造的收益(按实例隔离)只在多实例嵌入场景兑现。 - 后果:多实例嵌入时引擎内部日志无法按实例隔离(已知技术债);驱动侧已无此问题。端口契约稳定(core.Logger 不需变更),未来注入时只需改引擎内部接线。
ADR-009 端到端延迟追踪:pipeline 直方图已实现,OTLP 导出与 trace 贯穿延迟
- 状态:部分采纳
- 上下文:端到端延迟追踪要求,包含三部分:(1)
corec_pipeline_latency_seconds直方图;(2) OTLP 导出接 Jaeger/Tempo;(3) DataPoint 透传 trace context。 - 决策:
- pipeline 延迟直方图:已实现。数据年龄以
corec_data_age_seconds记录 Publish 时刻与DataPoint.Timestamp的差值(采集到发布的端到端延迟)。观测点覆盖:批量路径在publishWithRetryAndBuffer内每次尝试、逐点观测(engine/batcher_retry.go:34-39,重试期间年龄增长被如实记录);直发路径在publishToTargets观测(engine/processing.go:347-348);fallback 直发路径在tryFallback观测(engine/transport_manager.go:313-315)。直方图由labeledLatencyStore持有:全局聚合 + 按传输标注两份(engine/labeled_latency.go:105-108),经core.LabeledLatencyProvider角色暴露(route 层优先读取标注版,无角色时回退core.LatencyProvider/core.DataAgeProvider的无标注版,hub/route/metrics_engine.go:190-208)。读取延迟同理按驱动标注(corec_read_latency_seconds{driver=...})。 - OTLP 导出:延迟实施。当前
common/trace的 span 仅以slog.Debug记录(debug 关闭时为共享 noopSpan,common/trace/trace.go:92-95)。落地 OTLP 导出属运维基础设施配置(部署 Jaeger/Tempo + 配置 OTLP endpoint),非代码架构变更。 - DataPoint trace context 贯穿:延迟实施。在
core.DataPoint增加 trace 字段属 core 契约变更(虽为新增可选字段、非破坏性,但需评估序列化影响和 hexagonal 边界)。当前 trace 主要在 HTTP 入口(hub/route经trace.Middleware解析 traceparent 并生成 W3C trace ID),未从采集端贯穿到 Publish 端。
- pipeline 延迟直方图:已实现。数据年龄以
- 后果:用户最关心的"数据有多新鲜"已可通过
corec_data_age_seconds直方图量化回答(p50/p99/p999,含按传输细分)。全链路 trace 可视化需待 OTLP 基础设施部署。
ADR-010 规则引擎 continue 标记:多规则匹配(iptables 风格)
- 状态:已采纳
- 上下文:原规则引擎采用首次匹配即生效(first-match-wins)语义——
Match返回单个*RuleMatchResult,第一条命中规则执行后立即停止。这无法表达"告警后继续正常转发"等复合场景:要么用 alert 规则"吃掉"数据点(下游收不到),要么不告警。用户需在一条数据点上触发多个动作(告警 + 转发、变换 + 路由、多级告警),但旧语义下只能选一个。 - 决策:为
RuleConfig增加Continue bool字段(YAMLcontinue,默认false)。Match返回类型改为[]*RuleMatchResult——引擎按优先级遍历,命中规则加入结果集,若!r.Continue()则终止并返回全部结果,否则继续评估。engine/processing.go逐条执行结果集中的动作;transform动作对point的修改对后续规则的发布数据可见(但不影响匹配,见下)。配置校验拒绝drop+continue: true(丢弃意味着停止,继续评估矛盾)。 - 后果:
- 向后兼容:
continue默认false→ 所有规则均为终止规则 → 结果集至多 1 个元素 → 与旧的 first-match-wins 完全等价。现有配置无需修改。 - 接口变更:
core.RuleEngine.Match签名从*RuleMatchResult改为[]*RuleMatchResult,core.Rule接口新增Continue() bool方法。core.RuleManager(hub 使用的子集)不含Match,故 hub mock 不受影响。 - 统计语义:miss 统计基于"最后一条终止规则的优先级"——若所有命中规则均
continue: true(无终止),则所有其他驱动的规则记 miss;否则仅优先级低于终止规则的规则记 miss。 - 性能:
continue: true规则增多时单点评估次数上升,但工业场景规则数小(<100),无感知。 - 局限性(transform 不影响后续匹配):
Match()对所有规则求值时使用的是原始DataPoint,transform动作在结果集遍历时才修改point——因此 transform 后续规则的发布数据看到变换后的值,但后续规则的匹配条件已在原始值上求值完毕。例如transform(tag: temp_c→temp_f, continue: true)后跟match: tag == 'temp_f'不会命中。这是"批量匹配 + 事后执行"设计的固有取舍:将匹配与执行交错可解决,但会模糊规则引擎与处理流水线的边界并绕过驱动索引优化。当前设计支持所有常见场景(alert+forward、多级告警、transform+兜底转发),transform-then-conditional-route 可用match: ALL规避。
- 向后兼容:
ADR-011 CORS 默认维持 *(直连形态兼容 + 文档引导收紧,方案 A+C)
- 状态:已采纳(2026-10-17 项目所有者批准方案 A+C;零行为变更,纯文档 + 配置样例注释)
- 上下文:
hub/route/middleware.go的corsMiddleware在未配置global.api.allowed-origins(core/engine.goAPIConfig.AllowedOrigins,yaml 键allowed-origins)时输出Access-Control-Allow-Origin: *,并放行方法GET, POST, PUT, PATCH, OPTIONS与头Content-Type, Authorization;代码注释明示意图是浏览器 Dashboard "can connect out of the box"。这是刻意设计而非疏漏,且不是无认证裸奔——配套防线全部在位(均经实测核实):- 认证恒在:全部业务端点位于 auth group 内(未认证面仅
/、/version、/healthz/*探针);空 secret fail-closed;token 与 secret 先sha256定长化再hmac.Equal恒时比较。无 token 的跨域页面只能拿到 401。 - token 传递面收窄:HTTP 只认
Authorization: Bearer;?token=仅允许 WS upgrade(浏览器 WS API 不能设自定义头);safeRequestLogger对 query token 脱敏。 - 限流不信任转发头:按直连
RemoteAddr令牌桶限流,注释明示X-Forwarded-For可伪造、trusted-proxy 支持须显式 opt-in。 - 无 cookie:凭据走 header,浏览器不会自动附带——经典 CSRF(cookie 自动携带)模型不适用。
- WebSocket upgrade 的 Origin 校验(
hub/route/common.go)与 CORS 共用同一 allowlist,未配置时同样放行。 Dashboard 有两种已文档化的部署形态:同源反代(Dashboardserver.mjs把/corec-api/*反代到 CoreC,注释明言 "Eliminates CSP, CORS, and port-blocking issues")不依赖宽松 CORS;直连模式(静态 SPA——如 GitHub Pages——直接访问远程 CoreC,Dashboarddeploy.yml明确描述该架构)必须允许跨域,否则浏览器无法直连后端。收紧默认值会让直连形态开箱即坏。 真实残余风险是"token 泄露一次即放大为跨域可读写":带Authorization头的请求在 CORS 语义里不算 credentialed request,*下响应体可被任意网页读取——比"只能写不能读"更宽。早期审计文档曾把该默认表述为"开箱即用但裸奔",不准确,以本 ADR 为准修正。
- 认证恒在:全部业务端点位于 auth group 内(未认证面仅
- 决策:采 A + C——维持默认
*(方案 A),叠加文档加固(方案 C):在config.example.yaml、docs/config/global.md与docs/guide/installation.md写明"何时应收紧"。收紧方式即global.api.allowed-origins配置:一旦列出来源,中间件仅对列表内来源反射Origin(限制模式,WS 同步收紧),未配置则维持*。备选评估:B(默认收紧为同源)属破坏性变更,直连形态开箱即坏——若确认直连仅用于受控内网/演示,按流程另立 issue 显式评估;D(trusted-proxy 按真实 IP 限流)为独立议题,不与本决策绑定。 - 后果:直连形态保持开箱可用;生产公网直连部署应把 Dashboard 域名配入
allowed-origins;同源反代部署天然无 CORS 需求——生产建议优先同源反代形态。残余风险(token 泄露即跨域可读写)依赖认证 + TLS + 高熵 secret + allowlist 的组合缓解,文档已同步引导。
新增决策请追加至本文件,遵循"上下文 / 决策 / 后果"三段式,并标注状态。