3.3 模型绑定与验证 模型绑定把散落的请求数据拼装成强类型对象:绑定器按"表单值、路由数据、查询串"的固定顺序搜刮取值,沿名称匹配递归组装复杂类型;验证则紧跟绑定之后,把注解规则跑一遍,结论写进 ModelState。绑定失败大多不是玄学,是顺序与命名问题。 上一节动作方法签名里那个 ,就是本节的主角。观察哨此刻贴着绑定器工作:请求进站时它还只是一堆键值对,动作执行前它必须变成完整对象——这中间发生了什么、为什么参数有时是 null、有时是 0,答案都在取值阶梯与命名规则里。 绑定器在什么时机进场 动作被选中之后、方法体执行之前,框架检查每个参数:简单类型(int、string、bool 等)直接按来源查找;复杂类型(自定义类)触发递归组装。
模型绑定把散落的请求数据拼装成强类型对象:绑定器按"表单值、路由数据、查询串"的固定顺序搜刮取值,沿名称匹配递归组装复杂类型;验证则紧跟绑定之后,把注解规则跑一遍,结论写进 ModelState。绑定失败大多不是玄学,是顺序与命名问题。
上一节动作方法签名里那个 ProductInput input,就是本节的主角。观察哨此刻贴着绑定器工作:请求进站时它还只是一堆键值对,动作执行前它必须变成完整对象——这中间发生了什么、为什么参数有时是 null、有时是 0,答案都在取值阶梯与命名规则里。
动作被选中之后、方法体执行之前,框架检查每个参数:简单类型(int、string、bool 等)直接按来源查找;复杂类型(自定义类)触发递归组装。来源顺序是固定的,先到先得:
// 同名值出现在多个来源时,表单值胜出 public IActionResult Search(string q, int page) // ?q=键盘&page=2 → q="键盘",page=2(查询串是这两个参数的唯一来源) [HttpPost] public IActionResult Save(ProductInput input) // 表单提交 items[name]=键盘、items[price]=199 → input.Name="键盘",input.Price=199
显式指定来源能消除歧义,接口开发里尤其常用:
public IActionResult Detail( [FromRoute] int id, // 只从路由数据取 [FromQuery] string? sort, // 只从查询串取 [FromForm] ProductInput input) // 只从表单取 => Content($"id={id} sort={sort} name={input.Name}"); // GET /shop/items/42?sort=new,表单带 name → id=42 sort=new name=表单值

复杂类型的组装靠名称对位。表单字段名 Name 对上 ProductInput.Name;嵌套类型用点号或下标:Supplier.Name、Tags[0]。这套规则叫前缀匹配——绑定器会尝试"参数名.属性名"与"裸属性名"两种前缀,都找不到才放弃该属性。
<!-- 表单字段命名决定绑定成败:加粗对应关系 --> <input name="Name" /> <!-- 对上 input.Name --> <input name="Supplier.Name" /> <!-- 对上 input.Supplier.Name --> <input name="Tags[0]" /> <!-- 对上 input.Tags 集合第一项 --> <input name="tags[0].Name" /> <!-- 集合元素是复杂类型时继续下钻 -->
集合与字典同样支持:items[0].Qty、items[1].Qty 会组装成列表。前缀规则的存在让"参数为什么绑不上"变成可推理问题:逐字段核对名称拼写、大小写、前缀层级,九成问题当场现形。
绑定完成后,数据注解验证上场。规则写在模型上,验证器自动执行:
// 验证规则与模型共存,前后端共用一份语义 public class ProductInput { [Required(ErrorMessage = "商品名必填")] [StringLength(40, MinimumLength = 2)] public string Name { get; set; } = ""; [Range(0.01, 99999)] public decimal Price { get; set; } [EmailAddress] // 格式校验,不校验必填 public string? Contact { get; set; } }
验证结论写入 ModelState,动作里第一件事就是检查它:
[HttpPost] public IActionResult Save(ProductInput input) { if (!ModelState.IsValid) return View(input); // 打回表单,错误信息自动显示在对应字段旁 // 校验通过才落库——永远不要信任客户端脚本做过校验 return RedirectToAction("List"); } // 恶意用户绕过页面直接 POST 空名商品:服务端 ModelState 拦下,返回原表单
自动验证与手动验证配合:注解覆盖通用规则(必填、范围、格式),跨字段规则("打折商品必须填原因")在动作里用 ModelState.AddModelError 补。服务端校验是底线,客户端脚本只是体验优化——这条原则第 7 章的安全实践会再次出现。
背景:新上线的商品录入页,运营反馈"价格填了但库里全是 0"。前端有校验、有预览,一切看起来正常,唯独落库数据不对。
操作:观察哨两步定位。先在动作里打印收到的原始表单:
[HttpPost] public IActionResult Save(ProductInput input) { // 打印绑定结果,看价格到底进来没有 Console.WriteLine($"[绑定哨] Name={input.Name} Price={input.Price}"); foreach (var k in Request.Form.Keys) Console.WriteLine($" 表单项 {k} = {Request.Form[k]}"); ... }
输出揭示了真相:
[绑定哨] Name=机械键盘 Price=0 表单项 Name = 机械键盘 表单项 pro_price = 199 ← 前端字段名是下划线风格
结果:名称对不上——模型属性叫 Price,表单字段叫 pro_price,绑定器按名称找不到,decimal 得默认值 0;Name 属性名恰好一致所以正常。改前端字段名为 Price(或给属性加 BindProperty 特性指定绑定名),价格恢复正常。
解读:值类型的"找不到就默认值"行为放大了事故的隐蔽性——string 找不到是 null,容易察觉;数值找不到是 0,看起来像"合法但为零"。凡是数值参数,都要警惕默认值陷阱。
变式:遗留前端改字段名代价大时,用 FromForm(Name = "pro_price") 逐属性映射;或写一个自定义模型绑定器做统一样式转换。另一个常见变体是小数点与逗号:区域文化设置会让 "1,299" 解析失败或解析成 1.299,金额字段建议前端统一传字符串、服务端按固定文化解析。
绑定在前、验证在后,都发生在动作执行前。绑定负责"把值找齐拼成对象",可能产生格式类错误(字符串转数值失败);验证负责"检查值是否满足规则"。两者的错误都汇入同一个 ModelState,动作里一个 IsValid 全涵盖。
JSON 请求体不在默认取值阶梯里(表单值指的是表单编码格式),接口的 JSON 体要显式声明 FromBody 才会交给格式化器反序列化——一个动作最多一个 FromBody 参数,第 6 章展开。
💡 关键直觉:绑定器是"按名字找零件的装配工"——图纸(动作签名)与来料(字段名)对不上,它不会喊停,只会默默留下空位。调试绑定问题,永远先并排打印"它要什么"与"你给了什么"。