这是我参与「第五届青训营」伴学笔记创作活动的第 7 天。
Node.js 的应用场景
- 前端工程化
- Bundle:webpack, vite, esbuild, parcel
- Uglify:uglifyjs
- Transpile:bablejs, typescript
- 其他语言加入竞争:esbuild, parcel, prisma
- 现状:难以替代
- Web服务端应用
- 学习曲线平缓,开发效率较高
- 运行效率接近常见的编译语言
- 社区生态丰富及工具链成熟(npm, V8 inspector)
- 与前端结合的场景会有优势(SSR)
- 现状:竞争激烈,Node.js 有自己独特的优势
- Electron跨端桌面应用
- 商业应用:vscode, slack, discord, zoom
- 大型公司的内部效率工具
- 现状:大部分场景在选型时,都值得考虑
字节内部的应用场景
-
BFF(Back-end For Front-end,接口由前端提供)应用、SSR应用(举例:Modern.js)
-
服务端应用(举例:头条搜索,西瓜视频,懂车帝)
-
Electron 应用(飞连,飞书)
-
每年新增1000+ Node.js应用
Node.js运行时结构
- N-API提供的JS的API不能提供的Native能力
- V8是JS运行时,也提供调试工具(Inspector)
- libuv提供系统调用(syscall)、event loop
- nghttp2提供HTTP/2相关能力
- zlib提供压缩能力
- c-ares提供DNS查询能力
- llhttp提供HTTP报文解析能力
特点
异步I/O
当 Node.js执行I/O操作时,会在响应返回后恢复操作,而不是阻塞线程并占用额外内存等待。
向开发者暴露的是单线程模型
绝大多数情况下,开发者完全可以认为是在开发单线程应用。当然也可以用worker_thread另行启动独立线程。
实际上Node.js内部实现了多线程的互操作:
- JS Runtime线程
- libuv线程池(默认4线程)
- V8任务线程池
- V8 Inspector线程
优点:
- 不用考虑多线程状态同步问题,也就不需要锁
- 同时还能比较高效地利用系统资源
缺点:
- JS Runtime线程中的阻塞会产生更多负面影响(解决办法:多进程或多线程)
跨平台
Node.js的大部分功能、API是跨平台的,大部分场景无需考虑platform-specific细节。
Node.js跨平台+JS无需编译环境(+Web 跨平台+诊断工具跨平台)=开发成本低,整体学习成本低
编写Http Server
安装Node.js
-
Mac, Linux推荐使用nvm多版本管理工具来安装。
-
Windows推荐nvm4w或是官方安装包。
-
安装慢/安装失败的情况:设置安装源
NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node nvm install 16
编写简易 HTTP Server & Client
HTTP Server
const http = require("http");
const port = 3000;
const server = http.createServer((req, res) => {
res.end("hello");
});
server.listen(port, () => {
console.log(`Server is listening on port ${port}`);
});
JSON Server
const http = require("http");
const port = 3000;
const server = http.createServer((req, res) => {
const bufs = [];
req.on("data", data => {
bufs.push(data);
});
req.on("end", () => {
let reqData = {};
try {
reqData = JSON.parse(Buffer.concat(bufs).toString("utf-8"));
} catch (err) {
console.warn("Failed to parse json!");
}
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({
echo: reqData.msg || "Hello",
}));
});
});
server.listen(port, () => {
console.log(`JSON Server is listening on port ${port}`);
});
Promise化
上述代码中充斥着各类回调函数,不容易理清业务逻辑,容易给后续代码维护留下坑,因此尝试使用Promise+async/await重写一下:
适合改写成Promise的callback应当只被调用一次。
const http = require("http");
const port = 3000;
const server = http.createServer(async (req, res) => {
// receive body from client
const reqData = await new Promise((resolve, reject) => {
const bufs = [];
req.on("data", data => {
bufs.push(data);
});
req.on("error", err => {
reject(err);
})
req.on("end", () => {
let reqData = {};
try {
reqData = JSON.parse(Buffer.concat(bufs).toString("utf-8"));
} catch (err) {
console.warn("Failed to parse json!");
}
resolve(reqData);
});
});
// response
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({
echo: reqData.msg || "Hello",
}));
});
server.listen(port, () => {
console.log(`JSON Server is listening on port ${port}`);
});
JSON Client
const http = require("http");
const port = 3000;
const body = JSON.stringify({
msg: "Hello from my client!",
});
const req = http.request(`http://127.0.0.1:${port}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
}, (res) => {
const bufs = [];
res.on("data", data => {
bufs.push(data);
});
res.on("end", () => {
const received = JSON.parse(Buffer.concat(bufs).toString("utf-8"));
console.log("Received:", received);
});
});
req.end(body);
编写静态文件服务器
这里采用Steam风格的API。Steam风格的API可以占用尽可能少的内存空间,直接readFile全部读入内存可能导致内存溢出。
const http = require("http");
const fs = require("fs");
const path = require("path");
const url = require("url");
const port = 3000;
// __dirname 表示当前文件所在的目录
const staticRootPath = path.resolve(__dirname, "./static");
const server = http.createServer((req, res) => {
// expected url example: http://127.0.0.1:3000/index.html?abc=1
const info = url.parse(req.url); // 为了去除查询参数而parsse
// expected filePath = static/index.html
const filePath = path.resolve(staticRootPath, “./” + info.path);
const file = fs.createReadStream(filePath);
file.pipe(res);
// 这里还可以写根据文件后缀名设置Content-Type
})
server.listen(port, () => {
console.log(`Static File Server is listening on port ${port}`);
})
要达成高性能、可靠服务的目标,还需要实现:
- CDN:缓存、加速
- 分布式储存:容灾
也可以使用外部服务:Cloudflare、七牛云、阿里云、火山云……
编写React SSR服务
SSR(Server Side Rendering,服务端渲染,但这里其实是指同构渲染)的特点:
-
优点
-
相比传统模版引擎:前后端不分离
-
相比 SPA(Single Page Application):首屏渲染更快,SEO友好
-
-
缺点
- 通常QPS(每秒查询率)较低,前端代码编写时需要考虑服务端渲染情况
-
实现难点
- 需要处理打包代码(部分静态文件的加载代码可能没有意义)
- 需要思考前端代码在服务端运行时的逻辑(拉取数据的代码需要注意执行时机)
- 移除对服务端无意义的副作用(例如对
window对象的修改),或每次渲染都重置环境(有性能问题)
适用Inspector进行调试、诊断
V8 Inspector:开箱即用、特性丰富强大、与前端开发一致、跨平台
node --inspect
默认可以用 http://127.0.0.1:9229/json ,找到devtoolFrontendURL来在浏览器端连接调试进程。
场景:
- 查看
console.log内容 - breakpoint
- 高 CPU、死循环:cpuprofile
- 高内存占用:heapsnapshot
- 性能分析
部署简介
部署要解决的问题:
- 守护进程:当进程退出时,重新拉起
- 多进程:cluster 便捷地利用多进程
- 记录进程状态,用于诊断
容器环境:通常有健康检查的手段,只需考虑多核cpu利用率即可
延伸话题
Node.js 代码贡献
- 快速了解 Node.js 代码(Node.js Core 贡献入门)
- 好处
- 从使用者的角色逐步理解底层细节,可以解决更复杂的问题
- 自我证明,有助于职业发展
- 解决社区问题,促进社区发展
- 难点
- 花时间
编译Node.js
- 为什么要学习编译 Node.js
- 认知:黑盒到白盒,发生问题时能有迹可循
- 贡献代码的第一步
- 如何编译
- 参考:Building Node.js
./configure && make install- 演示:给net模块添加自定义属性
诊断/追踪
- 诊断是一个低频、重要同时也相当有挑战的方向,是企业衡量自己能否依赖一门语言的重要参考。
- 技术咨询行业中的热门角色。
- 难点
- 需要了解 Node.js 底层,需要了解操作系统以及各种工具
- 需要经验
WASM、N-API
- Node.js(V8)是执行WASM代码的天然容器,和浏览器WASM是同一运行时,同时Node.js支持 WASI
- NAPI执行C接口的代码(C/C++/Rust……),同时能保留原生代码的性能
- 不同编程语言间通信的一种方案