为什么需要程序集扫描注册
在传统的ASP.NET Core项目中,每新增一个服务类,都要打开Startup或者Program文件,手动添加一行类似services.AddScoped<IOrderService, OrderService>()的代码。项目小的时候没什么感觉,但当一个解决方案里有几十个模块、上百个服务接口时,这个注册文件会膨胀到几百行,每次加服务都要改公共配置文件,既容易遗漏,也容易产生合并冲突。
更麻烦的是,手动注册违背了模块化设计的初衷。理想状态下,一个业务模块应该自成一体,新增或移除模块时不需要修改全局配置。程序集扫描注册就是为此而生的方案:通过约定(比如接口以I开头、实现类以Service结尾),让容器在启动时自动扫描指定程序集,把符合规则的类型批量注册进去,代码量和出错率都大幅下降。
Scrutor是.NET生态中最流行的扫描注册库,它以扩展方法的形式构建在微软自带的DependencyInjection之上,API流畅、语义清晰。它不是要替代内置容器,而是在内置容器的能力之上补充批量注册和装饰器等高级特性,因此几乎没有学习成本和迁移风险。

Scrutor的安装与基础用法
通过NuGet安装Scrutor非常简单,执行以下命令即可,它对.NET Core 3.1以后的所有版本都有良好支持:
dotnet add package Scrutor
最基础的用法是按类型扫描。假设项目中有多个以Service结尾的实现类,希望把所有非抽象的公共类自动注册为自身类型的瞬时服务,可以这样写:
using Scrutor;
using Microsoft.Extensions.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
services.Scan(scan => scan
.FromAssemblyOf<OrderService>() // 从OrderService所在程序集扫描
.AddClasses(classes => classes.Where(t => t.Name.EndsWith("Service")))
.AsSelf() // 以自身类型注册
.WithTransientLifetime() // 瞬时生命周期
);
return services;
}
}这段代码会在Program.cs中通过builder.Services.AddApplicationServices()调用后生效。注意FromAssemblyOf<T>是按某个类型定位程序集的写法,也可以用FromAssemblies传入多个程序集,或者用FromCallingAssembly、FromApplicationDependencies等方式。扫描范围越小,启动速度越快,这一点在后面还会强调。
按接口约定匹配注册
实际项目中最常见的场景是接口和实现类成对出现,比如IOrderService对应OrderService,IUserService对应UserServ�ice这样的命名约定。Scrutor提供了AsMatchingInterface方法来自动完成这种匹配:
services.Scan(scan => scan
.FromAssemblyOf<IOrderService>()
.AddClasses(classes => classes.AssignableTo<IService>()) // 可选:先过滤基类
.AsMatchingInterface((type, builder) =>
builder.InAssemblyOf(type)) // 在实现类所在程序集找对应接口
.WithScopedLifetime());AsMatchingInterface的默认约定是去掉实现类名前的I前缀:接口IOrderService匹配实现OrderService。如果你的命名约定不同,可以使用带委托参数的重载,自定义接口匹配逻辑。另一个常用选项是AsImplementedInterfaces,它会把类注册到它实现的所有接口上,适合一个类实现多个服务接口的情况。
如果同一个接口存在多个实现,需要决定是全部注册还是保留最后一个。Scrutor默认采用容器标准的“后注册覆盖前注册”行为,也可以通过UsingRegistrationStrategy明确指定策略:RegistrationStrategy.Skip表示遇到已注册的就跳过,RegistrationStrategy.Append表示全部追加(配合IEnumerable<T>注入可以拿到所有实现),RegistrationStrategy.Throw则会在重复时抛异常,便于及早发现配置错误。
services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses()
.AsImplementedInterfaces()
.UsingRegistrationStrategy(RegistrationStrategy.Skip) // 已存在则跳过
.WithScopedLifetime());生命周期、过滤与装饰器进阶用法
生命周期控制非常直观,WithSingletonLifetime、WithScopedLifetime、WithTransientLifetime分别对应单例、作用域和瞬时。如果不同服务需要不同生命周期,可以进行多次Scan,每次用不同的过滤条件和生命周期,互不干扰:
// 无状态的计算服务用单例
services.Scan(scan => scan
.FromAssemblyOf<Calculator>()
.AddClasses(c => c.AssignableTo<ICalculator>())
.AsImplementedInterfaces()
.WithSingletonLifetime());
// 依赖数据库上下文的仓储用Scoped
services.Scan(scan => scan
.FromAssemblyOf<OrderRepository>()
.AddClasses(c => c.AssignableTo<IRepository>())
.AsImplementedInterfaces()
.WithScopedLifetime());过滤条件除了AssignableTo(可赋值给某类型)和自定义Where谓词,还有NotInNamespaceOf<T>、InNamespaceOf<T>这类按命名空间过滤的方法,可以把 Infrastructure 层和 Application 层的服务分开注册,让边界更清晰。此外AddClasses默认会排除抽象类、接口和编译器生成的类型,通常不必额外操心。
Scrutor还有一个杀手级功能是装饰器注册。比如想给所有查询服务加上日志或缓存装饰,不需要手写一堆包装类注册代码:
services.AddScoped<IOrderService, OrderService>();
// 给IOrderService套上缓存装饰器
services.Decorate<IOrderService>((inner, provider) =>
new CachedOrderService(inner, provider.GetRequiredService<IMemoryCache>()));装饰器按注册顺序层层包裹,先注册的在最内层,非常适合实现横切关注点,比如审计、重试、缓存,而业务类保持纯粹。
实践中的注意事项与建议
第一是扫描范围的控制。扫描整个应用程序域会显著拖慢启动速度,大型项目建议按模块拆分程序集,每个模块提供一个自己的AddXxxModule扩展方法,内部只扫描本模块程序集,由组合根统一调用。这样既加快了扫描,也让模块的依赖注册职责内聚,移除模块时只删一行调用。
第二是要提防反射扫描的固有局限。程序集扫描依赖类型在编译期可见,如果你使用了程序集动态加载或AOT裁剪(比如.NET的NativeAOT),反射扫描可能找不到类型或被裁剪掉,这类场景建议退回显式注册,或使用源生成器方案。另外,强命名程序集、程序集版本冲突也可能导致扫描异常,排查时可以先缩小扫描范围定位问题程序集。
第三是约定要明确且稳定。扫描注册的本质是把“约定”当成契约,一旦有人命名不规范,比如接口没有加I前缀,注册就会静默失败,问题往往到运行时才暴露。建议配合单元测试验证关键服务能否正常解析,例如遍历所有接口断言容器能解析出实现,把约定验证自动化:
[Fact]
public void AllServices_ShouldBeResolvable()
{
var provider = new ServiceCollection()
.AddApplicationServices()
.BuildServiceProvider();
var orderService = provider.GetRequiredService<IOrderService>();
Assert.NotNull(orderService);
}总结来说,Scrutor用极小的成本解决了依赖注入配置膨胀的问题。合理设计命名约定、按模块划分扫描边界、用装饰器处理横切逻辑,再辅以启动时的解析验证测试,就能让项目的依赖注入配置既简洁又可靠,把开发者从繁琐的手工注册中彻底解放出来。