MVC
MVC 在这套框架里不是一条独立管线,它最终仍然会落到 Endpoint。
区别只在于:
- MiniAPI 终结点是你手写
mapGet(...)/mapPost(...) - MVC 终结点是框架先扫描控制器和动作,再替你生成
Endpoint
所以理解 MVC 的关键不是“控制器长什么样”,而是三步:
addControllers(...)注册 MVC 服务并发现控制器mapControllers()或mapControllerRoute(...)把动作生成为Endpoint- 路由匹配到
Endpoint后,由 MVC 的请求委托完成参数绑定、调用动作、写回结果
最小示例
import soulsoft_web_hosting.*
import soulsoft_web_http.*
import soulsoft_web_mvc.*
import soulsoft_web_mvc.core.*
import soulsoft_web_mvc.annotations.*
import soulsoft_extensions_injection.*
main(args: Array<String>): Int64 {
let builder = WebHost.createBuilder(args)
// 注册 MVC 核心服务,并把 demo.controllers 加入扫描范围
builder.services.addControllers("demo.controllers")
let app = builder.build()
// 只用 MVC 时,直接 mapControllers() 就够了
// 如果你要在路由和执行之间插入认证、授权、CORS,再手动 useRouting()
app.mapControllers()
app.run()
return 0
}更常见的显式顺序是:
app.useRouting()
app.useAuthentication()
app.useAuthorization()
app.mapControllers()这个顺序背后的含义和 MiniAPI 完全一致:
- 路由先匹配
Endpoint - 认证/授权/CORS 再读取
Endpoint.metadata - 最后才由 MVC 动作执行器真正调用控制器方法
一个完整控制器
import std.collection.*
import soulsoft_serialization.*
import soulsoft_web_mvc.*
import soulsoft_web_mvc.core.*
import soulsoft_web_mvc.annotations.*
import soulsoft_extensions_configuration.*
@Serialization
public class UserModel {
public var id: Int64 = 0
public var name: String = String.empty
public init(id: Int64, name: String) {
this.id = id
this.name = name
}
}
@Route("api/[controller]")
public class UserController <: Controller {
// GET /api/user/42
@HttpGet("{id}")
public func getById(@FromRoute id: Int64) {
return json(UserModel(id, "spire"))
}
// GET /api/user/search?keyword=spire
@HttpGet("search")
public func search(@FromQuery keyword: String) {
return content("keyword=${keyword}")
}
// POST /api/user
// 默认从 Body 绑定,所以 @FromBody 可以省略
@HttpPost("")
public func create(model: UserModel) {
return ok(model)
}
// GET /api/user/config
@HttpGet("config")
public func config(@FromServices configuration: IConfiguration) {
return content(configuration["app:name"] ?? "unknown")
}
}这段示例里最重要的不是返回 JSON,而是四个绑定来源:
@FromRoute@FromQuery@FromServices- 默认
Body
MVC 与 Endpoint 的关系
MVC 最值得注意的点,是“控制器动作先变成描述符,再变成 Endpoint”。
所以 MVC 不是绕开路由,而是通过另一套“终结点生成器”接入路由。
控制器是怎么被发现的
addControllers(...) 最终会准备一个 ApplicationPartManager,然后由 DefaultControllerFeatureProvider 扫描控制器类型。
识别规则很直接:
- 必须是类
- 不能是抽象类
- 必须继承
Controller
扫描范围来自 ApplicationPartManager.parts,也就是你通过这些方式加进去的包:
builder.services.addControllers("demo.controllers")
// 或者
builder.services.addControllers()
.addApplicationPart("demo.controllers")如果控制器没被加入 ApplicationPartManager,mapControllers() 不会自动看见它。
两种路由模式
MVC 在这个实现里有两条分支。
1. 属性路由
只要动作方法上存在实现了 IRouteTemplateProvider 的注解,例如:
@HttpGet@HttpPost@HttpPut
这个动作就走属性路由分支。
@Route("api/[controller]")
public class UserController <: Controller {
@HttpGet("{id}")
public func getById(@FromRoute id: Int64) {
return content(id.toString())
}
}这里的模板拼接规则是源码里写死的:
- 控制器上有
@Route(...),动作模板又不是以/开头 则使用控制器模板 + "/" + 动作模板 - 动作模板以
/开头时,忽略控制器前缀,按根路径处理 [controller]、[action]会在运行时替换成实际名字,并统一转成小写
例如:
@Route("api/[controller]")
public class UserController <: Controller {
@HttpGet("{id}")
public func getById(@FromRoute id: Int64) { ... }
}最终路径就是:
/api/user/{id}2. 约定路由
如果动作上没有路由模板注解,那它不会被 mapControllers() 暴露;这时要靠 mapControllerRoute(...) 提供约定路由模板。
app.mapControllerRoute(
name: "default",
pattern: "{controller}/{action}/{id?}"
)这条路由会把控制器和动作的 routeValues 套进模板里,为动作生成 Endpoint。
可以把两者的差别理解成:
mapControllers():只负责属性路由动作mapControllerRoute(...):给没有属性路由的动作一条全局约定路由
执行流程
一次 MVC 请求真正发生的事情是:
DefaultActionDescriptorCollectionProvider扫描控制器类型和实例方法,构造ControllerActionDescriptor。ActionEndpointFactory根据动作是否存在路由模板注解,决定走属性路由还是约定路由。- 它为每个动作生成
RouteEndpoint,并把ControllerActionDescriptor以及控制器/动作上的注解都挂到Endpoint.metadata。 - 路由匹配成功后,请求进入
ControllerRequestDelegateFactory生成的委托。 ControllerActionInvoker先通过 DI 实例化控制器,再把当前HttpContext注入到Controller基类。DefaultActionModelBinder开始逐个参数绑定:@FromQuery从查询串读@FromForm从表单读@FromRoute从路由值读@FromHeader从请求头读@FromServices从容器取服务- 没写绑定源时,默认按 Body 走输入格式化器
- 模型绑定失败时,优先使用
ApiBehaviorOptions.invalidModelStateResponseFactory;否则输出默认的ValidationProblemDetails。 - 绑定成功后执行动作方法,再按返回值类型分发响应。
参数绑定规则
这套实现最重要的一条规则是:
没有显式绑定源时,默认从 Body 绑定所以这两段代码的语义是一样的:
@HttpPost("")
public func create(@FromBody model: UserModel) {
return ok(model)
}@HttpPost("")
public func create(model: UserModel) {
return ok(model)
}而如果你想从 Query 或 Route 读复杂对象或基础类型,就必须显式标出来:
@HttpGet("search")
public func search(@FromQuery keyword: String, @FromQuery page: Int64) {
return content("${keyword}:${page}")
}这一点和很多“默认 query + route,复杂对象走 body”的框架不一样,文档里必须记住。
返回值是怎么写回响应的
ControllerActionInvoker 的派发规则非常明确:
- 返回
IActionResult:直接执行invoke(context) - 返回
String:自动包装成ContentResult - 返回
ISerializable:自动包装成ObjectResult - 其它类型:返回
204 No Content
这意味着下面几种写法都成立:
@HttpGet("text")
public func text() {
return "hello"
}
@HttpGet("json")
public func data() {
return ok(UserModel(1, "spire"))
}
@HttpGet("notfound")
public func missing() {
return notFound()
}如果你希望结果更可控,优先返回 IActionResult 或直接用 Controller 基类的方法:
ok(...)json(...)content(...)notFound()badRequest()redirect(...)file(...)
模型验证失败时会发生什么
当绑定失败时,MVC 不会继续调用动作方法。
默认行为是返回 ValidationProblemDetails:
- 不支持的
Content-Type会返回415 - 其它绑定或反序列化错误会返回
400
你也可以改写:
builder.services.configure<ApiBehaviorOptions> { options =>
options.invalidModelStateResponseFactory = { context =>
ContentResult("参数不合法", 400)
}
}这时所有模型状态无效的请求都会走你自己的结果工厂。
MvcOptions
MvcOptions 在这个实现里主要控制三块:
- 输入格式化器
- 输出格式化器
- 模型绑定器和 JSON 序列化选项
最常见的配置方式:
builder.services.addControllers()
.addMvcOptions { options =>
// JSON 序列化格式
options.jsonSerializerOptions.dateFormatString = "yyyy-MM-dd HH:mm:ss"
// Accept 不匹配时是否返回 406
options.returnHttpNotAcceptable = true
// 是否尊重浏览器发来的 */* Accept
options.respectBrowserAcceptHeader = true
}
.addApplicationPart("demo.controllers")默认 MvcOptionsSetup 已经注册了这些内置能力:
- 输入格式化器:
JsonInputFormatter - 输出格式化器:
TextOutputFormatter、JsonOutputFormatter - 常见基础类型、字符串、日期、文件等模型绑定器
所以大多数应用只需要改 JSON 选项,不需要自己重配整套格式化器。
MVC 与中间件顺序
MVC 终结点同样依赖前面中间件提供的上下文。
推荐顺序:
app.useRouting()
app.useAuthentication()
app.useAuthorization()
app.mapControllers()原因很直接:
mapControllers()只是把控制器动作注册成终结点- 真正的认证、授权、CORS 判断仍然发生在
Endpoint被匹配之后、动作执行之前
所以控制器动作上的授权注解、控制器上的 CORS 元数据,本质上都还是在操作 Endpoint.metadata。
几个实用结论
1. MVC 最终仍然是 Endpoint
控制器不是特殊执行通道,它只是另一种生成 Endpoint 的方式。
2. mapControllers() 不等于“扫描所有 public 方法并全部开放”
只有走属性路由分支的动作才会被 mapControllers() 暴露;没有路由模板注解的动作,需要 mapControllerRoute(...)。
3. 动作参数默认走 Body
这意味着 GET 场景下如果要从 Query 或 Route 读参数,最好显式写 @FromQuery / @FromRoute。
4. 控制器本身通过 DI 创建
所以构造函数注入是天然可用的;@FromServices 则是给动作参数单独取服务。
5. 模型错误默认不会落到动作方法里
一旦绑定失败,请求会直接走 ApiBehaviorOptions 或默认问题详情响应,不会继续执行动作。
6. 约定路由下要留意公开实例方法
动作描述符的构建阶段会扫描控制器的实例方法;在启用 mapControllerRoute(...) 的场景下,公开的辅助方法如果没有额外约束,也可能进入约定路由生成流程。