Skip to content

Before / After / Catch 过程链

SmartWeb 的 Controller 过程模型来自 Rain controller 模块。它保留了 Play Framework 早期 Controller 中 @Before@After@Catch 的直观写法,又加入了作用域、权重、过滤器和业务注解扩展。

这套机制的重点不是“在方法前后调用另一个方法”,而是把认证、授权、上下文装配、审计、异常翻译等横切业务,在应用启动时组合成每个 Action 独立的执行链。

最小示例

kotlin
@WebController
@Path("orders")
class OrderController {
    @Before(weight = -100)
    fun authenticate(user: WebUser?) {
        requireNotNull(user) { "请先登录" }
    }

    @Before(weight = 0)
    fun loadTenant(tenantId: Long): TenantContext =
        TenantContext(tenantId)

    @GetAction("{id}")
    fun detail(id: Long, tenantContext: TenantContext): Order =
        orderService.find(tenantContext, id)

    @After(weight = 100)
    fun audit() {
        auditService.record("order.detail")
    }

    @Catch(error = IllegalArgumentException::class)
    fun badRequest(error: IllegalArgumentException): ApiError =
        ApiError("BAD_REQUEST", error.message)
}

过程方法与 Action 使用同一套 WebMethodInvoker 参数装配能力,因此可以读取请求参数、路径变量、用户和 Context 中的值。

一次请求如何执行

最终的 WebActionInvoker 执行顺序是:

text
匹配 HTTP 路由

按 weight 升序执行 Before
  ↓(没有提前终止)
执行 Action
  ↓(没有提前终止)
按 weight 升序执行 After
  ↓(任一步骤抛出异常)
按 weight 升序尝试 Catch

检查结果并交给响应渲染

对应源码非常直接:SimpleActionInvoker.invoke 依次遍历 beforeProcesses、调用 action、遍历 afterProcesses,外层使用 runCatching 将异常交给 catchProcesses

这体现了 Rain 的实现哲学:不用庞大的 Handler Mapping/Adapter/Interceptor 体系描述一个请求,而是把核心语义压缩为三个 ProcessInvoker[] 和一个 Action 调用器。

Before

@Before 适合在 Action 前完成前置条件或准备上下文:

  • 登录状态验证。
  • 权限和租户边界验证。
  • 从 Header、Session 或 Token 中建立业务上下文。
  • 加载后续 Action 需要的对象。
  • 限流、幂等检查。
kotlin
@Before(weight = -100)
fun requireLogin(user: WebUser?) {
    requireNotNull(user)
}

多个 Before 按 weight 从小到大执行。相同权重下不要依赖稳定顺序;存在依赖关系时应显式拉开权重。

过程方法返回普通对象时,Rain 会按类型简单名首字母小写写入 ActionContext。例如返回 TenantContext,会保存为 tenantContext,后续过程或 Action 可以按上下文参数读取。这使 Before 不只是“检查器”,也可以是请求级数据生产者。

After

@After 只在 Before 与 Action 正常走完、且没有产生提前终止标记时运行,适合:

  • 业务审计。
  • 补充响应上下文。
  • 记录成功指标。
  • 对 Action 结果进行后续业务处理。
kotlin
@After(weight = 100)
fun recordSuccess(currentUser: WebUser?) {
    metrics.success(currentUser?.id)
}

它不是 Java finally:如果 Before 或 Action 抛出异常,控制流会直接进入 Catch,After 不执行。必须无论成功失败都执行的资源释放,不应只依赖 After,应放在底层服务的 try/finally 或生命周期组件中。

Catch

@Catch 只处理指定异常类型:

kotlin
@Catch(error = OrderNotFoundException::class, weight = 0)
fun notFound(error: OrderNotFoundException): ApiError =
    ApiError("ORDER_NOT_FOUND", error.message)

底层 SimpleCatchMethodInvoker / WebCatchMethodInvoker 会先检查 throwableType.isInstance(context.runtimeError),不匹配时返回 null,让后续 Catch 继续尝试。

Catch 可以把领域异常转换为稳定的 API 错误对象。若 Catch 自己抛出 ActionResult,Rain 会清空运行时异常并使用其中结果;若 Catch 抛出普通异常,原始异常继续向外传播。

当前行为

Catch 方法返回普通对象时,会像其他过程结果一样写入 Context,而不是自动成为 context.result。需要明确中止并返回结果时,应使用框架的 ActionResult 流程控制,或采用项目统一的异常 Render/响应策略。请针对当前 SmartWeb 版本做端到端测试。

作用域:Controller 与根路由

默认情况下,Controller 内声明的 Before、After、Catch 只加入当前 Controller 的过程集合。给过程方法加 @Global 后,它会加入当前根路由:

kotlin
@Global
@Before(weight = -1000)
fun traceRequest(): RequestTrace = RequestTrace.new()

最终为 Action 建链时,Rain 按以下来源收集:

  1. 当前根路由的过程。
  2. 当前 Controller 的过程。
  3. weight 对合并结果统一排序。

因此全局过程和局部过程不是两段固定执行;权重决定它们在同一 Before/After/Catch 队列中的位置。

SmartWeb 使用 Controller Bean 的 @Named 名称选择根路由。不同名称形成不同 rootRouterMap 项,也会创建不同命名 Web Server。@Global 的“全局”只在当前根路由/服务器内全局,不会天然跨越所有命名服务器。

父 Controller 与子 Controller

Rain 的动静分离 Controller Loader 会沿 Controller 的父类向上收集 @Path

