随着 .Net6 的发布,微软也改进了对之前 ASP.NET Core 构建方式,使用了新的 Minimal API 模式。以前默认的方式是需要在 Startup 中注册 IOC 和中间件相关,但是在 Minimal API 模式下你只需要简单的写几行代码就可以构建一个 ASP.NET Core的Web 应用,可谓非常的简单,加之配合 c# 的 global using 和 Program 的顶级声明方式,使得 Minimal API 变得更为简洁,不得不说 .NET 团队在 .NET 上近几年下了不少功夫,接下来我们就来大致介绍下这种极简的使用模式。

1. 使用方式

使用 Visual Studio 2022 新建的 ASP.NET Core 6 的项目,默认的方式就是 Minimal API 模式,整个 Web 程序的结构看起来更加简单,再i加上微软对 Lambda 的改进,使其可以对 Lambda 参数进行 Attribute 标记,有的场景甚至可以放弃去定义 Controller 类了。

2. 几行代码构建Web程序

使用 Minimal API 最简单的方式就是能通过三行代码就可以构建一个 WebApi 的程序,代码如下:

var app = WebApplication.Create(args);
app.MapGet("/", () => "Hello World");
app.Run();

是的你没有看错,仅仅这样运行起来就可以,默认监听的 http://localhost:5000https://localhost:5001,所以直接在浏览器输入http://localhost:5000地址就可以看到浏览器输出Hello World字样。

3. 更改监听地址

如果你想更改它监听的服务端口,可以使用如下的方式:

var app = WebApplication.Create(args);
app.MapGet("/", () => "Hello World");
app.Run("http://localhost:6666");

如果想同时监听多个端口的话,可以使用如下的方式:

var app = WebApplication.Create(args);
app.Urls.Add("http://localhost:6666");
app.Urls.Add("http://localhost:8888");
app.MapGet("/", () => "Hello World");
app.Run();

或者是直接通过环境变量的方式设置监听信息,设置环境变量ASPNETCORE_URLS的值为完整的监听URL地址,这样的话就可以直接省略了在程序中配置相关信息了。

ASPNETCORE_URLS=http://localhost:6666

如果设置多个监听的URL地址的话可以在多个地址之间使用分号;隔开多个值:

ASPNETCORE_URLS=http://localhost:6666;https://localhost:8888

如果想监听本机所有Ip地址,可以使用如下方式:

var app = WebApplication.Create(args);
app.Urls.Add("http://*:6666");
app.Urls.Add("http://+:8888");
app.Urls.Add("http://0.0.0.0:9999");
app.MapGet("/", () => "Hello World");
app.Run();

同样的也可以使用添加环境变量的方式添加监听地址:

ASPNETCORE_URLS=http://*:6666;https://+:8888;http://0.0.0.0:9999

4. 日志操作

日志操作也是比较常用的操作,在Minimal API中微软干脆把它提出来,直接简化了操作,如下所示:

var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddJsonConsole();
var app = builder.Build();
app.Logger.LogInformation("读取到的配置信息:{content}", builder.Configuration.GetSection("consul").Get<ConsulOption>());
app.Run();

5. 基础环境配置

无论我们在之前的.Net Core开发或者现在的.Net6开发都有基础环境的配置,它包括 ApplicationName、ContentRootPath、 EnvironmentName相关,不过在Minimal API中,可以通过统一的方式去配置:

var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
    ApplicationName = typeof(Program).Assembly.FullName,
    ContentRootPath = Directory.GetCurrentDirectory(),
    EnvironmentName = Environments.Staging
});

Console.WriteLine($"应用程序名称: {builder.Environment.ApplicationName}");
Console.WriteLine($"环境变量: {builder.Environment.EnvironmentName}");
Console.WriteLine($"ContentRoot目录: {builder.Environment.ContentRootPath}");

var app = builder.Build();

