MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Node.js 异步编程在实际项目中的应用案例

2022-06-126.4k 阅读

Node.js 异步编程基础概念

在深入探讨实际项目应用案例之前,先来回顾一下 Node.js 异步编程的一些基本概念。

Node.js 以其单线程、事件驱动、非阻塞 I/O 的架构设计,在处理高并发场景时展现出强大的性能优势。而异步编程是这种架构的核心。在传统的同步编程中,代码按照顺序依次执行,前一个任务完成后才会执行下一个任务。如果某个任务耗时较长(比如 I/O 操作,像读取文件、网络请求等),那么整个程序就会处于等待状态,这在需要处理大量并发请求的场景下是非常低效的。

在 Node.js 中,异步操作通过回调函数、Promise、async/await 等方式来实现。

回调函数

回调函数是 Node.js 异步编程最基础的方式。当一个异步操作完成时,Node.js 会调用事先传入的回调函数,并将操作结果作为参数传递进去。例如,使用 fs 模块读取文件:

const fs = require('fs');

fs.readFile('example.txt', 'utf8', (err, data) => {
    if (err) {
        console.error('读取文件出错:', err);
        return;
    }
    console.log('文件内容:', data);
});

在这个例子中,fs.readFile 是一个异步操作,它不会阻塞后续代码的执行。当文件读取完成后,会调用回调函数 (err, data) => {... }err 参数表示操作过程中是否出现错误,如果没有错误,data 就是读取到的文件内容。

回调函数虽然简单直接,但当异步操作嵌套过多时,就会出现“回调地狱”问题,代码变得难以阅读和维护。例如:

fs.readFile('file1.txt', 'utf8', (err1, data1) => {
    if (err1) {
        console.error('读取 file1 出错:', err1);
        return;
    }
    fs.readFile('file2.txt', 'utf8', (err2, data2) => {
        if (err2) {
            console.error('读取 file2 出错:', err2);
            return;
        }
        fs.readFile('file3.txt', 'utf8', (err3, data3) => {
            if (err3) {
                console.error('读取 file3 出错:', err3);
                return;
            }
            console.log('处理结果:', data1, data2, data3);
        });
    });
});

这种层层嵌套的代码结构,随着异步操作的增多,会迅速变得复杂和混乱。

Promise

Promise 是为了解决回调地狱问题而引入的。它表示一个异步操作的最终完成(或失败)及其结果值。一个 Promise 有三种状态:pending(进行中)、fulfilled(已成功)和 rejected(已失败)。一旦状态改变,就不会再变,状态的转变不可逆。

使用 Promise 改写上述读取文件的例子:

const fs = require('fs');
const util = require('util');

const readFilePromise = util.promisify(fs.readFile);

readFilePromise('example.txt', 'utf8')
   .then(data => {
        console.log('文件内容:', data);
    })
   .catch(err => {
        console.error('读取文件出错:', err);
    });

这里通过 util.promisify 方法将 fs.readFile 这个基于回调的函数转换为返回 Promise 的函数。then 方法用于处理 Promise 成功的情况,catch 方法用于捕获 Promise 失败时的错误。

多个 Promise 操作可以通过链式调用的方式进行组合,避免了回调地狱。例如:

readFilePromise('file1.txt', 'utf8')
   .then(data1 => {
        return readFilePromise('file2.txt', 'utf8');
    })
   .then(data2 => {
        return readFilePromise('file3.txt', 'utf8');
    })
   .then(data3 => {
        console.log('处理结果:', data1, data2, data3);
    })
   .catch(err => {
        console.error('读取文件出错:', err);
    });

这样的链式调用方式使得代码逻辑更加清晰,易于理解和维护。

async/await

async/await 是基于 Promise 的语法糖,它使得异步代码看起来更像是同步代码,进一步提高了代码的可读性。async 函数总是返回一个 Promise。await 只能在 async 函数内部使用,它用于暂停 async 函数的执行,等待一个 Promise 被解决(resolved)或被拒绝(rejected)。

使用 async/await 改写上述读取文件的例子:

const fs = require('fs');
const util = require('util');

const readFilePromise = util.promisify(fs.readFile);

