事件驱动架构

1 简介

事件驱动其实是一种设计模式,其中程序的执行流程不是由预先定义的顺序控制流驱动的,而是由外部事件或者内部事件触发的。在事件驱动里面有很多核心的概念:事件源,事件,事件处理器,事件队列,事件循环

  • 事件源:事件源就是发生事件的对象或者系统部分。比如一个按钮点击事件的事件源就是用户界面的按钮。
  • 事件:事件是引起系统反应的信号。它可以是由外部输入(比如用户的键盘输入,鼠标点击),也可以是系统内部产生的(比如说定时器触发,数据到达等)
  • 事件处理器:事件处理器就是当事件发生的时候处理事件的代码。事件处理器通常是一个函数,它定义了事件发生时系统应采取的行动。
  • 事件队列:事件队列是一个先进先出的数据结构,用于存储待处理的事件。当事件发生的时候,它会被加入到事件队列里面,等待事件循环进行处理。
  • 事件循环:事件循环是事件驱动核心机制,它会不断地检查事件队列是否存在待处理的事件。如果有的话,事件循环会取出事件队列中的事件,并调用相应的事件处理器。

概念的东西肯定是要说下的,接下来介绍一个例子让读者好好理解这几个概念,并且能够理解什么是事件驱动架构。假如我们设计一个基本的天气警报系统,这个系统会根据当前的天气条件发出警告。在这个系统中,天气变化就是一个外部事件,而警报系统的响应则应是内部事件处理的结果。

  • 事件源:这里的事件源就是气象站,它会去不断监测天气的变化。
  • 事件:当气象站发现天气变化时,比如天气要刮大风,它就会生成一个事件。
  • 事件处理器:这里的事件处理器就是警报系统。它订阅了气象站的事件,当接收到特定类型的事件时,它会被触发。
  • 事件队列:在警报系统里面可能存在多个事件处理器,比如发送短信警告,更新网页通知等。所有这些处理器被触发的时候,就会做出对应的反应,接着排队等待处理。
  • 事件循环:当警报系统检测到刮大风的事件时,事件循环就会去调用,接着调用发送短信的处理器做出反应。

总结:有一天晚上突然气象站检测到刮大风了,这出发了一个“大风警报”事件,警报系统事件循环检测到这个事件,接着它将调用发送短信警告的事件处理器,向所有订阅了天气警报的用户发送一条紧急信息,提醒用户将要刮大风了。希望通过上面的例子,你可以对事件驱动的基本概念有一些认识了。

2 为什么选择事件驱动?

在传统的顺序控制流设计中,警报系统可能需要周期性地查询气象站地天气数据,然后决定是否需要发送警报。这种方法不仅效率低(因为会一直调用查询),而且还有可能错过突发的天气变化。
相比之下,事件驱动架构它其实是等事件发生来触发警报,这样效率高,而且非常及时,并且这样也会减轻气象站的压力,它不用一直响应警报系统的查询。

3 事件驱动的优点

  • 高响应性:当事件发生时,才会触发事件的处理,它可以在任意时间响应新事件。
  • 灵活性:事件驱动架构允许响应各种类型的事件比如说这里的警报系统可以响应温度骤降,刮大风,下暴雨等等事件,可以让系统很容易地扩展和适应不同的场景和需求。
  • 可扩展性:事件驱动架构通过事件和事件处理器地解耦,使得添加新的事件类型和处理器变得简单。这样有利于系统的扩展,而且也不会影响系统原来的功能。

4 Node.js的事件机制

  • events模块
    Node.jsevents模块,它是实现事件驱动架构的核心模块,提供了EventEmitter类(最关键的部分)。
    通过这个模块你可以创建特定类型的事件处理器,这样你的应用程序就能够对特定的事件做出反应了。
  • EventEmitter类
    EventEmitter类的使用,包括如何创建事件发射器、绑定事件处理器、触发事件。
const EventEmitter = require('events');

方式一:
// 创建事件发射器 可以通过继承EventEmitter类来创建事件发射器
class MyEmitter extends EventEmitter {
  constructor() {
    super();
  }

  emitEvent() {
    // 触发事件(发生event类型的事件)
    this.emit('event');
  }
}

const myEmitter = new MyEmitter();

// 监听事件(绑定事件处理函数) 创建event类型的事件处理器
myEmitter.on('event', () => {
  console.log('Event emitted!');
})

myEmitter.emitEvent();

方式二:
// 创建事件发射器 可以通过直接实例化EventEmitter类来创建事件发射器
const emitter = new EventEmitter();

// 监听事件(绑定事件处理函数) 创建greeting类型的事件处理器
emitter.on('greeting', (who) => {
  console.log(`Hello, ${who}!`);
})

// 触发事件(发生greeting类型的事件)
emitter.emit('greeting', 'John Doe');

5 示例:使用EventEmitter

  • 实践练习:尝试自己创建一个事件驱动的小应用,比如一个计时器或简单的事件日志记录器。
const EventEmitter = require('events');