或者是通过环境变量的方式去配置,最终实现的效果都是一样的。

  • ASPNETCORE_ENVIRONMENT
  • ASPNETCORE_CONTENTROOT
  • ASPNETCORE_APPLICATIONNAME

6. 主机相关设置

我们在之前的.Net Core开发模式中,程序的启动基本都是通过构建主机的方式,比如之前的Web主机或者后来的泛型主机,在Minimal API中同样可以进行这些操作,比如我们模拟一下之前泛型主机配置Web程序的方式:

var builder = WebApplication.CreateBuilder(args);
builder.Host.ConfigureDefaults(args).ConfigureWebHostDefaults(webBuilder =>
{
    webBuilder.UseStartup<Startup>();
});

var app = builder.Build();

如果只是配置Web主机的话Minimal API还提供了另一种更直接的方式,如下所示:

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseStartup<Startup>();
builder.WebHost.UseWebRoot("webroot");

var app = builder.Build();

7. 默认容器替换

很多时候我们在使用IOC的时候会使用其他三方的IOC框架,比如大家耳熟能详的Autofac,我们之前也介绍过其本质方式就是使用UseServiceProviderFactory中替换容器的注册和服务的提供,在Minimal API中可以使用如下的方式去操作:

var builder = WebApplication.CreateBuilder(args);
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
//之前在Startup中配置ConfigureContainer可以使用如下方式
builder.Host.ConfigureContainer<ContainerBuilder>(builder => builder.RegisterModule(new MyApplicationModule()));

var app = builder.Build();

8. 中间件相关

相信大家都已经仔细看过了WebApplication.CreateBuilder(args).Build()通过这种方式构建出来的是一个WebApplication类的实例,而WebApplication正是实现了 IApplicationBuilder接口。所以其本质还是和我们之前使用Startup中的Configure方法的方式是一致的,比如我们配置一个Swagger程序为例:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();
//判断环境变量
if (app.Environment.IsDevelopment())
{
    //异常处理中间件
    app.UseDeveloperExceptionPage();
    app.UseSwagger();
    app.UseSwaggerUI();
}
//启用静态文件
app.UseStaticFiles();

app.UseAuthorization();
app.MapControllers();

app.Run();

<p>常用的中间件配置还是和之前是一样的,因为本质都是IApplicationBuilder的扩展方法,我们这里简单列举一下:</p>

中间件名称 描述 API
Authentication 认证中间件 app.UseAuthentication()
Authorization 授权中间件. app.UseAuthorization()
CORS 跨域中间件. app.UseCors()
Exception Handler 全局异常处理中间件. app.UseExceptionHandler()
Forwarded Headers 代理头信息转发中间件. app.UseForwardedHeaders()
HTTPS Redirection Https重定向中间件. app.UseHttpsRedirection()
HTTP Strict Transport Security (HSTS) 特殊响应头的安全增强中间件. app.UseHsts()
Request Logging HTTP请求和响应日志中间件. app.UseHttpLogging()
Response Caching 输出缓存中间件. app.UseResponseCaching()
Response Compression 响应压缩中间件. app.UseResponseCompression()
Session Session中间件 app.UseSession()
Static Files 静态文件中间件. app.UseStaticFiles(), app.UseFileServer()
WebSockets WebSocket支持中间件. app.UseWebSockets()

9. 请求处理

我们可以使用WebApplication中的Map{HTTPMethod}相关的扩展方法来处理不同方式的Http请求,比如以下示例中处理Get、Post、Put、Delete相关的请求:

app.MapGet("/", () => "Hello GET");
app.MapPost("/", () => "Hello POST");
app.MapPut("/", () => "Hello PUT");
app.MapDelete("/", () => "Hello DELETE");

如果想让一个路由地址可以处理多种Http方法的请求,可以使用MapMethods方法,如下所示:

app.MapMethods("/multiple", new[] { "GET", "POST","PUT","DELETE" }, (HttpRequest req) => $"Current Http Method Is {req.Method}" );

