WebSockets الزاوي. الجزء 2. حلول المنتج

الصورة

في مقال سابق ، تحدثنا عن حل عام لمقابس الويب في Angular ، حيث قمنا ببناء حافلة مع إعادة الاتصال والخدمة للاستخدام في المكونات القائمة على WebSocketSubject. هذا التنفيذ مناسب لمعظم الحالات البسيطة ، على سبيل المثال ، تلقي وإرسال رسائل الدردشة ، وما إلى ذلك ، ولكن قد لا تكون قدراته كافية حيث تحتاج إلى بناء شيء أكثر مرونة وتحكمًا. في هذه المقالة ، سأكشف عن بعض الميزات عند العمل مع مآخذ الويب وأتحدث عن المتطلبات التي واجهتها بنفسك ، وربما ستواجهها.

في كثير من الأحيان ، في المشاريع الكبيرة ذات الحضور المرتفع ، تواجه الواجهة الأمامية مهام تكون ، في ظروف أخرى ، أكثر شيوعًا لمشاهدتها في النهاية الخلفية. في ظروف التقشف في موارد الخادم ، يتم ترحيل جزء من المشاكل إلى منطقة الواجهة الأمامية ، ولهذا السبب يتم وضع أقصى قدر من التوسعة والتحكم في المشروع.

فيما يلي قائمة بالمتطلبات الأساسية لعميل مأخذ الويب التي سيتم تناولها في هذه المقالة:

  • إعادة الاتصال "الذكي" التلقائي ؛
  • وضع التصحيح
  • نظام اشتراك الأحداث على أساس RxJs ؛
  • استقبال وتحليل البيانات الثنائية ؛
  • إسقاط (رسم خرائط) المعلومات المستلمة على النموذج ؛
  • التحكم في تغييرات النموذج عند وصول الأحداث الجديدة ؛
  • تجاهل الأحداث التعسفية وإلغاء تجاهل.

ضع في اعتبارك كل عنصر بمزيد من التفصيل.

إعادة الاتصال / Debag


لقد كتبت عن إعادة الاتصال في مقال سابق ، لذلك سأقتبس فقط جزءًا من النص:

يعد إعادة الاتصال ، أو مؤسسة إعادة الاتصال بالخادم ، عاملاً بالغ الأهمية عند العمل مع مآخذ الويب ، مثل يمكن أن تتسبب فواصل الشبكة أو تعطل الخادم أو الأخطاء الأخرى التي تتسبب في قطع الاتصال في تعطل التطبيق.
من المهم أن نلاحظ أن محاولات إعادة الاتصال لا يجب أن تكون متكررة للغاية ولا يجب أن تستمر إلى ما لا نهاية ، كما هو يمكن لهذا السلوك تعليق العميل.

لا يعرف مقبس الويب نفسه كيفية إعادة الاتصال عند قطع الاتصال. لذلك ، إذا تم إعادة تشغيل الخادم أو تعطل الخادم ، أو قام المستخدم بإعادة الاتصال بالإنترنت ، ثم لمتابعة العمل ، فستحتاج أيضًا إلى إعادة توصيل مقبس الويب.

في هذه المقالة ، لإعادة الاتصال والتصحيح ، سنستخدم Reconnecting WebSocket ، الذي يحتوي على الوظائف الضرورية والخيارات الأخرى ، مثل تغيير عنوان url الخاص بمقبس الويب بين عمليات إعادة الاتصال ، واختيار مُنشئ WebSocket التعسفي ، إلخ. البدائل الأخرى مناسبة أيضًا. إعادة الاتصال من المقالة السابقة ليست مناسبة ، لأن هو مكتوب تحت WebSocketSubject ، والذي لا ينطبق هذه المرة.

نظام اشتراك الأحداث RxJs


لاستخدام مآخذ الويب في المكونات ، تحتاج إلى الاشتراك في الأحداث وإلغاء الاشتراك منها عند الضرورة. للقيام بذلك ، استخدم نمط تصميم Pub / Sub الشهير.
"Publisher-subscriber (eng. ، Publisher-subscriberber أو eng. Pub / sub) - نموذج تصميم سلوكي لنقل الرسائل لا يرتبط فيه مرسلو الرسائل ، الذين يطلق عليهم الناشرون (eng. الناشرون) ، مباشرة برمز البرنامج لإرسال الرسائل إلى المشتركين (eng. المشتركين ) بدلاً من ذلك ، يتم تقسيم الرسائل إلى فئات ولا تحتوي على معلومات حول المشتركين فيها ، إن وجدت. وبالمثل ، يتعامل المشتركون مع فئة واحدة أو أكثر من الرسائل ، مستخلصة من ناشرين محددين ".

لا يتصل المشترك بالناشر مباشرة ، ولكن عبر الحافلة الوسيطة - خدمة الموقع. يجب أن يكون من الممكن أيضًا الاشتراك في أحداث متعددة بنفس نوع الإرجاع. ينشئ كل اشتراك موضوعه الخاص ، والذي تتم إضافته إلى كائن المستمعين ، والذي يسمح لك بتوجيه أحداث مأخذ الويب إلى الاشتراكات الضرورية. عند العمل مع موضوع RxJs ، هناك بعض الصعوبات في إلغاء الاشتراك ، لذلك ، سنقوم بإنشاء جامع قمامة بسيط يقوم بإزالة الكائنات الفرعية من كائن المستمعين عندما لا يكون لديهم مراقبون.

استقبال وتحليل البيانات الثنائية


يدعم WebSocket نقل البيانات الثنائية أو الملفات أو التدفقات ، والتي غالبًا ما تستخدم في المشاريع الكبيرة. يبدو شيء مثل هذا:
0x80 ، <طول - بايت واحد أو أكثر> ، <نص الرسالة>

من أجل عدم إنشاء قيود على طول الرسالة المرسلة وفي نفس الوقت عدم إنفاق وحدات البايت بشكل غير عقلاني ، استخدم مطورو البروتوكول الخوارزمية التالية. يتم النظر إلى كل بايت في مؤشر الطول بشكل منفصل: يشير الأعلى إلى ما إذا كان البايت الأخير (0) أو الآخر (1) يتبعه ، وتحتوي البتات السفلية السبع على البيانات المرسلة. لذلك ، عندما تظهر علامة إطار البيانات الثنائية 0x80 ، يتم أخذ البايت التالي وتخزينه في "بنك أصبع" منفصل. ثم يتم تحويل البايت التالي ، إذا كان يحتوي على مجموعة البت الأكثر أهمية ، إلى "البنك الخنزير" وهكذا حتى يتم اكتشاف البايت الذي يحتوي على صفر من البت الأكثر أهمية. هذه البايت هي الأخيرة في مؤشر الطول وتتم إضافتها أيضًا إلى "بنك أصبع". الآن ، تتم إزالة البتات العالية من البايتات في الخنزير ، ويتم دمج الباقي. سيكون هذا هو طول نص الرسالة - أرقام 7 بت بدون البت الأكثر أهمية.

آلية تحليل الواجهة الأمامية والتدفق الثنائي معقدة وترتبط بتخطيط البيانات في النموذج. يمكن تخصيص مقال منفصل لهذا الغرض. هذه المرة سنقوم بتحليل خيار بسيط ، ونترك الحالات الصعبة للمنشورات التالية ، إذا كان هناك اهتمام بالموضوع.

إسقاط (رسم خرائط) المعلومات المستلمة على النموذج


بغض النظر عن نوع الإرسال المستلم ، يجب عليك قراءته وتعديله بأمان. لا يوجد توافق في الآراء حول كيفية القيام بذلك بشكل أفضل ، وأنا ألتزم بنظرية نموذج البيانات ، لأنني أعتبرها منطقية وموثوقة للبرمجة بأسلوب OOP.
"إن نموذج البيانات عبارة عن تعريف منطقي ومكتفي ذاتيًا للأشياء والعوامل والعناصر الأخرى التي تشكل معًا آلة وصول إلى البيانات المجردة التي يتفاعل معها المستخدم. "تسمح لك هذه الكائنات بنمذجة بنية البيانات والعوامل - سلوك البيانات."

جميع أنواع الإطارات الشائعة التي لا تعطي فكرة عن كائن كطبقة يتم فيها تعريف السلوك ، والبنية ، وما إلى ذلك ، تخلق ارتباكًا ، وتكون أقل تحكمًا بشكل جيد وأحيانًا متضخمة مع شيء غير نموذجي بالنسبة لها. على سبيل المثال ، يجب أن يصف فصل الكلب كلبًا تحت أي ظرف من الظروف. إذا تم النظر إلى الكلب على أنه مجموعة من الحقول: الذيل واللون والوجه وما إلى ذلك ، فقد ينمو الكلب مخلبًا إضافيًا ، وسيظهر كلب آخر بدلاً من الرأس.

الصورة

التحكم في تغييرات النموذج عند وصول الأحداث الجديدة


في هذه الفقرة سوف أصف المشكلة التي واجهتها أثناء العمل على واجهة الويب الخاصة بتطبيق الجوال الرهان الرياضي. عملت واجهة برمجة التطبيقات للتطبيق من خلال مآخذ الويب التي تلقوا من خلالها: تحديث الاحتمالات ، وإضافة وإزالة أنواع جديدة من الرهانات ، وإخطارات حول بداية أو نهاية المباراة ، وما إلى ذلك. - إجمالي حوالي ثلاثمائة حدث لمقبس الويب. أثناء المباراة ، يتم تحديث الرهانات والمعلومات باستمرار ، أحيانًا 2-3 مرات في الثانية ، لذلك كانت المشكلة هي أنه بعدها تم تحديث الواجهة بدون تحكم وسيط.

عندما قام المستخدم بمراقبة العطاء من جهاز محمول ، وفي نفس الوقت تم تحديث القوائم على شاشة العرض الخاصة به ، اختفى العطاء من مجال الرؤية ، لذلك كان على المستخدم البحث عن العطاء المتتبع مرة أخرى. تم تكرار هذا السلوك لكل تحديث.

الصورة

يتطلب الحل عدم ثبات الكائنات التي تم عرضها على الشاشة ، ولكن في نفس الوقت يجب أن تتغير معاملات الرهان ، وأن تصبح العطاءات غير ذات الصلة غير نشطة ، ولا تتم إضافة أخرى جديدة حتى يقوم المستخدم بتمرير الشاشة. لم يتم تخزين الخيارات القديمة على الواجهة الخلفية ، لذلك كان لا بد من تذكر هذه الخطوط وتمييزها بعلامة "محذوفة" ، والتي تم إنشاء مخزن بيانات وسيط لها بين مقبس الويب والاشتراك ، مما يضمن التحكم في التغييرات.

في الخدمة الجديدة ، سننشئ أيضًا طبقة بديلة وهذه المرة سوف نستخدم Dexie.js - وهي مجمعة فوق IndexedDB API ، ولكن أي قاعدة بيانات افتراضية أو متصفح أخرى ستفعل. Redux مقبول.

تجاهل الأحداث التعسفية وإلغاء تجاهل


في نفس الشركة غالبًا ما يكون هناك العديد من المشاريع من نفس النوع في وقت واحد: إصدارات الجوال والويب ، إصدارات ذات إعدادات مختلفة لمجموعات المستخدمين المختلفة ، إصدارات متقدمة ومبتورة من نفس التطبيق.

غالبًا ما يستخدمون جميعًا قاعدة رمز واحدة ، لذلك تحتاج في بعض الأحيان إلى إيقاف تشغيل الأحداث غير الضرورية في وقت التشغيل أو أثناء DI دون حذف الاشتراك وتشغيله مرة أخرى ، أي تجاهل بعضها ، حتى لا يتم معالجة الأحداث غير الضرورية. هذه ميزة بسيطة ولكنها مفيدة تضيف مرونة إلى ناقل Pub / Sub.

لنبدأ بوصف الواجهات:

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   

سوف نرث الموضوع لإنشاء جامع القمامة:

 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]; } } } 

على عكس التنفيذ السابق ، سيكون websocket.events.ts جزءًا من وحدة مقبس الويب

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

للتهيئة عند توصيل الوحدة ، قم بإنشاء websocket.config:

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

إنشاء نموذج للوكيل:

 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' }); } } 

محلل نموذج بسيط ، في الظروف الحقيقية من الأفضل تقسيمه إلى عدة ملفات:

 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"`); } }; 

الوحدة النمطية Websocket:

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

لنبدأ في إنشاء خدمة:

 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(); //     } 

طريقة الاتصال:

 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)); } 

قم بتكرار جامع القمامة ، وسوف يتحقق من الاشتراكات حسب المهلة:

 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]; } } } } 

نحن نبحث في أي اشتراك لإرسال الحدث:

 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); }); } } } } 

حدث خوذة في الموضوع:

 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"`); } } } } 

إنشاء موضوع Pub / Sub:

 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); } 

الاشتراك في حدث أو أكثر:

 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".`); } } 

كل شيء هنا مبسط عن قصد ، ولكن يمكن تحويله إلى كيانات ثنائية ، كما هو الحال مع الخادم. إرسال الأوامر إلى الخادم:

 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!'); } } 

أضف الأحداث إلى قائمة التجاهل في وقت التشغيل:

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

حذف الأحداث من قائمة الجاهلين:

 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); } }); } } 

نقوم بتوصيل وحدة مقابس الويب:

 @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 { } 

نستخدم في المكونات:

 @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); } } 

الخدمة جاهزة للاستخدام.



على الرغم من أن المثال من المقالة ليس حلاً شاملاً لكل مشروع ، إلا أنه يوضح أحد الأساليب للعمل مع مآخذ الويب في التطبيقات الكبيرة والمعقدة. يمكنك أخذها في الخدمة وتعديلها حسب المهام الحالية.

يمكن العثور على النسخة الكاملة من الخدمة على GitHub .

لجميع الأسئلة يمكنك الاتصال في التعليقات لي على Telegram أو على قناة Angular هناك.

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


All Articles