WebSockets di Angular. Bagian 2. Solusi Produk

gambar

Dalam artikel sebelumnya, kami berbicara tentang solusi umum untuk soket web di Angular, di mana kami membangun bus dengan koneksi kembali dan layanan untuk digunakan dalam komponen yang didasarkan pada WebSocketSubject. Implementasi seperti itu cocok untuk sebagian besar kasus sederhana, misalnya, menerima dan mengirim pesan obrolan, dll., Tetapi kemampuannya mungkin tidak cukup di mana Anda perlu membangun sesuatu yang lebih fleksibel dan terkontrol. Dalam artikel ini, saya akan mengungkapkan beberapa fitur ketika bekerja dengan soket web dan berbicara tentang persyaratan yang Anda temui dan, mungkin, Anda akan temui.

Seringkali, dalam proyek besar dengan kehadiran tinggi, front-end menghadapi tugas yang, dalam keadaan lain, lebih umum dilihat pada back-end. Dalam kondisi penghematan sumber daya server, bagian dari masalah bermigrasi ke wilayah front-end, untuk alasan ini, maka ekstensibilitas dan kontrol maksimum diletakkan dalam proyek.

Berikut adalah daftar persyaratan dasar untuk klien soket web yang akan dibahas dalam artikel ini:

  • Sambungkan kembali "pintar" otomatis;
  • Mode debug;
  • Sistem berlangganan acara berdasarkan pada RxJs;
  • Penerimaan dan penguraian data biner;
  • Proyeksi (pemetaan) dari informasi yang diterima pada model;
  • Kontrol atas perubahan model saat acara baru tiba;
  • Abaikan acara yang sewenang-wenang dan batalkan abaikan.

Pertimbangkan setiap item secara lebih rinci.

Hubungkan kembali / debag


Saya menulis tentang menyambung kembali di artikel sebelumnya, jadi saya akan mengutip bagian dari teks:

Sambungan ulang, atau organisasi menghubungkan kembali ke server, adalah faktor terpenting ketika bekerja dengan soket web, sebagai jeda jaringan, server lumpuh, atau kesalahan lain yang menyebabkan terputusnya koneksi dapat menyebabkan aplikasi mogok.
Penting untuk dicatat bahwa upaya rekoneksi tidak boleh terlalu sering dan tidak boleh dilanjutkan tanpa batas, seperti perilaku ini dapat menangguhkan klien.

Soket web itu sendiri tidak tahu cara menyambung kembali ketika terputus. Karena itu, jika server reboot atau server crash, atau pengguna menghubungkan kembali Internet, maka untuk terus bekerja, Anda juga perlu menghubungkan kembali soket web.

Dalam artikel ini, untuk menghubungkan kembali dan debugging, kita akan menggunakan Menghubungkan kembali WebSocket , yang berisi fungsionalitas yang diperlukan dan opsi lain, seperti mengubah url soket web antara koneksi ulang, memilih konstruktor WebSocket sewenang-wenang, dll. Alternatif lain juga cocok. Menyambung kembali dari artikel sebelumnya tidak cocok, karena Itu ditulis di bawah WebSocketSubject, yang kali ini tidak berlaku.

Sistem Berlangganan Acara RxJs


Untuk menggunakan soket web dalam komponen, Anda harus berlangganan acara dan berhenti berlangganan jika perlu. Untuk melakukan ini, gunakan pola desain Pub / Sub yang populer.
"Penerbit-pelanggan (eng. Penerbit-pelanggan atau eng. Pub / sub) - templat desain perilaku untuk transmisi pesan di mana pengirim pesan, yang disebut penerbit (eng. Penerbit), tidak secara langsung terikat pada kode program untuk mengirim pesan ke pelanggan (eng. Pelanggan ) Sebagai gantinya, pesan-pesan tersebut dibagi menjadi beberapa kelas dan tidak mengandung informasi tentang pelanggan mereka, jika ada. Demikian pula, pelanggan berurusan dengan satu atau beberapa kelas pesan, abstrak dari penerbit tertentu. "