kotlin
@Path("api")
open class ApiController

@WebController
@Path("orders")
class OrderController : ApiController() {
    @GetAction("{id}")
    fun detail(id: Long) = TODO()
}

最终路径为 /api/orders/{id}。如果某一层 @Path/ 开头,路径收集会在该层停止,它相当于新的绝对路径边界。

Controller Loader 扫描 allMethod,因此继承方法也可能成为子 Controller 的过程或 Action。推荐将父类用于稳定、通用的过程模板,而把具体 Action 留在子类:

kotlin
open class SecuredController {
    @Before(weight = -100)
    open fun authenticate(user: WebUser?) {
        requireNotNull(user)
    }
}

@WebController
@Path("admin/users")
class AdminUserController : SecuredController() {
    @GetAction
    fun listUsers() = userService.list()
}

这种方式比把权限检查复制到每个 Action 更适合业务分区;但继承层级过深会隐藏行为。跨多个无继承关系 Controller 的能力,优先使用业务注解与 ProcessProvider

only 与 except

三个过程注解都支持 onlyexcept

kotlin
@Before(only = ["create", "update@java.lang.Long"])
fun requireWritePermission() = Unit

@After(except = ["health"])
fun audit() = Unit

匹配规则来自源码:

  • methodName:匹配同名方法的所有重载。
  • methodName@:匹配无参数方法。
  • methodName@java.lang.Long:使用参数完整类名匹配。
  • methodName@.Long:参数名前加 .,使用简单类名匹配。
  • 多参数使用逗号分隔。

only 非空时必须命中其中一项;except 命中任一项就排除。重载较多时应使用带参数形式,避免误匹配。

用业务注解声明过程

把权限需求表达成业务注解,比直接在 Controller 中写重复 Before 更清晰:

kotlin
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@ProcessBy(PermissionProcessProvider::class)
annotation class RequirePermission(val value: String)

@Before(weight = -50)
class PermissionProcessProvider(
    private val permissionService: PermissionService,
) : ProcessProvider<WebActionContext> {
    override fun <T> invoke(
        provideAnnotation: Annotation,
        functionAnnotation: Annotation,
        controllerClass: Class<T>,
        controllerInstance: T,
        action: Method?,
    ): ProcessInvoker<WebActionContext> {
        val permission = provideAnnotation as RequirePermission
        return ProcessInvoker { context ->
            permissionService.require(context.user, permission.value)
        }
    }
}

然后在类或 Action 上声明:

kotlin
@WebController
@Path("admin")
@RequirePermission("admin:enter")
class AdminController {
    @DeleteAction("users/{id}")
    @RequirePermission("user:delete")
    fun deleteUser(id: Long) = userService.delete(id)
}

加载时,Rain 读取业务注解上的 @ProcessBy,从 DI 容器获取 Provider,并根据 Provider 类上的 @Before / @After / @Catch 决定过程类型和权重。Provider 在启动期为每个声明位置创建 ProcessInvoker,运行时只调用已经组装好的 invoker。

关于示例语法

ProcessInvoker 是 Kotlin 函数式接口/函数类型时可直接使用 lambda;请以当前源码中的实际类型签名调整构造方式。核心设计是 Provider 返回一个接收 WebActionContext 的调用器。

ProcessFilterBy

当注解是否适用于某个 Action 不能由 only / except 静态表达时,可以在注解声明位置增加 @ProcessFilterBy,由 DI Bean 实现 ProcessFilter

kotlin
class PublicEndpointFilter : ProcessFilter {
    override fun <T> invoke(
        provideAnnotation: Annotation,
        functionAnnotation: Annotation,
        controllerClass: Class<T>,
        controllerInstance: T,
        action: Method?,
    ): Boolean = action?.isAnnotationPresent(PublicEndpoint::class.java) != true
}

过滤发生在 Action 执行链构建阶段,不是每次请求重新反射判断。它适合根据 Controller/Action 元数据决定是否挂载过程,不适合根据当前用户或请求数据做动态判断;请求级判断应放在 ProcessInvoker 内。

流程控制值

SimpleActionInvoker 识别两个特殊结果:

  • DoNone:停止当前链,并保留特殊结果。
  • SkipMe:停止当前 Action,最终让路由返回 false,可继续尝试其他匹配逻辑。

抛出 ActionResult(value) 会中断当前调用并直接设置 Action 结果。普通过程返回值则写入 Context,供后续过程和 Action 使用。

面向业务的推荐分层

需求推荐位置
全站 Trace、访问日志根路由 @Global @Before/@After
某业务域统一登录校验父 Controller 或类级业务注解
单 Action 权限Action 上的业务注解
参数依赖的动态权限Provider 生成的 Before invoker
领域异常转 API 错误Controller 或根路由 Catch
必须执行的资源释放服务内部 try/finally,不要只依赖 After

与 Play Framework 的关系

Play 1.x 的 @Before@After@Catch 强调 Controller 生命周期回调,使用直观但容易把横切逻辑集中到继承体系。Rain/SmartWeb 保留这种易读体验,同时增加:

  • weight 统一排序。
  • only / except 精确作用到 Action 或重载。
  • @Global 提升到根路由。
  • @ProcessBy 把业务注解转为可复用过程。
  • @ProcessFilterBy 在启动期按元数据筛选 Action。
  • 过程返回对象写入 Context,形成显式的请求数据生产链。

因此它更适合把“进入业务 Action 前必须完成什么”表达成可组合的业务协议,而不仅是传统 MVC 拦截器。

基于 Apache License 2.0 发布