第5章 常 用 服 务 对象管理、路由系统、数据操作组成了Web框架三大基础模块,三大模块共同实现Web业务的基础架构。在日益多变的Web业务开发场景下,开发者还需要使用一些外部服务来满足业务需求,本章将介绍这些常见的外部服务在框架内的集成和应用。 25min 5.1消息队列功能 消息队列服务是在服务器端开发中比较常见的外部服务之一,尤其是在分布式架构的场景下,异构服务之间的消息是系统各部分通信交互的主要手段。 5.1.1RabbitMQ RabbitMQ是消息队列服务最具代表性的方案之一。RabbitMQ服务器采用高级消息队列协议(Advanced Message Queuing Protocol,AMQP)是不同的服务之间进行通信,以及进行消息队列处理工作。 RabbitMQ是基于MPL协议开源的消息队列服务软件,是软件商LShift提供的AMQP协议的实现,其开发语言是以高性能、健壮及可伸缩性著称的Erlang语言。 RabbitMQ消息队列服务的主要作用是在不同程序之间进行通信。以学校或公司门岗举例,如图51所示。 图51门岗是通信的关键桥梁 当左边的外卖/快递小哥往门岗放上一份商品时,门岗会通知消费者取走商品。这时门岗的作用就相当于RabbitMQ,从图51可得出RabbitMQ的几个特点: (1) 接收生产者的信息并主动通知消费者取走。RabbitMQ可以让消息在生产者和消费者所代表不同的应用程序之间传递起来。 (2) 生产者和消费者双方的逻辑互不依赖。门岗的存在让两边的人员在不需要接触对方的情形下完成各自的事情。同样地,RabbitMQ两边的程序,即生产者和消费者也是相互独立的,只顾完成自身的业务逻辑,甚至两者由两种不同编程语言写成,因此,RabbitMQ能够使双方隔离,确保其中一方的代码修改不会影响到另一方。 (3) 具备访问削峰能力。削峰是指在高并发的访问场景下,把不同时刻大小不一的并发请求平均分摊,使每个单位时间内,只有固定数量的请求达到Web程序进行处理,确保系统不会被瞬间的高流量冲垮。正如图51的门岗,当消费者数量不足以处理当前商品时,商品会被暂时保留在门岗,等消费者完成前一单的商品消费后再取用。同样RabbitMQ具备存储堆积消息的能力,让消费者在保证自身稳定的前提下持续地进行消费处理。 (4) 生产者和消费者具备横向扩展能力。RabbitMQ支持多个生产者和消费者的使用。在任一方需要增加服务器数量时,新加的服务器只需进行简单配置就能接入RabbitMQ服务。 学习RabbitMQ,需要了解以下两个概念。 (1) 队列(Queues): RabbitMQ存放消息的载体。消费者程序监听着指定的队列,队列一旦有新的消息,RabbitMQ就会通知这些消费者收取消息。 (2) 交换机(Exchanges): RabbitMQ接收消息的载体。一个或者多个队列通常会绑定在某个交换机上,当生产者的消息被发送至该交换机时,RabbitMQ会给绑定到该交换机的队列都投递一份消息。 注意: 生产者的消息也可以直接被发送到队列,供监听该队列的消费者使用。对比使用交换机队列的消息传递方式,直接使用队列收发消息的方式缺乏灵活性,只适用于较简单的消息传递的场合。 RabbitMQ的主要作用是消息通知。Web业务模块之间的联动就会产生通知信息,模块间的通知关系主要有两类: (1) 一对一的通知关系。一对一是同一个业务逻辑的前后两个步骤被拆分到不同的模块进行,因此需要模块间的通信调度,例如直播App的直播间送礼物操作。礼物模块给信令模块发送通知,信令模块便发送指令,让App播放送礼物的效果如图52所示。从实现角度而言就是仅有单个队列绑定交换机,生产者和消费者是固定的两个模块。 图52一对一的通知关系 (2) 一对多的通知关系,一对多是一个业务逻辑引发的连锁反应。例如一些App购买VIP可赠送多种特权,其业务逻辑是用户成功购买VIP时,VIP模块会将购买消息发送到特定的交换机,该交换机绑定了多个队列,当交换机把消息投递到这些队列时,监听这些队列的各个特权模块便会收到消息,独立完成对应的特权赠送工作,如图53所示。 图53一对多的通知关系 5.1.2安装RabbitMQ RabbitMQ的安装可以使用图形化的Docker Desktop,也可以使用Docker命令行,本节分别介绍这两种安装方式。 1. Docker Desktop安装RabbitMQ 打开Docker Desktop搜索rabbitmq,选择第1个带DOCKER OFFICIAL IMAGE的结果,tags选择3management,这是带有Web管理界面的版本,如图54所示,单击Run按钮即可拉取RabbitMQ镜像。 图54Docker Desktop 安装RabbitMQ 当完成拉取镜像时,Docker Desktop会弹出Run a new container窗口,可对即将运行的容器进行配置,这时需要填5672和15672这两个端口,如图55所示,其中5672是RabbitMQ服务器端口,Web程序将通过5672端口访问RabbitMQ的服务,而15672是Web管理界面的端口,开放此端口可以用浏览器管理RabbitMQ。 图55配置RabbitMQ服务器端口 继续单击Run按钮,直至RabbitMQ启动完成,如图56所示。 图56RabbitMQ启动 2. Docker命令行安装RabbitMQ Docker命令行安装RabbitMQ较简单,只需配置5672和15672这两个端口,镜像版本是带Web管理界面的rabbitmq:3management,命令如下: docker run -d -p 5672:5672 -p 15672:15672 rabbitmq:3-management 运行此命令会拉取RabbitMQ镜像并启动,如图57所示。 图57命令行安装RabbitMQ 3. 登入管理界面 RabbitMQ启动后,使用浏览器打开http://localhost:15672/,可以看到RabbitMQ的Web管理界面,如图58所示。 图58登入管理界面 管理界面的Username和Password默认都是guest,输入后单击Login按钮即可看到Web管理界面,如图59所示。 图59RabbitMQ的Web管理界面 5.1.3创建交换机和队列 本节介绍如何在Web管理界面上创建交换机和队列,以及如何对它们进行绑定。 1. 创建交换机 单击菜单Exchanges一栏,打开交换机列表界面,在页面底部的Add a new exchange表单填写新的交换机名称myexchanges,如图510所示。 图510填写新交换机名称 其他选项保持默认,单击Add exchange按钮即可在列表看到新创建的交换机,如图511所示。 图511完成创建交换机 2. 创建队列 单击菜单Queues and Streams一栏,打开队列界面。在页面底部Add a new queue表单填写新队列名称myqueues,如图512所示。 图512填写新队列名称 其他选项保持默认,单击Add queue按钮即可看到列表的新建队列,如图513所示。 图513完成创建队列 3. 绑定交换机 单击新建的myqueues队列,进入该队列的管理界面,如图514所示。 该界面的Overview视图可以观察队列的流量情况,Consumers栏是监听此队列的消费者,Bindings是队列绑定的交换机。这些目前暂无数据。 在Bindings栏的底部Add binding to this queue表单里填写前面创建的交换机名称myexchanges,单击Bind按钮即可绑定交换机。 这时Bindings栏会显示交换机和队列的绑定关系,如图515所示。 图514队列管理界面 图515绑定交换机 5.1.4使用amqplib库 RabbitMQ使用amqplib库,安装命令如下: npm install amqplib 新增testmq.class.ts文件,简单地测试amqplib库的使用,代码如下: //chapter05/01-message/test/src/test-mq.class.ts import { component, getMapping } from "../../"; import { connect } from "amqplib"; @component export default class TestMq { @getMapping("/mq/sendByQueue") async sendMq() { const queue = 'myqueues'; const text = "hello world, by queue"; //开启RabbitMQ连接 const connection = await connect('amqp://localhost'); //创建通道 const channel = await connection.createChannel(); //检查队列是否存在 await channel.checkQueue(queue); //将消息发送到队列 channel.sendToQueue(queue, Buffer.from(text)); console.log(" [x] Sent by queue '%s'", text); //关闭通道 await channel.close(); return "sent by queue"; } @getMapping("/mq/sendByExchange") async sendMq2() { const exchange = 'myexchanges'; const text = "hello world, by exchange"; //开启RabbitMQ连接 const connection = await connect('amqp://localhost'); //创建通道 const channel = await connection.createChannel(); //检查交换机是否存在 await channel.checkExchange(exchange); //将消息发送到交换机 channel.publish(exchange, '', Buffer.from(text)); console.log(" [x] Publish by exchange '%s'", text); //关闭通道 await channel.close(); return "sent by exchange"; } @getMapping("/mq/listen") async testMq() { //开启RabbitMQ连接 const connection = await connect('amqp://localhost'); //创建通道 const channel = await connection.createChannel(); const queue = 'myqueues'; const queue2 = 'myqueues2'; //检查队列queue是否存在 await channel.checkQueue(queue); //检查队列queue2是否存在,如果不存在,则创建队列 await channel.assertQueue(queue2); //监听队列queue,如果收到消息,则调用回调函数打印输出 await channel.consume(queue, (message) => { console.log(" [x] Received '%s'", message.content.toString()); }, { noAck: true }); //监听队列queue2,如果收到消息,则调用回调函数打印输出 await channel.consume(queue2, (message) => { console.log(" [x] Received queue2 '%s'", message.content.toString()); }, { noAck: true }); return "ok"; } } TestMq类有3个页面方法sendMq()、sendMq2()和testMq(),下面分别介绍它们的作用。 1. 监听队列 在5.1.1节介绍消费者需要监听队列,获取队列消息并进行处理。监听队列的页面方法是testMq(),方法里首先创建RabbitMQ的连接和channel。channel是处理消息的通道,每次对RabbitMQ进行操作都必须先创建通道,通道可复用。 channel使用checkQueue()方法检查队列myqueues的有效性,然后使用assertQueue()方法检查myqueues2的有效性。对比checkQueue()方法,assertQueue()方法可以在队列不存在时自动创建队列,因此这里会自动创建myqueues2队列,如图516所示。 图516assertQueue()方法创建新队列 接下来channel使用consume()方法监听队列,consume()方法有3个参数,分别如下: (1) 监听的队列名称,本例中是queue变量。 (2) 回调函数,用于接收队列消息进行处理,回调函数的参数message是接收到的信息。回调函数的内容是把接收的信息输出在命令行。 (3) 接收消息的配置,这里将noAck配置为true表示无须给RabbitMQ回复确认。 运行程序,使用浏览器访问http://localhost:8080/mq/listen,即可开启监听功能,这时命令行并没有输出消息。 打开RabbitMQ的管理界面,在队列一栏可见myqueues2队列已经创建,如图516所示。单击其中的一个队列,如图517所示,在消费者Consumers列表可以看到新的消费者,其IP则是当前机器的IP。 图517队列的消费者列表 队列消费者是跟随channel而存在的,因此当按快捷键Ctrl+C结束程序时,消费者列表就消失了,如图518所示。 图518停止程序后队列的消费者消失 2. 发送消息 TestMq类的sendMq()、sendMq2()方法都用于发送消息,两者的区别是sendMq()直接将消息发送到队列,而sendMq2()将消息发送给交换机。 将消息发送到队列,同样先使用channel的checkQueue()或assertQueue()来确保队列存在,然后使用channel.sendToQueue()方法发送消息,注意消息内容需要用Buffer.from()函数转换成Buffer内容。 将消息发送到交换机,首先使用channel的checkExchange()方法或assertExchange()方法来确认交换机的存在,assertExchange()方法也能够在交换机不存在时创建交换机。 注意: 当使用assertExchange()方法创建交换机时,还要用channel.bindQueue(queue, exchange)方法来将交换机绑定到队列,从而使新交换机的消息得到处理。 然后使用channel.publish()方法来将消息发送到交换机,消息内容同样需要使用Buffer.from()函数进行转换,代码如下: channel.publish(exchange, '', Buffer.from(text)); 运行程序,必须先访问http://localhost:8080/mq/listen以开启监听队列,然后打开http://localhost:8080/mq/sendByQueue将消息发送到队列。在管理界面的myqueues队列页面里,可以看到Message rates一栏会显示消息的速率,证明消息已经被发送到队列,如图519所示。 图519队列界面显示消息的速率 另外,由于消息马上被testMq()方法监听到并消费了,因此在Queued message一栏并没有显示,或者说速度太快而没来得及显示。 这时在命令行可以看到sendMq()打印了发送消息的日志,而testMq()接收到消息并将消息打印了出来,如图520所示。 图520消息被接收并显示输出 同样,使用浏览器访问http://localhost:8080/mq/sendByExchange也可以看到myexchanges交换机界面显示了消息的速率,如图521所示。 图521交换机界面显示消息的速率 这时监听队列的testMq()页面在命令行输出了sendMq2()发送给交换机的消息,如图522所示。 图522显示交换机绑定的队列消息 5.1.5监听消息装饰器 从RabbitMQ的使用情况可以看到,主要的操作是监听消息及发送消息,因此本节和5.1.6节将分别讲述如何将两者集成到框架。 监听消息需要标识监听的是哪个队列,并且要提供接收处理消息的函数体,因此框架设计了@rabbitListener带参数的方法装饰器作为监听消息装饰器。 @rabbitListener装饰器的参数是带监听的队列名称,装饰的方法是接收到消息进行处理的方法,其逻辑和5.1.4节的channel.consume()方法相对应。 @rabbitListener装饰器的代码如下: //chapter05/01-message/src/default/rabbitmq.class.ts let rabbitConnection = null; //获取通道函数,确保通道唯一,并且当程序关闭时退出 async function getChannel() { if (rabbitConnection === null) { rabbitConnection = await connect(config("rabbitmq")); process.once('SIGINT', async () => { await rabbitConnection.close(); }); } const channel = await rabbitConnection.createChannel(); return channel; } //RabbitMQ监听队列装饰器 function rabbitListener(queue: string) { return (target: any, propertyKey: string) => { (async function () { //创建通道 const channel = await getChannel(); //检查队列是否存在 await channel.checkQueue(queue); //监听队列queue,如果收到消息,则调用当前被装饰的方法 await channel.consume(queue, target[propertyKey], { noAck: true }); }()); } } rabbitmq.class.ts的全局变量rabbitConnection用于存放RabbitMQ的链接实例,@rabbitListener装饰器和发送消息的RabbitMQ类都使用了该链接实例,因此rabbitConnection是全局变量。 getChannel()方法会检查rabbitConnection是否已经被初始化,如果未被初始化,则将使用程序的rabbitmq配置项进行RabbitMQ链接实例化操作。需要注意getChannel()用了process.once('SIGINT')方法设置在程序关闭时调用rabbitConnection.close()方法来关闭RabbitMQ链接,以确保链接资源得到释放。 rabbitmq的配置项的代码如下: //chapter05/01-message/test/src/config.json "rabbitmq": { "protocol": "amqp", "hostname": "127.0.0.1", "port": 5672, "username": "guest", "password": "guest" } @rabbitListener装饰器的代码和channel.consume()方法的使用类似,首先从getChannel()方法取得本次通信的channel,然后检查队列是否存在,之后将被装饰的方法target[propertyKey]作为channel.consume()的第2个参数,启动队列的监听。 注意: 由于amqplib库是异步操作的,因此RabbitMQ类和@rabbitListener装饰器都使用async/await异步关键字进行编码。 在TestMq类编写listen()方法,用@rabbitListener进行装饰,观察其是否可以达到和/mq/listen页面相同的效果,代码如下: //chapter05/01-message/test/src/test-mq.class.ts @rabbitListener("myqueues") public async listen(message) { log(" Received by Decorator '%s'", message.content.toString()); } 运行程序,无须先访问/mq/listen页面,访问http://localhost:8080/mq/sendByQueue,即可看到listen()方法收到消息的日志输出,如图523所示。 图523@rabbitListener装饰器监听消息 5.1.6注入发送消息方法 rabbitmq.class.ts文件导出了RabbitMQ类,由于RabbitMQ类的实例化方法getRabbitMQ()被@bean装饰,因此它可以被@autoware注入其他类里使用。getRabbitMQ()有检查RabbitMQ的配置项的逻辑,避免在没有配置RabbitMQ时使用。 RabbitMQ类提供了将消息发送到交换机的publish()方法和将消息发送到队列的send()方法,这两个函数分别是publishMessageToExchange()和sendMessageToQueue()的别名。 publishMessageToExchange()和sendMessageToQueue()逻辑基本类似,先从getChannel()获取当前RabbitMQ的链接实例,然后用channel.checkExchange()和channel.checkQueue()检查交换机和队列,接着使用channel.publish()和channel.sendToQueue()发送消息,代码如下: //chapter05/01-message/src/default/rabbitmq.class.ts class RabbitMQ { //提供RabbitMQ对象 @bean public getRabbitMQ(): RabbitMQ { if (!config("rabbitmq")) { return null; } return new RabbitMQ(); } //将信息发布到交换机 public async publishMessageToExchange(exchange: string, routingKey: string, message: string): Promise<void> { const channel = await getChannel(); await channel.checkExchange(exchange); channel.publish(exchange, routingKey, Buffer.from(message)); await channel.close(); } //将消息发送到队列 public async sendMessageToQueue(queue: string, message: string): Promise<void> { const channel = await getChannel(); await channel.checkQueue(queue); channel.sendToQueue(queue, Buffer.from(message)); await channel.close(); } //publishMessageToExchange()方法的别名 public async publish(exchange: string, routingKey: string, message: string): Promise<void> { await this.publishMessageToExchange(exchange, routingKey, message); } //sendMessageToQueue()方法的别名 public async send(queue: string, message: string): Promise<void> { await this.sendMessageToQueue(queue, message); } } 这时在TestMq()中加入sendByMQClassExchange()和sendByMQClassQueue()来测试上述两种方法,代码如下: //chapter05/01-message/test/src/test-mq.class.ts @component export default class TestMq { //注入RabbitMQ对象 @autoware private rabbitMQ: RabbitMQ; //用RabbitMQ装饰器监听队列 @rabbitListener("myqueues") public async listen(message) { log(" Received by Decorator '%s'", message.content.toString()); } @getMapping("/mq/sendByMQClassExchange") async sendByMQClassExchange() { //将消息发布到myexchanges交换机 await this.rabbitMQ.publish("myexchanges", "", "hello world, by MQClass Exchange"); return "sent by MQClass"; } @getMapping("/mq/sendByMQClassQueue") async sendByMQClassQueue() { //将消息发送到myqueues队列 await this.rabbitMQ.send("myqueues", "hello world, by MQClass Queue"); return "sent by MQClass"; } } 运行程序,使用浏览器分别打开/mq/sendByMQClassExchange和/mq/sendByMQClass Queue两个页面,即可看到@rabbitListener()装饰的listen()会输出RabbitMQ分别发送的两次消息,如图524所示。 图524RabbitMQ类成功发送消息 注意: 在后续的代码里,装饰在listen()方法的@rabbitListener()注释被屏蔽了,避免开发者在启动程序时会自动监听而出现异常,读者在需要时可打开。 5.1.7小结 本节讲解了框架内置RabbitMQ消息队列功能的实现。消息队列的核心是消息的传递,因此需要监听队列及发送消息两部分功能: (1) @rabbitListener装饰器是标记了监听队列的方法,其参数是队列名称,当消息被投递到队列时,该方法便会被执行。 (2) RabbitMQ类使用@bean提供了注入对象,使用@autoware装饰器标记RabbitMQ的实例,在代码中即可调用RabbitMQ的send()和publish()方法将消息发送出去。 这两个功能均延续了框架的编码风格,方便开发者直接使用消息队列功能。 5.2Socket.IO 即时通信 即时通信(Instant Messaging)是较为常见的高阶开发需求之一,本节将基于Socket.IO实现框架的即时通信功能。 5.2.1Socket.IO Socket.IO是一个即时通信库,在客户端和服务器端之间实现低延迟、双向和基于事件的通信。Socket.IO支持多端互联的即时通信,在服务器端、Web端、移动端均有对应的开发包,它具备以下优势。 (1) 稳定: Socket.IO项目是一个非常成熟的开源项目,有大量的项目使用案例。 (2) 易用: Socket.IO采用基于事件的通信设计,采用与Node.js EventEmitter相似的编码方式,对JavaScript/TypeScript开发者较为友好,如图525所示。 图525Socket.IO的事件通信 通常即时通信会提到WebSocket技术,WebSocket是基于HTTP的即时通信实现,Socket.IO和WebSocket的关系如下: Socket.IO底层采用WebSocket作为首选的通信协议,但正如Socket.IO官方文档提到的,“Socket.IO不是WebSocket的实现”,因此Socket.IO不能直接连接WebSocket服务。 Socket.IO在实际开发中通常被看作WebSocket的替代方案,相比WebSocket,前者提供了对更多额外通信功能的支持,具体有以下两方面: (1) 在不支持WebSocket协议的场景下,Socket.IO提供了降级方案,如长轮询等技术,确保即时通信在所有场景都能正常运作。 (2) Socket.IO还提供了重连检测、数据离线缓存、ACK确认、广播、多路复用等功能,而WebSocket需要额外编程才能实现这些功能。 5.2.2即时通信 即时通信功能在小游戏、聊天应用、网页的推送通知等场景都有十分广阔的应用空间。 注意: 本书仅限于讨论在Web开发中常见的即时通信场景,不展开更大范围的方案讲述,如大型网络游戏等。 图526即时通信的4个关键点 即时通信技术需要关注4个重点,如图526所示。 (1) 认证: 即时通信的安全保障和用户鉴别基础,提供连接检查、用户识别、会话ID等技术实现。 (2) 事件: 即时通信的通信基础,基于事件的编码逻辑屏蔽底层各种通信细节,让开发者可以专注于编写收发事件的代码,极大地降低了开发复杂度。 (3) 房间(Room): 实现通信的分组机制,拓宽即时通信的应用范围。 (4) 断线: 保证通信正常运作的重要机制,提供断线事件处理、在线连接管理等实现,是极易被忽略但十分重要的一点。 网络游戏是即时通信的典型应用之一,如图527所示为即时通信的4个关键点在游戏里的体现。 图527即时通信的4个关键点在游戏中的体现 (1) 在玩家进入游戏时,游戏服务要进行认证,验证玩家的合法性。需要注意的是,认证的作用不局限于登录时对用户名和密码的验证,它还需要对每次游戏与服务器的连接都进行检查,类似于在3.8.5节提到的JWT鉴权的逻辑。 (2) 游戏内玩家的各种行为,以及玩家间的交互等,其底层的实现都依赖于事件通信,而且事实上包括认证、房间、断线逻辑等在内的各种逻辑,也是特殊的事件种类。开发时采用事件驱动的方式进行编码。 (3) 具体的游戏内容时常需要进行分组,如限定人数的对抗或合作的比赛。这都是将少部分玩家接入一个分组内进行通信的逻辑实现。这就是即时通信的房间概念。 (4) 当玩家退出游戏或在网络不佳的情况下掉线时,断线逻辑将起作用,它能够检查玩家的连接情况,继而重新连接服务器或断开连接等。尤其在对实时性要求较高的游戏里,断线逻辑往往是最难处理的部分。 实现一套完善的即时通信功能,就需要实现这4个关键点的逻辑,更具体点说就是实现这四类事件的监听及提供相应的处理方案。 17min 5.2.3使用Socket.IO Socket.IO的设计抽象了服务及连接两部分,即io对象和socket对象。io对象表示整个Socket.IO应用服务,io对象提供广播信息、配置服务参数、处理底层信息等功能。当io对象的连接事件(onConnected)发生时,表示有新的连接接入,新的连接将产生一个socket对象。socket对象提供单个连接的消息收发、进入或退出房间、断线逻辑、异常捕获等功能。 Socket.IO服务器端使用的是NPM库,安装命令如下: npm install socket.io Socket.IO的初步使用集中在下面3个文件。 (1) src/default/socketio.class.ts: 开启Socket.IO服务,提供@SocketIo.onEvent装饰器。 (2) app/src/testio.class.ts: 使用@SocketIo.onEvent装饰器监听test事件,并显示测试页面。 (3) app/src/views/socket.html: 测试Socket.IO连接服务器和收发信息的页面。 Socket.IO的装饰器与框架的其他装饰器有所不同的是,Socket.IO的装饰器是类的静态方法,其使用和函数作为装饰器的使用并无差异,只是名称会长一些。使用Socket.IO的静态方法装饰器的代码如下: //chapter05/02-socket-io/part1/app/src/test-io.class.ts @component export default class TestIo { //注入Socket.IO对象 @resource() public socketIo: SocketIo; //测试接收事件 @SocketIo.onEvent("test") public connection(socket, message) { console.log(message); //使用两种方式发送广播 this.socketIo.sockets.emit("test","test-from-server2"); socket.emit("test", "test-from-server3"); } //显示页面 @getMapping("/socketIo") public socketIoPage(req, res) { res.render("socket"); } } connection ()方法的装饰器@SocketIo.onEvent()是类静态方法装饰器,这种装饰器的命名较为统一,可直观地看出其属于SocketIo系列的装饰器。@SocketIo.onEvent()的作用是当test事件发生时执行connection()方法。 @SocketIo.onEvent()的代码暂时用于测试,比较简单,代码如下: //chapter05/02-socket-io/part1/src/default/socket-io.class.ts //创建Socket.IO对象 const ioObj:IoServer = new IoServer({ cors: { origin: "http://localhost:8081" } }); class SocketIo extends IoServer { //静态方法装饰器 public static onEvent(event: string) { return (target: any, propertyKey: string) => { ioObj.on("connection", (socket) => { //监听事件 socket.on(event, (message) => { ioObj.emit("test", "test-from-server1"); target[propertyKey](socket, message); }); }); //启动Socket.IO服务 ioObj.listen(8085); } } } export { SocketIo } 代码中全局变量ioObj是Socket.IO的io对象,表示整个Socket.IO服务。ioObj变量在文件开始时被实例化,接着在@SocketIo.onEvent装饰器里调用ioObj.on("connection")方法监听连接事件。 每次新的Socket.IO客户端连接成功时,ioObj.on()就会收到connection事件,这里需要注意以下两点: (1) 每个连接只会触发一次connection事件。 (2) 当connection事件发生时,回调函数的socket暂时没有具体数据,connection事件只是表示连接在网络底层连接成功,但是具体内容并没有开始传输。 ioObj.on()当收到connection事件时会执行回调函数,回调函数的参数是socket对象,表示此次的连接对象。socket对象用socket.on(event)监听网页端的发送event事件。 在回调函数里使用ioObj.emit()发送名为test的全局广播信息,检查网页端能否收到确认信息。接下来在事件里执行被装饰的方法target[propertyKey](),输入socket对象和接收的信息。在文件的最后,ioObj.listen(8085)开启Socket.IO服务,监听8085端口。 由于8085端口和Web服务器端口8080形成了跨域连接,因此要在Socket.IO实例化时进行跨域设置,代码如下: const ioObj:IoServer = new IoServer({ cors: { origin: "http://localhost:8080" } }); 这里可以设置为允许8080端口的地址进行跨域访问,还可以设置如连接超时、重试次数、服务路径、传输包大小等相关参数。这些在后续都将实现为Socket.IO的程序配置。 socketIoPage()方法会输出socket.html页面,作为网页端连接SocketIO服务进行测试,代码如下: //chapter05/02-socket-io/part1/app/src/views/socket.html <html> <head> <title>Socket Test</title> <!--引入Socket.IO客户端库--> <script src="https://cdn.socket.io/4.7.1/socket.io.min.js"></script> <script> var io = io('http://localhost:8085'); io.on('connect', function() { console.log('connected'); }); io.on('test', function(data) { console.log(data); }); </script> </head> <body> <!--发送广播按钮--> <button onclick="io.emit('test', 'test-from-client')">Send</button> </body> </html> socket.html页面首先引入socket.io.min.js库,这是Socket.IO页面端使用的js库。 接着使用io('http://localhost:8085')连接Socket.IO的8085端口,该操作会触发服务器端的ioObj.on("connection")事件。 然后由io.on('connect')监听连接成功的事件,这时页面端代码便能进行其他操作。io.on('test')监听服务器端发送的test事件并输出消息,对应的是ioObj.emit("test")。 socket.html页面的Send按钮可将test事件发送给服务器端。 启动程序,使用浏览器访问网页http://localhost:8080/socketIo,命令行会输出connection事件的打印信息,如图528所示。 图528Socket.IO收到connection事件 在网页端右击并选择检查,打开开发者工具观察控制台输出。单击页面中的Send按钮,可以看到控制台输出了来自服务器端发回的消息,如图529所示。 图529单击Send按钮网页接收到服务器端回复 控制台输出的3行信息分别如下: (1) socket.on(event)发送的testfromserver1。 (2) @SocketIo.onEvent 装饰的connection()方法使用this.socketIo.sockets.emit()发送的testfromserver2。 (3) @SocketIo.onEvent 装饰的connection()方法使用socket.emit()发送的testfromserver3。 后两者分别使用注入的this.socketIo对象发送的消息和事件传入的socket对象发送的消息,可以理解成两者有着同等的作用。 现在保持网页打开状态,在命令行按快捷键Ctrl+C终止程序运行,再重新运行程序,命令行会输出connection事件被触发的日志,如图530所示。 图530网页端断线重连触发connection事件 这是因为网页端在程序关闭后一直尝试断线重连,当服务器端程序再次启动时,网页端会自动连接并触发connection事件。这是Socket.IO断线重连机制起了作用。 5.2.4与Web服务共用端口 接下来改良上述程序,让Socket.IO和ExpressJS的Web服务可以共用端口,并使用应用程序配置。 要使Socket.IO和Web服务共用端口,就要取得ExpressJS的httpServer对象,将其在Socket.IO实例化时输入IoServer,经过IoServer内部处理即可实现共用端口。实现此过程的伪代码如下: const httpServer = createServer(ExpressJS对象); ioObj = new IoServer(httpServer, Socket.IO的配置); httpServer.listen(端口); 首先进行的是SocketIo类的改造,新增静态方法setIoServer(app, ioSocketConfig),该方法的第1个参数是httpServer对象,用于输入IoServer实例化,第2个参数是Socket.IO的应用程序配置,此配置也会输入IoServer,代码如下: //chapter05/02-socket-io/part2/src/default/socket-io.class.ts public static setIoServer(app, ioSocketConfig) { const httpServer = createServer(app); ioObj = new IoServer(httpServer, ioSocketConfig); //其余代码省略 return httpServer; } setIoServer()方法返回httpServer对象,此时httpServer对象在ExpressServer类里继续用于ExpressJS服务开启等操作,代码如下: //chapter05/02-socket-io/part2/src/default/express-server.class.ts export default class ExpressServer extends ServerFactory { ... @value("socket") private socketIoConfig: object; ... public start(port: number): any { ... if(this.socketIoConfig) { const newSocketApp = SocketIo.setIoServer(this.app, this.socketIoConfig); return newSocketApp.listen(port); }else{ return this.app.listen(port); } } ... } ExpressServer类的start()方法增加了SocketIo.setIoServer()的调用,输入参数是当前的app对象和@value注入socket配置项,取得返回值后调用其listen()方法启动Web服务。 这时还需要修改socket.html页面,将其指向的Socket.IO服务地址端口改回8080,代码如下: <head> <title>Socket Test</title> <script src="https://cdn.socket.io/4.7.1/socket.io.min.js"></script> <script> var socket = io('http://localhost:8080'); socket.on('connect', function() { console.log('connected'); }); ... 程序启动后可以看到和5.2.3节演示的效果一样,可以正常连接上Socket.IO服务器。 39min 5.2.5开发Socket.IO装饰器 Socket.IO收发消息功能基本已实现。在实际应用中框架还需要提供认证、断线处理、房间和错误捕获等功能,帮助开发者完成整个即时通信系统的开发。 这些功能对应了即时通信的4个关键点,其对应关系如图531所示。 图531即时通信的4个关键点对应的装饰器功能 注意: 房间是对socket对象的操作,因此可使用join()和leave()等方法实现,无须设计成装饰器。 SocketIo类的其他几个装饰器同样是静态方法,代码如下: //chapter05/02-socket-io/part3/src/default/socket-io.class.ts //监听事件装饰器 public static onEvent(event: string) { return (target: any, propertyKey: string) => { listeners["event"].push([target[propertyKey], event]); } } //错误处理装饰器 public static onError(target: any, propertyKey: string) { listeners["error"] = target[propertyKey]; } //断线处理装饰器 public static onDisconnect(target: any, propertyKey: string) { listeners["disconnect"] = target[propertyKey]; } //连接成功处理装饰器 public static onConnected(target: any, propertyKey: string) { listeners["connected"] = target[propertyKey]; } @SocketIo.onEvent()事件装饰器是SocketIo类装饰器中唯一带参数的方法装饰器,并且@SocketIo.onEvent可以用于装饰多种方法,表示监听多个事件。 @SocketIo.onEvent()的参数是事件名称,当网页端发送该名称的事件时,@SocketIo.onEvent()就会收到信息并执行被装饰的方法。 @SocketIo.onEvent()装饰的方法有两个参数,分别是当前连接的socket对象和收到的信息message。 @SocketIo.onError是错误处理装饰器,当@SocketIo.onEvent()装饰的方法出现错误时,框架便会启动@SocketIo.onError装饰的方法以处理错误,@SocketIo.onError只能被标记在一个方法上,统一处理即时通信的系统错误。 @SocketIo.onError装饰的方法有两个参数,分别是当前连接的socket对象和传递本次错误信息的err对象。 @SocketIo.onDisconnect是断线处理装饰器,当由于网页端关闭网页、网络中断等情形而断开连接时,框架便会启动@SocketIo.onDisconnect装饰的方法。@SocketIo.onDisconnect同样只能被标记在一个方法上,统一处理断线逻辑。 @SocketIo.onDisconnect装饰的方法有两个参数,分别是当前连接的socket对象和引起掉线的原因reason。 @SocketIo.onConnected是连接成功装饰器,当每个连接成功地连上服务时会被执行一次。 @SocketIo.onConnected是构建即时通信系统的重要部分,@SocketIo.onConnected可进行用户认证、请求鉴权、初始化用户数据、发送广播给其他用户等一系列处理。@SocketIo.onConnected同样只能被标记在一个方法上,统一处理连接成功逻辑。 @SocketIo.onConnected装饰的方法有两个参数,分别是当前连接的socket对象和next函数,next()函数在正常情况下可以忽略,仅当出现错误时可调用next(错误信息)将错误转到@SocketIo.onError进行处理。 上述几个装饰器的使用示例详见5.2.6节,这里先来介绍这些装饰器的具体实现过程,它们的执行逻辑保存在setIoServer()方法中,代码如下: //chapter05/02-socket-io/part3/src/default/socket-io.class.ts //将Socket.IO服务绑定到Web服务 public static setIoServer(app, ioSocketConfig) { const httpServer = createServer(app); //创建Socket.IO服务,注意参数为Web服务对象 io = new IoServer(httpServer, ioSocketConfig); //Socket.IO服务的全局中间件,其影响范围是整个服务 io.use((socket, next) => { //当新连接建立时,执行连接成功处理装饰器 if (listeners["connected"] !== null) { listeners["connected"](socket, async (err) => { if (listeners["error"] !== null && err) { await listeners["error"](socket, err); } }); } next(); }); //连接成功事件 io.on("connection", (socket) => { //当收到断线事件时,执行断线处理装饰器 if (listeners["disconnect"] !== null) { socket.on("disconnect", async (reason) => { await listeners["disconnect"](socket, reason); }); } //Socket级中间件,其影响范围是当前连接 socket.use(async ([event, ...args], next) => { try { //遍历事件监听器,将事件分配到具体装饰器 for (let listener of listeners["event"]) { if (listener[1] === event) { await listener[0](socket, ...args); } } } catch (err) { next(err); } }); //当出现错误时,执行错误处理装饰器 if (listeners["error"] !== null) { socket.on("error", async (err) => { await listeners["error"](socket, err); }); } }); //返回Web服务对象 return httpServer; } @SocketIo.onConnected装饰器的实现比较有意思,需要关注两点: 使用io.use()来启动@SocketIo.onConnected标记的方法,其做法类似中间件。只有这样@SocketIo.onConnected才能做到在连接建立完成时立即获取本次连接的socket对象。如果把@SocketIo.onConnected放在connection事件的回调函数里执行,则@SocketIo.onConnected只有在网页端发送第1次事件后才能取得socket对象,这时便无法在第1次事件之前对连接进行检查,容易形成只连接而不发送的攻击漏洞。 @SocketIo.onConnected的错误处理并非直接用中间件的next函数,而是使用了仅有err参数的回调函数。这样做的好处是无须强制开发者在@SocketIo.onConnected装饰的方法中调用next()。 程序接下来处理io.on("connection")事件逻辑,先检查事件名是否是disconnect,当disconnect事件发生时表示网页端出现断线情况,转向执行@SocketIo.onDisconnect装饰器。 进入 io.on("connection") 代表连接成功后,首先看事件是不是 disconnect,由于disconnect 事件代表了网页端已经断线,所以会执行 @SocketIo.onDisconnect 断线处理装饰器。 随后使用socket.use()来处理消息事件的分派,socket.use()是socket对象的中间件写法。这里遍历每个listeners["event"]消息事件,当事件名匹配上listeners["event"]里的事件名时,执行相应的@SocketIo.onEvent事件装饰器。在循环之外使用try catch捕获事件循环产生的错误信息,一旦出现错误便转向@SocketIo.onError。 同时,@SocketIo.onError也会捕获名为error的事件进行处理。 上述装饰器的逻辑示意图如图532所示。 图532SocketIo装饰器的逻辑示意图 5.2.6测试即时通信功能 本节将通过开发即时聊天的简单示例,讲解用户认证、SocketIo装饰器的使用及房间逻辑的开发。 1. 用户认证 在3.8节介绍了Web服务的鉴权过程,网页端将标识用户的JWT密钥附带在HTTP请求的头信息进行传输,Web服务器收到请求时解析头信息,从而验证用户的合法性。 Socket.IO的用户认证逻辑同样是把认证密钥附带在传输信息里,由服务器端取得并进行检查,如图533所示。 图533Socket.IO传输附带认证信息 网页端初始化Socket.IO连接时,可将需要传输的参数输入io()方法,在服务器端接收@SocketIo.onConnected事件时,即可从socket对象的handshake属性里取得这些参数进行验证。 现在来对上述过程进行测试,在@SocketIo.onConnected装饰的connected()方法里输出socket.handshake.auth,代码如下: //chapter05/02-socket-io/part3/app/src/test-socket.class.ts @SocketIo.onConnected public connected(socket, next) { console.log(socket.handshake.auth); } 页面端在io()调用的第2个参数中输入auth值,代码如下: //chapter05/02-socket-io/part3/app/src/views/socket.html var socket = io('http://localhost:8080', { auth: { token: 'SOME_TOKEN', username: 'test' } }); 运行程序,打开地址http://localhost:8080/socketIo,即可看到从socket对象的handshake属性取得了网页端传递的auth信息,如图534所示。 图534从socket对象的handshake属性取得auth信息 2. SocketIo装饰器的使用 TestSocket类使用了上述装饰器,同时输出socket.html页面,代码如下: //chapter05/02-socket-io/part3/app/src/test-socket.class.ts @component export default class TestSocket { //准备两个用户名 static names = ["LiLei", "HanMeiMei"]; //记录当前在线用户 static loginUsers: Map<string, string> = new Map<string, string>(); //连接成功 @SocketIo.onConnected public connected(socket, next) { //取出用户名 let name = TestSocket.names.pop(); //将该用户设置为在线状态 TestSocket.loginUsers.set(socket.id, name); //发送广播,通知所有用户 io.sockets.emit("all", "We have a new member: " + name); } //某个客户端断线 @SocketIo.onDisconnect public disconnect(socket, reason) { //发送广播,通知所有用户 io.sockets.emit("all", "We lost a member by: " + reason); } //该事件将触发错误 @SocketIo.onEvent("test-error") public testError(socket, message) { throw new Error("test-error"); } //错误处理 @SocketIo.onError public error(socket, err) { //发送广播,通知所有用户 io.sockets.emit("all", "We have a problem!"); } //接收say事件,即客户端发送的消息 @SocketIo.onEvent("say") public say(socket, message) { //发送广播,并附带当前发送消息者的ID io.sockets.emit("all", TestSocket.loginUsers.get(socket.id) + " said: " + message); } //该事件会让客户端加入一个房间 @SocketIo.onEvent("join") public join(socket, message) { socket.join("private-room"); //对该房间发送广播,附带当前加入房间的客户端ID io.to("private-room").emit("all", TestSocket.loginUsers.get(socket.id) + " joined private-room"); } //该事件会让客户端离开一个房间 @SocketIo.onEvent("leave") public leave(socket, message) { socket.leave("private-room"); //给房间内余下的客户端发送广播,附带退出房间的客户端ID io.to("private-room").emit("all", TestSocket.loginUsers.get(socket.id) + " leaved private-room"); } //该事件会在房间内发送广播 @SocketIo.onEvent("say-inroom") public sayInRoom(socket, message) { io.to("private-room").emit("all", TestSocket.loginUsers.get(socket.id) + " said in Room: " + message); } //显示Socket.IO测试页面 @getMapping("/socketIo") public socketIoPage(req, res) { res.render("socket"); } } socket.html页面增加了6个按钮和对应事件的发送操作,分别是发送消息、加入房间、离开房间、房间内广播、错误测试和关闭连接,代码如下: //chapter05/02-socket-io/part3/app/src/views/socket.html <html> <head> <title>Socket Test</title> <script src="https://cdn.socket.io/4.7.1/socket.io.min.js"></script> <script> var socket = io('http://localhost:8080'); socket.on('connect', function() { console.log('connected'); }); socket.on('all', function(data) { console.log(data); }); </script> </head> <body> <button onclick="socket.emit('say', 'I say some thing')">发送消息</button> <button onclick="socket.emit('join', 'I want to join the room')">加入房间</button> <button onclick="socket.emit('leave', 'I want to join the room')">离开房间</button> <button onclick="socket.emit('say-inroom', 'say some in room')">房间内广播</button> <button onclick="socket.emit('test-error', 'this is a error')">错误测试</button> <button onclick="socket.close()">关闭连接</button> </body> </html> 1) @SocketIo.onConnected @SocketIo.onConnected用于装饰connected()方法,当有新的连接触发时,connected()方法从names变量里取一个名字,赋值给loginUsers变量。 loginUsers变量的格式是键值对,它的键是socket.id,socket.id是识别连接的唯一ID值,可作为此次连接的标识。loginUsers变量的值是从names取得的用户名,这里便将该用户名和此次连接绑定起来。 注意: 在实际应用中,通常会将连接和对应的用户信息存放到Redis或者数据库里,方便分布式环境的其他服务器读取。 保存用户信息后,程序通过io.sockets.emit()发送事件名为all的全局广播,告诉所有在线的网页端有新的用户加入。 测试该广播的逻辑,运行程序,用第1个浏览器打开http://localhost:8080/socketIo,然后用第2个浏览器打开同样的地址,可以看到前者的控制台输出We have a new member: LiLei的广播信息,如图535所示。 图535广播新用户加入的信息 2) @SocketIo.onEvent() 当单击页面的“发送消息”按钮时,网页端执行socket.emit('say')发送say事件,服务器端的@SocketIo.onEvent("say")收到消息后再次调用io.sockets.emit()广播把消息发到所有的网页端。效果如图536所示。 图536发送消息 3) @SocketIo.onError 当单击页面的“错误测试”按钮时,网页端执行socket.emit('testerror')发送testerror事件,服务器端的@SocketIo.onEvent("testerror")接收到事件,这是普通的事件,但该事件执行的testError()方法会抛出错误throw new Error(),以此模拟事件处理时发生错误的情况。 当在事件处理的过程中发生错误时,程序会转换为调用@SocketIo.onError装饰的错误处理方法error(),该方法会广播通知所有客户端出错信息。效果如图537所示。 图537测试错误处理 4) @SocketIo.onDisconnect 当单击页面的“关闭连接”按钮时,页面端会执行socket.close()关闭SocketIO连接。这时服务器端的@SocketIo.onDisconnect被触发,并且会广播通知所有网页端有用户下线,在广播信息中带有断线的原因。此处因为网页端主动执行了close()操作,所以原因为client namespace disconnect,如图538所示。 图538处理关闭连接 3. 房间逻辑 房间逻辑相当于分组通信,因此用户需要先加入特定的房间,然后便可以对房间内的其他用户进行广播,也就是群聊。Socket.IO对房间的支持有3个主要的方法: (1) socket.join()加入房间方法,socket对象表示当前连接,join()方法的参数是房间名。 (2) io.to(房间名).emit()发送房间广播的方法,to()方法表示对哪个房间发送广播,使用emit()方法发送事件和信息。 (3) socket.leave()离开房间方法,socket对象表示当前连接,leave ()方法的参数是房间名。 加入房间、发送房间内广播及离开房间的代码如下: //chapter05/02-socket-io/part3/app/src/test-socket.class.ts @SocketIo.onEvent("join") public join(socket, message) { socket.join("private-room"); io.to("private-room").emit("all", TestSocket.loginUsers.get(socket.id) + " joined private-room"); } @SocketIo.onEvent("say-inroom") public sayInRoom(socket, message) { io.to("private-room").emit("all", TestSocket.loginUsers.get(socket.id) + " said in Room: " + message); } @SocketIo.onEvent("leave") public leave(socket, message) { socket.leave("private-room"); io.to("private-room").emit("all", TestSocket.loginUsers.get(socket.id) + " leaved private-room"); } 上述3种方法都会发送房间内广播,而房间外的用户是无法收到广播的,下面来测试一下。由于前面测试中已经关闭连接,因此这时重启了程序,两个浏览器再次登入页面,单击左边浏览器的“加入房间”按钮,发送房间内广播信息,如图539所示。 图539左边浏览器加入房间并发送广播 可以看到右边浏览器并没有收到这条广播,因为它并没有在房间内,这时单击右边浏览器的“加入房间”按钮,可见两边浏览器都收到了加入房间的消息,如图540所示。 图540两边浏览器都收到了房间内广播 两边浏览器都分别单击“房间内广播”按钮,可以看到两个浏览器都收到了广播信息,并且是由不同的用户发出的,如图541所示。 图541发送房间内广播信息 在右边浏览器单击“离开房间”按钮,左边浏览器便会收到用户离开房间的广播,但右边浏览器却已经收不到房间内广播了,当左边浏览器继续单击“房间内广播”按钮发送信息时,右边浏览器仍不会收到信息,如图542所示。 图542离开房间 至此房间逻辑测试完毕,从测试过程可以了解房间的实现过程,join()方法会把连接的socket对象加入分组里,分组的结构类似数组,房间内广播便是对该分组的socket对象进行遍历以发送消息,这时只有分组内的socket对象才能收到房间内信息,而leave()方法用于将socket对象移出分组。上述3个操作即可完成分组逻辑。 5.2.7小结 本节完成了即时通信Socket.IO的框架内置开发。Socket.IO是一个成熟、易用的即时通信方案。框架的集成开发重心在即时通信的4个关键点,即认证、断线处理、房间和错误捕获。框架提供了完善的Socket.IO功能的支持,开发者使用SocketIo类的相关装饰器即可实现即时通信逻辑,从而提升开发效率。 34min 5.3Redis 数据库 Redis是非关系型数据库(NoSQL)的典型数据库之一,有着性能高、结构类型丰富、使用简单等优点,在特定开发场景,如缓存、分布式锁、排行榜逻辑等,Redis是最优的数据存储选型。Redis可以认为是MySQL之外服务器端开发领域最常用的数据库。 学习Redis可以从常用的String、List、Hash、Set、Sorted Set等5种数据结构和发布订阅pub/sub开始,以下是对它们的简单介绍: String结构是键值对(KeyValue),String有取出get(key)和存入set(key, value)等操作,可用作缓存、单值存储等。 List、Hash、Set这3种结构在TypeScript有相似的语法,List对应Array数组、Hash对应Map类型、Set对应Set类型,可对照理解,其中Set有一种名为Sorted Set的特殊Set结构,Sorted Set的每项有分数值(score),它可以根据分数自动排序,非常适合用于排行榜等逻辑实现。 发布订阅pub/sub,可实现类似RabbitMQ的消息通知机制,但功能较RabbitMQ要少。 5.3.1安装Redis服务 Redis同样可以用Docker Desktop图形化安装和命令行安装。 1. 图形化安装 在Docker Desktop搜索Redis,选择带有DOCKER OFFICAL IMAGE标识的首个结果,单击Run按钮,如图543所示。 图543Redis的搜索结果 拉取镜像后会弹出Run a new container窗口,首先需要填写端口6379,然后单击Run按钮启动,如图544所示。 图544填写6379端口 Redis启动成功,这时在Docker Desktop的Containers面板上可以看到Redis的运行状态,如图545所示。 图545Redis启动成功 2. 命令行安装 使用命令同样可以安装Redis,参数指定端口为6379,命令如下: docker run -d -p 6379:6379 redis:latest 运行此命令会拉取Redis最新的镜像并启动,如图546所示。 图546命令行安装Redis 5.3.2集成Redis Redis的依赖库是ioredis,安装命令如下: npm install ioredis 框架的Redis类直接继承于IoRedis,即可直接使用IoRedis提供的各种Redis操作功能。使用@bean装饰器返回Redis实例的方法,方便使用@autoware注入该对象,代码如下: //chapter05/03-redis/src/default/redis.class.ts import IoRedis from "ioredis"; export default class Redis extends IoRedis { @bean public getRedis(): Redis { if (!config("redis")) { return null; } return new Redis(config("redis")); } } 这里使用getRedis()方法检查Redis配置,避免在没有Redis配置的情况下启动Redis。向config.json文件加入配置,代码如下: //chapter05/03-redis/test/config.json "redis" : { "host" : "localhost", "port" : "6379", "db" : 0 } Redis测试页面是/redis,仅测试String结构的存入和获取,代码如下: //chapter05/03-redis/test/test-orm.class.ts @autoware private redisObj: Redis; @getMapping("/redis") async redisTest() { await this.redisObj.set("redisKey", "Hello World"); const value = await this.redisObj.get("redisKey"); log(value) return "get from redis: " + value; } 上述页面代码先用this.redisObj.set()方法存入redisKey键值,然后用this.redisObj.get()方法取出该值并显示在页面上,set()和get()方法对应的是Redis的String数据结构。执行程序,访问http://localhost:8080/redis即可看到结果,如图547所示。 图547Redis页面输出结果 在实际开发时,可使用Another Redis Desktop Manager工具查看Redis存储的数据。 在该工具的网站https://gitee.com/qishibo/AnotherRedisDesktopManager下载并安装,打开界面后单击New Connection,填写Host和Port值,如图548所示。 图548Redis工具新建连接 连接上Redis服务后,即可看到/redis页面写入的String值,如图549所示。 图549测试页面已存入数据 5.3.3发布订阅功能 Redis的发布订阅可以被视为精简版本的RabbitMQ,它可以针对某个主题发送一对一的通知,但它并没有RabbitMQ的消息确认、交换机、主题路由等丰富的功能,因此,在以下两类情况下可以考虑使用Redis的发布订阅功能: (1) 仅仅需要通知其他程序执行特定的操作,对消息的到达率要求不高,也不需要复杂的通知功能。 (2) 程序已在使用Redis服务,考虑成本等因素认为没有必要架设额外的RabbitMQ队列服务器。 发布订阅由发布和订阅两部分组成,下面分别进行集成。 1. 订阅功能 实现ioredis订阅功能需要两个步骤,首先用redis.subscribe(channel)方法订阅某个channel,然后用redis.on("message", function (channel, message){})方法监听发送至此channel的消息,示例代码如下: redis.subscribe("my-channel-1", "my-channel-2", (err, count) => { if (err) { console.error("Failed to subscribe: %s", err.message); } else { console.log( `Subscribed successfully! This client is currently subscribed to ${count} channels.` ); } }); redis.on("message", (channel, message) => { console.log(`Received ${message} from ${channel}`); }); 在redis.class.ts文件中修改支持订阅功能,代码如下: //chapter05/03-redis/src/default/redis.class.ts //收集Redis订阅事件 const redisSubscribers = {}; class Redis extends IoRedis { //分别创建发布对象和订阅对象 private static pubObj: Redis = null; private static subObj: Redis = null; //提供Redis对象,这里是发布对象 @bean public getRedis(): Redis { return Redis.getInstanceOfRedis("pub"); } //获取实例化Redis对象,此处为单例模式 static getInstanceOfRedis(mode: "sub" | "pub") { //检查Redis配置,避免没有配置Redis时报错 if (!config("redis")) { return null; } //根据参数,分别创建发布对象和订阅对象 if (mode === "pub") { this.pubObj = this.pubObj || new Redis(config("redis")); return this.pubObj; } else { this.subObj = this.subObj || new Redis(config("redis")); return this.subObj; } } } //订阅装饰器 function redisSubscriber(channel: string) { //检查Redis配置,避免没有配置Redis时报错 if (!config("redis")) return function(){ throw new Error("redis not configured"); }; //开启订阅 Redis.getInstanceOfRedis("sub").subscribe(channel, function (err, count) { if (err) { console.error(err); } }); //收集订阅事件 return function (target: any, propertyKey: string) { redisSubscribers[channel] = target[propertyKey]; }; } //检查Redis配置,避免没有配置Redis时报错 if (config("redis")) { Redis.getInstanceOfRedis("sub").on("message", function (channel, message) { redisSubscribers[channel](message); }); } //当进程退出时关闭Redis连接 process.once('SIGINT', () => { Redis.getInstanceOfRedis("sub") || Redis.getInstanceOfRedis("sub").disconnect(); Redis.getInstanceOfRedis("pub") || Redis.getInstanceOfRedis("pub").disconnect(); }); export { Redis, redisSubscriber }; 上述代码内容可分作三部分进行理解。 1) Redis.getInstanceOfRedis()静态函数 getInstanceOfRedis()的参数mode的取值是sub或pub,分别表示获取订阅或发布Redis实例。因为Redis实例只能是订阅或者发布模式之一,单个实例无法同时存在两种模式,因此getInstanceOfRedis()存储着发布pubObj和订阅subObj两个实例,通过mode参数可以取得其中的一个。 注意: 同一个Redis实例开启订阅后再执行发布publish,Redis将提示Error: Connection in subscriber mode, only subscriber commands may be used错误,即在订阅模式无法使用发布命令,因此需要创建两个Redis对象,分别处理发布和订阅。 getInstanceOfRedis()是静态函数,方便在Redis类、@redisSubscriber等位置获取Redis实例。 2) @redisSubscriber装饰器 @redisSubscriber是ioredis订阅的第1步,即用redis.subscribe(channel)方法来订阅某个channel,同时也将订阅处理方法记录到redisSubscribers全局变量。 接着是ioredis订阅的第2步,由redis.on("message")开启订阅消息。 注意这里用的Redis实例是Redis.getInstanceOfRedis("sub"),即订阅实例。 3) 避免异常 redis.class.ts文件有多处代码对Redis配置进行检查,包括getInstanceOfRedis()、@redisSubscriber和redis.on("message"),避免在没有配置Redis时执行Redis相关操作而出现异常。尤其是@redisSubscriber装饰器将直接抛出异常,要求在程序没有配置Redis前,不允许使用@redisSubscriber装饰器。 另外,process.once('SIGINT')方法确保了在程序关闭时调用Redis实例的disconnect()方法以关闭连接,避免出现内存泄漏等问题。 2. 发布功能 使用发布功能只需注入Redis对象,因为Redis类继承于IoRedis,并且用@bean装饰器来提供发布模式的实例,代码如下: //chapter05/03-redis/src/default/redis.class.ts @bean public getRedis(): Redis { return Redis.getInstanceOfRedis("pub"); } 留意getRedis()返回的是pub实例,而前面订阅相关操作使用的是sub实例。 随后在TestRedis类测试发布订阅功能,代码如下: //chapter05/03-redis/app/src/test-redis.class.ts @component export default class TestRedis { @autoware private redisObj: Redis; @redisSubscriber("mychannel") public listen(message) { log("Received by Decorator '%s'", message); } @getMapping("/redis/publish") async redisTest() { await this.redisObj.publish("mychannel", "Hello World"); return "Published!"; } } @redisSubscriber装饰listen()方法,它监听mychannel,在收到消息时输出日志。 在/redis/publish页面用this.redisObj.publish()将一条消息发布到mychannel,this.redisObj是@autoware装饰器注入的Redis对象。 运行程序,使用浏览器打开http://localhost:8080/redis/publish,即可在命令行看到listen()方法的输出,如图550所示。 图550Redis订阅信息输出 5.3.4优化排行榜逻辑 排行榜是开发中常见的业务需求之一,例如积分排名、游戏排名等场景。 MySQL数据库的排行实现主要利用SQL语法的ORDER BY排序子句对分值进行排序,但ORDER BY子句在性能上并不能满足排行榜逻辑的实现。 为了避免ORDER BY查询使用性能较差的全表扫描,开发者需要在ORDER BY分数值字段创建索引。索引字段每次更新时都会加锁来保证数据一致性,加锁会使索引性能下降,而排行榜的分数值更新频繁,从而导致查询性能大幅下降,因此,ORDER BY子句虽然有排序能力,但在排行榜的业务场景下就显得力不从心了。 Redis的Sorted Set采用特殊的数据结构,是专为解决排行榜排序问题而设计的,它有两个特点: (1) Sorted Set能够在百万量级的数据操作时保持良好的性能,而ORDER BY子句的性能会随着数据量的增大而急剧下降。 (2) 频繁地对排序项进行增、删、改和加减分,不会影响Sorted Set的排序性能。 Sorted Set有一系列名称以z开头的操作方法,其中较为重要的方法是zadd()和zrevrange()。 zadd()增加了排行榜的项和分数,或更新某一项的分数。例如zadd("考试排行榜", 190, "张三")在考试排行榜上加入张三的分数190。如果排行榜上原来就有张三的分数,就将分数改为190。需要注意zadd()方法的参数顺序是分数在项名称之前。 zrevrange()按名次获取排名列表,分数从高到低。例如zrevrange("考试排行榜",0,9,"WITHSCORES")用于获取考试排行榜的前10名列表,列表项包含项名和分数。zrevrange()的第2个和第3个参数表示排序开始和结束,当第2个参数为0时表示从第一名开始,而当第3个参数为-1时表示到最后一名结束,例如0,-1就是获取整个排行榜的数据,0,9则是获取前10名。此外,和zrevrange()刚好相反,zrange()方法按名次从低到高获取排名列表。 zrevrange()和zrange()方法有个缺点,它们的返回内容是数组。例如zrevrange("考试排行榜", 0,9, "WITHSCORES")返回的是[ "张三", 190, "李四", 180 ... ],其格式是[项目,分数,项目,分数, ...]这样的数组。开发者在使用zrevrange()返回值前还需要做一次转换,将返回值转换成{"张三": 190, "李四": 180 ...}的格式以方便使用。 因此框架加入zrevranking()和zranking()方法,它们的参数同样是排行榜名称、开始位置和结束位置,只比zrevrange()方法少了WITHSCORES这个完全没有必要的参数。 zrevranking()和zranking()方法的返回值是{"张三": 190, "李四": 180 ...}排名项和分数的键值对格式,开发者直接就能使用,无须再自行转换格式。zrevranking()和zranking()方法的代码如下: //chapter05/03-redis/src/default/redis.class.ts //获取排行榜数据,按分数从高到低排序 public async zrevranking(key: RedisKey, start: number | string, stop: number | string): Promise<Map<string, number>> { //用zrevrange()获得数据 const list = await this.zrevrange(key, start, stop, "WITHSCORES"); //转换数据格式,方便开发者直接使用 const map = new Map<string, number>(); for (let i = 0; i < list.length; i = i + 2) { map.set(list[i], Number(list[i + 1])); } return map; } //获取排行榜数据,按分数从低到高排序 public async zranking(key: RedisKey, start: number | string, stop: number | string): Promise<Map<string, number>> { //zrange()获得数据 const list = await this.zrange(key, start, stop, "WITHSCORES"); //转换数据格式,方便开发者直接使用 const map = new Map<string, number>(); for (let i = 0; i < list.length; i = i + 2) { map.set(list[i], Number(list[i + 1])); } return map; } 从上述代码可以看到,zrevranking()和zranking()方法的实现是基于原有的zrevrange()和zrange()方法,只是增加了对结果进行循环赋值的代码。 /redis/add和/redis/ranking是两个新增方法的测试页面,代码如下: //chapter05/03-redis/app/src/test-redis.class.ts @getMapping("/redis/add") async addZset(@reqQuery name: string, @reqQuery score: number) { log("add zset: %s, %s", name, score) await this.redisObj.zadd("scoreSet", score, name); return "add zset success"; } @getMapping("/redis/ranking") async listRanking() { const list = await this.redisObj.zrevranking("scoreSet", 0, -1); return Object.fromEntries(list); } /redis/add路由参数是排名项名称name和分数score,两个参数在页面方法addZset()内,使用zadd()加到scoreSet排行榜。 /redis/ranking页面用zrevranking()方法取得排行榜列表并且显示。需要注意的是,结果list变量是object对象,其内部实现为Map类型,因此首先要用Object.fromEntries()方法转换为JSON格式,然后返回。 运行程序,使用浏览器打开/redis/add页面,输入一些name和score参数填充排行榜,访问地址类似于http://localhost:8080/redis/add?name=张三&score=98。从命令可以看到addZset()页面方法输出的日志。注意图551中第4条增加的记录,它覆盖了前面第1条的分数值。 图551增加排行榜数据 接着打开http://localhost:8080/redis/ranking地址,即可看到排行榜内容,它采用的是名称和分数对应的键值对格式,如图552所示。 图552输出排行榜数据 此外,使用Another Redis Desktop Manager工具查看Redis数据,也可以看到该排行榜的内容,如图553所示。 图553Redis的排行榜数据 5.3.5Session支持Redis 存储 本书在3.6.5节介绍了框架的Session功能,这是ExpressJS中间件的典型的使用案例。该Session功能的数据仅存储在本机。对于分布式应用开发而言,数据存储在本机显然不能满足功能需求。 因此本节将实现Redis作为Session存储数据库,使Web程序即便采用分布式多机部署,它们也能访问Redis服务器获得同样的Session数据,实现用户跨多服务器的登录功能。 Redis接入Session存储使用connectredis库,安装命令如下: npm install connect-redis ExpressServer类用@autoware获取Redis实例,该实例在setDefaultMiddleware()方法中作为connectRedis库的RedisStore类构造参数实例化,配置为expressSession中间件存储器,代码如下: //chapter05/03-redis/src/default/express-server.class.ts @value("redis") private redisConfig: object; @autoware private redisClient: Redis; private setDefaultMiddleware() { ... if (this.session) { const sessionConfig = this.session; //将true proxy属性设置为1 if (sessionConfig["trust proxy"] === 1) { this.app.set('trust proxy', 1); } if (this.redisConfig) { //用connectRedis()提供的Redis作为Session存储 const RedisStore = connectRedis(expressSession); sessionConfig["store"] = new RedisStore({ client: this.redisClient }); } this.app.use(expressSession(sessionConfig)); } ... 运行程序,使用浏览器访问http://localhost:8080的任意页面,打开Another Redis Desktop Manager工具即可看到sess目录下存储着一些Session数据,如图554所示。 图554Redis存储Session数据 5.3.6小结 本节介绍了框架内 Redis数据库的支持。Redis是Web开发最为常用的服务之一。本节在介绍Redis的安装和简单使用之外,还讲解了Redis的发布订阅功能,以及如何给Sorted Set增加zrevranking()/zranking()方法简化排行榜的实现,最后介绍了Session接入Redis分布式存储配置。 14min 5.4命令行脚手架功能 本节将介绍框架命令行的脚手架功能开发。NPM库的命令通常由package.json的bin配置实现,因此本节将同时会介绍关于NPM发布的相关命令和配置。 5.4.1脚手架是什么 开源项目通常会提供一些命令或者Web页面来和开发者进行交互,进而生成一些初始化项目的配置、一些样本代码,乃至半成品项目等,这类功能称为脚手架。例如,前端VUE项目可以用vue create helloworld的命令生成一个VUE项目,新项目有各种初始文件和默认配置,开发者能直接在新项目的基础上开发,节省很多配置和创建文件的时间。脚手架有以下3个优势: (1) 节省开发时间。脚手架协助生成很多重复性的代码文件。例如NestJS框架的nest generate命令能够生成各种关联的类文件、中间件、模块等。 (2) 增强开发者的信心。脚手架生成的初始项目通常是可运行的,使开发者不至于因为烦琐的项目搭建工作而感到沮丧。 (3) 提供开发周期各阶段所需的工具。例如自动生成测试用例、获取依赖库、代码检查、编译打包、部署发布等功能。 脚手架从交互形式上分为三类,分别如下。 (1) 命令式: 最常见的脚手架类型。命令式脚手架定义了多种命令,能够生成一组代码或整个项目。例如NestJS框架提供的命令式脚手架提供了new、generate、add等命令,可以创建如类型、配置、模块等多种文件,如图555所示。 (2) 向导式: 通常以Web界面进行交互,开发者可在界面配置参数和挑选所需的组件模块,一步步生成初始化项目。例如Vue框架提供了http://localhost:8000/project/create页面,如图556所示。 (3) 半成品项目: 顾名思义,脚手架提供的初始化项目是一个已经具备了大部分基础功能的站点,开发者的工作更像是在修改项目。例如Yii框架提供的半成品项目具备登录和注册功能,提供了强大的数据管理后台,能够对各种数据表进行增、删、查、改等操作,开发者只需少量修改便可以用作业务管理系统,如图557所示。 图555NestJS提供的命令式脚手架 图556Vue提供的向导式脚手架创建项目 图557Yii提供基础功能完备的半成品项目 5.4.2开发命令行程序 TypeSpeed框架的脚手架提供了创建初始项目的命令,命令格式为typespeed new [appName],本节将讲解该命令的实现。 命令行交互需要用到commander库,安装命令如下: npm install commander commander提供了两类命令行功能: (1) 显示帮助说明,参考图555,命令输入help参数时将显示一系列说明。此外,当开发者输入错误的命令或参数时,commander也能够配置显示相关的说明。 (2) 关联命令和程序,即输入不同的命令可执行相应的代码。 注意: commander库的作者T.J.Holowaychuk,同时也是ExpressJS框架的作者。 图558脚手架源码目录 脚手架从命令执行,它相对独立,和Web程序并无直接关联,因此框架源码新增了scaffold目录,用作存放脚手架程序,如图558所示。 scaffold/tempaltes目录有5个模板文件,它们是新项目所需的文件。new命令的实现过程就是基于这些模板文件来替换内容,然后在新的目录生成文件。这些模板文件分别如下。 (1) frontpage.class.ts.tpl: 首页文件。 (2) main.ts.tpl: 程序入口文件。 (3) nodemon.json.tpl: nodemon配置文件,nodemon可以动态地监控文件的变化,以此来启动程序,方便开发时使用。 (4) package.json.tpl: 项目配置文件。 (5) tsconfig.json: 项目的TypeScript配置文件。 模板文件的后缀都是.tpl,避免影响TypeScript编译过程,在替换文件时会去掉.tpl后缀。package.json.tpl文件里有个"name": "###appName###"字符串,用于替换新项目名称,其他4个文件都会原样被生成到新项目的目录里。 command.ts文件是命令行交互程序,代码如下: //chapter05/04-command/src/scaffold/command.ts //声明执行此文件的命令,位置必须在第1行 #!/usr/bin/env node import { Command } from 'commander'; const program = new Command(); import * as fs from "fs"; //设置命令 program.command('new [appName]') //命令名称 .description('Create a new app.') //命令的help描述 .action((appName) => { //命令的执行函数 const currentDir = process.cwd(); //创建新项目的初始文件 fs.mkdirSync(currentDir + "/" + appName); mkFile("nodemon.json", currentDir + "/" + appName, appName); mkFile("package.json", currentDir + "/" + appName, appName); mkFile("tsconfig.json", currentDir + "/" + appName, appName); fs.mkdirSync(currentDir + "/" + appName + "/src"); mkFile("main.ts", currentDir + "/" + appName + "/src", appName); mkFile("front-page.class.ts", currentDir + "/" + appName + "/src", appName); //输出提示 console.log(''); console.log(' Create app success!'); console.log(''); console.log(' Please run `npm install` in the app directory.'); console.log(''); }); //设置当开发者输入帮助命令时,需要显示的信息 program.on('--help', () => { console.log(''); console.log(' Examples:'); console.log(''); console.log(' $ type speed new blog'); }); //解析命令行参数 program.parse(process.argv); //创建文件和替换内容的函数 function mkFile(fileName, targetPath, appName) { const tplPath = __dirname + "/templates"; const fileContents = fs.readFileSync(tplPath + "/" + fileName + ".tpl", "utf-8"); fs.writeFileSync(targetPath + "/" + fileName, fileContents.replace("###appName###", appName)); } command.ts文件分4部分来讲解。 (1) 声明执行程序: 该文件的首行通过#!/usr/bin/env node声明该程序要用node命令执行。这是node命令行程序必需的配置,如果没有该行的声明,则在执行command.ts程序时将出现异常,如图559所示。 图559未声明执行程序而导致命令行出错 (2) mkFile()函数用于替换模板文件,它的3个参数分别是模板文件地址、新项目路径及新项目名称appName,其中appName用于替换###appName###字符串。 文件替换则使用Node.js的fs文件库,先从模板读出内容,替换字符串后写入新路径的文件中。 (3) new命令的实现: command.ts开始实例化commander库对象,并调用其program.command()方法定义new命令,每个program.command()代表一个命令的解析,program支持链式调用。 program.command('new [appName]')对应的命令是typespeed new [appName]。 description()是命令加help参数时显示的说明内容,上述代码执行typespeed new help命令就会输出Create a new app的说明。 action()是命令执行函数。当new命令执行时,命令行输入的appName参数就被解析到这个函数里执行相应的代码。函数内部多次用mkFile()函数生成新的项目文件,并且使用console.log()输出说明和空行。 (4) help命令的实现: program.on('help')用于显示脚手架的帮助信息。当开发者输入typespeed help命令时,便会输出program.on('help')里面的内容。 测试command.ts需要开启命令行并进入scaffold目录,用tsnode命令启动该文件。直接不带参数执行command.ts的命令如下: cd src/scaffold ts-node command.ts 这时命令行将输出脚手架的帮助信息,即program.on('help')设置的内容,如图560所示。 测试new命令可加入参数,命令如下: ts-node command.ts new hello-world 此处新项目名称为helloworld,命令执行输出program.command().action()配置的console.log()信息,如图561所示。 图560help命令输出说明信息 图561new命令执行成功信息 这时名为helloworld的新项目创建成功,文件结构如图562所示。 此外,new命令还有帮助信息,可通过下面两种方式显示,命令如下: ts-node command.ts new --help 该命令在new参数后加入help显示帮助信息。另一种方式的命令如下: ts-node command.ts help new 这个命令用help来显示new命令的帮助信息。两种方式的效果相同,如图563所示。 图562新建项目的目录结构 图563new命令的帮助信息 图563输出的new命令的帮助信息是用description()配置的信息。 5.4.3发布命令 接着把command.ts发布成框架的NPM命令,设置NPM命令的方法是配置package.json文件中的bin配置,代码如下: //chapter05/04-command/package.json "bin": { "typespeed": "./dist/scaffold/command.js" }, bin配置表示当开发者用npm install typespeed安装框架时,bin的键值typespeed会作为命令名称配置在本机的命令行里,而bin的值执行命令的程序文件。 由于command.ts文件编译后的文件是./dist/scaffold/command.js,文件后缀是js,因此"typespeed": "./dist/scaffold/command.js"配置表示开发者的命令行支持typespeed命令,执行typespeed命令相当于执行node ./dist/scaffold/command.js。特别需要注意的是,此处node是command.ts文件首行声明的命令。 此外,框架文件在编译的过程中,后缀.tpl文件并不会被编译,因此还需要配置package.json文件中的postbuild属性,在项目编译之后把tpl模板文件都复制到发布目录dist里,代码如下: //chapter05/04-command/package.json "postbuild": "cp -r src/default/pages dist/default/pages && cp -r src/scaffold/templates dist/scaffold/templates", 配置完成后编译项目,然后发布框架NPM包,命令如下: npm build npm publish 注意: 发布框架项目的相关知识会在6.3节详细介绍。 发布完成后稍等片刻,然后在本机安装新版本的TypeSpeed框架测试脚手架功能,命令如下: npm install typespeed -g 命令的g参数表示把typespeed库安装到全局命令。如果安装时提示权限不足,则需要加sudo执行上述命令,如图564所示。 图564安装TypeSpeed框架 安装框架后可执行typespeed命令测试新建项目,如图565所示。 图565测试新建项目命令 5.4.4小结 本节介绍了开源项目的脚手架功能,在TypeSpeed框架中实现简单的脚手架功能。脚手架通过命令行或者Web界面向导的方式,为开发者创建一个有着基本配置的可正常运行的新项目,让开发者迅速上手开发,增强信心。 24min 5.5支持Swagger平台 本节将介绍如何开发外部应用项目,配合框架共同实现对Swagger平台的支持。 5.5.1Swagger接口交互平台 在前后端分离的开发场景里,接口交互是前后端开发人员共同的关注点。Swagger是一个提供接口描述和测试的开源项目,方便进行接口交互的协作。 Swagger项目包含一系列接口交互相关工具,其中Swagger UI提供了展示和测试API的Web界面,能够陈列各种接口的路径、参数类型、返回格式等,还可以直接在界面上输入数据调试接口,如图566所示。 图566Swagger项目示例 通过Swagger接口平台,前端开发人员能轻松地获得各种接口的信息、提交的参数及返回类型格式等,以便对接开发,而服务器端开发人员可以用Swagger直接对接口进行自测,检查接口的有效性。此外,在实现了页面接口自动化收集后,服务器端开发人员便无须再额外编写接口文档,从而提升工作效率。 Swagger显示的接口信息来源于JSDoc格式的文档,从该文档提取接口细节进行显示,而与JSDoc文档相关的内容将在5.6节详细介绍,此处先用示范文档作为Swagger的集成实现。 5.5.2外部项目 开源项目的开发流程和一般程序的开发流程稍有不同。由于开源项目首要考虑的是开发者使用项目的灵活性,因此开源项目的开发流程有一些特别的步骤,例如模拟开发者应用、扩展外部应用等,如图567所示。 图567开源项目开发流程 图567所示的扩展外部应用是本节的主要内容,外部应用指的是和开源项目本身关系密切的模块,因为一些原因被编写成独立的项目。 这样的情形很常见,例如第2章介绍的session、bodyparser、multer等ExpressJS中间件都是托管在ExpressJS GitHub账号上的独立项目,此外Spring Boot框架的JPA、Security、Thymeleaf等外部项目亦是如此。 外部应用之所以被拆分为独立项目,主要有3个方面的原因: (1) 保持核心项目足够小巧。架构设计领域推崇微内核设计,即核心部分只提供基础功能和扩展机制,其他大部分功能由扩展外部项目支持。ExpressJS就是微内核的典型代表。 (2) 外部项目可独立进行迭代优化,甚至一些外部项目具备了兼容各种更多核心项目的超高扩展性。例如上面提到的Thymeleaf模板引擎就不仅支持在Spring Boot中使用,还能够支持其他Java框架集成使用。此外,独立的开源项目也方便更多的开发者加入,从而共同开发。 (3) 部分外部项目使用一些非标准的库代码,可能对核心项目的通用性有一定影响,因此将其独立成项目,让有需要的开发者可以酌情选用。 基于上述原因,TypeSpeed框架对Swagger的支持,同样是由外部项目实现的。该项目名为TypeSpeedSwagger,Git网址为https://gitee.com/SpeedPHP/typespeedswagger。 5.5.3设计TypeSpeedSwagger 由于外部项目的特殊定位,对外部项目开发设计时需要注意以下几点: (1) 延续核心项目的设计风格,例如ExpressJS 外部项目都采用中间件的设计风格。 (2) 减少对核心项目额外的配置和改动,方便开发者集成外部项目功能。 (3) 外部项目功能尽可能专一,即仅限于解决一个问题。 TypeSpeedSwagger依据上述设计需求,提供了非常简便的集成方法: (1) 在入口文件main.ts配置swaggerMiddleware()中间件。swaggerMiddleware()的主要作用是显示Swagge页面,同时提供动态的JSDoc文档。 (2) TypeSpeed项目与路由相关的7个装饰器需修改为从TypeSpeedSwagger引入。替换这些装饰器是为了对路由信息进行收集处理。 7个装饰器分别是: @reqBody、@reqQuery、@reqForm、@reqParam(请求参数装饰器,用于收集请求参数信息)、@getMapping、@postMapping和@requestMapping(路由装饰器,用于收集页面路径信息)。 接下来,用5.4节脚手架命令创建的test项目集成TypeSpeedSwagger,介绍上述两个集成步骤。 (1) 安装TypeSpeedSwagger库,命令如下: npm install typespeed-swagger (2) 向src/main.ts文件添加swaggerMiddleware()函数,其参数是当前Web服务的app实例,代码如下: import { app, log, autoware, ServerFactory } from "typespeed"; import { swaggerMiddleware } from "typespeed-swagger"; @app class Main { @autoware public server: ServerFactory; public main() { swaggerMiddleware(this.server.app); this.server.start(8081); log('start application'); } } (3) 在新项目的第1个页面FrontPage的import部分把@getMapping改为从typespeedswagger引入,代码如下: import { log, component } from "typespeed"; import { getMapping } from "typespeed-swagger"; @component export default class FrontPage { @getMapping("/") public index(req, res) { log("Front page running."); res.send("Front page running."); } } 此时执行新项目,使用浏览器打开http://localhost:8081/docs,即可看到第1个页面/index的接口信息,而且可以按下Execute按钮测试该接口,如图568所示。 图568新项目集成Swagger 此外,TypeSpeedSwagger的swaggerMiddleware()还提供了以下3个配置项。 (1) path: Swagger页面路径,默认为/docs。 (2) allowip: 访问Swagger页面的白名单IP数组,即只有allowip数组里的客户端IP才能进入Swagger页面,默认值为 ["127.0.0.1", "::1"]。 (3) packageJsonPath: 项目package.json文件路径。TypespeedSwagger将使用package.json文件配置的项目名称和版本信息作为页面显示。 尝试修改swaggerMiddleware()配置,如图569所示,Swagger访问的地址和显示的项目信息均有变化。 图569TypeSpeedSwagger的配置参数生效 5.5.4实现集成Swagger中间件 本节创建外部项目TypeSpeedSwagger,项目结构比较简单,只有index.ts文件是需要导出的,另外附带一个测试项目app,如图570所示。 实现Swagger集成有两部分内容: 集成Swagger中间件及动态生成JSDoc文档,本节将讲解集成Swagger中间件的实现。动态生成JSDoc文档涉及TypeScript反射和编译相关知识,将在5.6节详述。 ExpressJS集成Swagger使用的库是swaggeruiexpress,它比Swagger官网提供的库更好用,安装命令如下: npm install swagger-ui-express swaggerMiddleware()函数集成了swaggeruiexpress库,代码如下: //chapter05/05-swagger/version1/index.ts function swaggerMiddleware(app: any, options?: {}) { app.use( "/docs", swaggerUi.serve, swaggerUi.setup(undefined, { swaggerOptions: { url: "/example.json" } }) ); } 图570TypeSpeedSwagger项目 swaggeruiexpress提供了两个ExpressJS中间件,它们分别如下。 (1) swaggerUi.serve: 用于显示Swagger页面,包括HTML、CSS和构建接口界面的JS等。 (2) swaggerUi.setup: 根据输入的配置信息,对Swagger页面进行替换修改。 代码中swaggerUi.setup配置的url值是/example.json,该文件是JSDoc文档的示例,访问地址是http://localhost:8081/example.json。静态文件的配置如下: //chapter05/05-swagger/version1/app/config.json { "static": "/static" } 运行程序,访问http://localhost:8081/docs/即可看到Swagger界面,其内容是/example.json提供的接口信息,如图571所示。 图571Swagger中间件显示的页面 5.5.5替换装饰器收集接口信息 TypeSpeedSwagger的第2个设计逻辑是替换TypeSpeed项目的路由装饰器,以便收集到全部的接口信息,进而做到动态显示JSDoc文档。 本节先实现替换装饰器的逻辑,和本书其他跨装饰器开发逻辑一样,收集数据采用的是全局变量,代码如下: //chapter05/05-swagger/version1/index.ts const routerMap: Map<string, RouterType> = new Map(); const requestBodyMap: Map<string, RequestBodyMapType> = new Map(); const requestParamMap: Map<string, ParamMapType[]> = new Map(); 上述代码的3个全局变量分别表示: (1) routerMap变量用于收集路由页面信息,即接口路径。toMapping()方法替换@getMapping、@postMapping和@RequestMapping将路由信息收集到routerMap变量,代码如下: //chapter05/05-swagger/version1/index.ts //接管路由装饰器,收集信息 function toMapping(method: MethodMappingType, path: string, mappingMethod: Function) { //调用TypeSpeed框架的路由装饰器,取得回调函数 const handler = mappingMethod(path); return (target: any, propertyKey: string) => { //收集信息,以便在显示JSDoc时分析 const key = [target.constructor.name, propertyKey].toString(); if (!routerMap.has(key)) { routerMap.set(key, { "method": method, "path": path, "clazz": target.constructor.name, "target": target, "propertyKey": propertyKey }); } //继续使用TypeSpeed框架的回调函数 return handler(target, propertyKey); } } //新的路由装饰器 const getMapping = (value: string) => toMapping("get", value, tsGetMapping); const postMapping = (value: string) => toMapping("post", value, tsPostMapping); const requestMapping = (value: string) => toMapping("all", value, tsRequestMapping); (2) requestBodyMap变量用于收集@reqBody的信息,在JSDoc文档里Body信息是单独呈现的,因此需要抽离一个变量对信息进行收集,代码如下: //chapter05/05-swagger/version1/index.ts function reqBody(target: any, propertyKey: string, parameterIndex: number) { const key = [target.constructor.name, propertyKey].toString(); requestBodyMap.set(key, { "target": target, "propertyKey": propertyKey, "parameterIndex": parameterIndex }); return tsReqBody(target, propertyKey, parameterIndex); } (3) requestParamMap变量用于收集@reqParam、@reqQuery和@reqForm的信息,它们的逻辑基本相同,举例@reqParam的代码如下: //chapter05/05-swagger/version1/index.ts function reqParam(target: any, propertyKey: string, parameterIndex: number) { const key = [target.constructor.name, propertyKey].toString(); if (!requestParamMap.has[key]) { requestParamMap.set(key, new Array()); } requestParamMap.get(key).push({ paramKind: "path", "target": target, "propertyKey": propertyKey, "parameterIndex": parameterIndex }) return tsReqParam(target, propertyKey, parameterIndex); } 在这些变量收集结束后,这些装饰器最终会调用TypeSpeed框架原有的装饰器,因此在引入装饰器时做了别名处理,代码如下: //chapter05/05-swagger/version1/index.ts import { reqBody as tsReqBody, reqQuery as tsReqQuery, reqForm as tsReqForm, reqParam as tsReqParam, getMapping as tsGetMapping, postMapping as tsPostMapping, requestMapping as tsRequestMapping, log } from 'typespeed'; 上述装饰器替换收集的逻辑,如图572所示。 图572替换装饰器收集信息 图573输出收集信息 测试时,在swaggerMiddleware()方法里输出上述3个全局变量,以便观察其有效性。当程序运行时,即可看到如图573所示的收集信息。 5.5.6小结 本节创建了TypeSpeedSwagger项目作为框架的外部应用,用以支持Swagger平台。TypeSpeedSwagger项目的两个设计目标是集成Swagger中间件和替换TypeSpeed框架的路由装饰器,以达到无缝接入的效果。 Swagger平台是展示Web服务API的自动化平台,在前后端开发分离的工作场景下可以发挥极大的作用,从而提升团队的开发效率。 此外,本节还介绍了开源项目外部应用的意义。外部项目既可以为核心项目提供额外的扩展支持,又能够独立开发维护,在各种开源项目中十分常见。 5.6自动化文档 本节将实现自动化的JSDoc文档。在5.5节的Swagger页面里/example.json就是一个示范用的JSDoc文档。 5.6.1JSDoc文档和工具 JSDoc文档是一种描述API文档的标准格式,用于可显示JSDoc文档格式的平台,如Swagger,而JSDoc工具是依据源代码的特定注释来生成JSDoc文档的软件,JSDoc工具有很多,常用的有JSDoc、Swagger Tools等。 JSDoc工具要求开发者在编写程序时,根据特定的规范在源码注释中描述接口信息,然后使用JSDoc 工具对注释进行静态扫描,从而生成JSDoc文档。 注意: 从JSDoc工具的使用方法可以看出,这里生成的JSDoc文档是静态的,当页面接口有所变化时,必须再次扫描源码以生成JSDoc文档。 这是ExpressJS中间件JSDoc源码注释的示例,代码如下: /** * Description of my middleware. * @module myMiddleware * @function * @param {Object} req - Express request object * @param {Object} res - Express response object * @param {Function} next - Express next middleware function * @return {undefined} */ function (req, res, next) {} 从示例可以看到,开发者需要在注释中写的接口信息和一份完整的接口文档并没有太大区别,这样引出了两个问题: (1) 对于开发者而言,写这样的注释内容同样增加了额外的工作量。在实际工作中,开发者会认为这些增加的工作量并没有带来足够的好处。 (2) 在项目迭代更新比较频繁的情况下,接口注释不一定能够及时更新,容易造成一些不必要的误会,从而降低了团队的沟通效率。 框架是为开发者带来助力的,因此框架提供自动生成JSDoc文档的功能是非常有必要的。 查看5.5节的/example.json示范文档,文档里主要的内容是接口的地址、输入的参数和类型及接口返回值的类型。 在5.5.5节中开发的3个全局变量routerMap、requestBodyMap和requestParamMap里,接口地址是比较容易获得的,而重点是输入参数及返回值。从示范文档可知,参数和返回值的类型有以下4种: (1) 基本类型,例如Number、String等TypeScript语言的基本类型。 (2) 数据类,例如UserDTO,通常是开发者自定义的类型。 (3) Promise泛型,由于部分接口使用了async/await关键字,即异步方法,因此返回值必须是Promise泛型,例如Promise<UserDTO>。 (4) 数组,上述类型的数组,例如Promise<UserDTO[]>。 注意: TypeScript泛型是一种类型约束语法,泛型用于限定某些参数只能是指定的类型。例如Array<string>限定数组的项只能是字符串类型。需要注意TypeScript只会在编译时依照泛型进行类型检查,而编译后的JavaScript代码并不存在泛型的信息。 图574反射测试项目 要获取类型信息,就需要使用反射机制,那么2.1.3节介绍的Reflect Metadata库能否取得上述几种类型信息呢? 14min 5.6.2Reflect Metadata运行原理 TypeScript的编译过程会将原有的类型信息抹除,进而改写为JavaScript文件,那么反射库是如何保存类型信息以供程序在运行时获取的呢?接下来使用Reflect Metadata库进行编译使用,观察其编译后保留的信息。 本节准备了两个反射测试项目,如图574所示。 用于编译测试的源码是MainReflect类。MainReflect的3种方法的返回值类型分别是基本类型Number、字符串Promise泛型、字符串数组,它们被@injectReflect装饰,而@injectReflect装饰器仅简单地输出当前方法的返回类型,代码如下: //chapter05/05-swagger/reflect/main-reflect.ts import "reflect-metadata"; class MainReflect { @injectReflect getAge(): Number { return 1; } @injectReflect getPromise(): Promise<string> { return Promise.resolve('promise'); } @injectReflect getArray(): Array<string> { return ['array']; } } function injectReflect(target: any, propertyKey: string) { const returnType: any = Reflect.getMetadata("design:returntype", target, propertyKey); console.log(target[propertyKey].name, "的返回类型是:", returnType.name); } 测试项目的配置比较精简,tsconfig.json配置的限定待编译的文件是mainreflect.ts,package.json配置了测试脚本和reflectmetadata库依赖,代码如下: //chapter05/05-swagger/reflect/package.json { "scripts": { "build": "npx tsc", "prebuild": "npm i" }, "dependencies": { "reflect-metadata": "latest" } } 测试脚本配置在tsc编译命令前,先执行npm i命令安装依赖库,避免因为依赖库冲突出现异常。测试脚本命令如下: npm run build 上述命令会先安装依赖库,然后执行tsc编译,在dist目录生成mainreflect.js文件,如图575所示。 图575编译生成mainreflect.js文件 mainreflect.js文件的内容并不复杂,分3部分理解。 (1) 反射工具函数__decorate()和__metadata()的定义,它们的作用是把参数里的类型信息存放到target或者Reflect的属性上,待后续使用Reflect.getMetadata()读取。 (2) 编译后的MainReflect类,从它的3种方法和injectReflect()函数可以看到它们都没有了类型信息,是普通的JavaScript语法。 (3) 调用__decorate()和__metadata()将injectReflect函数和MainReflect的原型关联起来。这里是Reflect Metadata能够收集类型信息的关键部分,代码如下: //chapter05/05-swagger/reflect/dist/main-reflect.js //接管getAge()方法,设置metadata数据 __decorate([ injectReflect, __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Number) ], MainReflect.prototype, "getAge", null); //接管getPromise()方法,设置metadata数据 __decorate([ injectReflect, __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], MainReflect.prototype, "getPromise", null); //接管getArray()方法,设置metadata数据 __decorate([ injectReflect, __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Array) ], MainReflect.prototype, "getArray", null); 在上述代码中,__metadata()函数的第1个参数是"design:type"、"design:paramtypes"、"design:returntype"三者之一,第2个参数是对应的类型。例如__metadata("design:returntype", Number)表示getAge()方法的返回值类型returntype是Number。 从这个编译结果可以看出Reflect Metadata有两个作用限制: (1) Reflect Metadata只能收集方法类型design: type、参数类型design:paramtypes、返回类型design:returntype三种数据。 (2) Reflect Metadata无法收集到Promise和Array的泛型。例如__metadata("design:returntype", Promise)只知道getPromise()方法的返回值是Promise类型,但具体泛型就不得而知了。Array的情况与此类似。 Reflect Metadata不能满足生成JSDoc动态文档的需求,因此,需要寻找比Reflect Metadata更高级的反射工具库。 5.6.3进阶反射库 Reflect Metadata的工作原理是在源码编译时,给生成的JS文件保存了简单的类型信息,在运行阶段提供这些信息,而要在编译时加入功能或者改变代码,就需要用到下列几种TypeScript编译工具。 (1) TypeScript compiler API: TypeScript内置编译API。使用TypeScript compiler API对编译知识要求比较高,相比接下来的两者而言,算是比较原始的编译工具,其应用例子就是Reflect Metadata。 (2) ttypescript: 提供了和tsc命令相似的ttsc命令编译工具。ttypescript支持transform插件,可以使用插件在编译期做各种改写操作。ttypescript的使用比compiler API简单很多,它在底层调用compiler API对源码进行编译期修改。ttypescript目前不支持TypeScript 5.0+版本,实际测试中发现还有些小问题,例如不能在装饰器里直接编码以获取类型信息。 (3) tspatch: 号称可替代ttypescript的编译工具,同样支持transform插件,只支持TypeScript 5.1以上版本。tspatch通过修改TypeScript源码库的方式进行编译期修改。tspatch目前功能不太稳定,实测发现其import只能支持JavaScript文件,框架放弃了tspatch方案。 上述的编译工具只提供编译时修改源码的接口,其中后两者采用transform插件机制。transform插件是具体修改源码的实现,其编写方法可参考上述工具的文档说明。 transform插件需要配置在tsconfig.json文件里,例如typespeedswagger项目使用的就是 typescriptrtti库的transform插件,代码如下: "compilerOptions": { ... "plugins": [ { "transform": "typescript-rtti/dist/transformer" } ] }, 使用上述编译工具,保存源码类型信息,以及在运行期提供的反射库,除了Reflect Metadata,还介绍两个比Reflect Metadata更强大的第三方反射库: (1) tstreflect,网址为https://github.com/Hookyns/tstreflect,主要使用泛型和接口来定义反射类型,以及获得类型信息。 (2) typescriptrtti,网址为https://typescriptrtti.org,提供链式语法以获取类型信息。 两个反射库都能够提供比Reflect Metadata更为全面的类型信息。不过实际测试发现,tstreflect无法取得Promise信息,而typescriptrtti能够同时取得Promise和Array信息,因此TypeSpeedSwagger项目的选择是ttypescript + typescriptrtti。 普通的TypeScript编译和ttypescript + typescriptrtti组合的编译过程对比,如图576所示。 图576编译过程对比 此外,tsnode命令需要在tsconfig.json文件做下列配置才能支持ttypescript,代码如下: //chapter05/05-swagger/version2/tsconfig.json "ts-node": { "compiler": "ttypescript" } 或者在执行tsnode命令时加入C参数,命令如下: ts-node -C ttypescript app/main.ts 配置ttypescript+typescriptrtti完成后,同样对mainreflect.ts进行编译,对比看和前面的Reflect Metadata编译的差别,如图577所示。编译命令只需执行npm run build,其package.json已经配置了依赖库和脚本命令,代码如下: //chapter05/05-swagger/ttypescript/package.json { "scripts": { 图577两种编译结果的差异 "build": "npx ttsc", "prebuild": "npm i" }, "dependencies": { "reflect-metadata": "latest", "typescript-rtti": "0.9.5" }, "devDependencies": { "ttypescript": "1.5.15", "typescript": "4.9" } } 右边ttypescript+typescriptrtti编译后的JS文件内容要比左边Reflect Metadata编译的结果多一倍,因此,typescriptrtti能够获取的信息较多,代码如下: //chapter05/05-swagger/ttypescript/dist/main-reflect.js //getAge()方法记录了6类反射信息 __RΦ.m("rt:p", [])(MainReflect.prototype, "getAge"); __RΦ.m("design:paramtypes", [])(MainReflect.prototype, "getAge"); __RΦ.m("rt:f", "M")(MainReflect.prototype, "getAge"); __RΦ.m("rt:t", () => __RΦ.a(74))(MainReflect.prototype, "getAge"); __RΦ.m("rt:f", "M")(MainReflect.prototype["getPromise"]); ((t, p) => __RΦ.m("rt:h", () => typeof t === "object" ? t.constructor : t)(t[p]))(MainReflect.prototype, "getPromise"); 编译后的上述代码__RΦ.m()和__metadata()的作用类似,但__RΦ.m()每种方法有6种信息输入,而__metadata()只有3种。 需要注意上述代码中的__RΦ.m("rt:t", () => __RΦ.a(74)),这是getAge()的返回值类型。由于这些代码比较难以辨认,因此把返回值的相关代码抽离出来分析,代码如下: //chapter05/05-swagger/ttypescript/dist/main-reflect.js //24行,记录所有编译前的类型信息,按索引方式存储 t: { [74]: { LΦ: t => Number }, [3269]: { RΦ: t => ({ TΦ: "g", t: __RΦ.a(796), p: [__RΦ.a(15)] }) }, [796]: { LΦ: t => Promise }, [15]: { LΦ: t => String }, [96]: { RΦ: t => ({ TΦ: "[", e: __RΦ.a(15) }) }, [1]: { RΦ: t => ({ TΦ: "~" }) }, [24]: { RΦ: t => ({ TΦ: "V" }) } } } //63行,getAge()方法的相关类型索引 __RΦ.m("rt:t", () => __RΦ.a(74))(MainReflect.prototype, "getAge"); //69行,getPromise()方法的相关类型索引 __RΦ.m("rt:t", () => __RΦ.a(3269))(MainReflect.prototype, "getPromise"); //75行,getArray()方法的相关类型索引 __RΦ.m("rt:t", () => __RΦ.a(96))(MainReflect.prototype, "getArray"); 上述代码后面三行表示三种方法的返回值,其后面的__RΦ.a()表示返回值类型。 (1) getAge()方法对应__RΦ.a(74),在24行里面找74对应的值是[74]: { LΦ: t => Number },因此getAge()方法的返回值是Number数值类型。 (2) getPromise()方法对应__RΦ.a(3269),在24行找3269对应的值是[3269]: { RΦ: t => ({ TΦ: "g", t: __RΦ.a(796), p: [__RΦ.a(15)] }) },这里引出两个__RΦ.a(),继续寻找对应的数字,796对应的是 { LΦ: t => Promise },而15对应的是{ LΦ: t => String },因此getPromise()方法的返回值就是Promise类型,其泛型是String字符串。 (3) getArray()方法对应__RΦ.a(96),96对应的是[96]: { RΦ: t => ({ TΦ: "[", e: __RΦ.a(15) }),这里第1个TΦ: "["表示这是数组,而第2个__RΦ.a(15)对应的是{ LΦ: t => String },即字符串,因此getArray()方法的返回值是数组,其泛型是字符串。 有了上述数据,就能在编译后的文件里取得Promise和Array的返回值类型。 注意: typescriptrtti目前不够完善,部分属性没有提供获取的方法,因此本节部分代码直接读取其属性。 31min 5.6.4实现中间件配置 在5.5.3节介绍了swaggerMiddleware()的3个配置项,现在来看这些配置项是怎么起作用的,代码如下: //chapter05/05-swagger/version2/index.ts function swaggerMiddleware(app: any, options?: { path: string, "allow-ip": string[] }, packageJsonPath?: string) { //Swagger页面的网址 const path = options && options.path || "/docs"; //拼装Swagger文档的地址 const swaggerJsonPath = path + "/swagger.json"; //构建检查浏览器IP是否在白名单内的中间件 const checkAllowIp = (req, res, next) => { //白名单配置 const allowIp = options && options["allow-ip"] || ["127.0.0.1", "::1"]; //从请求头获取浏览器IP const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; if (allowIp.indexOf(ip) !== -1) return next(); //当浏览器IP不在白名单内时,返回403 res.status(403).send("Forbidden"); } const swggerOptions = { swaggerOptions: { url: swaggerJsonPath } } //设置swagger.json的响应中间件 app.get(swaggerJsonPath, checkAllowIp, (req, res) => res.json(swaggerDocument(packageJsonPath))); //显示Swagger页面的中间件 app.use(path, checkAllowIp, swaggerUi.serveFiles(null, swggerOptions), swaggerUi.setup(null, swggerOptions)); } 在swaggerMiddleware()函数里路径变量有两个: (1) path是Swagger页面的网址,app.get()给path路径配置了checkAllowIp、swaggerUi.serveFiles()和swaggerUi.setup()等3个中间件,后两者是swaggeruiexpress库用以显示Swagger页面的中间件。 (2) swaggerJsonPath是path拼接"/swagger.json"组成的路径,即JSDoc文档的地址。swaggerJsonPath的默认值为/docs/swagger.json。app.set()为swaggerJsonPath配置了checkAllowIp和装载swaggerDocument()返回值的中间件。 这里两次提到checkAllowIp中间件,它是检查请求IP是否匹配allowip配置的安全检查中间件,如果IP匹配得上,则认为是合法的访问,允许查看Swagger页面。当IP不匹配时,返回403 Forbidden权限不足的错误提示。 注意: 由于Swagger页面展示了服务器端程序的接口和参数类型等信息,容易被利用来对系统进行分析破解,从而导致非法参数攻击,因此仅允许特定IP访问Swagger页面是常规的安全手段。 此外,packageJsonPath参数直接输入swaggerDocument()函数,packageJsonPath是项目package.json的文件路径,这里读取文件作为Swagger页面信息,代码如下: //chapter05/05-swagger/version2/index.ts function swaggerDocument(packageJsonPath?: string): object { if (routerMap.size === 0) return; //构建Swagger文档对象 const apiDocument = new ApiDocument(); if (packageJsonPath && fs.existsSync(packageJsonPath)) { //从项目package.json文件中读取项目信息 try { const jsonContents = fs.readFileSync(packageJsonPath, 'utf8'); const packageJson = JSON.parse(jsonContents); apiDocument.title = packageJson.name; apiDocument.description = packageJson.description; apiDocument.version = packageJson.version; apiDocument.license = packageJson.license; apiDocument.openapi = packageJson.openapi; } catch (err) { error(`Error reading file from disk: ${err}`); } } //遍历路由 routerMap.forEach((router, key) => { //创建路由ApiPath对象,同时处理请求响应类型 const apiPath = createApiPath(router); if (requestBodyMap.has(key)) { //处理请求体信息 handleRequestBody(apiPath, requestBodyMap.get(key)); } if (requestParamMap.has(key)) { //处理请求参数信息 handleRequestParams(apiPath, requestParamMap.get(key)); } //添加到文档对象中 apiDocument.addPath(router.path, apiPath); }); //遍历对象实体信息,并增加到文档对象中 schemaMap.forEach((schema) => { apiDocument.addSchema(schema); }); //文档对象将收集的信息转换为JSDoc文件输出 return apiDocument.toDoc(); }; 上述代码swaggerDocument()函数使用fs库读取package.json内容,并把内容里面的name、description、version、license和openapi这5个属性赋予ApiDocument对象。效果参考图569所示。 而ApiDocument对象是JSDoc文档的基础类,上述5个属性,加上Path和Schema两部分共同组成了完整的JSDoc结构。swaggerDocument()返回值调用了ApiDocument的toDoc()方法输出动态JSDoc文档,如图578所示。 swaggerDocument()函数的第二部分是遍历routerMap,在每个页面上使用createApiPath()函数创建ApiPath对象。createApiPath()的作用是构建页面的响应类型,代码如下: //chapter05/05-swagger/version2/index.ts function createApiPath(router: RouterType): ApiPath { const { method, clazz, target, propertyKey } = router; //创建ApiPath对象 const apiPath = new ApiPath(method, clazz, propertyKey); //用反射取得路由的响应类型 const responseType = reflect(target[propertyKey]).returnType; if(!responseType || !responseType["_ref"]){ //当响应类型不存在时,只需返回200 OK apiPath.addResponse("200", "OK"); return apiPath; } let realType = responseType["_ref"]; if (responseType.isPromise()) { //当响应类型为Promise时,需要取得下一个层次的类型,即Promise的泛型类型 realType = responseType["_ref"]["p"][0]; } //解析响应类型,类型信息将被收集到schemaMap handleRealType(realType, (item?: ApiItem) => { if (item === undefined) { apiPath.addResponse("200", "OK"); } else { apiPath.addResponse("200", "OK", item); } }) return apiPath; } 图578swaggerDocument构建文档的过程 代码里用到typescriptrtti库的reflect()函数来取得页面方法的响应类型responseType,当responseType为空时页面的状态码为200 OK。 当responseType.isPromise()为真时,也就是当该页面的响应类型是Promise时,将responseType变量的_ref.p.0引用类型输入handleRealType()方法计算其类型。 swaggerDocument()接着将每个页面的请求体和参数类型从requestBodyMap和requestParamMap取出,分别交由handleRequestBody()和handleRequestParams()处理。 handleRequestBody()使用typescriptrtti的parameters属性取得页面方法的参数类型paramType,然后把paramType变量的_ref引用类型输入handleRealType()方法计算其类型,代码如下: //chapter05/05-swagger/version2/index.ts function handleRequestBody(apiPath: ApiPath, bodyParam: RequestBodyMapType) { const { target, propertyKey, parameterIndex } = bodyParam; const paramType = reflect(target[propertyKey]).parameters[parameterIndex]; //if(!paramType || !paramType.type || paramType.type["_ref"]) return; const realType = paramType.type["_ref"]; handleRealType(realType, (item: ApiItem) => { apiPath.addRequestBody(item); }) } handleRequestParams()的逻辑与handleRequestBody()几乎相同,仅handleRealType()计算类型之后的赋值不同,handleRequestBody()用于输入apiPath.addRequestBody(),而handleRequestParams()用于输入apiPath.addParameter()。这是因为在JSDoc文档格式中,请求体和参数的格式是不同的,代码如下: //chapter05/05-swagger/version2/index.ts function handleRequestParams(apiPath: ApiPath, params: ParamMapType[]) { params.forEach(param => { const paramType = reflect(param.target[param.propertyKey]).parameters[param.parameterIndex]; //if(!paramType || !paramType.type || paramType.type["_ref"]) return; const realType = paramType.type["_ref"]; handleRealType(realType, (item: ApiItem) => { apiPath.addParameter(param.paramKind, param.paramName || paramType.name, item); }) }); } 至此,5.5节收集信息的3个全局变量均起到了作用。接下来进入关键方法handleRealType()的讲解。 5.6.5获取对象详细信息 JSDoc文档里的页面响应类型、请求体类型、页面参数类型都使用handleRealType()函数获取其类型,代码如下: //chapter05/05-swagger/version2/index.ts function handleRealType(realType: any, callback: Function) { if (typeof realType === "function") { if (/^class\s/.test(realType.toString())) { //当输入类型是开发者自定义类型时,调用handleComponent()进一步获取其方法 handleComponent(realType); callback(ApiItem.fromType("$ref", realType.name)); } else { callback(ApiItem.fromType(realType.name.toLowerCase())); } } else if (realType["TΦ"] === 'V') { callback(); } else if (realType["TΦ"] === 'O') { callback(ApiItem.fromType("object")); } else if (realType["TΦ"] === '~') { callback(ApiItem.fromType("string")); } else if (realType["TΦ"] === '[') { const deepRealType = realType["e"]; if (/^class\s/.test(deepRealType.toString())) { //当数组泛型是开发者自定义类型时,调用handleComponent()进一步获取其方法 handleComponent(deepRealType); callback(ApiItem.fromArray("$ref", deepRealType.name)); } else { callback(ApiItem.fromArray(deepRealType.name.toLowerCase())); } } else { callback(ApiItem.fromType("string")); } } handleRealType()的第1个参数realType是上述几个场景中输入的引用类型变量,这里进行了一系列判断: (1) 当realType是function时,用toString()函数检查其源码是否存在class关键字,因为源码里带class关键字的function类型就是开发者自定义的类型,这时会把它直接交给handleComponent()函数处理。当没有class关键字时,表示类型是基础类型,取name属性返回即可。 (2) 当realType["TΦ"]等于V时,类型是void,即无返回值。 (3) 当realType["TΦ"]等于O时,类型是object,即{}。 (4) 当realType["TΦ"]等于~时,类型是string字符串。 (5) 当realType["TΦ"]等于[时,类型是数组。数组类型需要再深入检查其泛型类型,继续检查源码是否存在class关键字,交给handleComponent()处理或者返回基础类型的name属性。 handleComponent()和handleRealType()形成递归关系,因为handleComponent()会扫描自定义类型的所有方法和参数,调用handleRealType()检查参数类型。递归循环的结果会赋值给ApiSchema对象,每个ApiSchema对象对应一个自定义类型,这些ApiSchema对象最后会被收集到schemaMap全局变量里。 在swaggerDocument()函数最后一部分代码里会遍历schemaMap以取得所有自定义类型,用apiDocument.addSchema()方法输入ApiDocument对象里。 如果要测试上述整个过程的效果,则需要在页面上增加各种类型的参数,观察是否能够识别和分析其类型,并在Swagger页面上输出。 程序的启动方式是进入chapter05/05swagger/version2目录,然后执行npm test命令。该命令会在当前目录再次拉取NPM库,保证NPM库是可用的。 npm test命令拉取NPM库后便会启动程序,这时使用浏览器访问http://localhost:8081/docs即可看到Swagger页面的显示。 注意: 这里的端口号是8081,和框架运行的端口号不同。 接下来挑选部分接口的实现效果进行讲解。 (1) 在/request/string页面测试Promise泛型是基础类型string,代码如下: @getMapping("/request/string") async testString(req, res, @reqQuery id: number): Promise<string> { log("id: " + id); return Promise.resolve("test string"); } 该页面的参数是基础类型number,响应类型是Promise<string>,显示效果如图579所示。 图579/request/string页面的显示效果 (2) 在/request/query页面测试Promise泛型是自定义类型数组,代码如下: @getMapping("/request/query") async testQuery(req, res, @reqQuery id: number): Promise<DataC[]> { log("id: " + id); return Promise.resolve([new DataC("value to C")]); } 该页面的参数是基础类型number,响应类型是Promise<DataC[]>,其显示效果如图580所示。 图580/request/quer页面的结果 (3) 在/request/body页面测试请求体类型和响应自定义类型,代码如下: @postMapping("/request/body") testBody(@res res, @reqBody body: DataB): DataB { log("body: " + JSON.stringify(body)); return new DataB(100, new DataC("B to C")); } 该页面是POST请求,参数是自定义类型DataB,响应类型同样是DataB,其效果如图581所示。 图581/request/body页面的显示效果 从图581可以看到自定义类型DataB有两种类型的成员变量,即基础类型number和自定义类型DataC,这里已经实现对DataB两种变量类型的分析和显示。 (4) 在/request/param/:id页面测试路径参数类型和复杂的响应自定义类型,代码如下: @getMapping("/request/param/:id") testParam(@res res, @reqParam id: number) : DataA[] { log("id: " + id); return [new DataA(100, "A to B", new DataB(200, new DataC("AB to C")), [new DataC("A to C")])]; } 该页面的参数是路径参数,类型是number,而响应类型是DataA[],即DataA数组,其效果如图582所示。 图582/request/param/:id页面的显示效果 该页面的参数是路径参数,因此在Parameters栏显示时,名称id显示的类型是(path),表示这是路径参数。观察其他页面id参数的类型是(query),表示是Query字符串参数。 页面的响应类型是比较复杂的自定义类型DataA,可观察其代码和显示效果的关系,代码如下: //chapter05/05-swagger/version2/app/entities/data-a.class.ts import DataB from "./data-b.class"; import DataC from "./data-c.class"; export default class DataA { constructor(public paramANumber: number, public paramAString: string, public paramAObjB: DataB, public paramAObjC: DataC[]){} } 在Swagger页面底部的Schemas栏列出了在接口出现的自定义类型,每种类型都可以展开其属性类型,以便了解类型细节,如图583所示。 图583Schemas显示全部自定义类型 5.6.6小结 至此,TypeSpeedSwagger项目完成。该项目经过简单修改即可为TypeSpeed框架的项目增加对Swagger平台的支持,并且任何接口更新都会自动反映在Swagger界面上,比起每次增加接口都要修改源码注释的做法要方便很多。在收集类型数据的开发中,使用了进阶反射库获取各种类型信息,同时讲解了TypeScript反射库的编译过程和原理,有助于读者深入理解TypeScript装饰器原理和反射机制的作用与限制。