这里还是基于对《Pro ASP.NET Core 3.0》的学习,练习一下在Visual Studio Code中创建ASP.NET Core应用。这里将从头开始创建一个Web项目,然后运行EntityFrameworkCore来访问数据库,然后在此基础上使用Endpoint手动来实现一个WebService,最后运用Controller来实现WebService。

创建项目


    在PowerShell中运行以下命令,来创建项目的基本框架。

PS D:\Study\ASPNETCORESTUDY> dotnet new globaljson --sdk-version 5.0 --output WebApp
PS D:\Study\ASPNETCORESTUDY> dotnet new web --no-https --output WebApp --framework net5.0
PS D:\Study\ASPNETCORESTUDY> dotnet new sln -o WebApp
PS D:\Study\ASPNETCORESTUDY> dotnet sln WebApp add WebApp

    这些命令在之前的文章中介绍过,基本就是先创建一个globaljson配置文件,然后创建项目模板,然后创建解决方案文件,最后将项目加到解决方案中。

添加数据模型


    这部分主要是准备数据模型,创建数据库迁移,以及准备测试数据,这里涉及到EntityFramework Core的使用。

Step1:添加EntityFramework Core相关的Nuget包,通过如下命令可以实现,或者通过Nuget包管理器也可以添加,这里通过如下命令进行:

dotnet add package Microsoft.EntityFrameworkCore.Design --version 5.0.7
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 5.0.7

    然后添加dotnet ef工具来创建和管理EntityFramework的迁移:

dotnet tool uninstall --global  dotnet-ef
dotnet tool install --global dotnet-ef --version 5.0.7

Step2:接下来就是创建数据模型。这个例子包含商品Product,商品类别Category和商品供货商Supplier三个类,在项目目录下新建Models文件夹,分别创建三个类,内容如下。

using System.ComponentModel.DataAnnotations.Schema;
namespace WebApp.Models
{
    public class Product
    {
        public long ProductId { get; set; }
        public string Name { get; set; }
        [Column(TypeName = "decimal(8,2)")]
        public decimal Price { get; set; }

        public long CategoryId { get; set; }
        public Category Category { get; set; }

        public long SupplierId { get; set; }
        public Supplier Supplier { get; set; }
    }
}

using System.Collections.Generic;
namespace WebApp.Models
{
    public class Supplier
    {
        public long SupplierId { get; set; }
        public string Name { get; set; }
        public string City { get; set; }
        public IEnumerable<Product> Products { get; set; }
    }
}

using System.Collections.Generic;
namespace WebApp.Models
{
    public class Category
    {
        public long CategoryId { get; set; }
        public string Name { get; set; }
        public IEnumerable<Product> Products { get; set; }
    }
}

    接下来,创建DatabaseContext,用来生成和访问数据库。

using Microsoft.EntityFrameworkCore;
namespace WebApp.Models
{
    public class DataContext : DbContext
    {
        public DataContext(DbContextOptions<DataContext> opts) : base(opts)
        {

        }

        public DbSet<Product> Products { get; set; }
        public DbSet<Category> Categories { get; set; }
        public DbSet<Supplier> Suppliers { get; set; }
    }
}

Step3:初始化测试数据,新建SeedData类,准备用于测试的数据。

