3

通常我们在做静态文件服务的时候,首选CDN。当文件内容需要经常变动时,则可以采用nginx代理的方式。node本身也可以搭建静态服务,用koa static可以很容易实现这个功能。

koa static是一个koa中间件,内部是对koa send的封装。koa static本身只做了一层简单的逻辑,所以这篇文章主要分析一下koa send的实现方式。

如果让我们自己实现这个功能,也很简单,逻辑就是根据用户请求路径,找到文件,然后做一个文件流的响应。

koa send的实现也大概是这个思路,另外多了一些基于http协议的处理,当然,阅读koa send的源码,还是有一些意外的收获。

koa send源码很简洁,唯一暴露了一个工具函数sendsend函数大致结构如下:

async function send(ctx, path, opts = {}) {
    // 1、参数path校验
    // 2、配置opts初始化
    // 3、accept encoding处理
    // 4、404、500处理
    // 5、缓存头处理
    // 6、流响应
}
  1. 第1步和第2步是koa send本身的一些配置的处理,代码比较啰嗦,我们可以忽略。
  2. 第3步,主要是根据请求头Accept-Encoding进行处理,如果用户浏览器支持br或者gzip的压缩方式,koa send会判断是否存在br或者gz格式文件,如果存在会优先响应br或者gz文件。
  3. 第4步,会做文件查找,如果不存在文件,或者文件查找异常,则进行404或者500的响应。具体代码如下:

    try {
        stats = await fs.stat(path)
        // 默认文件index
        if (stats.isDirectory()) {
            if (format && index) {
                path += '/' + index
                stats = await fs.stat(path)
            } else {
                return
            }
        }
    } catch (err) {
        const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']
        if (notfound.includes(err.code)) {
            throw createError(404, err)
        }
        err.status = 500
        throw err
    }
  4. 第5步,会设置协商缓存Last-Modified和强制缓存Cache-Control,代码很简单不解释,不过这里面有一个之前没遇到的知识点,koa send设置的Cache-Control会有类似max-age=10000,immutable的值,immutable表示永不改变,浏览器永不需要请求资源,这个感觉可以配合带hash或者版本号的资源使用。
  5. 第6步最有意思,代码很简单,只有一行:

    ctx.body = fs.createReadStream(path)

    熟悉node文件流的同学都会知道,fs.createReadStream创建了一个自path的文件流,调用.pipe即可通过管道做文件流传输。比如使用node的http模块实现的http服务,如果要对res做文件流响应,那么只要调用strame.pipe(res)即可。

    但是这里有意思的是,koa send把stream赋值给ctx.body就完了,没有看到任何流处理。另外平时我们做json格式的响应,也是类似的调用ctx.body={a:1}。也就是说,ctx.body可以做不同类型的赋值操作。要解释这个操作方式,就要回到koa本身对koa.body的实现。

    先贴上koa body的实现代码:

    set body(val) {
        
        // no content
        if (null == val) {
            if (!statuses.empty[this.status]) this.status = 204;
            this.remove('Content-Type');
            this.remove('Content-Length');
            this.remove('Transfer-Encoding');
            return;
        }
        
        // string
        if ('string' == typeof val) {
            if (setType) this.type = /^\s*</.test(val) ? 'html' : 'text';
            this.length = Buffer.byteLength(val);
            return;
        }
        
        // buffer
        if (Buffer.isBuffer(val)) {
            if (setType) this.type = 'bin';
            this.length = val.length;
            return;
        }
        
        // stream
        if ('function' == typeof val.pipe) {
            // 结束关闭流 stream.destroy stream.close
            // https://www.npmjs.com/package/on-finished
            // https://www.npmjs.com/package/destroy
            onFinish(this.res, destroy.bind(null, val));
            ensureErrorHandler(val, err => this.ctx.onerror(err));
            // overwriting
            if (null != original && original != val) this.remove('Content-Length');
            if (setType) this.type = 'bin';
            return;
        }
        
        // json
        this.remove('Content-Length');
        this.type = 'json';
    }

    从截取的源码可以看到,原来是ctx.body做了一层setter拦截,当我们赋值的时候,koa对于不同格式的body,统一在setter中做了分类处理。从源码上看,body依次支持了空值、字符串、字节、文件流和json几种响应类型。对于是否流的判断,就是通过是否对象存在pipe函数确定的。

    这个实现方式挺有意思,以后对于一些不同类型的复制操作,可以把类型判断和一些逻辑放到setter中来做,代码会清晰很多。


keller35
1.8k 声望83 粉丝