c#|.NET 6 迁移到 Minimal API

.NET 6 迁移到 Minimal API Intro 上次写了一篇 Minimal API Todo Sample,有些童鞋觉得 Minimal API 有些鸡肋,有一些功能的支持都不太好,但是其实 Host 之前支持的功能 Minimal API 大部分都是支持的,上次的 Todo Sample 完全没有使用 Controller 来使用 API,但也是可以使用 Controller 的,这一点从新的项目模板就能看的出来
New Template 使用 dotnet new webapi -n Net6TestApi 新的 ASP.NET Core Web API 模板项目结构如下创建新的项目,结构如下:
c#|.NET 6 迁移到 Minimal API
文章图片

主要变化的结构如下:

  • 默认启用了可空引用类型(enable)和隐式命名空间引用(enable)(可以参考项目文件的变化)
  • Program.cs
    • 和之前项目的相比,新的项目模板没有了 Startup,服务都在 Program.cs 中注册
    • Program 使用了 C# 9 中引入的顶级应用程序以及依赖 C# 10 带来的 Global Usings 的隐式命名空间引用
  • WeatherForecast/WeatherForecastController 使用 C# 10 的 File Scoped Namespace 新特性以及上述的隐式命名空间引用
    namespace Net6TestApi; public class WeatherForecast { public DateTime Date { get; set; }public int TemperatureC { get; set; }public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); public string? Summary { get; set; } }

如果想和之前的模板对比一下,可以使用 dotnet new webapi -o Net5TestApi -f net5.0 可以创建 .NET 5.0 的一个 API,因为 .NET 5.0 默认不支持 C# 10 新特性所以还是之前的项目模板
c#|.NET 6 迁移到 Minimal API
文章图片