const timeEmitter = new EventEmitter();
const eventLogger = new EventEmitter();

// 事件处理器:日志记录类型
eventLogger.on('log', (msg) => {
  let date = new Date().toISOString()
  console.log(`时间:${date},发生事件:${msg}`);
})

// 事件处理器:统计特定事件的发生次数
let errorCount = 0;
eventLogger.on('error', () => {
  errorCount++;
  console.log(`发生错误次数:${errorCount}`);
})

// 事件处理器:计时器
timeEmitter.on('timer', (type) => {
  if (type === 0) {
    console.log(`计时器已启动,时间为${new Date().toLocaleTimeString()}`);
  } else {
    console.log(`计时器结束,时间为${new Date().toLocaleTimeString()}`);
  }
})

// 触发事件
eventLogger.emit('log', '程序启动');
timeEmitter.emit('timer', 0);
setTimeout(() => {
  eventLogger.emit('log', '程序运行中');
  eventLogger.emit('error');
  eventLogger.emit('error');
  eventLogger.emit('log', '程序结束');
  timeEmitter.emit('timer', 1);
}, 2000);

6 事件驱动与异步编程

  • 异步I/O
    对于非阻塞I/O模型来说它是Node.js保证高并发的基础,异步I/O是指在执行I/O操作的时候,程序不会阻塞去等待I/O操作的完成,而是继续执行后续代码,当I/O操作完成后,事件循环会把它放入到事件队列之中,当事件循环轮询到这个事件的时候,去调用相应的处理器其实就是回调函数。
  • 案例分析:分析一个使用异步文件读写的示例,展示事件驱动架构在处理异步操作中的作用。
示例:
const fs = require('fs');

fs.readFile('file.txt', 'utf-8', (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log("文本的内容为", data);

  const writeData = data + '肉夹馍'
  fs.writeFile('file.txt', writeData, (err) => {
    if (err) {
      console.error(err);
      return;
    }
    console.log("文件已更新");
  })
})

console.log('程序继续执行');


这里我们打印的结果为

程序继续执行
文本的内容为 我爱吃肉夹馍
文件已更新

从这里我们可以分析得到跟我们说的js中的事件循环机制是一样的,异步函数不会影响程序的正常执行,因为I/O操作本身就是异步的,所以我们这里首先会打印程序继续执行。接着事件循环会去调用readFile的回调函数,执行里面的内容。

7 构建一个事件驱动的实时聊天应用

  • 需求分析:定义一个实时聊天应用的功能需求,包括用户登录、发送消息、接收消息等。
  • 设计与实现:使用Node.js的events模块和其他相关模块,设计并实现一个简单的实时聊天服务器。
  • node.js服务端代码:
// 实现一个事件驱动的实时聊天应用
// 我们需要去创建一个服务器,这样别人发送信息都先传入到我们的服务器,然后我们再进行分发
// 创建一个简单的WebSocket服务器,使用express来处理HTTP请求,ws来处理WebSocket连接
// 关于WebSocket,我们到时候专门玩一下
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服务器。

// 这里就表示用户端已经连接上了,并且可以进行通信了
wss.on('connection', (ws) => {
  ws.userName = 'Anonymous'; // 默认匿名用户

  // 监听用户发送的消息
  ws.on('message', (message) => {
    // 广播发送信息到聊天室里面
    broadcast(ws, message);
  })

  // 监听用户断开连接
  ws.on('close', (message) => {
    // 广播用户退出聊天室的消息
    broadcast(ws, message);
  })
})

// 广播消息到所有连接的客户端
function broadcast(sender, message) {
  // 将消息转换为JSON格式
  const parsedMessage = JSON.parse(message);
  // 判断消息类型,当类型为login时,表示用户登录
  if (parsedMessage.type === 'login') {
    // 为用户设置用户名(其实sender就是一个WebSocket对象所以这里我创建了一个userName属性)
    sender.userName = parsedMessage.userName;
    // 广播用户登录的消息
    wss.clients.forEach(client => {
      if (client !== sender && client.readyState === client.OPEN) {
        client.send(JSON.stringify({
          type: 'message',
          userName: sender.userName,
          message: `加入聊天室`
        }));
      } else {
        client.send(JSON.stringify({
          type: 'message',
          userName: sender.userName,
          message: `登录成功!`
        }));
      }
    });
  } else if (parsedMessage.type === 'message') {
    // 广播用户发送的消息
    wss.clients.forEach(client => {
      // 确保客户端的连接是打开状态, 并且保证不是本人
      if (client !== sender & client.readyState === client.OPEN) {
        client.send(JSON.stringify({
          type: 'message',
          userName: sender.userName,
          message: parsedMessage.message
        }))
      }
    })
  } else if (parsedMessage.type === 'logout') {
    // 广播用户发送的消息
    wss.clients.forEach(client => {
      // 确保客户端的连接是打开状态, 并且保证不是本人
      if (client !== sender & client.readyState === client.OPEN) {
        client.send(JSON.stringify({
          type: 'message',
          userName: sender.userName,
          message: `退出聊天室`
        }))
      } else {
        client.send(JSON.stringify({
          type: 'message',
          userName: sender.userName,
          message: `退出成功!`
        }));
      }
    })
  }
}

