使用TS装饰器从0封装一个socket.io服务器

news/2024/10/31 5:34:52/

背景

        最近沉迷WebRTC技术,想用WebRTC试做一个快启动的文件传输PWA应用。涉及到使用websocket服务器传输WebRTC的信令,全栈的话自然是选择Nodejs做服务器了。

选型

        因为项目体量肯定不是很大,所以所有选型都以高性能轻量为主要目标。前端选择的是solidjs+tailwindcss,后端的话思虑了很久,最开始想用的是fastify + ws,但是后来想了想,这个项目对HTTP的依赖程度不高,所有的资源传输都可以用websocket和WebRTC实现,所以就完全不用HTTP服务器了,又考虑到socket.io的封装程度更高,提供了Namespace、room等功能,所以根据官网介绍选择了uWebSockets.js + socket.io做服务器。

内存占用 | Socket.IO

        但是uWebSockets.js + socket.io的封装程度不好,现成框架对这俩组合的支持程度较低,所以我自己仿照nestjs的websocket服务,封装了一部分装饰器和模块的逻辑实现。

思路

        就像nestjs一样,main.ts中编写初始化框架、全局配置等的逻辑;而其他业务方面的代码,则使用模块的方式组织,模块有各自的namespace,维护namespace的事件监听、生命周期、自己内部的逻辑等,各个模块将在框架启动时实例化;

依赖及TS配置

- Nodejs v16以上,因为uWebSockets.js需要v16以上的环境

- pnpm 可选

- uWebSockets.js + socket.io + typescript + reflect-metadata

reflect-metadata是干什么用的?

        reflect-metadata库是一个对ES6 Reflect在元数据扩展方面能力的缺失而实现的一个polyfill,简单来说,就是可以通过Reflect对类、类成员添加一些元数据而不破坏这个类及类成员的构成。

TS配置

        务必开启以下两个配置,因为项目中会用到大量装饰器。

"experimentalDecorators": true,
"emitDecoratorMetadata": true

        虽然TS里面Decorator的实现已经与当前TC39 Decorator Stage3 的实现大相径庭了,但是个人项目使用一时半会儿还不要紧,不过实际项目最好做好准备,尽量不使用TS实现的Stage 2装饰器,等待原生装饰器语法成为正式标准。

代码实现

Socket.IO配合uWebSockets.js的官方示例代码

首先引入uWebSockets.js与socket.io,并将他们实例化

然后将socket.io与uWebSocket.js关联,使socket.io底层由uWebSockets.js实现

注册socket.io事件监听,执行逻辑

启动uWebSockets.js

const { App } = require("uWebSockets.js");
const { Server } = require("socket.io");const app = new App();
const io = new Server();io.attachApp(app);io.on("connection", (socket) => {// ...
});app.listen(3000, (token) => {if (!token) {console.warn("port already in use");}
});

改造

        在入口文件处,引入reflect-metadata,以启用Reflect polyfill

        初始化uWebSockets.js,开始监听端口

        关键函数instantiateModule,在启动时将所有模块实例化。这一块还未进行更好的设计,更进一步的设计是可以初始化一个或多个主模块,各个主模块管理自己的socket.io配置并实例化自己的子模块,socket.io的配置也由装饰器注入,而非在调用instantiateModule时传入。

import "reflect-metadata";
import { App } from "uWebSockets.js";
import { UserModule } from "./src/user/user.module";
import { instantiateModule } from "./factory/instantiateModule";
import { ExchangeModule } from "./src/exchange/exchange.module";async function boostrap () {/** uWebsocket entry */const app = App();instantiateModule(app, [UserModule,ExchangeModule], {transports: ['websocket']});const port = 3000;app.listen(port, token => {if(!token) console.warn(`port ${port} already in use`);});
}boostrap();

instantiateModule函数

1.创建一个socket.io的Server实例,并绑定uWebSockets.js。

2/然后实例化模块,getMetadata取得模块的namespace配置,开始监听该namespace,如果有onConnection或onDisconnecting成员,则添加connection或disconnecting监听。

3.之后注入模块中的server字段(注入Server实例)与namespace字段(注入Namespace实例)。

4.最后监听namespace的connect事件,在一个socket进入时为其添加模块中注明的事件监听器。

        namespace配置、server字段、socket事件监听函数应该怎么获得呢?使用装饰器,为类、类成员添加修饰,在实例化时读取到它们,然后处理。

