簡單聊下.NET6 Minimal API的使用方式

yi念之間發表於2021-12-02

前言

    隨著.Net6的釋出,微軟也改進了對之前ASP.NET Core構建方式,使用了新的Minimal API模式。之前預設的方式是需要在Startup中註冊IOC和中介軟體相關,但是在Minimal API模式下你只需要簡單的寫幾行程式碼就可以構建一個ASP.NET Core的Web應用,真可謂非常的簡單,加之配合c#的global using和Program的頂級宣告方式,使得Minimal API變得更為簡潔,不得不說.NET團隊在,NET上近幾年真是下了不少功夫,接下來我們就來大致介紹下這種極簡的使用模式。

使用方式

既然說它很簡單了,到底是怎麼個簡單法呢。相信下載過Visual Studio 2022的同學們已經用它新建過ASP.NET Core 6的專案了,預設的方式就是Minimal API模式,這樣讓整個Web程式的結構看起來更簡單了,加上微軟對Lambda的改進使其可以對Lambda引數進行Attribute標記,有的場景甚至可以放棄去定義Controller類了。

幾行程式碼構建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字樣。

更改監聽地址

如果你想更改它監聽的服務埠可以使用如下的方式進行更改

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

日誌操作

日誌操作也是比較常用的操作,在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();

基礎環境配置

無論我們在之前的.Net Core開發或者現在的.Net6開發都有基礎環境的配置,它包括 ApplicationNameContentRootPath 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

主機相關設定

我們在之前的.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();

預設容器替換

很多時候我們在使用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();

中介軟體相關

相信大家都已經仔細看過了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();

常用的中介軟體配置還是和之前是一樣的,因為本質都是IApplicationBuilder的擴充套件方法,我們這裡簡單列舉一下

中介軟體名稱 描述 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()

請求處理

我們可以使用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}";
    }
}

其實本質都是一樣的,那就是將他們轉換為可執行的委託,無論什麼樣的形式,能滿足委託的條件即可。

路由約束

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 請求資訊必須包含該引數

模型繫結

在我們之前使用ASP.NET Core Controller方式開發的話,模型繫結是肯定會用到的,它的作用就是簡化我們解析Http請求資訊也是MVC框架的核心功能,它可以將請求資訊直接對映成c#的簡單型別或者POCO上面。在Minimal Api的Map{HTTPMethod}相關方法中同樣可以進行豐富的模型繫結操作,目前可以支援的繫結源有如下幾種

  • Route(路由引數)
  • QueryString
  • Header
  • Body(比如JSON)
  • Services(即通過IServiceCollection註冊的型別)
  • 自定義繫結
繫結示例

接下來我們首先看一下繫結路由引數

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}"));
自定義繫結

Minimal Api採用了一種新的方式來自定義模型繫結,這種方式是一種基於約定的方式,無需提前註冊,也無需整合什麼類或者實現什麼介面,只需要在自定義的類中存在TryParseBindAsync方法即可,這兩個方法的區別是

  • 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/getarray?address=山東,山西,河南,河北 地址會得到和上面相同的結果,到底如何選擇同學們可以按需使用,得到的效果都是一樣的。如果類中同時存在TryParseBindAsync方法,那麼只會執行BindAsync方法。

輸出結果

相信通過上面的其他示例演示,我們大概看到了一些在Minimal Api中的結果輸出,總結起來其實可以分為三種情況

  1. IResult 結果輸出,可以包含任何值得輸出,包含非同步任務Task<IResult>ValueTask<IResult>
  2. string 文字型別輸出,包含非同步任務Task<string>ValueTask<string>
  3. 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方法。

總結

    本文我們主要是介紹了ASP.NET Core 6 Minimal API的常用的使用方式,相信大家對此也有了一定的瞭解,在.NET6中也是預設的專案方式,整體來說卻是非常的簡單、簡潔、強大、靈活,不得不說Minimal API卻是在很多場景都非常適用的。當然我也在其它地方看到過關於它的評價,褒貶不一吧,筆者認為,沒有任何一種技術是銀彈,存在即合理。如果你的專案夠規範夠合理,那麼使用Minimal API絕對夠用,如果不想用或者用不了也沒關係,能實現你想要結果就好了,其實也沒啥好評價的。

?歡迎掃碼關注我的公眾號? 簡單聊下.NET6 Minimal API的使用方式

相關文章