让异常代码参与业务逻辑

@eyasliu 2018-12-31 15:47:12发表于 eyasliu/blog

场景

在编写后端服务时,需要处理很多运行时异常,比如用户登录接口/api/v1/auth/login,controller逻辑可能是这样子的

  1. 当前用户是否已登录
  2. 判断验证码是否正确
  3. 判断该用户是否存在
  4. 判断该用户状态是否已禁用
  5. 判断该用户的密码是否正确

以上的逻辑是非常普遍的,每一个步骤的条件不成立都需要返回错误,可能错误信息都不尽相同,写成代码可能是这样子的,以nodejs为例

function AuthLogin(req, res) {
    // 是否已登录
    if (req.isLogin()) {
        res.status(400)
        res.json({msg: "you are already login"})
        return
    }
    // 判断验证码
    if (!req.verifyCapture()) {
        res.status(401)
        res.json({msg: "your capture was wrong"})
        return
    }

    const user = db.User.Where("username = ? OR email = ?", req.body.username, req.body.username).frist()
    // 判断用户是否存在
    if (!user || !user.id) {
        res.status(401)
        res.json({msg: "user is not exit."})
        return
    }
    // 判断用户是否被禁用
    if (user.isForbidden()) {
        res.status(400)
        res.json({msg: "user is forbidden."})
        return
    }
    // 验证密码
    if (!user.verifyPassword(req.body.password)) {
        res.status(401)
        res.json({msg: "your password was wrong"})
        return
    }

    // 成功登陆
    res.login()
    res.json({msg: "ok"})
}

上面代码很啰嗦(当然有些步骤都是用中间件处理的,这里只是为了举例判断条件很多的情况),事实上用一个错误中间件就可以改善。

改进

每当出现判断条件不成立需要立即返回时,统一用 throw 抛出错误,让上层错误中间件统一返回,这个中间件大概是这样子的

function errorMiddleware(req, res, next) {
    try {
        next()
    } catch(e) {
        if (typeof e !== 'object') {
            e = {msg: e}
        }
        res.status(e.status || 400)
        res.json(e)
    }
}

在controller 可以改成

function AuthLogin(req, res) {
    if (req.isLogin()) throw 'you are already login'

    req.verifyCapture() // 在 verifyCapture 函数抛出异常

    const user = db.User.Where("username = ? OR email = ?", req.body.username, req.body.username).frist()

    if (!user || !user.id) throw {status: 401, msg: 'user is not exit'}
    if (user.isForbidden()) throw "user is forbidden."
    if (!user.verifyPassword(req.body.password)) throw {status: 401, msg: 'user is not exit'}

    res.login()
    res.json({msg: "ok"})
}

改成这样,无论是coding感受,逻辑清晰度,思路都更好,当然你也发现了,throw 不只是接受 Error 类型的值,它接受任何类型的值

当然了,如果你用过 koajs 框架,应该早就熟悉了这种模式了,如果是 koa的中间件,应该是这样的

async function errorMiddleware(ctx) {
    try {
        await ctx.next()
    } catch(e) {
        // ...
    }
}

golang 例子

说了那么多,其实我是想总结一下这种方式,顺便记录一下在使用 gin 框架的时候中间件的写法,在go的推荐写法中,使用建议立即去处理各种异常,所以大多项目中都是满屏的 if err != nil {},身为静态语还是有点区别的

func ErrorMiddleware() gin.HandlerFunc {
	return func(ctx *gin.Context) {
		defer func() {
			if err := recover(); err != nil {
				switch err.(type) {
				case gin.H: // 只认定 gin.H 类型
					e := err.(gin.H)
					code := e["code"]
					if code == nil {
						code = types.CodeUnknowError
					}
					msg := e["msg"]
					if msg == nil {
						msg = "unknow error"
					}
					statusV := e["status"]
					statusCode := http.StatusBadRequest
					if statusV != nil {
						statusCode = statusV.(int)
					}
					ctx.Abort() // 需要显式终止中间件,不然依然会往里层调用
					ctx.JSON(statusCode, gin.H{
						"code": code,
						"msg":  msg,
						"data": &gin.H{},
					})
				default:
					panic(err)
				}
			}
		}()
		ctx.Next()
	}
}

在需要抛异常的的地方需要使用 gin.H 类型

panic(gin.H{
    "status": http.StatusUnauthorized, // 这行可不写,默认 400
    "code": types.CodePermissionDefined,
    "msg": "some message"
})

以上中间件参考 gin.Recovery(),但是,go官方是不推荐这么做的,但是这样确实能精简不少代码