Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Callback Promise Generator Async-Await 和异常处理的演进 #14

Open
ascoders opened this issue Jan 31, 2017 · 8 comments
Open

Callback Promise Generator Async-Await 和异常处理的演进 #14

ascoders opened this issue Jan 31, 2017 · 8 comments

Comments

@ascoders
Copy link
Owner

ascoders commented Jan 31, 2017

根据笔者的项目经验,本文讲解了从函数回调,到 es7 规范的异常处理方式。异常处理的优雅性随着规范的进步越来越高,不要害怕使用 try catch,不能回避异常处理。

我们需要一个健全的架构捕获所有同步、异步的异常。业务方不处理异常时,中断函数执行并启用默认处理,业务方也可以随时捕获异常自己处理。

优雅的异常处理方式就像冒泡事件,任何元素可以自由拦截,也可以放任不管交给顶层处理。

文字讲解仅是背景知识介绍,不包含对代码块的完整解读,不要忽略代码块的阅读。

1. 回调

如果在回调函数中直接处理了异常,是最不明智的选择,因为业务方完全失去了对异常的控制能力。

下方的函数 请求处理 不但永远不会执行,还无法在异常时做额外的处理,也无法阻止异常产生时笨拙的 console.log('请求失败') 行为。

function fetch(callback) {
    setTimeout(() => {
        console.log('请求失败')
    })
}

fetch(() => {
    console.log('请求处理') // 永远不会执行
})

2. 回调,无法捕获的异常

回调函数有同步和异步之分,区别在于对方执行回调函数的时机,异常一般出现在请求、数据库连接等操作中,这些操作大多是异步的。

异步回调中,回调函数的执行栈与原函数分离开,导致外部无法抓住异常。

从下文开始,我们约定用 setTimeout 模拟异步操作

function fetch(callback) {
    setTimeout(() => {
        throw Error('请求失败')
    })
}

try {
    fetch(() => {
        console.log('请求处理') // 永远不会执行
    })
} catch (error) {
    console.log('触发异常', error) // 永远不会执行
}

// 程序崩溃
// Uncaught Error: 请求失败

3. 回调,不可控的异常

我们变得谨慎,不敢再随意抛出异常,这已经违背了异常处理的基本原则。

虽然使用了 error-first 约定,使异常看起来变得可处理,但业务方依然没有对异常的控制权,是否调用错误处理取决于回调函数是否执行,我们无法知道调用的函数是否可靠。

更糟糕的问题是,业务方必须处理异常,否则程序挂掉就会什么都不做,这对大部分不用特殊处理异常的场景造成了很大的精神负担。

function fetch(handleError, callback) {
    setTimeout(() => {
        handleError('请求失败')
    })
}

fetch(() => {
	console.log('失败处理') // 失败处理
}, error => {
	console.log('请求处理') // 永远不会执行
})

番外 Promise 基础

Promise 是一个承诺,只可能是成功、失败、无响应三种情况之一,一旦决策,无法修改结果。

Promise 不属于流程控制,但流程控制可以用多个 Promise 组合实现,因此它的职责很单一,就是对一个决议的承诺。

resolve 表明通过的决议,reject 表明拒绝的决议,如果决议通过,then 函数的第一个回调会立即插入 microtask 队列,异步立即执行

简单补充下事件循环的知识,js 事件循环分为 macrotask 和 microtask。
microtask 会被插入到每一个 macrotask 的尾部,所以 microtask 总会优先执行,哪怕 macrotask 因为 js 进程繁忙被 hung 住。
比如 setTimeout setInterval 会插入到 macrotask 中。

const promiseA = new Promise((resolve, reject) => {
    resolve('ok')
})
promiseA.then(result => {
    console.log(result) // ok
})

如果决议结果是决绝,那么 then 函数的第二个回调会立即插入 microtask 队列。

const promiseB = new Promise((resolve, reject) => {
    reject('no')
})
promiseB.then(result => {
    console.log(result) // 永远不会执行
}, error => {
    console.log(error) // no
})