async function readFiles() {
    try {
        const data1 = await readFilePromise('file1.txt', 'utf8');
        const data2 = await readFilePromise('file2.txt', 'utf8');
        const data3 = await readFilePromise('file3.txt', 'utf8');
        console.log('处理结果:', data1, data2, data3);
    } catch (err) {
        console.error('读取文件出错:', err);
    }
}

readFiles();

readFiles 这个 async 函数中,通过 await 依次等待每个文件读取操作完成,代码结构简洁明了,如同同步代码一样直观。

实际项目应用案例 - 基于 Node.js 的 Web 爬虫

Web 爬虫是一个非常适合 Node.js 异步编程的实际项目场景。爬虫需要从多个网页获取数据,而网络请求是典型的耗时异步操作。

项目需求

我们要开发一个简单的 Web 爬虫,从多个新闻网站上抓取新闻标题和链接,并将结果存储到本地文件中。

技术选型

  • Axios:用于发送 HTTP 请求获取网页内容,Axios 基于 Promise,使用起来非常方便。
  • Cheerio:类似于 jQuery 的库,用于在 Node.js 中解析 HTML,方便提取网页中的数据。
  • fs:Node.js 内置的文件系统模块,用于将抓取到的数据写入本地文件。

代码实现

  1. 初始化项目: 首先创建一个新的目录,然后在命令行中执行 npm init -y 初始化 package.json 文件。接着安装所需的依赖:
    npm install axios cheerio
    
  2. 编写爬虫代码
const axios = require('axios');
const cheerio = require('cheerio');
const fs = require('fs');
const util = require('util');

const writeFilePromise = util.promisify(fs.writeFile);

async function crawlNews() {
    const newsSites = [
        'https://news.example.com',
        'https://anothernews.example.net'
    ];

    let newsData = '';

    for (const site of newsSites) {
        try {
            const response = await axios.get(site);
            const $ = cheerio.load(response.data);

            $('a.news - link').each((index, element) => {
                const title = $(element).text();
                const link = $(element).attr('href');
                newsData += `标题: ${title}\n链接: ${link}\n\n`;
            });
        } catch (err) {
            console.error(`抓取 ${site} 出错:`, err);
        }
    }

    try {
        await writeFilePromise('news.txt', newsData);
        console.log('数据已成功写入 news.txt');
    } catch (err) {
        console.error('写入文件出错:', err);
    }
}

crawlNews();

在这段代码中,crawlNews 是一个 async 函数。首先定义了要抓取的新闻网站数组 newsSites。然后通过 for...of 循环遍历每个网站,使用 axios.get 发送 HTTP 请求获取网页内容,await 等待请求完成。获取到网页内容后,使用 cheerio 加载并解析 HTML,通过 CSS 选择器 a.news - link 提取新闻标题和链接,并将其拼接成字符串 newsData。最后,使用 writeFilePromisenewsData 写入本地文件 news.txt,同样使用 await 等待写入操作完成。

优化与扩展

  1. 并发请求:上述代码是顺序依次请求每个网站,效率较低。可以使用 Promise.all 实现并发请求,提高抓取速度。
async function crawlNews() {
    const newsSites = [
        'https://news.example.com',
        'https://anothernews.example.net'
    ];

    const requests = newsSites.map(site => axios.get(site));
    const responses = await Promise.all(requests);

    let newsData = '';

    for (const response of responses) {
        const $ = cheerio.load(response.data);

        $('a.news - link').each((index, element) => {
            const title = $(element).text();
            const link = $(element).attr('href');
            newsData += `标题: ${title}\n链接: ${link}\n\n`;
        });
    }

    try {
        await writeFilePromise('news.txt', newsData);
        console.log('数据已成功写入 news.txt');
    } catch (err) {
        console.error('写入文件出错:', err);
    }
}

这里通过 map 方法将每个网站的请求封装成 Promise,然后使用 Promise.all 同时发起所有请求,await 等待所有请求都完成后再进行后续的数据提取和文件写入操作。

  1. 错误处理优化:在实际应用中,可以对不同类型的错误进行更细致的处理。例如,对于网络请求超时、HTTP 状态码错误等分别进行处理。
