Jebakan dan perbaikan pola Chain of Responsibility

Baru-baru ini saya menulis dua program Java (untuk Microsoft Windows OS) yang harus menangkap peristiwa keyboard global yang dihasilkan oleh aplikasi lain yang secara bersamaan berjalan di desktop yang sama. Microsoft menyediakan cara untuk melakukannya dengan mendaftarkan program sebagai pendengar hook keyboard global. Pengkodean tidak memakan waktu lama, tetapi debugging berhasil. Kedua program tersebut tampaknya berfungsi dengan baik saat diuji secara terpisah, tetapi gagal saat diuji bersama. Pengujian lebih lanjut mengungkapkan bahwa ketika kedua program berjalan bersama, program yang diluncurkan pertama kali selalu tidak dapat menangkap peristiwa utama global, tetapi aplikasi yang diluncurkan kemudian bekerja dengan baik.

Saya memecahkan misteri itu setelah membaca dokumentasi Microsoft. Kode yang mendaftarkan program itu sendiri sebagai pendengar hook tidak memiliki CallNextHookEx()panggilan yang diperlukan oleh kerangka kerja hook. Dokumentasi membaca bahwa setiap pendengar hook ditambahkan ke rantai hook dalam urutan permulaan; pendengar terakhir yang mulai akan berada di atas. Peristiwa dikirim ke pendengar pertama dalam rangkaian. Untuk mengizinkan semua pendengar menerima peristiwa, setiap pendengar harus melakukan CallNextHookEx()panggilan untuk menyampaikan peristiwa ke pendengar di sebelahnya. Jika ada pendengar yang lupa melakukannya, pendengar berikutnya tidak akan mendapatkan kejadian tersebut; akibatnya, fungsi yang dirancang tidak akan berfungsi. Itulah alasan pasti mengapa program kedua saya berhasil tetapi yang pertama tidak!

Misteri terpecahkan, tapi saya tidak senang dengan kerangka pengait. Pertama, ini mengharuskan saya untuk "mengingat" untuk memasukkan CallNextHookEx()pemanggilan metode ke dalam kode saya. Kedua, program saya dapat menonaktifkan program lain dan sebaliknya. Mengapa itu terjadi? Karena Microsoft menerapkan kerangka kerja global hook mengikuti persis pola Chain of Responsibility (CoR) klasik yang didefinisikan oleh Gang of Four (GoF).

Dalam artikel ini, saya membahas celah dari implementasi CoR yang disarankan oleh GoF dan mengusulkan solusi untuk itu. Itu dapat membantu Anda menghindari masalah yang sama saat Anda membuat kerangka kerja CoR Anda sendiri.

CoR klasik

Pola CoR klasik yang ditentukan oleh GoF dalam Pola Desain :

"Hindari menggabungkan pengirim permintaan ke penerima dengan memberi lebih dari satu objek kesempatan untuk menangani permintaan. Rantai objek penerima dan teruskan permintaan di sepanjang rantai sampai sebuah objek menanganinya."

Gambar 1 mengilustrasikan diagram kelas.

Struktur objek yang khas mungkin terlihat seperti Gambar 2.

Dari ilustrasi di atas, dapat disimpulkan bahwa:

  • Beberapa penangan mungkin dapat menangani permintaan
  • Hanya satu penangan yang benar-benar menangani permintaan tersebut
  • Pemohon hanya mengetahui referensi ke satu penangan
  • Pemohon tidak tahu berapa banyak penangan yang dapat menangani permintaannya
  • Pemohon tidak tahu penangan mana yang menangani permintaannya
  • Pemohon tidak memiliki kendali atas penangan
  • Penangan bisa ditentukan secara dinamis
  • Mengubah daftar penangan tidak akan mempengaruhi kode pemohon

Segmen kode di bawah ini menunjukkan perbedaan antara kode pemohon yang menggunakan CoR dan kode pemohon yang tidak.

Kode pemohon yang tidak menggunakan CoR:

handlers = getHandlers (); untuk (int i = 0; i <handlers.length; i ++) {handlers [i] .handle (request); jika (penangan [i]. ditangani ()) istirahat; }

Kode pemohon yang menggunakan CoR:

 getChain (). handle (request); 

Sampai sekarang, semuanya tampak sempurna. Tapi mari kita lihat implementasi yang disarankan GoF untuk CoR klasik:

public class Handler {private Handler successor; Penangan publik (HelpHandler s) {penerus = s; } pegangan publik (permintaan ARequest) {if (penerus! = null) penerus.handle (permintaan); }} kelas publik AHandler extends Handler {public handle (ARequest request) {if (someCondition) // Handling: lakukan sesuatu yang lain super.handle (request); }}

Kelas dasar memiliki metode,, handle()yang memanggil penggantinya, simpul berikutnya dalam rantai, untuk menangani permintaan tersebut. Subclass mengganti metode ini dan memutuskan apakah akan mengizinkan rantai untuk melanjutkan. Jika node menangani permintaan tersebut, subclass tidak akan memanggil super.handle()yang memanggil penerus, dan rantai berhasil dan berhenti. Jika node tidak menangani permintaan tersebut, subclass harus memanggil super.handle()agar rantai tetap berjalan, atau rantai akan berhenti dan gagal. Karena aturan ini tidak diterapkan di kelas dasar, kepatuhannya tidak dijamin. Ketika pengembang lupa untuk melakukan panggilan di subclass, rantai gagal. Kelemahan mendasar di sini adalah bahwa pengambilan keputusan eksekusi rantai, yang bukan merupakan urusan subclass, digabungkan dengan penanganan permintaan di subclass. Itu melanggar prinsip desain berorientasi objek: objek harus mengurus urusannya sendiri. Dengan membiarkan subclass membuat keputusan, Anda memberikan beban ekstra padanya dan kemungkinan kesalahan.

Celah kerangka kerja global Microsoft Windows dan kerangka kerja filter servlet Java

Implementasi kerangka kerja global hook Microsoft Windows sama dengan implementasi CoR klasik yang disarankan oleh GoF. Kerangka kerja bergantung pada pendengar hook individu untuk melakukan CallNextHookEx()panggilan dan menyampaikan peristiwa melalui rantai. Ini mengasumsikan pengembang akan selalu mengingat aturan dan tidak pernah lupa untuk melakukan panggilan. Secara alami, rantai pengait acara global bukanlah CoR klasik. Peristiwa harus dikirim ke semua pemroses dalam rantai, terlepas dari apakah pemroses sudah menanganinya. Jadi CallNextHookEx()panggilan tersebut tampaknya menjadi tugas kelas dasar, bukan pendengar individu. Membiarkan pendengar individu melakukan panggilan tidak ada gunanya dan menimbulkan kemungkinan untuk menghentikan rantai secara tidak sengaja.

Kerangka kerja filter servlet Java membuat kesalahan yang sama seperti hook global Microsoft Windows. Ini persis mengikuti implementasi yang disarankan oleh GoF. Setiap filter memutuskan apakah akan menggulung atau menghentikan rantai dengan memanggil atau tidak memanggil doFilter()filter berikutnya. Aturan tersebut ditegakkan melalui javax.servlet.Filter#doFilter()dokumentasi:

"4. a) Memanggil entitas berikutnya dalam rantai menggunakan FilterChainobjek ( chain.doFilter()), 4. b) atau tidak meneruskan pasangan permintaan / tanggapan ke entitas berikutnya dalam rantai filter untuk memblokir pemrosesan permintaan."

Jika satu filter lupa melakukan chain.doFilter()panggilan pada saat yang seharusnya, itu akan menonaktifkan filter lain dalam rantai. Jika salah satu filter melakukan chain.doFilter()panggilan pada saat yang seharusnya tidak ada, filter tersebut akan memanggil filter lain dalam rantai.

Larutan

Aturan pola atau kerangka kerja harus ditegakkan melalui antarmuka, bukan dokumentasi. Mengandalkan pengembang untuk mengingat aturan tidak selalu berhasil. Solusinya adalah memisahkan pengambilan keputusan eksekusi rantai dan penanganan permintaan dengan memindahkan next()panggilan ke kelas dasar. Biarkan kelas dasar membuat keputusan, dan biarkan subclass menangani permintaan saja. Dengan menghindari pengambilan keputusan, subclass dapat sepenuhnya fokus pada bisnis mereka sendiri, sehingga menghindari kesalahan yang dijelaskan di atas.

CoR Klasik: Kirim permintaan melalui rantai sampai satu node menangani permintaan tersebut

Ini adalah implementasi yang saya sarankan untuk CoR klasik:

 /** * Classic CoR, i.e., the request is handled by only one of the handlers in the chain. */ public abstract class ClassicChain { /** * The next node in the chain. */ private ClassicChain next; public ClassicChain(ClassicChain nextNode) { next = nextNode; } /** * Start point of the chain, called by client or pre-node. * Call handle() on this node, and decide whether to continue the chain. If the next node is not null and * this node did not handle the request, call start() on next node to handle request. * @param request the request parameter */ public final void start(ARequest request) { boolean handledByThisNode = this.handle(request); if (next != null && !handledByThisNode) next.start(request); } /** * Called by start(). * @param request the request parameter * @return a boolean indicates whether this node handled the request */ protected abstract boolean handle(ARequest request); } public class AClassicChain extends ClassicChain { /** * Called by start(). * @param request the request parameter * @return a boolean indicates whether this node handled the request */ protected boolean handle(ARequest request) { boolean handledByThisNode = false; if(someCondition) { //Do handling handledByThisNode = true; } return handledByThisNode; } } 

The implementation decouples the chain execution decision-making logic and request-handling by dividing them into two separate methods. Method start() makes the chain execution decision and handle() handles the request. Method start() is the chain execution's starting point. It calls handle() on this node and decides whether to advance the chain to the next node based on whether this node handles the request and whether a node is next to it. If the current node doesn't handle the request and the next node is not null, the current node's start() method advances the chain by calling start() on the next node or stops the chain by not calling start() on the next node. Method handle() in the base class is declared abstract, providing no default handling logic, which is subclass-specific and has nothing to do with chain execution decision-making. Subclasses override this method and return a Boolean value indicating whether the subclasses handle the request themselves. Note that the Boolean returned by a subclass informs start() in the base class whether the subclass has handled the request, not whether to continue the chain. The decision of whether to continue the chain is completely up to the base class's start() method. The subclasses can't change the logic defined in start() because start() is declared final.

In this implementation, a window of opportunity remains, allowing the subclasses to mess up the chain by returning an unintended Boolean value. However, this design is much better than the old version, because the method signature enforces the value returned by a method; the mistake is caught at compile time. Developers are no longer required to remember to either make the next() call or return a Boolean value in their code.

Non-classic CoR 1: Send request through the chain until one node wants to stop

This type of CoR implementation is a slight variation of the classic CoR pattern. The chain stops not because one node has handled the request, but because one node wants to stop. In that case, the classic CoR implementation also applies here, with a slight conceptual change: the Boolean flag returned by the handle() method doesn't indicate whether the request has been handled. Rather, it tells the base class whether the chain should be stopped. The servlet filter framework fits in this category. Instead of forcing individual filters to call chain.doFilter(), the new implementation forces the individual filter to return a Boolean, which is contracted by the interface, something the developer never forgets or misses.

Non-classic CoR 2: Regardless of request handling, send request to all handlers

For this type of CoR implementation, handle() doesn't need to return the Boolean indicator, because the request is sent to all handlers regardless. This implementation is easier. Because the Microsoft Windows global hook framework by nature belongs to this type of CoR, the following implementation should fix its loophole:

 /** * Non-Classic CoR 2, i.e., the request is sent to all handlers regardless of the handling. */ public abstract class NonClassicChain2 { /** * The next node in the chain. */ private NonClassicChain2 next; public NonClassicChain2(NonClassicChain2 nextNode) { next = nextNode; } /** * Start point of the chain, called by client or pre-node. * Call handle() on this node, then call start() on next node if next node exists. * @param request the request parameter */ public final void start(ARequest request) { this.handle(request); if (next != null) next.start(request); } /** * Called by start(). * @param request the request parameter */ protected abstract void handle(ARequest request); } public class ANonClassicChain2 extends NonClassicChain2 { /** * Called by start(). * @param request the request parameter */ protected void handle(ARequest request) { //Do handling. } } 

Contoh

Di bagian ini, saya akan menunjukkan dua contoh rantai yang menggunakan implementasi untuk CoR 2 non-klasik yang dijelaskan di atas.

Contoh 1