玩转node.js进阶

114 阅读16分钟

1. 深入理解事件循环机制

1.1 动手实践

  • Timers:这个阶段主要用于处理setTimeoutsetImmediate函数

  • I/O Callbacks:这个阶段主要用于处理I/O事件,比如说文件读写,网络请求。

  • Idle,Prepare:这个阶段主要用于内部处理,一般不会涉及用户代码

  • Poll:这个阶段主要用于监听I/O事件,如果此时有setImmediate任务,则会先执行它们。

  • Check:这个阶段主要用于处理setImmediate函数

  • Close Callbacks:这个阶段主要用于关闭相关的资源操作,释放资源的相关回调等,比如说关闭文件或网络连接。

  • 实验性脚本:

		console.log('Before');
		setImmediate(() => { console.log('immediate'); });
		setTimeout(() => { console.log('timeout'); },0);
		process.nextTick(() => { console.log('nextTick'); });
		console.log('After');

请少侠告诉我这个打印结果是什么呢? 答案就是

		Before
		After
		nextTick
		timeout
		immediate

来让我们狠狠分析一波: 当然同步代码肯定还是最先的,so 最先输出的肯定是Before,接着遇到了setImmediate函数,它会放到Check阶段的队列之中,等待事件循环执行;setTimeout函数会被放到下一个事件队列之中。 process.nextTick的回调将在当前事件循环的当前阶段结束后立即执行,但在任何其他异步操作之前。它是事件循环中优先级最高的异步调用。 因为After 是同步代码,所以我们知道它应该是属于当前这个事件循环执行的代码,所以执行顺序就是上面这个顺序了。

2. 异步编程模式

  • 关于异步编程的具体内容,已经在玩转异步编程涉及了,请少侠移步至这个网站。

2.1 实践任务

构建一个小型的web API,使用Express框架,实现异步数据处理,例如从数据库查询数据并返回给客户端。

现在我们开始构建一个小型的web API

当然我们在这里会使用到Express框架,所以你在实现这个需求之前,需要安装好对应的依赖

npm install express mysql2 dotenv

express, mysql2(用于 MySQL 数据库连接),以及 dotenv(用于环境变量管理)

当然我们都明白express和mysql2的作用,但是dotenv用于环境变量管理是干什么呢?

其实我们也能够很容易理解:一般我们如果要连接数据库,肯定要配置相关的内容的比如说DB_HOST指定数据库服务器的主机名或IP地址,DB_USER指定数据库的访问用户名,DB_PASSWORD指定数据库的密码,DB_NAME指定数据库的名字等等,如果我们要使用Express去连接数据库的话,会创建一个.env文件去存储这些配置信息。比如说下面:

DB_HOST=127.0.0.1
DB_USER=root
DB_PASSWORD=123456
DB_NAME=study

在Node.js脚本中运行 require('dotenv').config();dotenv模块会读取.env文件的内容,并将这些键值对设置为Node.js进程的环境变量。这意味着你可以通过process.env.DB_HOSTprocess.env.DB_USER等方式在代码中访问这些值。

服务端代码

通过下面的Javascript代码我们即可实现服务器与数据库的连接,实现异步的数据处理。相信你已经了解过前后端了,其实下面这段代码就是实现了服务端可以访问数据库,实现对数据库进行操作。

// 设置Express服务器和 Mysql数据库连接
const express = require('express'); // 引入Express模块
const mysql = require('mysql2/promise'); // 引入Mysql模块
require('dotenv').config(); // 引入环境变量模块

// 创建Express服务器
const app = express();
const port = process.env.PORT || 4021;

// 创建Mysql数据库连接池
const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
})

// 转换JSON请求体
app.use(express.json());

// 从数据库获取数据的请求
app.get('/data', async (req, res) => {
  console.log('开始向数据库发送请求');
  try {
    const [rows] = await pool.query('SELECT * FROM user');
    res.json(rows);
  } catch (err) {
    console.log(err);
    res.status(500).json({ error: '数据库查询错误' });
  }
})

