Skip to content

MVC

MVC 不是另一条管线。它和 MiniAPI 最终殊途同归——都是生成 Endpoint,挂到同一个路由管道上。区别只在于:

  • MiniAPI 是你手写 mapGet("/orders", handler)
  • MVC 是框架扫描控制器和动作,替你生成 Endpoint

所以理解 MVC 的关键不是"控制器怎么写",而是三步:发现 → 生成 → 执行

最小示例

一个文件,一个控制器,一个接口。

cangjie
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 发现 UserControllermapControllersgetById 生成 RouteEndpointEndpointRoutingMiddleware 匹配到后执行。

路由模板

模板占位符

模板里的 [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

几个实际例子:

cangjie
@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(等价于 @FromFormfunc create(model: UserModel)
@FromQuery查询串 ?key=valfunc search(@FromQuery keyword: String)
@FromRoute路由模板 {id}func getById(@FromRoute id: Int64)
@FromForm表单 key=valfunc login(@FromForm username: String)
@FromHeader请求头func check(@FromHeader token: String)
@FromServicesDI 容器func info(@FromServices config: IConfiguration)

最重要的规则:没写绑定源,默认走 Body,等价于 @FromForm 这点和很多"默认读 JSON Body"的框架不一样,写 MVC 时要记住。

cangjie
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 支持指定名称(和参数名不一致时用):

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

所以这几种写法都行:

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

想精确控制状态码和响应,用 IActionResultController 基类自带这些快捷方法:

方法状态码
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,文件下载
cangjie
// 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() 一样,只是把终结点注册到数据源。它不负责路由匹配、不负责认证——那些仍然是 useRoutinguseAuthenticationuseAuthorization 的事。

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

全局授权

所有控制器接口默认需要登录:

cangjie
app.mapControllers()
    .requireAuthorization("default")

允许匿名

登录接口、公开页面用 @AllowAnonymous 跳过:

cangjie
@AllowAnonymous
@HttpPost["login"]
public func login() {
    // 登录接口,不需要鉴权
}

局部授权

反过来,全局不加锁,只在需要的控制器上加 @Authorize

cangjie
@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 用统一模板匹配。

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

这条规则会把 UserController.getById 映射到 GET /user/getbyid/42

两者可以共存:

cangjie
app.mapControllers()                            // 处理带 [Route] 的
app.mapControllerRoute("default", "{controller}/{action}")  // 处理没带的

实际开发中,属性路由是主流——路径更可控、更直观。约定路由主要用于大量控制器遵循同一 URL 模式的场景。

配置选项

MVC 通过 MvcOptionsApiBehaviorOptions 暴露两个维度的配置:JSON 序列化行为,以及模型绑定失败时的响应格式。

自定义 400 响应

模型绑定失败(比如传了字符串给 Int64 参数),框架走 ApiBehaviorOptions 的响应工厂,默认返回 JSON 格式的 ProblemDetails。

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

JSON 序列化选项

通过 MvcOptions.jsonSerializerOptions 控制 JSON 的序列化行为。注意这些配置只影响 @FromBody 绑定和 JSON 响应@FromForm 等其他绑定源不走 JSON 序列化。

允许可空字段:

默认 nullable = JsonNullable.Required,null 值字段会触发校验错误。设为 Disabled 允许 null:

cangjie
builder.services.configure<MvcOptions> { options =>
    options.jsonSerializerOptions.nullable = JsonNullable.Disabled
}

日期格式化:

设置 dateFormatString 指定 DateTime 的输出格式:

cangjie
builder.services.configure<MvcOptions> { options =>
    options.jsonSerializerOptions.dateFormatString = "yyyy-MM-dd HH:mm:ss"
    options.jsonSerializerOptions.nullable = JsonNullable.Disabled
}

序列化时忽略 null 值字段:

cangjie
options.jsonSerializerOptions.defaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull

自定义转换器:

继承 JsonConverter<T>,实现 readwrite,注册到 converters

cangjie
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

cangjie
app.mapGroup("/api").mapControllers()

模型绑定失败会怎样?

返回 400,走 ApiBehaviorOptions 的响应工厂。自定义格式见上方配置选项。

控制器是怎么创建和释放的?

每次请求,DI 容器创建一个新的控制器实例 → 注入 HttpContext → 绑定参数 → 调用动作方法 → 请求结束后被 GC 回收。控制器是瞬态的,不存在并发问题。注意构造函数里拿不到 context——那是实例化之后框架才挂上去的。