乐趣区

Node.js新手上路——动手撸一个静态资源服务器

简介
本文介绍了一个简单的静态资源服务器的实例项目,希望能给 Node.js 初学者带来帮助。项目涉及到 http、fs、url、path、zlib、process、child_process 等模块,涵盖大量常用 api;还包括了基于 http 协议的缓存策略选取、gzip 压缩优化等;最终我们会发布到 npm 上,做成一个可以全局安装、使用的小工具。麻雀虽小,五脏俱全,一想是不是还有点小激动?话不多说,放码过来。
文中源码地址在最后附录中。
step1 新建项目
因为我们要发布到 npm 上,所以我们先按照国际惯例,npm init,走你!在命令行可以一路回车,有些配置会在最后的发布步骤中细说。
目录结构如下:bin 文件夹存放我们的执行代码,web 作为一个测试文件夹,里面放了些网页。
step2 码码
step2.1 雏形
静态资源服务器,通俗讲就是我们在浏览器地址栏输入形如“http:// 域名 /test/index.html”的一个地址,服务器从根目录下的对应文件夹找到 index.html,读出文件内容并返回给浏览器,浏览器渲染给用户。
const http = require(“http”);
const url = require(“url”);
const fs = require(“fs”);
const path = require(“path”);

const item = (name, parentPath) => {
let path = parentPath = `${parentPath}/${name}`.slice(1);
return `<div><a href=”${path}”>${name}</a></div>`;
}

const list = (arr, parentPath) => {
return arr.map(name => item(name, parentPath)).join(“”);
}

const server = http.createServer((req, res) => {
let _path = url.parse(req.url).pathname;// 去掉 search
let parentPath = _path;
_path = path.join(__dirname, _path);
try {
// 拿到路径所对应的文件描述对象
let stats = fs.statSync(_path);
if (stats.isFile()) {
// 是文件,返回文件内容
let file = fs.readFileSync(_path);
res.end(file);
} else if (stats.isDirectory()) {
// 是目录,返回目录列表,让用户可以继续点击
let dirArray = fs.readdirSync(_path);
res.end(list(dirArray, parentPath));
} else {
res.end();
}
} catch (err) {
res.writeHead(404, “Not Found”);
res.end();
}
});

const port = 2234;
const hostname = “127.0.0.1”;
server.listen(port, hostname, () => {
console.log(`server is running on http://${hostname}:${port}`);
});
以上这段 code 就是我们的核心代码了,已经实现了核心功能,本地运行即可看到返回了文件目录,点击文件名便可浏览对应的网页、图片、文本啦。
step2.2 优化
功能实现了,但是我们可以在某些方面做做优化,提升实用性,顺便多学习几个 api(装逼技巧)。
1. stream
我们目前读取文件返回给浏览器的操作是通过 readFile 一次性读出来,一次性返回,这样当然可以实现功能,但我们有更好的方式——用 stream(流)进行 IO 操作。stream 并不是 node.js 独有的概念,而是操作系统最基本的一种操作形式,所以理论上讲,任何一门 server 端语言都实现了 stream 的 API。
为什么讲用 stream 是一种更好的方式?因为一次性读取、操作大文件,内存和网络是吃不消的,尤其在用户访问量比较大的情况下更为明显;而借助 stream 可以让数据流动起来,一点一点操作,从而提升性能。代码修改如下:
if (stats.isFile()) {
// 是文件,返回文件内容
// 在 createServer 时传入的回调函数被添加到了 ”request” 事件上,回调函数的两个形参 req 和 res
// 分别为 http.IncomingMessage 对象和 http.ServerResponse 对象
// 并且它们都实现了流接口
let readStream = fs.createReadStream(_path);
readStream.pipe(res);
}
编码实现非常简单,在需要返回文件内容时,我们创建了一个可读流,并把它直接导向了 res 对象。
2. gzip 压缩
gzip 压缩带来的性能(用户访问体验)提升是非常明显的,尤其在当下 spa 应用大行其道的时代,开启 gzip 压缩,可以大幅减小 js、css 等文件资源的体积,提升用户访问速度。作为一个静态资源服务器,我们当然要加上这个功能。
node 中有一个 zlib 的模块,提供了很多压缩相关的 api,我们就用它来实现:
const zlib = require(“zlib”);

