HTTP协议与Node原生服务:从报文到实战一次搞懂

发布时间:2026/10/18 2:09:01
HTTP协议与Node原生服务:从报文到实战一次搞懂 1. 为什么要专门花一天搞懂HTTP和原生Node服务大概每个写前端或者刚开始接触后端的朋友都有过这种阶段框架用得很顺手接口也调得通但一旦遇到跨域、浏览器缓存、大文件上传、接口鉴权这类问题就懵了。你搜了一圈资料帖子都写得云里雾里最后只能改个配置碰运气过了就过不过就继续搜。其实问题的根源不在框架而在HTTP本身。HTTP协议就是浏览器和服务器之间对话的语法和规矩。你写的每一个接口、每一次fetch请求、浏览器里每一个资源的加载骨子里都在走这套协议。框架只是帮你把规矩封装好了让你少写几行代码但规矩本身从来没变过。Node.js 原生服务就是把这层封装拆开让你直接用最底层的http模块创建服务器、接收请求、返回响应。它没有路由没有中间件没有模板引擎什么都没有但恰恰是因为什么都没有你才能看见HTTP请求从进入到离开的完整路径。我记得第一次用http.createServer跑起来一个服务的时候最大的感受是原来我平时提交表单、加载图片、请求接口背后发生的是一整套这么明确的过程。浏览器把你要访问的地址、要带的参数、要用的方法打包成一段符合约定的文本通过网络送到服务器服务器解析完再把结果打包成另一段文本送回来。整个过程有来有回通通是明文可见的。Day 6 这个主题刚好卡在从会用工具到理解原理的分界线上。这篇内容适合两类人一是刚学完JavaScript基础、准备往后端迈进的新手二是已经在用框架、但想补一下底层原理的开发者。看完你能自己用原生代码稀里哗啦写出一个支持路由、能返回HTML和JSON的小服务顺便把HTTP的核心知识点串成一条线。2. 一次请求的背后HTTP的核心脉络HTTP这东西拆开来看其实特别直白。它规定了客户端和服务端怎么对话客户端先开口服务端听完再回话。一次完整的HTTP事务说穿了就是这么一件事。2.1 一次请求到响应的完整过程你在浏览器地址栏输入一个网址按下回车背后发生的事情是这样的浏览器解析URL拆出协议、域名、端口、路径和查询参数。比如http://localhost:3000/user?id123协议是http域名是localhost端口是3000路径是/user查询参数是id123。浏览器根据域名找到服务器的IP地址。本地开发时就是127.0.0.1线上环境则要通过DNS解析DNS解析是另一个话题先不展开。浏览器与服务器建立TCP连接。这个连接是一条可靠的传输通道HTTP报文就是在这条通道里传递的。用不严谨但好懂的话说TCP保证了数据能完整到达HTTP规定了到达后怎么理解。连接建立后浏览器把请求报文通过这个连接发过去。报文里包含请求行、请求头、请求体对应着你要做什么、你希望对方怎么回复。服务器收到报文解析出方法和路径执行对应的业务逻辑查数据库、读文件、单纯算个结果然后生成一份响应报文返回。浏览器收到响应报文根据状态码判断这次请求是否成功再根据响应头里的Content-Type决定怎么处理响应体——是渲染成页面还是解析成JSON数据还是直接下载成文件。这套流程跟你在两台电脑之间手动发消息没什么本质区别你先说我要xxx对方回答好的给你或不行因为xxx。只是HTTP把说和答变成了固定格式的文本。2.2 请求方法不是花架子HTTP定义了一组请求方法本质上是动词代表客户端想让服务器做什么事。最常用的就这几个方法语义典型场景GET获取资源打开页面、拉取列表、加载图片POST提交数据/创建资源提交表单、发布文章、上传文件PUT整体替换资源更新一条完整记录PATCH部分更新资源只修改某个字段DELETE删除资源删除一条记录新手容易混的就是GET和POST。我见过很多同学把删除操作写成GET把查询操作写成POST功能上确实都能跑通但语义上很别扭。GET请求应该只做读取不应该对服务器数据产生修改因为GET可以被浏览器预加载、被搜索引擎爬取、被缓存在本地。如果你把删除操作放在GET里一个爬虫路过就可能把数据删了这个事故我亲眼见过。POST则相反它专门用来传输数据不适合暴露在URL里也不适合被缓存。2.3 白话讲无状态每次说HTTP无状态新手都会问既然无状态那登录状态是怎么保持的打个比方。HTTP协议就像一家快餐店服务员不记得你上次点了什么你每次来都要重新点一次餐。第一次来你说要一份汉堡服务员给你第二次你说要一份可乐服务员根本不知道你刚才吃过汉堡。服务器处理完一个请求就把这次对话忘得一干二净不会自动记住你的任何信息。那网站的登录状态怎么来的靠的不是HTTP记住你而是服务器和浏览器私底下约定好的一种凭证机制你登录成功后服务器发一个凭证就是Cookie里存的那个Session ID或者Token浏览器收下下次请求的时候主动把这个凭证带上。服务器看见凭证就知道哦这是之前登录过的那个用户。每次请求都带上凭证这件事恰恰证明了HTTP本身是不记得你的请求之间互相独立。这一点理解了你后面看Session、JWT、OAuth这些东西就会顺畅很多。3. 报文HTTP传输的真正实体报文是HTTP最核心的概念。浏览器和服务器之间传的不是抽象数据而是一段符合特定格式的文本。这段文本叫HTTP报文分请求报文和响应报文两类。3.1 请求报文的四段结构一个完整的请求报文长这样POST /api/login HTTP/1.1 Host: localhost:3000 Content-Type: application/json Content-Length: 27 Connection: keep-alive {username:admin,password:123456}四段结构拆开看请求行第一行包含方法、路径和协议版本。POST /api/login HTTP/1.1表示用POST方法请求/api/login协议版本是HTTP/1.1。请求头第二行到空行之前一组键值对每行一个告诉服务器各种附加信息。Host是目标主机Content-Type是请求体的格式Content-Length是请求体有多长Cookie是浏览器存的凭证User-Agent是浏览器身份标识。空行报文头结束的标志。别小看这一行解析器就是靠它区分头部和主体的。请求体空行之后实际的载荷数据。GET请求一般没有请求体POST、PUT这些方法才会有。用http模块写原生服务时你能最直观地体会到这个过程请求头一行一行解析出来放到req.headers对象里请求体通过监听req的data事件一段一段接收拼完之后才是完整文本。3.2 响应报文的四段结构响应报文的结构和请求报文类似也是四段HTTP/1.1 200 OK Content-Type: text/html; charsetutf-8 Content-Length: 13 Connection: keep-alive h1Hello/h1区别主要在起始行。请求报文的起始行是方法路径版本响应报文的起始行是版本状态码状态描述。HTTP/1.1 200 OK表示这是HTTP/1.1协议的响应状态码200意味着成功OK是状态码的英文描述。响应头里的Content-Type尤其重要。浏览器收到响应后会看这个字段决定怎么处理响应体。如果我返回的是text/html浏览器就把内容当HTML渲染如果是application/json浏览器就把内容当数据结构处理比如fetch的.json()方法。3.3 状态码服务器的三言两语状态码是服务器告诉客户端请求结果的方式一共五大类记住规律就行状态码范围含义常见例子100-199信息性响应100 Continue200-299成功200 OK、201 Created、204 No Content300-399重定向301 Moved Permanently、302 Found、304 Not Modified400-499客户端错误400 Bad Request、401 Unauthorized、403 Forbidden、404 Not Found500-599服务器错误500 Internal Server Error、503 Service Unavailable很多新手遇到404就只想到路径不存在其实404也可能是因为服务器没有设置对应的路由或者静态资源文件名写错了。看到403第一反应不该是路径对不对而应该是权限够不够。状态码是排查问题的最快线索养成看状态码的习惯能省十倍调试时间。304这个状态码值得单独说下。它表示资源没变直接用浏览器缓存的吧。浏览器缓存机制就是靠304 ETag或Last-Modified实现的服务器第一次返回资源时带上版本标识浏览器存起来下次请求时带上这个标识服务器一看没变就返回304和一个空响应体省了几百KB流量。原生服务里手动实现这套逻辑很繁琐但理解了之后再看框架里的配置就通透得多。4. 上手实战用原生http模块搭一个Web服务理论说了一大堆该动手了。这一步的目标是不用框架只用Node.js自带的http模块写一个能跑起来的服务然后逐步加功能在这个过程中把前文的理论对上号。4.1 最小可运行服务先来一个最简单的版本。新建一个文件叫server.js写上const http require(http); const server http.createServer((req, res) { res.writeHead(200, { Content-Type: text/plain; charsetutf-8 }); res.end(Hello, HTTP); }); server.listen(3000, () { console.log(Server running at http://localhost:3000); });在终端执行node server.js然后浏览器打开http://localhost:3000页面上会显示Hello, HTTP。就这几行代码一个完整的HTTP服务已经跑起来了。解读一下这段代码对应HTTP的哪些环节http.createServer传入的回调函数会在每次收到请求时被调用。回调的两个参数req和res就是请求报文的解析结果和响应报文的构建工具。req是个可读流上面挂着请求相关的所有信息方法、URL、请求头。res是个可写流你往里面写的内容会变成响应报文发送给客户端。server.listen(3000, callback)让服务器监听3000端口。端口可以随便换但注意别和其他进程冲突。这里有个细节值得注意回调函数里的req和res实际上就是Node对原始HTTP报文的一层封装。Node已经把报文解析好了把请求头放在req.headers对象里请求行解析成req.method、req.url这些属性。你再也不用自己一行一行去解析字符串了拿现成的就行。4.2 解析请求里的信息接着在上面的回调函数里加几行代码看看能拿到什么const server http.createServer((req, res) { console.log(请求方法, req.method); console.log(请求路径, req.url); console.log(请求头, req.headers); res.writeHead(200, { Content-Type: text/plain; charsetutf-8 }); res.end(Hello, HTTP); });保存后重启服务在浏览器里刷新页面终端会输出这次请求的方法、路径和一大堆请求头字段。你会发现光是浏览器自己带上的头就有Host、Connection、User-Agent、Accept、Accept-Encoding等等好大一串。req.url这个字段比较特殊。它是带查询参数的完整路径比如你访问http://localhost:3000/user?id1nameadminreq.url的值是/user?id1nameadmin。想拿到干净的路径和查询参数需要用Node内置的url模块解析const url require(url); const server http.createServer((req, res) { const parsedUrl new URL(req.url, http://localhost:3000); console.log(纯路径, parsedUrl.pathname); console.log(查询参数, parsedUrl.searchParams.get(id)); res.end(Hello); });new URL(req.url, 基准地址)会把相对路径和基准地址拼成完整URL然后就能用pathname和searchParams了。这个方法在平时用框架时都是被封装好的但这里自己解析一次理解就深了一层。4.3 根据路由返回不同内容原生服务没有路由但路由本质就是根据路径执行不同逻辑。手动写好判断即可const server http.createServer((req, res) { const { pathname } new URL(req.url, http://localhost:3000); if (pathname / || pathname /index.html) { res.writeHead(200, { Content-Type: text/html; charsetutf-8 }); res.end(h1首页/h1p欢迎来到Day 6/p); } else if (pathname /about) { res.writeHead(200, { Content-Type: text/html; charsetutf-8 }); res.end(h1关于/h1p这是一篇HTTP原理与Node原生服务的博客/p); } else if (pathname /api/users) { res.writeHead(200, { Content-Type: application/json; charsetutf-8 }); res.end(JSON.stringify([ { id: 1, name: 张三, role: admin }, { id: 2, name: 李四, role: user } ])); } else { res.writeHead(404, { Content-Type: text/plain; charsetutf-8 }); res.end(404 Not Found); } });在这个例子里/和/index.html返回HTML/api/users返回JSON其他路径统一返回404。注意响应头里的Content-Type不同浏览器处理方式就不同——这就是前面讲的响应报文的作用。JSON返回时用的是res.end(JSON.stringify(...))。JSON.stringify把JavaScript对象转成JSON字符串然后作为响应体发送。这步不写的话浏览器收到的是一个对象的内存表示无法正确解析。4.4 处理POST请求与请求体处理GET请求只需要看req.url和req.headers就够了但处理POST请求必须读取请求体。请求体是流式到达的不能指望一口气拿全。正确的写法是监听data事件一段一段收集end事件触发后再统一处理const server http.createServer((req, res) { const { pathname } new URL(req.url, http://localhost:3000); if (req.method POST pathname /api/users) { let body ; req.on(data, chunk { body chunk; }); req.on(end, () { console.log(收到的请求体原文, body); const parsed JSON.parse(body); console.log(解析后的对象, parsed); res.writeHead(201, { Content-Type: application/json; charsetutf-8 }); res.end(JSON.stringify({ code: 0, message: 用户创建成功, data: parsed })); }); } else { res.writeHead(404, { Content-Type: text/plain; charsetutf-8 }); res.end(Not Found); } });这段代码里有几个关键点req是流对象data事件会触发多次每次传来的chunk是Buffer或字符串片段。用body chunk拼接时要注意编码如果内容是UTF-8文本这里没问题如果是二进制文件需要用Buffer.concat来处理。req.on(end)在请求体全部接收完毕后触发此时body变量才是完整内容。解析JSON用JSON.parse。如果客户端发的不是合法JSON这里会直接抛异常让进程崩溃。真实项目中必须用try-catch包起来。响应状态码用201语义是资源创建成功比200更准确。用curl测试这个接口curl -X POST http://localhost:3000/api/users \ -H Content-Type: application/json \ -d {username:admin,password:123456}终端会输出收到的请求体浏览器或curl会收到刚才构造的JSON响应。整个流程走下来你对发送请求-读取请求体-返回响应这条链路就有了肌肉记忆。5. 从单文件到小项目补全真实服务的必需能力一个只返回固定字符串的服务没什么实际用途。真实场景里服务需要读取文件、处理静态资源、应对各种异常。这一节把这些必需能力一个个补上。5.1 搭建目录结构与静态资源服务先设计一下目录结构my-server/ ├── server.js ├── public/ │ ├── index.html │ ├── style.css │ └── app.js └── package.jsonpublic目录放静态文件。浏览器访问http://localhost:3000/index.html时服务器需要把public目录下的对应文件读出来加上正确的Content-Type返回给浏览器。先看一个能用的静态文件服务写法const http require(http); const fs require(fs); const path require(path); const server http.createServer((req, res) { const { pathname } new URL(req.url, http://localhost:3000); // 防止路径穿越攻击不能允许 .. 这种路径 const safePath path.normalize(pathname).replace(/^(\.\.[\/\\])/, ); const filePath path.join(__dirname, public, safePath); fs.readFile(filePath, (err, data) { if (err) { res.writeHead(404, { Content-Type: text/plain; charsetutf-8 }); res.end(404 Not Found); return; } // 根据扩展名设置Content-Type const ext path.extname(filePath).toLowerCase(); const contentType { .html: text/html; charsetutf-8, .css: text/css; charsetutf-8, .js: application/javascript; charsetutf-8, .json: application/json; charsetutf-8, .png: image/png, .jpg: image/jpeg }[ext] || application/octet-stream; res.writeHead(200, { Content-Type: contentType }); res.end(data); }); }); server.listen(3000);这段代码做了一件容易被忽略但很重要的事防止路径穿越。path.join(__dirname, public, safePath)会把用户访问的路径拼接到public目录下如果用户访问的是http://localhost:3000/../server.jsURL解析后的pathname会包含..直接拼接可能导致服务器源码被读取。path.normalize和正则替换是简单有效的防线。你可能会发现这段代码没有写任何业务逻辑只是把文件读出来返回。但这就够了——浏览器本身处理HTML、CSS、JS的能力已经很强大服务器只需要把正确文件找出来送回去。5.2 用path模块跨平台处理路径path模块是Node原生服务里不可或缺的工具。它的核心作用就是处理路径字符串而且能自动适配Windows和Linux的差异。几个高频方法path.join(public, index.html)拼接路径自动处理分隔符。在Linux上结果是public/index.html在Windows上结果是public\index.html。path.extname(index.html)获取扩展名结果是.html。path.basename(/a/b/c.html)获取文件名结果是c.html。path.dirname(/a/b/c.html)获取目录名结果是/a/b。path.normalize(/a//b/../c)规范化路径结果是/a/c。写原生服务时所有涉及文件路径的地方都用path模块处理别用手动拼字符串。手动拼分两种情况出问题一是跨平台分隔符不同二是一些特殊字符空格、中文处理困难。用path.join一次性解决。5.3 用nodemon让你免于手动重启的折磨写原生Node服务有个痛点每次改代码都要手动停掉进程再重新node server.js非常繁琐。我强烈建议你在这一天就把nodemon用起来。安装很简单npm install -g nodemon然后启动命令从node server.js改成nodemon server.js。之后每次保存代码进程会自动重启省掉大量重复操作。顺便说一句nodemon只是监测文件变化然后重启进程它本身不参与HTTP协议的任何一个环节属于纯开发工具。5.4 为什么原生服务仍然值得你亲手写一遍有个问题值得想清楚现在框架遍地都是Express的res.send()一行就能返回JSON为什么还要费劲手写res.writeHead和fs.readFile我的看法是原生写法是理解框架的基础也是排查问题的底牌。你用框架时看到一个报错说Content-Type不对如果你没亲手用res.writeHead设置过响应头你根本不知道这个字段是怎么产生的。框架的文档写得再好也只是文档自己亲手跑通一次才是理解。而且原生服务的性能并不差。http模块本身就是Node底层直接暴露出来的API框架在上层只是加了些语法糖。真到了需要极致调优的地步剥掉框架回到原生是常有的操作。6. 实战中必踩的坑问题、现象与排查方法写原生HTTP服务一定会踩坑。有些坑我当时花了很久才搞明白原因现在整理成速查表希望能帮你跳过这些弯路。6.1 中文乱码问题现象页面或接口返回的中文变成宿–¹之类的一堆乱码。原因很明确响应头里的Content-Type没有带上charsetutf-8。浏览器接收到响应后不知道用什么编码去解码只好按默认方式处理就容易乱码。解决方式写响应头时永远带上字符集res.writeHead(200, { Content-Type: text/html; charsetutf-8 }); res.writeHead(200, { Content-Type: application/json; charsetutf-8 });还有另一种情况文件本身是UTF-8编码但读文件时没指定编码用fs.readFile拿到的就是Buffer而非字符串。此时用fs.readFile(filePath, utf8, callback)或者data.toString(utf8)处理。6.2 POST请求的请求体拿不到现象监听req.on(data)后没有触发或者body一直是空字符串。原因一般出在请求客户端那边。最常见的是用curl测试时忘了加-d参数或者用Postman时没选择body类型。GET请求本身就没有请求体所以监听data事件永远不会触发这也是正常现象。调试技巧在req.on(end)里打印body同时在req.on(data)里打印chunk.toString()看数据到底有没有到达。如果data事件确实没触发检查请求头里的Content-Length是否为0是否为GET请求。6.3 读文件时的路径错误现象fs.readFile报ENOENT说找不到文件。原因大概率是相对路径基准不对。fs.readFile的相对路径是相对于进程启动时所在的目录不是你代码文件所在的目录。如果你在项目根目录执行node src/server.js代码里写fs.readFile(public/index.html)它会去项目根目录/public/index.html找文件而不是src/public/index.html。解决方式是永远基于__dirname拼路径const filePath path.join(__dirname, public, index.html);__dirname是当前模块所在目录的绝对路径跟在哪里启动进程无关。用这个基准拼路径永远不会有偏差。6.4 端口被占用现象启动时报EADDRINUSE然后进程直接退出。原因是3000端口已经被别的进程占用了。解决方式有几种换一个端口比如3001。找到占用进程并终止它。Mac/Linux用lsof -i :3000查出进程PID再kill PIDWindows用netstat -ano | findstr 3000查PID再在任务管理器结束进程。让Node自动选择可用端口监听端口传0就行但要配合server.address().port来获知实际端口比较绕平时用得不多。6.5 浏览器缓存导致的改动没生效现象改了CSS或HTML刷新页面还是旧版本强刷CtrlShiftR才正常。原因浏览器认为资源没变直接用了缓存。这是HTTP缓存机制在起作用不是服务端Bug。开发阶段有两个应对方式设置响应头Cache-Control: no-cache告诉浏览器每次都要向服务器确认。文件名加上版本号或hash比如style.css?v20250101。原生服务里设置第一个很简单res.writeHead(200, { Content-Type: text/css; charsetutf-8, Cache-Control: no-cache });6.6 JSON.parse炸了导致进程崩溃现象POST请求收到非法JSONJSON.parse(body)直接抛错整个Node进程退出了。原因很直接没做异常处理。正确写法req.on(end, () { try { const parsed JSON.parse(body); res.writeHead(201, { Content-Type: application/json; charsetutf-8 }); res.end(JSON.stringify({ ok: true, data: parsed })); } catch (err) { res.writeHead(400, { Content-Type: application/json; charsetutf-8 }); res.end(JSON.stringify({ ok: false, message: 请求体不是合法JSON })); } });try-catch包住JSON.parse解析不了就返回400状态码告诉客户端你发的数据有问题而不是让服务器直接挂掉。6.7 关于Connection: keep-alive的问题刚接触时看到浏览器请求头里有Connection: keep-alive以为是保持登录状态其实是完全不同的东西。keep-alive是TCP连接层面的事表示这次请求结束后TCP连接不要断开后面还可以复用。多请求共享一个TCP连接能省掉反复建连的开销对性能影响很大。HTTP/1.1默认开启keep-aliveHTTP/2更进一步同一个连接能同时承载多个并发请求。这在原生服务里不用你刻意去管Node默认处理了但理解它的含义对看性能指标有用。7. 关于这一天的经验沉淀Day 6 的内容在整条学习线里核心不是能写一个多复杂的功能而是要建立三个层面的认知第一层HTTP的报文结构、方法和状态码这是你之后看任何网络相关文档的基础词汇。状态码200、301、404、500响应头里的Content-Type、Cache-Control、Set-Cookie这些东西在框架里被简化了但底层逻辑永远来自HTTP协议本身。第二层Node原生服务的运行模型事件循环、回调、流式读取这些是Node最核心的运行时概念。你在这里手动处理了一次req.on(data)后面再接触Express的body-parser、Koa的ctx.request.body就会知道框架只是替你把流拼接和解析做了。第三层也是我作为过来人最想提醒的一点框架是工具原理是内功。写原生服务不一定是为了生产部署更多是为了让你在遇到诡异问题时不慌。你能在原生层操作就说明你有能力在框架层调试你能解析报文就说明你能看懂任何语言任何框架下的网络数据流。我自己的经历是这样的学完原生HTTP服务之后再回去用框架很多以前觉得框架黑魔法的功能比如路由匹配、中间件、静态资源服务都变成了透明的操作。不用再背配置也不用死记API上手新框架的速度明显变快了。如果你在跟着做今天就把server.js里的代码拆开改一改试试路由里区分GET和POST试试返回一个HTML文件试试用curl带参数请求你的/api/users接口。多折腾几种场景比只看文章有用得多。把这几段代码跑通之后HTTP原理和Node原生服务这条线就算真正打通了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询