Asinkron Naskah dalam Aplikasi Internet Kaya dan Dekorator untuk Memeranginya

Sejak munculnya async / await , Typescript telah menerbitkan banyak artikel yang memuji pendekatan pengembangan ini ( hackernoon , blog.bitsrc.io , habr.com ). Kami menggunakannya sejak awal di sisi klien (ketika ES6 Generator mendukung kurang dari 50% browser). Dan sekarang saya ingin berbagi pengalaman saya, karena eksekusi paralel tidak semua yang baik untuk diketahui di sepanjang jalan ini.


Saya tidak begitu suka artikel terakhir: sesuatu mungkin tidak bisa dimengerti. Sebagian karena fakta bahwa saya tidak dapat memberikan kode kepemilikan - hanya untuk menguraikan pendekatan umum. Oleh karena itu:


  • jangan ragu untuk menutup tab tanpa membaca
  • jika Anda mengelolanya, mintalah detail yang tidak jelas
  • Saya dengan senang hati akan menerima saran dan kritik dari yang paling gigih dan sepenuhnya ditemukan sampai saat ini.

Daftar teknologi inti:


  • Proyek ini ditulis terutama dalam naskah menggunakan beberapa perpustakaan Javascript. Perpustakaan utama adalah ExtJS. Ini lebih rendah dalam hal fashionable untuk Bereaksi, tetapi paling cocok untuk produk perusahaan dengan antarmuka yang kaya: banyak komponen yang sudah jadi, tabel yang dirancang dengan baik di luar kotak, ekosistem kaya produk terkait untuk menyederhanakan pengembangan.
  • Server multithreaded asinkron.
  • RPC melalui Websocket digunakan sebagai transportasi antara klien dan server. Implementasinya mirip dengan .NET WCF.
    • Objek apa pun adalah layanan.
    • Objek apa pun dapat ditransmisikan oleh nilai dan referensi.
  • Antarmuka permintaan data menyerupai GraphQL dari Facebook, hanya pada Filescript.
  • Komunikasi dua arah: inisialisasi pembaruan data dapat diluncurkan baik dari klien maupun dari server.
  • Kode asinkron ditulis secara berurutan melalui penggunaan fungsi async / await Typercipt.
  • API server dihasilkan dalam Script: jika diubah, build akan segera menampilkannya jika terjadi kesalahan.

Apa outputnya


Saya akan memberi tahu Anda bagaimana kami bekerja dengan ini dan apa yang kami lakukan untuk pelaksanaan kode asinkron yang aman dan tidak bersaing: dekorator Typesrcipt kami yang menerapkan fungsi antrian. Dari dasar-dasar ke solusi kondisi ras dan kesulitan lain yang muncul selama proses pengembangan.


Bagaimana data yang diterima dari server disusun


Server mengembalikan objek induk yang berisi data (objek lain, koleksi objek, baris, dll.) Dalam propertinya dalam bentuk grafik. Hal ini disebabkan, antara lain, ke aplikasi itu sendiri:


  • itu membuat analisis data / ML grafik diarahkan dari node handler.
  • setiap node pada gilirannya dapat berisi grafik yang disematkan sendiri
  • grafik memiliki dependensi: node dapat "diwarisi", dan node baru dibuat oleh "kelas" mereka.

Tetapi struktur permintaan dalam bentuk grafik dapat diterapkan di hampir semua aplikasi, dan GraphQL, sejauh yang saya tahu, juga menyebutkan ini dalam spesifikasinya.


Contoh struktur data:


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

Bagaimana cara klien menerima data


Sederhana: ketika Anda meminta properti dari objek jenis non-skalar, RPC mengembalikan Promise :


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

Sinkronisasi tanpa "Panggilan Balik Neraka".


Untuk mengatur kode asinkron "sekuensial", digunakan fungsi Script 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); //          }); } 