import { Namespace, Server, ServerOptions, Socket } from "socket.io";
import { DecoratorMetadataName, ListenersMetadata, SocketFactoryOptions } from "./SocketDecorators";
import { TemplatedApp } from "uWebSockets.js";interface ModuleInstance {socket?: Socket,namespace?: Namespace,server?: Server,[key: string | number | symbol]: any
}function createGlobalServer (app: TemplatedApp, serverOpt?: Partial<ServerOptions>) {const io = new Server(serverOpt);io.attachApp(app);return io;
}function bindDecoratorListeners (self: ModuleInstance, socket: Socket) {/** mount listener */const listeners: ListenersMetadata = Reflect.getMetadata(DecoratorMetadataName.EventListener, self);listeners?.forEach(listener => {socket.on(listener.name, (...args: any) => listener.listener.call(self, socket, ...args))});
}function analyseDecoratorValues (self: ModuleInstance) {const map = new Map<DecoratorMetadataName, string>();const ownKeys = Object.keys(self);for(let key of ownKeys) {/** get websocket client socket propertyKey */if(Reflect.getMetadata(DecoratorMetadataName.SocketProperty, self, key)) {map.set(DecoratorMetadataName.SocketProperty, key);continue;}/** get websocket namespace propertyKey */if(Reflect.getMetadata(DecoratorMetadataName.NamespaceProperty, self, key)) {map.set(DecoratorMetadataName.NamespaceProperty, key);continue;}/** get websocket server propertyKey */if(Reflect.getMetadata(DecoratorMetadataName.ServerProperty, self, key)) {map.set(DecoratorMetadataName.ServerProperty, key);continue;}}return map;
}/** bind namespace and server */
function bindDecoratorValuesBeforeConnect (map: Map<DecoratorMetadataName, string>,self: ModuleInstance,nsp: Namespace,ioInstance: Server
) {const nspPropertyKey = map.get(DecoratorMetadataName.NamespaceProperty);const serverPropertyKey = map.get(DecoratorMetadataName.ServerProperty);if(nspPropertyKey) {Reflect.set(self, nspPropertyKey, nsp);}if(serverPropertyKey) {Reflect.set(self, serverPropertyKey, ioInstance);}
}/** bind socket */
function bindDecoratorValuesAfterConnect (map: Map<DecoratorMetadataName, string>,self: ModuleInstance,socket: Socket
) {const socketPropertyKey = map.get(DecoratorMetadataName.SocketProperty);if(socketPropertyKey) {Reflect.set(self, socketPropertyKey, socket);}
}/*** construct global instance of socket.io* construct module* inject provider*/
export function instantiateModule (app: TemplatedApp,modules: (new () => ModuleInstance)[],globalServerOpt?: Partial<ServerOptions>
) {const io = createGlobalServer(app, globalServerOpt);modules.forEach(ModuleItem => {const instance = new ModuleItem();const opt: SocketFactoryOptions = Reflect.getMetadata(DecoratorMetadataName.WebsocketModule, ModuleItem);const { namespace } = opt ?? {};const nsp = io?.of(namespace ?? '/');const decoratorMap = analyseDecoratorValues(instance);bindDecoratorValuesBeforeConnect(decoratorMap, instance, nsp, io);nsp.on('connection', (socket) => {bindDecoratorValuesAfterConnect(decoratorMap, instance, socket);bindDecoratorListeners(instance, socket);instance.onConnection?.(socket);socket.on('disconnect', (...args) => {if(instance?.onDisconnect) instance.onDisconnect.call(instance, ...args);});socket.on('disconnecting', (...args) => {if(instance?.onDisconnecting) instance.onDisconnecting.call(instance, ...args);});});});
}

装饰器

        共有WebsocketModule、Subscribe、WebsocketServer、WebsocketNamespace四个装饰器,为类、类成员注入metadata,metadata中保存相应信息,在实例化时取出并应用。

        Subscribe装饰器会将所有监听事件放入一个数组中,而非直接为该成员添加eventName metadata,是因为class创建的类中,类方法都未挂载在this上,而是挂载在原型链上,无法通过遍历类成员获得。

/** 注入Metadata的name */
export enum DecoratorMetadataName {WebsocketModule = '[[wsopt]]',EventListener = '[[listener]]',ServerProperty = '[[serverprop]]',NamespaceProperty = '[[namespaceprop]]',SocketProperty = '[[socketprop]]'
}/** 定义namespace模块 */
export interface SocketFactoryOptions {namespace?: string;
}
export function WebsocketModule(serverOpt?: SocketFactoryOptions): ClassDecorator {return function (target: Function) {Reflect.defineMetadata(DecoratorMetadataName.WebsocketModule, serverOpt,target);}
}/** 订阅消息装饰器 */
export type ListenersMetadata = Array<{name: string,listener: (...args: any[]) => void
}>
export function Subscribe(eventName?: string): MethodDecorator {return function (target: any, _, descriptor) {const listeners = Reflect.getMetadata(DecoratorMetadataName.EventListener, target) ?? [];Reflect.defineMetadata(DecoratorMetadataName.EventListener,listeners.concat([{name: eventName,listener: descriptor.value,}]),target);}
}/** 为类成员注入Server实例 */
export function WebsocketServer(target: object, propertyKey: string | symbol) {Reflect.defineMetadata(DecoratorMetadataName.ServerProperty, true, target, propertyKey)
}/** 为类成员注入Namespace实例 */
export function WebsocketNamespace(target: object, propertyKey: string | symbol) {Reflect.defineMetadata(DecoratorMetadataName.NamespaceProperty, true, target, propertyKey)
}/** 为类成员注入Socket实例*  暂时废弃,因为该实例在construct时无法获得,易造成误解*/
export function SocketInstance(target: object, propertyKey: string | symbol) {Reflect.defineMetadata(DecoratorMetadataName.SocketProperty, true, target, propertyKey)
}

