web后端最常用的功能之一是静态文件的托管,也就是那些存放在服务器上的只读文件,可以让前端自由下载。最直接的实现手段就是将url的路径和文件系统的路径一一对应,这样就可以通过url来下载文件夹内不同的文件包括子目录的文件。
如果你想做一个更“慷慨”一点的静态托管器,可以在前端请求一个目录的时候列举出目录下所有内容,这样在某些情况下可以丰富前端的应用。基于这两点功能,我设计的静态文件中间件代码如下(nodejs):
// 静态文件中间件的伪代码
const path = require("path");
const fs = require("fs");
const { Readable } = require("stream");
// this代表对等体的生命周期;global是全局作用域
const req = this.request;
const res = this.response;
const cfg = global.config;
// path/to/public/是FS中被托管的文件夹路径
// req.staticPath是从url中提炼出来的文件的相对路径
const absPath = path.join(__dirname, "path/to/public/", req.staticPath);
const isFile = await new Promise((resolve, reject) => {
fs.stat(absPath, (err, stats) => {
if (err) reject(err);
else if (stats.isFile()) resolve(true);
else if (stats.isDirectory()) resolve(false);
else reject(`${req.url}既不是文件也不是文件夹!`);
});
});
// 根据文件后缀名判断mime类型
const suffix = isFile ? path.extname(req.url) : "directory";
res.setHeader(
"Content-Type",
{
".js": "text/javascript; charset=UTF-8",
".wasm": "application/wasm",
".html": "text/html",
".css": "text/css",
".png": "image/png",
directory: "text/plain; charset=UTF-8"
}[suffix] || "application/octet-stream"
);
// 利用浏览器的古典缓存机制
res.setHeader("Cache-Control", `public, max-age=${cfg.cacheInSec}`);
res.setHeader("ETag", cfg.version);
let r;
if (isFile) {
r = fs.createReadStream(absPath);
} else {
// 如果请求的是目录,则构建一个虚拟流来读取目录下的所有内容
r = new Readable({
read() {}
});
fs.readdirSync(absPath)
.map(name => path.join(req.staticPath, name))
.forEach(pa => r.push(pa + "\n"));
r.push(null);
}
if (!res.headerSent) res.writeHead();
await new Promise((resolve, reject) => {
r.on("error", err => reject(err));
r.on("end", resolve);
r.pipe(res);
});
伪代码很简单,不做多余解释,得到的效果就是:请求文件得到文件;请求目录得到目录下以换行符分隔的所有资源的相对路径列表;如果请求的资源不存在则在当前的路由点抛出异常。由于路由树上每个点都有可能抛出异常,我们需要一个统一的错误处理机制。
很简单,只要在整棵异步决策树的末尾catch异常即可,但需要考虑出错时间点是否在response流内。如果response还未发送,可将错误信息作为内容发给前端;如果response已经发出去了,或者正在发送中,这时后端没有办法改变已经发出的事实,也就无法将错误信息告诉前端,这时候可以将错误消息给日志系统消化掉。
http/2.0流是从socket中抽象出来的机制,而且流的内容是http的body而非header。http头部是用来控制流的生命周期,换言之只有当header传完之后request和response对象才出现。
所以,错误按照发生时刻可以分为2类:response发送前和发送后。如果response还未发送,错误消息推荐写在http头部的自定义字段里,比如my-error;如果response已经发送,则将错误消息存在其他地方。
// 决策树错误处理的伪代码
module.exports = async error => {
const message = error.message || error || "有内鬼,终止交易";
// 判断response是否已经发出
if (this.response.headersSent) {
// 将message存入假想的日志系统
await require("path/to/logger").add(message);
} else {
this.response.writeHead(400, {
"Content-Type": "text/html; charset=utf-8",
"my-error": encodeURIComponent(message)
});
this.response.end(`<h1>${message}</h1>`);
}
};
伪代码中:
一般的后端框架都会内置一些bodyParser这样的body解析器,我们也来手写一个。
设计一个进度条最好的方式是在第一个数据包中指定整个资源的大小,前端根据传输的trunk数量来计算进度;如果很不幸无法在一开始得知资源的大小,那只能在每个chunk旁边写上这个是不是最后一个,当然会有额外的空间开销。
http/2.0的设计也考虑了这2种情况,于是给了我们content-length这个字段。伪代码就不放了,只要注意body的解析和其他中间件是并发进行的,所以request.body是一个promise。
------------------------不正经的分割线------------------------
当然还有很多地方欠考虑,但不妨碍我们给这个框架取一个响亮的名字:如果说http的特点是“fetch”,对后端来说就是“fetch me”,非常形象。
fetchme.js是一个渐进式web框架,基于ALFP协议,集合了一系列先进的web开发理念的下一代脚手架。虽然目前钱不够,合伙人未定,代码暂无,但fetchme的理论基础以及名字已经在本文中预定了,基于这些理论,即使做不成一个通用的web框架,至少也能搞一个实现具体应用的app,比如一个博客框架。
(完)