if (stats.isFile()) {
// 是文件,返回文件内容

res.setHeader(“content-encoding”, “gzip”);

const gzip = zlib.createGzip();
let readStream = fs.createReadStream(_path);
readStream.pipe(gzip).pipe(res);
}
有了 stream 的使用经验,我们再看这段代码的时候就好理解多了。把文件流先导向 gzip 对象,再导向 res 对象。此外,使用 gzip 压缩的时候还需要注意一点:需要把响应头里的 content-encoding 设置为 gzip。否则浏览器会把一堆乱码展示出来。
3. http 缓存
缓存这个东西让人又爱又恨,用得好,可以提升用户体验,减轻服务器压力;用得不好,可能就会面临各种各样奇奇怪怪的问题。一般来讲浏览器 http 缓存分为强缓存(非验证性缓存)和协商缓存(验证性缓存)。
什么叫强缓存呢?强缓存是由 cache-control 和 expires 两个首部字段控制的,现在一般用 cache-control。比如我们设置了 cache-control: max-age=31536000 的响应头,就是告诉浏览器这个资源有一年的缓存期,一年内不用向服务端发送请求,直接从缓存中读取资源。
而协商性缓存是使用 if-modified-since/last-modified、if-none-match/etag 等首部字段,配合强缓存,在强缓存没有命中(或告知浏览器 no-cache)的时候,向服务器发送请求,确认资源的有效性,决定从缓存中读取或是返回新的资源。
有了以上概念,我们便可以制定我们的缓存策略:
if (stats.isFile()) {
// 是文件,返回文件内容

// 增加判断文件是否有改动,没有改动返回 304 的逻辑

// 从请求头获取 modified 时间
let IfModifiedSince = req.headers[“if-modified-since”];
// 获取文件的修改日期——时间戳格式
let mtime = stats.mtime;
// 如果服务器上的文件修改时间小于等于请求头携带的修改时间,则认定文件没有变化
if (IfModifiedSince && mtime <= new Date(IfModifiedSince).getTime()) {
// 返回 304
res.writeHead(304, “not modify”);
return res.end();
}
// 第一次请求或文件被修改后,返回给客户端新的修改时间
res.setHeader(“last-modified”, new Date(mtime).toString());
res.setHeader(“content-encoding”, “gzip”);
let reg = /\.html$/;
// 不同的文件类型设置不同的 cache-control
if (reg.test(_path)) {
// 我们对 html 文件执行每次必须向服务器验证资源有效性的策略
res.setHeader(“cache-control”, “no-cache”);
} else {
// 我们对其余的静态资源文件采取强缓存策略,一个月内无需向服务器索取
res.setHeader(“cache-control”, `max-age=${1 * 60 * 60 * 24 * 30}`);
}

// 执行 gzip 压缩
const gzip = zlib.createGzip();
let readStream = fs.createReadStream(_path);
readStream.pipe(gzip).pipe(res);
}
这样一套缓存策略在现代前端项目体系下还是比较合适的,尤其是对于 spa 应用来讲。我们希望 index.html 能够保证每次向服务器验证是否有更新,而其余的文件统一本地缓存一个月(自己定);通过 webpack 打包或其他工程化方式构建之后,js、css 内容如果发生变化,文件名相应更新,index.html 插入的 manifest(或 script 链接、link 链接等)清单会更新,保证用户能够实时得到最新的资源。
当然,缓存之路千万条,适合业务才重要,大家可以灵活制定。
4. 命令行参数
作为一个在命令行执行的工具,怎么能不象征性的支持几个参数呢?
const config = {
// 从命令行中获取端口号,如果未设置采用默认
port: process.argv[2] || 2234,
hostname: “127.0.0.1”
}
server.listen(config.port, config.hostname, () => {
console.log(`server is running on http://${config.hostname}:${config.port}`);
});
这里就简单的举个栗子啦,大家可以自由发挥!
5. 自动打开浏览器
虽然没太大卵用,但还是要加。我就是要让你们知道,我加完之后什么样,你们就是什么样 :-(duang~
const exec = require(“child_process”).exec;
server.listen(config.port, config.hostname, () => {
console.log(`server is running on http://${config.hostname}:${config.port}`);
exec(`open http://${config.hostname}:${config.port}`);
});
step3 发布
基本上我们的代码都写完了,可以考虑发布了!(不发布到 npm 上何以显示逼格?)
step3.1 package.json
得到一个配置类似下面所示的 json 文件:
{
“name”: “here11”,
“version”: “0.0.13”,
“private”: false,
“description”: “a node static assets server”,
“bin”: {
“here”: “./bin/index.js”
},
“repository”: {
“type”: “git”,
“url”: “https://github.com/gww666/here.git”
},
“scripts”: {
“test”: “node bin/index.js”
},
“keywords”: [
“node”
],
“author”: “gw666”,
“license”: “ISC”
}
其中 bin 和 private 较为重要,其余的按照自己的项目情况填写。bin 这个配置代表的是 npm i -g xxx 之后,我们运行 here 命令所执行的文件。
step3.2 声明脚本执行类型
在 index.js 文件的开头加上:#!/usr/bin/env node 否则 linux 上运行会报错。
step3.3 注册 npm 账号
勉强贴一手命令,还不清楚自行百度:
没有账号的先添加一个,执行:npm adduser
然后依次填入 Username: your namePassword: your passwordEmail: yourmail
npm 会给你发一封验证邮件,记得点一下,不然会发布失败。
执行登录命令:npm login
执行发布命令:npm publish
发布的时候记得把项目名字、版本号、作者、仓库啥的改一下,别填成我的。
好了,齐活。
step3.4
还等啥啊,赶快把 npm i -g xxx 这行命令发给你的小伙伴啊。什么?你没有小伙伴?告辞!

本文项目源码地址:https://github.com/gww666/here 如果对你有帮助,还请不吝 star!

退出移动版