ASP.NET Core可以視為一種底層框架,它為我們構建出了基于管道的請求處理模型,這個管道由一個服務器和多個中間件構成,而與路由相關的EndpointRoutingMiddleware和EndpointMiddleware是兩個最為重要的中間件。MVC和gRPC開發框架就建立在路由基礎上。本篇提供了四個實例用來演示如何利用路由、MVC和gRPC來開發API/APP。[本文節選《ASP.NET Core 6框架揭秘》第1章]
[113]路由的應用(源代碼)
[114]開發MVC API(源代碼)
[115]開發MVC APP(源代碼)
[116]開發gRPC API(源代碼)
[113]路由的應用
ASP.NET Core的路由是由EndpointRoutingMiddleware和EndpointMiddleware這兩個中間件實現的,在所有預定義的中間件類中,這應該算是最重要的兩個中間件了,因為不僅僅是MVC和gRPC框架建立在路由系統之上,后面介紹的Dapr.NET針對發布訂閱和Actor編程模式也是如此。如下面的代碼片段所示,我們在利用WebApplicationBuilder將代表承載應用的WebApplication對象構建出來之后,并沒有注冊任何的中間件,而是調用它的MapGet擴展方法注冊了一個指向路徑“/greet”的路由終結點(Endpoint)。該終結點的處理器是一個指向Greet方法的委托,意味著請求路徑為“/greet”的GET請求會路由到這個終結點,并最終調用這個方法進行處理。
using?App;
var?builder?=?WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IGreeter,?Greeter>().Configure<GreetingOptions>(builder.Configuration.GetSection("greeting"));
var?app?=?builder.Build();
app.MapGet("/greet",?Greet);
app.Run();static?string?Greet(IGreeter?greeter)?=>?greeter.Greet(DateTimeOffset.Now);
ASP.NET Core的路由系統的強大之處在于,我們可以使用任何類型的委托作為注冊終結點的處理器,路由系統在調用處理器方法之前會“智能地”提取相應的數據初始化每一個參數。當方法執行之后,它還會針對我們具體返回的對象來對請求實施響應。對于我們提供的Greet方法來說,路由系統在調用它之前會利用依賴注入容器提供作為參數的IGreeter對象。由于返回的是一個字符串,文本經過編碼后會直接作為響應的主體內容, 響應的內容類型(Content-Type)最終會被設置為“text/plain”。程序啟動之后,如果我們利用瀏覽器請求“/greet”這個路徑,針對當前時間解析出來的問候語會以圖1的形式呈現出來。
圖1 采用路由返回的問候
[114]開發MVC API
我們直接將上面演示的程序改寫成MVC應用。MVC應用以Controller為核心,所有的請求總是指向定義在某個Controller類型中的某個Action方法。當應用接收到請求之后,會激活對應的Controller對象,并通過執行對應的Action方法來處理該請求。按照約定,合法的Controller類型必須是以“Controller”作為后綴命名的公共實例類型。我們一般會讓定義的Controller類型派生自Controller基類以“借用”一些有用的API,但這不是必須的,比如下面定義的GreetingController就沒有指定基類。
public?class?GreetingController
{[HttpGet("/greet")]????public?string?Greet([FromServices]?IGreeter?greeter)?=>?greeter.Greet(DateTimeOffset.Now);
}
由于MVC框架是建立在路由系統之上的,定義在Controller類型中的Action方法最終會轉換成一個或者多個注冊到指定路徑模板的終結點。對于定義在GreetingController類型中的Action方法Greet來說,我們通過標注的HttpGetAttrbute特性不僅為對應的路由終結點定義了針對HTTP方法的約束(該終結點僅限于處理GET請求),還同時指定了綁定的請求路徑(“/greet”)。
依賴的服務可以直接注入到Controller類型中。具體來說,它支持兩種注入形式,一種是注入到構造函數中,另一種則是直接注入到Action方法中。對于方法注入,對應參數上必須標注一個FromServiceAttribute特性。我們IGreeter對象就是采用這種方式注入注入到Greet方法中的。和路由系統針對返回對象的處理方式一樣,MVC框架針對Action方法的返回值也會根據其類型進行針對性的處理。Greet方法直接返回的字符串會直接作為響應的主體內容,響應的內容類型(Content-Type)會被設置為“text/plain”。
在完成了針對GreetingController類型的定義之后,我們需要對入口程序進行如下的修改。如代碼片段所示,在完成了針對IGreeter服務的注冊和針對GreetingOptions配置選項的設置之后,我們調用同一個IServiceCollection對象的AddControllers擴展方法注冊了與Controller相關服務的注冊。在WebApplication對象被構建出來后,我們調用了它的MapControllers擴展方法將定義在所有Controller類型中的Action方法映射為對應的終結點。程序啟動之后,如果我們利用瀏覽器請求“/greet”這個路徑,我們依然會得到如圖1的所示的輸出結果。
using?App;
var?builder?=?WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IGreeter,?Greeter>().Configure<GreetingOptions>(builder.Configuration.GetSection("greeting")).AddControllers();
var?app?=?builder.Build();
app.MapControllers();
app.Run();
[115]開發MVC APP
上面改造的MVC程序并沒有涉及到視圖,請求的響應內容是由Action方法直接提供的,現在我們利用視圖來呈現最終響應的內容。由于上個例子調用IServiceCollection接口的AddControllers擴展方法只會注冊Controller相關的服務,現在我們得將其換成AddControllersWithViews方法。顧名思義,新的擴展方法會將視圖相關的服務添加進來。
using?App;
var?builder?=?WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IGreeter,?Greeter>().Configure<GreetingOptions>(builder.Configuration.GetSection("greeting")).AddControllersWithViews();
var?app?=?builder.Build();
app.MapControllers();
app.Run();
我們對GreetinigController進行了改造。如下面的代碼片段所示,我們讓它繼承Controller這個基類。Action方法Greet的返回類型改為IActionResult接口,具體返回的是通過View方法創建的代表默認視圖(針對當前Action方法)的ViewResult對象。在Action方法返回之前,它還利用對ViewBag的設置將當前時間傳遞到呈現的視圖中。
public?class?GreetingController?:?Controller
{[HttpGet("/greet")]????public?IActionResult?Greet(){ViewBag.Time?=?DateTimeOffset.Now;????????return?View();}
}
ASP.NET Core MVC采用Razior視圖引擎,視圖被定義成一個后綴名為.cshtml的文件,這是一個按照Razor語法編寫的靜態HTML和動態C#代碼動態交織的文本文件。由于上面為了呈現試圖調用的View方法沒有指定任何參數,所以視圖引擎會根據當前Controller的名稱(“Greeting”)和Action的名稱(“Greet”)去定位定義目標視圖的.cshtml文件。為了迎合默認的視圖定位規則,我們需要采用Action的名稱來命名創建的視圖文件(Greet.cshtml),并將其添加到“Views/Greeting”目錄下。
@using?App
@inject?IGreeter?Greeter;
<html><head><title>Greeting</title></head><body><p>@Greeter.Greet((DateTimeOffset)ViewBag.Time)</p></body>
</html>
上面這個代碼片段就是添加的視圖文件(Views/Greeting/Greet.cshtml)的內容。總體來說,這是一個HTML文檔,除了在主體部分呈現的問候語文本(前置的@字符定義動態執行的C#表達式)是根據指定時間動態解析出來的,其他內容則均為靜態的HTML。我們借助@inject指令將依賴的IGreeter對象以屬性的形式注入進來,并且將屬性名稱設置為Greeter,所以我們可以在視圖中直接調用它的Greet方法得到呈現的問候語。調用Greet方法指定的時間是GreetingController利用ViewBag傳遞過來的,所以我們可以直接利用它將其提取出來。程序啟動之后,如果我們利用瀏覽器請求“/greet”這個路徑,雖然瀏覽器也會呈現出相同的文本(如圖2所示),但是響應的內容是完全不同的。之前響應的僅僅是內容類型為“text/plain”的單純文本,現在響應則是一份完整的HTML文檔,內容類型為“text/html”。
圖2 以試圖形式返回的問候
[116]開發gRPC API
雖然Vistual Studio提供了創建gRPC的項目模板,該模板提供的腳手架會自動為我們創建一系列的初始文件,同時也會對項目做一些初始設置,但這反而是筆者不想要的,至少是不希望在這里使用這個模板。和前面一樣,我們希望演示的實例只包含最本質和必要的元素,所以我們選擇在一個空的解決方案上構建gRPC應用。
圖3 gRPC解決方案
如圖3所示,我們在一個空的解決方案上添加了三個項目。Proto是一個空的類庫項目,我們將會使用它來存放標準的Proto Buffers消息和gRPC服務的定義;Server是一個空的ASP.NET Core應用,gRPC服務的實現類型就放在這里,它同時也是承載gRPC服務的應用。Client是一個控制臺程序,我們用它來模擬調用gRPC服務的客戶端。gRPC是語言中立的遠程調用框架,gRPC服務契約使用到的數據類型都采用標準的定義方式。具體來說,gRPC傳輸的數據采用Proto Buffers協議進行序列化,Proto Buffers采用高效緊湊的二進制編碼。我們將用于定義數據類型和服務的Proto Buffers文件定義在Proto項目中,在這之前我們需要為這個空的類庫項目添加針對“Grpc.AspNetCore”這個NuGet包的引用。
不再使用簡單的“Hello World”,現在我們為演示的gPRC服務指定另一種稍微“復雜”一點的應用場景——用它來完成簡單的加、減、乘、除運算。我們在Proto項目中添加一個名為Calculator.proto的文本文件,并在其中以如下的形式將Calculator這個rGPC服務定義出來。如代碼片段所示,這個服務包含四個操作,它們的輸入和輸出都被定義成Proto Buffers消息。作為輸入的InputMessage消息包含兩個整型的數據成員(表示運算的兩個操作數)。返回的OutpuMessage消息除了通過result表示計算結果外,還具有status和error兩個成員,前者表示計算狀態(成功還是失敗),后者提供計算失敗時的錯誤消息。
syntax?=?"proto3";
option?csharp_namespace?=?"App";service?Calculator?{rpc?Add?(InputMessage)?returns?(OutpuMessage);rpc?Substract?(InputMessage)?returns?(OutpuMessage);rpc?Multiply?(InputMessage)?returns?(OutpuMessage);rpc?Divide?(InputMessage)?returns?(OutpuMessage);}message?InputMessage?{int32?x?=?1;int32?y?=?2;}message?OutpuMessage?{int32?status?=?1;int32?result?=?2;string?error?=?3;}
創建的Calculator.proto文件無法直接使用,我們需要利用內置的代碼生成器將它轉換成.cs代碼。具體的作為很簡單,我們只需要在Visual Studio的解決方案窗口中右鍵選擇這個文件,打開如圖4所示的屬性對話框。我們在Build Action下拉列表中選擇“Protobuf compiler”選項,同時在gRPC Stub Classes下拉列表中選擇“Client and Server”。
圖4 Calculator.proto文件屬性對話框
做了這樣的設置之后,在任何時對Calculator.proto文件所作的改變都將觸發代碼的自動生成,具體生成的.cs文件會自動保存在obj目錄下。由于在gRPC Stub Classes下拉列表中選擇了“Client and Server”選項,所以它不僅會生成服務端用來定義服務實現類型的Stub類,還會生成客戶端用來調用服務的Stub類。上面以可視化形式所作的設置最終會體現在項目文件(Proto.csproj)上,所以我們直接修改此文件也可以達到相同的目的,如下所示的就是這個文件的完整內容。
<Project?Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net6.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup><ItemGroup><None?Remove="Calculator.proto"?/></ItemGroup><ItemGroup><PackageReference?Include="Grpc.AspNetCore"?Version="2.40.0"?/></ItemGroup><ItemGroup><Protobuf?Include="Calculator.proto"?/></ItemGroup>
</Project>
Proto項目中的Calculator.proto文件僅僅是按照標準的形式定義的“服務契約”,我們需要在Server項目中定義具體的實現類型。在添加了針對Proto項目的引用之后,我們定義了如下這個名為CalculatorService的gRPC服務實現類型。如代碼片段所示,我們讓CalculatorService類型繼承自一個內嵌于Calculator中的CalculatorBase類型,這個Calculator類型就是根據Calculator.proto生成的一個類型。
public?class?CalculatorService?:?Calculator.CalculatorBase
{private?readonly?ILogger?_logger;public?CalculatorService(ILogger<CalculatorService>?logger)?=>?_logger?=?logger;public?override?Task<OutpuMessage>?Add(InputMessage?request,??ServerCallContext?context)?=>?InvokeAsync((op1,?op2)?=>?op1?+?op2,?request);public?override?Task<OutpuMessage>?Substract(InputMessage?request,????ServerCallContext?context)?=>?InvokeAsync((op1,?op2)?=>?op1?-?op2,?request);public?override?Task<OutpuMessage>?Multiply(InputMessage?request,?????ServerCallContext?context)?=>?InvokeAsync((op1,?op2)?=>?op1?*?op2,?request);public?override?Task<OutpuMessage>?Divide(InputMessage?request,?????ServerCallContext?context)?=>?InvokeAsync((op1,?op2)?=>?op1?/?op2,?request);private?Task<OutpuMessage>?InvokeAsync(Func<int,?int,?int>?calculate,?????InputMessage?input){OutpuMessage?output;try{output?=?new?OutpuMessage?{?Status?=?0,?Result?=?calculate(input.X,?input.Y)?};}catch?(Exception?ex){_logger.LogError(ex,?"Calculation?error.");output?=?new?OutpuMessage?{?Status?=?1,?Error?=?ex.ToString()?};}return?Task.FromResult(output);}
}
Calculator.proto文件為Calcultor服務定義的四個操作會轉換成CalculatorBase類型中對應的虛方法,我們按照上面的方式重寫了它們。在完成了針對gRPC服務實現類型的定義之后,我們需要對承載它的入口程序定義編寫如下的代碼。由于gRPC采用HTTP2傳輸協議,所以在利用WebApplicationBuilder的WebHost屬性得到對應的IWebHostBuilder對象,我們調用其ConfigureKestrel擴展方法讓默認注冊的Kestrel服務器監聽的終結點默認采用HTTP2協議。gRPC相關的服務通過調用IServiceCollection接口的AddGrpc擴展方法進行注冊。由于gRPC也是建立在路由系統之上的,定義在服務中的每個操作最終也會轉換成相應的路由終結點,這些終結點的生成和注冊是通過調用WebApplication對象的MapGrpcService<TService>擴展方法完成的。
using?App;
using?Microsoft.AspNetCore.Server.Kestrel.Core;
var?builder?=?WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(kestrel?=>?kestrel.ConfigureEndpointDefaults(?endpoint?=>?endpoint.Protocols?=??HttpProtocols.Http2));
builder.Services.AddGrpc();
var?app?=?builder.Build();
app.MapGrpcService<CalculatorService>();
app.Run();
Calculator.proto文件生成的代碼包含用來調用對應gRPC服務的Stub類,所以模擬客戶端的Client項目也需要添加對Proto項目的引用。在此之后,我們可以編寫如下的程序調用gRPC服務完成四種基本的數學運算。
using?App;using?Grpc.Core;using?Grpc.Net.Client;using?var?channel?=?GrpcChannel.ForAddress("http://localhost:5000");var?client?=?new?Calculator.CalculatorClient(channel);var?inputMessage?=?new?InputMessage?{?X?=?1,?Y?=?0?};await?InvokeAsync(input?=>?client.AddAsync(input),?inputMessage,?"+");await?InvokeAsync(input?=>?client.SubstractAsync(input),?inputMessage,?"-");await?InvokeAsync(input?=>?client.MultiplyAsync(input),?inputMessage,?"*");await?InvokeAsync(input?=>?client.DivideAsync(input),?inputMessage,?"/");static?async?Task?InvokeAsync(Func<InputMessage,?AsyncUnaryCall<OutpuMessage>>?invoker,??InputMessage?input,?string?@operator){var?output?=?await?invoker(input);if?(output.Status?==?0){Console.WriteLine($"{input.X}{@operator}{input.Y}={output.Result}");}else{Console.WriteLine(output.Error);}}
如上面的代碼片段所示,我們通過調用GrpcChannel類型的靜態方法ForAddress針對gRPC服務的地址“http://localhost:5000”創建了一個GrpcChannel對象,該對象表示與服務進行通信的“信道(Channel)”。我們利用它創建了一個CalculatorClient對象作為調用gRPC服務的客戶端或者代理,CalculatorClient類型同樣是內嵌在生成的Calculator類型中。最終我們利用這個代理完成了針對四種基本運算的服務調用,具體的gRPC調用實現在InvokeAsync這個本地方法中。接下來我們以命令行的方式先后啟動Server和Client應用,客戶端和服務端控制臺上會呈現出如圖5所示的輸出結果。由于我們傳入的參數分別為1和0,所以除了除法運算,其它三此調用都會返回成功的結果,針對除法的調用則會將錯誤信息呈現出來。由于CalculatorService進行了異常處理,并且將異常信息以日志的形式記錄了下來,所以錯誤信息也輸出到了服務端的控制臺上。
圖5 gRPC應用的承載與調用