using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace WebApp.Models
{
    public static class SeedData
    {
        public static void SeedDatabase(DataContext context)
        {
            context.Database.Migrate();
            if (context.Products.Count() == 0 && context.Suppliers.Count() == 0 && context.Categories.Count() == 0)
            {
                Supplier s1 = new Supplier { Name = "Splash Dudes", City = "San Jose" };
                Supplier s2 = new Supplier { Name = "Soccer Town", City = "Chicago" };
                Supplier s3 = new Supplier { Name = "Chess Co", City = "New York" };
                Category c1 = new Category { Name = "Watersports" };
                Category c2 = new Category { Name = "Soccer" };
                Category c3 = new Category { Name = "Chess" };

                context.Products.AddRange(
                new Product
                {
                    Name = "Kayak",
                    Price = 275,
                    Category = c1,
                    Supplier = s1
                }, new Product
                {
                    Name = "Lifejacket",
                    Price = 48.95m,
                    Category = c1,
                    Supplier = s1
                },
                new Product
                {
                    Name = "Soccer Ball",
                    Price = 19.50m,
                    Category = c2,
                    Supplier = s2
                },
                new Product
                {
                    Name = "Corner Flags",
                    Price = 34.95m,
                    Category = c2,
                    Supplier = s2
                },
                new Product
                {
                    Name = "Stadium",
                    Price = 79500,
                    Category = c2,
                    Supplier = s2
                },
                new Product
                {
                    Name = "Thinking Cap",
                    Price = 16,
                    Category = c3,
                    Supplier = s3
                },
                new Product
                {
                    Name = "Unsteady Chair",
                    Price = 29.95m,
                    Category = c3,
                    Supplier = s3
                },
                new Product
                {
                    Name = "Human Chess Board",
                    Price = 75,
                    Category = c3,
                    Supplier = s3
                },
                new Product
                {
                    Name = "Bling-Bling King",
                    Price = 1200,
                    Category = c3,
                    Supplier = s3
                });
                context.SaveChanges();
            }
        }
    }
}

    SeedData会将所有的变更应用到数据库,然后初始化数据库里的数据。EntityFramework会自动帮我们生成对象之间的引用关系,比如主键和外键的对应关系。

Step4:配置EntityFrameworkCore和相关服务。现在需要配置数据库连接字符串,以及将数据库字符串配置到应用中。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using WebApp.Models;

namespace WebApp
{
    public class Startup
    {
        public IConfiguration Configuration { get; set; }
        public Startup(IConfiguration config)
        {
            Configuration = config;
        }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<DataContext>(opts =>
            {
                opts.UseSqlServer(Configuration["ConnectionStrings:ProductConnection"]);
                opts.EnableSensitiveDataLogging(true);
            });
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env, DataContext dbContext)
        {
            app.UseDeveloperExceptionPage();
            app.UseRouting();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapGet("/", async context =>
                {
                    await context.Response.WriteAsync("Hello World!");
                });
            });
            SeedData.SeedDatabase(dbContext);
        }
    }
}

    然后,在配置文件 appsettings.json 中,添加ConnectionString:ProductConnection键。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information",
      "Microsoft.EntityFrameworkCore": "Information"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "ProductConnection": "Server=DESKTOP-CUQU48I\\SQLEXPRESS;Database=Products;MultipleActiveResultSets=true;;Trusted_Connection=True;"
  }
}

Step5:创建和应用迁移,通过一下命令,可以创建和应用迁移到数据库。

PS D:\Study\ASPNETCORESTUDY\WebApp> dotnet ef migrations add Initial
PS D:\Study\ASPNETCORESTUDY\WebApp> dotnet ef database update

当然,还可以使用以下命令来重置数据库:

dotnet ef database drop --force

    执行完成之后,可以去数据库看,相关表以及主键外键都创建好了。

添加CSS框架


    后面会编写html代码,所以会用到一些css或者js框架,要管理这些框架,需要一些工具,这里使用的是LibMan,安装LibMan的方法如下:

dotnet tool uninstall --global Microsoft.Web.LibraryManager.Cli
dotnet tool install --global Microsoft.Web.LibraryManager.Cli --version 2.1.113

    LibMan安装完成之后,使用下面命令,安装bootstrap。

libman init -p cdnjs
libman install twitter-bootstrap@5.0.2 -d wwwroot/lib/twitter-bootstrap

配置请求管道


    为了方便测试,这里创建一个请求的Middleware来测试。新建TestMiddleware.cs类,内容如下:

using Microsoft.AspNetCore.Http;
using System.Linq;
using System.Threading.Tasks;
using WebApp.Models;

namespace WebApp
{
    public class TestMiddleware
    {
        private RequestDelegate nextDelegate;

        public TestMiddleware(RequestDelegate next)
        {
            nextDelegate = next;
        }

