Metafisika Injeksi Ketergantungan

gambar


Dependency Injection adalah teknik yang umum digunakan dalam pemrograman berorientasi objek yang dirancang untuk mengurangi konektivitas komponen. Ketika digunakan dengan benar, selain mencapai tujuan ini, itu dapat membawa kualitas yang sangat ajaib untuk aplikasi Anda. Seperti sihir apa pun, teknik ini dianggap sebagai serangkaian mantra, dan bukan risalah ilmiah yang ketat. Ini mengarah pada salah tafsir atas fenomena dan, sebagai konsekuensinya, penyalahgunaan artefak. Dalam materi penulis saya, saya menyarankan agar pembaca selangkah demi selangkah, secara singkat dan intinya, beralih dari dasar yang tepat dari desain berorientasi objek ke keajaiban injeksi ketergantungan otomatis.

Materi ini didasarkan pada pengembangan wadah Hypo IoC , yang saya sebutkan di artikel sebelumnya . Dalam contoh kode miniatur, saya akan menggunakan Ruby sebagai salah satu bahasa berorientasi objek yang paling ringkas untuk menulis contoh singkat. Ini seharusnya tidak menyebabkan masalah bagi pengembang dalam bahasa lain untuk mengerti.

Level 1: Prinsip Inversi Ketergantungan


Pengembang dalam paradigma berorientasi objek dihadapkan setiap hari dengan penciptaan objek, yang, pada gilirannya, mungkin bergantung pada objek lain. Ini mengarah ke grafik ketergantungan. Misalkan kita berhadapan dengan model objek dari bentuk:
gambar

- beberapa layanan penagihan (InvoiceProcessor) dan layanan notifikasi (NotificationService). Layanan pemrosesan faktur mengirim pemberitahuan ketika kondisi tertentu terpenuhi, kami akan mengambil logika ini di luar ruang lingkup. Pada prinsipnya, model ini sudah baik karena masing-masing komponen bertanggung jawab atas berbagai tanggung jawab. Masalahnya terletak pada bagaimana kita menerapkan dependensi ini. Kesalahan umum adalah menginisialisasi dependensi di mana dependensi ini digunakan:

class InvoiceProcessor def process(invoice) #      notificationService = NotificationService.new notificationService.notify(invoice.owner) end end 

Ini adalah kesalahan mengingat fakta bahwa kita mendapatkan konektivitas tinggi dari objek yang independen secara logis (Kopling Tinggi). Hal ini menyebabkan pelanggaran terhadap Prinsip Tanggung Jawab Tunggal - sebuah objek dependen, selain tanggung jawab langsungnya, harus menginisialisasi dependensinya; dan juga "tahu" antarmuka konstruktor dependensi, yang akan mengarah pada alasan tambahan untuk perubahan ( "alasan untuk berubah", R. Martin ). Lebih tepat untuk melewatkan dependensi semacam ini, diinisialisasi di luar objek dependen:

 class InvoiceProcessor def initialize(notificationService) @notificationService = notificationService end def process(invoice) @notificationService.notify(invoice.owner) end end notificationService = NotificationService.new invoiceProcessor = InvoiceProcessor.new(notificationService) 

Pendekatan ini konsisten dengan Prinsip Ketergantungan Inversi. Sekarang kami mentransfer objek dengan antarmuka pengiriman pesan - tidak ada lagi kebutuhan untuk layanan penagihan untuk "tahu" bagaimana membangun objek layanan notifikasi. Saat menulis pengujian unit untuk layanan pemrosesan faktur, pengembang tidak perlu memikirkan cara mengganti implementasi antarmuka layanan notifikasi dengan sebuah rintisan. Dalam bahasa dengan pengetikan dinamis, seperti Ruby, Anda dapat mengganti objek apa pun yang memenuhi metode pemberitahuan; dengan mengetik statis seperti C # / Java, Anda dapat menggunakan antarmuka INotificationService, yang membuatnya mudah untuk membuat Mock. Masalah inversi ketergantungan diungkapkan secara rinci oleh Alexander Byndyu dalam sebuah artikel yang baru-baru ini merayakan ulang tahunnya yang ke 10!