async function crawlNews() {
    const newsSites = [
        'https://news.example.com',
        'https://anothernews.example.net'
    ];

    const requests = newsSites.map(site => {
        return axios.get(site, {
            timeout: 5000 // 设置请求超时时间为 5 秒
        })
           .catch(err => {
                if (err.code === 'ECONNABORTED') {
                    console.error(`请求 ${site} 超时`);
                } else if (err.response && err.response.status >= 400) {
                    console.error(`请求 ${site} 失败,状态码: ${err.response.status}`);
                } else {
                    console.error(`请求 ${site} 出错:`, err);
                }
            });
    });

    const responses = await Promise.all(requests.filter(Boolean));

    let newsData = '';

    for (const response of responses) {
        const $ = cheerio.load(response.data);

        $('a.news - link').each((index, element) => {
            const title = $(element).text();
            const link = $(element).attr('href');
            newsData += `标题: ${title}\n链接: ${link}\n\n`;
        });
    }

    try {
        await writeFilePromise('news.txt', newsData);
        console.log('数据已成功写入 news.txt');
    } catch (err) {
        console.error('写入文件出错:', err);
    }
}

在这个优化版本中,为每个 axios.get 请求设置了超时时间,并在 catch 块中对不同类型的错误进行了区分处理。同时,通过 filter(Boolean) 过滤掉请求失败的 undefined 值,确保 responses 数组中只包含成功的响应。

实际项目应用案例 - 实时聊天应用

实时聊天应用是 Node.js 异步编程在实际项目中的另一个重要应用场景。这类应用需要处理大量的并发连接,并且要实时推送消息给客户端,Node.js 的异步特性使其能够高效地应对这些需求。

项目需求

开发一个简单的基于 WebSocket 的实时聊天应用,支持多用户在线聊天,消息实时推送。

技术选型

  • Socket.io:一个广泛使用的 WebSocket 库,它提供了跨浏览器的 WebSocket 实现,并且支持自动重连、广播等功能,非常适合实时应用开发。
  • Express:Node.js 中最流行的 Web 应用框架,用于搭建服务器,处理 HTTP 请求。

代码实现

  1. 初始化项目: 创建一个新目录,执行 npm init -y 初始化 package.json 文件。然后安装所需依赖:
    npm install socket.io express
    
  2. 编写服务器端代码
const express = require('express');
const app = express();
const http = require('http').Server(app);
const io = require('socket.io')(http);

io.on('connection', socket => {
    console.log('有用户连接');

    socket.on('chat message', msg => {
        io.emit('chat message', msg);
    });

    socket.on('disconnect', () => {
        console.log('用户断开连接');
    });
});

const port = 3000;
http.listen(port, () => {
    console.log(`服务器运行在端口 ${port}`);
});

在这段代码中,首先使用 express 创建一个 Web 应用,然后使用 http 模块将其包装成 HTTP 服务器。接着使用 socket.io 监听服务器,当有新用户连接时,触发 connection 事件,在事件处理函数中,监听客户端发送的 chat message 事件,一旦收到消息,通过 io.emit 将消息广播给所有连接的客户端。当用户断开连接时,触发 disconnect 事件并打印日志。

  1. 编写客户端代码
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF - 8">
    <meta name="viewport" content="width=device - width, initial - scale=1.0">
    <title>实时聊天</title>
    <script src="/socket.io/socket.io.js"></script>
</head>

<body>
    <ul id="messages"></ul>
    <form id="form" autocomplete="off">
        <input id="input" autocomplete="off" /><button>发送</button>
    </form>
    <script>
        const socket = io();

        const form = document.getElementById('form');
        const input = document.getElementById('input');
        const messages = document.getElementById('messages');

        form.addEventListener('submit', function (e) {
            e.preventDefault();
            if (input.value) {
                socket.emit('chat message', input.value);
                input.value = '';
            }
        });

        socket.on('chat message', function (msg) {
            const item = document.createElement('li');
            item.textContent = msg;
            messages.appendChild(item);
            window.scrollTo(0, document.body.scrollHeight);
        });
    </script>
</body>

</html>