// 启动服务器
app.listen(port, () => {
  console.log(`服务器开始监听http://localhost:${port}`);
});

接着你会发出疑问,之前我们有做过一个实战是在玩转node.js基础里面实现的一个简易版的聊天的应用,那这里使用Express + mysql和那里使用Express + WebSocket 有什么区别呢?接下来让我们直接看代码:

// 代码一:
// 设置Express服务器和 Mysql数据库连接
const express = require('express'); // 引入Express模块
const mysql = require('mysql2/promise'); // 引入Mysql模块
require('dotenv').config(); // 引入环境变量模块

// 创建Express服务器
const app = express();
const port = process.env.PORT || 4021;

// 创建Mysql数据库连接池
const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
})

// 代码二:
const express = require('express'); //导入Express模块,用于创建Web应用。
const WebScoket = require('ws'); // 导入ws模块,用于创建和管理WebSocket连接。
const http = require('http'); // 导入HTTP模块,用于创建HTTP服务器。

const app = express(); // 初始化一个Express应用实例。
const server = http.createServer(app); // 使用Express应用实例创建一个HTTP服务器。Express应用可以作为HTTP服务器的请求处理函数。
const wss = new WebScoket.Server({ server }); // 绑定WebSocket协议到HTTP服务器。

我想问大家有思考过上面的代码一和代码二有什么区别吗?

两者都使用了Express框架,但是它们使用的区别就在于代码一使用的是Express + MySQL技术,代码二使用的是Express + WebSocket技术。

Express + MySQL

在代码一中,Express框架被用来处理HTTP请求和响应,而MySQL数据库连接池被用来存储和检索数据。Express负责前端与后端之间的通信,而数据库用于持久化存储数据。这种模式适合于构建传统的Web应用,如数据的存取和更新。

Express + WebSocket

在代码二中,除了使用Express处理HTTP请求,还引入了WebSocket技术。WebSocket提供了一个全双工的通信通道,允许服务器和客户端之间进行实时双向数据传输。这在需要实时更新数据的应用场景中非常有用,比如在线聊天应用、实时数据分析、游戏等,其中客户端和服务器之间的数据交换频繁且需要低延迟。

区别

  • 通信方式HTTP是基于请求-响应模型的无状态通信,每次请求都需要建立和关闭连接。WebSocket则保持一个长连接,可以持续发送数据,减少连接建立和断开的开销。
  • 数据交换HTTP数据交换是分段的,每次请求和响应都相对独立。WebSocket则可以连续发送数据,适合流式数据传输。
  • 应用场景HTTP更适合于数据不频繁变化的应用,如浏览网页、API调用等。WebSocket则适用于需要实时数据更新的应用,如实时聊天、在线游戏、股票行情等。

3. 文件系统和流(Streams)

3.1 理论学习

fs模块和Stream API 是处理文件读写操作和数据流的重要工具,尤其适用于处理大量数据或实时数据处理场景。

fs模块

提供了同步异步的文件系统操作方法,如fs.readFile(),fs.writeFile(), fs.readdir(), fs.mkdir()等,

fs.readFile()(异步读取文件的内容),fs.writeFile()(异步写入数据到文件),fs.readdir()(异步读取目录内容),fs.unlink()(异步删除文件),fs.stat()(异步获取文件的状态信息)这些都是异步方法,它不会阻塞程序的流程,当执行完成之后,会自动调用对应的回调函数。

同步方法就是在上面的方法名后面加入Sync后缀

fs.readFileSync(),fs.writeFileSync(),fs.readdirSync(),fs.unlinkSync(),fs.statSync()用同步的操作方法,会导致长时间的阻塞。

所以通常我们尽量使用异步的fs模块的文件操作方法。

Stream API

Node.js 提供的一种用于处理数据流的机制。它定义了一组接口和行为规范,使得开发者可以创建和使用流式数据处理逻辑。流可以看作是一系列连续的数据块,可以被连续地读取或写入,而无需一次性加载所有数据到内存中。