Tidak masuk akal untuk memikirkannya secara rinci, pada hub sudah ada materi yang cukup rinci . Mereka muncul di belakang pada 2016. Kami telah menggunakan pendekatan ini sejak muncul di cabang fitur dari repositori Typecript, jadi kami telah mendapatkan benjolan untuk waktu yang lama dan sekarang kami bekerja dengan senang hati. Untuk beberapa waktu sekarang, dan dalam produksi.


Secara singkat, esensi bagi mereka yang tidak terbiasa dengan subjek:


Segera setelah Anda menambahkan kata kunci async ke fungsi, itu akan secara otomatis mengembalikan Promise<_> . Fitur fungsi tersebut:


  • Ekspresi di dalam fungsi async dengan await (yang mengembalikan Promise ) akan menghentikan pelaksanaan fungsi dan melanjutkan setelah menyelesaikan Promise diharapkan.
  • Jika pengecualian terjadi dalam fungsi async , Promise dikembalikan akan ditolak dengan pengecualian ini.
  • Saat mengkompilasi dalam kode Javascript, akan ada generator untuk standar ES6 ( function* alih-alih async function dan yield alih-alih await ) atau kode menakutkan dengan switch untuk ES5 (mesin negara). await adalah kata kunci yang menunggu hasil dari janji. Pada saat rapat, selama eksekusi kode, fungsi ShowNodes berhenti, dan sambil menunggu data, Javascript dapat menjalankan beberapa kode lainnya.

Dalam kode di atas, koleksi memiliki metode forEachParallel yang memanggil callback asinkron untuk setiap node secara paralel. Pada saat yang sama, await sebelum Nodes.forEachParallel akan menunggu semua panggilan balik. Di dalam implementasinya - 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); } 

Ini adalah gula sintaksis: metode seperti itu harus digunakan tidak hanya untuk koleksi mereka, tetapi juga untuk array Javascript standar.


Fungsi ShowNodes terlihat sangat tidak optimal: ketika kami meminta entitas lain, kami menunggu setiap kali. Kemudahannya adalah kode tersebut dapat ditulis dengan cepat, sehingga pendekatan ini baik untuk pembuatan prototipe cepat. Di versi final, Anda perlu menggunakan bahasa permintaan untuk mengurangi jumlah panggilan ke server.


Bahasa Pertanyaan


Ada beberapa fungsi yang digunakan untuk "membangun" permintaan data dari server. Mereka β€œmemberi tahu” server mana simpul dari grafik data yang akan dikembalikan dalam respons:


 /** *       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[]; 

Sekarang mari kita lihat aplikasi fungsi-fungsi ini untuk meminta data tertanam yang diperlukan dengan satu panggilan ke server:


 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 ... } 

Contoh permintaan yang sedikit lebih kompleks dengan informasi yang tertanam mendalam:


 //     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,   ,   ,        ]) ]); 

Bahasa permintaan membantu menghindari permintaan yang tidak perlu ke server. Tetapi kodenya tidak pernah sempurna, dan pasti akan berisi beberapa permintaan kompetitif dan, sebagai hasilnya, kondisi balapan.


Kondisi dan Solusi Ras


Karena kami berlangganan acara server dan menulis kode dengan sejumlah besar permintaan asinkron, kondisi balapan dapat terjadi ketika fungsi FuncOne async terganggu, menunggu Promise . Pada saat ini, peristiwa server (atau dari aksi pengguna berikutnya) dapat datang dan, setelah dijalankan secara kompetitif, ubah model pada klien. Kemudian FuncOne setelah menyelesaikan janji, dapat mengubah, misalnya, ke sumber daya yang sudah dihapus.


Bayangkan situasi yang disederhanakan: objek IParent memiliki delegasi server IParent .


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

Disebut ketika daftar node INodes di server diperbarui. Kemudian, dalam skenario berikut, kondisi balapan dimungkinkan:


  1. Kami menyebabkan penghapusan simpul yang tidak sinkron dari klien, menunggu penyelesaian untuk menghapus objek klien
     async function OnClickRemoveNode(node: INode): Promise<void> { let removedOnServer: boolean = await Parent.RemoveNode(node); //     if (removedOnServer) .... } 
  2. Melalui Parent.OnSynchronize , pembaruan acara daftar simpul terjadi.
  3. Parent.OnSynchronize diproses dan menghapus objek klien.
  4. async OnClickRemoveNode() terus dijalankan setelah await pertama dan upaya dilakukan untuk menghapus objek klien yang sudah dihapus.

Anda bisa melakukan pengecekan tentang keberadaan objek klien di OnClickRemoveNode . Ini adalah contoh yang disederhanakan dan di dalamnya pemeriksaan serupa adalah normal. Tetapi bagaimana jika rantai panggilan lebih rumit? Oleh karena itu, menggunakan pendekatan yang sama setelah setiap await adalah praktik yang buruk:


  • Kode yang kembung sangat rumit untuk didukung dan diperluas.
  • Kode tidak berfungsi sebagaimana dimaksud: penghapusan di OnClickRemoveNode , dan penghapusan aktual objek klien terjadi di tempat lain. Seharusnya tidak ada pelanggaran terhadap urutan yang ditentukan oleh pengembang, jika tidak akan ada kesalahan regresi.
  • Ini tidak cukup andal: jika Anda lupa melakukan pemeriksaan di suatu tempat, maka akan ada kesalahan. Bahayanya adalah, pertama-tama, bahwa pemeriksaan yang dilupakan mungkin tidak menyebabkan kesalahan secara lokal dan dalam lingkungan pengujian, dan bagi pengguna dengan penundaan jaringan yang lebih lama akan terjadi.
  • Dan jika pengontrol yang dimiliki penangan ini dapat dihancurkan? Setelah masing-masing await untuk memeriksa kehancurannya?

Pertanyaan lain muncul: bagaimana jika ada banyak metode kompetitif serupa? Bayangkan ada lebih banyak:


  • Menambahkan Node
  • Pembaruan node
  • Tambah / Hapus Tautan
  • Metode Konversi Beberapa Node
  • Perilaku aplikasi yang kompleks: kami mengubah status satu simpul dan server mulai memperbarui simpul yang bergantung padanya.

Diperlukan implementasi arsitektur, yang pada prinsipnya menghilangkan kemungkinan kesalahan karena kondisi ras, aksi pengguna paralel, dll. Solusi yang tepat untuk menghilangkan perubahan simultan model dari klien atau server adalah dengan mengimplementasikan bagian penting dengan antrian panggilan. Dekorator pengetik naskah akan berguna di sini untuk menandai fungsi pengontrol asinkron yang kompetitif secara deklaratif.


Kami menguraikan persyaratan dan fitur utama dari dekorator tersebut:


  1. Di dalam, antrian panggilan ke fungsi asinkron harus diterapkan. Bergantung pada jenis dekorator, panggilan fungsi dapat antri atau ditolak jika ada panggilan lain di dalamnya.
  2. Fungsi yang ditandai akan memerlukan konteks eksekusi untuk mengikat ke antrian. Anda harus membuat antrian secara eksplisit, atau melakukannya secara otomatis berdasarkan Tampilan yang dimiliki pengontrol.
  3. Informasi diperlukan tentang penghancuran instance controller (misalnya, properti IsDestroyed ). Untuk mencegah dekorator membuat panggilan antrian setelah pengontrol dihancurkan.
  4. Untuk pengontrol tampilan, kami menambahkan fungsionalitas menerapkan masker transparan untuk mengecualikan tindakan pada saat antrian dieksekusi dan secara visual menunjukkan pemrosesan sedang berlangsung.
  5. Semua dekorator harus diakhiri dengan panggilan ke Promise.done() . Dalam metode ini, Anda perlu menerapkan handler pengecualian yang tidak ditangani. Suatu hal yang sangat berguna:
    • pengecualian yang terjadi dalam Promise tidak tertangkap oleh penangan kesalahan standar (yang, misalnya, menampilkan jendela dengan teks dan jejak taruhan), jadi Anda mungkin tidak melihatnya (jika Anda tidak memantau konsol sepanjang waktu selama pengembangan). Dan pengguna tidak akan melihat mereka sama sekali - ini akan membuat dukungan sulit. Catatan: dimungkinkan untuk berlangganan untuk menangani acara unhandledrejection yang unhandledrejection ditangani, tetapi masih hanya Chrome dan Edge yang mendukungnya:

       window.addEventListener('unhandledrejection', function(event) { // handling... }); 
    • karena kami menandai fungsi pengendali event async tertinggi sebagai dekorator, kami mendapatkan seluruh kesalahan jejak tumpukan.

Sekarang kami memberikan daftar perkiraan dekorator tersebut dengan deskripsi dan kemudian menunjukkan bagaimana mereka dapat diterapkan.


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

Uraiannya cukup abstrak, tetapi begitu Anda melihat contoh penggunaan dengan penjelasan, semuanya akan menjadi lebih jelas:


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

Sekarang kita akan menganalisis beberapa skenario tipikal dari kemungkinan kesalahan dan penghapusannya oleh dekorator:


  1. Pengguna memulai tindakan: OnClickRemoveNode , OnClickRemoveLink . Untuk pemrosesan yang tepat, perlu bahwa tidak ada penangan pelaksana lainnya dalam antrian (baik klien atau server). Kalau tidak, misalnya, kesalahan seperti itu dimungkinkan:
    • Model pada klien masih diperbarui ke kondisi server saat ini
    • Kami memulai penghapusan objek sebelum pembaruan selesai (ada penangan OnServerSynchronize dalam antrian). Tetapi objek ini sebenarnya sudah tidak ada lagi - hanya sinkronisasi penuh belum selesai dan masih ditampilkan pada klien.
      Oleh karena itu, semua tindakan yang dilakukan oleh pengguna, Penghias Lock harus menolak jika ada penangan lain dalam antrian dengan konteks antrian yang sama. Mengingat bahwa server tidak sinkron, ini sangat penting. Ya, Websocket mengirimkan permintaan secara berurutan, tetapi jika klien memecah urutan, kami mendapatkan kesalahan di server.
  2. Kami memulai penambahan node: OnClickAddNewNode . OnServerSynchronize , peristiwa OnServerAddNode berasal dari server.
    • OnClickAddNewNode mengambil antrian (jika ada sesuatu di dalamnya, OnClickAddNewNode Lock metode ini akan menolak panggilan)
    • OnServerSynchronize , OnServerAddNode , dijalankan secara berurutan setelah OnClickAddNewNode , tidak bersaing dengannya.
  3. Antrian memiliki OnServerUpdateNode dan OnServerUpdateNode . Misalkan selama eksekusi yang pertama, pengguna menutup GraphController . Maka panggilan kedua ke OnServerUpdateNode tidak boleh dilakukan secara otomatis agar tidak mengambil tindakan pada controller yang hancur, yang dijamin akan menyebabkan kesalahan. Untuk ini, antarmuka ILockTarget telah IsDestroyed - dekorator memeriksa bendera tanpa mengeksekusi handler berikutnya dari antrian.
    Keuntungan: tidak perlu menulis if (!this.IsDestroyed()) setelah masing-masing await .
  4. Perubahan ke beberapa node dimulai. OnServerSynchronize , peristiwa OnServerUpdateNode berasal dari server. Eksekusi kompetitif mereka akan menyebabkan kesalahan yang tidak dapat direproduksi. Tapi sejak itu LockQueue mereka ditandai oleh LockBetween dan LockBetween , mereka akan dieksekusi secara berurutan.
  5. Bayangkan bahwa simpul dapat memiliki grafik simpul bersarang di dalamnya. GraphController #1 , β€” GraphController #2 . , GraphController - , ( β€” ), .. . :
    • GraphController #2 , , .
  6. OnSearchFieldChange , . - . @LockDeferred(300) 300 : , , 300 . , . :
    • , 500 , . β€” OnSearchFieldChange , .
    • OnSearchFieldChange β€” , .


  1. Deadlock: Handler1 , , await Handler2 , LockQueue , Handler2 β€” Handler1 .
  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/id464773/


All Articles