在客户端代码中,通过引入 socket.io/socket.io.js 库与服务器建立 WebSocket 连接。当用户在输入框中输入消息并提交表单时,触发 submit 事件,将消息通过 socket.emit 发送给服务器。同时,监听服务器发送的 chat message 事件,收到消息后在页面上显示,并将页面滚动到最新消息位置。

优化与扩展

  1. 用户管理:可以为每个连接的用户分配唯一标识符,记录用户的昵称等信息,实现更完善的用户管理功能。
io.on('connection', socket => {
    let userId = Date.now().toString();
    let username = '匿名用户';

    socket.on('set username', (name) => {
        username = name;
    });

    socket.on('chat message', msg => {
        io.emit('chat message', `${username}: ${msg}`);
    });

    socket.on('disconnect', () => {
        console.log(`${username} 断开连接`);
    });
});

在客户端增加设置用户名的功能:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF - 8">
    <meta name="viewport" content="width=device - width, initial - scale=1.0">
    <title>实时聊天</title>
    <script src="/socket.io/socket.io.js"></script>
</head>

<body>
    <input id="usernameInput" placeholder="输入用户名" />
    <button id="setUsernameButton">设置用户名</button>
    <ul id="messages"></ul>
    <form id="form" autocomplete="off">
        <input id="input" autocomplete="off" /><button>发送</button>
    </form>
    <script>
        const socket = io();

        const usernameInput = document.getElementById('usernameInput');
        const setUsernameButton = document.getElementById('setUsernameButton');
        const form = document.getElementById('form');
        const input = document.getElementById('input');
        const messages = document.getElementById('messages');

        setUsernameButton.addEventListener('click', function () {
            const username = usernameInput.value;
            if (username) {
                socket.emit('set username', username);
            }
        });

        form.addEventListener('submit', function (e) {
            e.preventDefault();
            if (input.value) {
                socket.emit('chat message', input.value);
                input.value = '';
            }
        });

        socket.on('chat message', function (msg) {
            const item = document.createElement('li');
            item.textContent = msg;
            messages.appendChild(item);
            window.scrollTo(0, document.body.scrollHeight);
        });
    </script>
</body>

</html>
  1. 消息持久化:可以将聊天消息存储到数据库(如 MongoDB)中,以便用户在重新连接时能够查看历史消息。
const mongoose = require('mongoose');
mongoose.connect('mongodb://localhost:27017/chatdb', { useNewUrlParser: true, useUnifiedTopology: true });

const messageSchema = new mongoose.Schema({
    username: String,
    message: String,
    timestamp: { type: Date, default: Date.now }
});

const Message = mongoose.model('Message', messageSchema);

io.on('connection', socket => {
    let userId = Date.now().toString();
    let username = '匿名用户';

    socket.on('set username', (name) => {
        username = name;
    });

    socket.on('chat message', async (msg) => {
        const newMessage = new Message({ username, message: msg });
        await newMessage.save();
        io.emit('chat message', `${username}: ${msg}`);
    });

    socket.on('disconnect', () => {
        console.log(`${username} 断开连接`);
    });
});

在客户端连接时,可以获取历史消息并显示:

socket.on('connect', async () => {
    const messages = await Message.find().sort({ timestamp: 1 });
    messages.forEach(message => {
        const item = document.createElement('li');
        item.textContent = `${message.username}: ${message.message}`;
        messages.appendChild(item);
    });
});

通过上述优化和扩展,实时聊天应用变得更加完善和实用,能够满足更多实际场景的需求。

实际项目应用案例 - 图片处理服务

在一些 Web 应用中,需要对用户上传的图片进行处理,如压缩、裁剪、格式转换等。Node.js 的异步编程能力可以有效地处理这些任务,同时处理多个图片处理请求而不会阻塞服务器。

项目需求

开发一个图片处理服务,支持接收用户上传的图片,对图片进行压缩处理,并返回处理后的图片链接。

技术选型

  • Express:用于搭建服务器,处理 HTTP 请求,接收图片上传。
  • Sharp:一个高性能的 Node.js 图像处理库,用于图片压缩等操作。
  • Multer:用于处理文件上传的中间件,方便在 Express 应用中接收图片。

代码实现

  1. 初始化项目: 创建新目录,执行 npm init -y 初始化 package.json 文件。安装所需依赖:
    npm install express sharp multer
    
  2. 编写服务器端代码