它的调用方式也是通过fs直接调用

  • Readable:可读流,可以从其中读取数据,如fs.createReadStream()
  • Writable:可写流,可以向其写入数据,如fs.createWriteStream()
  • Duplex:同时具有可读和可写功能,如net.Socket。这意味着它可以作为一个数据的源和目的地。net.Socket是一个典型的Duplex流的例子,它用于网络通信,既能发送数据也能接收数据。
  • Transform:是一种特殊的Duplex流,可以在数据通过时进行转换,如zlib.Gzip()。适用于压缩文件和解压文件数据过滤或清洗数据的格式转换等场景。

这里你可能会疑惑,不都是调用fs中的方法吗?那它们两个有什么区别呢?

好好好,这个问题我们只能来解释一波了。

开发者通过fs模块的操作是直接和硬盘上面的文件进行交互的,对文件有增删改等等操作,提供了底层的文件系统访问能力。

但是Stream API是一个更加通用且抽象的处理数据流的机制,流(Streams)是Node.js中处理数据的一种方式,它允许数据以块的形式连续不断地被读取或写入,而不是一次性加载所有数据到内存中。而且这个流的概念并不只是只存在于文件操作的层面,这个概念我们可以用在一些其他层面上面,包括网络套接字、加密解密操作、压缩解压缩过程等。

3.2 动手实践

创建一个流管道,用于压缩和解压大文件。

  • zlib.createGzip()zlib.createGunzip() 这两个函数分别用于创建压缩和解压缩的流。

    它们属于 Node.jszlib 模块,该模块提供了对 zlib 库的封装,用于数据的压缩和解压缩,支持 gzipdeflate 格式。

    zlib.createGzip() 会创建一个可写的Gzip流,可以将输入的数据流压缩成gzip格式。当数据被写入这个流中,数据会被实时压缩,开发者可以从可读端获取这个流的数据。

    zlib.createGunzip()会创建一个可读的Gzip流,可以将压缩成gzip数据流解压。数据会被实时解压,开发者可以获取未被压缩之前的数据。

压缩文件示例

const fs = require('fs')
const zlib = require('zlib')

const gzip = zlib.createGzip()
const input = fs.createReadStream('source.txt')
const output = fs.createWriteStream('source.txt.gz')

input.pipe(gzip).pipe(output)

好的,我又出现了疑惑,pipe方法有什么作用?

pipe直译过来就是管道的意思,okpipeStream API 重要的组成部分,它简化了开发者的操作,让开发者不用显式地读取或者写入数据缓冲区。

它的工作原理是从源流(input)读取数据,并且把它写入目标流(gizp)中,如果目标文件流是一个写入(Writable)流,它会自动的将源流的每一部分数据写入到目标流之中,知道源流暂停或者目标流关闭。

解压文件示例

const fs = require('fs')
const zlib = require('zlib')

const gunzip = zlib.createGunzip()
const input = fs.createReadStream('source.txt.gz')
const output = fs.createWriteStream('source.txt')

input.pipe(gunzip).pipe(output)

在数据流的过程中对一些数据进行筛选过滤示例

这也是我们经常可能会遇到的一种情况:

Stream API 中提供了Transform流,它非常适合用于数据的实时处理,如过滤、解析、加密、压缩等。你只需继承stream.Transform类并重写其中的_transform_flush方法即可。

// 数据流的过程中对一些数据进行筛选过滤
const { Transform } = require('stream');

class FilterStream extends Transform {
  constructor(options) {
    super(options);
    this.filterKeywords = ['a', 'b'];
  }