        public async Task Invoke(HttpContext context, DataContext dataContext)
        {
            if (context.Request.Path == "/test")
            {
                await context.Response.WriteAsync($"There are {dataContext.Products.Count()} products\n");
                await context.Response.WriteAsync($"There are {dataContext.Categories.Count()} categories\n");
                await context.Response.WriteAsync($"There are {dataContext.Suppliers.Count()} suppliers\n");
            }
            else
            {
                await nextDelegate(context);
            }
        }
    }
}

    这个类里面,当用户请求/test的时候,会打印出来数据库中的数据条数。接下来,还需在Startup.cs的Configure方法里,添加如下代码:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env, DataContext dbContext)
{
    app.UseDeveloperExceptionPage();
    app.UseRouting();
    app.UseMiddleware<TestMiddleware>();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    });

    SeedData.SeedDatabase(dbContext);
}

运行程序


    在使用Visual Studio Code编写代码的时候,通常在每完成一个类的时候,都会使用dotnet build编译一下,如果发现错误,修正。当所有代码写完成之后,运行dotnet run即可运行程序,可以看到结果如下:

    表示整个程序已经跑起来没问题了。

什么是RESTful Web Services


    WebService简单来说就是能够响应HTTP请求并返回数据,这些数据能够被客户程序使用比如JavaScript程序,如何编写WebService没有统一标准,但是应用最广泛的方法是使用表述性状态转移(Representational State Transfer,REST)模式,基于这种方法编写的WebService,我们称之为RESTfull Web Services。

    REST的核心是通过结合HTTP请求的URL和请求的方法比如GET、POST来定义API,URL里面包含请求的数据或对象,方法表示要执行何种操作。比如,对于如下请求:

/api/products/1

    在URL中包含了Product对象的ProductID=1,但是他没有表示该执行何种操作。如何操作有HTTP谓词来进行确定,HTTP方法以及对应的操作如下:

使用自定义Endpoint来实现一个WebService


    要手动实现WebService,实际上就是对HttpContext的Response进行操作,这里可以定义一个WebServiceEndpoint.cs的类,然后内容如下。

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Generic;
using System.Text.Json;
using WebApp.Models;
namespace Microsoft.AspNetCore.Builder
{
    public static class WebServiceEndpoint
    {
        private static string BASEURL = "/api/products";

        public static void MapWebService(this IEndpointRouteBuilder app)
        {
            app.MapGet($"{BASEURL}/{{id}}", async context =>
            {
                long key = long.Parse(context.Request.RouteValues["id"] as string);
                DataContext data = context.RequestServices.GetService<DataContext>();
                Product p = data.Products.Find(key);
                if (p == null)
                {
                    context.Response.StatusCode = StatusCodes.Status404NotFound;
                }
                else
                {
                    context.Response.ContentType = "application/json";
                    await context.Response.WriteAsync(JsonSerializer.Serialize<Product>(p));
                }
            });

            app.MapGet(BASEURL, async context =>
            {
                DataContext data = context.RequestServices.GetService<DataContext>();
                context.Response.ContentType = "application/json";
                await context.Response.WriteAsync(JsonSerializer.Serialize<IEnumerable<Product>>(data.Products));
            });

            app.MapPost(BASEURL, async context =>
            {
                DataContext data = context.RequestServices.GetService<DataContext>();
                Product p = await JsonSerializer.DeserializeAsync<Product>(context.Request.Body);
                await data.AddAsync(p);
                await data.SaveChangesAsync();
                context.Response.StatusCode = StatusCodes.Status200OK;
            });
        }
    }
}

     可以看到,上述基本就是在手动设置context.Response的属性。最后在Startup.cs的Configure方法中添加该Endpoint:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env, DataContext dbContext)
{
    app.UseDeveloperExceptionPage();
    app.UseRouting();
    app.UseMiddleware<TestMiddleware>();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Hello World!");
        });

        endpoints.MapWebService();
    });

    SeedData.SeedDatabase(dbContext);
}

    最后运行程序,可以看到对于两个Get方法,我们可以直接在浏览器里面测试,结果如下:

    要测试POST方法,我们可以使用一些工具,比如POSTMAN来进行,这里我们使用PowerShell里面的Invoke-RestMethod来通过命令进行。

PS D:\Study\ASPNETCORESTUDY> Invoke-RestMethod http://localhost:5000/api/products -Method POST -Body (@{Name="Swimming Goggles";Price=12.75;CategoryId=1;SupplierId=1}|ConvertTo-Json) -ContentType "application/json"

    通过上述Post方法,新建了一个Product对象,接下来通过Get方法刷新一下,就能看到新添加的对象。

