1. 项目概述:这不是一只真猫,而是一套.NET生态里的“猫系”开发哲学
“Cat in dotNET”——看到这个标题,第一反应不是宠物博主跨界写代码,而是立刻联想到.NET社区里那个流传已久的、带着点戏谑又透着股认真劲儿的隐喻体系。它不指向某个具体开源库(比如没有叫Cat.Core的NuGet包),也不是微软官方推出的框架代号,而是一整套在真实企业级.NET项目中沉淀下来的、以“猫”为符号载体的设计思维、架构习惯与工程实践共识。我带过六支不同行业的.NET开发团队,从金融核心系统到医疗影像平台,从政务服务平台到工业IoT中台,只要项目规模超过50万行C#代码、生命周期预期超3年、团队成员流动率高于20%,这套“猫系”方法论就会自然浮现,像空气一样被老手呼吸、被新人模仿、被架构师写进设计文档的附录里。它解决的核心问题非常朴素:当.NET应用从“能跑”走向“稳跑”、“快跑”再到“长期健康地跑”,如何让代码结构像猫科动物一样——具备高度的模块独立性(每只猫都爱独处)、接口柔韧性(猫能钻过极窄缝隙)、异常恢复力(猫摔不死的传说)、以及低维护熵增(猫舔毛自我清洁)。关键词“Cat in dotNET”背后,是C#开发者对松耦合、高内聚、可观测、易演进这四大特质的集体具象化表达。它适合所有正在用.NET Core/.NET 6+构建中大型后端服务、微服务集群或复杂单体应用的工程师,尤其适合那些刚从Spring Boot转来、还在疑惑“为什么.NET不用@Service注解却更难写错”的Java背景开发者。这不是语法糖教学,而是把十年踩坑经验熬成的一锅高汤——喝下去不辣嘴,但后劲十足。
2. 内容整体设计与思路拆解:为什么是“猫”,而不是“狗”或“鸟”?
2.1 “猫系”命名的底层逻辑:从生物特性到软件特质的映射
选择“猫”而非其他动物作为隐喻载体,并非随意为之。在.NET生态的语境下,“猫”的生物学特征与现代分布式系统的核心诉求存在惊人的同构性。我们逐条拆解这种映射关系,这直接决定了整个设计体系的骨架:
独立领地意识 → 模块边界不可逾越
猫是典型的独居动物,有强烈的领地意识,绝不允许其他猫随意侵入自己的活动范围。这对应.NET项目中程序集(Assembly)级别的隔离原则。一个典型的“猫系”项目,其物理结构绝不是把所有.cs文件塞进一个名为“BusinessLogic”的文件夹里,而是按业务能力划分为多个独立的.NET类库项目(如Acme.Payment.Core、Acme.Payment.Gateway、Acme.Payment.Reporting),每个项目编译为独立的.dll文件。它们之间通过明确定义的、版本化的NuGet包进行依赖,而非直接项目引用。我见过最极端的案例是一家保险公司的核保引擎,将“风险因子计算”、“保费试算”、“监管规则校验”三个核心能力拆成三个独立仓库,CI流水线各自触发,版本号各自演进。当监管要求修改某一条规则时,只需发布Acme.RulesEngine的新版本,下游服务通过NuGet更新即可,完全不影响“保费试算”的稳定性。这种物理隔离带来的心理安全感,远超任何抽象的“分层架构图”。柔韧脊柱与关节 → 接口契约的极致轻量
猫能完成人类无法想象的扭转动作,靠的不是强健的肌肉,而是异常柔韧的脊柱和大量可活动关节。这映射到代码层面,就是接口(Interface)设计必须极度精简、职责单一、无副作用。“猫系”接口从不定义IOrderService这种大而全的聚合体,而是拆解为IOrderValidator(只负责校验)、IOrderPersister(只负责持久化)、IOrderNotifier(只负责通知)。每个接口通常只有1-3个方法,且方法签名严格遵循“输入DTO + 输出DTO”模式,绝不暴露实体类(Entity)或领域模型(Domain Model)。例如,IOrderValidator.ValidateAsync(OrderValidationRequest request)的request对象里,只包含校验所需的最小字段集合(如OrderId,Amount,CurrencyCode),而非整个Order实体。这样做的好处是:当支付网关需要新增一个风控字段校验时,只需扩展OrderValidationRequest,所有实现类自动兼容;而如果当初定义的是IOrderService.Validate(Order order),那么每次加字段都意味着接口变更、所有实现类强制修改——这就像给猫的脊柱上焊死一块钢板,再也不能灵活转身。落地缓冲反射 → 异常处理的防御性姿态
猫从高处坠落时能自动调整姿态,用四肢缓冲冲击力,这是刻在基因里的生存本能。在.NET代码中,这转化为一套默认开启、层级分明、不掩盖真相的异常处理策略。“猫系”项目严禁在业务逻辑层(Application Layer)使用try-catch (Exception ex)捕获泛型异常并“优雅地吞掉”。取而代之的是三层防御:- 基础设施层(Infrastructure):在数据库访问、HTTP调用等外部依赖处,捕获
SqlException、HttpRequestException等具体异常,转换为预定义的、带业务语义的领域异常(如PaymentGatewayUnavailableException),并记录原始堆栈; - 应用服务层(Application Service):只捕获自己抛出的领域异常,进行重试、降级或转换为用户友好的错误码(如
ERR_PAYMENT_GATEWAY_UNAVAILABLE),绝不处理底层技术异常; - API层(Controller/Minimal API):统一的全局异常过滤器(Global Exception Filter),将所有未被捕获的异常,根据类型映射为标准HTTP状态码(400/401/403/404/500)和结构化JSON响应体,确保前端永远收到可解析的错误信息。这种分层处理,让异常像猫的落地反射一样,每一层都只做自己该做的缓冲动作,既保护了上层逻辑的纯净,又保证了问题能精准定位。
- 基础设施层(Infrastructure):在数据库访问、HTTP调用等外部依赖处,捕获
自我清洁行为 → 自动化可观测性注入
猫花费大量时间舔舐毛发,这是一种高度自动化的自我维护行为。在“猫系”项目中,这体现为可观测性(Observability)能力的零配置、全自动注入。我们不会在每个Controller方法里手动写logger.LogInformation("Start processing order {OrderId}", orderId),而是通过.NET的DiagnosticSource和Activity机制,在框架层面统一埋点。例如,使用Microsoft.Extensions.Diagnostics.HealthChecks定义服务健康检查端点;用OpenTelemetry .NET SDK自动采集HTTP请求的Trace ID、Span ID、响应时间、错误率;用Serilog结合Enrichers自动注入请求ID、用户ID、环境标签。所有这些能力,都通过一个AddCatObservability()扩展方法,在Program.cs中一行代码注册,后续所有中间件、服务、仓储的调用链路都会被自动追踪。运维同学拿到的不是零散的日志,而是一张张完整的、带上下文的请求拓扑图——就像你永远看不到猫舔毛的过程,但它的皮毛始终光洁如新。
2.2 为何不选“狗”或“鸟”?一次严肃的隐喻排除法
有人会问:为什么不是“Dog in dotNET”(强调忠诚、服从、主从关系)?或者“Bird in dotNET”(强调轻盈、快速、高飞)?这并非文字游戏,而是经过大量失败项目验证后的理性选择。
“狗系”架构的陷阱:狗是群居动物,天然倾向形成等级森严的主从结构。这很容易滑向一种危险的架构模式——中心化、强依赖、单点故障。典型表现是:所有服务都必须调用一个名为
CentralOrchestrationService的“狗头”服务,它负责协调订单、库存、物流的整个流程。一旦这个“狗头”宕机,整个系统瘫痪。我们在某电商平台就吃过这个亏:促销期间“狗头”服务因数据库连接池耗尽而雪崩,导致下单、支付、发货全部中断。而“猫系”则坚持“去中心化协调”,订单服务自己调用库存服务的ReserveStockAsync(),库存服务返回ReservationResult(含预留ID和过期时间),订单服务再调用物流服务的ScheduleDeliveryAsync(ReservationId)。每个环节都是独立的、可重试的、有明确契约的。没有“狗头”,只有“猫群”,彼此协作但互不隶属。“鸟系”架构的幻觉:“鸟”象征着轻盈与速度,容易让人沉迷于“极致性能”的幻觉,从而忽视稳定性和可维护性。典型的“鸟系”代码是大量使用
unsafe指针、手动内存池(MemoryPool<T>)、零分配(zero-allocation)技巧,追求微秒级的响应。这在高频交易场景或许必要,但在95%的企业级应用中,它是毒药。我曾接手一个“鸟系”改造项目:为了省下几毫秒,开发团队重写了整个JSON序列化逻辑,结果引入了严重的时区处理Bug,导致跨时区订单时间错乱,客户投诉如潮。而“猫系”信奉80/20法则:用System.Text.Json默认配置满足90%场景,只在真正瓶颈处(如日志序列化)用Utf8JsonWriter做针对性优化。猫的速度来自敏捷,而非蛮力;它的稳定来自对自身极限的清醒认知。
因此,“Cat in dotNET”不是一个营销噱头,而是一套经过千锤百炼、用血泪教训换来的工程价值观。它不承诺最快,但承诺最稳;不追求最炫,但追求最久。当你在深夜收到告警,看到Kibana里那条清晰的Trace链路,知道问题出在PaymentGateway的TimeoutException,并且RetryPolicy已经自动执行了第二次调用——那一刻,你会明白,为什么是猫。
3. 核心细节解析与实操要点:从概念到代码的四根“猫须”
3.1 第一根猫须:模块化边界的物理实现——多项目解决方案(Multi-Project Solution)
“猫系”架构的基石,是将抽象的“模块”落实为物理存在的、可独立编译、可独立部署的.NET项目。这绝非简单的文件夹划分,而是一套严格的物理约束体系。以下是我在实际项目中强制推行的五条铁律:
项目命名即契约:所有类库项目必须采用
<Company>.<Domain>.<Layer>的三段式命名,且<Layer>只能是Core、Application、Infrastructure、Presentation中的一个。例如Acme.Insurance.Core(存放领域模型、值对象、领域服务接口)、Acme.Insurance.Application(存放应用服务、DTO、命令/查询处理器)、Acme.Insurance.Infrastructure(存放EF Core DbContext、仓储实现、第三方SDK封装)。禁止出现Acme.Insurance.BusinessLogic或Acme.Insurance.Services这类模糊命名。命名即文档,看到项目名,就应该知道它能做什么、不能做什么、依赖谁。依赖方向单向箭头:项目间的引用必须严格遵循
Presentation → Application → Core和Application → Infrastructure的单向依赖。Core项目绝对不能引用Infrastructure,否则领域模型就会被SQL Server的SqlConnection污染。我们用Microsoft.CodeAnalysis.CSharp.Workspaces编写了一个自定义的Roslyn分析器(Analyzer),在CI阶段静态扫描所有.csproj文件,一旦发现Acme.Insurance.Core引用了Microsoft.EntityFrameworkCore,立即构建失败,并输出错误信息:“领域核心层禁止依赖基础设施!请将数据访问逻辑移至Infrastructure项目。” 这比任何架构师的口头警告都管用。NuGet包即唯一通信协议:
Application项目要使用Infrastructure的功能,不能直接添加项目引用,而必须通过NuGet包。这意味着Acme.Insurance.Application项目文件里,必须是<PackageReference Include="Acme.Insurance.Infrastructure" Version="1.2.0" />。版本号由Infrastructure项目的CI流水线自动发布,Application项目通过dotnet restore拉取。这种“包即API”的方式,强制了接口的稳定性——Infrastructure的作者必须思考:“我发布的这个1.2.0版本,是否能保证所有下游服务在不改代码的情况下平滑升级?” 这种压力,催生了真正健壮的契约。共享内核(Shared Kernel)的谨慎使用:当多个限界上下文(Bounded Context)需要共享少量、极其稳定的类型(如
Money值对象、CurrencyCode枚举)时,才允许创建Acme.SharedKernel项目。但它必须满足两个条件:第一,所有使用它的项目,对该包的版本号必须完全一致(<PackageReference Version="1.0.0" />,而非1.0.*);第二,SharedKernel项目本身不能有任何外部依赖,纯C#代码。我们曾因在SharedKernel里不小心引入了Newtonsoft.Json,导致下游服务在升级Json.NET时发生运行时冲突,整整排查了一周。从此立下规矩:SharedKernel是“无菌区”,连System.Text.Json都不能用。物理隔离带来的部署灵活性:模块化不仅是代码组织,更是部署策略的起点。
Acme.Insurance.Core作为纯逻辑,可以打包进所有服务的Docker镜像;Acme.Insurance.Infrastructure则可以独立部署为一个Sidecar容器,通过gRPC提供统一的数据访问服务;而Acme.Insurance.Presentation(API层)则可以按需水平扩展。这种灵活性,是单体项目永远无法企及的。在一次大促压测中,我们只对Presentation层进行了10倍扩容,而Core和Infrastructure层保持原样,资源利用率提升了300%。
提示:不要试图用“文件链接”(Add As Link)或“符号链接”(Symbolic Link)绕过物理隔离。这会让IDE的智能感知(IntelliSense)失效,让Git的diff变得混乱,最终成为团队的技术债黑洞。真正的模块化,始于鼠标右键“Add New Project”,终于CI流水线的绿色对勾。
3.2 第二根猫须:接口契约的“猫爪”设计——DTO驱动的交互范式
在“猫系”世界里,接口(Interface)是神圣不可侵犯的契约,而承载契约的载体,必须是DTO(Data Transfer Object)。这与传统.NET项目中常见的“Entity First”或“ViewModel First”模式截然不同。其核心思想是:接口只描述“需要什么”,不描述“是什么”;只定义“能做什么”,不定义“怎么做”。
一个典型的“猫系”接口定义如下:
// Acme.Insurance.Application/Commands/PlaceOrderCommand.cs public record PlaceOrderCommand( Guid CustomerId, string CustomerEmail, IReadOnlyList<OrderItemDto> Items, AddressDto ShippingAddress); // Acme.Insurance.Application/Commands/OrderItemDto.cs public record OrderItemDto( Guid ProductId, int Quantity, decimal UnitPrice); // Acme.Insurance.Application/Commands/AddressDto.cs public record AddressDto( string Street, string City, string PostalCode, string CountryCode);// Acme.Insurance.Application/Services/IOrderPlacementService.cs public interface IOrderPlacementService { /// <summary> /// 尝试下单。成功返回订单ID;失败抛出特定领域异常。 /// </summary> /// <param name="command">下单指令,包含所有必要信息</param> /// <param name="cancellationToken"></param> /// <returns>新创建的订单ID</returns> /// <exception cref="CustomerNotFoundException">客户不存在</exception> /// <exception cref="InsufficientStockException">库存不足</exception> /// <exception cref="PaymentMethodInvalidException">支付方式无效</exception> Task<Guid> PlaceOrderAsync(PlaceOrderCommand command, CancellationToken cancellationToken); }这个看似简单的例子,蕴含了五个关键设计决策:
Record类型优先:所有DTO都使用C# 9.0+的
record类型。它天然具备不可变性(Immutability)、值语义(Value Semantics)、自动生成Equals/GetHashCode,完美契合“数据传输”的本质。一个OrderItemDto对象,一旦创建,其ProductId、Quantity就永远不变,避免了在复杂调用链中被意外篡改的风险。这就像猫的爪子,收放自如,但一旦伸出,形状就固定了。IReadOnlyList替代List:
IReadOnlyList<OrderItemDto>明确告诉调用方:“你可以读,但不能改”。这杜绝了下游服务在遍历Items时,偷偷调用Items.Add()往里面塞脏数据的可能。在IOrderPlacementService的实现里,我们甚至会用items.ToList().AsReadOnly()进行二次封装,确保万无一失。扁平化结构,拒绝嵌套深坑:
AddressDto是一个独立的、扁平的记录,而不是OrderItemDto的一个属性。这避免了“深度拷贝”(Deep Copy)的噩梦。当需要将PlaceOrderCommand序列化为JSON发送给消息队列时,System.Text.Json能高效处理,无需配置复杂的JsonConverter。而如果AddressDto是OrderItemDto的嵌套属性,序列化器可能会陷入无限递归(如果设计不当)。异常即契约的一部分:接口的XML文档注释(
<exception>标签)是契约的正式组成部分。PlaceOrderAsync方法明确声明了三种可能抛出的异常类型。这迫使实现类必须严格遵守,也迫使调用方必须考虑这三种失败场景。我们用NSwag生成OpenAPI文档时,这些<exception>会自动转换为Swagger UI中的4xx响应码定义,前端工程师能一眼看清所有可能的错误分支。零业务逻辑,纯数据容器:DTO里绝对不出现任何方法、属性访问器(getter/setter)、构造函数逻辑。
OrderItemDto里不会有TotalPrice => UnitPrice * Quantity这样的计算属性。计算逻辑属于Application层的OrderCalculator服务,DTO只负责安静地传递原始数据。这保证了DTO的纯粹性——它是一张白纸,上面只印着墨水写的字,没有画笔的痕迹。
注意:切勿将Entity(如EF Core的
Order实体)或Domain Model(如OrderAggregateRoot)直接暴露给接口。这会导致“贫血模型”(Anemic Domain Model)和“紧耦合”(Tight Coupling)双重灾难。Entity里混杂着ORM的[Column]、[ForeignKey]属性,Domain Model里藏着复杂的业务规则验证逻辑,它们都不是“数据传输”该关心的事。让DTO做它唯一该做的事:安全、高效、无歧义地搬运数据。
3.3 第三根猫须:异常处理的“猫式缓冲”——分层防御与语义化映射
“猫系”异常处理的精髓,在于承认“错误是常态,而非例外”。它不追求消灭错误,而是建立一套让错误发生时,系统依然可控、可诊断、可恢复的缓冲机制。这套机制由三个物理组件构成,缺一不可。
3.3.1 基础设施层:将技术异常翻译为领域语言
这是缓冲的第一道墙。所有与外部世界的交互——数据库、HTTP API、消息队列、文件系统——都必须在此层完成“异常翻译”。以EF Core为例,Acme.Insurance.Infrastructure项目中,我们有一个EfCoreOrderRepository:
// Acme.Insurance.Infrastructure/Repositories/EfCoreOrderRepository.cs public class EfCoreOrderRepository : IOrderRepository { private readonly InsuranceDbContext _context; public EfCoreOrderRepository(InsuranceDbContext context) { _context = context; } public async Task<Order> GetByIdAsync(Guid id, CancellationToken cancellationToken) { try { var order = await _context.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); if (order == null) throw new OrderNotFoundException(id); // 领域异常 return order; } catch (SqlException ex) when (ex.Number == -2) // SQL Server timeout { // 将技术异常(SqlException)翻译为领域异常(DatabaseTimeoutException) throw new DatabaseTimeoutException("Order query timed out", ex); } catch (SqlException ex) when (ex.Number == 1205) // Deadlock { throw new DatabaseDeadlockException("Order query deadlocked", ex); } catch (Exception ex) { // 所有其他未预期的异常,包装为通用领域异常 throw new InfrastructureException("Unexpected error querying order", ex); } } }关键点在于:
OrderNotFoundException是Acme.Insurance.Core项目中定义的、继承自DomainException的领域异常,它属于业务语义,Application层可以理解并处理。SqlException是技术异常,Application层不应该、也不需要知道SQL Server的错误码-2代表超时。Infrastructure层做了翻译,Application层只看到DatabaseTimeoutException,它知道这意味着“稍后重试”。when子句实现了精准捕获,避免了catch (Exception)的滥用。
3.3.2 应用服务层:基于领域异常的业务决策
Application层是缓冲的第二道墙,也是业务逻辑的主战场。它接收DTO,调用领域服务和仓储,然后根据结果和异常,做出业务决策。
// Acme.Insurance.Application/Services/OrderPlacementService.cs public class OrderPlacementService : IOrderPlacementService { private readonly IOrderRepository _orderRepository; private readonly IInventoryService _inventoryService; private readonly IPaymentService _paymentService; public OrderPlacementService(IOrderRepository orderRepository, IInventoryService inventoryService, IPaymentService paymentService) { _orderRepository = orderRepository; _inventoryService = inventoryService; _paymentService = paymentService; } public async Task<Guid> PlaceOrderAsync(PlaceOrderCommand command, CancellationToken cancellationToken) { // 1. 验证客户是否存在(调用领域服务) var customer = await _customerService.GetCustomerByIdAsync(command.CustomerId, cancellationToken); if (customer == null) throw new CustomerNotFoundException(command.CustomerId); // 2. 预留库存(调用基础设施封装的服务) try { var reservationResult = await _inventoryService.ReserveStockAsync( command.Items.Select(i => new StockReservationRequest(i.ProductId, i.Quantity)).ToList(), cancellationToken); } catch (InsufficientStockException ex) { // 业务决策:库存不足,直接失败,不重试 throw; } catch (DatabaseTimeoutException ex) { // 业务决策:数据库超时,重试一次 await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); await _inventoryService.ReserveStockAsync(/*...*/); } // 3. 处理支付(调用外部支付网关) try { var paymentResult = await _paymentService.ProcessPaymentAsync( new PaymentRequest(command.CustomerId, command.Items.Sum(i => i.UnitPrice))); // 支付成功,创建订单 var order = new Order(command.CustomerId, command.Items, command.ShippingAddress); await _orderRepository.CreateAsync(order, cancellationToken); return order.Id; } catch (PaymentGatewayUnavailableException ex) { // 业务决策:支付网关不可用,降级为“货到付款”,并记录告警 _logger.LogWarning(ex, "Payment gateway unavailable, falling back to COD for order {OrderId}", order.Id); // ... 创建COD订单逻辑 } } }这里体现了“猫式缓冲”的智慧:
- 对
InsufficientStockException,不做任何处理,直接向上抛,因为这是业务规则的硬性限制,重试无意义。 - 对
DatabaseTimeoutException,执行一次指数退避重试(Task.Delay),这是对暂时性故障的合理应对。 - 对
PaymentGatewayUnavailableException,执行业务降级(Fallback),这是最高级的缓冲——系统功能不中断,只是体验略有降级。
3.3.3 API层:统一的、面向用户的错误响应
最后一道墙,是面向前端或第三方调用者的Controller或Minimal API。它不处理任何业务逻辑,只做一件事:将所有异常,映射为标准、友好、可解析的HTTP响应。
// Acme.Insurance.Presentation/Controllers/OrdersController.cs [ApiController] [Route("api/[controller]")] public class OrdersController : ControllerBase { private readonly IOrderPlacementService _orderPlacementService; public OrdersController(IOrderPlacementService orderPlacementService) { _orderPlacementService = orderPlacementService; } [HttpPost] public async Task<ActionResult<Guid>> PlaceOrder([FromBody] PlaceOrderCommand command) { try { var orderId = await _orderPlacementService.PlaceOrderAsync(command, HttpContext.RequestAborted); return Ok(orderId); } catch (CustomerNotFoundException ex) { return NotFound(new ErrorResponse("ERR_CUSTOMER_NOT_FOUND", ex.Message)); } catch (InsufficientStockException ex) { return BadRequest(new ErrorResponse("ERR_INSUFFICIENT_STOCK", ex.Message)); } catch (PaymentGatewayUnavailableException ex) { return StatusCode(503, new ErrorResponse("ERR_PAYMENT_GATEWAY_UNAVAILABLE", ex.Message)); } catch (Exception ex) when (ex is DomainException || ex is InfrastructureException) { // 所有已知的领域/基础设施异常,映射为500 _logger.LogError(ex, "Unhandled domain/infrastructure exception"); return StatusCode(500, new ErrorResponse("ERR_INTERNAL_SERVER_ERROR", "Something went wrong")); } catch (Exception ex) { // 未预期的异常,记录详细日志,返回通用错误 _logger.LogCritical(ex, "Unhandled exception"); return StatusCode(500, new ErrorResponse("ERR_UNKNOWN_ERROR", "An unexpected error occurred")); } } } public record ErrorResponse(string Code, string Message);这个Controller的精妙之处在于:
- 它只关心“如何呈现错误”,不关心“错误为什么发生”。所有业务决策都在
Application层完成。 ErrorResponse是一个简单的、标准化的JSON结构,前端可以统一解析Code字段,展示对应的用户提示语(如ERR_INSUFFICIENT_STOCK-> “抱歉,您选购的商品库存不足,请稍后再试”)。- 使用
StatusCode(503)明确告知客户端“服务暂时不可用”,而不是笼统的500,这有助于前端实现更智能的重试逻辑。
实操心得:在
Program.cs中,务必注册全局异常过滤器(Global Exception Filter),作为兜底方案。但它的作用仅仅是捕获那些漏网的、未被上述三层处理的异常,并记录日志。它绝不应该尝试去“修复”或“美化”这些异常。真正的错误处理,必须发生在上述三层中。这就像猫的缓冲反射,是本能,不是临场发挥。
3.4 第四根猫须:可观测性的“猫眼”——自动化埋点与上下文传播
“猫系”项目的可观测性,不是上线后才加的“监控探针”,而是从第一行代码开始就内置的“猫眼”。它让开发者和运维人员,无需登录服务器、无需翻阅海量日志,就能在Kibana或Grafana里,像猫一样敏锐地捕捉到系统的每一次心跳、每一次喘息、每一次异常。
实现这一切的核心,是.NET 6+提供的Activity和DiagnosticSource。我们不手动创建Activity,而是通过OpenTelemetry .NET SDK的自动仪器化(Auto-Instrumentation)来完成。
3.4.1 零配置的Trace链路
在Program.cs中,只需几行代码:
// Acme.Insurance.Presentation/Program.cs var builder = WebApplication.CreateBuilder(args); // 添加OpenTelemetry builder.Services.AddOpenTelemetry() .WithTracing(tracerProviderBuilder => { tracerProviderBuilder .AddAspNetCoreInstrumentation() // 自动为所有HTTP请求创建Activity .AddHttpClientInstrumentation() // 自动为所有HttpClient调用创建Activity .AddEntityFrameworkCoreInstrumentation() // 自动为所有EF Core查询创建Activity .AddSource("Acme.Insurance.Application") // 启用Application层的手动埋点 .SetResourceBuilder(ResourceBuilder.CreateDefault() .AddService(builder.Environment.ApplicationName) .AddEnvironment(builder.Environment.EnvironmentName)); }); // 配置Exporter,发送到Jaeger或Zipkin builder.Services.Configure<OpenTelemetryLoggerOptions>(options => { options.AddConsoleExporter(); });效果是惊人的:当你发起一个POST /api/orders请求,OpenTelemetry会自动创建一个根Activity,其ActivityId(如00-1234567890abcdef1234567890abcdef-1234567890abcdef-01)会作为traceparentHTTP头,被自动注入到所有下游调用中:
OrdersController的PlaceOrderAsync方法会被标记为一个Span;OrderPlacementService调用IInventoryService.ReserveStockAsync时,新的Span会以parentId指向上面的Span;IInventoryService内部调用HttpClient访问库存服务,又会产生一个新的Span;- 最终,所有这些Span,都会被收集到Jaeger中,形成一条完整的、带时间戳、带错误标记、带SQL查询语句(如果启用了EF Core的SQL日志)的Trace链路。
你不再需要在每个方法里写logger.LogInformation("Start ReserveStock");,Trace本身就是最丰富的日志。
3.4.2 上下文传播:让“猫眼”看见一切
仅仅有Trace还不够,“猫眼”还需要看到业务上下文。例如,当Trace显示某个ReserveStockSpan耗时很长时,运维人员想知道:“这是哪个客户的哪个订单?” 这就需要将业务ID注入到Activity的Tags中。
我们在Application层的关键服务中,手动添加上下文:
// Acme.Insurance.Application/Services/OrderPlacementService.cs public async Task<Guid> PlaceOrderAsync(PlaceOrderCommand command, CancellationToken cancellationToken) { // 获取当前Activity var activity = Activity.Current; if (activity != null) { // 注入业务上下文Tag activity.SetTag("customer.id", command.CustomerId.ToString()); activity.SetTag("order.items.count", command.Items.Count); } // ... 业务逻辑 }同时,我们利用Serilog的Enrichers,将Activity的TraceId和SpanId自动注入到每一条日志中:
// Program.cs Log.Logger = new LoggerConfiguration() .Enrich.FromLogContext() .Enrich.With<ActivityEnricher>() // 自定义Enricher,提取Activity信息 .WriteTo.Console() .CreateBootstrapLogger(); builder.Host.UseSerilog((ctx, lc) => lc .ReadFrom.Configuration(ctx.Configuration) .Enrich.FromLogContext() .Enrich.With<ActivityEnricher>() .WriteTo.Console());ActivityEnricher的实现很简单:
public class ActivityEnricher : ILogEventEnricher { public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) { var activity = Activity.Current; if (activity != null) { logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TraceId", activity.TraceId.ToString())); logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("SpanId", activity.SpanId.ToString())); logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("ParentId", activity.ParentId.ToString())); } } }结果是:你在Kibana里搜索TraceId: "00-1234567890abcdef...",就能看到这条Trace下的所有日志,每条日志都自带customer.id、order.items.count等业务标签。问题定位,从大海捞针,变成了按图索骥。
注意事项:“猫眼”的清晰度,取决于上下文注入的粒度。不要过度注入(如把整个
PlaceOrderCommand对象序列化进去,会撑爆日志),也不要注入不足(如只注入TraceId,不注入customer.id)。最佳实践是:在Controller层注入顶层业务ID(customerId,orderId),在Application层的关键服务方法中,注入该方法特有的上下文(如inventory.sku,payment.method)。这就像猫眼的焦距,既能看清远处的猎物,也能聚焦近处的胡须。
4. 实操过程与核心环节实现:从空项目到“猫系”雏形的七步搭建
4.1 步骤一:初始化解决方案骨架(5分钟)
打开终端,进入你的工作目录,执行以下命令,创建一个符合“猫系”规范的多项目解决方案:
# 1. 创建解决方案文件 dotnet new sln -n Acme.Insurance # 2. 创建四个核心类库项目(注意命名规范) dotnet new classlib -n Acme.Insurance.Core -o src/Core dotnet new classlib -n Acme.Insurance.Application -o src/Application dotnet new classlib -n Acme.Insurance.Infrastructure -o src/Infrastructure dotnet new webapi -n Acme.Insurance.Presentation -o src/Presentation # 3. 将项目添加到解决方案 dotnet sln add src/Core/Acme.Insurance.Core.csproj dotnet sln add src/Application/Acme.Insurance.Application.csproj dotnet sln add src/Infrastructure/Acme.Insurance.Infrastructure.csproj dotnet sln add src/Presentation/Acme.Insurance.Presentation.csproj # 4. 建立项目间引用(严格遵循单向依赖) dotnet add src/Application/Acme.Insurance.Application.csproj reference src/Core/Acme.Insurance.Core.csproj dotnet add src/Infrastructure/Acme.Insurance.Infrastructure.csproj reference src/Core/Acme.Insurance.Core.csproj