.NET 6 迁移到 Minimal API

Posted dotNET跨平台

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了.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 模板项目结构如下创建新的项目,结构如下:

主要变化的结构如下:

  • 默认启用了可空引用类型(<Nullable>enable</Nullable>)和隐式命名空间引用(<ImplicitUsings>enable</ImplicitUsings>)(可以参考项目文件的变化)

  • 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 新特性所以还是之前的项目模板

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<Startup>();
    })
    .ConfigureLogging(loggingBuilder =>
    {
        loggingBuilder.AddJsonConsole();
    })
    .Build();

using (var serviceScope = host.Services.CreateScope())
{
    var dbContext = serviceScope.ServiceProvider.GetRequiredService<SparkTodoDbContext>();
    await dbContext.Database.EnsureCreatedAsync();

    //init Database,you can add your init data here
    var userManager = serviceScope.ServiceProvider.GetRequiredService<UserManager<UserAccount>>();
    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
{
    /// <summary>
    /// StartUp
    /// </summary>
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration.ReplacePlaceholders();
        }
        public IConfiguration Configuration { get; }
        public void ConfigureServices(IServiceCollection services)
        {
            // Add framework services.
            services.AddDbContextPool<SparkTodo.Models.SparkTodoDbContext>(options => options.UseInMemoryDatabase("SparkTodo"));
            //
            services.AddIdentity<SparkTodo.Models.UserAccount, SparkTodo.Models.UserRole>(options =>
                {
                    options.Password.RequireLowercase = false;
                    options.Password.RequireUppercase = false;
                    options.Password.RequireNonAlphanumeric = false;
                    options.Password.RequiredUniqueChars = 0;
                    options.User.RequireUniqueEmail = true;
                })
                .AddEntityFrameworkStores<SparkTodo.Models.SparkTodoDbContext>()
                .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<JWT.TokenOptions>(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<ApiVersionAttribute>()
                        .SelectMany(attr => attr.Versions);

                    return versions.Any(v => $"v{v}" == docName);
                });

                option.OperationFilter<RemoveVersionParameterOperationFilter>();
                option.DocumentFilter<SetVersionInPathDocumentFilter>();

                // 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<string>() }
                });
            });
            services.AddHealthChecks();
            // Add application services.
            services.AddSingleton<ITokenGenerator, TokenGenerator>();
            //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<SparkTodo.Models.SparkTodoDbContext>(options => options.UseInMemoryDatabase("SparkTodo"));
//
builder.Services.AddIdentity<SparkTodo.Models.UserAccount, SparkTodo.Models.UserRole>(options =>
{
    options.Password.RequireLowercase = false;
    options.Password.RequireUppercase = false;
    options.Password.RequireNonAlphanumeric = false;
    options.Password.RequiredUniqueChars = 0;
    options.User.RequireUniqueEmail = true;
})
    .AddEntityFrameworkStores<SparkTodo.Models.SparkTodoDbContext>()
    .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<SparkTodo.API.JWT.TokenOptions>(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<ApiVersionAttribute>()
            .SelectMany(attr => attr.Versions);

        return versions.Any(v => $"v{v}" == docName);
    });

    option.OperationFilter<RemoveVersionParameterOperationFilter>();
    option.DocumentFilter<SetVersionInPathDocumentFilter>();

    // 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<string>() }
                });
});
builder.Services.AddHealthChecks();
// Add application services.
builder.Services.AddSingleton<ITokenGenerator, TokenGenerator>();
//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<SparkTodoDbContext>();
    await dbContext.Database.EnsureCreatedAsync();

    //init Database,you can add your init data here
    var userManager = serviceScope.ServiceProvider.GetRequiredService<UserManager<UserAccount>>();
    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>() 通过 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

  • https://gist.github.com/davidfowl/0e0372c3c1d895c3ce195ba983b1e03d

以上是关于.NET 6 迁移到 Minimal API的主要内容,如果未能解决你的问题,请参考以下文章

从 MVC 到使用 ASP.NET Core 6.0 的Minimal API

.NET 6使用.NET 6开发minimal api以及依赖注入的实现VS2022热重载和自动反编译功能的演示

ASP.NET Core 6 Minimal API

创建API服务最小只要4行代码!!!尝新体验ASP.NET Core 6预览版本中的最小Web API(minimal APIS)新特性

《ASP.NET Core 6框架揭秘》实例演示[27]:ASP.NET Core 6 Minimal API的模拟实现

使用 ASP.NET Core 6 Minimal API 在 Azure 中停止 Web 应用程序时未调用 BackgroundService StopAsync