  /**
   * 
   * 数据块被转换成字符串,然后按行分割,每一行都会检查是否包含过滤关键字列表中的任何一个词。
   * 如果不包含,该行将被保留;否则,将被过滤掉。
   * 最终,过滤后的数据被重新转换成Buffer对象并发送到下游流。
   * 
   * @param {*} chunk - 接收数据块
   * @param {*} encoding - 编码
   * @param {*} callback - 一个回调函数
   */
  _transform(chunk, encoding, callback) {
    // 做一些数据的筛选逻辑处理
    let data = chunk.toString().split('\n');
    let filteredData = data.filter(line => !this.filterKeywords.some(keyword => line.includes(keyword)));
    callback(null, Buffer.from(filteredData.join('\n')));
  }

  _flush(callback) {
    // 在数据流结束时做一些事情
    callback();
  }
}

const fs = require('fs');
const filterStream = new FilterStream();

const input = fs.createReadStream('source.txt');
const output = fs.createWriteStream('filtered.txt');

input.pipe(filterStream).pipe(output);

使用Transform流优势就是它可以在数据传输的过程中实时去做筛选和修改,不用将数据写入到内存之后,再去做处理,优化了性能。

4. HTTP服务器和客户端

4.1 学习

对于Node.jshttp模块我相信在之前的node.js基础篇有涉及,其实创建一个简单的服务端是非常简单的,来吧我们直接堆代码:

服务端代码

// 直接使用node.js中的http模块
const http = require('http');

// 这里直接创建的服务端
const server = http.createServer((req, res) => {
  if (req.url === '/') {
    console.log(req.url);
    res.end('hello world');
  }
})

server.listen(4021, () => {
  console.log('服务端开始启动 http://localhost:4021');
})

客户端代码

// 客户端
const http = require('http');

// 简单的配置
const options = {
  hostname: 'localhost',
  port: 3000,
  path: '/',
  method: 'GET'
};

// 发送请求
const req = http.request(options, res => {
  console.log(`状态码: ${res.statusCode}`);
  let data = '';

  res.on('data', chunk => {
    data += chunk;
    console.log(d.toString());
  });

  res.on('end', () => {
    console.log(`返回数据: ${data}`);
  });
})

// 处理错误
req.on('error', (error) => {
  console.error(`错误: ${error.message}`);
});

// 如果请求体包含了数据(比如说Post,Put请求),需要调用req.write(chunk),将数据写入请求体
req.write(JSON.stringify({id: 0}));

/**
 * 一旦req.end()被调用,Node.js就会将请求的所有信息打包,并将其发送到目标服务器。
 * 这意味着HTTP请求的生命周期从准备阶段进入了发送阶段。
 */
// 发送请求
req.end();

好的,客户端的代码我们也实现了,ok,其它的我能理解,但是

  res.on('data', chunk => {
    data += chunk;
    console.log(d.toString());
  });

这一块代码有什么作用呢?

Http在读取或者响应较大的文件的时候,它不是一下处理的,它会将这些大文件拆分成一块一块(即"chunks")的来处理,逐步传递给你的应用程序,这样就不会保证这些一块一块的内容可以一次性到达。这里就体现了流的概念的重要性,node.js中的http允许开发者以流的形式处理数据,这样提高了响应的性能。

所以当客户端使用了这一块代码的目的是对响应的一块一块数据进行整合,并且上面的chunk通常是以Buffer形式提供的。因为网络上面的数据传输是二进制的形式传递的,BufferNode.js中用于处理二进制数据的对象类型。那为什么我们打印出来它是一串字符串呢?是因为Node.js会默认调用toString()方法将其转换为字符串,除非你明确要求以二进制或其他编码方式输出。

4.2 实践

构建一个简单的HTTP服务器,提供静态文件服务,并实现一个RESTful API接口。

来吧我们直接上代码:

服务端代码

// 直接使用node.js中的http模块
const http = require('http');
const mysql = require('mysql2/promise'); // 引入Mysql模块
require('dotenv').config(); // 引入环境变量模块

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
})

// 这里直接创建的服务端
const server = http.createServer((req, res) => {
  // if (req.url === '/') {
  //   console.log(req.url);
  //   res.statusCode = 200;
  //   res.setHeader('Content-Type', 'text/plain');
  //   res.end('hello world');
  // }
})

