上周帮朋友看一个跑了五年的老项目,打开 csproj,依赖列表干净得像刚建的一样。

不是他写得好,是他该用的第三方库一个都没用。日志靠 Console.WriteLine,参数校验在 Controller 里堆 if,定时任务拿 Task.Run 硬扛。

这种项目不是不能跑,是每天都在踩自己五年前埋的雷。

我把自己这几年每个新项目都会装的 8 个包整理了一遍。都是用了很久、经得起生产环境折腾的,不是 GitHub 上一时热闹的玩具。

先说清楚,这 8 个包解决的是 8 类不同的问题,不重复、不打架。按需取用,不用全装。

包 解决什么 什么时候该上
Serilog 结构化日志 日志要按字段检索、要长期留存
FluentValidation 校验规则与业务逻辑分离 字段多、规则会反复改
Polly 重试、熔断、超时 调外部接口、跨网络调用
Mapster 对象映射,编译期生成 DTO 和实体之间频繁转换
Dapper 手写 SQL 的轻量 ORM 复杂查询、报表、EF 写不动时
Hangfire 后台任务与定时调度 任务要持久化、要看得见历史
Scalar 交互式 API 文档 .NET 9 以后,想要现代 OpenAPI UI
BenchmarkDotNet 性能基准测试 你要对性能下判断,而不是靠感觉

我每个新项目固定装的 8 个包,按解决问题分类


1. Serilog

先说日志,因为它是唯一一个你上线第一周就会后悔没用的包。

内置 ILogger 写出来的是纯文本。线上出问题你想查「某个用户 5 分钟内所有的支付日志」,只能 grep 字符串,还得祈祷日志格式没变过。

Serilog 把日志变成结构化数据。每个字段单独存,可以按字段查、按字段聚合,不用再做正则。

dotnet add package Serilog.AspNetCore
builder.Host.UseSerilog((ctx, cfg) => cfg
    .ReadFrom.Configuration(ctx.Configuration)
    .Enrich.FromLogContext()
    .WriteTo.Console()
    .WriteTo.Seq("http://localhost:5341"));

装完之后,日志就该这么写。

logger.LogInformation(
    "订单 {OrderId} 已创建,金额 {Amount}", id, amount);

看着像字符串拼接,实际存下来是 OrderId 和 Amount 两个独立字段。

这就是结构化日志和文本日志的全部区别。

什么时候别用。如果只是个内部小工具,日志写完没人看,那内置的 ILogger 加个控制台输出就够了,不用为了仪式感引一套。


2. FluentValidation

参数校验这件事,写起来最没成就感,也最容易写成一团。

DataAnnotations 的问题是它长在实体上。一个 CreateOrderDto 和 UpdateOrderDto 校验规则差一点,你就得开两个类或者塞一堆 Groups。

FluentValidation 把规则从实体里拿出来,单独成类。校验规则是一等公民,能单测,能复用,能按场景组合。

dotnet add package FluentValidation.DependencyInjectionExtensions
public class OrderValidator : AbstractValidator<OrderDto>
{
    public OrderValidator()
    {
        RuleFor(x => x.Amount).GreaterThan(0);
        RuleFor(x => x.Email).EmailAddress();
        RuleFor(x => x.Phone).Matches(@"^1[3-9]\d{9}$")
            .WithMessage("手机号格式不对");
    }
}

注册一行就够了,它会扫整个程序集。

builder.Services
    .AddValidatorsFromAssemblyContaining<OrderValidator>();

什么时候别用。三五个字段的简单表单,DataAnnotations 完全够,引 FluentValidation 属于杀鸡用牛刀。规则一旦开始分场景、分角色、分业务流程,再换。


3. Polly

只要你的系统会调别人的接口,就一定会遇到这三种情况。对方偶发 502,对方响应慢到十秒,对方整体挂掉还在被你的代码猛打。

手写 try catch 加重试,是很多项目的标准做法。问题是重试次数、退避策略、超时、并发隔离,这四件事手写下来基本都会漏一个。

Polly 这几年最大的变化是不用再手搓策略了。.NET 生态里现在有官方封装的弹性处理管道,一行注册,重试、超时、熔断、限流全给你配好。

dotnet add package Microsoft.Extensions.Http.Resilience
builder.Services.AddHttpClient("pay")
    .AddStandardResilienceHandler(o =>
    {
        o.Retry.MaxRetryAttempts = 3;
        o.TotalRequestTimeout.Timeout =
            TimeSpan.FromSeconds(10);
        o.CircuitBreaker.FailureRatio = 0.5;
    });

这段配置里最重要的不是重试次数,是那个总超时。它保证一个请求无论怎么重试,都不会把线程占超过十秒。

什么时候别用。同机房里两个服务互调,网络抖动的概率极低,加了重试反而在对方慢的时候把压力放大三倍。内网调用先想清楚再上。


4. Mapster

对象映射这个需求,只要你分了 DTO 和实体,就一定躲不开。

AutoMapper 是很多人的第一选择,但这几年它的定位越来越尴尬。运行时反射映射,性能有成本,出错要到运行期才知道,而且新版本对商业使用还有授权说明要读。

