MVC
MVC 不是另一条管线。它和 MiniAPI 最终殊途同归——都是生成 Endpoint,挂到同一个路由管道上。区别只在于:
- MiniAPI 是你手写
mapGet("/orders", handler) - MVC 是框架扫描控制器和动作,替你生成
Endpoint
所以理解 MVC 的关键不是"控制器怎么写",而是三步:发现 → 生成 → 执行。
最小示例
一个文件,一个控制器,一个接口。
import soulsoft_web_mvc.*
import soulsoft_web_http.*
import soulsoft_web_hosting.*
import soulsoft_extensions_injection.*
main(args: Array<String>): Int64 {
let builder = WebHost.createBuilder(args)
builder.services.addControllers("demo.controllers") // ① 注册服务 + 扫描控制器
let app = builder.build()
app.mapControllers() // ② 为每个动作生成 Endpoint 并注册
app.run()
return 0
}
@Route["api/[controller]"]
public class UserController <: Controller {
@HttpGet["{id}"]
public func getById(@FromRoute id: Int64) {
return "user ${id}"
}
}启动后访问 GET /api/user/42,返回 user 42。
三步走完:addControllers 发现 UserController → mapControllers 为 getById 生成 RouteEndpoint → EndpointRoutingMiddleware 匹配到后执行。
路由模板
模板占位符
模板里的 [controller] 和 [action] 是占位符——框架会在注册时替换成实际的控制器名和动作名,统一转小写:
UserController → user
getProfile → getprofile模板拼接规则
控制器上的 @Route 和动作上的 @HttpXxx 拼出一条完整路径:
| 条件 | 拼接方式 | 示例 |
|---|---|---|
动作模板不以 / 开头 | 控制器模板 + / + 动作模板 | @Route["api/[controller]"] + @HttpGet["{id}"] → api/user/{id} |
动作模板以 / 开头 | 忽略控制器模板,按根路径 | @Route["api/[controller]"] + @HttpGet["/health"] → /health |
控制器没写 @Route | 只看动作模板 | @HttpGet["orders"] → orders |
几个实际例子:
@Route["api/[controller]"]
public class OrderController <: Controller {
@HttpGet["list"] // → GET api/order/list 不加 /,普通路径拼接
public func list() { ... }
@HttpGet["{id}"] // → GET api/order/{id} 不加 /,带路由参数
public func getById(@FromRoute id: Int64) { ... }
@HttpGet["/health"] // → GET /health 加 /,忽略控制器模板
public func health() { ... }
}
@Route["api/[controller]/[action]"]
public class BlogController <: Controller {
@HttpPost // → POST api/blog/getList (动作名就是方法名)
public func getList() { ... }
}请求与响应
参数绑定
动作方法的参数怎么来?六个绑定源:
| 注解 | 从哪取值 | 示例 |
|---|---|---|
| 无注解(默认) | Body(等价于 @FromForm) | func create(model: UserModel) |
@FromQuery | 查询串 ?key=val | func search(@FromQuery keyword: String) |
@FromRoute | 路由模板 {id} | func getById(@FromRoute id: Int64) |
@FromForm | 表单 key=val | func login(@FromForm username: String) |
@FromHeader | 请求头 | func check(@FromHeader token: String) |
@FromServices | DI 容器 | func info(@FromServices config: IConfiguration) |
最重要的规则:没写绑定源,默认走 Body,等价于 @FromForm。 这点和很多"默认读 JSON Body"的框架不一样,写 MVC 时要记住。
public class OrderController <: Controller {
// POST /api/order — model 从 Body 反序列化
@HttpPost
public func create(model: OrderModel) {
return model
}
// GET /api/order/search?keyword=手机&page=1 — keyword 和 page 从 Query
@HttpGet["search"]
public func search(@FromQuery keyword: String, @FromQuery page: Int64) {
return "搜索: ${keyword}, 第${page}页"
}
// GET /api/order/42 — id 从路由取
@HttpGet["{id}"]
public func getById(@FromRoute id: Int64) {
return "订单 ${id}"
}
// POST /api/order/upload — file 从表单取
@HttpPost["upload"]
public func upload(@FromForm file: String) {
return "收到文件"
}
}@FromQuery 和 @FromRoute 支持指定名称(和参数名不一致时用):
@HttpGet["search"]
public func search(@FromQuery["k"] keyword: String) {
// 从 ?k=xxx 取值,赋给 keyword
}响应映射
动作方法返回的值,MVC 会自动派发成 HTTP 响应:
| 返回类型 | 行为 |
|---|---|
IActionResult | 直接执行 invoke(context) 写响应 |
String | 包装成 text/plain,200 |
ISerializable | 包装成 application/json,200 |
| 其他 | 204 No Content |
所以这几种写法都行:
@HttpGet["text"]
public func text() {
return "hello" // → 200 text/plain
}
@HttpGet["json"]
public func data() {
return UserModel(1, "spire") // → 200 application/json
}
@HttpPost["create"]
public func create(model: UserModel) {
return model // → 200 application/json
}
@HttpPost["silence"]
public func silence() {
// 返回 Unit → 204 No Content
}IActionResult
想精确控制状态码和响应,用 IActionResult。Controller 基类自带这些快捷方法:
| 方法 | 状态码 |
|---|---|
ok(obj) | 200,JSON |
content(str) | 200,text/plain |
created() | 201 |
noContent() | 204 |
redirect(url) | 302 |
badRequest() | 400 |
unauthorized() | 401 |
notFound() | 404 |
file(bytes, contentType, downloadName?) | 200,文件下载 |
// IActionResult — 精确控制状态码
@HttpGet["detail/{id}"]
public func detail(@FromRoute id: Int64) {
let user = findUser(id)
match (user) {
case Some(u) => ok(u) // → 200,JSON
case None => notFound() // → 404
}
}
@HttpPost["create"]
public func create(model: UserModel) {
if (model.name.size == 0) {
return badRequest() // → 400
}
return created() // → 201
}
@HttpGet["redirect"]
public func goHome() {
return redirect("/home") // → 302
}
@HttpDelete["{id}"]
public func delete(@FromRoute id: Int64) {
return noContent() // → 204
}
// 不用 IActionResult,返回非 200 状态码
public func raw(): IActionResult {
return content("<p>hello</p>", "text/html") // → 200,自定义 Content-Type
}
// 文件下载
@HttpGet["download"]
public func download() {
let bytes = readFileBytes("/data/report.pdf")
return file(bytes, "application/pdf", "report.pdf") // → 200,触发浏览器下载
}接入管道
mapControllers() 和 mapGet() 一样,只是把终结点注册到数据源。它不负责路由匹配、不负责认证——那些仍然是 useRouting、useAuthentication、useAuthorization 的事。
app.useRouting() // ① 匹配 URL → 找到 MVC 生成的 Endpoint
app.useAuthentication() // ② 验 token → 挂 User
app.useAuthorization() // ③ 读 Endpoint.metadata → 放行或拦截
app.mapControllers() // ④ 注册 MVC 终结点(也是在这里执行)顺序和 MiniAPI 完全一致。MVC 生成的 Endpoint 同样带 metadata——控制器上的 @Route、动作上的 @HttpXxx,最终都变成 Endpoint.metadata 里的条目,供授权中间件读取。
身份认证
和 MiniAPI 一样,MVC 的认证也是通过终结点 metadata 实现的。区别只在于:MiniAPI 用 .requireAuthorization() 挂策略,MVC 的注解 @AllowAnonymous 也会变成 metadata。
全局授权
所有控制器接口默认需要登录:
app.mapControllers()
.requireAuthorization("default")允许匿名
登录接口、公开页面用 @AllowAnonymous 跳过:
@AllowAnonymous
@HttpPost["login"]
public func login() {
// 登录接口,不需要鉴权
}局部授权
反过来,全局不加锁,只在需要的控制器上加 @Authorize:
@Route["api/admin"]
@Authorize
public class AdminController <: Controller {
@HttpGet["dashboard"]
public func dashboard() { ... } // 需要登录
@HttpGet["notice"]
@AllowAnonymous
public func notice() { ... } // 公开,覆盖类级 @Authorize
}
@Route["api/blog"]
public class BlogController <: Controller {
@HttpGet["posts"]
public func posts() { ... } // 公开,整个控制器没加 @Authorize
}@Authorize 可加在控制器上(所有动作继承),也可加在单个动作上。
小结
全局授权:mapControllers().requireAuthorization → 默认全锁
匿名豁免:@AllowAnonymous → 个别开门
局部授权:@Authorize → 默认公开,个别上锁约定路由
前面讲的全是属性路由——动作上写了 @HttpGet / @HttpPost 等路由模板注解。
还有一种方式叫约定路由:动作上不写任何路由模板,由 mapControllerRoute 用统一模板匹配。
app.mapControllerRoute(
name: "default",
pattern: "{controller}/{action}/{id?}"
)这条规则会把 UserController.getById 映射到 GET /user/getbyid/42。
两者可以共存:
app.mapControllers() // 处理带 [Route] 的
app.mapControllerRoute("default", "{controller}/{action}") // 处理没带的实际开发中,属性路由是主流——路径更可控、更直观。约定路由主要用于大量控制器遵循同一 URL 模式的场景。
配置选项
MVC 通过 MvcOptions 和 ApiBehaviorOptions 暴露两个维度的配置:JSON 序列化行为,以及模型绑定失败时的响应格式。
自定义 400 响应
模型绑定失败(比如传了字符串给 Int64 参数),框架走 ApiBehaviorOptions 的响应工厂,默认返回 JSON 格式的 ProblemDetails。
builder.services.configure<ApiBehaviorOptions> { options =>
options.invalidModelStateResponseFactory = { context =>
ContentResult("参数不合法", 400)
}
}JSON 序列化选项
通过 MvcOptions.jsonSerializerOptions 控制 JSON 的序列化行为。注意这些配置只影响 @FromBody 绑定和 JSON 响应,@FromForm 等其他绑定源不走 JSON 序列化。
允许可空字段:
默认 nullable = JsonNullable.Required,null 值字段会触发校验错误。设为 Disabled 允许 null:
builder.services.configure<MvcOptions> { options =>
options.jsonSerializerOptions.nullable = JsonNullable.Disabled
}日期格式化:
设置 dateFormatString 指定 DateTime 的输出格式:
builder.services.configure<MvcOptions> { options =>
options.jsonSerializerOptions.dateFormatString = "yyyy-MM-dd HH:mm:ss"
options.jsonSerializerOptions.nullable = JsonNullable.Disabled
}序列化时忽略 null 值字段:
options.jsonSerializerOptions.defaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull自定义转换器:
继承 JsonConverter<T>,实现 read 和 write,注册到 converters:
class DateTimeOffsetConverter <: JsonConverter<DateTimeOffset> {
public func read(dm: DataModel, options: JsonSerializerOptions): DateTimeOffset {
match (dm) {
case value: DataModelString =>
return DateTimeOffset.parse(value.getValue())
case _ => throw DataModelException("must be a valid DateTimeOffset")
}
}
public func write(value: DateTimeOffset, options: JsonSerializerOptions): DataModel {
return value.toString().serialize()
}
}
// 注册
builder.services.configure<MvcOptions> { options =>
options.jsonSerializerOptions.converters.add(DateTimeOffsetConverter())
}常见问题
MVC 和 MiniAPI 怎么选?
接口少、逻辑简单用 MiniAPI(mapGet/mapPost);接口多、需要参数绑定和返回值自动派发用 MVC。两者可以混用——同一个项目里 mapControllers() 和 mapGet() 并存。
为什么我的控制器没被发现?
检查三点:包名是否在 addControllers("包名") 的扫描范围内;类是否继承了 Controller;类是不是抽象类。
动作参数绑不上?
先确认绑定源是否正确。没写注解时默认走 Body——GET 请求没 Body,必须显式标 @FromQuery 或 @FromRoute。
怎么给所有接口统一加前缀?
在控制器上写 @Route["api/[controller]"] 就全部加 /api/ 前缀。或者在 mapControllers() 之前用 mapGroup:
app.mapGroup("/api").mapControllers()模型绑定失败会怎样?
返回 400,走 ApiBehaviorOptions 的响应工厂。自定义格式见上方配置选项。
控制器是怎么创建和释放的?
每次请求,DI 容器创建一个新的控制器实例 → 注入 HttpContext → 绑定参数 → 调用动作方法 → 请求结束后被 GC 回收。控制器是瞬态的,不存在并发问题。注意构造函数里拿不到 context——那是实例化之后框架才挂上去的。