实现一个node服务器
常用的静态服务器:http-server
- npm i http-server -g
- http-server
- 会以目录当做一个静态资源服务器
实现并拓展
功能点
- 本地命令启动
- 静态资源托管
- 支持自定义端口与地址
- 文件压缩
- 模板引擎渲染文件夹内容
- mock拦截,可以自定义页面返回或接口返回
- 图片防盗
- 跨域资源共享
- 文件缓存:强缓存+协商缓存
前置条件
- node版本16
- npm init -y 初始化项目
package.json
- 我的package.json文件
- name属性就是一会儿要执行的脚本名称
- bin则是要执行的文件
{
"name": "httpserver",
"version": "1.0.0",
"description": "",
"bin": "./bin/www",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"chalk": "^4.1.2",
"commander": "^9.4.0",
"debug": "^4.3.4",
"ejs": "^3.1.8",
"mime": "^3.0.0",
"url-parse": "^1.5.10"
},
"devDependencies": {
"@types/node": "^18.0.6"
}
}
bin目录
config.js
module.exports = {
port: {
option: "-p, --port <port>",
description: "端口号,默认8080",
default: 8080,
},
directory: {
option: "-d, --directory <dir>",
description: "地址,默认当前目录",
default: process.cwd(),
},
};
目录下新建www文件
#! /usr/bin/env node
const { program } = require("commander");
const pkg = require("../package.json");
const config = require("./config");
program.name("httpserver").usage("[options]").version(pkg.version);
let defaultValue = {};
Object.entries(config).forEach(
([key, { option, description, default: val }]) => {
defaultValue[key] = val;
program.option(option, description);
}
);
program.parse();
const options = program.opts(process.argv);
const userConfig = Object.assign(defaultValue, options);
const createServer = require("../src/server");
createServer(userConfig);
src
server.js
const http = require("http");
const path = require("path");
const url = require("url");
const { createReadStream, existsSync, readFileSync } = require("fs");
const fs = require("fs/promises");
const os = require("os");
const queryString = require("querystring");
const zlib = require("zlib");
const chalk = require("chalk");
const parse = require("url-parse");
const mime = require("mime");
const debugDev = require("debug")("development");
const ejs = require("ejs");
class Server {
constructor(userConfig = {}) {
this.port = userConfig.port;
this.directory = userConfig.directory;
this.address = userConfig.address;
this.template = readFileSync(
path.resolve(__dirname, "template.html"),
"utf8"
);
this.start();
}
async stat(queryPath) {
try {
let statObj = await fs.stat(queryPath);
return statObj;
} catch (err) {
debugDev(err);
return false;
}
}
async processAssets(pathname, req, res, statObj) {
const queryPath = path.join(this.directory, pathname);
console.log(chalk.blue(pathname));
try {
let statObj = await fs.stat(queryPath);
if (statObj.isFile()) {
this.sendFile(queryPath, req, res, statObj);
} else {
this.sendDirectory(queryPath, statObj, req, res, pathname);
}
} catch (err) {
this.sendError(err, res);
}
}
cors(req, res) {
if (req.headers.origin) {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Headers", "authorization");
res.setHeader("Access-Control-Max-Age", 10);
res.setHeader(
"Access-Control-Allow-Methods",
"GET,POST,DELETE,PUT,OPTIONS"
);
if (req.method === "OPTIONS") {
res.end();
return true;
}
}
}
async processData(pathname, req, res, query) {
req.query = query;
req.body = await new Promise((resolve, reject) => {
const arr = [];
req.on("data", (chunk) => {
arr.push(chunk);
});
req.on("end", () => {
let body = Buffer.concat(arr).toString();
switch (req.headers["content-type"]) {
case "application/json":
body = JSON.parse(body);
break;
case "application/x-www-form-urlencoded":
body = queryString.parse("a=1&b=2", "&", "=");
break;
}
resolve(body);
});
});
let mockPath = path.join(this.directory, "mock.js");
if (existsSync(mockPath)) {
let mockFn = require(mockPath);
let flag = mockFn(pathname, req, res);
return flag;
}
}
handleRequest = async (req, res) => {
const { pathname, query } = parse(req.url, true);
if (this.cors(req, res)) return;
if (await this.processData(pathname, req, res, query)) return;
this.processAssets(pathname, req, res);
};
async sendDirectory(queryPath, statObj, req, res, pathname) {
const homePath = path.join(queryPath, "index.html");
if (existsSync(homePath)) {
this.sendFile(homePath, req, res, statObj);
} else {
let dirs = await fs.readdir(queryPath);
const fileStatus = await Promise.all(
dirs.map(async (dir) => await fs.stat(path.join(queryPath, dir)))
);
dirs = dirs.map((dir, index) => {
return {
url: path.join(pathname, dir),
dir,
info: fileStatus[index].isFile() ? "文件" : "文件夹",
size: fileStatus[index].size,
};
});
const content = ejs.render(this.template, { dirs });
res.setHeader("Content-Type", "text/html;charset=utf-8");
res.end(content);
}
}
compress(filename, req, res) {
let encoding = req.headers["accept-encoding"];
if (encoding) {
if (encoding.includes("br")) {
res.setHeader("Content-Encoding", "br");
return zlib.createBrotliCompress();
} else if (encoding.includes("gzip")) {
res.setHeader("Content-Encoding", "gzip");
return zlib.createGzip();
} else if (encoding.includes("deflate")) {
res.setHeader("Content-Encoding", "deflate");
return zlib.createDeflate();
}
}
}
cache(filename, req, res, statObj) {
res.setHeader("Cache-Control", "max-age=20");
const etag = statObj.size + "/" + statObj.ctime.getTime().toString(16);
const ifNoneMatch = req.headers["if-none-match"];
res.setHeader("Etag", etag);
if (ifNoneMatch === etag) {
res.statusCode = 304;
res.end();
return true;
}
}
sendFile(filename, req, res, statObj) {
res.setHeader("Content-Type", mime.getType(filename) + ";charset=utf-8");
if (this.cache(filename, req, res, statObj)) return;
if (/\.(jpeg|png|jpg)$/.test(filename)) {
let referer = req.headers["referer"] || req.headers["referrer"];
if (referer) {
let host = "http://" + req.headers["host"];
let r1 = url.parse(host).hostname;
let r2 = url.parse(referer).hostname;
if (r1 != r2) {
res.end("Not found");
return;
}
}
}
let stream = this.compress(filename, req, res);
if (stream) {
return createReadStream(filename).pipe(stream).pipe(res);
}
createReadStream(filename).pipe(res);
}
sendError(err, res) {
debugDev(err);
res.end("Not found");
}
start() {
const server = http.createServer(this.handleRequest);
let port = this.port;
const address = this.address.map(
(item) => "http://" + item + ":" + chalk.green(port)
);
server.on("error", (err) => {
if (err && err.code === "EADDRINUSE") {
port += 1;
server.listen(port);
}
});
server.listen(port, () => {
console.log(address.join(" "));
});
}
}
module.exports = (userConfig) => {
const intsrface = os.networkInterfaces();
const address = Object.values(intsrface)
.flat(1)
.filter((item) => item.family === "IPv4")
.map((item) => item.address);
userConfig["address"] = address;
return new Server(userConfig);
};
mock.js
- 这个是自己定义的,如果有mock.js就走mock,没有就不走了
- 这里也可以根据路径来匹配路由,比如说项目打包后,history路由刷新会找不到文件,可以在这里的get请求出处理下,统一返回某个html
- 也可mock接口
module.exports = (pathname, req, res) => {
if (pathname === "/user") {
if (req.method === "GET") {
console.log(req.query);
res.end("get user");
} else if (req.method === "POST") {
console.log(req.query);
res.end("post user");
}
return true;
}
};
template.html
- 模板,在server.js里通过ejs进行解析渲染
<%开始、%>结束、<%=赋值变量
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>模板</title>
</head>
<body>
<%dirs.forEach(item=>{%>
<li><%=item.info%> - <a href="<%=item.url%>"><%=item.dir%></a> - <%=item.size%></li>
<%})%>
</body>
</html>
nodemon.json
- 这个没有的话,不影响整体操作,主要是方便调试,因为,每次修改内容后都手动重启服务会比较麻烦
- npm i nodemon -g
- 它会自动找nodemon.json,可以在里面设置监听文件,达到自动重启效果
- 执行的话直接nodemon就可以了
- 它会找exec命令进行执行
- watch是监听文件
{
"restartable": "rs",
"verbose": true,
"watch": [
"public/",
"src/server.js"
],
"ignore": [],
"delay": 1000,
"exec": "DEBUG=development httpserver"
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>测试文件</title>
<link rel="stylesheet" href="./index.css">
</head>
<body>
<img src="http://127.0.0.1:8080/img.png" alt="">
</body>
</html>
<script>
const xhr = new XMLHttpRequest();
xhr.open('POST', 'http://localhost:8080/user', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
xhr.setRequestHeader('authorization', '1212')
xhr.responseType = 'json';
xhr.onload = function () {
console.log(xhr.response);
}
xhr.send('a=1&b=2');
</script>
最后一步
使用
httpserver 将当前目录当做静态资源
httpserver -h 查看使用提示
httpserver -d 路径 指定路径为静态资源目录
httpserver -p 端口 指定端口
逻辑梳理
代码调试
- 自动,使用nodemon
- 代码提示在这里使用的两个第三方包debug和chalk
- chalk可以自定义console.log的颜色
- debug根据传入的参数生成一个可执行log,在传入环境变量的时候才会执行log,如果发包的话,那么用户执行就不会打印结果,我们也不用去手动删除方法了
- 在我们nodemon配置可以找到变量
DEBUG=development
- 按理说设置变量window是set,mac是export,它这个啥也不用......
启动服务
- 通过os模块拿到ip
- 过滤出ipv4类的ip,然后new Server的时候将地址和默认数据传递过去
- constructor对数据进行了一个初始化,然后调用start启动服务
- 这里面做了一个端口字段++逻辑,防止端口被占用导致失败
- 请求处理在handleRequest中,抽离出来了
跨域
- 跨域场景
- 协议域名端口号不一致
- 在响应头里添加个
Access-Control-Allow-Origin为请求头的origin即可,跨域的话浏览器会提示你这么做
OPTIONS请求
- 预检请求
- 获取服务器的反馈,如支持的HTTP版本号、支持的请求方法、支持的自定义请求头、是否支持跨域等
- 简单请求不会发送OPTIONS,只有跨域或复杂请求才会有
- 简单请求:GET、POST
- 复杂请求:DELETE、PUT、添加了自定义头的GET或POST
- 设置允许请求的类型:
Access-Control-Allow-Methods
- 设置允许的请求头:
Access-Control-Allow-Headers,如果options通过了了,但自定义请求头没设置允许,那还是跨域
- 每次都发options看着太多了,可以设置缓存,让客户端一段时间内不发,以秒为单位
Access-Control-Max-Age
- 这块逻辑可以拆出来,其他地方复用
cors(req, res) {
if (req.headers.origin) {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Headers", "authorization");
res.setHeader("Access-Control-Max-Age", 10);
res.setHeader(
"Access-Control-Allow-Methods",
"GET,POST,DELETE,PUT,OPTIONS"
);
if (req.method === "OPTIONS") {
res.end();
return true;
}
}
}
mock数据
- 这里需要注意一下,post需要通过ondata与onend来获取,因为是流传播,不是一次性拿到的
而get参数是query可以直接拿到的,
这个query是通过parse处理过的
- 通过existsSync来获取mock.js是否存在,不存在继续走后面逻辑
静态资源处理
- 因为路径对应文件可能不存在,所以try catch包一层,不存在直接返回Not fount
- 存在的话就判断是文件还是文件夹,
- 如果是文件直接返回
- 是文件夹就找它的index.html
- 存在就返回html
- 不存在就拿到它的目录数据,然后通过ejs搭配模板渲染出列表,可以点击跳转
文件的返回
- 这里默认使用流的方式,缓存、压缩、防盗链放下面分开说
防盗链
- 在a的域名下有一些图片,而b看到了,想省事,那么它直接在自己的html里通过url访问a的图片,那a不希望b使用,就需要阻止
- req的header会待过来一个referer参数,它代表的是请求发出的地址,告诉服务器我是从那个页面链接过来的
- referer是早期Http搞错了,后续改成referrer了,但仍然支持referer
- 直接访问图片,不会有这个字段
- 我们可以通过对referer的限制来达到防盗效果,例如返回Not found或固定的警告图片
if (/\.(jpeg|png|jpg)$/.test(filename)) {
let referer = req.headers["referer"] || req.headers["referrer"];
if (referer) {
let host = "http://" + req.headers["host"];
let r1 = url.parse(host).hostname;
let r2 = url.parse(referer).hostname;
if (r1 != r2) {
res.end("Not found");
return;
}
}
}
压缩
- 常见的三种格式gzip, deflate, br
- 通过Accept-Encoding拿到浏览器支持的压缩方式,然后通过zlib库创建对应的转化流,在pipe返回的时候,嵌套一层,放在中间即可
- 压缩后体积会减小,可以在控制台鼠标悬浮size看到效果
- 记得设置对应的Content-Encoding告诉浏览器解压格式,否则会乱码
常见面试题:为什么我请求一个页面,直接下载了?
compress(filename, req, res) {
let encoding = req.headers["accept-encoding"];
if (encoding) {
if (encoding.includes("br")) {
res.setHeader("Content-Encoding", "br");
return zlib.createBrotliCompress();
} else if (encoding.includes("gzip")) {
res.setHeader("Content-Encoding", "gzip");
return zlib.createGzip();
} else if (encoding.includes("deflate")) {
res.setHeader("Content-Encoding", "deflate");
return zlib.createDeflate();
}
}
}
缓存
- 走缓存顺序:先走强缓存,再走协商缓存
- 强缓存:缓存期间不向服务器发请求
- 协商缓存:拿着对应的头,向服务器发送请求,如果走缓存,服务器返回304,浏览器自己本地找缓存
- 有一些淘汰方案,代码有点多,贴server.js代码里了