使用Controller来创建WebService


    上面使用Endpoint来手动操作Response的方式比较原始,他存在一些重复的步骤,比如首先获取EntityFramework服务,设置ContentType类型,序列化对象为JSON等等操作,代码重复,且不够优雅等缺点。

     一种更好的方式,是使用Controller来实现,它使得我们能够在单独的类里定义所有的WebService操作。Controller控制器是MVC框架的一部分,他建立在ASP.NET Core基础之上,他能够处理用类似我们在上面的Endpoint的方式那样来处理HTTP请求。

    要开启Controller,需要修改Startup方法里的CongfigureService和Configure方法,修改如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using WebApp.Models;

namespace WebApp
{
    public class Startup
    {
        public IConfiguration Configuration { get; set; }
        public Startup(IConfiguration config)
        {
            Configuration = config;
        }
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<DataContext>(opts =>
            {
                opts.UseSqlServer(Configuration["ConnectionStrings:ProductConnection"]);
                opts.EnableSensitiveDataLogging(true);
            });
            services.AddControllers();
        }
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env, DataContext dbContext)
        {
            app.UseDeveloperExceptionPage();
            app.UseRouting();
            app.UseMiddleware<TestMiddleware>();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapGet("/", async context =>
                {
                    await context.Response.WriteAsync("Hello World!");
                });

                //endpoints.MapWebService();
                endpoints.MapControllers();
            });

            SeedData.SeedDatabase(dbContext);
        }
    }
}

    主要是services.AddControllers() 和 endpoints.MapControllers()方法。

创建Controller


    在Controllers文件夹下面新建ProductsController.cs类,内容如下:

using Microsoft.AspNetCore.Mvc;
using WebApp.Models;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using System.Linq;

namespace WebApp.Controllers
{

    [Route("api/[controller]")]
    public class ProductsController : ControllerBase
    {
        private DataContext context;
        public ProductsController(DataContext ctx)
        {
            context = ctx;
        }

        [HttpGet]
        public IEnumerable<Product> GetProducts()
        {
            return context.Products;
        }

        [HttpGet("{id}")]
        public Product GetProduct(long id, [FromServices] ILogger<ProductsController> logger)
        {
            logger.LogDebug("GetProduct action invoked");
            return context.Products.Find(id);
        }

        [HttpPost]
        public void SaveProduct([FromBody] Product product)
        {
            context.Products.Add(product);
            context.SaveChanges();
        }

        [HttpPut]
        public void UpdateProduct([FromBody] Product product)
        {
            context.Products.Update(product);
            context.SaveChanges();
        }

        [HttpDelete("{id}")]
        public void DeleteProduct(long id)
        {
            context.Products.Remove(new Product { ProductId = id });
            context.SaveChanges();
        }

    }
}

    ProductsController继承自ControllerBase,这个基类提供了一系列访问MVC框架以及ASP.NET Core平台的特性。它包含以下重要属性。

