غير متزامن الكتابة في تطبيق الإنترنت الغنية والديكور لمكافحته

منذ ظهور async / await ، نشرت Typescript العديد من المقالات التي تمدّ نهج التطوير هذا ( hackernoon ، blog.bitsrc.io ، habr.com ). نستخدمها منذ البداية من جانب العميل (عندما كانت ES6 Generators تدعم أقل من 50٪ من المتصفحات). والآن أريد أن أشارك تجربتي ، لأن التنفيذ الموازي ليس كل ما سيكون من الجيد معرفته على هذا الطريق.


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


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

قائمة التقنيات الأساسية:


  • تتم كتابة المشروع في المقام الأول في Typescript باستخدام عدة مكتبات Javascript. المكتبة الرئيسية هي ExtJS. يعتبر رد فعل React أقل شأناً ، ولكنه مناسب تمامًا لمنتج مؤسسة بواجهة غنية: العديد من المكونات الجاهزة ، طاولات مصممة جيدًا خارج الصندوق ، نظام بيئي غني من المنتجات ذات الصلة لتبسيط التطوير.
  • خادم متعدد مؤشرات الترابط غير متزامن.
  • يتم استخدام RPC عبر Websocket كوسيلة نقل بين العميل والخادم. يشبه التنفيذ .NET WCF.
    • أي كائن هو خدمة.
    • يمكن أن ينتقل أي كائن من حيث القيمة والمرجع.
  • تشبه واجهة طلب البيانات GraphQL من Facebook ، فقط على Typescript.
  • اتصال ثنائي الاتجاه: يمكن بدء تهيئة تحديث البيانات من العميل ومن الخادم.
  • تتم كتابة التعليمات البرمجية غير المتزامنة بالتتابع من خلال استخدام وظائف async / await الخاصة بـ Typesrcipt.
  • يتم إنشاء واجهة برمجة تطبيقات الخادم في Typescript: إذا تم تغييرها ، فستظهر البنية فورًا في حالة حدوث خطأ.

ما هو الإخراج


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


كيف يتم تنظيم البيانات الواردة من الخادم


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


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

لكن هيكل الاستعلام في شكل رسم بياني يمكن تطبيقه في أي تطبيق تقريبًا ، كما يشير GraphQL ، حسب علمي ، إلى ذلك في مواصفاته.


مثال بنية البيانات:


 //   interface IParent { ServerId: string; Nodes: INodes; // INodes -     INode } //     interface INodes<TNode extends INode> extends ICollection { IndexOf(item: TNode): number; Item(index: number): TNode; // ...     } //    interface INode extends IItem { Guid: string; Name: string; DisplayName: string; Links: ILinks; // ILinks -    Info: INodeInfo; //    -  } //      interface ILink { Guid: string; DisplayName: string; SourceNode: INode; //   -  TargetNode: INode; //   ,   } interface INodeInfo { Component: IComponent; ConfigData: IData; } 

كيف يتلقى العميل البيانات


الأمر بسيط: عندما تطلب خاصية لكائن من نوع غير قياسي ، تقوم RPC بإرجاع Promise :


 let Nodes = Parent.Nodes; // Nodes -> Promise<INodes> 

تزامن دون "رد الاتصال الجحيم".


لتنظيم رمز غير متزامن "متسلسل" ، يتم استخدام وظيفة Typescript async / await :


 async function ShowNodes(parent: IParent): Promise<void> { //    let Nodes = await parent.Nodes; //       await Nodes.forEachParallel(async function(node): Promise<void> { await RenderNode(node); //          }); } 

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


باختصار ، جوهر أولئك الذين ليسوا على دراية بالموضوع:


بمجرد إضافة الكلمة الأساسية غير async إلى الوظيفة ، ستُرجع Promise<_> تلقائيًا Promise<_> . ميزات هذه الوظائف:


  • التعبيرات بداخل وظائف async مع await (والتي ترجع Promise ) ستوقف تنفيذ الوظيفة وتستمر بعد حل Promise المتوقع.
  • في حالة حدوث استثناء في وظيفة async ، سيتم رفض Promise إرجاعه مع هذا الاستثناء.
  • عند التحويل البرمجي في كود Javascript ، سيكون هناك مولدات لمعيار ES6 ( function* بدلاً من async function متزامنة yield بدلاً من await ) أو رمز مخيف مع switch ES5 (جهاز الحالة). await هي الكلمة التي تنتظر نتيجة الوعد. في وقت الاجتماع ، أثناء تنفيذ التعليمات البرمجية ، ShowNodes وظيفة ShowNodes ، وأثناء انتظار البيانات ، قد تنفذ Javascript بعض التعليمات البرمجية الأخرى.

في التعليمة البرمجية أعلاه ، تحتوي المجموعة على طريقة forEachParallel التي تستدعي رد اتصال غير متزامن لكل عقدة على التوازي. في الوقت نفسه ، await حتى await Nodes.forEachParallel جميع عمليات الاسترجاعات. داخل التطبيق - Promise.all :


 /** *            * @param items  * @param callbackfn  * @param [thisArg]   ,      this  callbackfn */ export async function forEachParallel<T>(items: IItemArray<T>, callbackfn: (value: T, index: int, items: IItemArray<T>) => Promise<void | any>, thisArg?: any): Promise<void> { let xCount = items ? await items.Count : 0; if (!xCount) return; let xActions = new Array<Promise<void | any>>(xCount); for (let i = 0; i < xCount; i++) { let xItem = items.Item(i); xActions[i] = ExecuteCallback(xItem, callbackfn, i, items, thisArg); } await Promise.all(xActions); } /**   item   callbackfn */ async function ExecuteCallback<T>(item: Promise<T> | T, callbackfn: (value: T, index: int, items: IItemArray<T>) => Promise<void | any>, index: int, items: IItemArray<T>, thisArg?: any): Promise<void> { let xItem = await item; await callbackfn.call(thisArg, xItem, index, items); } 

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


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


لغة الاستعلام


هناك العديد من الوظائف التي يتم استخدامها "لبناء" طلب البيانات من الخادم. إنهم "يخبرون" الخادم أي عقد من الرسم البياني للبيانات لإرجاعه في الاستجابة:


 /** *       item    Promise  , *      properties */ selectAsync<T extends IItem>(item: T, properties: () => any[]): Promise<T>; /** *   items,       properties */ selectAsyncAll<T extends ICollection>(items: T[], properties: () => any[]): Promise<T[]>; /**    selectAsync     */ select<T>(item: T, properties: () => any[]): T; /**    selectAsync     */ selectAll<T>(items: T[], properties: () => any[]): T[]; 

الآن ، دعونا نلقي نظرة على تطبيق هذه الوظائف لطلب البيانات المضمنة اللازمة بمكالمة واحدة إلى الخادم:


 async function ShowNodes(parentPoint: IParent): Promise<void> { //       IParent -    selectAsync ( // Promise,  ). let Parent = await selectAsync(parentPoint, parent => [ //           selectAll(parent.Nodes, nodes => [node.Name, node.DisplayName]) // [node.Name, node.DisplayName] -        ]); //      Parent.Nodes ... } 

مثال على استعلام أكثر تعقيدًا بقليل من المعلومات المضمنة بعمق:


 //     parent.Nodes  selectAsyncAll,    let Parent = await selectAsyncAll(parent.Nodes, nodes => [ //    : select(node, node => [ node.Name, node.DisplayName, selectAll(node.Links, link => [ link.Guid, link.DisplayName, select(link.TargetNode, targetNode => [targetNode.Guid]) ]), select(node.Info, info => [info.Component]) //    IInfo    IComponent,   ,   ,        ]) ]); 

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


حالة السباق والحلول


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


تخيل مثل هذا الموقف المبسط: IParent كائن IParent على IParent خادم IParent .


 /**   */ Parent.OnSynchronize.AddListener(async function(): Promise<void> { //  .   ,  . }); 

يتم استدعاؤه عند تحديث قائمة العقد INodes على الخادم. بعد ذلك ، في السيناريو التالي ، تكون حالة السباق ممكنة:


  1. نتسبب في الإزالة غير المتزامنة للعقدة من العميل ، في انتظار الانتهاء لحذف كائن العميل
     async function OnClickRemoveNode(node: INode): Promise<void> { let removedOnServer: boolean = await Parent.RemoveNode(node); //     if (removedOnServer) .... } 
  2. من خلال Parent.OnSynchronize ، يحدث تحديث حدث قائمة العقدة.
  3. Parent.OnSynchronize معالجة Parent.OnSynchronize وحذف كائن العميل.
  4. يستمر تنفيذ async OnClickRemoveNode() بعد await الأول ويتم إجراء محاولة لحذف كائن عميل محذوف بالفعل.

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


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

يطرح سؤال آخر: ماذا لو كان هناك الكثير من الأساليب التنافسية المماثلة؟ تخيل أن هناك المزيد:


  • إضافة عقدة
  • تحديث العقدة
  • إضافة / إزالة الروابط
  • طريقة تحويل العقدة المتعددة
  • السلوك المعقد للتطبيق: نقوم بتغيير حالة عقدة واحدة ويبدأ الخادم في تحديث العقد التي تعتمد عليها.

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


نوجز المتطلبات والميزات الرئيسية لمثل هذه الديكورات:


  1. في الداخل ، يجب تنفيذ قائمة انتظار المكالمات إلى وظائف غير متزامنة. اعتمادًا على نوع الديكور ، قد يتم استدعاء أو رفض مكالمة الوظائف إذا كانت هناك مكالمات أخرى فيها.
  2. ستتطلب الدالات المحددة سياق تنفيذ لربط قائمة الانتظار. يجب عليك إما إنشاء قائمة انتظار بشكل صريح ، أو القيام بذلك تلقائيًا بناءً على طريقة العرض التي تنتمي إليها وحدة التحكم.
  3. المعلومات مطلوبة عن إتلاف مثيل وحدة التحكم (على سبيل المثال ، الخاصية IsDestroyed ). لمنع الديكور من إجراء مكالمات في قائمة الانتظار بعد إتلاف وحدة التحكم.
  4. بالنسبة لوحدة التحكم في العرض ، نضيف وظيفة تطبيق قناع شفاف لاستبعاد الإجراءات في وقت تنفيذ قائمة الانتظار وتشير بصريًا إلى أن العملية قيد التقدم.
  5. يجب أن تنتهي جميع Promise.done() باستدعاء Promise.done() . في هذه الطريقة ، تحتاج إلى تطبيق معالج الاستثناءات غير المعالجة. شيء مفيد للغاية:
    • لا يتم التعرف على الاستثناءات التي حدثت في Promise بواسطة معالج الأخطاء القياسي (والذي ، على سبيل المثال ، يعرض نافذة مع نص وتتبع stak) ، لذلك قد لا تلاحظها (إذا كنت لا تراقب وحدة التحكم طوال الوقت أثناء التطوير). ولن يراها المستخدم على الإطلاق - وهذا سيجعل الدعم صعبًا. ملاحظة: من الممكن الاشتراك للتعامل مع unhandledrejection ، ولكن لا يزال يدعمه Chrome و Edge فقط:

       window.addEventListener('unhandledrejection', function(event) { // handling... }); 
    • نظرًا لأننا نحتفل بأعلى وظيفة معالج حدث غير async كديكور ، فإننا نحصل على خطأ تتبع المكدس بالكامل.

الآن نقدم قائمة تقريبية لمثل هذه الديكورات مع وصف ثم نوضح كيف يمكن تطبيقها.


 /** * : * 1.      * 2.      ,   . * *  ,  :   ,         */ @Lock /** * : *     ,     . * *  ,     :   ,   . */ @LockQueue /** *  LockQueue .  -         * *   ,       . ,   . */ @LockBetween /** * : *       ,   . *     . :     ,     300 .       . */ @LockDeferred(300) // ,    ,     : interface ILockTarget { /** * ,   View,   .   ,        ,     ,        */ GetControllerView?(): IView; /**  true     */ IsDestroyed: boolean; } 

الأوصاف مجردة تمامًا ، ولكن بمجرد رؤية مثال للاستخدام مع التفسيرات ، يصبح كل شيء أكثر وضوحًا:


 class GraphController implements ILockTarget { /** ,      .      */ private View: IView; public GetControllerView(): IView { return this.View; } /**      . */ @Lock private async OnClickRemoveNode(): Promise<void> { ... } /**     . */ @Lock private async OnClickRemoveLink(): Promise<void> { ... } /**     */ @Lock private async OnClickAddNewNode(): Promise<void> { ... } /**    " " */ @LockQueue private async OnServerUpdateNode(): Promise<void> { ... } /**    " " */ @LockQueue private async OnServerAddLink(): Promise<void> { ... } /**    " " */ @LockQueue private async OnServerAddNode(): Promise<void> { ... } /**    -   */ @LockQueue private async OnServerRemoveNode(): Promise<void> { ... } /**    -       */ @LockBetween private async OnServerSynchronize(): Promise<void> { ... } /**    -    (/warning/error/...) */ @LockQueue private async OnServerUpdateNodeStatus(): Promise<void> { ... } /**       */ @LockDeferred(300) private async OnSearchFieldChange(): Promise<void> { ... } } 

الآن سنقوم بتحليل اثنين من السيناريوهات النموذجية للأخطاء المحتملة والقضاء عليها من قبل الديكور:


  1. يبدأ المستخدم إجراءً: OnClickRemoveNode و OnClickRemoveLink . من أجل المعالجة المناسبة ، من الضروري عدم وجود معالجات تنفيذ أخرى في قائمة الانتظار (إما عميل أو خادم). خلاف ذلك ، على سبيل المثال ، مثل هذا الخطأ ممكن:
    • لا يزال يتم تحديث النموذج على العميل إلى حالة الخادم الحالية
    • نبدأ في حذف الكائن قبل اكتمال التحديث (هناك معالج OnServerSynchronize قيد OnServerSynchronize في قائمة الانتظار). ولكن هذا الكائن لم يعد موجودًا بالفعل - لم تكتمل المزامنة الكاملة بعد ولا يزال معروضًا على العميل.
      لذلك ، يجب أن ترفض أداة تزيين Lock جميع الإجراءات التي بدأها المستخدم إذا كان هناك معالجات أخرى في قائمة الانتظار مع نفس سياق قائمة الانتظار. بالنظر إلى أن الخادم غير متزامن ، فهذا مهم بشكل خاص. نعم ، يرسل Websocket الطلبات بشكل متسلسل ، ولكن إذا كسر العميل التسلسل ، فسوف نحصل على خطأ على الخادم.
  2. نبدأ في إضافة عقدة: OnClickAddNewNode . أحداث OnServerAddNode ، تأتي OnServerAddNode من الخادم.
    • تولى OnClickAddNewNode قائمة الانتظار (إذا كان هناك شيء ما ، فإن أداة Lock هذه الطريقة ترفض الدعوة)
    • OnServerAddNode ، OnServerAddNode ، OnServerAddNode تنفيذه بالتسلسل بعد OnClickAddNewNode ، وليس التنافس معه.
  3. تحتوي قائمة الانتظار على OnServerUpdateNode . افترض أنه أثناء تنفيذ الأول ، يقوم المستخدم بإغلاق GraphController . ثم يجب عدم إجراء المكالمة الثانية إلى OnServerUpdateNode تلقائيًا حتى لا تتخذ إجراء على وحدة التحكم المدمرة ، والتي يضمن أن تؤدي إلى حدوث خطأ. لهذا ، ILockTarget واجهة ILockTarget على IsDestroyed - يتحقق IsDestroyed من العلم دون تنفيذ المعالج التالي من قائمة الانتظار.
    الربح: لا حاجة للكتابة if (!this.IsDestroyed()) بعد كل await .
  4. تبدأ التغييرات في العقد المتعددة. أحداث OnServerUpdateNode ، تأتي OnServerUpdateNode من الخادم. سيؤدي تنفيذها التنافسي إلى أخطاء غير قابلة للإنتاج. لكن منذ ذلك الحين LockQueue تم تمييزها بواسطة LockQueue LockBetween و LockBetween ، فسيتم تنفيذها بالتتابع.
  5. تخيل أن العقد قد تحتوي على رسومات بيانية للعقدة متداخلة بداخلها. GraphController #1 , — GraphController #2 . , GraphController - , ( — ), .. . :
    • GraphController #2 , , .
  6. OnSearchFieldChange , . - . @LockDeferred(300) 300 : , , 300 . , . :
    • , 500 , . — OnSearchFieldChange , .
    • OnSearchFieldChange — , .


  1. Deadlock: Handler1 , , await Handler2 , LockQueue , Handler2Handler1 .
  2. , View . : , — .


, , . :


  • - <Class> . <Method> => <Time> ( ).
  • .
  • .


, , , . ? ? :


 class GraphController implements ILockTarget { private View: IView; public GetControllerView(): IView { return this.View; } /**     . */ @Lock private async RunBigDataCalculations(): Promise<void> { await Start(); await UpdateSmth(); await End(); await CleanUp(); } /**   . */ @LockQueue private async OnChangeNodeState(node: INode): Promise<void> { await GetNodeData(node); await UpdateNode(node); } } 

:


  1. RunBigDataCalculations .
  2. await Start();
  3. / ( )
  4. await Start(); , await UpdateSmth(); .

:


  1. RunBigDataCalculations .
  2. OnChangeNodeState , (.. ).
  3. await GetNodeData(node);
  4. / ( )
  5. await GetNodeData(node); , await UpdateNode(node); .

- . :


  • :

 /** *       ,      */ export interface IQueuedDisposableLockTarget extends ILockTarget { /**     . Lock          IsDisposing() === true */ IsDisposing(): boolean; SetDisposing(): void; } 

  • :

 function QueuedDispose(controller: IQueuedDisposableLockTarget): void { //      let xQueue = GetQueue(controller); // 1. ,     -,   -   if (xQueue.Empty) { controller.Dispose(); return; } // 2.  ,     " ",     ,   . controller.SetDisposing(); // 3.   finally   xQueue.finally(() => { debug.assert(!IsDisposed(controller), "-      ,  "); controller.Dispose(); }); } 

, . QueuedDispose :


  • . .
  • QueuedDispose controller . — ExtJS .


, , .. . , ? , .


, :


vk.com
Telegram

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


All Articles