// 以便接收来自客户端的连接请求
server.listen(8080, () => {
  console.log('服务器开始监听8080端口');
});
  • html代码:
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Real-Time Chat</title>
</head>

<body>
  <input id="username" placeholder="请输入账号">
  <button onclick=" connect()">登录</button>
  <button onclick=" logout()">退出</button>
  <ul id="messages"></ul>
  <form id="message-form">
    <input id="message-input" placeholder="输入发送的信息">
    <button type="submit">发送</button>
  </form>

  <script>
    let socket;
    // 获取信息
    const messages = document.getElementById('messages');
    // 获取输入框
    const messageForm = document.getElementById('message-form');
    // 获取输入框的输入内容
    const messageInput = document.getElementById('message-input');
    // 获取用户名输入框
    const usernameInput = document.getElementById('username');

    // 在浏览器环境中,WebSocket实例的监听方法跟服务端不一样,
    // 使用addEventListener来添加监听或者用onmessage/onopen/onerror/onclose来监听从服务端返回的内容
    function connect() {
      const username = usernameInput.value.trim();
      if (!username) {
        alert('请填写用户名!');
        return;
      }
      if (socket && socket.readyState !== WebSocket.CLOSED) {
        alert('您已经登录!');
        return;
      }
      // 表示正在传建一个连接到本地主机的3000端口的WebSoket实例,使用的是非加密的WebSocket协议
      // 创建WebSocket实例
      // 参数:WebSocket的URL,例如:ws://localhost:3000
      socket = new WebSocket(`ws://localhost:8080`); // 为什么要在这里加ws呢?是因为ws://表示使用非加密的WebSocket协议,如果是wss://:表示使用加密的WebSocket协议

      // 监听连接状态
      socket.onopen = () => {
        socket.send(JSON.stringify({ type: 'login', userName: usernameInput.value }));
      };
      socket.onclose = () => {
        console.log('已断开连接');
      };
      // 监听服务器返回的消息
      socket.onmessage = (msg) => {
        const messageElement = document.createElement('li');
        const data = JSON.parse(msg.data)
        messageElement.textContent = `${data.userName}: ${data.message}`;
        messages.appendChild(messageElement);
      }
      socket.onerror = (error) => {
        console.error('WebSocket error:', error);
      };
      // 使用addEventListener来监听
      // socket.addEventListener('message', (event) => {
      //   const message = JSON.parse(event.data);
      //   const messageElement = document.createElement('li');
      //   messageElement.textContent = `${message.userName}: ${message.text}`;
      //   messages.appendChild(messageElement);
      // })
    }

    // 退出登录
    function logout() {
      if (socket && socket.readyState !== WebSocket.CLOSED) {
        socket.send(JSON.stringify({ type: 'logout', userName: usernameInput.value }));
      }
      alert('退出成功!');
      // 关闭WebSocket连接
      socket.close();
      // 清除状态,例如重置用户名和消息列表
      usernameInput.value = '';
      messages.innerHTML = '';
      // 清除socket变量
      socket = null;
    }

    // 发送消息
    messageForm.addEventListener('submit', (event) => {
      event.preventDefault();
      if (messageInput.value) {
        socket.send(JSON.stringify({ type: 'message', message: messageInput.value }));
        messageInput.value = '';
      }
    })
  </script>
</body>

</html>
  • 实现效果:

image.png

8 探索Node.js的事件循环机制

  • 内部原理:
    关于Node.js的事件循环机制我在玩转时间循环机制那篇文章也提到过,那里有更加详细的讲解关于事件循环机制的原理,其实基本的原理都是一样的。
  • 性能调优:
    如果我们想要性能比较好的话,我们需要注意下面几点:

    1. 避免长时间运行的同步代码:减少长时间的同步代码,因为同步代码是会阻塞程序的,它不是异步代码,导致其他任务无法执行。
    2. 利用集群模块:对于CPU密集型操作,可以使用Node.jscluster模块来创建多个工作进程,从而充分利用多核处理器的能力。(这个随着深入的学习之后我们再玩玩)
    3. 使用定时器和setImmediate:使用setTimeout(fn, 0)可以将任务推迟到下一次事件循环的开始执行,这有助于避免阻塞事件循环。(这个api我已经在之前的文档介绍过了,如果想要修炼,请少侠移步玩转setTimeout和setInterval学习!)setImmediate则保证回调在当前事件循环的末尾执行,玩法嘛就是下面这种
// 引入模块
const { setImmediate } = require('timers');

setImmediate(() => {
  console.log('我爱吃');
});

console.log('肉夹馍');       

少侠,自己去玩玩吧!请把结果告诉我,谁叫我爱吃肉夹馍,哈哈


肉夹馍
4 声望2 粉丝

努力学习,想要做全栈工程师的前端从事人员😁