从头编写 asp.net core 2.0 web api 基础框架 (2)

上一篇是: http://www.cnblogs.com/cgzl/p/7637250.html

Github源码地址是: https://github.com/solenovex/Building-asp.net-core-2-web-api-starter-template-from-scratch

本文讲的是里面的Step 2.

上一次, 我们使用asp.net core 2.0 建立了一个Empty project, 然后做了一些基本的配置, 并建立了两个Controller, 写了一些查询方法.

下面我们继续:

POST

POST一般用来表示创建资源, 也就是新增.

先看看Model, 其中的Id属性, 一般是创建的时候服务器自动生成的, 所以如果客户端在进行Post(创建)的时候, 它是不会提供Id属性的.

    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public float Price { get; set; }
        public ICollection<Material> Materials { get; set; }
    }

所以, 可以这样做, 再建立一个Dto, 专门用于创建: ProductCreation.cs: 

namespace CoreBackend.Api.Dtos
{
    public class ProductCreation
    {
        public string Name { get; set; }
        public float Price { get; set; }
    }
}

这里去掉了Id和Materials这个导航属性.

其实也可以使用同一个Model来做所有的操作, 因为它们的大部分属性都是相同的, 但是,

还是建议针对查询, 创建, 修改, 使用单独的Model, 这样以后修改和重构会简单一些, 再说他们的验证也是不一样的.

创建Post Action

     [Route("{id}", Name = "GetProduct")]
        public IActionResult GetProduct(int id)
        {
            var product = ProductService.Current.Products.SingleOrDefault(x => x.Id == id);
            if (product == null)
            {
                return NotFound();
            }
            return Ok(product);
        }

        [HttpPost]
        public IActionResult Post([FromBody] ProductCreation product)
        {
            if (product == null)
            {
                return BadRequest();
            }
            var maxId = ProductService.Current.Products.Max(x => x.Id);
            var newProduct = new Product
            {
                Id = ++maxId,
                Name = product.Name,
                Price = product.Price
            };
            ProductService.Current.Products.Add(newProduct);

            return CreatedAtRoute("GetProduct", new { id = newProduct.Id }, newProduct);
        }

 

[HttpPost] 表示请求的谓词是Post. 加上Controller的Route前缀, 那么访问这个Action的地址就应该是: 'api/product'

后边也可以跟着自定义的路由地址, 例如 [HttpPost("create")], 那么这个Action的路由地址就应该是: 'api/product/create'.

[FromBody] , 请求的body里面包含着方法需要的实体数据, 方法需要把这个数据Deserialize成ProductCreation, [FromBody]就是干这些活的.

客户端程序可能会发起一个Bad的Request, 导致数据不能被Deserialize, 这时候参数product就会变成null. 所以这是一个客户端发生的错误, 程序为让客户端知道是它引起了错误, 就应该返回一个Bad Request 400 (Bad Request表示客户端引起的错误)的 Status Code.

传递进来的model类型是 ProductCreation, 而我们最终操作的类型是Product, 所以需要进行一个Map操作, 目前还是挨个属性写代码进行Map吧, 以后会改成Automapper.

返回 CreatedAtRoute: 对于POST, 建议的返回Status Code 是 201 (Created), 可以使用CreatedAtRoute这个内置的Helper Method. 它可以返回一个带有地址Header的Response, 这个Location Header将会包含一个URI, 通过这个URI可以找到我们新创建的实体数据. 这里就是指之前写的GetProduct(int id)这个方法. 但是这个Action必须有一个路由的名字才可以引用它, 所以在GetProduct方法上的Route这个attribute里面加上Name="GetProduct", 然后在CreatedAtRoute方法第一个参数写上这个名字就可以了, 尽管进行了引用, 但是Post方法走完的时候并不会调用GetProduct方法. CreatedAtRoute第二个参数就是对应着GetProduct的参数列表, 使用匿名类即可, 最后一个参数是我们刚刚创建的数据实体

