ASP.NET Core可以視為一種底層框架,它為我們構建出了基於管道的請求處理模型,這個管道由一個服務器和多個中間件構成,而與路由相關的EndpointRoutingMiddleware和EndpointMiddleware是兩個最為重要的中間件。MVC和gRPC開發框架就建立在路由基礎上。本篇提供了四個實例用來演示如何利用路由、MVC和gRPC來開發API/APP。(本篇提供的實例已經匯總到《ASP.NET Core 6框架揭秘-實例演示版》)
[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的形式呈現出來。
[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”。
[116]開發gRPC API
雖然Vistual Studio提供了創建gRPC的項目模板,該模板提供的腳手架會自動為我們創建一系列的初始文件,同時也會對項目做一些初始設置,但這反而是筆者不想要的,至少是不希望在這里使用這個模板。和前面一樣,我們希望演示的實例只包含最本質和必要的元素,所以我們選擇在一個空的解決方案上構建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”。
做了這樣的設置之后,在任何時對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應用的承載與調用


![clip_image002[5] clip_image002[5]](/image/aHR0cHM6Ly9pbWcyMDIyLmNuYmxvZ3MuY29tL2Jsb2cvMTkzMjcvMjAyMjAyLzE5MzI3LTIwMjIwMjE1MDgxNzE2MDI2LTEzODc3MzA1OTkuanBn.png)


