概要
在ASP.NET CORE开发大型项目过程中,我们一般都会需要大量第三方的软件或服务配合使用,例如EntityFramwork,redist,Jwt等。根据ASP.NET CORE的设计思想“Pay for what you use”,像EntityFramwork/DBContext,redis,Jwt, Swagger的服务初始化配置内容,都要通过依赖注入的方式引入项目。
这就意味着在开发过程中,大量的服务配置相关的代码,都要放到Startup.cs文件中。造成该文件非常庞大并且不易管理。在多团队配合开发过程中,该文件可能被频繁merge,造成各种编译错误。
基于上述问题,本文介绍两种解决方案,实现Startup.cs文件中代码的优化管理。
已有解决方案的弊端
按照集中式管理方式,我们将所有的服务配置代码全部放到Startup.cs中的ConfigureServices方法中,如下所示,文件变得非常庞大。 中间件部分代码不是本次讨论重点,下面代码中已经略去。
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services
.AddDbContext<BranchMngtDbContext>(options => options
.UseSqlServer(configuration.GetDefaultConnectionString()));
.AddControllersWithViews();
services.AddIdentity<User, IdentityRole>(options =>
{
options.Password.RequiredLength = 6;
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
})
.AddEntityFrameworkStores<BranchMngtDbContext>();
services
.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(x =>
{
x.RequireHttpsMetadata = false;
x.SaveToken = true;
x.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = false,
ValidateAudience = false
};
});
services
.AddSwaggerGen(c =>
{
c.SwaggerDoc(
version,
new OpenApiInfo
{
Title = "Project Name",
Version = "v 1.0"
});
});
// 注入用户自定义服务
//services.AddScoped<>();
//services.Singleton<>();
//services.Transient<>();
}
}
对于有一定规模的团队开发而言,不同的小team负责不同的模块,很可能要在项目中注入不同的第三方服务,这样就造成了单文件多负责人的情况,从而对未来的开发和运维造成了很大的隐患。
分布式管理的解决方案
本文以一个银行分行管理系统为例,说明如何实现Startup文件的分布式管理。
解决方案1
基于分布式管理方式,我们解耦当前的文件,Startup文件只负责决定注入哪些服务,具体的服务配置代码,放到各自服务对应的文件中,结构图如下:
我们为按照OOP的思想,为每个服务的配置代码封装 一个类。在类内方法中完成具体配置。每个类的构造方法接受一个IServiceCollection参数。
基本思路
我们为IServiceCollection定义一个扩展方法,该方法负责实例化所有服务配置相关的类,并调用相关的服务配置方法。
考虑到未来的可扩展行,我们在扩展方法中采用泛型参数,一旦有新的服务配置类,我们不需要修改已有的代码。
如果采用常规的方式,我们还是要把配置类实例化的具体代码放到Startup文件中,这又造成该文件管理很多细节代码。所以在扩展方法中,我们采用表达式目录树的方式,实现泛型参数类的自动实例化。
代码实现
namespace BranchMngt.Web.Infrastructure
{
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using BranchMngt.Common.Settings;
public static class ServiceCollectionExtension
{
public static IServiceCollection CreateExtensionService<T>(this IServiceCollection services, Action<T> action){
// IServiceCollection => new T(IServiceCollection);
ParameterExpression parameter = Expression.Parameter(typeof(IServiceCollection),"builder");
ConstructorInfo ctor = typeof(T).GetConstructor(
BindingFlags.Instance | BindingFlags.Public,
null,
CallingConventions.HasThis,
new[] { typeof(IServiceCollection) }, new ParameterModifier[] {}
);
var expressionNew = Expression.New(ctor,new Expression[]{parameter});
LambdaExpression instantiateLambda = Expression.Lambda<Func<IServiceCollection,T>>(expressionNew, new ParameterExpression[]{parameter});
var instantiateDelegate = (Func<IServiceCollection,T>) instantiateLambda.Compile();
var builder = instantiateDelegate.Invoke(services);
action(builder);
return services;
}
}
}
- 我们通过表达式目录树构建一个Lambda表达式IServiceCollection => new T(IServiceCollection);,调用该表达式创建的委托,可以实例化任何配置类T;
- 构建Lambda表达式中的参数表达式;
- 在泛型类中找到包含IServiceCollection参数的构造函数表达式;
- 构建类实例化的New表达式;
- 利用构建好的表达式new T(IServiceCollection);和参数表达式构造Lambda表达式IServiceCollection => new T(IServiceCollection);
- 利用Lambda表达式构建委托;
- 调用委托实例化具体的泛型类;
- 调用实例中的方法,完成第三方服务配置。
- 为了支持责任链方式调用,将I配置好对应服务的ServiceCollection实例返回。
DBContext服务配置类的定义
根据基本思路中的要求,我们定义DBContextBuilder类来完成BranchMngtDbContext的依赖注入和EntityFramework的基本配置。代码如下,IConfiguration 的扩展方法详见附录。
namespace BranchMngt.Web.Infrastructure.Extensions
{
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using BranchMngt.Data;
using BranchMngt.Common.DI;
public class DBContextBuilder
{
private readonly IServiceCollection _services;
public DBContextBuilder(IServiceCollection services)
{
_services = services;
}
public void UseBranchMngtDbContext( IConfiguration configuration){
_services
.AddDbContext<BranchMngtDbContext>(options => options
.UseSqlServer(configuration.GetDefaultConnectionString()));
}
}
}
- DBContextBuilder类的构造方法包含IServiceCollection参数
- UseBranchMngtDbContext方法未来将以Action的形式作为参数,传入扩展方法CreateExtensionService中,完成BranchMngtDbContext的依赖注入。
IServiceCollection的扩展方法CreateExtensionService的调用
通过调用CreateExtensionService方法,我们可以将服务配置类中的配置方法在Startup文件的ConfigureService方法中逐一执行,代码如下。IdentityBuilder,JwtAuthBuilder和SwaggerBuilder代码组织方式与DBContextBuilder类似,不在赘述,详见附录。
public void ConfigureServices(IServiceCollection services){
services
.CreateExtensionService((DBContextBuilder builder) => builder.UseBranchMngtDbContext(this.Configuration))
.CreateExtensionService((IdentityBuilder builder) => builder.UseIdentity())
.CreateExtensionService((JwtAuthBuilder builder) => builder.UseJwtAuth(this.Configuration))
.CreateExtensionService((SwaggerBuilder builder) => builder.UseSwagger(this.Configuration))
.CreateExtensionServices(this.Configuration)
.AddControllersWithViews();
}
- CreateExtensionService方法会返回IServiceCollection实例,所以可以进行链式调用。
- Startup文件已经定义了Configuration,所以在执行服务配置的方式时候,如果需要读取项目配置,可以直接通过Configuration获取。
基于上述代码,我们实现了Startup文件和具体依赖注入服务的配置代码的解耦,
在Startup文件只决定注入什么服务,具体服务配置代码未来可以由不同的负责人来管理,从而避免了单个文件多负责人的隐患。
分布式管理的解决方案 2
解决方案1实现了Startup文件和具体服务配置代码之间的解耦,但是如果我们当前的项目要使用一个新的第三方的服务,例如缓存服务,我们需要使用Redis相关的配置代码,这个时候,我们就要修改Startup,如下图所示:
在Asp.NET CORE 项目的代码管理中,个人建议应该尽量减少对Startup文件的修改。
基于上述问题,我们提出方案2,实现服务配置代码的自动发现和执行。这样也就避免了对重要配置文件的频繁修改。
基本思路
方案1中,我们由Startup来决定哪些服务需要注入,从而造成了新服务添加,还要修改该文件的问题,所以在方案2中,我们将该控制权交给服务配置类。该问题也就自然解决。
我们要代码可以自动发现服务配置相关的类,就需要这些类具有一定的特征,例如都继承自某一个基类。
我们在执行服务注册代码的时候,有些特殊情况,是要考虑执行顺序问题的,所以当我们自动发现了所有服务配置类并实例化以后,需要进行一定的排序。
代码实现
AutoServiceBuilder类是服务配置类被自动发现的开关,如果具体的配置类内的配置代码希望被自动发现并执行,则请继承AutoServiceBuilder类。
namespace BranchMngt.Web.Infrastructure
{
using Microsoft.Extensions.Configuration;
public abstract class AutoServiceBuilder
{
public int Priority {get; set;} = 0;
public abstract void InjectService(IConfiguration configuration);
}
}
- Priority为权值,用于决定该中代码的执行顺序,默认为0,权值越高,则执行越优先执行。
- 继承AutoServiceBuilder的子类要复写InjectService方法,该方法中定义具体的服务配置代码。
- InjectService方法参数IConfiguration,即项目的配置文件appsettings,json,任何服务配置相关参数可以通过该文件读取,例如Swagger的版本号,DBContext的数据库链接字符等。
- 取消该继承关系,则该类内服务配置代码不会被自动执行。
我们还是通过为IServiceCollection定义一个扩展方法,来实现配置服务类的自动发现,实例化和执行相关配置的操作。
public static IServiceCollection CreateExtensionServices(this IServiceCollection services, IConfiguration configuration){
var builderInstances = typeof(AutoServiceBuilder)
.Assembly
.GetExportedTypes()
.Where(t => t.IsClass && ! t.IsAbstract && typeof(AutoServiceBuilder).IsAssignableFrom(t))
.Select(s => (AutoServiceBuilder)Activator.CreateInstance(s, services))
.OrderByDescending( s => s.Priority);
foreach(var builder in builderInstances){
builder.InjectService(configuration);
}
return services;
}
- 获取当前程序集Assembly;
- 在程序集中获取所有的public的类,并且这些类继承自抽象类AutoServiceBuilder;
- 将所有服务配置类实例化并且按照权值大小,降序排列。
- 执行每个实例中的依赖注入代码,完成服务的配置。
- 为了便于和方案1混合使用,所以该方法最后依然返回IServiceCollection实例。
IServiceCollection的扩展方法CreateExtensionServices的调用
通过调用CreateExtensionServices方法我们可以将服务配置类中的配置方法在Startup文件的ConfigureService方法中自动执行。
public void ConfigureServices(IServiceCollection services){
services
.CreateExtensionServices(this.Configuration)
.AddControllersWithViews();
}
基于上述代码,我们实现了Startup文件不在关系那个服务要进行依赖注入,它只关心具体的服务配置类自动发现的开关是否打开。这样我们要增加对Redis的配置代码,只需要将服务配置类RedisBuilder继承自AutoServiceBuilder并复写InjectService方法。
namespace BranchMngt.Web.Infrastructure.Extensions
{
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
using Microsoft.Extensions.Configuration;
using BranchMngt.Data;
using BranchMngt.Common.Settings;
public class RedisBuilder : AutoServiceBuilder
{
private readonly IServiceCollection _services;
public RedisBuilder(IServiceCollection services)
{
_services = services;
}
public override void InjectService(IConfiguration configuration){
this.UseRedis(configuration);
}
public void UseRedis(IConfiguration configuration){
AppSettings settings = _services.GetApplicationSettings(configuration);
_services.AddDistributedRedisCache(options =>
{
options.InstanceName = settings.Redis.InstanceName;
options.Configuration = settings.Redis.Configuration;
});
}
}
}
其它服务配置类代码类似,不再赘述。
FAQ
两个解决方案各适用于什么场景?
解决方案1中,如果添加新的服务,还是需要手动修改Startup配置文件,所以个人觉得适用于中小规模的项目场景,因为毕竟中小规模项目使用的第三方服务相对固定。
解决方案2中,自动化程度更高,适合于多团队开发的大型项目。当然方案1和方案2也可以混合使用。
方案1中,为什么需要通过表达式目录树来实例化泛型类,为什么不能用new实例化?
因为每个服务配置类的构建方法都包含参数,所以不能用new来实例化。具体请参考我的文章C#表达式目录树系列之4 – 解决C#泛型约束与无法创建带参数的泛型实例的矛盾
按照解决方案2配置Staratup文件,执行数据库Migration命令会失败,为什么?
是的,因为在Migration过程中找不到数据库连接字符串,所以Migration命令会失败。
自动加载服务配置代码是通过反射实现的,所以这个过程发生在代码运行时,Migration命令是在编译阶段寻找连接字符串,所以找不到。
解决方案: 将DBContext相关的服务按照方案一去组织,其他服务按照方案2来组织。代码如下:
```csharp
public void ConfigureServices(IServiceCollection services){
services
.CreateExtensionService((DBContextBuilder builder) => builder.UseBranchMngtDbContext(this.Configuration))
.CreateExtensionServices(this.Configuration)
.AddControllersWithViews();
}
注意DBContextBuilder 不要继承AutoServiceBuilder,关闭自动加载开关。
附录
方案1 IdentityBuilder类
namespace BranchMngt.Web.Infrastructure.Extensions
{
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using BranchMngt.Data;
using BranchMngt.Entity.DB;
public class IdentityBuilder
{
private readonly IServiceCollection _services;
public IdentityBuilder(IServiceCollection services)
{
_services = services;
this.Priority = 300;
}
public void UseIdentity(){
System.Console.WriteLine("UseIdentity is called");
_services
.AddIdentity<User, IdentityRole>(options =>
{
options.Password.RequiredLength = 6;
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
})
.AddEntityFrameworkStores<BranchMngtDbContext>();
}
}
}
方案1 JwtAuthBuilder类
namespace BranchMngt.Web.Infrastructure.Extensions
{
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using BranchMngt.Common.Settings;
public class JwtAuthBuilder
{
private readonly IServiceCollection _services;
public JwtAuthBuilder(IServiceCollection services)
{
_services = services;
}
public void UseJwtAuth(IConfiguration configuration){
System.Console.WriteLine("UseJwtAuth is called");
AppSettings settings = _services.GetApplicationSettings(configuration);
var key = Encoding.ASCII.GetBytes(settings.Secret);
_services
.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(x =>
{
x.RequireHttpsMetadata = false;
x.SaveToken = true;
x.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = false,
ValidateAudience = false
};
});
}
}
}
方案1 SwaggerBuilder 类
namespace BranchMngt.Web.Infrastructure.Extensions
{
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
using Microsoft.Extensions.Configuration;
using BranchMngt.Data;
using BranchMngt.Common.Settings;
public class SwaggerBuilder : AutoServiceBuilder
{
private readonly IServiceCollection _services;
public SwaggerBuilder(IServiceCollection services)
{
_services = services;
}
public void UseSwagger(IConfiguration configuration){
AppSettings settings = _services.GetApplicationSettings(configuration);
var title = settings.Swagger.Title;
var version = settings.Swagger.Version;
_services
.AddSwaggerGen(c =>
{
c.SwaggerDoc(
version,
new OpenApiInfo
{
Title = title,
Version = version
});
});
}
}
}
IServiceCollection 其他辅助扩展方法
public static AppSettings GetApplicationSettings(this IServiceCollection services, IConfiguration configuration){
var setttings = configuration.GetSection("ApplicationSettings");
services.Configure<AppSettings>(setttings);
return setttings.Get<AppSettings>();
}
IConfiguration 扩展方法
namespace BranchMngt.Web.Infrastructure.Extensions
{
using Microsoft.Extensions.Configuration;
public static class ConfigurationExtensions
{
public static string GetDefaultConnectionString(this IConfiguration configuration)
=> configuration.GetConnectionString("DefaultConnection");
}
}