通过上面的示例我们不仅看到了处理不同Http请求的方式,还可以看到Minimal Api可以根据委托的类型自行推断如何处理请求,比如上面的示例,我们没有写Response Write相关的代码,但是输出的却是委托里的内容,因为我们上面示例中的委托都满足Func<string>的形式,所以Minimal Api自动处理并输出返回的信息,其实只要满足委托类型的它都可以处理,接下来咱们来简单一下,首先是本地函数的形式:

static string LocalFunction() => "This is local function";
app.MapGet("/local-fun", LocalFunction);

还可以是类的实例方法:

HelloHandler helloHandler = new HelloHandler();
app.MapGet("/instance-method", helloHandler.Hello);

class HelloHandler
{
    public string Hello()
    {
        return "Hello World";
    }
}

亦或者是类的静态方法:

app.MapGet("/static-method", HelloHandler.SayHello);

class HelloHandler
{
    public static string SayHello(string name)
    {
        return $"Hello {name}";
    }
}

其实本质都是一样的,那就是将他们转换为可执行的委托,无论什么样的形式,能满足委托的条件即可。

10. 路由约束

Minimal Api还支持在对路由规则的约束,这个和我们之前使用UseEndpoints的方式类似,比如我约束路由参数只能为整型,如果不满足的话会返回404。

app.MapGet("/users/{userId:int}", (int userId) => $"user id is {userId}");
app.MapGet("/user/{name:length(20)}", (string name) => $"user name is {name}");

经常使用的路由约束还有其他几个,也不是很多大概有如下几种,简单的列一下表格:

限制 示例 匹配示例 说明
int {id:int} 123456789, -123456789 匹配任何整数
bool {active:bool} true, false 匹配 truefalse. 忽略大小写
datetime {dob:datetime} 2016-12-31, 2016-12-31 7:32pm 匹配满足DateTime类型的值
decimal {price:decimal} 49.99, -1,000.01 匹配满足 decimal类型的值
double {height:double} 1.234, -1,001.01e8 匹配满足 double 类型的值
float {height:float} 1.234, -1,001.01e8 匹配满足 float 类型的值
guid {id:guid} CD2C1638-1638-72D5-1638-DEADBEEF1638 匹配满足Guid类型的值
long {ticks:long} 123456789, -123456789 匹配满足 long 类型的值
minlength(value) {username:minlength(4)} KOBE 字符串长度必须是4个字符
maxlength(value) {filename:maxlength(8)} CURRY 字符串长度不能超过8个字符
length(length) {filename:length(12)} somefile.txt 字符串的字符长度必须是12个字符
length(min,max) {filename:length(8,16)} somefile.txt 字符串的字符长度必须介于8和l6之间
min(value) {age:min(18)} 20 整数值必须大于18
max(value) {age:max(120)} 119 整数值必须小于120
range(min,max) {age:range(18,120)} 100 整数值必须介于18和120之间
alpha {name:alpha} Rick 字符串必须由一个或多个a-z的字母字符组成,且不区分大小写。
regex(expression) {ssn:regex(^\d{{3}}-\d{{2}}-\d{{4}}$)} 123-45-6789 字符串必须与指定的正则表达式匹配。
required {name:required} JAMES 请求信息必须包含该参数

11. 模型绑定

在我们之前使用ASP.NET Core Controller方式开发的话,模型绑定是肯定会用到的,它的作用就是简化我们解析Http请求信息也是MVC框架的核心功能,它可以将请求信息直接映射成c#的简单类型或者POCO上面。在Minimal Api的Map{HTTPMethod}相关方法中同样可以进行丰富的模型绑定操作,目前可以支持的绑定源有如下几种:

  • Route(路由参数)
  • QueryString
  • Header
  • Body(比如JSON)
  • Services(即通过IServiceCollection注册的类型)
  • 自定义绑定