Migration 上面是一个模板的变化,对于已有的项目如何做项目升级呢?
以之前的一个 TodoApp 为例,升级到 .NET 6 之后向 Minimal API 做迁移的一个示例:
修改之前的代码是这样的:
Program.cs,比默认模板多了 Runtime metrics 的注册和数据库和默认用户的初始化
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Prometheus.DotNetRuntime; using SparkTodo.API; using SparkTodo.Models; DotNetRuntimeStatsBuilder.Customize() .WithContentionStats() .WithGcStats() .WithThreadPoolStats() .StartCollecting(); var host = Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(webHostBuilder => { webHostBuilder.UseStartup(); }) .ConfigureLogging(loggingBuilder => { loggingBuilder.AddJsonConsole(); }) .Build(); using (var serviceScope = host.Services.CreateScope()) { var dbContext = serviceScope.ServiceProvider.GetRequiredService(); await dbContext.Database.EnsureCreatedAsync(); //init Database,you can add your init data here var userManager = serviceScope.ServiceProvider.GetRequiredService>(); var email = "weihanli@outlook.com"; if (await userManager.FindByEmailAsync(email) == null) { await userManager.CreateAsync(new UserAccount { UserName = email, Email = email }, "Test1234"); } }await host.RunAsync();

Startup 代码如下:
using System; using System.IdentityModel.Tokens.Jwt; using System.IO; using System.Linq; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using Prometheus; using SparkTodo.API.Services; using SparkTodo.API.Swagger; using SparkTodo.DataAccess; using Swashbuckle.AspNetCore.SwaggerGen; namespace SparkTodo.API { /// /// StartUp /// public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration.ReplacePlaceholders(); } public IConfiguration Configuration { get; } public void ConfigureServices(IServiceCollection services) { // Add framework services. services.AddDbContextPool(options => options.UseInMemoryDatabase("SparkTodo")); // services.AddIdentity(options => { options.Password.RequireLowercase = false; options.Password.RequireUppercase = false; options.Password.RequireNonAlphanumeric = false; options.Password.RequiredUniqueChars = 0; options.User.RequireUniqueEmail = true; }) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); // Add JWT token validation var secretKey = Configuration.GetAppSetting("SecretKey"); var signingKey = new SymmetricSecurityKey(System.Text.Encoding.ASCII.GetBytes(secretKey)); var tokenAudience = Configuration.GetAppSetting("TokenAudience"); var tokenIssuer = Configuration.GetAppSetting("TokenIssuer"); services.Configure(options => { options.Audience = tokenAudience; options.Issuer = tokenIssuer; options.ValidFor = TimeSpan.FromHours(2); options.SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); }); services.AddAuthentication(options => { options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultForbidScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => { options.TokenValidationParameters = new TokenValidationParameters { // The signing key must match! ValidateIssuerSigningKey = true, IssuerSigningKey = signingKey, // Validate the JWT Issuer (iss) claim ValidateIssuer = true, ValidIssuer = tokenIssuer, // Validate the JWT Audience (aud) claim ValidateAudience = true, ValidAudience = tokenAudience, // Validate the token expiry ValidateLifetime = true, // If you want to allow a certain amount of clock drift, set that here: ClockSkew = System.TimeSpan.FromMinutes(2) }; }); // Add MvcFramework services.AddControllers(); // Add api version // https://www.hanselman.com/blog/ASPNETCoreRESTfulWebAPIVersioningMadeEasy.aspx services.AddApiVersioning(options => { options.AssumeDefaultVersionWhenUnspecified = true; options.DefaultApiVersion = ApiVersion.Default; options.ReportApiVersions = true; }); // swagger // https://stackoverflow.com/questions/58197244/swaggerui-with-netcore-3-0-bearer-token-authorization services.AddSwaggerGen(option => { option.SwaggerDoc("spark todo", new OpenApiInfo { Version = "v1", Title = "SparkTodo API", Description = "API for SparkTodo", Contact = new OpenApiContact() { Name = "WeihanLi", Email = "weihanli@outlook.com" } }); option.SwaggerDoc("v1", new OpenApiInfo { Version = "v1", Title = "API V1" }); option.SwaggerDoc("v2", new OpenApiInfo { Version = "v2", Title = "API V2" }); option.DocInclusionPredicate((docName, apiDesc) => { var versions = apiDesc.CustomAttributes() .OfType() .SelectMany(attr => attr.Versions); return versions.Any(v => $"v{v}" == docName); }); option.OperationFilter(); option.DocumentFilter(); // include document file option.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, $"{typeof(Startup).Assembly.GetName().Name}.xml"), true); option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme() { Description = "Please enter into field the word 'Bearer' followed by a space and the JWT value", Name = "Authorization", In = ParameterLocation.Header, Type = SecuritySchemeType.ApiKey, }); option.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference = new OpenApiReference() { Id = "Bearer", Type = ReferenceType.SecurityScheme } }, Array.Empty() } }); }); services.AddHealthChecks(); // Add application services. services.AddSingleton(); //Repository services.RegisterAssemblyTypesAsImplementedInterfaces(t => t.Name.EndsWith("Repository"), ServiceLifetime.Scoped, typeof(IUserAccountRepository).Assembly); }public void Configure(IApplicationBuilder app) { // Disable claimType transform, see details here https://stackoverflow.com/questions/39141310/jwttoken-claim-name-jwttokentypes-subject-resolved-to-claimtypes-nameidentifie JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear(); // Emit dotnet runtime version to response header app.Use(async (context, next) => { context.Response.Headers["DotNetVersion"] = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription; await next(); }); //Enable middleware to serve generated Swagger as a JSON endpoint. app.UseSwagger(); //Enable middleware to serve swagger-ui (HTML, JS, CSS etc.), specifying the Swagger JSON endpoint app.UseSwaggerUI(option => { option.SwaggerEndpoint("/swagger/v2/swagger.json", "V2 Docs"); option.SwaggerEndpoint("/swagger/v1/swagger.json", "V1 Docs"); option.RoutePrefix = string.Empty; option.DocumentTitle = "SparkTodo API"; }); app.UseRouting(); app.UseCors(builder=> { builder.AllowAnyHeader().AllowAnyMethod().AllowCredentials().SetIsOriginAllowed(_=>true); }); app.UseHttpMetrics(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapHealthChecks("/health"); endpoints.MapMetrics(); endpoints.MapControllers(); }); } } }

使用 Minimal API 改造后是下面这样的:
DotNetRuntimeStatsBuilder.Customize() .WithContentionStats() .WithGcStats() .WithThreadPoolStats() .StartCollecting(); var builder = WebApplication.CreateBuilder(args); builder.Logging.AddJsonConsole(); // Add framework services. builder.Services.AddDbContextPool(options => options.UseInMemoryDatabase("SparkTodo")); // builder.Services.AddIdentity(options => { options.Password.RequireLowercase = false; options.Password.RequireUppercase = false; options.Password.RequireNonAlphanumeric = false; options.Password.RequiredUniqueChars = 0; options.User.RequireUniqueEmail = true; }) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); // Add JWT token validation var secretKey = builder.Configuration.GetAppSetting("SecretKey"); var signingKey = new SymmetricSecurityKey(System.Text.Encoding.ASCII.GetBytes(secretKey)); var tokenAudience = builder.Configuration.GetAppSetting("TokenAudience"); var tokenIssuer = builder.Configuration.GetAppSetting("TokenIssuer"); builder.Services.Configure(options => { options.Audience = tokenAudience; options.Issuer = tokenIssuer; options.ValidFor = TimeSpan.FromHours(2); options.SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); }); builder.Services.AddAuthentication(options => { options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultForbidScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => { options.TokenValidationParameters = new TokenValidationParameters { // The signing key must match! ValidateIssuerSigningKey = true, IssuerSigningKey = signingKey, // Validate the JWT Issuer (iss) claim ValidateIssuer = true, ValidIssuer = tokenIssuer, // Validate the JWT Audience (aud) claim ValidateAudience = true, ValidAudience = tokenAudience, // Validate the token expiry ValidateLifetime = true, // If you want to allow a certain amount of clock drift, set that here: ClockSkew = System.TimeSpan.FromMinutes(2) }; }); // Add MvcFramework builder.Services.AddControllers(); // Add api version // https://www.hanselman.com/blog/ASPNETCoreRESTfulWebAPIVersioningMadeEasy.aspx builder.Services.AddApiVersioning(options => { options.AssumeDefaultVersionWhenUnspecified = true; options.DefaultApiVersion = ApiVersion.Default; options.ReportApiVersions = true; }); // swagger // https://stackoverflow.com/questions/58197244/swaggerui-with-netcore-3-0-bearer-token-authorization builder.Services.AddSwaggerGen(option => { option.SwaggerDoc("spark todo", new OpenApiInfo { Version = "v1", Title = "SparkTodo API", Description = "API for SparkTodo", Contact = new OpenApiContact() { Name = "WeihanLi", Email = "weihanli@outlook.com" } }); option.SwaggerDoc("v1", new OpenApiInfo { Version = "v1", Title = "API V1" }); option.SwaggerDoc("v2", new OpenApiInfo { Version = "v2", Title = "API V2" }); option.DocInclusionPredicate((docName, apiDesc) => { var versions = apiDesc.CustomAttributes() .OfType() .SelectMany(attr => attr.Versions); return versions.Any(v => $"v{v}" == docName); }); option.OperationFilter(); option.DocumentFilter(); // include document file option.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"), true); option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme() { Description = "Please enter into field the word 'Bearer' followed by a space and the JWT value", Name = "Authorization", In = ParameterLocation.Header, Type = SecuritySchemeType.ApiKey, }); option.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference = new OpenApiReference() { Id = "Bearer", Type = ReferenceType.SecurityScheme } }, Array.Empty() } }); }); builder.Services.AddHealthChecks(); // Add application services. builder.Services.AddSingleton(); //Repository builder.Services.RegisterAssemblyTypesAsImplementedInterfaces(t => t.Name.EndsWith("Repository"), ServiceLifetime.Scoped, typeof(IUserAccountRepository).Assembly); var app = builder.Build(); // Disable claimType transform, see details here https://stackoverflow.com/questions/39141310/jwttoken-claim-name-jwttokentypes-subject-resolved-to-claimtypes-nameidentifie JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear(); // Emit dotnet runtime version to response header app.Use(async (context, next) => { context.Response.Headers["DotNetVersion"] = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription; await next(); }); //Enable middleware to serve generated Swagger as a JSON endpoint. app.UseSwagger(); //Enable middleware to serve swagger-ui (HTML, JS, CSS etc.), specifying the Swagger JSON endpoint app.UseSwaggerUI(option => { option.SwaggerEndpoint("/swagger/v2/swagger.json", "V2 Docs"); option.SwaggerEndpoint("/swagger/v1/swagger.json", "V1 Docs"); option.RoutePrefix = string.Empty; option.DocumentTitle = "SparkTodo API"; }); app.UseRouting(); app.UseCors(builder => { builder.AllowAnyHeader().AllowAnyMethod().AllowCredentials().SetIsOriginAllowed(_ => true); }); app.UseHttpMetrics(); app.UseAuthentication(); app.UseAuthorization(); app.MapHealthChecks("/health"); app.MapMetrics(); app.MapControllers(); using (var serviceScope = app.Services.CreateScope()) { var dbContext = serviceScope.ServiceProvider.GetRequiredService(); await dbContext.Database.EnsureCreatedAsync(); //init Database,you can add your init data here var userManager = serviceScope.ServiceProvider.GetRequiredService>(); var email = "weihanli@outlook.com"; if (await userManager.FindByEmailAsync(email) == null) { await userManager.CreateAsync(new UserAccount { UserName = email, Email = email }, "Test1234"); } } await app.RunAsync();

改造方法:
  • 原来 Program 里的 Host.CreateDefaultBuilder(args) 使用新的 var builder = WebApplication.CreateBuilder(args); 来代替
  • 原来 Program 里的 ConfigureLogging 使用 builder.Logging 来配置 builder.Logging.AddJsonConsole();
  • 原来 Program 里的 ConfigureAppConfiguration 使用 builder.Configuration.AddXxx 来配置 builder.Configuration.AddJsonFile("");
  • 原来 Startup 里的服务注册使用 builder.Services 来注册
  • 原来 Startup 里的配置是从构造器注入的,需要使用配置的话用 builder.Configuration 来代替
  • 原来 Startup 里中间件的配置,通过 var app = builder.Build(); 构建出来的 WebApplication 来注册
  • 原来 Program 里的 host.Run/host.RunAsync 需要改成 app.Run/app.RunAsync
More Minimal API 会有一些限制,比如
  • 不能通过 builder.WebHost.UseStartup() 通过 Startup 来注册服务和中间件的配置的
  • 不能通过 builder.Host.UseEnvironment/builder.Host.UseContentRoot/builder.WebHost.UseContentRoot/builder.WebHost.UseEnvironment/builder.WebHost.UseSetting 来配置 host 的一些配置
  • 现在的 WebApplication 实现了 IEndpointRouteBuilder,可以不用 UseEndpoints 来注册,比如可以直接使用 app.MapController() 代替 app.UseEndpoints(endpoints => endpoints.MapController())
更多可以参考 David 总结的一个迁移指南 https://gist.github.com/davidfowl/0e0372c3c1d895c3ce195ba983b1e03d
Minimal API 结合了原来的 Startup,不再有 Startup,但是原来的应用也可以不必迁移到 Minimal API,根据自己的需要进行选择
References
  • https://github.com/WeihanLi/SparkTodo/commit/d3e327405c0f151e89378e9c01acde4648a7812f
  • https://github.com/WeihanLi/SparkTodo
  • 【c#|.NET 6 迁移到 Minimal API】https://gist.github.com/davidfowl/0e0372c3c1d895c3ce195ba983b1e03d

    推荐阅读