const express = require('express');
const app = express();
const multer = require('multer');
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
const util = require('util');

const storage = multer.memoryStorage();
const upload = multer({ storage: storage });

const writeFilePromise = util.promisify(fs.writeFile);

app.post('/upload', upload.single('image'), async (req, res) => {
    try {
        const compressedImage = await sharp(req.file.buffer)
           .jpeg({ quality: 80 })
           .toBuffer();

        const outputPath = path.join(__dirname, 'compressed', `${Date.now()}.jpg`);
        await writeFilePromise(outputPath, compressedImage);

        const imageUrl = `/compressed/${path.basename(outputPath)}`;
        res.json({ imageUrl });
    } catch (err) {
        console.error('图片处理出错:', err);
        res.status(500).send('图片处理失败');
    }
});

const port = 3000;
app.listen(port, () => {
    console.log(`服务器运行在端口 ${port}`);
});

在这段代码中,首先使用 multermemoryStorage 将上传的图片存储在内存中,通过 upload.single('image') 中间件接收名为 image 的单个图片文件。然后使用 sharp 对内存中的图片进行压缩,将图片质量设置为 80% 并转换为 JPEG 格式。接着将压缩后的图片写入到 compressed 目录下,文件名使用当前时间戳,以确保唯一性。最后返回处理后的图片链接给客户端。

优化与扩展

  1. 并发处理:可以使用 Promise.all 实现同时处理多个图片上传请求,提高处理效率。假设客户端同时上传多个图片:
app.post('/upload', upload.array('images', 5), async (req, res) => {
    try {
        const promises = req.files.map(async file => {
            const compressedImage = await sharp(file.buffer)
               .jpeg({ quality: 80 })
               .toBuffer();

            const outputPath = path.join(__dirname, 'compressed', `${Date.now()}.jpg`);
            await writeFilePromise(outputPath, compressedImage);

            return `/compressed/${path.basename(outputPath)}`;
        });

        const imageUrls = await Promise.all(promises);
        res.json({ imageUrls });
    } catch (err) {
        console.error('图片处理出错:', err);
        res.status(500).send('图片处理失败');
    }
});

这里使用 upload.array('images', 5) 接收最多 5 个名为 images 的图片文件数组。通过 map 方法为每个图片创建一个处理 Promise,然后使用 Promise.all 并行处理所有图片,最后返回所有处理后的图片链接。

  1. 支持多种格式处理:可以扩展代码,支持更多图片格式的处理。例如,除了 JPEG 格式,还支持 PNG 格式:
app.post('/upload', upload.array('images', 5), async (req, res) => {
    try {
        const promises = req.files.map(async file => {
            let compressedImage;
            const fileExtension = path.extname(file.originalname).toLowerCase();
            if (fileExtension === '.png') {
                compressedImage = await sharp(file.buffer)
                   .png({ quality: 80 })
                   .toBuffer();
            } else {
                compressedImage = await sharp(file.buffer)
                   .jpeg({ quality: 80 })
                   .toBuffer();
            }

            const outputPath = path.join(__dirname, 'compressed', `${Date.now()}${fileExtension}`);
            await writeFilePromise(outputPath, compressedImage);

            return `/compressed/${path.basename(outputPath)}`;
        });

        const imageUrls = await Promise.all(promises);
        res.json({ imageUrls });
    } catch (err) {
        console.error('图片处理出错:', err);
        res.status(500).send('图片处理失败');
    }
});

在这个优化版本中,根据上传图片的原始文件名扩展名判断图片格式,如果是 PNG 格式,则使用 sharp 进行 PNG 格式的压缩处理,否则进行 JPEG 格式的压缩处理,从而支持多种图片格式的处理需求。

通过以上实际项目应用案例,我们可以看到 Node.js 异步编程在不同场景下的重要性和实用性。无论是 Web 爬虫、实时聊天应用还是图片处理服务,异步编程都使得 Node.js 能够高效地处理大量并发任务,提供良好的用户体验和系统性能。在实际开发中,根据具体项目需求选择合适的异步编程方式(回调函数、Promise、async/await),并进行合理的优化和扩展,是开发高质量 Node.js 应用的关键。