Pelanggan tidak menghubungi penerbit secara langsung, tetapi melalui bus perantara - layanan situs web. Seharusnya juga dimungkinkan untuk berlangganan beberapa acara dengan tipe pengembalian yang sama. Setiap langganan membuat Subjek sendiri, yang ditambahkan ke objek pendengar, yang memungkinkan Anda untuk mengatasi peristiwa soket web ke langganan yang diperlukan. Ketika bekerja dengan Subjek RxJs, ada beberapa kesulitan dengan berhenti berlangganan, oleh karena itu, kami akan membuat pengumpul sampah sederhana yang akan menghapus subyek dari objek pendengar ketika mereka tidak memiliki pengamat.

Penerimaan dan penguraian data biner


WebSocket mendukung transfer data biner, file atau stream, yang sering digunakan dalam proyek-proyek besar. Itu terlihat seperti ini:
0x80, <panjang - satu byte atau lebih>, <isi pesan>

Agar tidak membuat batasan pada panjang pesan yang dikirim dan pada saat yang sama tidak menghabiskan byte secara tidak rasional, pengembang protokol menggunakan algoritma berikut. Setiap byte dalam indikasi panjang dianggap secara terpisah: yang tertinggi menunjukkan apakah itu byte terakhir (0) atau yang lain (1) mengikutinya, dan 7 bit yang lebih rendah berisi data yang dikirim. Oleh karena itu, ketika tanda bingkai data biner 0x80 muncul, maka byte berikutnya diambil dan disimpan dalam "celengan" yang terpisah. Kemudian byte berikutnya, jika memiliki set bit paling signifikan, juga ditransfer ke "celengan" dan seterusnya hingga byte dengan nol bit paling signifikan ditemui. Byte ini adalah yang terakhir dalam indikator panjang dan juga ditambahkan ke "celengan". Sekarang, bit tinggi dihapus dari byte di celengan, dan sisanya digabungkan. Ini akan menjadi panjang badan pesan - angka 7-bit tanpa bit yang paling signifikan.

Mekanisme parsing front-end dan aliran biner rumit dan dikaitkan dengan pemetaan data pada model. Artikel terpisah dapat dikhususkan untuk ini. Kali ini kami akan menganalisis opsi sederhana, dan meninggalkan kasus-kasus sulit untuk publikasi berikut, jika ada minat pada topik.

Proyeksi (pemetaan) dari informasi yang diterima pada model


Terlepas dari jenis transmisi yang diterima, wajib membaca dan memodifikasi dengan aman. Tidak ada konsensus tentang bagaimana melakukan ini dengan lebih baik, saya mematuhi teori model data , karena saya menganggapnya logis dan dapat diandalkan untuk pemrograman dalam gaya OOP.
“Model data adalah definisi objek yang abstrak, mandiri, logis, operator dan elemen lainnya yang bersama-sama membentuk mesin akses data abstrak yang digunakan pengguna untuk berinteraksi. "Objek-objek ini memungkinkan Anda untuk memodelkan struktur data, dan operator - perilaku data."

Semua jenis ban populer yang tidak memberikan gambaran tentang objek sebagai kelas di mana perilaku, struktur, dll didefinisikan menciptakan kebingungan, kurang terkontrol dengan baik dan kadang-kadang ditumbuhi sesuatu yang tidak khas bagi mereka. Misalnya, kelas anjing harus menggambarkan anjing dalam kondisi apa pun. Jika anjing dianggap sebagai seperangkat bidang: ekor, warna, wajah, dll, maka anjing dapat menumbuhkan cakar ekstra, dan anjing lain akan muncul sebagai ganti kepala.

gambar

Kontrol atas perubahan model saat acara baru tiba


Dalam paragraf ini saya akan menjelaskan masalah yang saya temui saat bekerja di antarmuka web aplikasi mobile taruhan olahraga. API aplikasi bekerja melalui soket web di mana mereka menerima: memperbarui peluang, menambah dan menghapus jenis taruhan baru, pemberitahuan tentang awal atau akhir pertandingan, dll. - total sekitar tiga ratus peristiwa soket web. Selama pertandingan, taruhan dan informasi terus diperbarui, kadang-kadang 2-3 kali per detik, jadi masalahnya adalah setelah mereka antarmuka diperbarui tanpa kontrol perantara.

Ketika pengguna memantau tawaran dari perangkat seluler, dan pada saat yang sama daftar diperbarui pada layarnya, tawaran menghilang dari bidang tampilan, sehingga pengguna harus mencari lagi tawaran yang dilacak. Perilaku ini diulang untuk setiap pembaruan.