HttpContext 返回当前请求的HttpContext对象
ModelState 用于返回对参数验证的详情
Request 返回当前请求的HttpRequest对象
Response 返回当前返回的HttpResponse对象
RouteData 返回从Url中获取的路由信息
User 返回当前请求的User对象
  • ProductController前面的[Route("api/[controller]")] 属性用来指定请求的URL地址。这里表示请求的地址为 api/products,[controller]提取的是ProductController中不带Controller后缀部分。
  • 方法前面的[HttpPost]、[HttpGet]、[HttpPut]、[HttpDelete]属性,代表不同的HTTP请求谓词,其余的谓词还包括HttpPatch、HttpHead、AcceptVerbs(表示可以接受多个HTTP谓词)。
  • 在GetProduct方法前面的[HttpGet("{id}")]属性,这里面id会从api/products/{id},中提取id参数,然后赋值给GetProduct方法的第一个参数。这里,对于同样使用HttpGet请求,如果URL为api/products,则会请求GetProducts()方法,如果URL为api/products/{id},则会请求GetProduct(id,logger)方法。
  • GetProduct方法的返回值为Product类型,可以看到运行程序后,输出的已经是JSON序列化后的对象。Controller会帮我们处理好序列化问题,而不是在之前的Endpoint中,我们需要首先设置Content-Type属性,然后再手动序列化对象,然后写入到Response中,Controller帮助我们完成了很多工作。
  • 在ProductsController的构造函数中,我们通过依赖注入,注入了DataContext对象,通过该对象可以访问数据库。这里对于每个ProductsController实例,我们共用了同一个DatacContext对象。
  • 如果要在Action方法中使用依赖注入,必须要在方法的参数类型前面加上[FromServices]标签,比如在GetProduct方法中,我们使用了ILogger<ProductsController>对象,所以该方法的签名为 public Product GetProduct([FromServices] ILogger<ProductsController> logger)
  • 默认情况下MVC框架会试图从URL中获取参数的值,但是使用[FromServices]会覆盖这一行为。在有一些比如HttpPost的方法中,我们期望从Body中获取参数值,这里就需要用到[FromBody]标签来修饰了,比如在SaveProduct方法中,我们需要从HttpPost的Body中获取Product对象,所以该方法的签名为:SaveProduct([FromBody]Product product)。这里面同时会用到模型绑定,在UpdateProduct方法中也是如此。

    运行上述程序,我们可以看到其表现行为跟我们之前手动编写的Endpoint是一致的,现在我们使用Invoke-RestMethod来测试一下其他几个方法,首先是Post方法,添加对象。

Invoke-RestMethod http://localhost:5000/api/products -Method POST -Body (@{Name="Soccer Boots";Price=89.99;CategoryId=2;SupplierId=2}|ConvertTo-Json) -ContentType "application/json" 

    接着是测试UpdateProduct方法,修改了ProductId=1对象的Name属性。

Invoke-RestMethod http://localhost:5000/api/products -Method PUT -Body (@{ProductId=1;Name="Green Kayak";Price=275;CategoryId=1;SupplierId=1}|ConvertTo-Json) -ContentType "application/json" 

     

    接下来是删除对象。这里删除ProductId为2的对象,可以看到执行命令之后,再次刷新后ProductId为2的对象已经不存在了:

Invoke-RestMethod http://localhost:5000/api/products/2 -Method DELETE  

WebService实现的改进


    前面已经演示了使用Controller来实现WebService,但是这里面仍然有可以改进的地方。首先是,添加对跨域请求的支持,需要引用 Microsoft.AspNetCore.Cors.Infrastructure 命名空间,然后在Startup的ConfigureService方法中添加如下:

services.AddCors();

使用异步Action


    ASP.NET Core是使用线程池来处理每一个请求的。能同时处理请求的数量收到线程池大小的限制,当线程池里的某个线程正在等待某个请求处理返回结果之前,他不能处理其他请求。在一些依赖外部请求资源的Action中可能会导致额外的时间等待,比如当Action在访问数据库。这种问题可以通过异步的Action来解决。它使得当前处理某个Action的线程可以处理其他请求而不是阻塞在那里一直等待结果返回,虽然异步Action并不能增加处理某个Action的速度,但这能够增加应用的吞吐量。对ProductsController的异步改造如下:

using Microsoft.AspNetCore.Mvc;
using WebApp.Models;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using System.Linq;
using System.Threading.Tasks;

namespace WebApp.Controllers
{

    [Route("api/[controller]")]
    public class ProductsController : ControllerBase
    {
        private DataContext context;
        public ProductsController(DataContext ctx)
        {
            context = ctx;
        }

        [HttpGet]
        public IAsyncEnumerable<Product> GetProducts()
        {
            return context.Products;
        }

        [HttpGet("{id}")]
        public async Task<Product> GetProduct(long id, [FromServices] ILogger<ProductsController> logger)
        {
            logger.LogDebug("GetProduct action invoked");
            return await context.Products.FindAsync(id);
        }

        [HttpPost]
        public async Task SaveProduct([FromBody] Product product)
        {
            await context.Products.AddAsync(product);
            await context.SaveChangesAsync();
        }

        [HttpPut]
        public async Task UpdateProduct([FromBody] Product product)
        {
            context.Products.Update(product);
            await context.SaveChangesAsync();
        }