运行程序试验一下, 注意需要在Headers里面设置Content-Type: application/json. 结果如图:

从头编写 asp.net core 2.0 web api 基础框架 (2)

返回的状态是201.

看一下那一堆Headers:

从头编写 asp.net core 2.0 web api 基础框架 (2)

里面的location 这个Header, 所以客户端就知道以后想找这个数据, 就需要访问这个地址, 我们可以现在就试试:

从头编写 asp.net core 2.0 web api 基础框架 (2)

嗯. 没什么问题.

 Validation 验证

针对上面的Post方法,  如果请求没有Body, 参数product就会是null, 这个我们已经判断了; 如果body里面的数据所包含的属性在product中不存在, 那么这个属性就会被忽略.

但是如果body数据的属性有问题, 比如说name没有填写, 或者name太长, 那么在执行action方法的时候就会报错, 这时候框架会自动抛出500异常, 表示是服务器的错误, 这是不对的. 这种错误是由客户端引起的, 所以需要返回400 Bad Request错误.

验证Model/实体, asp.net core 内置可以使用 Data Annotations进行: 

using System;
using System.ComponentModel.DataAnnotations;

namespace CoreBackend.Api.Dtos
{
    public class ProductCreation
    {
        [Display(Name = "产品名称")]
        [Required(ErrorMessage = "{0}是必填项")]
        // [MinLength(2, ErrorMessage = "{0}的最小长度是{1}")]
        // [MaxLength(10, ErrorMessage = "{0}的长度不可以超过{1}")]
     [StringLength(10, MinimumLength = 2, ErrorMessage = "{0}的长度应该不小于{2}, 不大于{1}")]
public string Name { get; set; } [Display(Name = "价格")] [Range(0, Double.MaxValue, ErrorMessage = "{0}的值必须大于{1}")] public float Price { get; set; } } }

这些Data Annotation (理解为用于验证的注解), 可以在System.ComponentModel.DataAnnotation找到, 例如[Required]表示必填, [MinLength]表示最小长度, [StringLength]可以同时验证最小和最大长度, [Range]表示数值的范围等等很多.

[Display(Name="xxx")]的用处是, 给属性起一个比较友好的名字.

其他的验证注解都有一个属性叫做ErrorMessage (string), 表示如果验证失败, 就会把ErrorMessage的内容添加到错误结果里面去. 这个ErrorMessage可以使用参数, {0}表示Display的Name属性, {1}表示当前注解的第一个变量, {2}表示当前注解的第二个变量.

在Controller里面添加验证逻辑:

     [HttpPost]
        public IActionResult Post([FromBody] ProductCreation product)
        {
            if (product == null)
            {
                return BadRequest();
            }

            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            var maxId = ProductService.Current.Products.Max(x => x.Id);
            var newProduct = new Product
            {
                Id = ++maxId,
                Name = product.Name,
                Price = product.Price
            };
            ProductService.Current.Products.Add(newProduct);

            return CreatedAtRoute("GetProduct", new { id = newProduct.Id }, newProduct);
        }

ModelState: 是一个Dictionary, 它里面是请求提交到Action的Name和Value的对们, 一个name对应着model的一个属性, 它也包含了一个针对每个提交的属性的错误信息的集合.

每次请求进到Action的时候, 我们在ProductCreationModel添加的那些注解的验证, 就会被检查. 只要其中有一个验证没通过, 那么ModelState.IsValid属性就是False. 可以设置断点查看ModelState里面都有哪些东西.

如果有错误的话, 我们可以把ModelState当作Bad Request的参数一起返回到前台.

我们试试:

从头编写 asp.net core 2.0 web api 基础框架 (2)

从头编写 asp.net core 2.0 web api 基础框架 (2)

如果通过Data Annotation的方式不能实现比较复杂验证的需求, 那就需要写代码了. 这时, 如果验证失败, 我们可以错误信息添加到ModelState里面,

            if (product.Name == "产品")
            {
                ModelState.AddModelError("Name", "产品的名称不可以是'产品'二字");
            }        

