http 报文
我们知道,应用层的 http 会封装 http 数据报文并进行数据传输,我们来看下数据报文的格式。
curl -v www.baidu.com // 使用 curl 命令查看 http 报文
报文如下
* Rebuilt URL to: www.baidu.com/ // 默认添加 / 路径
* Trying 110.242.68.3...
* TCP_NODELAY set
* Connected to www.baidu.com (110.242.68.3) port 80 (#0)
> GET / HTTP/1.1 // http 版本号
> Host: www.baidu.com // 主机
> User-Agent: curl/7.54.0 // 客户端内核
> Accept: */* // 接收类型
>
< HTTP/1.1 200 OK
< Accept-Ranges: bytes
< Cache-Control: private, no-cache, no-store, proxy-revalidate, no-transform
< Connection: keep-alive
< Content-Length: 2381
< Content-Type: text/html
< Date: Sun, 04 Jul 2021 14:44:35 GMT
< Etag: "588604c1-94d"
< Last-Modified: Mon, 23 Jan 2017 13:27:29 GMT
< Pragma: no-cache
< Server: bfe/1.0.8.18
< Set-Cookie: BDORZ=27315; max-age=86400; domain=.baidu.com; path=/
<
<!DOCTYPE html>
...
http 需要两个端,客户端负责的是发送 request(请求),服务端负责的是对请求做出 response(响应)。
请求又分为三部分:
- 请求行 (GET / HTTP/1.1)
- 请求头 (可以放一些自定义信息,请求头不要过大,防止页面白屏时间加长)
- 请求体 (传输的数据)
响应也分为三部分
- 响应行 (HTTP/1.1 200 OK)
- 响应头
- 响应体
请求头和请求行之间有一个换行,请求头和请求体之间有两个换行,响应也一样。
使用 tcp 模块模拟简单的 http 请求
const net = require('net');
const server = net.createServer(socket => {
socket.on('data', function(data) {
console.log(data.toString());
});
// 请求头和请求行之间有一个换行,请求头和请求体之间有两个换行
socket.end(`HTTP/1.1 200 OK
content-length: 5
world`);
});
server.listen(8000, () => {
console.log(`opened server on ${ server.address().port }`);
});
此时浏览器访问 localhost:8000, world 就被输出到页面上啦。 可以看出,http 就是在 tcp 模块之上包装了请求行,请求头和请求体。
restful 风格
该规范规定,根据请求的方法来区分对资源的操作,常用的请求方式有 GET、POST、PUT、DELETE、PATCH。
比如传统我们操作用户,会有以下接口命名:
/addUser // 增加用户
/updateUser // 更新用户
/deleteUser // 删除用户
/getUser?id=1 // 获取用户
如果我们使用了 restful 风格的 api,我们可以很清晰的根据请求类型来区分各种操作,而不用通过接口命名
/user // 增加用户 POST
/user // 整个更新用户(替换) UPDATE
/user // 更新用户 PATCH
/user // 删除用户 DELETE
/user?id=1 // 获取用户 GET
复杂与简单请求
还有一种请求类型我们没有提到,就是 OPTIONS 请求。该请求作为一个预检请求(嗅探能否正常发送数据,是否存在跨域),只有遇到复杂请求才会触发。那什么是复杂请求呢?
- 增加自定义 header 的 get 和 post
- put、patch、delete
options 也可以定义发送的时间间隔,比如每 30 分钟触发一次,比如在 node 中:
app.all('/test', function(req, res, next) {
// ...
// 表示隔30分钟才发起预检请求
res.header("Access-Control-Max-Age", "1800");
});
常见状态码
列举一些我们常见的 http 状态码,我们在 node 可以自定义状态码,但是如果不符合约定,浏览器可能不能正常识别哦
1xx 服务器收到了信息,等待浏览器后续要做的事情 (websocket)
2xx 成功
3xx 重定向、缓存
4xx 客户端出错(浏览器参数错误,或者服务器无法解析客户端的参数)
5xx 服务器错误
- 101 websocket 是基于 http 的,根服务器协商(第一次通过 http,后续要切换协议)
- 204 请求成功
- 204 请求成功,但是无响应内容
- 206 响应部分内容,用于分段请求
- 301 永久重定向
- 302 临时重定向(这次重定向到 A,下次可能重定向到 B,服务器负载的场景)
- 304 缓存
- 400 客户端请求错误
- 401 权限问题,当前用户没登录,无权限访问
- 403 登录了,权限不足,forbidden
- 404 找不到
- 405 请求方法错误
- 500 服务器报错
- 502 bad getway,客户端已经与服务端建立了连接,但超时
- 503 负载均衡挂了
- 504 gateway time-out,网关超时,客户端已经与服务端连接未建立,超时。
node 中的 http 模块
const http = require('http');
// 发布订阅,客户端访问时,底层 net 模块会接收消息,内部会派发对应的事件
const server = http.createServer(function(req, res) {
// 请求行部分
console.log(req.method); // GET
console.log(req.url); // 默认是 /
console.log(req.httpVersion); // 默认是 1.1
// 请求头
console.log(req.headers);
const bufferArr = [];
// 当接收到 data,对 data 进行处理
// data 是个 buffer
// 可以看出 req 是个可读流,有 data 和 end 方法
req.on('data', function(data) {
bufferArr.push(data);
console.log(data);
});
// 如果请求没有 data(GET 请求没有 data),会触发 end 事件
req.on('end', function() {
console.log(Buffer.concat(bufferArr).toString(), '请求体');
console.log('end');
});
// ----------------------- 响应相关 ---------------------------
res.statusCode = 200;
res.setHeader('token', 123);
// 可以看出 req 是个可写流,有 write 和 end 方法
res.write('服务器给浏览器写入数据'); // 如果不停写,可能会被粘包哦
res.end('写入第二段数据,分块传输,对应响应头里有 Transfer-Encoding: chunked 字段');
});
// 也可以这样写 去监听 request
// server.on('request', function(req, res) {
// console.log(2);
// });
// 端口最大 65535,不要使用低于 3000 的端口,可能过低的被内部占用了
server.listen(3000, function() {
console.log('server is running on port 3000');
});
get 请求因为没有 data 传输,会直接触发 end 事件,比如访问浏览器 localhost:3000。
我们也可以模拟 post 请求(先触发 data,后触发 end):
curl -X POST --data a=1 localhost:3000
手动实现服务端返回 & 解析静态文件
文件目录如下,我希望访问 http://localhost:3000/public/index.html 能拿到 index.html 文件,并且样式表和 js 文件能正常执行。
- public
- index.css
- index.html
- index.js
- fileServer.js
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>Document</title>
</head>
<body>
<h1>123,你好</h1>
</body>
<!-- 加载 js 和 css,如果加载的 content-Type 不对,会导致文件不能正常解析执行 -->
<link rel="stylesheet" href="/public/index.css">
<script src="/public/index.js"></script>
</html>
index.css
body {
color: blueviolet;
}
index.js
console.log(1);
fileServer.js
const http = require('http');
const fs = require('fs');
const url = require('url');
const path = require('path');
const mime = require('mime');
const server = http.createServer((req, res) => {
// url 的组成,协议://用户名,密码,域名,端口号,资源路径?查询参数#hash
// url.parse 如果传递第二个参数为 true 的含义:把 query 变成一个对象
const { pathname } = url.parse(req.url); // 这里其实就是后端的路由 根据前端请求地址,来识别我要做什么事
const filePath = path.join(__dirname, pathname);
// console.log(pathname);
fs.readFile(filePath, function(err, data) {
if (err) {
res.statusCode = 404;
return res.end('Not Found');
}
// 对 html 格式进行编码 "文件类型; 文件应用编码"
res.setHeader('Content-Type', `${ mime.getType(filePath) }; charset=utf8`);
// 把读取到的文件返回给客户端
res.end(data);
});
});
server.listen(3000, function() {
console.log('server is running on port 3000');
});
此时访问 http://localhost:3000/public/index.html 就能看到我们的页面啦。
- 解析请求的 url,拿到资源路径(路由匹配)
- 读取资源内容,添加对应文件类型 Content-Type, 不添加浏览器无法解析哦
- 文件内容(html, css, js)返回给客户端
当然这样实现静态文件处理的代码有点丑,都是回调套回调,我们正常开发中还是要基于 async & await 来实现更优雅的写法。
手动实现 http-server(静态文件服务) 命令行工具
我们知道,vue 或者 react 打包后的代码,我们需要起一个服务才能访问,上面咱们实现的静态文件,仅仅是对当前请求的文件作出响应,比如我访问文件夹 localhost:3000,这不就歇菜了么,我想展示文件列表怎么办呢。
http-server 是一个 node 包,能帮我们起一个静态文件服务,如果没有找到具体文件,会把当前目录下所有的文件全部列出来。
> npm i http-server -g
cd public
hs ./ --port 8082 // 在 public 目录下启动服务
// Starting up http-server, serving ./
// Available on:
// http://127.0.0.1:8082
// http://192.168.0.103:8082
// Hit CTRL-C to stop the server
访问 localhost:8082
还可以点进去看到文件内容,它是如何实现的呢。
目录结构和脚本测试
- ys-http-server
- bin
- config.js // 一些工具的默认配置
- www // 工具脚本
- src
- util.js // 模板引擎的实现 with + new Function
- tmpl.html // 用于请求文件夹目录时渲染文件列表
- main.js // 核心逻辑
- package.json
- 新建 ys-http-server 目录,初始化 npm,package.json 新增 bin 配置。
{
"bin": {
"ys-hs": "./bin/www" // 脚本别命名
}
}
- 新建 ys-http-server/bin 目录,新建 ys-http-server/bin/www 文件,并声明在脚本执行方式。
// www 文件顶部添加
#! /usr/bin/env node
console.log('脚本正常工作啦');
- npm link 把 ys-http-server 和 ys-hs 软链到当前 node 环境命令中。
- 测试脚本能否正常执行,命令行输入 ys-hs,输出 "脚本正常工作啦"
手动实现核心功能
ok,我们可以开始改造,来实现我们想要的功能了。
bin/www
#! /usr/bin/env node
// 我们的服务器要支持 可以改端口号
// --directory 指定以哪个目录为基准
// --port 指定端口号
// --help 帮助提示
// --version 版本号
// --usesage 支持的功能列表
const { program } = require('commander');
const config = require('./config');
const Server = require('../src/main.js');
const version = require('../package.json').version; // 版本号
program.version(version)
.name('ys-hs')
.usage("[options]");
const usages = [];
Object.entries(config).forEach(([key, value]) => {
usages.push(value.usage);
// 设置 --help 时,展示的可用配置 options
program.option(value.option, value.description, value.default)
})
program.on('--help',function () {
// 设置 --help 时,展示的用例 Examples
console.log('\nExamples:')
usages.forEach(usage=> console.log(' ' + usage))
})
program.parse(process.argv); // 解析参数
let ops = program.opts(); // 拿到用户配置的参数 这里来起一个服务
// 这里也可以监控 ctrl + c 哦
// process.on('SIGINT',function(){
// console.log('exit');
// process.exit()
// })
let server = new Server(ops);
server.start(); // 开启服务
bin/config
const config = {
'port': {
option: '-p,--port <n>',
description: 'set server port',
default: 8080,
usage: 'ys-hs --port <n>'
},
'directory': {
option: '-d,--directory <n>',
description: 'set server directory',
default: process.cwd(),
usage: 'ys-hs -d D:'
}
}
module.exports = config
src/utils.js
const fs = require('fs').promises
// 模板引擎的实现原理 new Function + will, WEBPACK loader 最终原理都是字符串拼接
async function renderFile(filePath,data){
let tmplStr =await fs.readFile(filePath,'utf8');
let myTemplate = `let str = ''\r\n`;
myTemplate += 'with(obj){'
myTemplate += 'str+=`'
tmplStr = tmplStr.replace(/<%=(.*?)%>/g,function(){
return '${' + arguments[1] + '}'
})
myTemplate += tmplStr.replace(/<%(.*?)%>/g,function(){
return '`\r\n' + arguments[1] + '\r\nstr+=`'
})
myTemplate += '`\r\n return str \r\n}'
let fn = new Function('obj',myTemplate);
return fn(data);
}
async function render(tmplStr,data){
let myTemplate = `let str = ''\r\n`;
myTemplate += 'with(obj){'
myTemplate += 'str+=`'
tmplStr = tmplStr.replace(/<%=(.*?)%>/g,function(){
return '${' + arguments[1] + '}'
})
myTemplate += tmplStr.replace(/<%(.*?)%>/g,function(){
return '`\r\n' + arguments[1] + '\r\nstr+=`'
})
myTemplate += '`\r\n return str \r\n}'
let fn = new Function('obj',myTemplate);
return fn(data);
}
exports.renderFile = renderFile;
exports.render = render;
src/main.js
// 核心源码
const http = require('http');
const fs = require('fs').promises;
const path = require('path');
const url = require('url');
const mime = require('mime');
const os = require('os');
const chalk = require('chalk'); // 换个打印颜色
const { createReadStream, readFileSync } = require('fs'); // 读取文件
const { render } = require('./util'); // 渲染模板的方法
const template = readFileSync(path.resolve(__dirname, 'tmpl.html'), 'utf8'); // 渲染文件列表的模板
// 获取当前局域网内的 ipv4 的 ip,比如 192.168.0.103
let address = Object.values(os.networkInterfaces()).flat().find(item => item.family == 'IPv4' && !item.internal).address;
module.exports = class MyServer {
constructor(opts) {
this.port = opts.port;
this.directory = opts.directory;
this.address = address;
}
sendError(res, e) {
console.log(e)
res.statusCode = 404; // 顺序是先响应状态码 在结束响应
res.end('NOT Found');
}
sendFile(req, res, statObj, filePath) {
// 设置编码,不然 html,css,js 都不能解析 默认文本类型
res.setHeader('Content-Type', (mime.getType(filePath) || 'text/plain') + ';charset=utf-8')
// 可读流读到文件传给可写流 res
createReadStream(filePath).pipe(res);
}
handleRequest = async (req, res) => { // es7 写法 保存 this,低版本 node 不支持 可以采用箭头函数和bind
// 核心文件读取,模板引擎实现
// 请求到来的时候,需要监控路径,看一下路径是否是文件,如果是文件 直接将文件返回,如果不是文件则读取文件中的目录
let { pathname } = url.parse(req.url);
pathname = decodeURIComponent(pathname); // 解码,兼容中文路径
let filePath = path.join(this.directory, pathname); // 在当前执行目录下进行查找
try {
let statObj = await fs.stat(filePath); // it.throw
if (statObj.isDirectory(filePath)) {
// 文件夹则生成模板函数渲染一次
const dirs = await fs.readdir(filePath);
// 我们希望通过模板引擎的方式来实现
let content = await render(template, {
dirs: dirs.map(dir => ({
url: path.join(pathname, dir),
dir
}))
});
res.setHeader('Content-Type', 'text/html;charset=utf-8')
res.end(content)
} else {
// 是文件直接返回给客户端
this.sendFile(req, res, statObj, filePath);
}
} catch (e) {
// 文件不存在把错误返回给客户端
this.sendError(res, e)
}
}
start() {
const server = http.createServer(this.handleRequest);
server.listen(this.port, () => {
console.log(`${chalk.yellow('Starting up http-server, serving:')}` + this.directory)
console.log(` http://${address}:${chalk.green(this.port)}`)
console.log(` http://127.0.0.1:${chalk.green(this.port)}`)
});
}
}
src/tmpl.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>
</head>
<body>
<%dirs.forEach(item=>{%>
<a href="<%=item.url%>"><%=item.dir%></a>
<%})%>
</body>
</html>
创建测试文件夹
- public
- index.html
- index.css
使用我们的工具启动服务
ys-hs ./ --port 9000
此时再访问 http://127.0.0.1:9000/public 就能看到文件列表了 访问 http://127.0.0.1:9000/public/index.html 就能看到页面了
流程总结
- 静态服务器工具周边能力封装(--help,--version等)。
- 参数解析,获取服务启动的根目录和期望端口号。
- 创建服务,判断客户端请求资源类型,如果是文件夹,通过 new Function + with 手动实现一个模板引擎的处理方法,返回给客户端,如果是文件则封装文件类型头,返回给客户端。
- 根据本地 ipv4 + 端口号,启动服务。
添加响应体压缩(gzip、 deflate 和 br)
我们实际看到的很多请求中,请求实际返回大小跟响应体实际大小可能不一样,拿百度举例。
我们点开这个请求,可以看到,两个关于压缩协商的 header 字段:
我们可以在 nginx 中强制根据一些规则(文件类型,大小)来配置是否在头部添加压缩字段。
gzip 压缩方式介绍
gzip 压缩,它主要的压缩方式是替换,重复率越高压缩越有效,在 node 中,zlib 模块可以用来做 gzip 压缩。(比如用1*10 表示 10个1)。
笔者建了个 3.4M 的文件,名为 1.txt,里面都是数字 1
const zlib = require('zlib');
const fs = require('fs');
const path = require('path');
zlib.gzip(fs.readFileSync(path.join(__dirname, './1.txt')), function(err, data) {
fs.writeFileSync(path.join(__dirname, '1.txt.gzip'), data);
});
压缩完,1.txt.gzip 大小为 3.4kb,不过这种操作是全部把文件读到内存中,再去做操作,这一个操作就占了 3.4M 内存空间,这要是文件大了那还得了,所以,他有没有流的方式呢? 我们先来了解下这种新的流,转化流。
转化流(能读能写)
// 读取用户输入
// process.stdin.on('data', function(chunk) { // 监听用户输入
// console.log('输入了', chunk);
// process.stdout.write(`我要输出,等同于 console.log, 输出结果为 ${ chunk }`);
// });
// 可以看到,以上的一个是可读流(具备 data 方法),一个是可写流(具备 write 方法),这样我们就可以借助 pipe 来更优雅的实现数据传输。
process.stdin.pipe(process.stdout);
如果这时候我希望命令行输入字母之后,需要把字母转化为大写字母再输出呢,是不是要在可读可写流之间加一层 pipe,也就是转化流。
和可读流(_read)、可写流(_write)一样。可读流也需要自己实现一个 _transform 方法,该方法被父类默认调用。
const { Transform } = require('stream')
class MyTransform extends Transform {
// 转换流需要自己实现 _transform 方法,该方法被父类默认调用
_transform(chunk, encoding, clearBuffer) { // 参数和可写流一样
// 通知父类触发 data 回调(可读流的特性)
this.push(chunk.toString().toUpperCase());
// 写入完毕后,缓存区弹出该数据(可写流的特性)
clearBuffer();
}
}
let transform = new MyTransform;
// 读 -> 转化 + 触发父类 data -> 写
process.stdin.pipe(transform).pipe(process.stdout);
zlib 提供的压缩流(转化流的一种)
流既能读又能写
process.stdin.pipe(zlib.createGzip()).pipe(process.stdout)
实现响应体压缩
ok,我们来改写我们实现的 ys-http-server,使得它支持响应体压缩。 大概思路就是可读流获取到的内容通过转化流压缩后,返回给可写流。
修改 main.js
module.exports = class MyServer {
// .......
sendFile(req, res, statObj, filePath) {
// 要进行压缩处理 浏览器和服务器说,我支持: Accept-Encoding: gzip, deflate, br
// 服务器会和浏览器说:content-encoding: gzip 我的内容是通过 gzip 压缩的
let zip = this.gzip(req,res);
// 设置编码,不然 html,css,js 都不能解析 默认文本类型
res.setHeader('Content-Type', (mime.getType(filePath) || 'text/plain') + ';charset=utf-8')
if (zip) { // 如果支持压缩就压缩 不支持就返回
createReadStream(filePath).pipe(zip).pipe(res); // 压缩后返回
} else {
// 可读流读到文件传给可写流 res
createReadStream(filePath).pipe(res); // 直接返回
}
}
gzip(req,res) {
let encoding = req.headers['accept-encoding'];
let zip;
// 如果是图片就不要锁了
if (encoding) { // 浏览器支持的压缩方法,一般是 gzip > deflate > br,不过 node 不支持 br
let ways = encoding.split(', ');
for (let i = 0; i < ways.length; i++) {
let lib = ways[i];
if (lib == 'gzip') {
res.setHeader('content-encoding', 'gzip')
zip = zlib.createGzip();
break;
} else if (lib === 'deflate') {
res.setHeader('content-encoding', 'deflate')
zip = zlib.createDeflate();
break
}
}
}
return zip
}
// ........
}
我们新建测试文件夹,public/1.txt,在 1.txt 写很多很多数字 1 (就是很多),然后使用我们的静态服务工具启动服务
- public
- index.html
- index.js
- index.css
- 1.txt
ys-hs ./ --port 9000
访问 http://127.0.0.1:9000/public/1.txt
main.js 整体代码
// 核心源码
const http = require('http');
const fs = require('fs').promises;
const path = require('path');
const url = require('url');
const mime = require('mime');
const os = require('os');
const chalk = require('chalk'); // 换个打印颜色
const zlib = require('zlib');
const { createReadStream, readFileSync } = require('fs'); // 读取文件
const { render } = require('./util'); // 渲染模板的方法
const template = readFileSync(path.resolve(__dirname, 'tmpl.html'), 'utf8'); // 渲染文件列表的模板
// 获取当前局域网内的 ipv4 的 ip,比如 192.168.0.103
let address = Object.values(os.networkInterfaces()).flat().find(item => item.family == 'IPv4' && !item.internal).address;
module.exports = class MyServer {
constructor(opts) {
this.port = opts.port;
this.directory = opts.directory;
this.address = address;
}
sendError(res, e) {
console.log(e)
res.statusCode = 404; // 顺序是先响应状态码 在结束响应
res.end('NOT Found');
}
sendFile(req, res, statObj, filePath) {
// 要进行压缩处理 浏览器和服务器说,我支持: Accept-Encoding: gzip, deflate, br
// 服务器会和浏览器说:content-encoding: gzip 我的内容是通过 gzip 压缩的
let zip = this.gzip(req,res);
// 设置编码,不然 html,css,js 都不能解析 默认文本类型
res.setHeader('Content-Type', (mime.getType(filePath) || 'text/plain') + ';charset=utf-8')
if (zip) { // 如果支持压缩就压缩 不支持就返回
createReadStream(filePath).pipe(zip).pipe(res); // 压缩后返回
} else {
// 可读流读到文件传给可写流 res
createReadStream(filePath).pipe(res); // 直接返回
}
}
gzip(req,res) {
let encoding = req.headers['accept-encoding'];
let zip;
if (encoding) { // 浏览器支持的压缩方法,一般是 gzip > deflate > br,不过 node 不支持 br
let ways = encoding.split(', ');
for (let i = 0; i < ways.length; i++) {
let lib = ways[i];
if (lib == 'gzip') {
res.setHeader('content-encoding', 'gzip')
zip = zlib.createGzip();
break;
} else if (lib === 'deflate') {
res.setHeader('content-encoding', 'deflate')
zip = zlib.createDeflate();
break
}
}
}
return zip
}
handleRequest = async (req, res) => { // es7 写法 保存 this,低版本 node 不支持 可以采用箭头函数和bind
// 核心文件读取,模板引擎实现
// 请求到来的时候,需要监控路径,看一下路径是否是文件,如果是文件 直接将文件返回,如果不是文件则读取文件中的目录
let { pathname } = url.parse(req.url);
pathname = decodeURIComponent(pathname); // 解码,兼容中文路径
let filePath = path.join(this.directory, pathname); // 在当前执行目录下进行查找
try {
let statObj = await fs.stat(filePath); // it.throw
if (statObj.isDirectory(filePath)) {
// 文件夹则生成模板函数渲染一次
const dirs = await fs.readdir(filePath);
// 我们希望通过模板引擎的方式来实现
let content = await render(template, {
dirs: dirs.map(dir => ({
url: path.join(pathname, dir),
dir
}))
});
res.setHeader('Content-Type', 'text/html;charset=utf-8')
res.end(content)
} else {
// 是文件直接返回给客户端
this.sendFile(req, res, statObj, filePath);
}
} catch (e) {
// 文件不存在把错误返回给客户端
this.sendError(res, e)
}
}
start() {
const server = http.createServer(this.handleRequest);
server.listen(this.port, () => {
console.log(`${chalk.yellow('Starting up http-server, serving:')}` + this.directory)
console.log(` http://${address}:${chalk.green(this.port)}`)
console.log(` http://127.0.0.1:${chalk.green(this.port)}`)
});
}
}
流程总结
- 浏览器请求时通过 header 中的 accept-encoding 告诉服务器我支持哪种压缩(gzip > deflate > br),优先级排序,node 服务不支持 br 压缩。
- 服务端判断使用哪种压缩方式,并在响应头添加 header 字段 content-encoding 告知浏览器
- 浏览器获取到资源后,根据服务端告知的方式,进行解压,获取资源文件。
实现 HTTP 缓存策略
其实,我们操作的缓存,都只是跟浏览器约定好的状态码罢了(可以认为是浏览器暴露的接口),我们返回 304,我们携带字段,浏览器去做对应的事情。
我们在 main.js --> sendFile 方法顶部增加一句打印:
sendFile(req, res, statObj, filePath) {
console.log(req.url);
// ....
}
此刻刷新三次 http://127.0.0.1:9000/public/index.html 页面,发现不管是 html,还是 js 和 css,都被分别请求了三次。
/public/index.html
/public/index.css
/public/index.js
/public/index.html
/public/index.css
/public/index.js
/public/index.html
/public/index.css
/public/index.js
那这有问题啊,我的 js 和 css 都没有发生变化,却一次次的重新请求,这是对带宽极大的浪费啊。 此处应该有人疑惑,那 html 也被重新请求啊,你咋光说 js 和 css 呢,其实 html 就该被重新请求,往下看。
强缓存
强缓存特点:
- 命中之后不会发送 http 请求到服务器,且状态码为 200, 来源于 memory cache 或者 disk cache。
- 地址栏访问的资源,不会被设置强缓存。
- 根据两个字段判断是否命中缓存,分别是 expirs(http1.0) 和 cache-control(http1.1,优先级高)。
两个字段对比:
- http1.0 的 expires
// @1 只能设置绝对时间或者 -1 (永不过期),服务端设置,客户端判断是否过期拿本地时间对
// 比,本地时间不准则缓存失效,故被新版 http 废弃。
// @2 优先级低
const 10sAfter = new Date(Date.now() + 10 * 1000).toGMTString();
res.setHeader('Expires', 10sAfter); // 10s 不要再访问
- http1.1 的 cache-control
// @1 相对时间,更安全
// @2 优先级高
// @3 Cache-Control: no-store 时,没有缓存(强缓 + 协商缓),浏览器不缓存
// @4 Cache-Control: no-cache 时,不走强缓,直接协商缓存,协商判断有缓存才采用浏览器缓存
// @5 public:可以被任何人缓存,比如中间代理、CDN 等
// @6 private:只能被浏览器缓存
res.setHeader('Cache-Control', 'max-age=10, public'); // 秒为单位,10s 内不要再访问。
此刻刷新 http://127.0.0.1:9000/public/index.html 页面,发现只有 index.html 直接重新请求了,而 css 和 js 每 10s 重新请求一次。
浏览器地址栏访问的资源(不管什么类型的资源),是不会被强缓存的,比如 localhost:3000/index.html,或者省略 index.html 直接访问 localhost:3000,index.html 都是不会强缓存的(不管添加何种 meta 标签都没用),不然如果断网了,还能访问百度首页,那不是很怪异么,只有被引用的资源适用于强缓策略,比如 index.html 内部引用了 index.css 和 index.js
不要因为上例提到的是 html 文件,就认为 css 文件如果通过地址栏访问,是不是就存在强缓了呀,答案是否定的,比如访问 localhost:8000/index.css,该 index.css 文件也是不能被强缓的哦。
为了向下兼容,两者一般同时设置。
协商缓存
我们想一下,如果 10s 后我的 js 和 css 文件还是没有变,这时候又要重新拉文件了,也是不太好的,那怎么办呢,我能不能有个标识,等文件修改了我再去拉。
注意,index.html 也应该被协商缓存的,协商缓存会判断文件有没有修改,修改了再次请求,会节约带宽。
协商缓存特点:
- 会发送http请求到服务器,如果服务器文件并没有改变,本地缓存空间的文件仍然可用, 此时状态码为 304(not modified),否则状态码为 200,重新拉取文件。
- 根据两对字段判断是否命中缓存,分别是Last-Modified / If-Modified-Since 和 Etag / If-None-Match (优先级高)。
两组字段对比:
- last-modified / if-modified-since
// @1 如果资源的修改时间变了,但是内容却没变(我加了一行代码,又删掉了),这是再利用
// Last-Modified 来计算是否需要重新响应这无疑会带来资源浪费
// @2 某些文件修改非常频繁,比如在秒以下的时间内进行修改,(比方说 1s 内修改了 N 次),
// If-Modified-Since 的单位是秒,这种修改无法判断
// @3 某些服务器不能精确的得到文件的最后修改时间
// @4 优先级较低
let statObj = await fs.stat(filePath); // 文件状态
const ctime = statObj.ctime.toGMTString(); // 文件修改时间
res.setHeader('last-modified', ctime);
- etag / if-none-matched,性能换安全,读取文件信息性能比较差
// @1 根据文件信息(摘要算法)生成 etag 标识,计算方式可以自己实现,摘要算法不可逆,不同内容生
// 成摘要后长度一样,相同内容出来的结果完全相同,默认是 nginx 实现,可以自己实现哦。
// @2 强 etag:要求资源在字节级别必须完全相符
// @3 弱 etag:在值前有个 “W/” 标记,只要求资源在语义上没有变化,但内部可能会有部分发生了改
// 变(例如 HTML 里的标签顺序调整,或者多了几个空格)
res.setHeader('Cache-Control', 'max-age=10, public'); // 文件修改时间
在我们的静态服务中添加缓存策略
main.js 增加 cache 方法,修改 sendFile
module.exports = class MyServer {
cache(req,res,statObj,filePath) {
<!-- <!----------------------- 强缓存区域 ------------------------> -->
// 他表的是每次都来服务器来询问,缓存中有,no-store 代表没有缓存
res.setHeader('Cache-Control','no-cache');
// s 单位, 10s内我引用的其他资源不要在访问了
// res.setHeader('Cache-Control','max-age=10');
// res.setHeader('Expires',new Date(Date.now() + 10 * 1000).toGMTString());
<!-- <!----------------------- 协商缓存区 ------------------------> -->
const ifModifiedSince = req.headers['if-modified-since']
const ctime = statObj.ctime.toGMTString();
res.setHeader('last-modified',ctime);
// if (ifModifiedSince != ctime ) {
// return false;
// }
// tag 根据内容来生成一个唯一的标识 ETAG
const ifNoneMatch = req.headers['if-none-match'];
let etag = crypto.createHash('md5').update(readFileSync(filePath)).digest('base64');
res.setHeader('ETag',etag);
// 服务器需要提供一个 etag 浏览器 提供一个 if-none-match
if (ifNoneMatch != etag){
return false;
}
return true; // 强缓存 + 对比缓存的方式
}
sendFile(req, res, statObj, filePath) {
console.log(req.url);
// 缓存处理 能走进这里的肯定是协商缓存啦
if (this.cache(req,res,statObj,filePath)){
res.statusCode = 304
}
// ..............
}
此刻多次刷新 http://127.0.0.1:9000/public/index.html 页面,发现不管是 html,还是 js 和 css,都会被缓存,只有当文件本身修改了,才会被重新请求,状态码为 200。
main.js 完整代码
// 核心源码
const http = require('http');
const fs = require('fs').promises;
const path = require('path');
const url = require('url');
const mime = require('mime');
const os = require('os');
const chalk = require('chalk'); // 换个打印颜色
const zlib = require('zlib');
const crypto = require('crypto');
const { createReadStream, readFileSync } = require('fs'); // 读取文件
const { render } = require('./util'); // 渲染模板的方法
const template = readFileSync(path.resolve(__dirname, 'tmpl.html'), 'utf8'); // 渲染文件列表的模板
// 获取当前局域网内的 ipv4 的 ip,比如 192.168.0.103
let address = Object.values(os.networkInterfaces()).flat().find(item => item.family == 'IPv4' && !item.internal).address;
module.exports = class MyServer {
constructor(opts) {
this.port = opts.port;
this.directory = opts.directory;
this.address = address;
}
sendError(res, e) {
console.log(e)
res.statusCode = 404; // 顺序是先响应状态码 在结束响应
res.end('NOT Found');
}
cache (req, res, statObj, filePath) {
res.setHeader('Cache-Control','no-cache'); // 他表的是每次都来服务器来询问,缓存中有, no-store 代表没有缓存
// res.setHeader('Cache-Control','max-age=10'); // s 单位, 10s内我引用的其他资源不要在访问了
// res.setHeader('Expires',new Date(Date.now() + 10 * 1000).toGMTString());
// 有的文件 可能10s后 还是没有变, (我希望对比一下,如果文件没变 就接着找缓存去)
// last-modified: Tue, 07 Jul 2020 03:31:44 GMT 服务器和浏览器说 此文件最后修改时间是多少
// if-modified-since 浏览器下次访问的时候带过来的 (和压缩一样)
const ifModifiedSince = req.headers['if-modified-since']
const ctime = statObj.ctime.toGMTString();
res.setHeader('last-modified',ctime);
// if(ifModifiedSince != ctime ){ // 根据最后修改时间 可能会出现时间变化后但是内容没变,或者如果1s内多次变化 也监控不到 ,缓存时间的单位是秒
// return false;
// }
// tag 根据内容来生成一个唯一的标识 ETAG
const ifNoneMatch = req.headers['if-none-match'];
let etag = crypto.createHash('md5').update(readFileSync(filePath)).digest('base64');
res.setHeader('ETag',etag);
// 服务器需要提供一个 etag 浏览器 提供一个 if-none-match
if(ifNoneMatch != etag){
return false;
}
return true; // 强缓存 + 对比缓存的方式
}
sendFile(req, res, statObj, filePath) {
console.log(req.url);
// 缓存处理 能走到这里的肯定是协商缓存啦
if (this.cache(req,res,statObj,filePath)){
res.statusCode = 304
return res.end(); // 我不用返回内容,告诉浏览器找缓存即可
}
// 要进行压缩处理 浏览器和服务器说,我支持: Accept-Encoding: gzip, deflate, br
// 服务器会和浏览器说:content-encoding: gzip 我的内容是通过 gzip 压缩的
let zip = this.gzip(req,res);
// 设置编码,不然 html,css,js 都不能解析 默认文本类型
res.setHeader('Content-Type', (mime.getType(filePath) || 'text/plain') + ';charset=utf-8')
if (zip) { // 如果支持压缩就压缩 不支持就返回
createReadStream(filePath).pipe(zip).pipe(res); // 压缩后返回
} else {
// 可读流读到文件传给可写流 res
createReadStream(filePath).pipe(res); // 直接返回
}
}
gzip(req,res) {
let encoding = req.headers['accept-encoding'];
let zip;
if (encoding) { // 浏览器支持的压缩方法,一般是 gzip > deflate > br,不过 node 不支持 br
let ways = encoding.split(', ');
for (let i = 0; i < ways.length; i++) {
let lib = ways[i];
if (lib == 'gzip') {
res.setHeader('content-encoding', 'gzip')
zip = zlib.createGzip();
break;
} else if (lib === 'deflate') {
res.setHeader('content-encoding', 'deflate')
zip = zlib.createDeflate();
break
}
}
}
return zip
}
handleRequest = async (req, res) => { // es7 写法 保存 this,低版本 node 不支持 可以采用箭头函数和bind
// 核心文件读取,模板引擎实现
// 请求到来的时候,需要监控路径,看一下路径是否是文件,如果是文件 直接将文件返回,如果不是文件则读取文件中的目录
let { pathname } = url.parse(req.url);
pathname = decodeURIComponent(pathname); // 解码,兼容中文路径
let filePath = path.join(this.directory, pathname); // 在当前执行目录下进行查找
try {
let statObj = await fs.stat(filePath); // it.throw
if (statObj.isDirectory(filePath)) {
// 文件夹则生成模板函数渲染一次
const dirs = await fs.readdir(filePath);
// 我们希望通过模板引擎的方式来实现
let content = await render(template, {
dirs: dirs.map(dir => ({
url: path.join(pathname, dir),
dir
}))
});
res.setHeader('Content-Type', 'text/html;charset=utf-8')
res.end(content)
} else {
// 是文件直接返回给客户端
this.sendFile(req, res, statObj, filePath);
}
} catch (e) {
// 文件不存在把错误返回给客户端
this.sendError(res, e)
}
}
start() {
const server = http.createServer(this.handleRequest);
server.listen(this.port, () => {
console.log(`${chalk.yellow('Starting up http-server, serving:')}` + this.directory)
console.log(` http://${address}:${chalk.green(this.port)}`)
console.log(` http://127.0.0.1:${chalk.green(this.port)}`)
});
}
}
npm 发包
这个过程比较简单了
npm login
npm publish
ys-http-server,欢迎下载查看源码,备注很清晰哦