Skip to content

写代码是一件与确定性为伍的事情

2020年9月19日

我们所处的世界充满了各种各样的不确定性。但有一件事是不存在不确定性的,即写代码。

多年以前,在我刚入行不久的时候,有一位前辈和我说过“出现问题的时候,先怀疑是自己的原因,因为机器是不会出错的,错的永远是人。”这句话我记了很久,也时不时就会翻出来回想一番,也会冒出很多更细的想法:那机器不也是人造的?机器的程序不也是人写的?就一定是自己的原因,不能是别人的原因吗?但反复想了很多年,还是觉得这句话相当有道理,即使是别人的原因,那错的也是人而不是机器。

这其实就是写代码时的确定性,我们写的代码会被怎么运行,是非常确定的。即便它要依赖更多的底层软硬件机制,但仍然是确定的,只是找出这个确定性的过程更加复杂而已。

一个例子

如果你看不懂例子,跳过就好。

背景

项目中需要上传下载文件,使用的是某云服务的存储服务。在下载的部分,为了方便,使用Node.js封装了一个下载方法,返回一个Stream,而这个Stream本质上是由http请求库request.js请求后返回的。最后由koa框架返回这个Stream给浏览器。

请求下载 -> 下载方法 -> request.js请求云服务 -> 返回Stream

代码大致如下:

javascript
router.get('/api/download-file', async (ctx) => {
    ctx.body = Download.getPrivateStream(ctx.query.fileId);
});
router.get('/api/download-file', async (ctx) => {
    ctx.body = Download.getPrivateStream(ctx.query.fileId);
});

然而,同样的代码,在不同的项目下,表现却大不一样,A项目访问图片时是直接在浏览器中显示图片,B项目访问同样的图片却变成了下载。调试工具一查看,发现它们有不一样的HTTP Header返回:

  • A项目Content-Type: image/png
  • B项目Content-Type: application/octet-stream

解决

经过初步排查,A B两个项目中都没有手工设置过这个Header值,可以基本确认这个差异并不是由于下载部分的写法造成的。

虽然原因不是很明朗,但这个问题却很好解决:手工加一个设置Content-Type值的代码,一行代码就能解决。

javascript
router.get('/api/download-file', async (ctx) => {
    ctx.type = mime.getType(fileExt);
    ctx.body = Download.getPrivateStream(ctx.query.fileId);
});
router.get('/api/download-file', async (ctx) => {
    ctx.type = mime.getType(fileExt);
    ctx.body = Download.getPrivateStream(ctx.query.fileId);
});

寻找确定性

虽然上面的代码解决了这个应用场景下的问题,但却并没有找到真正的原因。也就是说,这里遗留了一段具有不确定性的代码。

为了找到真正确定的原因,我在接下来的两天内花了一个晚上+一个上午的时间,从下载的封装到request.js的源码都一一做了排查,最终找到了原因。

request.js在发现responseStream)被pipe到一个新的Stream的时候,会尝试使用新StreamsetHeader方法,将源响应中的HTTP header都设置到新的Stream上。

javascript
if (dest.headers && !dest.headersSent) {
    if (response.caseless.has('content-type')) {
        var ctname = response.caseless.has('content-type')
        if (dest.setHeader) {
            dest.setHeader(ctname, response.headers[ctname])
        } else {
            dest.headers[ctname] = response.headers[ctname]
        }
    }

    if (response.caseless.has( 'content-length')) {
        var clname = response.caseless.has( 'content-length')
        if (dest.setHeader) {
            dest.setHeader(clname, response.headers[clname])
        } else {
            dest.headers[clname] = response.headers[clname]
        }
    }
}
if (dest.headers && !dest.headersSent) {
    if (response.caseless.has('content-type')) {
        var ctname = response.caseless.has('content-type')
        if (dest.setHeader) {
            dest.setHeader(ctname, response.headers[ctname])
        } else {
            dest.headers[ctname] = response.headers[ctname]
        }
    }

    if (response.caseless.has( 'content-length')) {
        var clname = response.caseless.has( 'content-length')
        if (dest.setHeader) {
            dest.setHeader(clname, response.headers[clname])
        } else {
            dest.headers[clname] = response.headers[clname]
        }
    }
}

然而调试到这里的时候会发现A B两个项目走到了不同的逻辑。B项目的新Stream(代码中的dest)并不存在setHeader方法。

通过查看koa的源码,会发现这个dest其实就是ctx.body。按理说,ctx.body是一个http response stream,肯定是有setHeader方法的。那么,唯一的解释就是:有别的代码动过ctx.body了。

最后经过一番排查,找到了一个万万想不到的事实:koa-logger会替换ctx.body

javascript
// calculate the length of a streaming response
// by intercepting the stream with a counter.
// only necessary if a content-length header is currently not set
const length = ctx.response.length
const body = ctx.body
let counter
if (length == null && body && body.readable) {
    ctx.body = body
        .pipe(counter = Counter())
        .on('error', ctx. onerror)
}
// calculate the length of a streaming response
// by intercepting the stream with a counter.
// only necessary if a content-length header is currently not set
const length = ctx.response.length
const body = ctx.body
let counter
if (length == null && body && body.readable) {
    ctx.body = body
        .pipe(counter = Counter())
        .on('error', ctx. onerror)
}

原来,koa-logger为了记录响应体的大小,粗暴地将ctx.body pipe到了一个Counter实例上,并就此替换了ctx.body。刚好,A项目没有使用koa-logger,而B项目使用了。

因为出于对不确定性的不放心,所以尽管有现成的解决办法,但仍然没有放弃对它的追查。谁能想得到,最终的问题出在一个看起来人畜无害的代码库中呢?好在,花费了一番工夫,总算把一个不确定的事情变成了确定的事情。 在这个例子中,可能我们会再慎重地评估这个代码库,即使不能第一时间进行替换和修复,也可以给出足够的文档,而这将为后续代码的稳定运行打下坚实基础。

写代码是一件与确定性为伍的事情,如果你觉得你的代码有诸多不确定性,那只能说明一件事情,就是你又欠工夫了。

2022-08更新:2年过去了,koa-logger仍然没有修复这个问题。

issue Pull Request