- Published on
日志和架构测试守的是同一件事:让出问题时查得动、让边界不被悄悄打穿
- Authors

- Name
- Jack Qin
日志和架构边界,表面上是两件不相干的事——一个关乎"线上出问题怎么查",一个关乎"模块怎么不被打穿"。但把它们放在一起看,会发现它们守护的是同一个东西:系统在不断改动中的可治理性。 日志保证出问题时你查得动,架构测试保证模块化单体在持续改动中不被悄悄溶解。两者都不是"写代码"本身,而是"让代码长期可控"的护栏。
这篇想拆的是两个判断:日志该记在哪里、不该记什么,以及为什么模块边界必须靠架构测试强制、而不能只靠评审。后一条尤其反直觉——很多人觉得边界靠 code review 盯着就行,但评审是有记性的人做的,而记性会随项目增长稳定地失效。
日志:在哪记、记什么、绝不记什么
日志的两个极端都很常见,而且都让系统失去可治理性。一边是"日志太少",真出了线上问题,日志里只有一句没头没尾的报错,根本无从下手。另一边是"日志太脏",有人图省事把整个请求体、甚至 auth cookie 和 token 都打了出来——这既是噪音,淹没真正有用的信号,又是安全隐患,把敏感数据写进了一个通常防护更弱的存储。
后端在 API 和 Worker 两个 host 都用 Serilog 做结构化日志:
- 两个 host 都尽早 bootstrap Serilog。
- API host 启用请求日志(
UseSerilogRequestLogging())。 - 异常集中记录。
- 模块/job 代码用
ILogger<T>。 - 日志输出要帮 operator 调试生产问题,同时不泄漏敏感数据。
host 级日志:API host 在构建 app 前创建 bootstrap logger,从配置和 service 配置 Serilog,启用请求日志。Worker host 在构建 host 前创建 bootstrap logger,从配置和 service 配置 Serilog,并在退出前记录 fatal 级启动失败。
"在边界处记录"为什么是关键判据:日志的价值不在于"记得多",而在于"记在 operator 会去看的地方"。系统内部纯逻辑的每一步都记下来,只会制造噪音;真正需要日志的是边界——那些一旦出错就跨越了你视野的地方。所以在模块代码、job、consumer、service 里用 ILogger<T>,并优先记录这些点:
- 在边界处记录:job 执行、集成调用、意外失败、迁移失败、启动生命周期。
- 优先用结构化模板,而不是字符串拼接。
- 包含有助于关联工作的标识符,如 schedule ID、user ID、asset ID 或模块名。
- 让全局异常处理器或 host 级错误处理拥有真正未处理的异常(而不是在每一层重复捕获记录)。
// 结构化模板 + 关联标识符
_logger.LogInformation(
"Processing email schedule {ScheduleId} for site {SiteId}",
schedule.Id, schedule.SiteId);
结构化模板不只是格式偏好——{ScheduleId} 这样的占位符让日志后端能按字段索引和查询,而字符串拼接出来的日志只能全文搜索。关联标识符则是让你能把分散在多处的日志串成一次完整的工作链路。
敏感数据规则——不要记录:
- 密钥或凭据
- 原始 auth cookie 或 token
- 个人敏感数据,除非业务需求明确且已审批
- 默认就把整个请求体打出来(只因为加日志很容易)
最后一条点破了一个常见心理:加日志太容易了,容易到大家会"顺手"把整个对象倒进去。但记录失败时,正确的做法是优先用稳定标识符和高信号上下文,而不是把一切都倒出来——倾倒一切既泄漏敏感字段,又用噪音稀释了真正的信号。
错误日志:未处理的 API 异常由全局异常处理器规范化——服务端记录异常,返回经净化的 ProblemDetails,只在开发环境包含额外异常细节。这条同时守住了两端:operator 在服务端能看到完整异常,客户端拿到的却不含内部状态。
模块边界:为什么靠评审记性一定守不住
模块化单体的全部价值都压在那条边界上。一旦有人加了 Api -> Worker 的直接依赖,或者往 BuildingBlocks 里塞了一个具体基础设施依赖,边界就开始溶解,改一处波及一片。
这里有个关键判断:这种边界违规,光靠评审是盯不住的。 原因很结构性——一次违规可能只是某个 .csproj 里多了一行 ProjectReference,藏在几百行 diff 里,评审者很容易划过去。而即便这次抓住了,下一次、再下一次呢?评审依赖人的记性和注意力,而这两样都会随项目变大、人员变动而稳定地失效。边界约束如果只活在评审者的脑子里,它的失效只是时间问题。 真正能守住的,是让架构测试把这些约束变成 CI 里的红灯——机器没有记性问题,也不会划过 diff。
后端质量靠构建规则、架构边界、自动化测试和 host/模块分离来保证。当前信号:.NET 10 + 共享构建配置;通过共享构建属性把警告当错误;架构测试保护项目边界;host 和模块测试覆盖端点与集成行为;Testcontainers 是常规后端测试的一部分。
必须遵守的模式:
- 尊重模块边界:用
ModuleRegistration.cs作为模块 service 注册和端点映射的一致入口;API host 关注点留在Platform.Api,Worker host 关注点留在Platform.Worker;跨模块通信走 Contracts 项目和消息抽象。 - 端点要薄:聚焦 HTTP 关注点、权限和路由本地的持久化编排。
- 用对测试层:架构测试 / host 集成测试 / 模块测试 / API 契约测试,选能证明行为的最小层。
明令禁止的模式(其中几条由架构测试强制):
- 引入
Api -> Worker或Worker -> Api的直接依赖。 - 往
BuildingBlocks里加具体基础设施依赖。 - 用直接实现引用绕过模块契约。
- 创建横跨模块的通用 Repository 层。
- 把长期业务规则只放在 Program/bootstrap 代码里。
最后这条容易被低估:业务规则塞进 Program.cs 不只是位置不对,而是它既测不到、又不属于 bootstrap。bootstrap 代码的职责是接线,长期业务规则放进去会让它无法被模块测试覆盖,也让规则散落到一个谁也不会去找它的地方。
评审清单(评审仍然有用,只是不再独自承担守边界的责任):改动是否留在拥有它的模块边界内?跨模块是否用 Contracts 而非实现引用?写入用 EF Core、Dapper 留在查询/读路径了吗?schema 变了,迁移和受影响的 SQL/测试一起更新了吗?选的测试层合适吗?改动在生产启动和 Testcontainers 测试里都正确吗?
反例
// 反例 1:把整个请求体 + token 打进日志
_logger.LogInformation("Request: {Body}", JsonSerializer.Serialize(request)); // ❌ 含敏感字段
_logger.LogDebug("Auth cookie: {Cookie}", httpContext.Request.Cookies["app-auth"]); // ❌
// 正确:只记录稳定标识符和高信号上下文
_logger.LogInformation("Login attempt for user {UserId}", userId); // ✅
// 反例 2:ad hoc 控制台日志,而 Serilog + ILogger<T> 已就位
Console.WriteLine($"Job failed: {ex.Message}"); // ❌
// 正确:用注入的 ILogger<T>
_logger.LogError(ex, "Email schedule job {ScheduleId} failed", scheduleId); // ✅
// 反例 3:Api 直接依赖 Worker —— 架构测试会红
// Platform.Api 项目引用 Platform.Worker ❌
// 正确:跨 host/模块通信走消息抽象(IEventPublisher / ICommandDispatcher)
// 反例 4:把业务规则只塞进 Program.cs
// Program.cs 里写了一大段排程业务规则 ❌ 测不到、也不属于 bootstrap
// 正确:规则放进拥有它的模块,bootstrap 只做接线
其它要避免的:不要在 Serilog/ILogger<T> 已就位时加 ad hoc 控制台日志;不要默认记录敏感请求数据;不要在多层重复同一条失败日志(除非每层都加了独有上下文);不要在开发环境之外向客户端返回原始异常细节;不要加破坏模块化单体边界的跨模块捷径;不要忘了 API host 启动会自动跑迁移而测试可能跳过;不要把基础设施关注点塞进共享抽象。
落地建议
- 日志记在边界:job 执行、集成调用、意外失败、迁移失败、启动生命周期——这些地方是 operator 查问题的抓手。
- 结构化模板 + 关联 ID:用
{ScheduleId}而不是字符串拼接;带上能串起一次工作的标识符。 - 敏感数据零容忍:密钥、token、原始 cookie、整请求体默认都不打;失败时给稳定标识符,不要倾倒一切。
- 让架构测试当守门人:
Api -> Worker、BuildingBlocks依赖具体基础设施这类违规,靠测试在 CI 红掉,而不是靠评审记性。 - 警告即错误:共享构建属性把警告当错误,别养成"警告无所谓"的习惯。
- 业务规则归模块:长期规则放进拥有它的模块,bootstrap 只负责接线。
可迁移的那一层
抛开 Serilog 和架构测试的具体工具,日志和边界共享同一个可迁移的认知:一条只活在人的自律或记性里的约束,会随系统增长稳定地腐烂。 "记得别打 token""记得别加跨模块依赖"——这些靠记性维持的纪律,在第一次有人忘记时就破了,而总会有人忘记。
让约束长期成立的办法,是把它从"人要记得"降级成"机制会检查":敏感数据靠结构化日志的字段选择来避免倾倒,边界违规靠架构测试在 CI 里红灯。每当你写下一条"大家记得要……"的规范时,值得反问一句:这条约束有没有一个不依赖记性的执行点? 找不到执行点的规范,写得再清楚也只是延缓腐烂,而非阻止它。