Koa 中返回 html 文件引发的思考

May 21, 2021

前言

在使用 Koa 框架搭建网站时,一般返回 index.html 当做入口文件,这需要设置特定的 response header.

由于 Koa 自身极简的设计思想,我相信没看过源码的同学,第一时间会写出如下代码:

const Koa = require("koa");
const fs = require("fs");

const app = new Koa();

app
  .use(ctx => {
    ctx.set("Content-Type", "text/html");
    ctx.body = fs.readFileSync("index.html");
  })
  .listen(3000);

这当然可行,还能加强你对 HTTP Headers 的理解,但真正熟悉 Koa 的人,不屑于用如此原始的方法。

别看 Koa 的源码短小精悍,它依旧为开发者提供了诸多便捷的功能,例如此文着重介绍的 ctx.type.

带你认识 ctx.type

先上代码,只需将上下文(ctx)的 type 设置为 "html" 即可:

app
  .use(ctx => {
    ctx.type = "html";
    ctx.body = fs.readFileSync("index.html");
  })
  .listen(3000);

http(没有安装这个命令的小伙伴,访问这个网址:https://httpie.io/ )在终端进行测试:

其实它的实现非常简单,通过传入不同的 type,自动设置对应的 Content-Type response header.

具体源码戳着 lib/response.js.

module.exports = {
  // ...
  set type(type) {
    type = getType(type);
    if (type) {
      this.set("Content-Type", type);
    } else {
      this.remove("Content-Type");
    }
  },
};

那么 getType 函数是个啥?原来借助了第三方包的“神秘力量”。

const getType = require("cache-content-type");

出现了关键词 "cache",作为一名专业的前端工程师(切图仔),察觉到这里一定做了某些优化。

为了验证我的猜测,我查看了此文件在 GitHub 上的历史提交记录。

果不其然,在 18 年的 7 月,一个有关性能优化的 PR 被合入 master.

来看看具体改动了哪些内容:

破案了!最先使用的是 mime-types 库。

这个库实现同样很简单,大致就是从一个 JSON 文件(mime-db/db.json)匹配文件后缀名,返回 Content-Type,如 html -> text/html 的映射关系,库还会根据情况来为你加上 charset=utf-8.

// db.json
{
  "text/html": {
    "source": "iana",
    "compressible": true,
    "extensions": ["html", "htm", "shtml"]
  }
}

这里推荐一个非常 nice 的在线运行 Node.js package 的网站:https://npm.runkit.com/mime-types.

你可以在浏览器上在线体验这些库、还包括查看源码,README.md 等功能。

Koa 的优化之路:LRU

让我们把视角转向现在,Koa 中 mime-types 库已被无情抛弃,取而代之的是 cache-content-type 库.

它的源码更简单,总共就 15 行:

"use strict";

const mimeTypes = require("mime-types");
const LRU = require("ylru");

const typeLRUCache = new LRU(100);

module.exports = type => {
  let mimeType = typeLRUCache.get(type);
  if (!mimeType) {
    mimeType = mimeTypes.contentType(type);
    typeLRUCache.set(type, mimeType);
  }
  return mimeType;
};

核心思路是维护一个 Cache:

  1. 当 Cache 中没有该 type 对应的 mimeType 时,会去进行一次查找并将结果 mimeType 存储在 Cache 中
  2. 当第二次查找 type,发现 Cache 中已存在,就直接返回对应的 mimeType,不再进行查找

因为存储 MIME 信息的 JSON 文件非常大,每次查找都非常耗时,所以尽可能减少查找次数。

细心的小伙伴发现了,这个 typeLRUCache 为什么还要设置最大容量为 100 个?难道存储超过 100 个 type 后,之后查找 type 的结果就不能被缓存了吗?

No,因为代码中 typeLRUCache 跟我说的 Cache 不是同一个东西。

我说的 Cache 是一种单纯以空间换时间的方法,但也不能无脑去拓宽空间,假设容量为 1000000 或 1e10 个,这会急剧增加程序的运行内存,引起电脑卡顿、死机。

归功于 ylru 这个库,我们有一种折中的方案。

因为它实现了 Least recently used (LRU) 算法,这个算法做了什么呢?我举个栗子,便于大家理解。

一个小区里只有两个停车位 P1 和 P2,但有四个车主分别为 A、B、C、D.

  • 第一天特斯拉车主 A 使用了 P1
  • 第二天宝马车主 B 使用了 P2
  • 第三天保时捷车主 C 来停车,发现没有停车位了,咋办呢?

    • 小区物业出于人性化考虑,宝马车主 B 一天前刚用的 P2,而特斯拉车主 A 是两天前用的 P1,估计特斯拉车主 A 已经去世了,于是强制让 A 移出了停车位
    • 保时捷车主 C 顺利将车停入 P1
  • 第四天上午,宝马车主 B 离开了停车位
  • 第四天下午,宝马车主 B 进入了停车位
  • 第五天奔驰车主 D 来停车,发现停车位已满,咋办呢?

    • 小区物业出于人性化考虑,宝马车主 B 一天前刚频繁使用过 P2,而保时捷车主 C 已经一天没动静了,就算你是豪华车也没用,强制让保时捷车主 C 移出了停车位
    • 奔驰车主 D 顺利将车停入 P1

总结:停车位总量不变,但至少能让最近经常使用停车位车主的权益得到保障,停车位始终有你一份,而不是看先来后到,也不是看谁的车贵。

类比到 typeLRUCache 上,只能存储 100 个缓存结果,防止容量无限增大,但能保证最近被频繁查找 type 的结果始终被缓存,提示后续查找效率。

我在这写了一个简易版 LRU 的 demo:

class LRUCache {
  constructor(capacity) {
    this.cache = new Map();
    this.capacity = capacity;
  }
  get(key) {
    if (this.cache.has(key)) {
      const temp = this.cache.get(key);
      this.cache.delete(key);
      this.cache.set(key, temp);
      return temp;
    }
    return -1;
  }
  put(key, value) {
    if (this.cache.has(key)) {
      this.cache.delete(key);
    } else if (this.cache.size >= this.capacity) {
      this.cache.delete(this.cache.keys().next().value);
    }
    this.cache.set(key, value);
  }
}

const cache = new LRUCache(2);

cache.put("A", 1);
cache.put("B", 2);
cache.get("A"); // 返回  1
cache.put("C", 3); // 该操作会使得 'B' 作废
cache.get("B"); // 返回 -1 (未找到)
cache.put("D", 4); // 该操作会使得 'A' 作废
cache.get("A"); // 返回 -1 (未找到)
cache.get("C"); // 返回  3

结尾

这篇文章到这就 happy ending 了,从设置 Content-Type 出发,我们一连看了 4 个库的源码,这波血赚不亏。

如果对 Koa 源码感兴趣的同学,请猛击 Koa 源码剖析 & 实现,教你手写一个 “复刻版” Koa.


Written by B2D1(包邦东)