typescript get方法_使用 Typescript 构建类型安全的 Websocket 应用
本文會通過一個簡單的聊天室例子分享如何使用 Typescript 實現一個類型安全 Websocket 應用,在文章最后有 Github 項目地址。例子中的前端是使用 Angular 不過本文不會涉及相關知識點,其他框架使用者不必為此擔心。
最終代碼預覽背景
當我們選擇使用 Websocket 與服務器進行通訊時可能會遇到多個消息類型復用一條連接的情況, 這時就有希望有一個能夠根據消息類型來約束數據類型的方案,以便在開發階段發現問題并減低維護成本和提升開發體驗。
假設我們約定的是這樣的消息格式:
{type: 'TYPE', // 消息類型data: {...} // 數據 }按照傳統的設計思路我們可能會這樣設計和使用:
// 消息服務import { webSocket, WebSocketSubject } from 'rxjs/webSocket'class MessageService {private ws: WebSocketSubject<any>;connect() {this.ws = webSocket({url: 'ws://localhost:8080/ws'});}send(type: string, data?:any) {this.ws.next({type,data});} }// 在組件中使用class AppComponent {constructor(private messageService: MessageService) // 訂閱消息this.messageService.subscribe(data => {switch (data.type) {case 'TYPE_1':...case 'TYPE_2':...}})}// 發送消息shend1() {this.messageService.send('TYPE_1', {...});}shend2() {this.messageService.send('TYPE_2', {...});}}這樣的好處是使用非常靈活,幾乎就是把 Rxjs 的 webSocket 返回直接提供給了開發者,不過缺點也很明顯,就是上文提到的無法將消息類型和數據類型對應起來,尤其是在大型的 Websocket 應用中尤為突出。
為了解決這些問題,接下來我們會利用 Typescript 的一些高階用法重新編寫一個類型安全的 Websocket 應用。
類型定義
在開始之前我們需要先解釋下文會用到的兩個詞的意思,避免混淆。
- 消息類型(用于區分不同行為的消息, 與 Typescript 無關)
- 數據類型(真正意義上的 Typescript 類型,對應每個消息的數據類型)
消息類型
為了區分不同行為的消息我們需要先定義消息類型的枚舉,其中 Receive 是接收類型,Send 是發送類型。其實一個枚舉就可以滿足這個需求,不過為了覆蓋更多在項目中可能會出現的情況這里聲明了兩個枚舉。
export enum Receive {CONNECT = 'CONNECT', // 連接成功USER_LIST = 'USER_LIST', // 聊天室用戶列表MESSAGE = 'MESSAGE', // 新消息JOINED = 'JOINED', // 新用戶加入LEAVE = 'LEAVE', // 用戶離開 RENAME = 'RENAME', // 用戶重命名 }export enum Send {JOINED = 'JOINED', // 加入聊天室LEAVE = 'LEAVE', // 離開聊天室RENAME = 'RENAME', // 重命名MESSAGE = 'MESSAGE', // 發送新消息GET_USER_LIST = 'GET_USER_LIST' // 獲取用戶列表 }將枚舉初始為字符串可以讓我們直接獲取到對應字符串
數據類型
出于演示目的,這里的數據類型僅滿足最小可用度,同時也是為了便于理解。
/** 用戶 */ export type User = string;/** 重命名 */ export interface Rename {user: User; // 之前的用戶名newName: User; // 新的用戶名 }/** 聊天消息體 */ export interface ChatMessage {form: User; // 發送這條消息的用戶content: string; // 消息正文time: number; // 發送時的時間戳 }類型映射
不同的消息類型對應了不同的數據, 這里我們再添加兩個 interface 用于映射消息類型和數據類型的關系,其中 key 是消息類型 value 是與之對應的數據類型。
需要注意的是有些 value 的類型為 never, 這意味著它不需要要發送數據。
export interface MessageReceiveData {[Receive.CONNECT]: never;[Receive.USER_LIST]: User[];[Receive.MESSAGE]: ChatMessage;[Receive.JOINED]: User;[Receive.LEAVE]: User;[Receive.RENAME]: Rename; }export interface MessageSendData {[Send.MESSAGE]: ChatMessage;[Send.GET_USER_LIST]: never;[Send.JOINED]: User;[Send.LEAVE]: User;[Send.RENAME]: Rename; }消息格式
接下來定義我們約定的 Websocket 消息格式的類型
type DataType<T extends (Send | Receive)> = T extends Send ? MessageSendData[Send] : MessageReceiveData[Receive];export interface MessageBody<T extends (Send | Receive)> {type: T;data: DataType<T>; }這里的 T 是類型參數,通過 extends 將它限制在了 Send 和 Receive 兩個枚舉之間。
DataType 是一個條件類型,用于確定在不同消息類型時返回正確的數據類型,理解這個簡單的工具類型對閱讀下面的內容很有幫助,這里舉例稍做解釋。
當 T 為 Receive.USER_LIST 時,將會返回如下類型
MessageBody<Receive.USER_LIST>{type: 'USER_LIST',data: User[] }當 T 為 Receive.CONNECT 時,將會返回如下類型。注意!這里因為對應的數據類型是 never 所以沒有 data 屬性。
MessageBody<Receive.CONNECT>{type: 'CONNECT' }WebSocket 服務
接下來修改最初版本的服務,為 WebSocketSubject 定義明確的類型,并添加一個 receive 方法通過一個 Subject 來分發消息。
class MessageService {// ws 同時控制著發送和接受,所以類型可能是 `Receive` 也肯能是 `Send `private ws: WebSocketSubject<MessageBody<Receive | Send>>;// 這個 Subject 充當分發接受消息的角色,其類型只可能是 `Receive `private received$ = new Subject<MessageBody<Receive>>();connect() {this.ws = webSocket({url: 'ws://localhost:8080/ws'});this.ws.subscribe(data => this.received$.next(data as MessageBody<Receive>));}receive<T extends Receive>(type: T): Observable<MessageReceiveData[T]> {return this.received$.pipe(filter(message => message.type === type),map(message => message.data)) as Observable<MessageReceiveData[T]>;}... }這里的 receive 方法同樣接收一個參數類型,同時也是消息類型,并且返回在 MessageReceiveData 中對應的數據類型,這樣我們便可以安全的使用 receive 方法來訂閱消息在保證類型安全同時還能獲得高級編輯器的智能提示功能。
class AppComponent {constructor(private messageService: MessageService) this.messageService.receive(Receive.CONNECT).subscribe(() => {// 鏈接成功})this.messageService.receive(Receive.MESSAGE).subscribe(data => {// 收到消息console.log(data.content) // 正常打印console.log(data.a) // Error Property 'a' does not exist on type 'ChatMessage'.})} }熟悉 Rxjs 的同學知道這里還需要做取消訂閱處理,稍后我們會有統一的方案處理這個問題。
接收消息的問題解決了,接下來需要解決發送數據的問題了,不同的消息類型需要發送不同的數據,而比如上面的 Send.GET_USER_LIST 類型根本不需要發送數據,只需要發送對于的消息類型即可,要做到這一點我們希望 send 方法能根據類型識別需要的數據類型,同時還能支持無數據的消息類型。在開始之前我們先看看之前的 send 方法。
class MessageService {... send(type: string, data?:any) {this.ws.next({type,data});} }可以看到這里的 data 參數是可選的,就是為了應對無數據的消息類型,現在我們希望 Typescript 在知道 type 的情況下能明確告訴我們是否需要 data,以及什么類型的 data。請考慮下面這種實現方式。
send<T extends Send>(type: T, data?: MessageSendData[T]) {this.ws.next({type,data});}send<Send.GET_USER_LIST>(Send.GET_USER_LIST); // 正常工作切不報錯send<Send.MESSAGE>(Send.MESSAGE, message); // 正常工作且能正確識別類型send<Send.RENAME>(Send.RENAM); // 不能正常工作,但是不報錯這樣的確可以知道 data 的類型,不過由于有些消息類型不需要 data,所以類型是可選的,這導致我們無法確切的知道一個消息類型是否需要 data。 有沒有什么辦法可以同時兼顧著兩需求呢?請再考慮下面的實現方式。
type ArgumentsType<T> = T extends (...args: infer U) => void ? U : never;type SendArgumentsType<T extends keyof MessageSendData> =MessageSendData[T] extends never? ArgumentsType<(type: T) => void>: ArgumentsType<(type: T, data: MessageSendData[T]) => void>;send<T extends Send>(...args: SendArgumentsType<T>) {const [type, data] = args;this.ws.next({type,data});}這兩個類型定義看上去有點復雜,也許還有些沒見過的操作符,讓我們先來看看 ArgumentsType 類型。
這里它接受一個類型參數,如果是參數個方法則返回 U,那么這個 U 是哪里來的呢?是通過 infer 操作符推導出來的,這里推導的是傳入方法的參數,而且是通過參數展開操作符(...) 展開后的數組,實際使用的效果是這樣的。
type ArgumentsType<T> = T extends (...args: infer U) => void ? U : never;// 正確返回函數參數類型數組 ArgumentsType<(string, number) => void>; // [string, number]; ArgumentsType<(boolean) => void>; // [boolean]; ArgumentsType<() => void>; // [];// 錯誤的輸入返回 naver ArgumentsType<string> // never現在再來看SendArgumentsType 類型,首先判斷 MessageSendData[T] 類型是不是 naver, 如果是的話則通過 ArgumentsType 來返回無數據的參數類型,否則就通過 ArgumentsType 來返回對應消息類型的參數類型。
export type SendArgumentsType<T extends keyof MessageSendData> =MessageSendData[T] extends never? ArgumentsType<(type: T) => void>: ArgumentsType<(type: T, data: MessageSendData[T]) => void>;// 需要發送數據的類型正確返回消息類型和數據類型 SendArgumentsType<Send.MESSAGE> // [Send.MESSAGE, ChatMessage] SendArgumentsType<Send.RENAME> // [Send.RENAME, Rename]// 不需要發送數據的類型只返回消息類型 SendArgumentsType<Send.GET_USER_LIST> // [Send.GET_USER_LIST]那么現在我們利用參數展開操作符(...),將 shend 方法的參數展開成數組,再利用 SendArgumentsType 類型就能約束參數的格式和類型了。
send<Send.GET_USER_LIST>(Send.GET_USER_LIST); // 正常工作切不報錯 send<Send.MESSAGE>(Send.MESSAGE, message); // 正常工作且能正確識別類型 send<Send.RENAME>(Send.RENAM); // 不能正常工作,能正確報錯現在我們發送/接受消息都能正確提示和約束類型了。
class AppComponent {username = '';constructor(private messageService: MessageService) this.messageService.receive(Receive.CONNECT).subscribe(() => {// 鏈接成功this.getUserList();})this.messageService.receive(Receive.MESSAGE).subscribe(data => {// 收到消息})}getUserList() {this.messageService.send<Send.GET_USER_LIST>(Send.GET_USER_LIST);}join(username: string) {this.username = username;this.messageService.send<Send.JOINED>(Send.JOINED, this.username);}rename(username: string) {const data = {user: this.username,newName: username};this.messageService.send<Send.RENAME>(Send.RENAME, data);}sendMessage(content: string) {const message = {content,form: this.username,time: Date.now()};this.messageService.send<Send.MESSAGE>(Send.MESSAGE, message);} }更近一步
看上去我們的問題已經解決了,不過訂閱消息的方式還是顯得很臃腫,而且還有取消訂閱的問題沒有解決,如果應用中很多組件都訂閱了消息,那么光是維護訂閱也會花費不少的時間了精力。所以接下來我們還要繼續改造我們的應用,添加一個訂閱管理器以及訂閱裝飾器。
class AppComponent {username = '';constructor(private messageService: MessageService) this.messageService.receive(Receive.CONNECT).subscribe(() => {// 鏈接成功})this.messageService.receive(Receive.MESSAGE).subscribe(data => {// 收到消息})this.messageService.receive(Receive.USER_LIST).subscribe(data => {// 更新用戶列表})} }上面的代碼依然存在兩個問題:
所以現在我們要著手解決這兩個問題,讓一個方法負責一個類型的消息,同時不再需要關心取消訂閱的問題,就像下面這樣。
export class AppComponent extends MessageListenersManager {constructor(private messageService: MessageService) {super(messageService);}@MessageListener(Receive.CONNECT)onConnect() {// 鏈接成功}@MessageListener(Receive.MESSAGE)addMessage(message: ChatMessage) {// 收到消息}@MessageListener(Receive.USER_LIST)updateUserList(users: User[]) {// 收到用戶列表} }訂閱管理器
我們先完成 MessageListenersManager 類,它的目的主要有兩個一是確保派生類注入 MessageService 實例,二是通過一些方法管理 Subscription 并在合適的時機取消訂閱,它的實現是這樣的。
export class MessageListenersManager {static __messageListeners__: Function[] = [];readonly __messageListenersTakeUntilDestroy$__ = new Subject<void>();constructor(public messageService: MessageService) {while (MessageListenersManager.__messageListeners__.length > 0) {const fun = MessageListenersManager.__messageListeners__.pop();fun.apply(this);}}ngOnDestroy(): void {this.__messageListenersTakeUntilDestroy$__.next();this.__messageListenersTakeUntilDestroy$__.complete();} }首先定義一個靜態屬性 __messageListeners__,用于存放創建訂閱的方法,然后再 constructor 里面依次執行它們以創建訂閱,但是為什么要用靜態屬性呢?因為在成員裝飾器被調用的時候類還沒有被構造,無法訪問內部的屬性,只有靜態屬性可以在構造前被訪問。之后再創建一個名為 __messageListenersTakeUntilDestroy$__ 的 Subject 再訂閱時放在 Rxjs 操作符 takeUntil 里用于取消訂閱,最后在 ngOnDestroy 生命周期里調用以取消訂閱。
訂閱裝飾器
接下來創建一個成員裝飾器,創建指定消息類型的訂閱然后將方法推入 __messageListeners__,這里的 ReceiveArgumentsType 類型是用來約束被裝飾方法的參數類型。
export type ReceiveArgumentsType<T extends keyof MessageReceiveData> = MessageReceiveData[T] extends undefined? () => void: (data?: MessageReceiveData[T]) => void;export function MessageListener<T extends keyof MessageReceiveData>(type: T) {return (target: MessageListenersManager,propertyKey: string,descriptor: TypedPropertyDescriptor<ReceiveArgumentsType<T>>) => {// 獲取構造上的靜態屬性 `__messageListeners__ `const constructor = Object.getPrototypeOf(target).constructor;if (constructor && constructor.__messageListeners__) {// 將創建訂閱的方法推入 `__messageListeners__ `, 以便在構造時調用constructor.__messageListeners__.push(function() {// 創建指定類型的訂閱this.messageService.receive(type)// 使用 `takeUntil ` 操作符以便自動取消訂閱.pipe(takeUntil(this.__messageListenersTakeUntilDestroy$__)).subscribe(data => {// 收到返回后調用被裝飾得方法descriptor.value.call(this, data);});});}return descriptor;};現在我們就可以像下面這樣訂閱和發送 Websocket 消息了!
export class AppComponent extends MessageListenersManager {username = '';constructor(private messageService: MessageService) {super(messageService);}@MessageListener(Receive.CONNECT)onConnect() {// 鏈接成功}@MessageListener(Receive.MESSAGE)addMessage(message: ChatMessage) {// 收到消息}@MessageListener(Receive.USER_LIST)updateUserList(users: User[]) {// 收到用戶列表}getUserList() {// 獲取用戶列表 this.messageService.send<Send.GET_USER_LIST>(Send.GET_USER_LIST);}join(username: string) {// 加入聊天室this.username = username;this.messageService.send<Send.JOINED>(Send.JOINED, this.username);}rename(username: string) {// 重命名const data = {user: this.username,newName: username};this.messageService.send<Send.RENAME>(Send.RENAME, data);}sendMessage(content: string) {// 發送新消息const message = {content,form: this.username,time: Date.now()};this.messageService.send<Send.MESSAGE>(Send.MESSAGE, message);} }總結
我們使用 Typescript 實現了在多類型消息復用一條 Websocket 連接下的類型安全,讓我們能在編譯階段發現類型的使用錯誤以及編輯器的智能提示,同時利用裝飾器讓我們能夠跟好的組織訂閱消息的代碼。充分利用 Typescript 的強大特性而不是僅僅使用 interface 能在保證項目健壯的同時提高開發體驗,感謝閱讀 Happy coding !
https://github.com/hsuanxyz/ts-websocket?github.com總結
以上是生活随笔為你收集整理的typescript get方法_使用 Typescript 构建类型安全的 Websocket 应用的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 女子喜欢养鸡鸭患鹦鹉热住进ICU:莫名高
- 下一篇: 单例模式双重校验锁_滴滴面试官:如何实现