iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Modern Web

現在就學C# 與 ASP.NET Core系列 第 27 篇

Day 27|Model Binding、DTO 與 Validation

  • 分享至 

  • xImage
  •  

Day 26 我們已經知道:

HTTP Request
↓
Routing
↓
Controller Action
↓
Action Result
↓
HTTP Response

例如:

GET /api/products/10
↓
Routing
↓
GetById(int id)

但還有一個問題:

URL 裡的 10,到底怎麼進入 int id?

我們沒有自己寫:

int id = 10;

但 Action 執行時:

id
→ 10

已經準備好了。

這就是今天第一個核心:

Model Binding

另外還會處理兩件事情:

API 應該接收哪些資料?
→ DTO

收到資料之後,
怎麼判斷資料能不能使用?
→ Validation

所以今天主要理解三件事情:

Model Binding:Request Data 怎麼進入 C#?

DTO:API 要接哪些資料?

Validation:這些資料能不能使用?


1. Model Binding 是什麼?

先看:

[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
    return Ok(id);
}

Request:

GET /api/products/10

Routing 會先找到:

GetById(int id)

但真正執行 Action 前,ASP.NET Core 還要準備:

int id

Route 裡有:

/api/products/10
              ↑
              10

ASP.NET Core 會嘗試把:

"10"

轉成:

int id = 10

這就是 Model Binding。

可以把它理解成:

Model Binding 是 ASP.NET Core 在 Action 執行前,自動取得 HTTP Request Data,並準備成 Action 可以使用的 C# Parameter 或 Object 的機制。

例如:

/api/products/10
→ int id = 10
?keyword=keyboard
→ string keyword = "keyboard"

Request Body:

{
  "name": "Keyboard",
  "price": 2000
}

則可以建立成:

CreateProductRequest request

Model Binding 發生在真正執行 Action 之前:

HTTP Request
↓
Routing
↓
找到 Action
↓
Model Binding
↓
準備 Parameter / Object
↓
Validation
↓
執行 Action

所以可以記成:

Routing
→ 找到誰來處理

Model Binding
→ 準備 Action 需要的資料

2. Request Data 可以從哪裡來?

Web API 最常看到三種來源:

資料來源 範例 常見用途
Route /api/products/10 Resource Id
Query String ?keyword=keyboard 搜尋、篩選、排序、分頁
Request Body JSON 新增、修改資料

例如:

GET /api/products/10
→ int id
GET /api/products/search?keyword=keyboard
→ string? keyword
{
  "name": "Keyboard",
  "price": 2000
}

可以變成:

CreateProductRequest request

一個 Action 也可以同時使用不同來源:

[HttpPut("{id:int}")]
public IActionResult Update(
    int id,
    UpdateProductRequest request
)

其中:

id
→ Route

request
→ Request Body

3. DTO 是什麼?

DTO 全名:

Data Transfer Object

可以先理解成:

DTO 用來定義 API 要接收或傳遞哪些資料,也就是 API 的資料邊界。

例如 Product:

public record Product(
    int Id,
    string Name,
    decimal Price
);

代表系統裡完整的 Product。

但建立 Product 時,Client 不一定應該提供:

Id

因為 Id 通常應該由 Server 或 Database 決定。

所以建立 Product 時可以另外定義:

public class CreateProductRequest
{
    public string Name { get; set; }
        = string.Empty;

    public decimal Price { get; set; }
}

這表示:

Create API

允許 Client 提供:

Name
Price

所以:

Product
→ 系統中的資料

CreateProductRequest
→ Create API 接收的資料

DTO 最重要的價值就是明確定義:

API Contract

也就是:

Client 可以傳什麼,API 預期收到什麼。


4. Validation:資料進來後能不能使用?

假設建立 Product 時有以下規則:

Name
→ 必填
→ 最長 100 個字元

Price
→ 1 ~ 1,000,000

可以直接把規則放在 DTO:

using System.ComponentModel.DataAnnotations;

public class CreateProductRequest
{
    [Required]
    [StringLength(100)]
    public string Name { get; set; }
        = string.Empty;

    [Range(1, 1_000_000)]
    public decimal Price { get; set; }
}

例如:

{
  "name": "",
  "price": -100
}

雖然可以形成:

CreateProductRequest

但內容不符合 Validation Rules。

如果 Controller 使用:

[ApiController]

