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

solenovex發表於2017-10-09

上一篇是: 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最基本的操作先告一段落.

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

相關文章