1. 程式人生 > >從頭編寫 asp.net core 2.0 web api 基礎框架 (2)

從頭編寫 asp.net core 2.0 web api 基礎框架 (2)

req new 定義 問題 ... htm error 期待 實驗

上一篇是: 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. 結果如圖:

技術分享

返回的狀態是201.

看一下那一堆Headers:

技術分享

裏面的location 這個Header, 所以客戶端就知道以後想找這個數據, 就需要訪問這個地址, 我們可以現在就試試:

技術分享

嗯. 沒什麽問題.

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的參數一起返回到前臺.

我們試試:

技術分享

技術分享

如果通過Data Annotation的方式不能實現比較復雜驗證的需求, 那就需要寫代碼了. 這時, 如果驗證失敗, 我們可以錯誤信息添加到ModelState裏面,

            if (product.Name == "產品")
            {
                ModelState.AddModelError("Name", "產品的名稱不可以是‘產品‘二字");
            }        

看看運行結果:

技術分享

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作為參數一起傳到前臺.兩種效果如圖:

技術分享

技術分享

註意: PUT是整體更新/修改, 但是如果只想修改部分屬性的時候, 我們看看會發生什麽.

首先在Product相關Dto裏面再加上一個屬性Description吧.

技術分享
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, 這不操作就不需要做了):

技術分享
        [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進行實驗單個屬性修改:

這對這條數據:

技術分享

我們修改name和price屬性:

技術分享

然後再看一下修改後的數據:

技術分享

Description被設置成null. 這就是HTTP PUT標準的本意: 整體修改, 更新所有屬性, 盡管你的代碼可能不這麽做.

Patch 部分更新

Http Patch 就是做部分更新的, 它的Request Body應該包含需要更新的屬性名 和 值, 甚至也可以包含針對這個屬性要進行的相應操作.

針對Request Body這種情況, 有一個標準叫做 Json Patch RFC 6092, 它定義了一種json數據的結構 可以表示上面說的那些東西.

Json Patch定義的操作包含替換, 復制, 移除等操作.

這對我們的Product, 它的結構應該是這樣的:

技術分享

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().

試一下:

技術分享

然後查詢一下:

技術分享

技術分享

與期待的結果一樣.

然後試一下傳入一個不存在的屬性:

技術分享

結果顯示找不到這個屬性.

再試一下, ProductModification 這個model上的驗證: 例如刪除name這個屬性的值:

技術分享

返回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裏面.

再試一次上面的操作:

技術分享

這回對了.

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();

試一下:

技術分享

成功.

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

上班了比較忙了, 今天先寫這些.....................................................

從頭編寫 asp.net core 2.0 web api 基礎框架 (2)