写在前面
大家好,我是一溪风月,一名前端程序员,这篇文章我们将讲解Web服务器端具体的内容,这些内容我们将对Node中原生api对服务器开发的方式,虽然在实际的开发中我们可能会使用框架来开发,但是实际上在框架的底层也是这样进行操作的,所以学习这些内容有利于我们理解框架的运行方式,这篇文章的内容会按照如下的思维导图来划分,好了废话不多说了,让我们开始吧!
一.认识Stream
事实上Node中的很多对象都是基于流实现的,比如http模块的request
和response
那么什么是流哪? 我们对流的第一反应就是流水,源源不断的流动,事实上程序中的流也是类似的含义,我们可以想象当从一个文件中读取数据的时候,二进制的数据被源源不断的读取到了我们的程序中,而这一连串的字节就是我们程序中的流,说到这里是不是大脑中会有如下的一个场景?
所以我们可以这样理解流,是连续字节的一种表现形式和抽象概念,流是可读的
也是可写的
在我们之前文件读写的时候,我们可以直接通过readFile
和writeFile
方式读取文件,为什还需要流哪?
因为直接读取文件的方式,虽然简单,但是无法对一些细节进行控制,比如从某个位置开始读,读到什么位置,一次性读取多少个字节,读到某个位置后,暂停读取,某个时刻恢复读取等等,或者这个文件比较大,比如一个视频文件,并不适合一次性进行读取。
二.文件读取的Stream
官方文档中指明,所有的流都是EventEmitter
的实例。
那么在Node中都有哪些流哪? Node.js
中有四种基本流类型:
流类型 | 描述 | 示例 |
---|---|---|
Writable | 可写入数据的流 | fs.createWriteStream() |
Readable | 可读取数据的流 | fs.createReadStream() |
Duplex | 同时具备可读和可写功能 | net.Socket |
Transform | 在读写数据时可修改或转换数据的Duplex流 | zlib.createDeflate() |
在日常中使用最频繁的是Readable
和Writeable
其他两个有需要再自行学习。
三.Readable
在之前的文章中我们读取一个文件信息,我们会通过这个方式来读取。
js
const fs = require("fs")
fs.readFile("./foo.txt", (err, data) => {
if (err) {
console.log(err)
} else {
console.log(data)
}
})
这种方式是一次性将所有的内容都读取到程序内,但是这种读取方式存在很多问题,我们已经提到了,文件过大,读取的位置,结束的位置,一次读取的大小,这个问题我们需要使用createReadStream
来进行处理,他们有如下几个参数:
- start:文件读取的开始位置。
- end:文件读取的结束位置。
- highWaterMark:一次性读取字节的长度,默认是64Kb。
然后我们来进行Readable
的使用和创建
js
const fs = require("fs")
const read = fs.createReadStream("./foo.txt", {
start: 3,
end: 8,
highWaterMark: 4
})
//
获取到上述的内容可以使用以下的方式
js
read.on("data", (data) => {
console.log(data)
})
也可以做一些其他的操作:监听其他事件,暂停或者恢复
js
// 监听到文件打开
read.on("open", (fd) => {
console.log(fd)
})
// 文件读取结束
read.on("end", (fd) => {
console.log("文件读取结束")
})
// 文件被关闭
read.on("close", (fd) => {
console.log("文件被关闭")
})
// 读取暂停
read.pause()
setTimeout(() => {
// 读取恢复
read.resume()
}, 3000)
四.Writable
在之前的文件中我们编写的写入文件的方式是这样的
js
const fs = require("fs")
const content = "这是一个内容!"
fs.writeFile("./foo.txt", content, ((err, data) => {
if (err) {
console.log("文件写入发生错误!")
}
console.log(data)
}))
这种方式其实跟读取是一样的,也是一次性将内容写入到了文件中,对文件写入如果使用流的话就可以使用createWriteStream
它有以下几个常见的参数。
- flags:默认是w,如果我们希望追加写入,可以使用
a
或者a+
- start:写入的位置。
然后我们来使用Writeable
来进行一次简单的写入
js
const fs = require("fs")
const writStream = fs.createWriteStream("./foo.txt", {
flags: 'a+'
})
writStream.write("Mongo")
五.close的监听
我们在读取和写入的时候是监听不到close
事件的,这是因为写入流在打开后是不会自动关闭的,我们必须手动关闭,来告诉Node已经写入结束了,并且发出一个finish
事件的。
除了close
方法还是end
方法,end
方法相当于做了两个操作:write传入的数据和调用close
方法。
js
// close
writer.on("close",()=>{
console.log("文件关闭")
})
// end
writer.end("HelloWorld")
六.pipe方法
正常情况下,我们可以将读取到的输入流,手动的放到输出流进行写入。
js
// 不用pipe的情况
const fs = require("fs")
const reader = fs.createReadStream("./foo.txt")
const writer = fs.createWriteStream("./bar.txt")
reader.on("data", (data) => {
console.log(data)
writer.write(data, (err) => {
console.log(err)
})
})
js
// 使用pipe的情况
const fs = require("fs")
const reader = fs.createReadStream("./foo.txt")
const writer = fs.createWriteStream("./bar.txt")
reader.pipe(writer)
七.Web服务器
日常进行前端开发的你是否听说过Web服务器的概念,那么什么是Web服务器哪?当应用程序(客户端)需要某一个资源时,可以向一台服务器,通过http
请求获取到这个资源,提供这个资源的服务器就是Web服务器
目前有很多开源的Web服务器:Nginx,Apache(静态),Apache Tomcat(静态,动态),Node.js等。
八.http模块
在Node中,提供web服务器的资源返回给浏览器,主要通过http
模块,我们来进行简单的使用
js
const http = require("http")
// 创建一个http服务器
const PORT = 3000
const server = http.createServer()
// 开启对应的服务器,并告知端口
// 监听端口监听1024以上~65535以下的端口,1024以下是特殊服务的端口
server.listen(PORT, () => {
console.log(`服务器开启在${PORT}端口~`)
})
为什么需要指定不同的端口哪?因为可能不同的端口会指定不同的服务如下图。
我们在创建http
服务的时候使用的是createServer
来完成的http.createServer
会返回服务器的对象,底层其实是直接进行了new Server
对象。
js
function createServer(opts,requestListener){
return new Server(opts,requestListener)
}
当然,我们也可以自己来创建这个对象。
js
const server2 = new http.Server((req,res)=>{
res.end("Hello Server2");
});
server2.listen(9000,()=>{
console.log("服务器启动成功~")
})
💡Tips:127.0.0.1:回环地址,表达的意思其实是我们主机自己发出去的包,直接被自己接收;
我们会发现每次我们修改代码都需要重新进行代码的执行来启动服务,所以我们可以使用社区的工具来做这个事情
js
npm install nodemon
然后我们就可以直接使用nodemon
来进行启动,可以进行服务的自动热重启。
九.request对象
在向服务器发送请求的时候,我们会携带很多数据信息,比如:
- 本次请求的URL,服务器需要根据不同的URL进行不同的处理。
- 本次请求的请求方式:比如GET,POST,请求传入的参数和处理的方式是不同的
- 本次请求的headers中也会携带一些信息,比如客户端信息,接受数据的格式,支持的编码格式等。
这些信息,Node会帮助我们封装📦到一个request
的对象中,我们可以直接处理这个request对象。
js
const http = require("http")
// 创建一个http服务器
const PORT = 3000
const server = http.createServer((req,res)=>{
console.log(req.url);
console.log(req.method);
console.log(req.headers);
})
// 开启对应的服务器,并告知端口
// 监听端口监听1024以上~65535以下的端口,1024以下是特殊服务的端口
server.listen(PORT, () => {
console.log(`服务器开启在${PORT}端口~`)
})
十.URL的处理
客户端在发送请求的时候,会请求不同的数据,那么会传入不同的请求地址,服务器需要根据不同的请求地址,做出不同的响应。
js
const http = require("http")
// 创建一个http服务器
const PORT = 8000
const server = http.createServer((req, res) => {
let url = req.url;
let method = req.method;
if (url === '/home') {
res.end("你好啊~")
}
if (url === "/login") {
res.end("登录成功!")
}
})
// 开启对应的服务器,并告知端口
// 监听端口监听1024以上~65535以下的端口,1024以下是特殊服务的端口
server.listen(PORT, () => {
console.log(`服务器开启在${PORT}端口~`)
})
十一.URL的解析
如果客户的地址中还携带了一些额外的参数哪?
js
http://localhost:8000/login?name=why&password=123
这个时候url
的值是/login?name=why&password=123
那么我们该如何对它进行解析哪?使用内置的url
js
let urlPath = req.url
let urlInfo = url.parse(urlPath)
console.log(urlInfo.query, urlInfo.pathname)
但是query
如何可以获取哪进行解析哪?我们可以使用queryString
模块来处理解析。
js
const http = require("http")
const url = require("url")
const qs = require("querystring")
// 创建一个http服务器
const PORT = 8000
const server = http.createServer((req, res) => {
let urlPath = req.url
let urlInfo = url.parse(urlPath)
let queryObj = qs.parse(urlInfo.query)
console.log(queryObj)
})
// 开启对应的服务器,并告知端口
// 监听端口监听1024以上~65535以下的端口,1024以下是特殊服务的端口
server.listen(PORT, () => {
console.log(`服务器开启在${PORT}端口~`)
})
// 发送请求就可以获取到对象 [Object: null prototype] { name: 'why', age: '12' }
十二.method的解析
在restful接口规范中我们对数据的增删改查可以通过不同的请求方式:
GET:查询数据
POST:新建数据
PATCH:更新数据
DELETE:删除数据
我们可以通过不同的方式来进行不同的增删改查的操作,在代码中我们可以通过这种方式来获取method
js
let method = req.method;
十三.body的解析
了解和使用了query
参数之后我们还需要获取下body
参数进行解析,在进行body
解析的时候我们还需要了解一个知识,如果我们直接通过req
来获取body
其实是根本获取不到的,因为根本没有这个参数,想要获取body
需要了解到我们前面讲解的可读流的知识,因为request
本质上是一个readable
可读流,我们可以通过如下的这种方式来解析和处理。
js
const http = require("http")
const server = http.createServer((req, res) => {
req.setEncoding("utf-8")
let isLogin = false
req.on("data", (res) => {
console.log(res)
const dataString = res
const loginInfo = JSON.parse(dataString)
if (loginInfo.name === "zzz" && loginInfo.age === 12) {
isLogin = true
} else {
isLogin = false
}
})
req.on("end", () => {
if (isLogin) {
res.end("登录成功,欢迎回来~")
}
})
})
server.listen(8000, () => {
console.log("服务器启动在8000端口")
})
十四.header的解析
在request
对象的header中也包含很多有用的信息,客户端会默认传递过来一些信息。
- content-type :表示这次请求携带的数据的类型,常见的值有
application/x-www-form-urlencoded
(数据被编码成以&
分隔的键 - 值对,同时以=
分隔键和值)、application/json
(表示是一个json类型)、text/plain
(表示是文本类型)、application/xml
(表示是xml类型)、multipart/form-data
(表示是上传文件)。 - content-length:表示文件的大小长度。
- keep-alive :HTTP是基于TCP协议的,通常一次请求和响应结束后会立刻中断连接。在HTTP1.0中,如果想要保持连接,浏览器需要在请求头中添加
connection: keep-alive
,服务器需要在响应头中添加connection: keep-alive
,当客户端再次发送请求时,就会使用同一个连接,直到一方中断连接;在HTTP1.1中,所有连接默认是connection: keep-alive
的,不同的Web服务器保持keep-alive
的时间不同,Node中默认是5秒。 - accept-encoding :告知服务器客户端支持的文件压缩格式,如js文件可以使用gzip编码,对应
.gz
文件。 - accept:告知服务器客户端可接受文件的格式类型。
- user-agent:包含客户端相关的信息。
十五.返回响应结果
向客户端返回响应结果有两种常用方式:
- write方法 :直接写出数据,但不会关闭流,可以多次调用
write
方法连续写入数据。例如:
js
res.write("Hello World");
res.write("Hello Response");
- end方法 :写出最后的数据,并在写出后关闭流。通常在所有数据都写入完成后调用
end
方法,例如:
js
res.end("message end");
如果没有调用end
或close
方法,客户端将会一直等待结果,所以客户端在发送网络请求时,都会设置超时时间。
返回状态码与响应头文件
HTTP状态码(HTTP Status Code)用于表示HTTP响应状态,常见的状态码如下:
状态码 | 状态描述 | 信息说明 |
---|---|---|
200 OK | 客户端请求成功 | 表示请求正常处理并成功返回数据 |
201 Created | POST请求,创建新的资源 | 用于表示通过POST请求成功创建了新资源 |
301 Moved Permanently | 请求资源的URL已经修改,响应中会给出新的URL | 用于重定向,告知客户端资源的新地址 |
400 Bad Request | 客户端的错误,服务器无法或者不进行处理 | 表示客户端请求存在语法错误或其他问题 |
401 Unauthorized | 未授权的错误,必须携带请求的身份信息 | 表示客户端未提供有效的身份验证信息 |
403 Forbidden | 客户端没有权限访问,被拒接 | 表示客户端有权限访问,但被服务器拒绝 |
404 Not Found | 服务器找不到请求的资源 | 表示请求的资源在服务器上不存在 |
500 Internal Server Error | 服务器遇到了不知道如何处理的情况 | 表示服务器内部发生错误 |
503 Service Unavailable | 服务器不可用,可能处理维护或者重载状态,暂时无法访问 | 表示服务器当前无法处理请求 |
设置响应状态码可以这样操作:
js
res.statusCode = 400;
res.writeHead(200);
返回头部信息主要有两种方式:
- res.setHeader:一次写入一个头部信息,例如:
js
res.setHeader("Content-Type", "application/json; charset=utf8");
- res.writeHead:同时写入header和status,例如:
js
res.writeHead(200, {
"Content-Type": "application/json; charset=utf8"
});
设置Content-Type
的作用是告知客户端接收到的数据类型,这样客户端可以按照正确的方式进行处理。如果不设置,默认客户端接收到的是字符串,客户端会按照自己默认的方式处理数据,可能导致数据解析错误。
十六.axios库在Node中的使用
axios库既可以在浏览器中使用,也可以在Node中使用。在Node中,axios使用的是http内置模块。使用axios发送HTTP请求示例如下:
js
const http = require('http');
const axios = require('axios');
// GET请求
axios.get("http://localhost:8000").then(response => {
console.log(response.data);
}).catch(error => {
console.error(error);
});
// POST请求
axios.post("http://localhost:8000", {
username: "test",
password: "123"
}).then(response => {
console.log(response.data);
}).catch(error => {
console.error(error);
});
上述代码中,分别展示了使用axios发送GET
和POST
请求的方式,通过then
方法处理成功响应,通过catch
方法处理错误。
十七.文件上传的实现(了解即可)
文件上传的错误示范
在处理文件上传时,如果直接使用以下方式可能会出现问题:
js
const fs = require('fs');
const http = require('http');
const server = http.createServer((req, res) => {
const filewriter = fs.createWriteStream('./foo.png');
req.pipe(filewriter);
const fileSize = req.headers['content-Length'];
let curSize = 0;
console.log(fileSize);
req.on("data", (data) => {
curSize += data.length;
console.log(curSize);
res.write(`文件上传进度: ${curSize / fileSize * 100}%\n`);
});
req.on('end', () => {
res.end("文件上传完成~");
});
});
server.listen(8000, () => {
console.log("服务器启动成功~");
});
这种方式的问题在于,没有正确处理文件上传的边界情况和数据格式。对于大文件上传,可能会导致内存占用过高,并且无法准确解析文件内容。
文件上传的正确做法
正确的文件上传实现需要考虑多个方面,以下是详细步骤:
- 设置编码与获取边界值 :首先,需要将请求设置为二进制编码,并从
content-type
中获取boundary
的值。
js
req.setEncoding('binary');
var boundary = req.headers['content-type'].split(";")[1].replace('boundary=', '');
- 记录数据与监听数据 :记录文件大小和当前已接收数据的大小,通过监听
data
事件获取上传的数据。
js
const fileSize = req.headers['content-length'];
let curSize = 0;
let body = '';
req.on("data", (data) => {
curSize += data.length;
res.write(`文件上传进度: ${curSize / fileSize * 100}%\n`);
body += data
})
js
// 数据结构
req.on('end', () => {
// 切割数据
const payload = qs.parse(body, "\r\n", ":");
// 获取最后的类型(image/png)
const fileType = payload["Content-Type"].substring(1);
// 获取要截取的长度
const fileTypePosition = body.indexOf(fileType) + fileType.length;
let binaryData = body.substring(fileTypePosition);
binaryData = binaryData.replace(/^\s\s*/, '');
// binaryData = binaryData.replaceAll('\r\n','');
const finalData = binaryData.substring(0, binaryData.indexOf('--'+boundary+'--'));
fs.writeFile('./boo.png', finalData, 'binary', (err) => {
console.log(err);
res.end("文件上传完成~");
});
});
十八.总结
本文聚焦 Node.js 的 Web 服务器开发与文件上传技术。先介绍 Stream,它是连续字节的抽象,有可读、可写特性,弥补传统文件读写不足,包含 Writable、Readable 等四种类型 。接着阐述 Web 服务器相关知识,如 http 模块创建服务器、request 和 response 对象处理,以及 URL、method、请求头处理和状态码、响应头设置。还讲解了 axios 库在 Node 中的使用。最后探讨文件上传,指出错误示范问题,给出正确做法,包括设置编码、获取边界值、监听数据等步骤。