Skip to content

MVC

MVC 在这套框架里不是一条独立管线,它最终仍然会落到 Endpoint

区别只在于:

  • MiniAPI 终结点是你手写 mapGet(...) / mapPost(...)
  • MVC 终结点是框架先扫描控制器和动作,再替你生成 Endpoint

所以理解 MVC 的关键不是“控制器长什么样”,而是三步:

  1. addControllers(...) 注册 MVC 服务并发现控制器
  2. mapControllers()mapControllerRoute(...) 把动作生成为 Endpoint
  3. 路由匹配到 Endpoint 后,由 MVC 的请求委托完成参数绑定、调用动作、写回结果

最小示例

cangjie
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
}

更常见的显式顺序是:

cangjie
app.useRouting()
app.useAuthentication()
app.useAuthorization()
app.mapControllers()

这个顺序背后的含义和 MiniAPI 完全一致:

  • 路由先匹配 Endpoint
  • 认证/授权/CORS 再读取 Endpoint.metadata
  • 最后才由 MVC 动作执行器真正调用控制器方法

一个完整控制器

cangjie
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,也就是你通过这些方式加进去的包:

cangjie
builder.services.addControllers("demo.controllers")

// 或者
builder.services.addControllers()
    .addApplicationPart("demo.controllers")

如果控制器没被加入 ApplicationPartManagermapControllers() 不会自动看见它。

两种路由模式

MVC 在这个实现里有两条分支。

1. 属性路由

只要动作方法上存在实现了 IRouteTemplateProvider 的注解,例如:

  • @HttpGet
  • @HttpPost
  • @HttpPut

这个动作就走属性路由分支。

cangjie
@Route("api/[controller]")
public class UserController <: Controller {
    @HttpGet("{id}")
    public func getById(@FromRoute id: Int64) {
        return content(id.toString())
    }
}

这里的模板拼接规则是源码里写死的:

  • 控制器上有 @Route(...),动作模板又不是以 / 开头 则使用 控制器模板 + "/" + 动作模板
  • 动作模板以 / 开头时,忽略控制器前缀,按根路径处理
  • [controller][action] 会在运行时替换成实际名字,并统一转成小写

例如:

cangjie
@Route("api/[controller]")
public class UserController <: Controller {
    @HttpGet("{id}")
    public func getById(@FromRoute id: Int64) { ... }
}

最终路径就是:

text
/api/user/{id}

2. 约定路由

如果动作上没有路由模板注解,那它不会被 mapControllers() 暴露;这时要靠 mapControllerRoute(...) 提供约定路由模板。

cangjie
app.mapControllerRoute(
    name: "default",
    pattern: "{controller}/{action}/{id?}"
)

这条路由会把控制器和动作的 routeValues 套进模板里,为动作生成 Endpoint

可以把两者的差别理解成:

  • mapControllers():只负责属性路由动作
  • mapControllerRoute(...):给没有属性路由的动作一条全局约定路由

执行流程

一次 MVC 请求真正发生的事情是:

  1. DefaultActionDescriptorCollectionProvider 扫描控制器类型和实例方法,构造 ControllerActionDescriptor
  2. ActionEndpointFactory 根据动作是否存在路由模板注解,决定走属性路由还是约定路由。
  3. 它为每个动作生成 RouteEndpoint,并把 ControllerActionDescriptor 以及控制器/动作上的注解都挂到 Endpoint.metadata
  4. 路由匹配成功后,请求进入 ControllerRequestDelegateFactory 生成的委托。
  5. ControllerActionInvoker 先通过 DI 实例化控制器,再把当前 HttpContext 注入到 Controller 基类。
  6. DefaultActionModelBinder 开始逐个参数绑定:
    • @FromQuery 从查询串读
    • @FromForm 从表单读
    • @FromRoute 从路由值读
    • @FromHeader 从请求头读
    • @FromServices 从容器取服务
    • 没写绑定源时,默认按 Body 走输入格式化器
  7. 模型绑定失败时,优先使用 ApiBehaviorOptions.invalidModelStateResponseFactory;否则输出默认的 ValidationProblemDetails
  8. 绑定成功后执行动作方法,再按返回值类型分发响应。

参数绑定规则

这套实现最重要的一条规则是:

text
没有显式绑定源时,默认从 Body 绑定

所以这两段代码的语义是一样的:

cangjie
@HttpPost("")
public func create(@FromBody model: UserModel) {
    return ok(model)
}
cangjie
@HttpPost("")
public func create(model: UserModel) {
    return ok(model)
}

而如果你想从 Query 或 Route 读复杂对象或基础类型,就必须显式标出来:

cangjie
@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

这意味着下面几种写法都成立:

cangjie
@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

你也可以改写:

cangjie
builder.services.configure<ApiBehaviorOptions> { options =>
    options.invalidModelStateResponseFactory = { context =>
        ContentResult("参数不合法", 400)
    }
}

这时所有模型状态无效的请求都会走你自己的结果工厂。

MvcOptions

MvcOptions 在这个实现里主要控制三块:

  • 输入格式化器
  • 输出格式化器
  • 模型绑定器和 JSON 序列化选项

最常见的配置方式:

cangjie
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
  • 输出格式化器:TextOutputFormatterJsonOutputFormatter
  • 常见基础类型、字符串、日期、文件等模型绑定器

所以大多数应用只需要改 JSON 选项,不需要自己重配整套格式化器。

MVC 与中间件顺序

MVC 终结点同样依赖前面中间件提供的上下文。

推荐顺序:

cangjie
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(...) 的场景下,公开的辅助方法如果没有额外约束,也可能进入约定路由生成流程。