看看运行结果: 

从头编写 asp.net core 2.0 web api 基础框架 (2)

Good. 

但是这种通过注解的验证方式把验证的代码和Model的代码混到了一起, 并不是很好的Separationg of Concern, 而且同时在Model和Controller里面为Model写验证相关的代码也不太好. 

这是方式是asp.net core 内置的, 所以简单的情况下还是可以用的. 如果需求比较复杂, 可以使用FluentValidation, 以后会加入这个库.

PUT

put应该用于对model进行完整的更新. 

首先最好还是单独为Put写一个Dto Model, 尽管属性可能都是一样的, 但是也建议这样写, 实在不想写也可以.

ProducModification.cs

    public class ProductModification
    {
        [Display(Name = "产品名称")]
        [Required(ErrorMessage = "{0}是必填项")]
        [StringLength(10, MinimumLength = 2, ErrorMessage = "{0}的长度应该不小于{2}, 不大于{1}")]
        public string Name { get; set; }

        [Display(Name = "价格")]
        [Range(0, Double.MaxValue, ErrorMessage = "{0}的值必须大于{1}")]
        public float Price { get; set; }
    }

然后编写Controller的方法:

     [HttpPut("{id}")]
        public IActionResult Put(int id, [FromBody] ProductModification product)
        {
            if (product == null)
            {
                return BadRequest();
            }

            if (product.Name == "产品")
            {
                ModelState.AddModelError("Name", "产品的名称不可以是'产品'二字");
            }

            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            var model = ProductService.Current.Products.SingleOrDefault(x => x.Id == id);
            if (model == null)
            {
                return NotFound();
            }
            model.Name = product.Name;
            model.Price = product.Price;

            // return Ok(model);
            return NoContent();
        }

按照Http Put的约定, 需要一个id这样的参数, 用于查找现有的model.

由于Put做的是完整的更新, 所以把ProducModification整个Model作为参数.

进来之后, 进行了一套和POST一摸一样的验证, 这地方肯定可以改进, 如果验证逻辑比较复杂的话, 到处写同样验证逻辑肯定是不好的, 所以建议使用FluentValidation.

然后, 把ProductModification的属性都映射查询找到给Product, 这个以后用AutoMapper来映射.

返回: PUT建议返回NoContent(), 因为更新是客户端发起的, 客户端已经有了最新的值, 无需服务器再给它传递一次, 当然了, 如果有些值是在后台更新的, 那么也可以使用Ok(xxx)然后把更新后的model作为参数一起传到前台.两种效果如图:

从头编写 asp.net core 2.0 web api 基础框架 (2)

从头编写 asp.net core 2.0 web api 基础框架 (2)

注意: PUT是整体更新/修改, 但是如果只想修改部分属性的时候, 我们看看会发生什么.

首先在Product相关Dto里面再加上一个属性Description吧.

从头编写 asp.net core 2.0 web api 基础框架 (2)从头编写 asp.net core 2.0 web api 基础框架 (2)
namespace CoreBackend.Api.Dtos
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public float Price { get; set; }
        public string Description { get; set; }
        public ICollection<Material> Materials { get; set; }
    }
}

namespace CoreBackend.Api.Dtos
{
    public class ProductCreation
    {
        [Display(Name = "产品名称")]
        [Required(ErrorMessage = "{0}是必填项")]
        [StringLength(10, MinimumLength = 2, ErrorMessage = "{0}的长度应该不小于{2}, 不大于{1}")]
        public string Name { get; set; }

        [Display(Name = "价格")]
        [Range(0, Double.MaxValue, ErrorMessage = "{0}的值必须大于{1}")]
        public float Price { get; set; }

        [Display(Name = "描述")]
        [MaxLength(100, ErrorMessage = "{0}的长度不可以超过{1}")]
        public string Description { get; set; }
    }
}

