Node.js基础
https://nodejs.org/dist/latest/docs/api/
Node.js是一个基于Chrome V8引擎的JS运行环境,它采用了单线程,事件驱动、非阻塞式的I/O模型,尤其适合构建I/O密集型的高性能服务端,如今整个前端生态的工具链都构建在Node.js的基础之上,是往全栈工程师进阶的必备技能,掌握Node.js之后你可以:
- 编写高性能服务端程序
- 开发命令行工具
- 编写爬虫程序
- 通过Electron之类的框架编写PC客户端程序
这节课我们主要学习使用Node.js进行文件操作和HTTP网络编程,这些内容需要了解一些计算机原理的底层知识。
需要注意,虽然都是JS代码,但是Node.js提供的API并不能用于浏览器环境,反过来也是一样,初学者很容易搞混他们的差别。
使用TypeScript编写Node.js程序
使用TS来编写Node.js程序的关键是安装对应的类型提示模块,我们首先通过 npm init 初始化一个空的npm工程,然后通过下面的命令来安装类型依赖
npm i @types/node --save-dev
@types/node 就是Node.js的类型提示模块,安装之后TS就可以识别Node.js中的API和数据类型了。然后我们需要创建一个 tsconfig.json 文件,用来告诉TS如何编译我们的代码,最关键的一个选项是将模块类型转换为 commonjs ,如下是一个非常基本的配置选项。
{
"compilerOptions": {
"module": "commonjs",
"target": "esnext",
"skipLibCheck": true,
"sourceMap": true,
"outDir": "dist"
},
"include": [
"src/**/*"
]
}
因为历史原因,Node.js最早是支持 CommonJS 模块规范的,后来随着 ES Modules 的发展Node.js也提供了支持,但是目前两者的兼容还存在很多问题,为了更好的兼容性,我们建议目前还是转换成 CommonJS 模块来执行,当然,TS会帮我们自动完成这个工作,我们还是直接使用 ES Modules 即可。
完成上面的配置后,我们可以把TS源代码放在 src 目录中,通过执行 tsc 命令即可编译输出JS文件到 dist 目录。
Buffer
计算机中数据的存储和传输都是通过二进制数据的形式进行的,最小存储单位是字节 byte ,而每个 byte 又由8个二进制位 bit 表示,也就是说每个字节 byte 有 2^8=256 种可能的值(0~255)。Node.js中的 Buffer 表示的就是一段连续的二进制数据字节序列,Node.js中很多API都支持Buffer,我们最常用的就是使用Buffer进行编码的转换,比如我们读取的文本文件内容默认是Buffer类型,也就是一段二进制数据,然后我们可以使用Buffer的 toString 方法将其转换成 utf-8 编码,这样就可以拿到可读的文本内容了。
Buffer类位于全局作用域中,不需要通过 import 引入就可直接使用。
let str = '十'
let buf_1 = Buffer.from(str, 'utf-8')
console.log(buf_1)
console.log(buf_1.byteLength, str.length)
let buf_2 = Buffer.from('a', 'utf-8')
console.log(buf_2)
let buf = Buffer.from([0xe5, 0x8d, 0x81])
console.log(buf.toString('utf-8'))
console.log(buf.toString('base64'))
console.log(buf.toString('hex'))
let str = Buffer.from('5Y2B', 'base64').toString('utf-8')
console.log(str)
let buf_1 = Buffer.from([0xe5, 0x8d, 0x81])
let buf_2 = Buffer.from([0xe4, 0xb8, 0x80])
let buf = Buffer.concat([buf_1, buf_2])
console.log(buf.toString('utf-8'))
path
path模块提供了用来处理文件路径的实用工具,因为不同操作系统的路径规则不同,比如Windows上面路径采用 \\ 分隔,通常路径是这种格式:
D:\\www\\task\\index.html
但Linux采用 / 分隔,如:
/root/www/task/index.html
path 提供了一致的API,可以帮我们屏蔽这些差异,要使用 path 的API我们只需要这样引入即可:
import * as path from 'path'
basename
可以返回路径的最后一部分,用来获取文件名
import * as path from 'path'
path.basename('D:\\www\\task\\index.html')
dirname
用来返回路径的目录名
import * as path from 'path'
path.dirname('D:\\www\\task\\index.html')
extname
用来返回路径的扩展名
import * as path from 'path'
path.extname('D:\\www\\task\\index.html')
resolve
用来将给定的路径片段转换为绝对路径
import * as path from 'path'
path.resolve('/index.html')
path.resolve('index.html')
join
该方法可以合并多个路径片段,在Node.js的CommonJS模块代码中,有两个特殊的路径变量
__dirname :当前代码文件所在目录的绝对路径__filename :当前代码文件的绝对路径
import * as path from 'path'
path.join('D:\\www', 'task', 'index.html')
path.join(__dirname, 'test.js')
fs
fs模块提供了操作本地文件的能力,是我们最常用的功能之一,fs中的绝大多数API都有三种版本,分别为
异步非阻塞版本
这种版本的API异步非阻塞,在执行的时候不会阻塞线程,但是需要通过回调函数才能拿到返回结果,例如
import * as fs from 'fs'
fs.readFile('data.txt', 'utf-8', (err, data) => {
if (err) {
console.error('文件读取失败')
} else {
console.log(data)
}
})
同步阻塞版本
这种版本的API在执行的时候会阻塞线程,需要等它执行完才能处理其他任务,所以一般在服务端开发中我们都会禁止使用这种同步IO的API,同步版本的API名字和异步版本的类似,会在末尾加上 Sync ,如
import * as fs from 'fs'
try {
let data = fs.readFileSync('data.txt', 'utf-8')
console.log(data)
} catch (err) {
console.error('文件读取失败')
}
Promise版本
Promise版本的API是Node.js新加入的特性,它相当于是对异步非阻塞版本API的Promise化,配合async/await,既可以让我们以串行的方式写代码,又可以避免IO阻塞线程,通过 fs.promises 可以访问到这些API
async function run() {
try {
let data = await fs.promises.readFile('data.txt', 'uttf-8')
console.log(data)
} catch (err) {
console.error('文件读取失败')
}
}
需要注意,在实际项目中文件操作时最好传入文件的绝对路径,如果传入的是相对路径,那么得到的是相对于当前工作目录的路径,而不是相对于代码文件的路径,这个和模块加载的相对路径规则不同。
假如有代码 D:\\www\\node\\src\\fs.js ,内容如下
import * as fs from 'fs'
let data = fs.readFileSync('../data/news.json', 'utf-8')
console.log(data)
我们在读取文件的时候传入了一个相对路径,本来的期望是读取 D:\\www\\node\\data\\news.json 这个文件,但是这个相对路径是相对于我们的工作目录的,而不是代码文件自身,所以如果我们在 D:\\www\\node\\src\\ 目录下面执行 node fs.js 那么得到的将会是正确的结果,如果我们在 D:\\www\\node\\ 目录下面执行,则文件路径变成了 D:\\www\\data\\news.json ,读取出错,所以一定要使用绝对路径来读取,这样就不会受工作目录的影响,如
import * as fs from 'fs'
import * as path from 'path'
let data = fs.readFileSync(path.join(__dirname, '../data/news.json'), 'utf-8')
console.log(data)
通过 path.join 将 __dirname 和目标文件的相对路径连接起来就可以得到目标文件的绝对路径了,同样的道理,其他的文件操作API也需要用这种方式
readFile
该方法可以读取整个文件的内容到内存中,通常用来直接处理一些比较小的文本文件,具体用法参考上面的例子
writeFile
该方法可以将数据写入到磁盘文件,写入的内容可以是字符串文本,也可以是Buffer二进制序列
import * as fs from 'fs'
fs.writeFile('./data.txt', 'hello', err => {
if (err) {
console.error('文件写入失败')
} else {
console.log('文件写入成功')
}
})
stat
该方法可以用来获取文件的一些属性
import * as fs from 'fs'
let stats = fs.statSync(file)
console.log(stats.size)
console.log(stats.mtimeMs)
console.log(stats.isDirectory())
readdir
该方法可以读取指定目录下的所有文件和目录名
import * as fs from 'fs'
let dirs = fs.readdirSync('./')
console.log(dirs)
events
Node.js的一大特点就是事件驱动,事件的发布订阅也是经典的设计模式,通过events模块中的 EventEmitter 我们可以轻松实现这个功能,Node.js中的很多对象也都是 EventEmitter 的示例
import { EventEmitter } from 'events'
class EventBus extends EventEmitter {}
let eventBus = new EventBus()
eventBus.on('custom', args => {
console.log(args)
})
eventBus.emit('custom', 'custom event data')
stream
stream(流)是Node.js中处理流式数据的抽象接口,Node.js中有很多流对象,最常见的比如文件读写流,HTTP请求、响应流等等,stream是Node.js中IO的精髓,通常用来处理大规模的流式数据。
让我们来看这样一个场景,假如我们通过Node.js创建了一个HTTP服务,用户可以通过这个服务下载服务器上的文件,你可能会使用前面讲到的 fs.readFile 来读取整个文件,然后将数据写入到HTTP返回流中。对于一些小文件这样做可能没有太大的问题,但是如果用户要下载的是一个超大的文件,这就会带来一些严重问题:
- 直接读取整个文件会占用很多内存
- 如果用户的带宽很小,被写入到HTTP返回流中的数据需要很长时间才能被读取完,没有被读取的数据会一直缓冲在服务器内存中
- 如果很多用户同时来下载,服务器内存很快就会被耗尽
如果使用stream则会是这样的流程:
- 创建一个文件读取流,开辟一块固定的内存缓冲区(比如64KB),读取文件内容填充缓冲区,等待数据被消费
- 将缓冲区的数据写入HTTP返回流,缓冲区的数据被读取完毕之后,继续读取文件数据填充缓冲区
- 重复前面的流程直到文件传输完毕
这样一来我们就不需要担心内存耗尽的问题了,因为stream只会占用缓冲区的内存大小。
stream也有多种类型,我们最常见的主要是
- readableStream:可读取数据的流,比如文件读取流、HTTP请求流
- writableStream:可写数据的流,比如文件写入流、HTTP返回流
stream还提供了管道(pipe)API,可以将一个读取流和一个写入流连接起来,这样它就会自动完成前面stream读取的控制过程,非常方便,示例如下,通过stream接口来实现大文件的复制
let readable = fs.createReadStream('./data.bin')
let writable = fs.createWriteStream('./target.bin')
readable.pipe(writable)
writable.on('finish', () => {
console.log('文件写入完毕')
})
http
http模块提供了创建http服务端和客户端的能力,http提供的API是比较底层的,它只进行流处理和消息解析。
server
通过http模块创建一个服务端是非常简单的
import * as http from 'http'
const server = http.createServer((req, res) => {
res.end('hello')
})
server.listen(3000)
通过上面的代码,Node.js会创建一个http服务,并且监听3000端口,当我们在浏览器访问 http://localhost:3000/ 时,可以看到服务端返回文字 hello 。
createServer 后面是一个回调函数,每次有新的请求到达就会触发该函数的调用,其中 req 是Node.js解析的请求对象,它是一个可读流,里面包含了请求头信息、请求体数据流等,res 是返回对象,它是一个可写流,可以用来设定返回状态码、响应头,也可以往返回流里面写入数据。
createServer 创建的服务端功能比较基础,我们需要自己去实现路由、body的解析等功能,所以在实际开发中我们通常会使用一些框架,它们封装了更强大的功能,开发起来更方便,后面我们会介绍Koa框架。
client
同服务端一样,http提供的客户端功能也比较底层,这里我们推荐一个官方新推出的库 undici ,https://undici.nodejs.org/
它的用法也非常的简单,参考我们的示例代码。
MongoDB
MongoDB是一种文档数据库,他和传统的SQL数据库不同,MongoDB没有表结构,每个集合里面保存的是一条一条的BSON数据,这是一种类似于JSON的数据结构,但是类型比JSON更丰富。MongoDB非常灵活,BSON的数据结构又和Node.js天然接近,两者结合开发效率非常的高。
MongoDB 包含了多个工具,其中最核心的是
mongod :MongoDB的服务端程序,通过它可以启动MongoDB的的服务实例mongo :MongoDB的客户端程序,通过它可以连接MongoDB服务,执行命令进行查询或者数据的修改
MongoDB的存储结构可以分为库 -> 集合 -> 文档,我们来看一些基本操作
列出所有数据库
show databases;
切换数据库
use testdb;
查看当前数据库所有集合
show collections;
插入数据
db.users.insertOne({name: 'Tom', age: 24})
当我们往数据库中插入数据时,如果目标数据库或者集合不存在的话,MongoDB会自动创建。默认情况下MongoDB会给插入的每一条记录创建一个类型为 ObjectId 的属性 _id ,它是全局唯一的,所以上面插入的数据在MongoBD中会是这个样子:
{
"_id": ObjectId("603cee6abd814d23c09912f5"),
"name": "Tom",
"age": 24
}
查询数据
db.users.find()
db.users.findOne({_id: ObjectId("603cee6abd814d23c09912f5")})
更新数据
db.users.update({_id: ObjectId("603cee6abd814d23c09917f5")}, {$set: { age: NumberInt(18) }})
这里需要特别注意,update默认会替换掉整个文档,如果只想修改部分属性,需要使用 $set
删除数据
db.users.remove({_id: ObjectId("603cee6abd814d23c09917f5")})
Koa
|