gambar

Solusi ini membutuhkan ketetapan untuk objek yang ditampilkan di layar, tetapi pada saat yang sama koefisien taruhan harus berubah, tawaran yang tidak relevan menjadi tidak aktif, dan yang baru tidak ditambahkan hingga pengguna menggulir layar. Opsi yang kedaluwarsa tidak disimpan di backend, oleh karena itu baris tersebut harus diingat dan ditandai dengan bendera "dihapus", yang untuk itu penyimpanan data antara dibuat antara situs web dan berlangganan, yang memastikan kontrol terhadap perubahan.

Dalam layanan baru, kami juga akan membuat lapisan pengganti dan kali ini kami akan menggunakan Dexie.js - pembungkus API IndexedDB, tetapi basis data virtual atau browser lainnya akan melakukannya. Redux dapat diterima.

Abaikan acara yang sewenang-wenang dan batalkan abaikan


Di perusahaan yang sama sering ada beberapa proyek dari jenis yang sama sekaligus: versi mobile dan web, versi dengan pengaturan berbeda untuk grup pengguna yang berbeda, versi lanjutan dan terpotong dari aplikasi yang sama.

Seringkali mereka semua menggunakan basis kode tunggal, jadi kadang-kadang Anda perlu mematikan acara yang tidak perlu di runtime atau selama DI tanpa menghapus langganan dan menyalakannya lagi, yaitu. abaikan beberapa dari mereka, agar tidak memproses acara yang tidak perlu. Ini adalah fitur sederhana namun bermanfaat yang menambah fleksibilitas ke Pub / Sub bus.

Mari kita mulai dengan deskripsi antarmuka:

export interface IWebsocketService { //    addEventListener<T>(topics: string[], id?: number): Observable<T>; runtimeIgnore(topics: string[]): void; runtimeRemoveIgnore(topics: string[]): void; sendMessage(event: string, data: any): void; } export interface WebSocketConfig { //   DI url: string; ignore?: string[]; garbageCollectInterval?: number; options?: Options; } export interface ITopic<T> { //   Pub/Sub [hash: string]: MessageSubject<T>; } export interface IListeners { //    [topic: string]: ITopic<any>; } export interface IBuffer { //    ws.message type: string; data: number[]; } export interface IWsMessage { // ws.message event: string; buffer: IBuffer; } export interface IMessage { //   id: number; text: string; } export type ITopicDataType = IMessage[] | number | string[]; //  callMessage   

Kami akan mewarisi Subjek untuk membuat pemulung:

 export class MessageSubject<T> extends Subject<T> { constructor( private listeners: IListeners, //    private topic: string, //   private id: string // id  ) { super(); } /* *   next, *       , *   garbageCollect */ public next(value?: T): void { if (this.closed) { throw new ObjectUnsubscribedError(); } if (!this.isStopped) { const {observers} = this; const len = observers.length; const copy = observers.slice(); for (let i = 0; i < len; i++) { copy[i].next(value); } if (!len) { this.garbageCollect(); //   } } } /* * garbage collector * */ private garbageCollect(): void { delete this.listeners[this.topic][this.id]; //  Subject if (!Object.keys(this.listeners[this.topic]).length) { //    delete this.listeners[this.topic]; } } } 

Berbeda dengan implementasi sebelumnya, websocket.events.ts akan menjadi bagian dari modul socket web

 export const WS_API = { EVENTS: { MESSAGES: 'messages', COUNTER: 'counter', UPDATE_TEXTS: 'update-texts' }, COMMANDS: { SEND_TEXT: 'set-text', REMOVE_TEXT: 'remove-text' } }; 

Untuk mengonfigurasi saat menghubungkan modul, buat websocket.config:

 import { InjectionToken } from '@angular/core'; export const config: InjectionToken<string> = new InjectionToken('websocket'); 

Buat model untuk Proksi:

 import Dexie from 'dexie'; import { IMessage, IWsMessage } from './websocket.interfaces'; import { WS_API } from './websocket.events'; class MessagesDatabase extends Dexie { //    Dexie  typescript public messages!: Dexie.Table<IMessage, number>; // id is number in this case constructor() { super('MessagesDatabase'); //   this.version(1).stores({ //   messages: '++id,text' }); } } 

Pengurai model sederhana, dalam kondisi nyata lebih baik membaginya menjadi beberapa file:

 export const modelParser = (message: IWsMessage) => { if (message && message.buffer) { /*  */ const encodeUint8Array = String.fromCharCode .apply(String, new Uint8Array(message.buffer.data)); const parseData = JSON.parse(encodeUint8Array); let MessagesDB: MessagesDatabase; // IndexedDB if (message.event === WS_API.EVENTS.MESSAGES) { // IMessage[] if (!MessagesDB) { MessagesDB = new MessagesDatabase(); } parseData.forEach((messageData: IMessage) => { /*   */ MessagesDB.transaction('rw', MessagesDB.messages, async () => { /* ,    */ if ((await MessagesDB.messages .where({id: messageData.id}).count()) === 0) { const id = await MessagesDB.messages .add({id: messageData.id, text: messageData.text}); console.log(`Addded message with id ${id}`); } }).catch(e => { console.error(e.stack || e); }); }); return MessagesDB.messages.toArray(); //   IMessage[] } if (message.event === WS_API.EVENTS.COUNTER) { // counter return new Promise(r => r(parseData)); //    } if (message.event === WS_API.EVENTS.UPDATE_TEXTS) { // text const texts = []; parseData.forEach((textData: string) => { texts.push(textData); }); return new Promise(r => r(texts)); //     } } else { console.log(`[${Date()}] Buffer is "undefined"`); } }; 

WebsocketModule:

 @NgModule({ imports: [ CommonModule ] }) export class WebsocketModule { public static config(wsConfig: WebSocketConfig): ModuleWithProviders { return { ngModule: WebsocketModule, providers: [{provide: config, useValue: wsConfig}] }; } } 

Mari mulai membuat layanan:

 private listeners: IListeners; //   private uniqueId: number; //   id  private websocket: ReconnectingWebSocket; //   constructor(@Inject(config) private wsConfig: WebSocketConfig) { this.uniqueId = -1; this.listeners = {}; this.wsConfig.ignore = wsConfig.ignore ? wsConfig.ignore : []; //  this.connect(); } ngOnDestroy() { this.websocket.close(); //     } 

Metode koneksi:

 private connect(): void { // ReconnectingWebSocket config const options = { connectionTimeout: 1000, //  ,    maxRetries: 10, //  ,    ...this.wsConfig.options }; //  this.websocket = new ReconnectingWebSocket(this.wsConfig.url, [], options); this.websocket.addEventListener('open', (event: Event) => { //   console.log(`[${Date()}] WebSocket connected!`); }); this.websocket.addEventListener('close', (event: CloseEvent) => { //   console.log(`[${Date()}] WebSocket close!`); }); this.websocket.addEventListener('error', (event: ErrorEvent) => { //   console.error(`[${Date()}] WebSocket error!`); }); this.websocket.addEventListener('message', (event: MessageEvent) => { //     this.onMessage(event); }); setInterval(() => { //    this.garbageCollect(); }, (this.wsConfig.garbageCollectInterval || 10000)); } 

Gandakan pengumpul sampah, akan memeriksa langganan dengan batas waktu:

 private garbageCollect(): void { for (const event in this.listeners) { if (this.listeners.hasOwnProperty(event)) { const topic = this.listeners[event]; for (const key in topic) { if (topic.hasOwnProperty(key)) { const subject = topic[key]; //  Subject    if (!subject.observers.length) { delete topic[key]; } } }  ,   if (!Object.keys(topic).length) { delete this.listeners[event]; } } } } 

Kami melihat ke berlangganan mana untuk mengirim acara:

 private onMessage(event: MessageEvent): void { const message = JSON.parse(event.data); for (const name in this.listeners) { if (this.listeners.hasOwnProperty(name) && !this.wsConfig.ignore.includes(name)) { const topic = this.listeners[name]; const keys = name.split('/'); //      const isMessage = keys.includes(message.event); const model = modelParser(message); //     if (isMessage && typeof model !== 'undefined') { model.then((data: ITopicDataType) => { //   Subject this.callMessage<ITopicDataType>(topic, data); }); } } } } 

Acara helm dalam Subjek:

 private callMessage<T>(topic: ITopic<T>, data: T): void { for (const key in topic) { if (topic.hasOwnProperty(key)) { const subject = topic[key]; if (subject) { //   subject.next(data); } else { console.log(`[${Date()}] Topic Subject is "undefined"`); } } } } 

Buat Pub / Subtopik:

 private addTopic<T>(topic: string, id?: number): MessageSubject<T> { const token = (++this.uniqueId).toString(); const key = id ? token + id : token; //  id   const hash = sha256.hex(key); // SHA256-   id  if (!this.listeners[topic]) { this.listeners[topic] = <any>{}; } return this.listeners[topic][hash] = new MessageSubject<T>(this.listeners, topic, hash); } 

Berlangganan satu atau lebih acara:

 public addEventListener<T>(topics: string | string[], id?: number): Observable<T> { if (topics) { //       const topicsKey = typeof topics === 'string' ? topics : topics.join('/'); return this.addTopic<T>(topicsKey, id).asObservable(); } else { console.log(`[${Date()}] Can't add EventListener. Type of event is "undefined".`); } } 

Semuanya di sini sengaja disederhanakan, tetapi dapat dikonversi ke entitas biner, seperti halnya dengan server. Mengirim perintah ke server:

 public sendMessage(event: string, data: any = {}): void { //   ,      if (event && this.websocket.readyState === 1) { this.websocket.send(JSON.stringify({event, data})); } else { console.log('Send error!'); } } 

Tambahkan acara ke pengabaian di runtime:

 public runtimeIgnore(topics: string[]): void { if (topics && topics.length) { //    this.wsConfig.ignore.push(...topics); } } 

Hapus acara dari ignorelist:

 public runtimeRemoveIgnore(topics: string[]): void { if (topics && topics.length) { topics.forEach((topic: string) => { //      const topicIndex = this.wsConfig.ignore.findIndex(t => t === topic); if (topicIndex > -1) { //    this.wsConfig.ignore.splice(topicIndex, 1); } }); } } 

Kami menghubungkan modul soket web:

 @NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule, ReactiveFormsModule, WebsocketModule.config({ url: environment.ws, //  "ws://mywebsocketurl" //    ignore: [WS_API.EVENTS.ANY_1, WS_API.EVENTS.ANY_2], garbageCollectInterval: 60 * 1000, //    options: { connectionTimeout: 1000, //   maxRetries: 10 //   } }) ], providers: [], bootstrap: [AppComponent] }) export class AppModule { } 

Kami menggunakan dalam komponen:

 @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent implements OnInit, OnDestroy { private messages$: Observable<IMessage[]>; private messagesMulti$: Observable<IMessage[]>; private counter$: Observable<number>; private texts$: Observable<string[]>; public form: FormGroup; constructor( private fb: FormBuilder, private wsService: WebsocketService) { } ngOnInit() { this.form = this.fb.group({ text: [null, [ Validators.required ]] }); // get messages this.messages$ = this.wsService .addEventListener<IMessage[]>(WS_API.EVENTS.MESSAGES); // get messages multi this.messagesMulti$ = this.wsService .addEventListener<IMessage[]>([ WS_API.EVENTS.MESSAGES, WS_API.EVENTS.MESSAGES_1 ]); // get counter this.counter$ = this.wsService .addEventListener<number>(WS_API.EVENTS.COUNTER); // get texts this.texts$ = this.wsService .addEventListener<string[]>(WS_API.EVENTS.UPDATE_TEXTS); } ngOnDestroy() { } public sendText(): void { if (this.form.valid) { this.wsService .sendMessage(WS_API.COMMANDS.SEND_TEXT, this.form.value.text); this.form.reset(); } } public removeText(index: number): void { this.wsService.sendMessage(WS_API.COMMANDS.REMOVE_TEXT, index); } } 

Layanan siap digunakan.



Meskipun contoh dari artikel ini bukan solusi universal untuk setiap proyek, ini menunjukkan satu pendekatan untuk bekerja dengan soket web dalam aplikasi besar dan kompleks. Anda dapat membawanya ke layanan dan memodifikasinya tergantung pada tugas saat ini.

Versi lengkap dari layanan ini dapat ditemukan di GitHub .

Untuk semua pertanyaan, Anda dapat menghubungi di komentar, kepada saya di Telegram atau di saluran Angular di tempat yang sama.

Source: https://habr.com/ru/post/id419099/


All Articles