當 ModelState 無效時,ASP.NET Core Web API 可以自動回傳 400 Bad Request,不需要每個 Action 手動檢查 ModelState.IsValid。

另外要區分:

"abc" → decimal
→ Binding Error

-100 → decimal 成功
但不符合 [Range]
→ Validation Error

前者是資料無法轉成需要的 C# 型別;後者則是型別正確,但內容不符合規則。


5. 把完整 API 專案放在一起看

到這裡先不要再拆更多概念。

直接看目前這個 Product API 的完整結構會更容易理解。

專案可以先整理成:

MyApi2
│
├─ Controllers
│  └─ ProductsController.cs
│
├─ Models
│  ├─ Product.cs
│  ├─ CreateProductRequest.cs
│  └─ UpdateProductRequest.cs
│
└─ Program.cs

今天主要看三個角色:

Product.cs
→ Product 本身長什麼樣子

CreateProductRequest.cs
→ POST API 可以接收什麼資料

ProductsController.cs
→ HTTP Request 實際怎麼處理

6. Product.cs

檔案:

Models/Product.cs

完整程式碼:

namespace MyApi2.Models;

public record Product(
    int Id,
    string Name,
    decimal Price
);

這個檔案很單純。

它定義:

一個 Product
有哪些資料?

目前有:

Id
Name
Price

例如:

new Product(
    1,
    "Mouse",
    1000m
);

就代表:

Id = 1
Name = Mouse
Price = 1000

7. CreateProductRequest.cs

檔案:

Models/CreateProductRequest.cs

完整程式碼:

using System.ComponentModel.DataAnnotations;

namespace MyApi2.Models;

public class CreateProductRequest
{
    [Required]
    [StringLength(100)]
    public string Name { get; set; }
        = string.Empty;

    [Range(1, 1_000_000)]
    public decimal Price { get; set; }
}

這個檔案不是描述完整 Product。

它描述的是:

Client 要建立 Product 時,可以傳什麼資料。

所以只有:

Name
Price

沒有:

Id

因為目前 Id 由 Server 處理。

而:

[Required]
[StringLength]
[Range]

則負責描述這些輸入資料需要符合哪些規則。


8. UpdateProductRequest.cs

因為 Controller 裡還有 PUT:

[HttpPut("{id:int}")]
public IActionResult Update(
    int id,
    UpdateProductRequest request
)

所以還需要一個:

Models/UpdateProductRequest.cs

可以寫成:

using System.ComponentModel.DataAnnotations;

namespace MyApi2.Models;

public class UpdateProductRequest
{
    [Required]
    [StringLength(100)]
    public string Name { get; set; }
        = string.Empty;

    [Range(1, 1_000_000)]
    public decimal Price { get; set; }
}

目前 Create 與 Update 的欄位一樣,所以內容看起來很接近。

但它們仍然代表兩個不同 API 的輸入:

CreateProductRequest
→ 建立 Product

UpdateProductRequest
→ 修改 Product

未來需求不同時,兩個 DTO 也可以各自演化。


9. ProductsController.cs

檔案:

Controllers/ProductsController.cs

完整程式碼:

using Microsoft.AspNetCore.Mvc;
using MyApi2.Models;

namespace MyApi2.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private static readonly List<Product> Products =
    [
        new Product(
            1,
            "Mouse",
            1000m
        )
    ];

    private static int nextId = 2;

    [HttpGet]
    public ActionResult<IEnumerable<Product>> GetAll()
    {
        return Ok(Products);
    }

    [HttpGet("{id:int}")]
    public ActionResult<Product> GetById(int id)
    {
        Product? product =
            Products.FirstOrDefault(
                product => product.Id == id
            );

        if (product is null)
        {
            return NotFound();
        }

        return Ok(product);
    }

    [HttpPost]
    public ActionResult<Product> Create(
        CreateProductRequest request
    )
    {
        Product product =
            new(
                nextId,
                request.Name,
                request.Price
            );

        nextId++;

        Products.Add(product);

        return CreatedAtAction(
            nameof(GetById),
            new
            {
                id = product.Id
            },
            product
        );
    }

    [HttpPut("{id:int}")]
    public IActionResult Update(
        int id,
        UpdateProductRequest request
    )
    {
        int index =
            Products.FindIndex(
                product => product.Id == id
            );

        if (index == -1)
        {
            return NotFound();
        }

        Products[index] =
            new Product(
                id,
                request.Name,
                request.Price
            );

        return NoContent();
    }

    [HttpDelete("{id:int}")]
    public IActionResult Delete(int id)
    {
        Product? product =
            Products.FirstOrDefault(
                product => product.Id == id
            );

        if (product is null)
        {
            return NotFound();
        }

        Products.Remove(product);

        return NoContent();
    }
}