// 接下来我们来实现静态文件服务,使用fs模块读取文件,并将其发送回客户端
const fs = require('fs');
const path = require('path');

server.on('request', (req, res) => {
  console.log('进入请求');
  const filePath = path.join(__dirname, 'public', req.url);
  console.log(filePath);
  // 出现错误,会导致一直读不出来,一直在加载静态资源
  // fs.createReadStream(filePath, (err, data) => {
  //   if (err) {
  //     if (req.url.startsWith('/api/')) {
  //       handleApiRequest(req, res);
  //     } else {
  //       res.writeHead(404, {
  //         'Content-Type': 'text/html'
  //       });
  //       res.end('资源未找到');
  //     }
  //   } else {
  //     res.writeHead(200, {
  //       'Content-Type': 'text/plain'
  //     });
  //     res.end(data);
  //   }
  // })
  // 错误1:当你使用fs.createReadStream时,错误处理函数应正确接收流的错误事件,而不是将err和data作为参数传给回调函数。
  // data参数在这里实际上是流本身,而不是读取的数据。
  // 错误2:Content-Type的格式问题
  // 所以根据上面的错误进行修改
  // 使用fs.stat检查文件是否存在以及是否是文件,而不是直接尝试创建读取流
  fs.stat(filePath, (err, stats) => {
    // 出现错误:1.表示调用的接口不是获取静态资源的接口 2.不存在这个静态资源
    if (err) {
      console.log('req.url', req.url);
      if (req.url.startsWith('/api/')) {
        handleApiRequest(req, res);
      } else {
        res.writeHead(404, { 'Content-Type': 'text/plain' });
        res.end('资源未找到');
      }
    }
    // 存在
    else if (stats.isFile()) {
      // 使用createReadStream方法获取对应的数据流,并把这个数据流最终写入到res
      const fileStream = fs.createReadStream(filePath);
      // 设置正确的Content-Type
      switch (path.extname(filePath)) {
        case '.html':
          res.setHeader('Content-Type', 'text/html');
          break;
        case '.css':
          res.setHeader('Content-Type', 'text/css');
          break;
        case '.js':
          res.setHeader('Content-Type', 'application/javascript');
          break;
        default:
          res.setHeader('Content-Type', 'application/octet-stream');
      }
      fileStream.pipe(res);
    }
    // 不存在
    else {
      res.writeHead(404, { 'Content-Type': 'text/plain' });
      res.end('资源未找到');
    }
  })

});



// RESTful API接口通常意味着接收GET、POST、PUT或DELETE等HTTP方法,并根据这些方法对数据进行操作。
// 实现一个RESTful API接口,以下是一个简单的API接口示例,它响应GET请求并返回JSON数据
async function handleApiRequest(req, res) {
  if (req.url === '/api/data' && req.method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    const [rows] = await pool.query('SELECT * FROM user');
    // 通常,res.json方法是在诸如Express这样的框架中提供的,它简化了JSON响应的生成。s
    res.end(JSON.stringify({ message: rows }));
  } else {
    res.writeHead(404, { 'Content-Type': 'text/plain' });
    res.end('内容并未找到\n');
  }
}

server.listen(4021, () => {
  console.log('服务端开始启动 http://localhost:4021');
})

如果要连接数据库的话,我们需要创建相应的配置文件.env

.env:

DB_HOST=127.0.0.1
DB_USER=root
DB_PASSWORD=123456
DB_NAME=study

在当前目录之下还要创建你对应的静态文件资源,要不然它是无法找到对应的内容的。

  1. __dirname 是Node.js中的一个全局变量,它表示当前执行脚本所在的绝对目录路径。这对于确定相对路径非常有用,因为它总是相对于当前模块或脚本的目录。
  2. req.url 是HTTP请求对象的一个属性,它包含了客户端请求的URL路径。例如,如果用户访问http://example.com/index.html,那么req.url将会是"/index.html"
  3. path.join()Node.jspath模块提供的一个函数,用于安全地组合多个路径段,同时处理不同操作系统之间的路径分隔符差异。