        [HttpDelete("{id}")]
        public async Task DeleteProduct(long id)
        {
            context.Products.Remove(new Product { ProductId = id });
            await context.SaveChangesAsync();
        }

    }
}

    可以看到,异步化的修改很简单:

  • 将返回类型从IEnumerable修改为IAsyncEnumerable。
  • 将Void改为async Task。
  • 将EntityFrameowrkCore提供了操作数据库的异步方法,所以将同步的改为异步方法,FindAsync,AddAsync,SaveChangesAsync,这些异步方法都需要添加await修饰。需要注意的是有些方法没有对应的异步方法,比如Remove,Update。

阻止过度绑定


    一些方法可以使用模型绑定,来从请求的Body中获取绑定后的对象来执行一些操作。比如SaveProduct方法。如果使用如下命令来创建方法时,会报错。

 Invoke-RestMethod http://localhost:5000/api/products -Method POST -Body (@{ProductId=100;Name="swim Buoy";Price=19.99;CategoryId=1;SupplierId=1}|ConvertTo-Json) -ContentType "application/json"  

    报错信息如下:

Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
      An unhandled exception has occurred while executing the request.
      Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while updating the entries. See the inner exception for details.
       ---> Microsoft.Data.SqlClient.SqlException (0x80131904): 当 IDENTITY_INSERT 设置为 OFF 时,不能为表 'Products' 中的标识列插入显式值。

    在默认情况下EntityFramework Core会让数据库来自动生成主键Id值,这有个好处是当有多个应用同时创建对象的时候,可以不用协调,让数据库来自动生成。在模型绑定中,Product对象需要ProductId属性,但是在创建Product对象的时候,ProductId是不需要我们给他赋值的。如果赋值,那么就会报如上错误,当然也可以把ProductId赋值为0。

    这种就是所谓的过度绑定,最安全的解决办法是,我们重新定义一个单独的数据模型类,来用于模型绑定。比如这里我们在WebApp/Models文件夹下定义一个ProductBindingTarget.cs类。

namespace WebApp.Models
{
    public class ProductBindingTarget
    {
        public string Name { get; set; }
        public decimal Price { get; set; }
        public long CategoryId { get; set; }
        public long SupplierId { get; set; }
        public Product ToProduct() => new Product
        {
            Name = this.Name,
            Price = this.Price,
            CategoryId = this.CategoryId,
            SupplierId = this.SupplierId
        };
    }
}

    然后修改SaveProduct方法的接受的参数类型为ProductBindingTarget,如下:

[HttpPost]
public async Task SaveProduct([FromBody] ProductBindingTarget target)
{
    await context.Products.AddAsync(target.ToProduct());
    await context.SaveChangesAsync();
}

    这样,运行下面的测试方法,即使在请求的Body里面提供了我们不想要的ProductId,该方法仍能够正常运行。

Invoke-RestMethod http://localhost:5000/api/products -Method POST -Body (@{ProductId=100;Name="swim Buoy v2";Price=29.99;CategoryId=1;SupplierId=1}|ConvertTo-Json) 
-ContentType "application/json"

使用Action结果返回值


    MVC框架会帮助我们自动设置返回状态码,但有时候它返回的状态码可能不是我们想要的,比如下面这个,我们利用HttpGet请求一个不存在的对象:

PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/products/1000 |Select-Object StatusCode

StatusCode
----------
       204

    他返回的是204代码。当查询数据库中ProductId为1000的对象时,返回的是null,当MVC判断返回结果为null的时候,他就会设置StatusCode为204,表示请求成功执行了,但是没有产生任何数据。并不是所有的应用都需要这种默认的结果,一个很通常的需求是我们需要返回一个404错误,表示资源未找到。

    同样,在SaveProducts方法中,当成功保存一个对象后,MVC会返回200状态码,但是用户并不知道创建完Product对象后,数据库帮我们自动生成的ProductId,这就需要我们重新去查询一下才知道刚创建的对象的Id是多少。

    在MVC中,Action方法可以返回一个实现了IActionResult接口的对象,通过返回这些对象我们不需要手动操作HttpResponse对象了。ControllBase提供了一系列方法来创建ActionResult对象,这些方法如下:

     可以看到,当返回的值为null的时候,实际上是返回了一个NoContent对象,我们对ProductController中GetProduct和SaveProduct进行如下修改:

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(long id, [FromServices] ILogger<ProductsController> logger)
{
    Product p = await context.Products.FindAsync(id);
    if (p == null)
    {
        return NotFound();
    }
    logger.LogDebug("GetProduct action invoked");
    return Ok(p);
}