如果一直不决议,此 promise 将处于 pending 状态。

const promiseC = new Promise((resolve, reject) => {
	// nothing
})
promiseC.then(result => {
    console.log(result) // 永远不会执行
}, error => {
    console.log(error) // 永远不会执行
})

未捕获的 reject 会传到末尾,通过 catch 接住

const promiseD = new Promise((resolve, reject) => {
    reject('no')
})
promiseD.then(result => {
    console.log(result) // 永远不会执行
}).catch(error => {
    console.log(error) // no
})

resolve 决议会被自动展开(reject 不会)

const promiseE = new Promise((resolve, reject) => {
    return new Promise((resolve, reject) => {
        resolve('ok')
    })
})
promiseE.then(result => {
    console.log(result) // ok
})

链式流,then 会返回一个新的 Promise,其状态取决于 then 的返回值。

const promiseF = new Promise((resolve, reject) => {
    resolve('ok')
})
promiseF.then(result => {
    return Promise.reject('error1')
}).then(result => {
    console.log(result) // 永远不会执行
    return Promise.resolve('ok1') // 永远不会执行
}).then(result => {
    console.log(result) // 永远不会执行
}).catch(error => {
    console.log(error) // error1
})

4 Promise 异常处理

不仅是 reject,抛出的异常也会被作为拒绝状态被 Promise 捕获。

function fetch(callback) {
    return new Promise((resolve, reject) => {
        throw Error('用户不存在')
    })
}

fetch().then(result => {
    console.log('请求处理', result) // 永远不会执行
}).catch(error => {
    console.log('请求处理异常', error) // 请求处理异常 用户不存在
})

5 Promise 无法捕获的异常

但是,永远不要在 macrotask 队列中抛出异常,因为 macrotask 队列脱离了运行上下文环境,异常无法被当前作用域捕获。

function fetch(callback) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
             throw Error('用户不存在')
        })
    })
}

fetch().then(result => {
    console.log('请求处理', result) // 永远不会执行
}).catch(error => {
    console.log('请求处理异常', error) // 永远不会执行
})

// 程序崩溃
// Uncaught Error: 用户不存在

不过 microtask 中抛出的异常可以被捕获,说明 microtask 队列并没有离开当前作用域,我们通过以下例子来证明:

Promise.resolve(true).then((resolve, reject)=> {
	throw Error('microtask 中的异常')
}).catch(error => {
	console.log('捕获异常', error) // 捕获异常 Error: microtask 中的异常
})

至此,Promise 的异常处理有了比较清晰的答案,只要注意在 macrotask 级别回调中使用 reject,就没有抓不住的异常。

6 Promise 异常追问

如果第三方函数在 macrotask 回调中以 throw Error 的方式抛出异常怎么办?

function thirdFunction() {
    setTimeout(() => {
        throw Error('就是任性')
    })
}

Promise.resolve(true).then((resolve, reject) => {
    thirdFunction()
}).catch(error => {
    console.log('捕获异常', error)
})

// 程序崩溃
// Uncaught Error: 就是任性

值得欣慰的是,由于不在同一个调用栈,虽然这个异常无法被捕获,但也不会影响当前调用栈的执行。

我们必须正视这个问题,唯一的解决办法,是第三方函数不要做这种傻事,一定要在 macrotask 抛出异常的话,请改为 reject 的方式。

function thirdFunction() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject('收敛一些')
        })
    })
}

Promise.resolve(true).then((resolve, reject) => {
    return thirdFunction()
}).catch(error => {
    console.log('捕获异常', error) // 捕获异常 收敛一些
})

请注意,如果 return thirdFunction() 这行缺少了 return 的话,依然无法抓住这个错误,这是因为没有将对方返回的 Promise 传递下去,错误也不会继续传递。

我们发现,这样还不是完美的办法,不但容易忘记 return,而且当同时含有多个第三方函数时,处理方式不太优雅:

function thirdFunction() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject('收敛一些')
        })
    })
}

Promise.resolve(true).then((resolve, reject) => {
    return thirdFunction().then(() => {
        return thirdFunction()
    }).then(() => {
		return thirdFunction()
    }).then(() => {
    })
}).catch(error => {
    console.log('捕获异常', error)
})

是的,我们还有更好的处理方式。

番外 Generator 基础

generator 是更为优雅的流程控制方式,可以让函数可中断执行:

function* generatorA() {
    console.log('a')
    yield
    console.log('b')
}
const genA = generatorA()
genA.next() // a
genA.next() // b

yield 关键字后面可以包含表达式,表达式会传给 next().value

next() 可以传递参数,参数作为 yield 的返回值。

这些特性足以孕育出伟大的生成器,我们稍后介绍。下面是这个特性的例子:

function* generatorB(count) {
    console.log(count)
    const result = yield 5
    console.log(result * count)
}
const genB = generatorB(2)
genB.next() // 2
const genBValue = genB.next(7).value // 14
// genBValue undefined

第一个 next 是没有参数的,因为在执行 generator 函数时,初始值已经传入,第一个 next 的参数没有任何意义,传入也会被丢弃。

const result = yield 5

这一句,返回值不是想当然的 5。其的作用是将 5 传递给 genB.next(),其值,由下一个 next genB.next(7) 传给了它,所以语句等于 const result = 7

最后一个 genBValue,是最后一个 next 的返回值,这个值,就是函数的 return,显然为 undefined

我们回到这个语句:

const result = yield 5

如果返回值是 5,是不是就清晰了许多?是的,这种语法就是 await。所以 Async Awaitgenerator 有着莫大的关联,桥梁就是 生成器,我们稍后介绍 生成器

番外 Async Await

如果认为 Generator 不太好理解,那 Async Await 绝对是救命稻草,我们看看它们的特征:

const timeOut = (time = 0) => new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(time + 200)
    }, time)
})

async function main() {
    const result1 = await timeOut(200)
    console.log(result1) // 400
    const result2 = await timeOut(result1)
    console.log(result2) // 600
    const result3 = await timeOut(result2)
    console.log(result3) // 800
}

main()

所见即所得,await 后面的表达式被执行,表达式的返回值被返回给了 await 执行处。

但是程序是怎么暂停的呢?只有 generator 可以暂停程序。那么等等,回顾一下 generator 的特性,我们发现它也可以达到这种效果。

番外 async await 是 generator 的语法糖

终于可以介绍 生成器 了!它可以魔法般将下面的 generator 执行成为 await 的效果。

function* main() {
    const result1 = yield timeOut(200)
    console.log(result1)
    const result2 = yield timeOut(result1)
    console.log(result2)
    const result3 = yield timeOut(result2)
    console.log(result3)
}

下面的代码就是生成器了,生成器并不神秘,它只有一个目的,就是:

所见即所得,yield 后面的表达式被执行,表达式的返回值被返回给了 yield 执行处。

达到这个目标不难,达到了就完成了 await 的功能,就是这么神奇。

function step(generator) {
    const gen = generator()
    // 由于其传值,返回步骤交错的特性,记录上一次 yield 传过来的值,在下一个 next 返回过去
    let lastValue
    // 包裹为 Promise,并执行表达式
    return () => Promise.resolve(gen.next(lastValue).value).then(value => {
        lastValue = value
        return lastValue
    })
}

利用生成器,模拟出 await 的执行效果:

const run = step(main)

function recursive(promise) {
    promise().then(result => {
        if (result) {
            recursive(promise)
        }
    })
}

recursive(run)
// 400
// 600
// 800

可以看出,await 的执行次数由程序自动控制,而回退到 generator 模拟,需要根据条件判断是否已经将函数执行完毕。

7 Async Await 异常

不论是同步、异步的异常,await 都不会自动捕获,但好处是可以自动中断函数,我们大可放心编写业务逻辑,而不用担心异步异常后会被执行引发雪崩:

function fetch(callback) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject()
        })
    })
}

async function main() {
    const result = await fetch()
    console.log('请求处理', result) // 永远不会执行
}

main()

8 Async Await 捕获异常

我们使用 try catch 捕获异常。

认真阅读 Generator 番外篇的话,就会理解为什么此时异步的异常可以通过 try catch 来捕获。

因为此时的异步其实在一个作用域中,通过 generator 控制执行顺序,所以可以将异步看做同步的代码去编写,包括使用 try catch 捕获异常。

function fetch(callback) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject('no')
        })
    })
}

async function main() {
    try {
        const result = await fetch()
        console.log('请求处理', result) // 永远不会执行
    } catch (error) {
        console.log('异常', error) // 异常 no
    }
}

main()

9 Async Await 无法捕获的异常

和第五章 Promise 无法捕获的异常 一样,这也是 await 的软肋,不过任然可以通过第六章的方案解决:

function thirdFunction() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject('收敛一些')
        })
    })
}

async function main() {
    try {
        const result = await thirdFunction()
        console.log('请求处理', result) // 永远不会执行
    } catch (error) {
        console.log('异常', error) // 异常 收敛一些
    }
}

main()

现在解答第六章尾部的问题,为什么 await 是更加优雅的方案:

async function main() {
    try {
        const result1 = await secondFunction() // 如果不抛出异常,后续继续执行
        const result2 = await thirdFunction() // 抛出异常
        const result3 = await thirdFunction() // 永远不会执行
        console.log('请求处理', result) // 永远不会执行
    } catch (error) {
        console.log('异常', error) // 异常 收敛一些
    }
}

main()

10 业务场景

在如今 action 概念成为标配的时代,我们大可以将所有异常处理收敛到 action 中。

我们以如下业务代码为例,默认不捕获错误的话,错误会一直冒泡到顶层,最后抛出异常。

const successRequest = () => Promise.resolve('a')
const failRequest = () => Promise.reject('b')

class Action {
    async successReuqest() {
        const result = await successRequest()
        console.log('successReuqest', '处理返回值', result) // successReuqest 处理返回值 a
    }

    async failReuqest() {
        const result = await failRequest()
        console.log('failReuqest', '处理返回值', result) // 永远不会执行
    }

    async allReuqest() {
        const result1 = await successRequest()
        console.log('allReuqest', '处理返回值 success', result1) // allReuqest 处理返回值 success a
        const result2 = await failRequest()
        console.log('allReuqest', '处理返回值 success', result2) // 永远不会执行
    }
}

const action = new Action()
action.successReuqest()
action.failReuqest()
action.allReuqest()

// 程序崩溃
// Uncaught (in promise) b
// Uncaught (in promise) b

为了防止程序崩溃,需要业务线在所有 async 函数中包裹 try catch

我们需要一种机制捕获 action 最顶层的错误进行统一处理。

为了补充前置知识,我们再次进入番外话题。

番外 Decorator

Decorator 中文名是装饰器,核心功能是可以通过外部包装的方式,直接修改类的内部属性。

装饰器按照装饰的位置,分为 class decorator method decorator 以及 property decorator(目前标准尚未支持,通过 get set 模拟实现)。

Class Decorator

类级别装饰器,修饰整个类,可以读取、修改类中任何属性和方法。

const classDecorator = (target: any) => {
    const keys = Object.getOwnPropertyNames(target.prototype)
    console.log('classA keys,', keys) // classA keys ["constructor", "sayName"]
}

@classDecorator
class A {
    sayName() {
        console.log('classA ascoders')
    }
}
const a = new A()
a.sayName() // classA ascoders

Method Decorator

方法级别装饰器,修饰某个方法,和类装饰器功能相同,但是能额外获取当前修饰的方法名。

为了发挥这一特点,我们篡改一下修饰的函数。

const methodDecorator = (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
    return {
        get() {
            return () => {
                console.log('classC method override')
            }
        }
    }
}