Level 2: registrasi objek terkait


Menggunakan prinsip inversi ketergantungan tampaknya bukan praktik yang rumit. Namun seiring waktu, karena peningkatan jumlah objek dan hubungan, tantangan baru muncul. NotificationService dapat digunakan oleh layanan selain InvoiceProcessor. Selain itu, ia sendiri dapat bergantung pada layanan lain, yang, pada gilirannya, bergantung pada layanan ketiga, dll. Juga, beberapa komponen mungkin tidak selalu digunakan dalam satu salinan. Tugas utama adalah menemukan jawaban untuk pertanyaan - "kapan membuat dependensi?".
Untuk mengatasi masalah ini, Anda dapat mencoba membangun solusi berdasarkan array dependensi asosiatif. Contoh antarmuka karyanya mungkin terlihat seperti ini:

 registry.add(InvoiceProcessor) .depends_on(NotificationService) registry.add(NotificationService) .depends_on(ServiceX) invoiceProcessor = registry.resolve(InvoiceProcessor) invoiceProcessor.process(invoice) 

Tidak sulit untuk menerapkannya dalam praktik:

gambar

Setiap kali container.resolve () dipanggil, kami akan beralih ke pabrik, yang akan membuat instance ketergantungan, secara rekursif melewati grafik ketergantungan yang dijelaskan dalam registri. Dalam kasus `container.resolve (InvoiceProcessor)`, berikut ini akan dieksekusi:

  1. factory.resolve (InvoiceProcessor) - pabrik meminta dependensi InvoiceProcessor dalam register, menerima NotificationService, yang juga perlu dirakit.
  2. factory.resolve (NotificationService) - pabrik meminta dependensi NotificationService dalam register, menerima ServiceX, yang juga perlu dirakit.
  3. factory.resolve (ServiceX) - tidak memiliki dependensi, membuat, mengembalikan tumpukan panggilan ke langkah 1, dapatkan objek rakitan tipe InvoiceProcessor.

Setiap komponen mungkin bergantung pada beberapa yang lain, jadi pertanyaan yang jelas adalah "bagaimana mencocokkan parameter desainer dengan contoh ketergantungan yang dihasilkan?". Contoh:

 class InvoiceProcessor def initialize(notificationService, paymentService) # ... end end 

