ASP.NET Core 6框架揭秘实例演示[02]:基于路由、MVC和gRPC的应用开发
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应用的承载与调用