1) 绑定示例
接下来我们首先看一下绑定路由参数:

app.MapGet("/sayhello/{name}", (string name) => $"Hello {name}");

还可以使用路由和querystring的混用方式:

app.MapGet("/sayhello/{name}", (string name,int? age) => $"my name is {name},age {age}");

这里需要注意的是,我的age参数加了可以为空的标识,如果不加的话则必须要在url的请求参数中传递age参数,否则将报错,这个和我们之前的操作还是有区别的。

具体的类也可以进行模型绑定,比如咱们这里定义了名为Goods的POCO进行演示:

app.MapPost("/goods",(Goods goods)=>$"商品{goods.GName}添加成功");

class Goods
{
    public int GId { get; set; }
    public string GName { get; set; }
    public decimal Price { get; set; }
}

需要注意的是HTTP方法GET、HEAD、OPTIONS、DELETE将不会从body进行模型绑定,如果需要在Get请求中获取Body信息,可以直接从HttpRequest中读取它。

如果我们需要使用通过IServiceCollection注册的具体实例,可以以通过模型绑定的方式进行操作(很多人喜欢叫它方法注入,但是严格来说却是是通过定义模型绑定的相关操作实现的),而且还简化了具体操作,我们就不需要在具体的参数上进行FromServicesAttribute标记了。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<Person>(provider => new() { Id = 1, Name = "yi念之间", Sex = "Man" });
var app = builder.Build();

app.MapGet("/", (Person person) => $"Hello {person.Name}!");
app.Run();

如果是混合使用的话,也可以不用指定具体的BindSource进行标记了,前提是这些值的名称在不同的绑定来源中是唯一的,这种感觉让我想到了刚开始学习MVC4.0的时候模型绑定的随意性,比如下面的例子:

app.MapGet("/sayhello/{name}", (string name,int? age,Person person) => $"my name is {name},age {age}, sex {person.Sex}");

上面示例的模型绑定参数来源可以是:

参数 绑定来源
name 路由参数
age querystring
person 依赖注入

不仅仅如此,它还支持更复杂的方式,这使得模型绑定更为灵活,比如以下示例:

app.MapPost("/goods",(Goods goods, Person person) =>$"{person.Name}添加商品{goods.GName}成功");

它的模型绑定的值来源可以是:

参数 绑定来源
goods body里的json
person 依赖注入

当然如果你想让模型绑定的来源更清晰,或者就想指定具体参数的绑定来源那也是可以的,反正就是各种灵活,比如上面的示例改造一下,这样就可以显示声明:

app.MapPost("/goods",([FromBody]Goods goods, [FromServices]Person person) =>$"{person.Name}添加商品{goods.GName}成功");

很多时候我们可能通过定义类和方法的方式来声明Map相关方法的执行委托,这个时候呢依然可以进行灵活的模型绑定,而且可能你也发现了,直接通过lambda表达式的方式虽然支持可空类型,但是它不支持缺省参数,也就是咱们说的方法默认参数的形式,比如:

app.MapPost("/goods", GoodsHandler.AddGoods);

class GoodsHandler
{
    public static string AddGoods(Goods goods, Person person, int age = 20) => $"{person.Name}添加商品{goods.GName}成功";
}

当然你也可以对AddGoods方法的参数进行显示的模型绑定处理,十分的灵活。

public static string AddGoods([FromBody] Goods goods, [FromServices] Person person, [FromQuery]int age = 20) => $"{person.Name}添加商品{goods.GName}成功";

在使用Map相关方法的时候,由于是在Program入口程序或者其他POCO中直接编写相关逻辑的,因此需要用到HttpContext、HttpRequest、HttpResponse相关实例的时候没办法进行直接操作,这个时候也需要通过模型绑定的方式获取对应实例:

app.MapGet("/getcontext",(HttpContext context,HttpRequest request,HttpResponse response) => response.WriteAsync($"IP:{context.Connection.RemoteIpAddress},Request Method:{request.Method}"));

