写代码是一件与确定性为伍的事情
2020年9月19日
我们所处的世界充满了各种各样的不确定性。但有一件事是不存在不确定性的,即写代码。
多年以前,在我刚入行不久的时候,有一位前辈和我说过“出现问题的时候,先怀疑是自己的原因,因为机器是不会出错的,错的永远是人。”这句话我记了很久,也时不时就会翻出来回想一番,也会冒出很多更细的想法:那机器不也是人造的?机器的程序不也是人写的?就一定是自己的原因,不能是别人的原因吗?但反复想了很多年,还是觉得这句话相当有道理,即使是别人的原因,那错的也是人而不是机器。
这其实就是写代码时的确定性,我们写的代码会被怎么运行,是非常确定的。即便它要依赖更多的底层软硬件机制,但仍然是确定的,只是找出这个确定性的过程更加复杂而已。
一个例子
如果你看不懂例子,跳过就好。
背景
项目中需要上传下载文件,使用的是某云服务的存储服务。在下载的部分,为了方便,使用Node.js封装了一个下载方法,返回一个Stream
,而这个Stream
本质上是由http请求库request.js请求后返回的。最后由koa框架返回这个Stream
给浏览器。
请求下载 -> 下载方法 -> request.js请求云服务 -> 返回Stream
代码大致如下:
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
值的代码,一行代码就能解决。
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在发现response
(Stream
)被pipe
到一个新的Stream
的时候,会尝试使用新Stream
的setHeader
方法,将源响应中的HTTP header都设置到新的Stream
上。
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
// 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仍然没有修复这个问题。