装饰器使用方法

import { WebsocketServer, Subscribe, WebsocketModule, SocketInstance } from "../../factory/SocketDecorators";
import { Server, Socket } from "socket.io";@WebsocketModule({ namespace: '/user' })
export class UserModule {@WebsocketServer server?: Server;@SocketInstance socket?: Socket;constructor() {}onConnection() {console.log('user connection', this.socket?.id);}@Subscribe('token')public handleToken(socket: Socket, data: any) {console.log('token', data);}
}

全部代码

        目前是未完成版本,之后可能会对其进行迭代优化

TransferS/backend at master · YThinker/TransferS · GitHub


http://www.ppmy.cn/news/276665.html

相关文章

Mybatis面试题

Mybatis常见面试题 #{}和${}的区别是什么&#xff1f; {}和${}的区别是什么&#xff1f; 在Mybatis中&#xff0c;有两种占位符 #{}解析传递进来的参数数据${}对传递进来的参数原样拼接在SQL中#{}是预编译处理&#xff0c;${}是字符串替换。使用#{}可以有效的防止SQL注入&…

新版本踩坑记录:SpringBoot2.3.x整合ElasticSearch7.6.x(ElasticsearchRestTemplate)包含类型转换踩坑

使用ElasticSearch7.6.x 的时候&#xff0c;和7之前的版本有很大的不同&#xff0c;下面列举了一些踩坑记录&#xff1a;eg &#xff1a;StringTerms 类型转换失败问题&#xff0c;聚合查询自动拆分搜索关键字问题…等等&#xff08;之后出现问题还会回头补坑&#xff09; 聚合…

观念 信仰的价格

转 观念 信仰的价格 观念 信仰的价格 要点&#xff1a;在社会上&#xff0c;有很多关于怎样训练创造力的讨论&#xff0c;而很少有人去讨论人为什么去创造。本问引入一个新的单位PH&#xff08;幸福值&#xff09;&#xff0c;来统一经济利润最大化和精神价值追求者之间的分歧…

一周总结: 2020.3.2-2020.3.8

所有资料来源于网络&#xff0c;非原创 1.mysql 1对多查询 分页&#xff0c;并且用多表字段对查询结果表进行排序 分页暂时无法实现&#xff0c;在数据库进行分页&#xff0c;返回由mybatis对结果进行处理&#xff0c;多的映射为list集合 2.MySQL 取分组后每组的最新创建的记…

Debezium系列之:同一张表的数据始终进入Kafka Topic的相同分区,同时表结构变化的DDL事件也发送到相同分区

Debezium系列之:同一张表的数据始终进入Kafka Topic的相同分区,同时表结构变化的DDL事件也发送到相同分区 一、需求背景二、实现思路三、核心参数和参数详解四、创建表五、提交Debezium Connector六、插入数据,并消费Topic七、修改表结构,消费Topic八、再次插入数据,消费T…

为什么当贝F3依然是家用投影仪性价比之王 有哪些惊艳之处

最近在网上4G冲浪时&#xff0c;看到许多网友对于家用投影仪当贝F3的宠爱&#xff0c;都觉得当贝F3是家用投影仪界的性价比之王。但是也有网友在评论下发问在如今新品众多的情况下&#xff0c;当贝F3是如何稳坐性价比之王的宝座的&#xff1f;那么今天小编就来和大家说说为什么…

为什么 Mac 适合编程?

强劲的 GPU 和 CPU。我的家用电脑和笔记本都配了顶级的显示器和 GPU。Steam 上有 2000 游戏&#xff0c;我和孩子玩了很多&#xff0c;并且我对 CUDA 和 深度学习很感兴趣。而 Mac 对此就无能为力了。对我来说&#xff0c;强大的 GPU 是非常重要的&#xff0c;所以我配了一台搭…

星巴克、苹果、谷歌、亚马逊等巨头,为何同时做这件事?

综合整理&#xff5c;《中国企业家》记者 周夫荣 编辑&#xff5c;马吉英 摘要&#xff1a;除了星巴克之外&#xff0c;苹果、谷歌、亚马逊等高科技公司也早已在可持续能源领域悄然布局。除了经济原因和社会责任&#xff0c;这些公司或许有更深远的考量。 当外界把星巴克视为咖…