前面的内容已经覆盖了 HTTP 服务器的搭建、请求与响应的构造,以及客户端请求的发起。但在实际 Web 开发中,有三个功能几乎每个应用都会用到:接收客户端上传的文件、维持会话状态的 Cookie,以及基于 Cookie 的服务端 Session 管理。主流框架(Express、Koa 等)都封装了成熟的时间,但在某些高性能场景或学习底层原理时,了解 Node.js 原生实现仍然是必要的。
本节将完全基于 http、fs、path 等内置模块,实现一个支持文件上传、Cookie 读写和 Session 存储的简易 HTTP 服务,不引入任何第三方依赖。
1. 文件上传:手动解析 multipart/form-data
当 HTML 表单设置 enctype="multipart/form-data" 时,浏览器会将文件内容连同普通字段一同包装成多部分格式(multipart),通过 HTTP 请求体发送。Node.js 的 http 模块不会自动解析这种格式,我们需要从 TCP 字节流中一步步提取数据。
multipart 数据格式
一个上传文件的请求体大致如下:
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="username"
zhangsan
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="avatar"; filename="avatar.png"
Content-Type: image/png
<二进制文件内容>
------WebKitFormBoundary7MA4YWxkTrZu0gW--
整个请求体被一个随机生成的 boundary 分割成多个部分。每个部分包含头部信息(name、filename、Content-Type 等),然后是一段数据体。最后一个 boundary 后跟 -- 表示结束。
原生解析步骤
- 从请求头
content-type中提取boundary字符串。 - 将请求体读取为 Buffer,按照 boundary 切割成多个部分。
- 遍历每个部分,解析出字段名、文件名和内容。
下面是完整实现:
const http = require('http');
const fs = require('fs');
const path = require('path');
function parseMultipart(req, options = {}) {
const contentType = req.headers['content-type'];
if (!contentType || !contentType.includes('multipart/form-data')) {
throw new Error('Not a multipart request');
}
// 获取 boundary
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/);
if (!boundaryMatch) throw new Error('No boundary found');
const boundary = boundaryMatch[1] || boundaryMatch[2];
const boundaryBuffer = Buffer.from('--' + boundary);
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
const body = Buffer.concat(chunks);
const fields = {};
const files = [];
// 按 boundary 切割各个部分
let start = body.indexOf(boundaryBuffer) + boundaryBuffer.length;
let end = body.indexOf(boundaryBuffer, start);
while (end !== -1) {
const part = body.slice(start, end - 2); // 去掉尾部的 \r\n
start = end + boundaryBuffer.length;
end = body.indexOf(boundaryBuffer, start);
// 分离头部和体部
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const headerText = part.slice(0, headerEnd).toString();
const content = part.slice(headerEnd + 4);
// 解析头部信息
const nameMatch = headerText.match(/name="([^"]+)"/);
const filenameMatch = headerText.match(/filename="([^"]+)"/);
const contentTypeMatch = headerText.match(/Content-Type:\s*(.+)/i);
const fieldName = nameMatch ? nameMatch[1] : null;
if (!fieldName) continue;
if (filenameMatch) {
// 这是一个文件字段
const filename = filenameMatch[1];
const fileType = contentTypeMatch ? contentTypeMatch[1].trim() : 'application/octet-stream';
const uploadDir = options.uploadDir || './uploads';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const savePath = path.join(uploadDir, Date.now() + '_' + filename);
fs.writeFileSync(savePath, content);
files.push({
fieldName,
filename,
contentType: fileType,
size: content.length,
path: savePath
});
} else {
// 普通文本字段
fields[fieldName] = content.toString().trim();
}
}
resolve({ fields, files });
});
req.on('error', reject);
});
}
使用示例
const server = http.createServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/upload') {
try {
const result = await parseMultipart(req, { uploadDir: './uploads' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Upload success',
fields: result.fields,
files: result.files
}));
} catch (err) {
res.writeHead(500);
res.end('Upload failed: ' + err.message);
}
} else {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="text" name="username" placeholder="用户名"><br>
<input type="file" name="avatar"><br>
<button type="submit">上传</button>
</form>
`);
}
});
server.listen(3000);
运行后访问 http://localhost:3000,选择文件并提交,服务器就会在 uploads 目录下保存上传的文件,并返回 JSON 结果。
原生实现虽然直接,但需要处理 chunk 拼接、boundary 解析、文件写入等细节。生产环境通常建议使用成熟的库(如 busboy、formidable、multer),它们在流式解析、内存控制、大文件处理方面已经做得非常成熟。
2. Cookie 操作:服务端读写与安全属性
Cookie 是浏览器存储的一小段文本数据(通常不超过 4KB),每次请求时浏览器会自动将匹配域名的 Cookie 发送到服务器,从而实现状态保持、用户识别等功能。
读取客户端发送的 Cookie
HTTP 请求头中的 Cookie 字段是一串键值对:
Cookie: theme=dark; lang=zh-CN; remember=1
可以编写一个工具函数将其解析为 JavaScript 对象:
function parseCookies(req) {
const cookieHeader = req.headers.cookie;
const cookies = {};
if (cookieHeader) {
cookieHeader.split(';').forEach(cookie => {
const parts = cookie.split('=');
const key = parts[0].trim();
const value = parts[1] ? parts[1].trim() : '';
cookies[key] = value;
});
}
return cookies;
}
设置 Cookie 到客户端
服务端通过响应头 Set-Cookie 来指示浏览器保存 Cookie。一个 Cookie 可以携带多种属性:过期时间 Expires / Max-Age、路径 Path、域名 Domain、Secure(仅 HTTPS)、HttpOnly(禁止 JS 访问)、SameSite 等。
我们可以封装一个设置 Cookie 的函数:
function setCookie(res, name, value, options = {}) {
const defaults = {
path: '/',
httpOnly: true, // 防止 XSS 攻击窃取 Cookie
sameSite: 'Lax', // 防范 CSRF
secure: false // 生产环境应设为 true
};
const opts = { ...defaults, ...options };
let cookieStr = `${name}=${encodeURIComponent(value)}`;
if (opts.maxAge) {
cookieStr += `; Max-Age=${opts.maxAge}`;
}
if (opts.expires) {
cookieStr += `; Expires=${opts.expires.toUTCString()}`;
}
if (opts.path) {
cookieStr += `; Path=${opts.path}`;
}
if (opts.domain) {
cookieStr += `; Domain=${opts.domain}`;
}
if (opts.secure) {
cookieStr += `; Secure`;
}
if (opts.httpOnly) {
cookieStr += `; HttpOnly`;
}
if (opts.sameSite) {
cookieStr += `; SameSite=${opts.sameSite}`;
}
// 可能同时存在多个 Set-Cookie 头,使用数组
const existing = res.getHeader('Set-Cookie') || [];
const newHeaders = Array.isArray(existing) ? existing : [existing];
newHeaders.push(cookieStr);
res.setHeader('Set-Cookie', newHeaders);
}
Cookie 使用示例
const server = http.createServer((req, res) => {
const cookies = parseCookies(req);
if (req.url === '/login') {
// 假设登录成功,设置一个 sessionId Cookie
setCookie(res, 'sessionId', 'abc123xyz', {
maxAge: 3600, // 1小时
httpOnly: true,
sameSite: 'Lax'
});
res.end('Set cookie: sessionId');
} else if (req.url === '/dashboard') {
const sessionId = cookies.sessionId;
if (sessionId === 'abc123xyz') {
res.end('You are logged in!');
} else {
res.writeHead(403);
res.end('Forbidden');
}
} else {
res.end('Hello');
}
});
server.listen(3000);
原生实现 Cookie 功能完全可行,但在实际项目中 Cookie 的签名、加密、防篡改等需求会进一步增加复杂度。框架中的 cookie-parser 等中间件已经解决了这些问题。
3. Session 实现:基于 Cookie 的会话管理
Session 是为了解决 HTTP 无状态特性而产生的服务端存储机制。服务端为每个用户生成一个唯一标识(sessionId),通过 Cookie 发送给客户端;后续请求中浏览器携带该标识,服务端再从存储(内存、Redis、数据库)中取出对应的会话数据。
原生实现步骤
- 创建一个
sessionsMap 作为存储容器。 - 当用户首次访问时,生成一个随机 sessionId,设置 Cookie,并在 sessions 中初始化一个空对象。
- 后续请求根据 Cookie 中的 sessionId 查找对应会话数据。
- 提供简单的过期清理机制(可以结合定时器或惰性删除)。
const crypto = require('crypto');
class SessionManager {
constructor(options = {}) {
this.store = new Map(); // 简单内存存储
this.maxAge = options.maxAge || 3600000; // 默认1小时
this.cleanupInterval = options.cleanupInterval || 300000; // 每5分钟清理一次过期会话
this._startCleanup();
}
// 生成随机 sessionId
generateId() {
return crypto.randomBytes(16).toString('hex');
}
// 获取或创建会话
getSession(req, res) {
const cookies = parseCookies(req);
let sessionId = cookies.sessionId;
let session = null;
if (sessionId && this.store.has(sessionId)) {
session = this.store.get(sessionId);
// 检查是否过期
if (Date.now() > session._expires) {
this.store.delete(sessionId);
sessionId = null;
session = null;
}
}
if (!session) {
sessionId = this.generateId();
session = { _expires: Date.now() + this.maxAge };
this.store.set(sessionId, session);
// 设置 Cookie
setCookie(res, 'sessionId', sessionId, {
maxAge: this.maxAge / 1000,
httpOnly: true,
sameSite: 'Lax'
});
}
// 返回会话对象引用,方便业务代码直接修改
return session;
}
// 销毁会话
destroy(sessionId) {
this.store.delete(sessionId);
}
// 清理过期会话
_startCleanup() {
setInterval(() => {
const now = Date.now();
for (const [id, session] of this.store.entries()) {
if (now > session._expires) {
this.store.delete(id);
}
}
}, this.cleanupInterval);
}
}
Session 使用示例:简易登录
const sessionManager = new SessionManager({ maxAge: 600000 }); // 10分钟
const server = http.createServer((req, res) => {
const session = sessionManager.getSession(req, res);
if (req.url === '/login' && req.method === 'POST') {
// 简化的登录过程(实际应验证用户名密码)
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
const params = new URLSearchParams(body);
if (params.get('user') === 'admin' && params.get('pass') === '123456') {
session.user = {
name: 'admin',
role: 'admin'
};
res.writeHead(302, { Location: '/dashboard' });
res.end();
} else {
res.end('Login failed');
}
});
} else if (req.url === '/dashboard') {
if (session.user) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<h1>Welcome, ${session.user.name}!</h1><a href="/logout">Logout</a>`);
} else {
res.writeHead(403);
res.end('Please login first');
}
} else if (req.url === '/logout') {
// 获取 sessionId 并销毁
const cookies = parseCookies(req);
if (cookies.sessionId) {
sessionManager.destroy(cookies.sessionId);
}
setCookie(res, 'sessionId', '', { maxAge: 0 }); // 清除客户端Cookie
res.writeHead(302, { Location: '/' });
res.end();
} else {
res.end(`
<form method="post" action="/login">
<input name="user" placeholder="User"><br>
<input name="pass" type="password" placeholder="Password"><br>
<button type="submit">Login</button>
</form>
`);
}
});
server.listen(3000);
这个示例完整实现了基于内存的 Session 管理,包括登录状态保持、访问控制和退出登录。
4. 原生实现的现实考量
通过上面的例子可以看到,完全依赖 Node.js 原生模块实现文件上传、Cookie 和 Session 在技术上是可行的,并且能够帮助我们:
- 深入理解 HTTP 协议的数据传输机制。
- 掌握二进制数据处理和流解析的技巧。
- 在受限环境(如嵌入式设备)中减少依赖。
但在实际生产项目中,这种做法通常不建议:
- 手动解析 multipart 对内存使用不够高效,无法处理超大文件的上传。
- Cookie 操作缺少签名和加密,容易导致安全漏洞(如会话劫持)。
- 内存 Session 无法跨进程共享,且重启即丢失。
因此,绝大多数 Node.js 应用都会选择成熟的中间件:
- 文件上传:
multer、formidable、busboy - Cookie 解析:
cookie-parser - Session 管理:
express-session(可对接 Redis、MongoDB 等存储)
下一章我们将介绍这些框架级中间件的用法,帮助你在保证安全性和可维护性的前提下,快速实现这些基础功能。