const filePath = path.join(__dirname, 'public', req.url);

所以这一句代码会根据当前脚本所在目录、静态文件存放的子目录('public')和客户端请求的URL路径(req.url),生成一个指向服务器文件系统中具体文件的完整路径。例如如果当前脚本的位置是/www/study,我们的请求路径为http://localhost:4021/index.html,那么组合下来就会是/www/study/public/index.html

客户端代码

// 客户端
const http = require('http');

// 简单的配置
const options1 = {
  hostname: 'localhost',
  port: 4021,
  path: '/api/data',
  method: 'GET'
};

const options2 = {
  hostname: 'localhost',
  port: 4021,
  path: '/index.html',
  method: 'GET'
};

// 发送请求
const req1 = http.request(options1, res => {
  console.log(`状态码: ${res.statusCode}`);
  let data = '';

  res.on('data', chunk => {
    console.log(`接收数据: ${chunk}`);
    data += chunk;
  });

  res.on('end', () => {
    const jsonData = JSON.parse(data);
    console.log(`返回数据: ${jsonData}`);
  });
})

const req2 = http.request(options2, res => {
  console.log(`状态码: ${res.statusCode}`);
  let data = '';

  res.on('data', chunk => {
    console.log(`接收数据: ${chunk}`);
    data += chunk;
  });

  res.on('end', () => {
    console.log(`返回数据: ${data}`);
  });
})

// 处理错误
req1.on('error', (error) => {
  console.error(`错误: ${error.message}`);
});

req2.on('error', (error) => {
  console.error(`错误: ${error.message}`);
});

/**
 * 一旦req.end()被调用,Node.js就会将请求的所有信息打包,并将其发送到目标服务器。
 * 这意味着HTTP请求的生命周期从准备阶段进入了发送阶段。
 */
// 发送请求
req1.end();
req2.end();


// 优化请求:
// 按照上面的这种写法的话,会导致我们每次加一个接口,我们都需要去写一套相同的内容,这样当如果存在多个请求的时候,
// 会导致代码冗余,所以我们可以使用一个函数并结合Promise(控制异步来提高系统的性能)来封装,这样就可以减少代码冗余。

const http = require('http');

function makeRequest(options, callback) {
  return new Promise((resolve, reject) => {
    const req = http.request(options, res => {
      let data = '';

      res.on('data', chunk => {
        data += chunk;
      });

      res.on('end', () => {
        resolve(data);
      });
    });

    req.on('error', (error) => {
      reject(error);
    });

    req.end();
  })
}

makeRequest(options1).then(
  data => console.log(data)
).catch(
  error => console.error(error)
);

makeRequest(options2).then(
  data => console.log(data)
).catch(
  error => console.error(error)
);

测试结果

  • 对于静态文件服务我们可以直接通过url进行访问看到对应的效果 在这里插入图片描述 在这里插入图片描述

  • 对于api请求,我们可以直接打印。 在这里插入图片描述

5. 中间件和路由

5.1 学习

  • 首先我们需要确定一个东西:就是中间件和路由我们是发生在什么阶段的。 我来讲一下整个流程你就知道了:

整体流程:

  • 客户端发送请求:客户端发送一个HTTP请求到服务端。

  • 服务器接收请求:服务器上的Express应用接收到这个请求。

  • 中间件处理请求:请求进入中间件堆栈。根据中间件的调用顺序,去执行必要的逻辑,当执行完一个中间件之后,可以直接响应请求,如果执行完一个中间件之后会调用next()方法,执行下一个中间件(若存在的话),一直执行到中间件堆栈清空为止。

  • 路由匹配Express尝试根据请求的URLHTTP方法找到匹配的路由。如果找到匹配的路由,其关联的处理函数将被调用。

  • 路由处理器处理请求:路由处理器可以执行逻辑处理,比如去数据库获取数据,新增数据,查询数据等等操作,并生成响应。

  • 中间件处理响应:响应在处理器生成后向下传递,又回到中间件来执行一些已经创建的逻辑,例如修改响应头、压缩响应体等。

  • 响应发送给客户端:最终,响应被完全构造好并通过网络发送回客户端。

  • 请求响应循环结束:当响应被完全发送并且连接关闭,请求响应循环结束。