[HttpPost]
public async Task<IActionResult> SaveProduct([FromBody] ProductBindingTarget target)
{
    Product p = target.ToProduct();
    await context.Products.AddAsync(p);
    await context.SaveChangesAsync();
    return Ok(p);
}

    修改之后,重新运行,再次请求一个不存在的对象,会发现返回值变成了404,请求存在的对象就是200。

PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/products/1000 |Select-Object StatusCode
Invoke-WebRequest : 远程服务器返回错误: (404) 未找到。
所在位置 行:1 字符: 1
+ Invoke-WebRequest http://localhost:5000/api/products/1000 |Select-Obj ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-WebRequest],WebException
    + FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand

PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/products/10 |Select-Object StatusCode

StatusCode
----------
       200

    同时,创建对象的时候,也能返回一个包含了ProductId值的新的对象:

PS D:\Study\ASPNETCORESTUDY> Invoke-RestMethod http://localhost:5000/api/products -Method POST -Body (@{Name="Boot Laces"; Price=19.99;CategoryId=2; SupplierId=2} | ConvertTo-Json) -ContentType "application/json"

productId  : 14
name       : Boot Laces
price      : 19.99
categoryId : 2
category   :
supplierId : 2
supplier   :

执行跳转


    有时候请求会跳转到其他URL,最简单的方法是调用Redirect方法,方法如下:

[HttpGet("redirect")]
public IActionResult Redirect()
{
    return Redirect("/api/products/1");
}

    现在,请求一下:

PS D:\Study\ASPNETCORESTUDY> Invoke-RestMethod http://localhost:5000/api/products/redirect

productId  : 1
name       : Green Kayak
price      : 275.00
categoryId : 1
category   :
supplierId : 1
supplier   :

    除了直接编写url之外,还可以使用RedirectToAction方法,它会跳转到当前Controller的指定的Action,并且给与参数,比如:

[HttpGet("redirect")]
public IActionResult Redirect() {
    return RedirectToAction(nameof(GetProduct), new { Id = 1 });
}

    也可以使用RedirectToRoute来实现如下功能:

[HttpGet("redirect")]
public IActionResult Redirect() {
    return RedirectToRoute(new { controller = "Products", action = "GetProduct", Id = 1});
}

数据校验


    当接收用户从表单上输入的数据时,我们需要首先进行校验。在MVC框架中,提供了校验的特性,首先需要做的是,我们需要在数据绑定对象上添加校验标记。

using System.ComponentModel.DataAnnotations;
namespace WebApp.Models
{
    public class ProductBindingTarget
    {
        [Required]
        public string Name { get; set; }
        [Range(1, 1000)]
        public decimal Price { get; set; }
        [Range(1, long.MaxValue)]
        public long CategoryId { get; set; }
        [Range(1, long.MaxValue)]
        public long SupplierId { get; set; }
        public Product ToProduct() => new Product
        {
            Name = this.Name,
            Price = this.Price,
            CategoryId = this.CategoryId,
            SupplierId = this.SupplierId
        };
    }
}

    接下来,在SaveProduct的时候,判断校验状态:

[HttpPost]
public async Task<IActionResult> SaveProduct([FromBody] ProductBindingTarget target)
{
    if (ModelState.IsValid)
    {
        Product p = target.ToProduct();
        await context.Products.AddAsync(p);
        await context.SaveChangesAsync();
        return Ok(p);
    }
    return BadRequest(ModelState);
}

    ModelState是ControllerBase里面的属性,当模型绑定校验无误时,返回true,否则返回false,现在运行应用。

PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/products -Method POST -Body (@{Name="Boot Laces"} |
>> ConvertTo-Json) -ContentType "application/json"
Invoke-WebRequest : 远程服务器返回错误: (400) 错误的请求。
所在位置 行:1 字符: 1
+ Invoke-WebRequest http://localhost:5000/api/products -Method POST -Bo ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-WebRequest],WebException
    + FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand

    可以看到,返回的是400 BadRequest。

