主题
http 服务与http 服务案例(Day9 2/2)
本页对应 Day09_Node,共 4 段视频,约 1 小时 56 分。
学习目标
- 能够使用
req.on('data')与req.on('end')分块读取 POST 请求体,拼接完整请求体,并使用querystring.parse()将请求体解析为对象。 - 能够通过
res.statusCode、res.statusMessage、res.setHeader()、res.writeHead()、res.write()和res.end()设置响应行、响应头与响应体,并完成响应结束。 - 能够使用
URL模块解析请求地址中的pathname,剔除查询字符串影响,并根据不同路径返回首页、登录页、注册页或 404 内容。 - 能够根据
req.method与路径组合使用if/else分支,对同一路径的 GET 请求返回登录页面,对 POST 请求执行模拟登录处理。 - 能够将 HTML 文件读取为字符串并作为响应体返回,说明服务器只负责读取和传递文件内容,浏览器负责解析 HTML。
- 能够根据请求处理结果设置成功或失败响应,并验证不同路径、不同请求方式下的响应内容与状态码是否符合预期。
知识点
一、先分清查询字符串和请求体
为什么需要:GET、POST、PUT、DELETE 等请求都可以在 URL 上拼查询字符串,但请求体通常只有 POST、PUT 等请求才有;GET、DELETE 典型没有请求体。表单默认 method 是 GET,会把输入项拼到 URL 查询字符串里;改成 POST 后,数据进入请求体。后端处理方式完全不同。
怎么想:先看表单 method,再决定后端从 req.url 取查询字符串,还是从请求体流里读数据。请求体可能很大,不能假设一次读完,要监听 data 累积、end 后处理。
怎么做:表单写 method="post";后端用 data/end 读取,再用 querystring.parse 转对象。字段名必须和表单 name 前后端商量一致。
js
const http = require('http');
const qs = require('querystring');
http.createServer((req, res) => {
let reqBody = '';
req.on('data', chunk => { reqBody += chunk; }); // Buffer 拼字符串会自动转
req.on('end', () => {
const body = qs.parse(reqBody);
res.end(`name=${body.name}, email=${body.email}`);
});
}).listen(8080);易错点:在地址栏回车、点普通超链接、默认表单提交都是 GET;只有表单 method="post" 等才发 POST。请求体是流,不能在 data 之前就以为拿到了完整数据;中文和 @ 等会被编码,后续可用 querystring.parse 还原结构。
二、把请求体写入文件:读取、拼接、落盘
为什么需要:真实开发常把提交信息写数据库;没学数据库时,可先写文件,理解“服务端拿到请求体后再处理”的流程。
怎么想:在 end 里处理完整请求体;用客户端 IP 做文件名,避免多人提交互相覆盖;但 IP 里可能有冒号,不能直接做文件名。
怎么做:用 req.socket.remoteAddress 取 IP,slice(7) 去掉前面的 ::ffff:;用 path.join 拼到 dbs 目录;fs.writeFile 写入,成功/失败分别响应。
js
const fs = require('fs');
const path = require('path');
const body = qs.parse(reqBody);
const content = `姓名:${body.name}\n邮箱:${body.email}\n留言:${body.message}`;
const ip = req.socket.remoteAddress.slice(7);
const fileName = path.join(__dirname, 'dbs', ip + '.txt');
fs.writeFile(fileName, content, err => {
if (err) res.end('<h2>表单提交失败</h2>');
else res.end('<h2>表单提交成功</h2>');
});易错点:文件名不能含冒号;writeFile 会覆盖同名文件,重复提交会覆盖旧内容;响应要等请求体读取并处理完再发,不能过早 end。
三、设置响应报文:状态行、响应头、响应体
为什么需要:服务端不仅要“回”,还要告诉浏览器状态码、内容类型、自定义头等信息。不结束响应,浏览器会一直转圈。
怎么想:响应行用 res.statusCode、res.statusMessage;响应头用 res.setHeader 或 res.writeHead 一次性设置;响应体用 res.write 多次写,最后必须 res.end。
怎么做:
js
res.statusCode = 201;
res.statusMessage = 'OK';
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Server', 'leserver 1.0.1');
res.write('<p>第一段</p>');
res.writeHead(280, 'OK', { token: '随机字符' }); // 会覆盖前面的状态码/描述及同名头
res.end('<h2>结束响应</h2>');易错点:只写 res.write 不 end,请求不结束;writeHead 会覆盖之前设置的状态码、状态描述和同名响应头;响应体有中文要设 Content-Type: text/html; charset=utf-8;状态码可“乱写”,但实际应按标准写 200、404、500 等。
四、按路径响应:url.parse(req.url).pathname
为什么需要:同一台服务器要根据不同路径返回首页、登录页、注册页或 404。查询字符串不应影响路径判断。
怎么想:req.url 是残缺 URL,用 url.parse 取 pathname;/login?x=1 的 pathname 仍是 /login。用 switch 分支响应,最后 default 返回 404。
怎么做:
js
const url = require('url');
const pathname = url.parse(req.url).pathname;
switch (pathname) {
case '/':
case '/index':
res.writeHead(200, 'OK', { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<h1>首页</h1><a href="/login">登录</a>');
break;
case '/login':
res.end('<h1>登录</h1>');
break;
default:
res.writeHead(404, 'Not Found', { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<h1>404 页面不存在</h1><a href="/">返回首页</a>');
}易错点:switch 分支忘记 break,会继续执行后面的 res.end,出现“响应已结束还继续写”的报错;不要把查询字符串当成路径的一部分。
五、按请求方式响应:GET 加载页面,POST 模拟登录
为什么需要:同一路径 /login,GET 通常展示登录页,POST 提交登录数据,这是常见设计。服务端要同时判断路径和 req.method。
怎么想:首页不挑请求方式;/login + GET 读取 login.html 返回给浏览器;/login + POST 读取请求体、querystring.parse 后模拟校验;其他 404。
怎么做:
js
if (pathname === '/' || pathname === '/index') {
res.end('<h1>首页</h1>');
} else if (pathname === '/login' && req.method === 'GET') {
fs.readFile(path.join(__dirname, 'login.html'), (err, data) => {
if (err) { res.writeHead(500, 'Internal Server Error'); res.end('服务器错误'); }
else { res.writeHead(200, 'OK', { 'Content-Type': 'text/html; charset=utf-8' }); res.end(data); }
});
} else if (pathname === '/login' && req.method === 'POST') {
let reqBody = '';
req.on('data', c => reqBody += c);
req.on('end', () => {
const body = qs.parse(reqBody);
const ok = body.username === 'admin' && body.pwd === '123456';
res.end(ok ? '<p>登录成功</p>' : '<p>登录失败</p>');
});
} else {
res.writeHead(404, 'Not Found'); res.end('404');
}易错点:req.method 是大写,如 GET、POST;POST 的请求体必须等 end 后再解析和响应;服务器读取 HTML 只是“搬运内容”,真正解析 HTML 的是浏览器;表单 name 与后端字段名不一致,解析出来就取不到值。
先分清:查询字符串与请求体
为什么需要:表单提交后,后端必须知道数据到底在 URL 里还是在请求体里;否则用错读取方式,永远拿不到参数。
怎么想:所有请求方式都可以在 URL 上拼查询字符串,但请求体通常只有 POST、PUT 等才有;GET、DELETE 典型没有请求体。浏览器地址栏回车、点超链接、默认表单提交,都是 GET。
怎么做:表单默认 method="get",参数会拼到 URL;要发 POST,必须显式写 method="post"。
html
<form action="/login" method="post">
<input name="username">
<input name="pwd" type="password">
<button>提交</button>
</form>易错点:转写稿里在 F12 网络面板验证过:不写 method 时“请求方法”显示 GET,参数在 URL;改成 POST 后 URL 不拼参数,数据出现在“载荷/表单数据”里。不要把“地址栏访问”误认为 POST。
读取请求体:data 分片与 end 汇总
为什么需要:请求体理论上可能很大,不能假设一次读完;Node 把请求对象当可读流处理。
怎么想:请求对象 req 本质是可读流,响应对象 res 本质是可写流;用 data 事件一段段读,用 end 事件表示读完。
怎么做:先定义空字符串,data 里拼接,end 里再处理业务。
js
let reqBody = '';
req.on('data', chunk => {
reqBody += chunk; // Buffer 与字符串相加会自动转字符串
});
req.on('end', () => {
// 请求体读取完毕,才能解析、写文件、响应
});易错点:转写稿调试时长文本触发多次 data,每次得到的是 Buffer 分片;不能只拿第一次。所有后续操作要放在 end 后,否则请求体还没读完就响应。
解析请求体并落盘
为什么需要:请求体读出来是 name=xxx&email=yyy 这种查询字符串格式,直接使用不方便;案例还要求把提交信息写入文件。
怎么想:用内置 querystring.parse 转对象;用客户端 IP 当文件名,避免多人提交互相覆盖;文件名不能含冒号,所以要截掉 ::ffff: 前缀。
怎么做:
js
const qs = require('querystring');
const path = require('path');
const fs = require('fs');
const body = qs.parse(reqBody);
const ip = req.socket.remoteAddress.slice(7);
const fileName = path.join(__dirname, 'dbs', ip + '.txt');
fs.writeFile(fileName, `姓名:${body.name}\n邮箱:${body.email}`, err => {
if (err) {
res.writeHead(500, 'Server Error', {'Content-Type': 'text/html; charset=utf-8'});
res.end('<h1>表单提交失败</h1>');
} else {
res.end('<h2>恭喜您,表单提交成功</h2>');
}
});易错点:文件名含冒号会创建失败;writeFile 会覆盖同名文件;表单 name 必须和后端取值的键一致,转写稿强调“前后端要商量好 name”。
设置响应报文:状态行、头、体
为什么需要:后端不仅要返回内容,还要告诉浏览器状态码、内容类型、服务器信息等。
怎么想:响应行用 statusCode/statusMessage,响应头用 setHeader 或 writeHead,响应体用 write/end;writeHead 可一次设置状态码、描述和多个头。
怎么做:
js
res.statusCode = 201;
res.statusMessage = 'Happy';
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Server', 'le-server 1.0.1');
res.write('<p>段落1</p>');
res.end('<h2>结束响应</h2>');
// 或一次设置
res.writeHead(404, 'Not Found', {'Content-Type': 'text/html; charset=utf-8'});
res.end('<h1>404 页面不存在</h1>');易错点:不调用 end,浏览器一直转圈直到超时;响应体有中文必须设 Content-Type: text/html; charset=utf-8;状态码乱写浏览器也能收到,但不规范;writeHead 会覆盖前面设置的同类响应头。
按路径路由:pathname 去掉查询串
为什么需要:同一个服务器要根据 /、/login、/register 返回不同页面,其他路径返回 404。
怎么想:req.url 含查询字符串,不能直接当路径判断;用 url.parse(req.url).pathname 取路径名,查询串不影响路径。
怎么做:
js
const url = require('url');
const pathname = url.parse(req.url).pathname;
switch (pathname) {
case '/':
case '/index':
res.end('<h1>首页</h1><a href="/login">登录</a>');
break;
case '/login':
res.end('<h1>登录页</h1>');
break;
default:
res.writeHead(404, 'Not Found', {'Content-Type': 'text/html; charset=utf-8'});
res.end('<h1>404 页面不存在</h1>');
}易错点:转写稿调试时 switch 的 case 忘写 break,导致继续往后执行、重复 end 报错;/login?x=1 的 pathname 仍是 /login。
按请求方式路由:同一路径 GET/POST 分流
为什么需要:同一路径 /login,GET 通常展示登录页,POST 才执行登录;这是常见的接口设计。
怎么想:先判断 pathname,再判断 req.method;注意 req.method 是大写 GET/POST。GET 可读 login.html 作为响应体,POST 要读请求体后再校验。
怎么做:
js
if (pathname === '/login' && req.method === 'GET') {
fs.readFile(path.join(__dirname, 'login.html'), (err, data) => {
if (err) {
res.writeHead(500, 'Server Error', {'Content-Type': 'text/html; charset=utf-8'});
res.end('<h1>服务器错误</h1>');
} else {
res.end(data); // 服务器只搬运 HTML,浏览器负责解析
}
});
} else if (pathname === '/login' && req.method === 'POST') {
// 先读请求体,再 qs.parse(reqBody),校验用户名密码后 res.end(...)
}易错点:点超链接发的是 GET,不是 POST;读文件失败属于服务器问题,转写稿建议用 500 而不是 404;HTML 文件由后端读取后原样返回,真正解析它的是客户端浏览器。
课堂画面




代码示例
示例一:POST 表单 + 逐段读取请求体 + 写入文件 + 响应
form.html
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>表单提交</title>
</head>
<body>
<!-- method 默认是 get,改成 post 后表单数据才会放进请求体 -->
<form action="http://127.0.0.1:8080" method="post">
姓名:<input type="text" name="name"><br>
邮箱:<input type="text" name="email"><br>
电话:<input type="text" name="phone"><br>
生日:<input type="date" name="birthday"><br>
留言:<textarea name="message"></textarea><br>
<button>提交</button>
</form>
</body>
</html>04-获取请求体.js(同目录下需先建好 dbs 文件夹)
js
const http = require('http');
const fs = require('fs');
const path = require('path');
const qs = require('querystring');
const server = http.createServer((req, res) => {
// 1. 准备变量,用来拼接请求体(最终是字符串)
let reqBody = '';
// 2. 请求对象本质是一个「可读流」,一点一点读
req.on('data', (chunk) => {
reqBody += chunk; // Buffer 与字符串相加会自动转成字符串
});
// 3. 读取完毕,才能做后续处理
req.on('end', () => {
// 4. 处理请求体数据:查询字符串格式 -> 对象
const body = qs.parse(reqBody);
// 5. 定义要写入文件的内容(键名必须和表单 name 一致)
const content = `
姓名:${body.name}
邮箱:${body.email}
电话:${body.phone}
生日:${body.birthday}
留言:${body.message}
`;
// 6. 拼接文件名:以客户端 IP 作为文件名
// remoteAddress 形如 ::ffff:127.0.0.1,冒号不能做文件名,前 7 位截掉
const ip = req.socket.remoteAddress.slice(7);
const fileName = path.join(__dirname, 'dbs', ip + '.txt');
// 7. 写入文件,成功/失败都要给客户端响应
fs.writeFile(fileName, content, (err) => {
if (err) {
res.end('<h2>很遗憾,表单提交失败</h2>');
return;
}
res.end('<h2>恭喜您,表单提交成功</h2>');
});
});
});
server.listen(8080, () => {
console.log('服务已启动,监听 8080 端口');
});要点
req.on('data')会触发多次,每次拿到的是一个 Buffer 分片,所以必须用变量累加,不能一次读全。- 所有后续操作(解析、写文件、响应)都必须写在
req.on('end')里,请求体没读完就响应是逻辑错误。 reqBody += chunk利用了字符串拼接的隐式类型转换,Buffer 会自动变成字符串,不用手动toString()。qs.parse()负责把name=xx&email=xx这种查询字符串转成对象,键名来自表单元素的name属性,前后端必须商量好。- 文件名不能含冒号,所以
::ffff:127.0.0.1要用slice(7)截取;path.join拼路径比手写字符串更稳。 - 用 IP 作文件名,多人同时提交就是一人一个文件;同名再提交会覆盖原文件(
writeFile是覆盖写)。
示例二:设置响应报文(响应行 / 响应头 / 响应体)
05-设置响应报文.js
js
const http = require('http');
const server = http.createServer((req, res) => {
// 一、响应行:状态码 + 状态描述
res.statusCode = 201;
res.statusMessage = 'happy';
// 二、响应头:一个个设置
res.setHeader('content-type', 'text/html; charset=utf-8');
res.setHeader('Server', 'atguigu 1.0.1');
// 三、响应体:响应对象是可写流,可以多次 write
res.write('<h2>锄禾日当午</h2>');
res.write('<h2>汗滴禾下土</h2>');
// 四、结束响应,并写入最后一段内容
res.end('<h2>结束</h2>');
});
server.listen(8080, () => {
console.log('服务已启动,监听 8080 端口');
});要点
- 不调用
res.end()响应就不会结束,浏览器会一直转圈直到超时,这是最常见的坑。 statusCode/statusMessage可以乱写(如 250),但规范写法是 200 OK、201、404 Not Found、500 Internal Server Error,状态码会直接影响浏览器显示成功/失败。setHeader('content-type', 'text/html; charset=utf-8')是中文不乱码的关键;漏写就是一堆乱码。res.write()可调用任意多次,每次内容都进入响应体;res.end(内容)等于「写入内容 + 结束响应」。- 还有一个一步到位的方法
res.writeHead(状态码, 状态描述, {响应头对象}),它会覆盖前面用statusCode/setHeader设过的同名信息(content-type 不冲突则保留)。
示例三:根据路径 + 请求方式做出不同响应
login.html(与 js 同目录)
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<form action="/login" method="post">
用户名:<input type="text" name="username"><br>
密码:<input type="password" name="password"><br>
<button>登录</button>
</form>
</body>
</html>02-根据请求方式不同做出不同响应.js
js
const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
const qs = require('querystring');
const server = http.createServer((req, res) => {
// url.parse 得到对象,pathname 已自动剔除查询字符串
const { pathname } = url.parse(req.url);
const method = req.method; // 注意是大写 GET / POST
// 情况一:首页,不区分请求方式
if (pathname === '/' || pathname === '/index') {
const resBody = `
<h1>首页</h1>
<hr>
<a href="/login">登录</a>
`;
res.writeHead(200, 'OK', { 'content-type': 'text/html; charset=utf-8' });
res.end(resBody);
// 情况二:GET /login —— 读取 login.html 作为响应体
} else if (pathname === '/login' && method === 'GET') {
fs.readFile(path.join(__dirname, 'login.html'), (err, data) => {
if (err) {
res.writeHead(500, 'Internal Server Error', {
'content-type': 'text/html; charset=utf-8'
});
res.end('<h1>500 服务器错误,请联系管理员</h1>');
return;
}
// data 是 Buffer,可以直接作为响应体
res.end(data);
});
// 情况三:POST /login —— 从请求体取数据,模拟登录
} else if (pathname === '/login' && method === 'POST') {
let reqBody = '';
req.on('data', (chunk) => { reqBody += chunk; });
req.on('end', () => {
const body = qs.parse(reqBody);
let resBody = '';
if (body.username === 'admin' && body.password === '123456') {
resBody = '<p>登录成功</p><a href="/">返回首页</a>';
} else {
resBody = '<p>登录失败</p><a href="/login">重新登录</a>';
}
// 不管成功失败,统一在这里响应
res.writeHead(200, 'OK', { 'content-type': 'text/html; charset=utf-8' });
res.end(resBody);
});
// 情况四:其它路径一律 404
} else {
res.writeHead(404, 'Not Found', { 'content-type': 'text/html; charset=utf-8' });
res.end('<h1>404 您访问的页面不存在</h1><a href="/">返回首页</a>');
}
});
server.listen(8080, () => {
console.log('running 8080');
});要点
url.parse(req.url).pathname只取路径部分,/login?aa=100也能正确匹配到/login,查询字符串不影响路由。req.method是大写字符串,判断时必须写'GET'/'POST',写成小写永远进不去分支。- 超链接、地址栏回车都只能发起 GET,只有
method="post"的表单才能发出 POST,所以「点登录」和「提交登录」是两个不同请求。 - 服务器只是 HTML 的搬运工:
fs.readFile把 login.html 读成 Buffer 直接res.end(data)给浏览器,解析 HTML 的是浏览器,不是服务器。 - 读文件失败属于服务器问题,规范做法是回 500 而不是 404;状态码是你说了算,但要符合约定。
- 如果用
switch (pathname)写分支,每个case末尾千万别忘break,否则会继续往下执行,已经end过再end就会报错。 - POST 分支里的响应必须写在
req.on('end')回调内,请求体读完才能拿到用户名密码。
常见错误
现象:用浏览器地址栏或超链接测试登录提交,后端始终收到 GET。
原因:地址栏、超链接默认发起 GET;只有表单method="post"等方式才能发 POST。
修法:用表单并显式设置method="post",在 F12 网络面板确认请求方法。现象:
switch(pathname)中访问/login后报错“响应已结束”或继续执行到后续分支。
原因:case中调用res.end()后忘记break,发生 case 穿透。
修法:每个case结束响应后补break;或用return res.end(...)提前返回。现象:POST 请求体解析为空、字段缺失,或响应时页面一直转圈。
原因:在data事件外立即解析/响应,未等end;或忘记res.end()。
修法:在req.on('data')中累积 Buffer/字符串,在req.on('end')中解析并统一响应,确保最终结束响应。现象:访问
/login?name=x时匹配不到/login,返回 404 或错误页面。
原因:直接用req.url做路径判断,查询字符串被一起带进比较。
修法:用 URL 模块解析后取pathname再判断,查询字符串单独处理。现象:按客户端 IP 写文件失败,提示文件名非法。
原因:req.socket.remoteAddress可能含::ffff:和冒号,冒号不能用于文件名。
修法:先截取/清洗 IP,去掉::ffff:等前缀,再用path.join拼接安全文件名。现象:响应体含中文时浏览器乱码。
原因:未设置正确的Content-Type与charset,浏览器按默认编码解析。
修法:在写响应体前设置Content-Type: text/html; charset=utf-8,或确保 HTML 内 meta charset 正确。
练习
- 实现
/、/index、/login、/register四个路径的响应,其他路径返回 404;要求/login?redirect=/仍匹配登录页。 - 给登录表单设置
method="post" action="/login",后端读取请求体并校验用户名/密码:成功返回“登录成功”,失败返回“登录失败”。 - 使用
writeHead同时设置状态码、状态描述和多个响应头,再用浏览器 F12 验证自定义头是否生效、与setHeader冲突时谁覆盖谁。 - 将 POST 表单内容按客户端 IP 写入
dbs/IP.txt,要求处理 IP 中的冒号,并分别验证写入成功与写入失败时的响应。 - 读取一个现成 HTML 文件作为响应体返回给浏览器,分别测试“响应头设置 charset”和“HTML meta 设置 charset”两种方式。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 获取请求体 | 37:59 |
| 2 | 设置响应报文 | 21:53 |
| 3 | 案例练习-根据不同的路径响应不同的内容 | 17:31 |
| 4 | 案例练习-根据请求方式不同做出不同响应 | 39:36 |
小结
- 请求体不是所有请求方式都有,GET/DELETE 典型没有,POST/PUT 才需要按流读取。
- 表单
method决定提交方式:GET 拼查询字符串,POST 放请求体,后端处理入口不同。 - 请求体必须等
data累积到end后再解析和响应,请求对象是读取流,响应对象是写入流。 - 路径匹配应使用解析后的
pathname,查询字符串不应参与资源路径判断。 - 同一路径配合不同请求方式可映射不同行为,这是后续 RESTful 设计的基础。
- 响应状态码、状态描述、响应头应在写响应体前设置;
writeHead可批量设置并覆盖先前设置。 - 服务器读取 HTML 文件只是搬运内容,真正解析 HTML 的是客户端浏览器。
- 文件写入等副作用应在请求体读取完成后执行,并区分客户端错误与服务端错误。