相信你通过这个流程对中间件和路由都有了更加深刻的了解。

接着我们来详细说一下中间件和路由

中间件

中间件是Express框架中的一个关键特性,它允许开发者在请求到达目标路由处理器之前,插入自定义的处理逻辑。中间件函数可以执行以下操作:

  • 执行任何代码。
  • 修改请求和响应对象。
  • 结束请求响应循环。
  • 调用堆栈中的下一个中间件。

中间件函数通常接收三个参数:requestreq),responseres),以及next函数。next函数用于将控制权传递给下一个中间件或路由处理器。

路由

路由定义了客户端请求与服务器端处理程序之间的映射关系。Express通过app对象提供了多种方法来处理不同的HTTP请求类型,如app.get(), app.post(), app.put(), app.delete()等。

路由可以是静态的,也可以包含参数,这些参数可以从请求的URL中提取出来。比如是get请求中/api/:id,我们可以动态获取这个id

中间件和路由一般是组合使用的,接下来就让我们开始实战吧。

5.2 实践

使用Express框架构建一个完整的web应用,包含用户认证、权限管理等功能。

服务端代码

// 构建一个完整的web应用,包含用户认证、权限管理等功能。
/**
 * 当客户端用户尝试登录时,服务器验证他们的凭据,如果成功,会返回一个JWT。
 * 客户端需要在后续的请求中携带这个令牌,以证明其身份并访问受保护的资源。
 */
const express = require('express');
const mysql = require('mysql2/promise');

const dotenv = require('dotenv');
dotenv.config();

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
})

// body-parser 是一个中间件,用于解析客户端发送的HTTP请求体。
// 它能够解析不同类型的请求体,如表单数据、JSON数据。
const bodyParser = require('body-parser');
// express-session 是一个会话中间件,用于管理用户的会话状态。
// 它在服务器端创建并维护会话数据,这样在用户与服务器的交互过程中,即使请求之间是无状态的,服务器也能识别出用户。
const session = require('express-session');
// jsonwebtoken 是一个用于生成和验证 JSON Web Tokens 的库,用于实现用户认证和授权。
const jwt = require('jsonwebtoken');
// bcrypt 是一个用于加密和哈希密码的库,用于保护用户密码的安全。
const bcrypt = require('bcrypt');
const app = express();
const port = 4021;

// Express应用中配置中间件,目的是解析客户端发送过来的HTTP请求体。
/**
 * 专门用于解析Content-Type为application/json的请求体。
 * body-parser.json()方法会将请求体解析为一个JavaScript对象,并将其赋值给req.body属性。
 */
app.use(bodyParser.json());
/**
 * 专门用于解析Content-Type为application/x-www-form-urlencoded的请求体。
 * extended: true选项表示支持解析嵌套的对象和数组
 */
app.use(bodyParser.urlencoded({ extended: true }));

/**
 * 这个中间件函数authenticate负责检查每个请求是否附带了有效的JWT。
 * 它从请求头x-access-token中提取令牌,然后使用jsonwebtoken模块的verify方法验证令牌。
 * 如果验证失败,请求将被拒绝并返回401 Unauthorized状态。
 * 如果验证成功,用户信息将被附加到req.user对象上,然后调用next(),允许请求继续到下一个中间件或路由处理器。
 * @param {*} req 
 * @param {*} res 
 * @param {*} next 
 */