2) 自定义绑定
Minimal Api采用了一种新的方式来自定义模型绑定,这种方式是一种基于约定的方式,无需提前注册,也无需集成什么类或者实现什么接口,只需要在自定义的类中存在TryParse和BindAsync方法即可,这两个方法的区别如下:

TryParse方法是对路由参数、url参数、header相关的信息进行转换绑定
BindAsync可以对任何请求的信息进行转换绑定,功能比TryParse要强大
接下来我们分别演示一下这两种方式的使用方法,首先是TryParse方法:

app.MapGet("/address/getarray",(Address address) => address.Addresses);

public class Address
{
    public List<string>? Addresses { get; set; }

    public static bool TryParse(string? addressStr, IFormatProvider? provider, out Address? address)
    {
        var addresses = addressStr?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        if (addresses != null && addresses.Any())
        {
            address = new Address { Addresses = addresses.ToList() };
            return true;
        }
        address = new Address();
        return false;
    }
}

这样就可以完成简单的转换绑定操作,从写法上我们可以看到,TryParse方法确实存在一定的限制,不过操作起来比较简单,这个时候我们模拟请求:

http://localhost:5036/address/getarray?address=山东,山西,河南,河北

请求完成会得到如下结果:

["山东","山西","河南", "河北"]

然后我们改造一下上面的例子使用BindAsync的方式进行结果转换,看一下它们操作的不同:

app.MapGet("/address/getarray",(Address address) => address.Addresses);

public class Address
{
    public List<string>? Addresses { get; set; }

    public static ValueTask<Address?> BindAsync(HttpContext context, ParameterInfo parameter)
    {
        string addressStr = context.Request.Query["address"];
        var addresses = addressStr?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        Address address = new();
        if (addresses != null && addresses.Any())
        {
            address.Addresses = addresses.ToList();
            return ValueTask.FromResult<Address?>(address);
        }
        return ValueTask.FromResult<Address?>(address);
    }
}

同样请求http://localhost:5036/address...山东,山西,河南,河北 地址会得到和上面相同的结果,到底如何选择同学们可以按需使用,得到的效果都是一样的。如果类中同时存在TryParse和BindAsync方法,那么只会执行BindAsync方法。

输出结果:

相信通过上面的其他示例演示,我们大概看到了一些在Minimal Api中的结果输出,总结起来其实可以分为三种情况:

IResult 结果输出,可以包含任何值得输出,包含异步任务Task<IResult>和ValueTask<IResult>
string 文本类型输出,包含异步任务Task<string>和ValueTask<string>
T 对象类型输出,比如自定义的实体、匿名对象等,包含异步任务 Task<T>和ValueTask<T>
接下来简单演示几个例子来简单看一下具体是如何操作的,首先最简单的就是输出文本类型:

app.MapGet("/hello", () => "Hello World");

然后输出一个对象类型,对象类型可以包含对象或集合甚至匿名对象,或者是咱们上面演示过的HttpResponse对象,这里的对象可以理解为面向对象的那个对象,满足Response输出要求即可。

app.MapGet("/simple", () => new { Message = "Hello World" });
    //或者是
    app.MapGet("/array",()=>new string[] { "Hello", "World" });
    //亦或者是EF的返回结果
    app.Map("/student",(SchoolContext dbContext,int classId)=>dbContext.Student.Where(i=>i.ClassId==classId));

还有一种是微软帮我们封装好的一种形式,即返回的是IResult类型的结果,微软也是很贴心的为我们统一封装了一个静态的Results类,方便我们使用,简单演示一下这种操作:

//成功结果
    app.MapGet("/success",()=> Results.Ok("Success"));
    //失败结果
    app.MapGet("/fail", () => Results.BadRequest("fail"));
    //404结果
    app.MapGet("/404", () => Results.NotFound());
    //根据逻辑判断返回
    app.Map("/student", (SchoolContext dbContext, int classId) => {
        var classStudents = dbContext.Student.Where(i => i.ClassId == classId);
        return classStudents.Any() ? Results.Ok(classStudents) : Results.NotFound();
    });

上面我们也提到了Results类其实是微软帮我们多封装了一层,它里面的所有静态方法都是返回IResult的接口实例,这个接口有许多实现的类,满足不同的输出结果,比如Results.File("foo.text")方法,本质就是返回一个FileContentResult类型的实例。

public static IResult File(byte[] fileContents,string? contentType = null,
    string? fileDownloadName = null,
    bool enableRangeProcessing = false,
    DateTimeOffset? lastModified = null,
    EntityTagHeaderValue? entityTag = null)
    => new FileContentResult(fileContents, contentType)
    {
        FileDownloadName = fileDownloadName,
        EnableRangeProcessing = enableRangeProcessing,
        LastModified = lastModified,
        EntityTag = entityTag,
    };

亦或者Results.Json(new { Message="Hello World" }),本质就是返回一个JsonResult类型的实例。

public static IResult Json(object? data, JsonSerializerOptions? options = null, string? contentType = null, int? statusCode = null)
                => new JsonResult
                {
                    Value = data,
                    JsonSerializerOptions = options,
                    ContentType = contentType,
                    StatusCode = statusCode,
                };

当然我们也可以自定义IResult的实例,比如我们要输出一段html代码。

微软很贴心的为我们提供了专门扩展Results的扩展类IResultExtensions基于这个类我们才能完成IResult的扩展:

static class ResultsExtensions
    {
        //基于IResultExtensions写扩展方法
        public static IResult Html(this IResultExtensions resultExtensions, string html)
        {
            ArgumentNullException.ThrowIfNull(resultExtensions, nameof(resultExtensions));
            //自定义的HtmlResult是IResult的实现类
            return new HtmlResult(html);
        }
    }
    
    class HtmlResult:IResult
    {
        //用于接收html字符串
        private readonly string _html;
    
        public HtmlResult(string html)
        {
            _html = html;
        }
    
        /// <summary>
        /// 在该方法写自己的输出逻辑即可
        /// </summary>
        /// <returns></returns>
        public Task ExecuteAsync(HttpContext httpContext)
        {
            httpContext.Response.ContentType = MediaTypeNames.Text.Html;
            httpContext.Response.ContentLength = Encoding.UTF8.GetByteCount(_html);
            return httpContext.Response.WriteAsync(_html);
        }
    }

定义完成这些我们就可以直接在Results类中使用我们定义的扩展方法了,使用方式如下:

app.MapGet("/hello/{name}", (string name) => Results.Extensions.Html(@$"<html>
        <head><title>Index</title></head>
        <body>
            <h1>Hello {name}</h1>
        </body>
    </html>"));

这里需要注意的是,我们自定义的扩展方法一定是基于IResultExtensions扩展的,然后再使用的时候注意是使用的Results.Extensions这个属性,因为这个属性是IResultExtensions类型的,然后就是我们自己扩展的Results.Extensions.Html方法。

12. 总结

本文我们主要是介绍了ASP.NET Core 6 Minimal API的常用的使用方式,相信大家对此也有了一定的了解,在.NET6中也是默认的项目方式,整体来说却是非常的简单、简洁、强大、灵活,不得不说Minimal API却是在很多场景都非常适用的。当然我也在其它地方看到过关于它的评价,褒贬不一吧,笔者认为,没有任何一种技术是银弹,存在即合理。如果你的项目够规范够合理,那么使用Minimal API绝对够用,如果不想用或者用不了也没关系,能最终实现需要的结果就好。

参考资料

  1. C#教程
  2. 编程宝库

编程宝库
7 声望1 粉丝

编程宝库(www.codebaoku.com)站长,创业公司技术合伙人