Skip to content

异常处理

异常处理中间件的职责很单一:包住后续管道,把未处理异常统一转成 HTTP 响应。

参考实现的核心有三个关键点:

  • next(context) 外层统一 try/catch
  • KnowException 这类业务异常直接返回业务消息
  • 其他异常统一返回 500;开发环境附带 stackTrace,同时记录错误日志

最小示例

cangjie
package web

import soulsoft_web_http.*
import soulsoft_web_hosting.*
import soulsoft_web_routing.*
import soulsoft_extensions_injection.*
import soulsoft_extensions_logging.*
import stdx.encoding.json.*

class KnowException <: Exception {
    public init(message: String) {
        super(message)
    }
}

class ExceptionHandlerMiddleware <: IMiddleware {
    private let logger: ILogger

    public ExceptionHandlerMiddleware(
        let environment: IWebHostEnvironment,
        let loggerFactory: ILoggerFactory
    ) {
        logger = loggerFactory.createLogger<ExceptionHandlerMiddleware>()
    }

    public func invoke(context: HttpContext, next: RequestDelegate): Unit {
        try {
            next(context)
        } catch (ex: Exception) {
            let obj = JsonObject()
            obj.put("status", JsonInt(500))
            if (ex is KnowException) {
                obj.put("description", JsonString(ex.message))
            } else {
                obj.put("description", JsonString("服务器错误"))
                if (environment.isDevelopment()) {
                    obj.put("stackTrace", JsonString(ex.toString()))
                }
                logger.error(ex, ex.message)
            }
            context.response.statusCode = StatusCodes.InternalServerError
            context.response.writeAsJson(obj)
        }
    }
}

extend ApplicationBuilder {
    public func useExceptionHandler() {
        this.use<ExceptionHandlerMiddleware>([])
    }
}

main(args: Array<String>): Int64 {
    let builder = WebHost.createBuilder(args)
    builder.services.addRouting()

    let app = builder.build()
    app.useExceptionHandler()
    app.useRouting()

    app.mapGet("/known") {
        _ => throw KnowException("这是一个可预期的业务错误")
    }

    app.mapGet("/unknown") {
        _ => throw Exception("这是一个未处理异常")
    }

    app.mapGet("/ok") {
        ctx => ctx.response.write("ok")
    }

    app.run()
    return 0
}

这个版本刻意只保留最小依赖:

  • IWebHostEnvironment:判断是不是开发环境
  • ILoggerFactory:给未知异常记日志
  • stdx.encoding.json.*:构造 JSON 响应

管道顺序

异常处理中间件必须放在最外层,否则它后面的中间件抛异常时,没人接。

cangjie
app.useExceptionHandler()   // ① 最外层兜底
app.useRouting()            // ② 匹配 URL,挂 Endpoint
app.useAuthentication()     // ③ 可选:认证
app.useAuthorization()      // ④ 可选:授权
app.mapGet(...)             // ⑤ 注册终结点

如果还要挂静态文件、健康检查,一般也是先异常处理,再其他中间件:

cangjie
app.useExceptionHandler()
app.useHealthChecks("/health")
app.useFileServer()
app.useRouting()

响应行为

已知业务异常

访问 /known

json
{"status":500,"description":"这是一个可预期的业务错误"}

这里和参考实现保持一致:虽然是业务异常,HTTP 状态码仍然统一写 500,只是把错误描述换成可直接返回的业务消息。

未知异常

访问 /unknown

生产环境:

json
{"status":500,"description":"服务器错误"}

开发环境:

json
{"status":500,"description":"服务器错误","stackTrace":"..."}

开发环境判断来自 environment.isDevelopment()。可以用下面任一方式启动:

powershell
$env:cangjie_environment = "Development"
cjpm run

或者:

powershell
cjpm run -- --environment=Development

为什么要区分 KnowException

参考实现里,KnowException 本质上是“可以安全返回给调用方”的业务异常,比如:

  • 用户名或密码错误
  • 参数缺失
  • 当前资源状态不允许该操作

这类错误如果统一返回“服务器错误”,前端没法给出明确提示;但如果把所有异常原文都暴露出去,又会把内部实现细节泄漏给外部。

所以常见做法就是分两层:

  • 业务可预期异常:返回明确提示
  • 非预期异常:返回通用消息,日志里保留细节

一个推荐写法

如果项目里会反复用到这套逻辑,推荐把中间件单独放文件,再扩展 ApplicationBuilder 暴露一个稳定入口:

cangjie
extend ApplicationBuilder {
    public func useExceptionHandler() {
        this.use<ExceptionHandlerMiddleware>([])
    }
}

这样有两个直接好处:

  • 调用方只写 app.useExceptionHandler(),不会暴露中间件构造细节
  • 构造函数依赖仍然由 DI 自动解析,后续补错误码映射、审计日志、统一响应模型也只改这一处