一个函数,如果不纯洁,那么它的行为将不可预料,即使当前没有发现什么异常,但是会埋下很大的隐患:有一天线上出问题了,你怎么也想不到,居然是某一个看上去不会干坏事的函数捣的鬼。

不纯洁的危害,血淋淋的例子:


曾经碰到一个线上的问题,在非生产环境没有出现过,在生产环境上也只是偶尔出现,经过长时间的排查,最后发现罪魁祸首正是一个看上去是一个很正常的函数做了一件不纯洁的事情,最后做得一个紧急修复如下:
diff

  • const { data, response } = await this.httpClient.executeJsonRequest(url, options)
  • const { data, response } = await this.httpClient.executeJsonRequest(url, { ...options })


改动虽然很小,但是代码变得更丑了,但这没有办法,因为 executeJsonRequest 是一个公共库,它的名字看上去只是执行一个 http 请求,但是实际上它偷偷干了一点其他的事情,就是会改变传入的参数 options 下的某个属性,正好在后面某处有人把这个 options 传给了一个 logger,然而这个 logger 使用了 JSON.stringify 。

多数情况下,这没有问题,但是看了这个公共库的源码才知道,这个原本发送请求的方法竟然将 options 参数改成了一个网络请求中的对象,本来这个 options 参数只是一个 Plain JavaScript Object,但是被改成了一个网络请求中的对象后就复杂了,在某些网络情况下,这个对象的层次会变得很深,而且某一层的属性引用了父级属性,如此导致出现了一个循环引用,这在运行时的内存中不会引发任何问题,但是当后面的日志模块将这个 options 进行序列化时就崩溃了!

但是后面的日志模块哪里会想到这个 Plain JavaScript Object 会被别人修改呢?所以这就是不纯洁的危害。 typescript async executeRequest(url string, options RequestOptions = {}) Promise { const timer = new Date() const requestMethod string = options.method || GET

// 罪魁祸首!为什么要修改外部传进来的参数?! if (url.startsWith(https)) { options.agent = this.httpsAgent } else { options.agent = this.httpAgent } ... }

和纯函数相处,拥有纯粹的快乐

给定相同的输入,总是返回相同的输出,并且不存在任何可以观测到的副作用的函数,就是纯函数。 副作用指的是在函数计算出结果的同时,改变了系统状态或者改变了与外界的可观测到的交互行为。

上面的血淋淋的例子,就是那个本该只发送网络请求的函数,改变了外部传入的参数中的某个属性。当然,发送网络请求的函数,涉及到网络 IO,无论如何做不到完全的纯粹,但是应该将不纯粹的部分减少到最小。无论如何,修改外部传入的参数是不允许的,关于发送网络请求的函数如何保证同样的输入总是得到同样的输出,可以使用 memoize 实现(后面有机会单独分享)。

和纯函数相处,将拥有纯粹的快乐,这些快乐包括但是不限于:

  • 可缓存性好(参考 memoize)
  • 可移植性好/自文档(完全自包含,强制做到了依赖“注入”)
  • 可测试性好(只需要给定输入验证输出,连 mock 都用不着啦!)
  • 可推理性好(引用透明性)
  • 可并行性好(由于没有副作用,所以不会产生竞态条件)

挑战:异常处理


看到这个么多纯粹的快乐,你很可能已经动手开始写纯函数式的代码了,这似乎不难,比如对于那个不纯洁的公共库,只要改成不要修改外部传入的参数就很接近了。但是我的代码总要进行异常处理吧,难以置信的是,try/catch/throw 这种代码竟然也是不纯洁的。

因为当有错误被抛出时,这个函数与平时的表现不一样,没有返回值,取而代之的竟然是打断了程序的正常运行,抛出一些奇奇怪怪的东西出来。

前面提到通过使用 memoize 可以将原本不纯洁的网络 IO 操作变成一个纯洁的函数,像知名的 _lodash 和 rambda 都有 memoize 供你伸手取用,那么对于异常处理的 try/catch/throw 这种代码是否也有相应的库呢?

你一定没有想到这里会出现一个广告:弱弱地推荐一下我 8 个月前写的一个纯洁的异常处理小库,名曰: @jeff-tian/failable 。已经用在公司的生产环境快一年了,觉得有一点点价值,可以帮你做一些脏活累活,特出此文专门分享。

@jeff-tian/failable


使用姿势(可以用在 TypeScript 和 JavaScript 项目中):

  1. 安装: typescript npm i --save @jeff-tian/failable
  2. 使用示例

太简单了,直接贴一下这个小库中的测试代码吧(不是有句话叫测试即文档吗?)。 typescript describe(wrap throwable functions, () => { const sut = (x number) => { if (x > 100) { throw new Error(too big!) }

    return x
}

it(ok, () => {
    assert(Failable.dontThrow(sut, 5).toString() === Ok(5))
})

it(err, () => {
    const res = Failable.dontThrow(sut, 101)
    assert(res instanceof Err)
    assert(res.value.message === too big!)
})

})

可见,对于一个可能抛异常的代码,只要将它用 @jeff-tian/failable 封装,它就一定会返回一个值,并且对同样的输入,一定得到同样的输出,完全可以预期,不会有任何意外。
对于异步代码仍然适用,举例如下: typescript describe(Async, () => { const sut = async (x number) => { await sleepAtLeast(1)

    if (x > 100) {
        throw new Error(too big!)
    }

    return x
}

it(ok, async () => {
    let res = await Failable.dontThrowAsync(sut, 5)
    assert(res.value === 5)
})

it(err, async () => {
    const res = await Failable.dontThrowAsync(sut, 101)
    assert(res instanceof Err)
    assert(res.value.message === too big!)

    return err
})

})

额外的好处

通过使用 @jeff-tian/failable 可以是代码变得更加简洁和易读,拿一段实际代码举个例子:经常会有那种发送网络请求,确保拿到预期结果后,才做下一步逻辑;拿到结果不符合预期或者网络错误的情况下,执行另外一段逻辑。所以可能会看到这样的代码(我在实际项目中经常碰到这样的代码,当然,我总是使用 @jeff-tian/failable 把它重构掉) typescript try { const response = await this.request(url, options) if (!response) { // 这里是错误处理逻辑 } else { // 进行后续处理逻辑 } } catch { // 这里又是一段错误处理逻辑(和 try 里的一段重复) }

使用 @jeff-tian/failable 重构后的代码大致长这样: typescript const response = await Failable.dontThrowAsync(this.request, url, options)

if (!response.isOk() || !response.value) { // 错误处理逻辑 } else { // 后续处理逻辑 }

总结


本文给出了一个将异常处理纯洁化的 TypeScript 语言实现,欢迎留言给出其他语言对应的示例。