Skip to content

http 服务与http 服务案例(Day9 2/2) ​

本页对应 Day09_Node,共 4 段视频,约 1 小时 56 分。

学习目标 ​

  1. 能够使用 req.on('data') 与 req.on('end') 分块读取 POST 请求体,拼接完整请求体,并使用 querystring.parse() 将请求体解析为对象。
  2. 能够通过 res.statusCode、res.statusMessage、res.setHeader()、res.writeHead()、res.write() 和 res.end() 设置响应行、响应头与响应体,并完成响应结束。
  3. 能够使用 URL 模块解析请求地址中的 pathname,剔除查询字符串影响,并根据不同路径返回首页、登录页、注册页或 404 内容。
  4. 能够根据 req.method 与路径组合使用 if/else 分支,对同一路径的 GET 请求返回登录页面,对 POST 请求执行模拟登录处理。
  5. 能够将 HTML 文件读取为字符串并作为响应体返回,说明服务器只负责读取和传递文件内容,浏览器负责解析 HTML。
  6. 能够根据请求处理结果设置成功或失败响应,并验证不同路径、不同请求方式下的响应内容与状态码是否符合预期。

知识点 ​

一、先分清查询字符串和请求体 ​

为什么需要: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 文件由后端读取后原样返回,真正解析它的是客户端浏览器。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

代码示例 ​

示例一: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') 回调内,请求体读完才能拿到用户名密码。

常见错误 ​

  1. 现象:用浏览器地址栏或超链接测试登录提交,后端始终收到 GET。
    原因:地址栏、超链接默认发起 GET;只有表单 method="post" 等方式才能发 POST。
    修法:用表单并显式设置 method="post",在 F12 网络面板确认请求方法。

  2. 现象:switch(pathname) 中访问 /login 后报错“响应已结束”或继续执行到后续分支。
    原因:case 中调用 res.end() 后忘记 break,发生 case 穿透。
    修法:每个 case 结束响应后补 break;或用 return res.end(...) 提前返回。

  3. 现象:POST 请求体解析为空、字段缺失,或响应时页面一直转圈。
    原因:在 data 事件外立即解析/响应,未等 end;或忘记 res.end()。
    修法:在 req.on('data') 中累积 Buffer/字符串,在 req.on('end') 中解析并统一响应,确保最终结束响应。

  4. 现象:访问 /login?name=x 时匹配不到 /login,返回 404 或错误页面。
    原因:直接用 req.url 做路径判断,查询字符串被一起带进比较。
    修法:用 URL 模块解析后取 pathname 再判断,查询字符串单独处理。

  5. 现象:按客户端 IP 写文件失败,提示文件名非法。
    原因:req.socket.remoteAddress 可能含 ::ffff: 和冒号,冒号不能用于文件名。
    修法:先截取/清洗 IP,去掉 ::ffff: 等前缀,再用 path.join 拼接安全文件名。

  6. 现象:响应体含中文时浏览器乱码。
    原因:未设置正确的 Content-Type 与 charset,浏览器按默认编码解析。
    修法:在写响应体前设置 Content-Type: text/html; charset=utf-8,或确保 HTML 内 meta charset 正确。

练习 ​

  1. 实现 /、/index、/login、/register 四个路径的响应,其他路径返回 404;要求 /login?redirect=/ 仍匹配登录页。
  2. 给登录表单设置 method="post" action="/login",后端读取请求体并校验用户名/密码:成功返回“登录成功”,失败返回“登录失败”。
  3. 使用 writeHead 同时设置状态码、状态描述和多个响应头,再用浏览器 F12 验证自定义头是否生效、与 setHeader 冲突时谁覆盖谁。
  4. 将 POST 表单内容按客户端 IP 写入 dbs/IP.txt,要求处理 IP 中的冒号,并分别验证写入成功与写入失败时的响应。
  5. 读取一个现成 HTML 文件作为响应体返回给浏览器,分别测试“响应头设置 charset”和“HTML meta 设置 charset”两种方式。

原视频对照 ​

#视频时长
1获取请求体37:59
2设置响应报文21:53
3案例练习-根据不同的路径响应不同的内容17:31
4案例练习-根据请求方式不同做出不同响应39:36

小结 ​

  1. 请求体不是所有请求方式都有,GET/DELETE 典型没有,POST/PUT 才需要按流读取。
  2. 表单 method 决定提交方式:GET 拼查询字符串,POST 放请求体,后端处理入口不同。
  3. 请求体必须等 data 累积到 end 后再解析和响应,请求对象是读取流,响应对象是写入流。
  4. 路径匹配应使用解析后的 pathname,查询字符串不应参与资源路径判断。
  5. 同一路径配合不同请求方式可映射不同行为,这是后续 RESTful 设计的基础。
  6. 响应状态码、状态描述、响应头应在写响应体前设置;writeHead 可批量设置并覆盖先前设置。
  7. 服务器读取 HTML 文件只是搬运内容,真正解析 HTML 的是客户端浏览器。
  8. 文件写入等副作用应在请求体读取完成后执行,并区分客户端错误与服务端错误。