Mapster 的路线不一样,它走源生成器。映射代码在编译期就生成好了,运行时零反射,映射写错编译就报错。 这两种体验差距,用过一次就回不去。

dotnet add package Mapster
dotnet add package Mapster.Tool
config.NewConfig<Order, OrderDto>()
      .Map(d => d.Total,
           s => s.Items.Sum(i => i.Price));

var dto = order.Adapt<OrderDto>();

什么时候别用。映射逻辑不复杂、字段还老在变的时候,手写一个扩展方法反而更清楚。有时候多写十行代码,比多学一个包的映射语法便宜。


5. Dapper

用不用 ORM,是 .NET 圈吵了十年也没结论的话题。我的答案是两个都要。

写数据用 EF Core,改数据用它的事务和变更跟踪,很舒服。但报表、多表联合查询、需要精确控制 SQL 的场景,EF 生成的语句经常不是你想要的。

Dapper 只有几百行核心代码。它不生成 SQL,它只负责把 SQL 结果映射成对象。 你可以和 EF Core 共存,同一个连接上查询走 Dapper,写入走 EF。

dotnet add package Dapper
const string sql = @"
    select o.Id, o.Amount, c.Name
    from Orders o
    join Customers c on c.Id = o.CustomerId
    where o.CreatedAt >= @from";

var rows = await conn.QueryAsync<OrderRow>(
    sql, new { from = start });

参数化查询是它的默认写法,字符串拼接永远不要碰。

什么时候别用。如果整个项目都是标准 CRUD、领域模型还有聚合根和值对象,那 EF Core 就够了,再引一层 Dapper 只会让团队不知道该在哪写。


6. Hangfire

定时任务这件事,我见过太多项目在 Startup 里起一个 Timer,然后一部署多实例就跑到重。

Hangfire 把任务存进数据库。任务不会因为进程重启而丢,多实例部署会自动抢锁只有一个执行,执行失败了自动重试,还有一个 Web 界面能看见每个任务的执行历史。

凌晨三点对账失败了,第二天早上你能打开界面看见它失败了三次、报什么错、什么时候会再试。

dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.SqlServer
builder.Services.AddHangfire(c =>
    c.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
     .UseSimpleAssemblyNameTypeSerializer()
     .UseSqlServerStorage(connStr));

app.UseHangfireDashboard("/jobs");

RecurringJob.AddOrUpdate(
    "daily-report", () => Report.Send(), Cron.Daily);

什么时候别用。就一个进程、任务失败了重跑一次也无所谓,那用 BackgroundService 加一个简单循环就够。Hangfire 的价值在持久化和可观测,这两点用不上就别引。


7. Scalar

这个包跟前 6 个不一样,它替换的是你已经在用的东西。

Swashbuckle 撑了 .NET 的 API 文档很多年,但从 .NET 9 开始官方模板已经把 Swagger 那一套去掉了,换成内置的 OpenAPI 文档生成。文档数据的来源变了,界面就成了可选项。

Scalar 就是那个界面。同样吃 OpenAPI 文档,但支持内嵌请求示例、多语言代码生成、暗色主题,还不用自己维护 UI 静态资源。

dotnet add package Scalar.AspNetCore
builder.Services.AddOpenApi();

app.MapOpenApi();
app.MapScalarApiReference();

什么时候别用。项目还在 .NET 6 或 7,Swagger 已经跑得好好的,没有升级计划,那就别动。换个文档 UI 不值得你承担一次框架升级。


8. BenchmarkDotNet

最后一个包不解决业务问题,它解决的是你和同事吵架的问题。

用 Stopwatch 测性能是通不过评审的。JIT 预热、GC 时机、CPU 频率、死代码消除,任何一个都能让你的数字偏出好几倍。

BenchmarkDotNet 会自己处理预热、多次迭代、统计显著性,还会告诉你每一轮分配了多少内存。

dotnet add package BenchmarkDotNet
[MemoryDiagnoser]
public class SumBench
{
    [Benchmark(Baseline = true)]
    public int ForLoop() { /* ... */ }

    [Benchmark]
    public int LinqSum() => _data.Sum();
}

跑完它会给你一张表,谁快谁慢、差多少、各分配了多少字节,写得明明白白。有了这张表,性能讨论就从「我觉得」变成「数据显示」。

什么时候别用。它是测试项目依赖,别引进生产代码。而且每次跑要几分钟,别放进 CI 的每次提交里,独立跑或者按需跑。


9. 说回装包这件事

8 个包装进 csproj 加起来不到 20 行。真正花时间的不是学会它们,是判断什么时候不该装第九个。

每引一个包,你就多了一份升级责任。NuGet 上躺着的包,三年不更新的太多了。今天的省事,很可能是三年后那次框架升级里的一个坑。

所以我的习惯是,装之前先问一句。这个问题我手写要多少行,写完了会不会有人维护。答案是几十行就能解决、又要跟着业务一直改的,那就自己写。

反过来,像上面这 8 个,它们解决的都不是你的业务问题,是基础设施问题。这类问题别人已经踩了十年坑,重写一遍只是把坑再踩一次。

工具的克制,比工具的数量更能说明一个工程师的水平。