class C {
    @methodDecorator
    sayName() {
        console.log('classC ascoders')
    }
}
const c = new C()
c.sayName() // classC method override

Property Decorator

属性级别装饰器,修饰某个属性,和类装饰器功能相同,但是能额外获取当前修饰的属性名。

为了发挥这一特点,我们篡改一下修饰的属性值。

const propertyDecorator = (target: any, propertyKey: string | symbol) => {
    Object.defineProperty(target, propertyKey, {
        get() {
            return 'github'
        },
        set(value: any) {
            return value
        }
    })
}

class B {
    @propertyDecorator
    private name = 'ascoders'

    sayName() {
        console.log(`classB ${this.name}`)
    }
}
const b = new B()
b.sayName() // classB github

11 业务场景 统一异常捕获

我们来编写类级别装饰器,专门捕获 async 函数抛出的异常:

const asyncClass = (errorHandler?: (error?: Error) => void) => (target: any) => {
    Object.getOwnPropertyNames(target.prototype).forEach(key => {
        const func = target.prototype[key]
        target.prototype[key] = async (...args: any[]) => {
            try {
                await func.apply(this, args)
            } catch (error) {
                errorHandler && errorHandler(error)
            }
        }
    })
    return target
}

将类所有方法都用 try catch 包裹住,将异常交给业务方统一的 errorHandler 处理:

const successRequest = () => Promise.resolve('a')
const failRequest = () => Promise.reject('b')

const iAsyncClass = asyncClass(error => {
    console.log('统一异常处理', error) // 统一异常处理 b
})

@iAsyncClass
class Action {
    async successReuqest() {
        const result = await successRequest()
        console.log('successReuqest', '处理返回值', result)
    }

    async failReuqest() {
        const result = await failRequest()
        console.log('failReuqest', '处理返回值', result) // 永远不会执行
    }

    async allReuqest() {
        const result1 = await successRequest()
        console.log('allReuqest', '处理返回值 success', result1)
        const result2 = await failRequest()
        console.log('allReuqest', '处理返回值 success', result2) // 永远不会执行
    }
}

const action = new Action()
action.successReuqest()
action.failReuqest()
action.allReuqest()

我们也可以编写方法级别的异常处理:

const asyncMethod = (errorHandler?: (error?: Error) => void) => (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
    const func = descriptor.value
    return {
        get() {
            return (...args: any[]) => {
                return Promise.resolve(func.apply(this, args)).catch(error => {
                    errorHandler && errorHandler(error)
                })
            }
        },
        set(newValue: any) {
            return newValue
        }
    }
}

业务方用法类似,只是装饰器需要放在函数上:

const successRequest = () => Promise.resolve('a')
const failRequest = () => Promise.reject('b')

const asyncAction = asyncMethod(error => {
    console.log('统一异常处理', error) // 统一异常处理 b
})

class Action {
    @asyncAction async successReuqest() {
        const result = await successRequest()
        console.log('successReuqest', '处理返回值', result)
    }

    @asyncAction async failReuqest() {
        const result = await failRequest()
        console.log('failReuqest', '处理返回值', result) // 永远不会执行
    }

    @asyncAction async allReuqest() {
        const result1 = await successRequest()
        console.log('allReuqest', '处理返回值 success', result1)
        const result2 = await failRequest()
        console.log('allReuqest', '处理返回值 success', result2) // 永远不会执行
    }
}

const action = new Action()
action.successReuqest()
action.failReuqest()
action.allReuqest()

12 业务场景 没有后顾之忧的主动权

我想描述的意思是,在第 11 章这种场景下,业务方是不用担心异常导致的 crash,因为所有异常都会在顶层统一捕获,可能表现为弹出一个提示框,告诉用户请求发送失败。

业务方也不需要判断程序中是否存在异常,而战战兢兢的到处 try catch,因为程序中任何异常都会立刻终止函数的后续执行,不会再引发更恶劣的结果。

