从零构建Node.js静态资源服务器
在开始构建服务之前,首先需要初始化项目结构。通过命令行工具创建项目目录并安装必要的依赖包,例如用于终端颜色输出的 chalk、命令行参数解析的 yargs 以及模板引擎 handlebars。
npm init -y
npm install chalk handlebars yargs --save
基础服务架构搭建
使用 Node.js 原生的 http 模块创建 Web 服务器。为了更好的代码组织,我们将服务端的配置与路由处理逻辑分离。配置文件 config.js 负责管理端口号、主机名等基础信息。
const path = require('path');
module.exports = {
root: process.cwd(),
hostname: '127.0.0.1',
port: 8080,
compression: /\.(html|js|css|md)$/
};
主程序入口 server.js 负责启动 HTTP 服务,并将请求转发给具体的路由处理器。
const http = require('http');
const chalk = require('chalk');
const path = require('path');
const config = require('./config');
const handleRequest = require('./router');
class StaticServer {
constructor(options) {
this.conf = { ...config, ...options };
}
start() {
const server = http.createServer(async (req, res) => {
const targetPath = path.join(this.conf.root, req.url);
await handleRequest(req, res, targetPath, this.conf);
});
server.listen(this.conf.port, this.conf.hostname, () => {
const address = `http://${this.conf.hostname}:${this.conf.port}`;
console.log(`Server running at ${chalk.green(address)}`);
});
}
}
module.exports = StaticServer;
请求路由与文件处理
核心的请求处理逻辑封装在 router.js 中。为了解决回调地狱问题并提升代码可读性,这里使用了 Node.js 提供的 fs.promises API 配合 async/await 语法。我们需要判断请求路径是文件还是目录,并分别进行处理。
const fs = require('fs').promises;
const path = require('path');
const Handlebars = require('handlebars');
const { getType } = require('./mime');
const { compressStream } = require('./compress');
const { parseRange } = require('./range');
const { checkCache } = require('./cache');
const tplPath = path.join(__dirname, '../templates/dir.tpl');
const templateSource = fs.readFileSync(tplPath, 'utf-8');
const template = Handlebars.compile(templateSource);
module.exports = async function (req, res, filePath, config) {
try {
const stats = await fs.stat(filePath);
if (checkCache(stats, req, res)) {
res.statusCode = 304;
res.end();
return;
}
if (stats.isFile()) {
const mimeType = getType(filePath);
res.setHeader('Content-Type', mimeType);
const rangeData = parseRange(stats.size, req, res);
let fileStream;
if (rangeData) {
res.statusCode = 206;
fileStream = fs.createReadStream(filePath, { start: rangeData.start, end: rangeData.end });
} else {
res.statusCode = 200;
fileStream = fs.createReadStream(filePath);
}
if (filePath.match(config.compression)) {
fileStream = compressStream(fileStream, req, res);
}
fileStream.pipe(res);
} else if (stats.isDirectory()) {
const files = await fs.readdir(filePath);
res.setHeader('Content-Type', 'text/html');
const relativeDir = path.relative(config.root, filePath);
const data = {
title: path.basename(filePath),
dir: relativeDir ? `/${relativeDir}` : '',
files: files.map(item => ({
file: item,
icon: getType(item)
}))
};
res.end(template(data));
}
} catch (error) {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end(`Resource not found: ${filePath}`);
}
};
MIME类型映射
为了正确处理不同文件的响应头,我们需要一个简单的工具来判断文件的 MIME 类型。这里创建了一个简化的映射表 mime.js。
const path = require('path');
const typeMap = {
'js': 'application/javascript',
'css': 'text/css',
'html': 'text/html',
'json': 'application/json',
'png': 'image/png',
'jpg': 'image/jpeg',
'gif': 'image/gif',
'svg': 'image/svg+xml',
'txt': 'text/plain',
'md': 'text/markdown'
};
function getType(filePath) {
const extension = path.extname(filePath).slice(1).toLowerCase();
return typeMap[extension] || 'application/octet-stream';
}
module.exports = { getType };
内容压缩处理
利用 zlib 模块对静态资源进行压缩,可以显著减少传输数据量,提升加载速度。compress.js 根据客户端请求头中的 Accept-Encoding 自动选择 Gzip 或 Deflate 算法。
const { createGzip, createDeflate } = require('zlib');
module.exports = function (stream, req, res) {
const encoding = req.headers['accept-encoding'];
if (!encoding) return stream;
if (encoding.includes('gzip')) {
res.setHeader('Content-Encoding', 'gzip');
return stream.pipe(createGzip());
}
if (encoding.includes('deflate')) {
res.setHeader('Content-Encoding', 'deflate');
return stream.pipe(createDeflate());
}
return stream;
};
支持断点续传
通过解析 HTTP Range 请求头,服务器可以只返回文件的指定部分,这对于大文件下载或视频流播放至关重要。range.js 负责解析范围并设置相应的响应头。
module.exports = function (totalSize, req, res) {
const rangeHeader = req.headers['range'];
if (!rangeHeader) return null;
const parts = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (!parts) return null;
const start = parts[1] ? parseInt(parts[1]) : 0;
const end = parts[2] ? parseInt(parts[2]) : totalSize - 1;
if (start >= totalSize || end >= totalSize || start > end) {
return null;
}
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Range', `bytes ${start}-${end}/${totalSize}`);
res.setHeader('Content-Length', end - start + 1);
return { start, end };
};
服务端缓存策略
合理的缓存策略能减轻服务器压力。在 cache.js 中,我们实现了基于 Last-Modified 和 ETag 的缓存校验机制。当资源未变化时,服务器返回 304 状态码。
module.exports = function (stats, req, res) {
const lastModified = stats.mtime.toUTCString();
const etag = `${stats.size}-${stats.mtime.getTime()}`;
res.setHeader('Last-Modified', lastModified);
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'max-age=3600');
const ifModifiedSince = req.headers['if-modified-since'];
const ifNoneMatch = req.headers['if-none-match'];
if (ifModifiedSince && ifModifiedSince !== lastModified) {
return false;
}
if (ifNoneMatch && ifNoneMatch !== etag) {
return false;
}
return true;
};
命令行工具集成
最后,使用 yargs 将程序封装为一个命令行工具(CLI),允许用户自定义端口、根目录等参数。
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');
const StaticServer = require('./server');
const argv = yargs(hideBin(process.argv))
.usage('Usage: $0 [options]')
.option('port', {
alias: 'p',
describe: 'Server port number',
type: 'number',
default: 8080
})
.option('root', {
alias: 'r',
describe: 'Root directory for static files',
type: 'string',
default: process.cwd()
})
.option('host', {
alias: 'h',
describe: 'Server hostname',
type: 'string',
default: '127.0.0.1'
})
.help()
.argv;
const server = new StaticServer({
port: argv.port,
root: argv.root,
hostname: argv.host
});
server.start();
通过以上模块的组合,我们已经构建了一个具备目录浏览、文件下载、压缩传输、断点续传以及缓存控制功能的静态资源服务器。