這支 Controller 現在提供:

HTTP Method URL Action
GET /api/products GetAll()
GET /api/products/{id} GetById()
POST /api/products Create()
PUT /api/products/{id} Update()
DELETE /api/products/{id} Delete()

目前:

List<Product>

只是暫時模擬 Database。

而:

nextId

只是暫時模擬 Database 自動產生 Id。

之後使用 EF Core 時,這些部分會換成真正的 Database 操作。


10. 現在只看 POST

前面的完整 Controller 先不用全部一次理解。

今天真正要觀察的是:

[HttpPost]
public ActionResult<Product> Create(
    CreateProductRequest request
)
{
    Product product =
        new(
            nextId,
            request.Name,
            request.Price
        );

    nextId++;

    Products.Add(product);

    return CreatedAtAction(
        nameof(GetById),
        new
        {
            id = product.Id
        },
        product
    );
}

Client 發出:

POST /api/products
Content-Type: application/json

Request Body:

{
  "name": "Keyboard",
  "price": 2000
}

ASP.NET Core 先找到:

Create(CreateProductRequest request)

接著 Framework 會嘗試把 JSON 準備成:

CreateProductRequest

Name = "Keyboard"
Price = 2000

Validation 通過後,才真正執行 Create()。

因此進到 Action 時:

request.Name
request.Price

都已經可以直接使用。


Action 裡真正做的事情

這一行:

Product product =
    new(
        nextId,
        request.Name,
        request.Price
    );

把 DTO 裡的資料建立成真正的 Product。

假設:

nextId = 2

最後會得到:

Product

Id = 2
Name = Keyboard
Price = 2000

接著:

nextId++;

只是準備下一個 Id。

再:

Products.Add(product);

把 Product 暫時存進 List<Product>。

最後:

return CreatedAtAction(
    nameof(GetById),
    new
    {
        id = product.Id
    },
    product
);

回傳建立結果。

CreatedAtAction 會建立 201 Created Response,並使用指定的 Action 與 Route Values 產生新 Resource 的位置。


11. Client 最後會收到什麼?

假設建立的是:

Id = 2
Name = Keyboard
Price = 2000

Response:

201 Created
Location: /api/products/2

Body:

{
  "id": 2,
  "name": "Keyboard",
  "price": 2000
}

所以整個 POST 可以理解成:

Client
↓
POST /api/products
↓
JSON
↓
CreateProductRequest
↓
Validation
↓
Create(...)
↓
Product
↓
201 Created

這就是今天最重要的一條線。


12. Binding Error 和 Validation Error

最後再看一次最容易混淆的地方。

如果 Client 傳:

{
  "name": "Keyboard",
  "price": "abc"
}

但:

public decimal Price { get; set; }

"abc" 無法正確轉成 decimal。

這是:

Binding Error

如果 Client 傳:

{
  "name": "",
  "price": -100
}

可以建立 DTO,但違反:

[Required]
[Range]

這是:

Validation Error

因為 Controller 使用:

[ApiController]

當 ModelState 無效時,ASP.NET Core Web API 可以自動回 400 Bad Request。


Day 27 小結

今天不用記很多流程圖。

只要看懂這三個檔案:

Product.cs
→ 系統中的 Product

CreateProductRequest.cs
→ POST API 接收的資料與規則

ProductsController.cs
→ Request 實際怎麼被處理

再記住:

HTTP Request
↓
Model Binding
↓
DTO / Parameter
↓
Validation
↓
Action
↓
HTTP Response

最後三個核心:

Model Binding:把 Request Data 準備成 Action 可以使用的 C# 資料。

DTO:定義 API 可以接收或傳遞哪些資料。

Validation:檢查輸入資料是否符合規則。

如果可以從:

POST /api/products

一路看懂:

JSON
↓
CreateProductRequest
↓
Create(...)
↓
Product
↓
201 Created

Day 27 的主要概念就已經掌握了。


上一篇
Day 26|Controller API 與 Routing
下一篇
Day 28|Dependency Injection
系列文
現在就學C# 與 ASP.NET Core 共 29 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言