namespace CoreBackend.Api.Dtos
{
    public class ProductModification
    {
        [Display(Name = "产品名称")]
        [Required(ErrorMessage = "{0}是必填项")]
        [StringLength(10, MinimumLength = 2, ErrorMessage = "{0}的长度应该不小于{2}, 不大于{1}")]
        public string Name { get; set; }

        [Display(Name = "价格")]
        [Range(0, Double.MaxValue, ErrorMessage = "{0}的值必须大于{1}")]
        public float Price { get; set; }

        [Display(Name = "描述")]
        [MaxLength(100, ErrorMessage = "{0}的长度不可以超过{1}")]
        public string Description { get; set; }
    }
}
View Code

然后在POST和PUT的方法里面映射那部分, 添加上相应的代码, (如果有AutoMapper, 这不操作就不需要做了):

从头编写 asp.net core 2.0 web api 基础框架 (2)从头编写 asp.net core 2.0 web api 基础框架 (2)
        [HttpPost]
        public IActionResult Post([FromBody] ProductCreation product)
        {
            if (product == null)
            {
                return BadRequest();
            }

            if (product.Name == "产品")
            {
                ModelState.AddModelError("Name", "产品的名称不可以是'产品'二字");
            }

            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            var maxId = ProductService.Current.Products.Max(x => x.Id);
            var newProduct = new Product
            {
                Id = ++maxId,
                Name = product.Name,
                Price = product.Price,
                Description = product.Description
            };
            ProductService.Current.Products.Add(newProduct);

            return CreatedAtRoute("GetProduct", new { id = newProduct.Id }, newProduct);
        }

        [HttpPut("{id}")]
        public IActionResult Put(int id, [FromBody] ProductModification product)
        {
            if (product == null)
            {
                return BadRequest();
            }

            if (product.Name == "产品")
            {
                ModelState.AddModelError("Name", "产品的名称不可以是'产品'二字");
            }

            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            var model = ProductService.Current.Products.SingleOrDefault(x => x.Id == id);
            if (model == null)
            {
                return NotFound();
            }
            model.Name = product.Name;
            model.Price = product.Price;
            model.Description = product.Description;
            
            return NoContent();
        }
View Code

然后我们用PUT进行实验单个属性修改:

这对这条数据:

从头编写 asp.net core 2.0 web api 基础框架 (2)

我们修改name和price属性:

从头编写 asp.net core 2.0 web api 基础框架 (2)

然后再看一下修改后的数据:

从头编写 asp.net core 2.0 web api 基础框架 (2)

Description被设置成null. 这就是HTTP PUT标准的本意: 整体修改, 更新所有属性, 尽管你的代码可能不这么做.

Patch 部分更新

 Http Patch 就是做部分更新的, 它的Request Body应该包含需要更新的属性名 和 值, 甚至也可以包含针对这个属性要进行的相应操作.

针对Request Body这种情况, 有一个标准叫做 Json Patch RFC 6092, 它定义了一种json数据的结构 可以表示上面说的那些东西. 

Json Patch定义的操作包含替换, 复制, 移除等操作.

这对我们的Product, 它的结构应该是这样的:

从头编写 asp.net core 2.0 web api 基础框架 (2)

op 表示操作, replace 是指替换; path就是属性名, value就是值.

相应的Patch方法:

        [HttpPatch("{id}")]
        public IActionResult Patch(int id, [FromBody] JsonPatchDocument<ProductModification> patchDoc)
        {
            if (patchDoc == null)
            {
                return BadRequest();
            }
            var model = ProductService.Current.Products.SingleOrDefault(x => x.Id == id);
            if (model == null)
            {
                return NotFound();
            }
            var toPatch = new ProductModification
            {
                Name = model.Name,
                Description = model.Description,
                Price = model.Price
            };
            patchDoc.ApplyTo(toPatch, ModelState);

            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            model.Name = toPatch.Name;
            model.Description = toPatch.Description;
       model.Price = toPatch.Price;
return NoContent(); }