使用ApiController属性


    在Controller上使用ApiController属性可以在模型绑定和验证上简化很多操作和行为。比如在参数中使用的[FromBody]属性以及在SaveProduct中判断ModelState.IsValide的逻辑,现在都可以省略。修改后的ProductsController如下:

using Microsoft.AspNetCore.Mvc;
using WebApp.Models;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using System.Linq;
using System.Threading.Tasks;

namespace WebApp.Controllers
{

    [ApiController]
    [Route("api/[controller]")]
    public class ProductsController : ControllerBase
    {
        private DataContext context;
        private ILogger<ProductsController> logger;
        public ProductsController(DataContext ctx, ILogger<ProductsController> l)
        {
            context = ctx;
            logger = l;
        }

        [HttpGet]
        public IAsyncEnumerable<Product> GetProducts()
        {
            return context.Products;
        }

        [HttpGet("{id}")]
        public async Task<IActionResult> GetProduct(long id)
        {
            Product p = await context.Products.FindAsync(id);
            if (p == null)
            {
                return NotFound();
            }
            logger.LogDebug("GetProduct action invoked");
            return Ok(p);
        }

        [HttpPost]
        public async Task<IActionResult> SaveProduct(ProductBindingTarget target)
        {
            Product p = target.ToProduct();
            await context.Products.AddAsync(p);
            await context.SaveChangesAsync();
            return Ok(p);
        }

        [HttpPut]
        public async Task UpdateProduct(Product product)
        {
            context.Products.Update(product);
            await context.SaveChangesAsync();
        }

        [HttpDelete("{id}")]
        public async Task DeleteProduct(long id)
        {
            context.Products.Remove(new Product { ProductId = id });
            await context.SaveChangesAsync();
        }

        [HttpGet("redirect")]
        public IActionResult Redirect()
        {
            return RedirectToAction(nameof(GetProduct), new { Id = 1 });
        }

    }
}

    需要注意的是,这里ILogger移到构造函数里了。要打印出debug级别的日志,需要修改日志里面的级别从"Information”修改为“Debug”。

{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  }
}

忽略Null属性


    最后一步的修改是移除掉返回值里面是null的属性字段。在EntityFrameowrk Core模型中,因为涉及要外键的引用,所以会有很多不需要赋值或者返回的对象。比如我们请求Product详情对象时:

PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/products/1 | Select-Object Content

Content
-------
{"productId":1,"name":"Green Kayak","price":275.00,"categoryId":1,"category":null,"supplierId":1,"supplier":null}

    会看到返回值里面,有很多的null对象。解决方法有两种,一种是返回一个新的匿名对象,只返回需要的字段。另外一种是配置Json序列化参数。

返回新的匿名对象


    现在修改GetProduct方法,在返回的方法里面返回匿名对象,指给需要的字段赋值。

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(long id)
{
    Product p = await context.Products.FindAsync(id);
    if (p == null)
    {
        return NotFound();
    }
    logger.LogDebug("GetProduct action invoked");
    return Ok(new
    {
        ProductId = p.ProductId,
        Name = p.Name,
        Price = p.Price,
        CategoryId = p.CategoryId,
        SupplierId = p.SupplierId
    });
}

   重新运行,可以看到结果如下,多余的字段已经没有了。

JSON序列化配置


    还有一种方法是,修改JSON序列化的默认行为,忽略为null的字段,修改如下:

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<DataContext>(opts =>
    {
        opts.UseSqlServer(Configuration["ConnectionStrings:ProductConnection"]);
        opts.EnableSensitiveDataLogging(true);
    });
    services.AddControllers();
    services.AddCors();
    services.Configure<JsonOptions>(opts =>
    {
        opts.JsonSerializerOptions.IgnoreNullValues = true;
    });
}

     重新运行程序,可以看到,所有为null的值都被忽略了。

总结


    这里只是一个学习笔记,练习了使用Visual Studio Code编写ASP.NET Core应用,其中还使用到了EntityFramework Core来访问数据库。最后在此基础上首先手动使用EndPoint实现了简单的web services,最后展示了如何使用MVC的Controller来简化RESTfull web services的开发,希望操作能够能加深自己印象。