Dalam bahasa dengan pengetikan statis, tipe parameter dapat berfungsi sebagai pemilih:

 class InvoiceProcessor { constructor(notificationService: NotificationService, paymentService: PaymentService) { // ... } } 

Di dalam Ruby, Anda dapat menggunakan konvensi - cukup gunakan nama jenis dalam format snake_case, ini akan menjadi nama parameter yang diharapkan.

Level 3: manajemen ketergantungan seumur hidup


Kami sudah mendapat solusi manajemen ketergantungan yang baik. Satu-satunya batasan adalah kebutuhan untuk membuat instance baru dari ketergantungan dengan setiap panggilan. Tetapi bagaimana jika kita tidak dapat membuat lebih dari satu instance komponen? Misalnya, kumpulan koneksi ke database. Gali lebih dalam, dan jika kita perlu menyediakan masa ketergantungan yang terkendali? Misalnya, tutup koneksi ke database setelah penyelesaian permintaan HTTP.
Menjadi jelas bahwa kandidat untuk penggantian dalam solusi asli adalah InstanceFactory. Grafik yang diperbarui:

gambar

Dan solusi logisnya adalah menggunakan seperangkat strategi ( Strategi, GoF ) untuk mendapatkan komponen contoh. Sekarang kami tidak selalu membuat instance baru ketika memanggil Container :: resolve, jadi pantas untuk mengubah nama Factory menjadi Resolver. Harap dicatat bahwa metode Container :: register memiliki parameter baru - life_time (seumur hidup). Parameter ini opsional - secara default nilainya β€œsementara” (transient), yang sesuai dengan perilaku yang diterapkan sebelumnya. Strategi tunggal juga jelas - dengan penggunaannya hanya satu contoh komponen dibuat, yang akan dikembalikan setiap waktu.
Lingkup adalah strategi yang sedikit lebih kompleks. Alih-alih "jalur sementara" dan "penyendiri," sering diperlukan untuk menggunakan sesuatu di antaranya - komponen yang ada sepanjang kehidupan komponen lain. Contoh serupa dapat berupa objek permintaan aplikasi web, yang merupakan konteks keberadaan objek seperti, misalnya, parameter HTTP, koneksi database, agregat model. Sepanjang kehidupan permintaan, kami mengumpulkan dan menggunakan dependensi ini, dan setelah kehancurannya, kami berharap bahwa semuanya juga akan dihancurkan. Untuk mengimplementasikan fungsionalitas seperti itu, perlu untuk mengembangkan struktur objek yang cukup kompleks:

gambar

Diagram menunjukkan sebuah fragmen yang mencerminkan perubahan dalam kelas Component dan LifetimeStrategy dalam konteks implementasi Seumur Hidup Scoped. Hasilnya adalah semacam "jembatan ganda" (mirip dengan jembatan, templat GoF ). Menggunakan seluk-beluk teknik pewarisan dan agregasi, Komponen menjadi inti dari wadah. Omong-omong, diagram tersebut memiliki banyak pewarisan. Jika bahasa pemrograman dan hati nurani mengizinkannya, Anda dapat membiarkannya demikian. Di Ruby saya menggunakan pengotor, dalam bahasa lain Anda bisa mengganti warisan dengan jembatan lain:
gambar

Diagram urutan menunjukkan siklus hidup komponen sesi, yang terkait dengan masa pakai komponen permintaan:

gambar

Seperti yang dapat Anda lihat dari diagram, pada titik waktu tertentu, ketika komponen permintaan menyelesaikan misinya, metode pelepasan disebut, yang memulai proses penghancuran ruang lingkup.

Level 4: Injeksi Ketergantungan


Sampai sekarang, saya berbicara tentang cara menentukan registri dependensi, dan kemudian bagaimana membuat dan menghancurkan komponen sesuai dengan grafik relasi yang dibentuk. Dan untuk apa ini? Misalkan kita menggunakan ini sebagai bagian dari Ruby on Rails:

 class InvoiceController < ApplicationController def pay(params) invoice_repository = registry.resolve(InvoiceRepository) invoice_processor = registry.resolve(InvoiceProcessor) invoice = invoice_repository.find(params[:id]) invoice_processor.pay(invoice) end end 

Kode yang akan ditulis dengan cara ini tidak akan lebih mudah dibaca, diuji, atau fleksibel. Kita tidak bisa "memaksa" Rails untuk menyuntikkan dependensi controller melalui konstruktornya, ini tidak disediakan oleh framework. Tetapi, misalnya, dalam ASP.NET MVC ini diterapkan pada tingkat dasar. Untuk mendapatkan hasil maksimal dari menggunakan mekanisme resolusi dependensi otomatis, Anda perlu menerapkan teknik Inversion of Control (IoC, inversion of control). Ini adalah pendekatan di mana tanggung jawab untuk menyelesaikan dependensi melampaui ruang lingkup kode aplikasi dan berada pada kerangka kerja. Pertimbangkan sebuah contoh.
Bayangkan kita sedang merancang sesuatu seperti Rails dari awal. Kami menerapkan skema berikut:

gambar

Aplikasi menerima permintaan, router mengambil parameter dan memerintahkan pengontrol yang sesuai untuk memproses permintaan ini. Skema seperti itu secara kondisional menyalin perilaku kerangka kerja web yang khas dengan hanya sedikit perbedaan - wadah IoC terlibat dalam menciptakan dan mengimplementasikan dependensi. Tapi di sini muncul pertanyaan, di mana wadah itu sendiri dibuat? Untuk mencakup sebanyak mungkin objek aplikasi masa depan, kerangka kerja kami harus membuat wadah pada tahap awal operasinya. Jelas, tidak ada tempat yang lebih cocok daripada aplikasi pembangun aplikasi. Ini juga merupakan tempat paling cocok untuk mengonfigurasi semua dependensi:

 class App #   - ,      . def initialize @container = Container.new @container .register(Controller) .using_lifetime(:transient) # ,     @container .register(InvoiceService) .using_lifetime(:singleton) # ,     @container .register(Router) .using_lifetime(:singleton) #  end #     -     , #      . def call(env) router = @container.resolve(Router) router.handle(env.path, env.method, env.params) end end 

Aplikasi apa pun memiliki titik masuk, misalnya, metode utama. Dalam contoh ini, titik masuk adalah metode panggilan. Tujuan dari metode ini adalah untuk memanggil router untuk memproses permintaan yang masuk. Titik masuk harus menjadi satu-satunya tempat untuk memanggil wadah secara langsung - sejak saat itu wadah harus pergi di pinggir jalan, semua sihir berikutnya harus terjadi "di bawah tenda". Implementasi controller dalam arsitektur seperti itu benar-benar terlihat tidak biasa. Terlepas dari kenyataan bahwa kami tidak membuat instantiate secara eksplisit, ia memiliki konstruktor dengan parameter:

 class Controller #   . #    . def initialize(invoice_service) @invoice_service = invoice_service end def create_invoice(params) @invoice_service.create(params) end end 

Lingkungan "memahami" cara membuat instance pengontrol. Ini dimungkinkan berkat mekanisme injeksi ketergantungan yang disediakan oleh wadah IoC yang tertanam di jantung aplikasi web. Dalam konstruktor controller Anda sekarang dapat mendaftar semua yang diperlukan untuk operasinya. Yang utama adalah bahwa komponen yang sesuai terdaftar dalam wadah. Sekarang mari kita beralih ke implementasi router:

 class Router #         -  #      #     . def initialize(controller) @controller = controller end def handle(path, method, params) #  ""- if path == '/invoices' && method == 'POST' @controller.create(params) end end end 

Perhatikan bahwa Router bergantung pada pengontrol. Jika kita mengingat pengaturan ketergantungan, maka Controller adalah komponen yang berumur pendek, dan Router adalah penyendiri yang konstan. Bagaimana ini bisa terjadi? Jawabannya adalah bahwa komponen bukan turunan dari kelas yang sesuai, karena terlihat secara eksternal. Bahkan, ini adalah objek proxy ( Proxy, GoF ) dengan contoh metode pabrik ( Metode Pabrik, GoF ); mereka mengembalikan instance komponen sesuai dengan strategi yang ditugaskan. Karena Kontroler terdaftar sebagai "sementara", Router akan selalu menangani instance baru ketika diakses. Diagram urutan menunjukkan perkiraan mekanisme kerja:

gambar

Yaitu Selain manajemen ketergantungan, kerangka kerja yang baik berdasarkan wadah IoC juga bertanggung jawab atas pengelolaan masa pakai komponen yang benar.

Kesimpulan


Teknik Injeksi Ketergantungan dapat memiliki implementasi internal yang cukup canggih. Ini adalah harga untuk mentransfer kompleksitas penerapan aplikasi fleksibel ke inti kerangka kerja. Pengguna kerangka kerja seperti itu tidak dapat khawatir tentang aspek teknis semata, tetapi mencurahkan lebih banyak waktu untuk pengembangan nyaman logika bisnis program aplikasi. Menggunakan implementasi DI berkualitas tinggi, seorang programmer aplikasi awalnya menulis kode yang dapat diuji dan didukung dengan baik. Contoh yang baik dari implementasi Ketergantungan Injeksi adalah kerangka Dandy yang dijelaskan dalam artikel saya sebelumnya Orthodox Backend .

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


All Articles