HttpPatch, 按约定方法有一个参数id, 还有一个JsonPatchDocument类型的参数, 它的泛型应该是用于Update的Dto, 所以选择的是ProductionModification. 如果使用Product这个Dto的话, 那么它包含id属性, 而id属性是不更改的. 但如果你没有针对不同的操作使用不同的Dto, 那么别忘了检查传入Dto的id 要和参数id一致才行.

然后把查询出来的product转化成用于更新的ProductModification这个Dto, 然后应用于Patch Document 就是指为toPatch这个model更新那些需要更新的属性, 是使用ApplyTo方法实现的.

但是这时候可能会出错, 比如说修改一个根本不存在的属性, 也就是说客户端可能引起了错误, 这时候就需要它进行验证, 并返回Bad Request. 所以就加上ModelState这个参数. 然后进行判断即可.

然后就是和PUT一样的更新操作, 把toPatch这个Update的Dto再整体更新给model. 其实里面不管怎么实现, 只要按约定执行就好.

然后按建议, 返回NoContent().

试一下:

从头编写 asp.net core 2.0 web api 基础框架 (2)

然后查询一下:

从头编写 asp.net core 2.0 web api 基础框架 (2)

从头编写 asp.net core 2.0 web api 基础框架 (2)

与期待的结果一样.

然后试一下传入一个不存在的属性:

从头编写 asp.net core 2.0 web api 基础框架 (2)

结果显示找不到这个属性.

再试一下, ProductModification 这个model上的验证: 例如删除name这个属性的值:

从头编写 asp.net core 2.0 web api 基础框架 (2)

返回204, 表示成功, 但是name是必填的, 所以代码还有问题.

我们做了ModelState检查, 但是为什么没有验证出来呢? 这是因为, Patch方法的Model参数是JsonPatchDocument而不是ProductModification, 上面传进去的参数对于JsonPatchDocument来说是没有问题的.

所以我们需要对toPatch这个model进行验证:

[HttpPatch("{id}")]
        public IActionResult Patch(int id, [FromBody] JsonPatchDocument<ProductModification> patchDoc)
        {
            if (patchDoc == null)
            {
                return BadRequest();
            }
            var model = ProductService.Current.Products.SingleOrDefault(x => x.Id == id);
            if (model == null)
            {
                return NotFound();
            }
            var toPatch = new ProductModification
            {
                Name = model.Name,
                Description = model.Description,
                Price = model.Price
            };
            patchDoc.ApplyTo(toPatch, ModelState);

            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            if (toPatch.Name == "产品")
            {
                ModelState.AddModelError("Name", "产品的名称不可以是'产品'二字");
            }
            TryValidateModel(toPatch);
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            model.Name = toPatch.Name;
            model.Description = toPatch.Description;
            model.Price = toPatch.Price;

            return NoContent();
        }

使用TryValidateModel(xxx)对model进行手动验证, 结果也会反应在ModelState里面.

再试一次上面的操作:

从头编写 asp.net core 2.0 web api 基础框架 (2)

这回对了.

DELETE 删除

这个比较简单:

        [HttpDelete("{id}")]
        public IActionResult Delete(int id)
        {
            var model = ProductService.Current.Products.SingleOrDefault(x => x.Id == id);
            if (model == null)
            {
                return NotFound();
            }
            ProductService.Current.Products.Remove(model);
            return NoContent();
        }

按Http Delete约定, 参数为id, 如果操作成功就回NoContent();

试一下:

从头编写 asp.net core 2.0 web api 基础框架 (2)

成功.

目前, CRUD最基本的操作先告一段落.

上班了比较忙了, 今天先写这些.....................................................

下面是我的关于ASP.NET Core Web API相关技术的公众号--草根专栏:

从头编写 asp.net core 2.0 web api 基础框架 (2)

上一篇:希腊字母表


下一篇:从头编写 asp.net core 2.0 web api 基础框架 (1)