function authenticate(req, res, next) {
  const token = req.headers['x-access-token'];
  if (token) {
    // 这里的your-secret-key应该替换为你实际的JWT令牌
    jwt.verify(token, process.env.JWT_SECRET, function (err, decoded) {
      if (err) {
        return res.status(401).send({ message: '未经授权' });
      }
      req.user = decoded;
      next();
    });
  } else {
    return res.status(401).send({ message: '未提供令牌' });
  }
}
/**
 * 注册路由
 */
app.post('/register', async (req, res) => {
  try {
    const { name, age, username, password } = req.body;
    const saltRounds = 10; // 盐的轮数,通常推荐10或更高

    // 使用bcrypt.hash方法生成哈希密码
    const hashedPassword = await bcrypt.hash(password, saltRounds);

    // 将哈希后的密码存储到数据库中
    const connection = await pool.getConnection();
    await connection.query('INSERT INTO user (name, age, user_name, password) VALUES (?, ?, ?, ?)', [name, age, username, hashedPassword]);
    connection.release();

    // 返回成功响应
    res.json({ message: '注册成功' });
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: '服务器错误' });
  }
});

/**
 * 这是登录路由,处理POST请求。
 * 它首先在用户数据库中查找与请求体中username匹配的用户,然后使用bcrypt比较请求体中的password与数据库中存储的哈希密码。
 * 如果密码匹配,它会生成一个JWT,并通过响应返回给客户端。如果凭证无效,返回401 Unauthorized状态。
 */
app.post('/login', async (req, res) => {
  try {
    const { username, password } = req.body;
    const connection = await pool.getConnection();
    const [rows] = await connection.query('SELECT * FROM user WHERE user_name = ?', [username]);
    console.log(rows);
    connection.release(); // 释放连接回到连接池
    if (rows.length > 0) {
      const user = rows[0];
      console.log(password, user.password);
      // console.log('await bcrypt.compare(password, user.password)', await bcrypt.compare(password, user.password));
      // await bcrypt.compare(password, user.password)
      console.log('password == user.password', password == user.password);
      if (await bcrypt.compare(password, user.password)) {
        const token = jwt.sign({ username: user.user_name }, process.env.JWT_SECRET, { expiresIn: '1h' });
        console.log('token', token);
        res.json({ token });
      } else {
        res.status(401).json({ message: '密码不正确' });
      }
    } else {
      res.status(401).json({ message: '用户不存在' });
    }
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: '服务器错误' });
  }
});

/**
 * 这是一个受保护的路由,只能由通过了authenticate中间件的已认证用户访问。
 * 当用户访问这个路由时,他们必须在请求头中提供有效的JWT。如果认证成功,服务器将返回一个欢迎消息。
 */
app.get('/protected', authenticate, (req, res) => {
  res.json({ message: '访问受保护的路由' });
});

app.listen(port, () => {
  console.log(`服务端开始启动:http://localhost:${port}`);
});

客户端代码

async function main() {
  // 异步导入 node-fetch
  const fetch = await import('node-fetch').then(module => module.default);
  
  fetch('http://localhost:4021/register', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      name: '笑笑',
      age: 26,
      username: '112255',
      password: '123456'
    })
  }).then(response => response.json()).then(data => {
    console.log('注册成功:', data.message);
  })

  setTimeout(() => {
    // 登录请求
    fetch('http://localhost:4021/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        username: '112255',
        password: '123456'
      })
    })
      .then(response => response.json())
      .then(data => {
        console.log('登录成功:', data.token);

        // 存储token以便后续使用
        const fs = require('fs');
        fs.writeFile('./token.txt', data.token, err => {
          if (err) throw err;
          console.log('Token 保存到txt文件中了。');
        });

        // 访问受保护的端点
        fetch('http://localhost:4021/protected', {
          method: 'GET',
          headers: {
            'x-access-token': data.token
          }
        })
          .then(response => response.json())
          .then(data => console.log('访问受保护的路由:', data))
          .catch(error => console.error('错误:', error));
      })
      .catch(error => console.error('错误:', error));
  }, 1000);


}

main();

测试结果

在这里插入图片描述