像 golang 中异常处理方式,就存在这个问题
通过 err, result := func() 的方式,虽然固定了第一个参数是错误信息,但下一行代码免不了要以 if error {...} 开头,整个程序的业务代码充斥着巨量的不必要错误处理,而大部分时候,我们还要为如何处理这些错误想的焦头烂额。

而 js 异常冒泡的方式,在前端可以用提示框兜底,nodejs端可以返回 500 错误兜底,并立刻中断后续请求代码,等于在所有危险代码身后加了一层隐藏的 return

同时业务方也握有绝对的主动权,比如登录失败后,如果账户不存在,那么直接跳转到注册页,而不是傻瓜的提示用户帐号不存在,可以这样做:

async login(nickname, password) {
	try {
		const user = await userService.login(nickname, password)
		// 跳转到首页,登录失败后不会执行到这,所以不用担心用户看到奇怪的跳转
	} catch (error) {
		if (error.no === -1) {
			// 跳转到登录页
		} else {
			throw Error(error) // 其他错误不想管,把球继续踢走
		}
	}
}

补充

nodejs 端,记得监听全局错误,兜住落网之鱼:

process.on('uncaughtException', (error: any) => {
    logger.error('uncaughtException', error)
})

process.on('unhandledRejection', (error: any) => {
    logger.error('unhandledRejection', error)
})

在浏览器端,记得监听 window 全局错误,兜住漏网之鱼:

window.addEventListener('unhandledrejection', (event: any) => {
    logger.error('unhandledrejection', event)
})
window.addEventListener('onrejectionhandled', (event: any) => {
    logger.error('onrejectionhandled', event)
})

如有错误,欢迎斧正,本人 github 主页:https://github.com/ascoders 希望结交有识之士!

@acthtml
Copy link

acthtml commented Feb 4, 2017

如果每个类中的方法都用try/catch包裹,是否有性能问题?

@codezyc
Copy link

codezyc commented Feb 4, 2017

@acthtml 异常可以在最外层统一处理,可以不用每个可能出现异常的地方都写上try/catch

@acthtml
Copy link

acthtml commented Feb 4, 2017

@codezyc 我的意思就是使用Decorator之后,对性能影响如何? 不过这个异常捕获方案的确不错。

@ascoders
Copy link
Owner Author

ascoders commented Feb 6, 2017

@acthtml

装饰器仅在初始化时工作,此时函数已被替换,性能问题基本上可以忽略。

try catch 造成的性能影响不必担心,try 中直接调用函数,性能几乎不受影响,而带来的可维护性价值很大,见下图:

image

几乎所有程序都需要错误上报机制,就算自己不写 try catch,库不写 try catch(库中含有大量的 try catch),业务代码最上层也会有 try catch 捕获和收集异常,所以不用纠结,现在就开始用吧~

@jin5354
Copy link

jin5354 commented Jun 30, 2017

写的太赞了,我也要把这个方案应用到我的项目中

@paranoidjk
Copy link

用 decorator 来做切面异常处理的思路很赞。

对写 try catch 这一点做下探讨

业务方也不需要判断程序中是否存在异常,而战战兢兢的到处 try catch,因为程序中任何异常都会立刻终止函数的后续执行,不会再引发更恶劣的结果。

在浏览器端可能是这样,但在后端思路可能是 fail fast,不要兜住任何开发者编程时预知不到的错误类型,开发者不应该到处 try catch,由多进程模型来保证高可用,比如 egg 的 cluster。
更严格的语言甚至会要求对 Error 类型做静态的分析和检查,比如 http://www.yinwang.org/blog-cn/2017/05/23/kotlin

@ascoders
Copy link
Owner Author

@paranoidjk 滥 try 确实非常影响代码质量,在前端建议只在调用第三方接口时使用(比如发 http 请求),这已经是业务层行为,还是比较合理的。

至于框架层,兜住异常并吞掉是自取毁灭的行为。

@yanlee26
Copy link

异常真正的应用场景是异常场景

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants