Pegang Kerangka – Menjelajahi Pola Injeksi Ketergantungan

Diterbitkan: 2022-03-11

Pandangan tradisional tentang inversi kontrol (IoC) tampaknya menarik garis tegas antara dua pendekatan yang berbeda: pencari lokasi layanan dan pola injeksi ketergantungan (DI).

Hampir setiap proyek yang saya tahu menyertakan kerangka kerja DI. Orang-orang tertarik pada mereka karena mereka mempromosikan kopling longgar antara klien dan dependensi mereka (biasanya melalui injeksi konstruktor) dengan kode boilerplate minimal atau tanpa kode. Meskipun ini bagus untuk pengembangan yang cepat, beberapa orang merasa bahwa ini dapat membuat kode sulit untuk dilacak dan di-debug. “Keajaiban di balik layar” biasanya dicapai melalui refleksi, yang dapat membawa serangkaian masalah baru.

Dalam artikel ini, kita akan mengeksplorasi pola alternatif yang cocok untuk basis kode Java 8+ dan Kotlin. Ini mempertahankan sebagian besar manfaat kerangka kerja DI sekaligus semudah pencari layanan, tanpa memerlukan perkakas eksternal.

Motivasi

  • Hindari ketergantungan eksternal
  • Hindari refleksi
  • Promosikan injeksi konstruktor
  • Minimalkan perilaku runtime

Sebuah contoh

Dalam contoh berikut, kami akan memodelkan implementasi TV, di mana sumber yang berbeda dapat digunakan untuk mendapatkan konten. Kita perlu membangun perangkat yang dapat menerima sinyal dari berbagai sumber (misalnya, terestrial, kabel, satelit, dll). Kami akan membangun hierarki kelas berikut:

Hirarki kelas perangkat TV yang mengimplementasikan sumber sinyal arbitrer

Sekarang mari kita mulai dengan implementasi DI tradisional, di mana kerangka kerja seperti Spring menghubungkan segalanya untuk kita:

 public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }

Kami memperhatikan beberapa hal:

  • Kelas TV mengekspresikan ketergantungan pada TvSource. Kerangka kerja eksternal akan melihat ini dan menyuntikkan contoh implementasi konkret (Terrestrial atau Cable).
  • Pola injeksi konstruktor memungkinkan pengujian yang mudah karena Anda dapat dengan mudah membuat instans TV dengan implementasi alternatif.

Kami memulainya dengan baik, tetapi kami menyadari bahwa memasukkan kerangka kerja DI untuk ini mungkin sedikit berlebihan. Beberapa pengembang telah melaporkan masalah men-debug masalah konstruksi (jejak tumpukan panjang, dependensi yang tidak dapat dilacak). Klien kami juga menyatakan bahwa waktu produksi sedikit lebih lama dari yang diharapkan, dan profiler kami menunjukkan perlambatan dalam panggilan reflektif.

Alternatifnya adalah dengan menerapkan pola Service Locator. Ini mudah, tidak menggunakan refleksi, dan mungkin cukup untuk basis kode kecil kami. Alternatif lain adalah membiarkan kelas-kelas itu sendiri dan menulis kode lokasi ketergantungan di sekitar mereka.

Setelah mengevaluasi banyak alternatif, kami memilih untuk mengimplementasikannya sebagai hierarki antarmuka penyedia. Setiap dependensi akan memiliki penyedia terkait yang akan bertanggung jawab penuh untuk menemukan dependensi kelas dan membuat instance yang disuntikkan. Kami juga akan membuat penyedia antarmuka dalam untuk kemudahan penggunaan. Kami akan menyebutnya Mixin Injection karena setiap penyedia dicampur dengan penyedia lain untuk menemukan dependensinya.

Rincian mengapa saya memilih struktur ini diuraikan dalam Detail dan Alasan, tetapi inilah versi singkatnya:

  • Ini memisahkan perilaku lokasi ketergantungan.
  • Memperluas antarmuka tidak termasuk dalam masalah berlian.
  • Antarmuka memiliki implementasi default.
  • Ketergantungan yang hilang mencegah kompilasi (poin bonus!).

Diagram berikut menunjukkan bagaimana dependensi dan penyedia berinteraksi, dan implementasinya diilustrasikan di bawah ini. Kami juga menambahkan metode utama untuk mendemonstrasikan bagaimana kami dapat menyusun dependensi kami dan membangun objek TV. Versi yang lebih panjang dari contoh ini juga dapat ditemukan di GitHub ini.

Interaksi antara penyedia dan dependensi

 public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }

Beberapa catatan tentang contoh ini:

  • Kelas TV bergantung pada TvSource, tetapi tidak mengetahui implementasi apa pun.
  • TV.Provider memperluas TvSource.Provider karena memerlukan metode tvSource() untuk membangun TvSource, dan dapat menggunakannya meskipun tidak diimplementasikan di sana.
  • Sumber Terrestrial dan Kabel dapat digunakan secara bergantian oleh TV.
  • Antarmuka Terrestrial.Provider dan Cable.Provider menyediakan implementasi TvSource yang konkret.
  • Metode utama memiliki implementasi konkret MainContext of TV.Provider yang digunakan untuk mendapatkan instance TV.
  • Program ini memerlukan implementasi TvSource.Provider pada waktu kompilasi untuk membuat instance TV, jadi kami menyertakan Cable.Provider sebagai contoh.

Detail dan Alasan

Kami telah melihat pola dalam tindakan dan beberapa alasan di baliknya. Anda mungkin tidak yakin bahwa Anda harus menggunakannya sekarang, dan Anda akan benar; itu bukan peluru perak. Secara pribadi, saya percaya bahwa ini lebih unggul daripada pola pencari layanan di sebagian besar aspek. Namun, jika dibandingkan dengan kerangka kerja DI, kita harus mengevaluasi apakah keuntungannya lebih besar daripada biaya tambahan untuk menambahkan kode boilerplate.

Penyedia Memperpanjang Penyedia Lain untuk Menemukan Ketergantungan Mereka

Ketika penyedia memperluas yang lain, dependensi terikat bersama. Ini memberikan dasar dasar untuk validasi statis yang mencegah pembuatan konteks yang tidak valid.

Salah satu masalah utama dari pola pencari lokasi layanan adalah Anda perlu memanggil metode GetService<T>() generik yang entah bagaimana akan menyelesaikan ketergantungan Anda. Pada waktu kompilasi, Anda tidak memiliki jaminan bahwa ketergantungan akan pernah terdaftar di locator, dan program Anda bisa gagal saat runtime.

Pola DI juga tidak membahas hal ini. Resolusi dependensi biasanya dilakukan melalui refleksi oleh alat eksternal yang sebagian besar tersembunyi dari pengguna, yang juga gagal saat runtime jika dependensi tidak terpenuhi. Alat seperti CDI IntelliJ (hanya tersedia dalam versi berbayar) menyediakan beberapa tingkat verifikasi statis, tetapi hanya Dagger dengan praprosesor anotasinya yang tampaknya mengatasi masalah ini dengan desain.

Kelas Mempertahankan Injeksi Konstruktor Khas dari Pola DI

Ini tidak diperlukan tetapi pasti diinginkan oleh komunitas pengembang. Di satu sisi, Anda bisa melihat konstruktor, dan langsung melihat dependensi kelas. Di sisi lain, ini memungkinkan jenis pengujian unit yang dipatuhi banyak orang, yaitu dengan membangun subjek yang diuji dengan tiruan dari ketergantungannya.

Ini bukan untuk mengatakan bahwa pola lain tidak didukung. Faktanya, orang bahkan mungkin menemukan bahwa Mixin Injection menyederhanakan pembuatan grafik ketergantungan kompleks untuk pengujian karena Anda hanya perlu mengimplementasikan kelas konteks yang memperluas penyedia subjek Anda. MainContext di atas adalah contoh sempurna di mana semua antarmuka memiliki implementasi default, sehingga dapat memiliki implementasi kosong. Mengganti ketergantungan hanya membutuhkan penggantian metode penyedianya.

Mari kita lihat tes berikut untuk kelas TV. Itu perlu membuat instance TV, tetapi alih-alih memanggil konstruktor kelas, ia menggunakan antarmuka TV.Provider. TvSource.Provider tidak memiliki implementasi default, jadi kita perlu menulisnya sendiri.

 public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }

Sekarang mari tambahkan ketergantungan lain ke kelas TV. Ketergantungan CathodeRayTube bekerja dengan ajaib untuk membuat gambar muncul di layar TV. Itu dipisahkan dari implementasi TV karena kami mungkin ingin beralih ke LCD atau LED di masa depan.

 public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println("Beaming electrons to produce the TV image"); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

Jika Anda melakukan ini, Anda akan melihat bahwa tes yang baru saja kita tulis masih dikompilasi dan lulus seperti yang diharapkan. Kami menambahkan ketergantungan baru ke TV, tetapi kami juga menyediakan implementasi default. Ini berarti bahwa kita tidak perlu menirunya jika kita hanya ingin menggunakan implementasi nyata, dan pengujian kita dapat membuat objek kompleks dengan tingkat perincian tiruan apa pun yang kita inginkan.

Ini berguna ketika Anda ingin mengejek sesuatu yang spesifik dalam hierarki kelas yang kompleks (misalnya, hanya lapisan akses database). Pola ini memungkinkan dengan mudah mengatur jenis tes bersosialisasi yang terkadang lebih disukai daripada tes soliter.

Terlepas dari preferensi Anda, Anda dapat yakin bahwa Anda dapat beralih ke segala bentuk pengujian yang lebih sesuai dengan kebutuhan Anda dalam setiap situasi.

Hindari Ketergantungan Eksternal

Seperti yang Anda lihat, tidak ada referensi atau penyebutan komponen eksternal. Ini adalah kunci untuk banyak proyek yang memiliki batasan ukuran atau bahkan keamanan. Ini juga membantu dengan interoperabilitas karena kerangka kerja tidak harus berkomitmen pada kerangka kerja DI tertentu. Di Jawa, ada upaya seperti JSR-330 Dependency Injection untuk Java Standard yang mengurangi masalah kompatibilitas.

Hindari Refleksi

Implementasi pencari lokasi layanan biasanya tidak bergantung pada refleksi, tetapi implementasi DI melakukannya (dengan pengecualian Dagger 2). Ini memiliki kelemahan utama memperlambat startup aplikasi karena kerangka kerja perlu memindai modul Anda, menyelesaikan grafik ketergantungan, membangun objek Anda secara reflektif, dll.

Mixin Injection mengharuskan Anda untuk menulis kode untuk membuat instance layanan Anda, mirip dengan langkah pendaftaran dalam pola pencari layanan. Pekerjaan ekstra kecil ini sepenuhnya menghilangkan panggilan reflektif, membuat kode Anda lebih cepat dan mudah.

Dua proyek yang baru-baru ini menarik perhatian saya dan mendapat manfaat dari menghindari refleksi adalah VM Substrat Graal dan Kotlin/Native. Keduanya dikompilasi ke bytecode asli, dan ini mengharuskan kompiler untuk mengetahui terlebih dahulu setiap panggilan reflektif yang akan Anda buat. Dalam kasus Graal, ini ditentukan dalam file JSON yang sulit untuk ditulis, tidak dapat diperiksa secara statis, tidak dapat dengan mudah difaktorkan ulang menggunakan alat favorit Anda. Menggunakan Mixin Injection untuk menghindari refleksi di tempat pertama adalah cara yang bagus untuk mendapatkan manfaat kompilasi asli.

Minimalkan Perilaku Waktu Proses

Dengan menerapkan dan memperluas antarmuka yang diperlukan, Anda membuat grafik ketergantungan satu per satu. Setiap penyedia duduk di sebelah implementasi konkret, yang membawa ketertiban dan logika ke program Anda. Layering seperti ini akan familiar jika Anda pernah menggunakan pola Mixin atau pola Cake sebelumnya.

Pada titik ini, mungkin ada baiknya membicarakan kelas MainContext. Ini adalah akar dari grafik ketergantungan dan mengetahui gambaran besarnya. Kelas ini mencakup semua antarmuka penyedia dan merupakan kunci untuk mengaktifkan pemeriksaan statis. Jika kita kembali ke contoh dan menghapus Cable.Provider dari daftar implementasinya, kita akan melihat ini dengan jelas:

 static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider

Apa yang terjadi di sini adalah bahwa aplikasi tidak menentukan TvSource konkret untuk digunakan, dan kompiler menangkap kesalahannya. Dengan pencari lokasi layanan dan DI berbasis refleksi, kesalahan ini bisa saja luput dari perhatian hingga program mogok saat runtime—bahkan jika semua pengujian unit lulus! Saya percaya ini dan manfaat lain yang kami tunjukkan lebih besar daripada kerugian menulis boilerplate yang dibutuhkan untuk membuat polanya bekerja.

Tangkap Dependensi Melingkar

Mari kembali ke contoh CathodeRayTube dan tambahkan dependensi melingkar. Katakanlah kita ingin itu disuntikkan instance TV, jadi kita memperluas TV.Provider:

 public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

Kompiler tidak mengizinkan pewarisan siklik dan kami tidak dapat mendefinisikan hubungan semacam ini. Sebagian besar kerangka kerja gagal saat runtime ketika ini terjadi, dan pengembang cenderung mengatasinya hanya untuk membuat program berjalan. Meskipun anti-pola ini dapat ditemukan di dunia nyata biasanya merupakan pertanda desain yang buruk. Ketika kode gagal dikompilasi, kita harus didorong untuk mencari solusi yang lebih baik sebelum terlambat untuk mengubahnya.

Pertahankan Kesederhanaan dalam Konstruksi Objek

Salah satu argumen yang mendukung SL daripada DI adalah mudah dan lebih mudah untuk di-debug. Jelas dari contoh bahwa membuat instance ketergantungan hanya akan menjadi rantai panggilan metode penyedia. Menelusuri kembali sumber dependensi semudah melangkah ke pemanggilan metode dan melihat di mana Anda berakhir. Debugging lebih sederhana daripada kedua alternatif karena Anda dapat menavigasi dengan tepat di mana dependensi dibuat, langsung dari penyedia.

Layanan Seumur Hidup

Pembaca yang penuh perhatian mungkin telah memperhatikan bahwa implementasi ini tidak mengatasi masalah seumur hidup layanan. Semua panggilan ke metode penyedia akan membuat instance objek baru, membuatnya mirip dengan lingkup Prototipe Spring.

Pertimbangan ini dan lainnya sedikit di luar cakupan artikel ini, karena saya hanya ingin menyajikan esensi pola tanpa mengganggu detail. Namun, penggunaan dan implementasi penuh dalam suatu produk perlu mempertimbangkan solusi lengkap dengan dukungan seumur hidup.

Kesimpulan

Baik Anda terbiasa dengan kerangka kerja injeksi ketergantungan atau menulis pencari layanan Anda sendiri, Anda mungkin ingin menjelajahi alternatif ini. Pertimbangkan untuk menggunakan pola mixin yang baru saja kita lihat dan lihat apakah Anda dapat membuat kode Anda lebih aman dan lebih mudah untuk dipikirkan.

Terkait: Praktik Terbaik JS: Bangun Bot Perselisihan dengan TypeScript dan Injeksi Ketergantungan