Prinsip Pembalikan Ketergantungan di Jawa

1. Ikhtisar

The Dependency Inversion Principle (DIP) merupakan bagian dari kumpulan prinsip-prinsip pemrograman berorientasi objek yang dikenal sebagai SOLID.

Pada dasarnya, DIP adalah paradigma pemrograman sederhana - namun kuat - yang dapat kita gunakan untuk mengimplementasikan komponen perangkat lunak yang terstruktur dengan baik, sangat terpisah, dan dapat digunakan kembali .

Dalam tutorial ini, kita akan menjelajahi berbagai pendekatan untuk mengimplementasikan DIP - satu di Java 8, dan satu di Java 11 menggunakan JPMS (Java Platform Module System).

2. Injeksi Ketergantungan dan Inversi Pengendalian Bukan Penerapan DIP

Pertama dan terpenting, mari kita buat perbedaan mendasar untuk mendapatkan dasar yang benar: DIP bukanlah injeksi ketergantungan (DI) atau inversi kontrol (IoC) . Meski begitu, mereka semua bekerja sama dengan baik.

Sederhananya, DI adalah tentang membuat komponen perangkat lunak untuk secara eksplisit menyatakan dependensi atau kolaboratornya melalui API mereka, alih-alih mendapatkannya sendiri.

Tanpa DI, komponen perangkat lunak digabungkan erat satu sama lain. Karenanya, mereka sulit untuk digunakan kembali, diganti, dipermainkan dan diuji, yang menghasilkan desain yang kaku.

Dengan DI, tanggung jawab untuk menyediakan dependensi komponen dan grafik objek kabel ditransfer dari komponen ke kerangka kerja injeksi yang mendasarinya. Dari perspektif itu, DI hanyalah salah satu cara untuk mencapai IoC.

Di sisi lain, IoC adalah pola di mana kontrol aliran aplikasi dibalik . Dengan metodologi pemrograman tradisional, kode kustom kami memiliki kontrol aliran aplikasi. Sebaliknya, dengan IoC, kontrol ditransfer ke kerangka kerja atau wadah eksternal .

Kerangka kerja ini adalah basis kode yang dapat diperpanjang, yang mendefinisikan titik kait untuk memasukkan kode kita sendiri .

Pada gilirannya, kerangka kerja memanggil kembali kode kami melalui satu atau beberapa subkelas khusus, menggunakan implementasi antarmuka, dan melalui anotasi. Framework Spring adalah contoh bagus dari pendekatan terakhir ini.

3. Dasar-dasar DIP

Untuk memahami motivasi di balik DIP, mari kita mulai dengan definisi formalnya, yang diberikan oleh Robert C. Martin dalam bukunya, Agile Software Development: Principles, Patterns, and Practices :

  1. Modul tingkat tinggi tidak harus bergantung pada modul tingkat rendah. Keduanya harus bergantung pada abstraksi.
  2. Abstraksi tidak harus bergantung pada detail. Detail harus bergantung pada abstraksi.

Jadi, jelas bahwa pada intinya, DIP adalah tentang membalikkan ketergantungan klasik antara komponen level tinggi dan level rendah dengan mengabstraksi interaksi di antara mereka .

Dalam pengembangan perangkat lunak tradisional, komponen tingkat tinggi bergantung pada komponen tingkat rendah. Dengan demikian, sulit untuk menggunakan kembali komponen tingkat tinggi.

3.1. Pilihan Desain dan DIP

Mari pertimbangkan kelas StringProcessor sederhana yang mendapatkan nilai String menggunakan komponen StringReader , dan menulisnya di tempat lain menggunakan komponen StringWriter :

public class StringProcessor { private final StringReader stringReader; private final StringWriter stringWriter; public StringProcessor(StringReader stringReader, StringWriter stringWriter) { this.stringReader = stringReader; this.stringWriter = stringWriter; } public void printString() { stringWriter.write(stringReader.getValue()); } } 

Meskipun implementasi kelas StringProcessor bersifat dasar, ada beberapa pilihan desain yang dapat kita buat di sini.

Mari kita uraikan setiap pilihan desain menjadi beberapa item terpisah, untuk memahami dengan jelas bagaimana masing-masing dapat memengaruhi keseluruhan desain:

  1. StringReader dan StringWriter , komponen tingkat rendah, adalah kelas konkret yang ditempatkan dalam paket yang sama. StringProcessor , komponen tingkat tinggi ditempatkan dalam paket yang berbeda. StringProcessor bergantung pada StringReader dan StringWriter . Tidak ada inversi dependensi, oleh karena itu StringProcessor tidak dapat digunakan kembali dalam konteks yang berbeda.
  2. StringReader dan StringWriter adalah antarmuka yang ditempatkan dalam paket yang sama bersama dengan implementasinya . StringProcessor sekarang bergantung pada abstraksi, tetapi komponen tingkat rendah tidak. Kami belum mencapai inversi dependensi.
  3. StringReader dan StringWriter adalah antarmuka yang ditempatkan dalam paket yang sama bersama-sama dengan StringProcessor . Sekarang, StringProcessor memiliki kepemilikan eksplisit atas abstraksi. StringProcessor, StringReader, dan StringWriter semuanya bergantung pada abstraksi. Kami telah mencapai inversi dependensi dari atas ke bawah dengan mengabstraksi interaksi antara komponen . StringProcessor sekarang dapat digunakan kembali dalam konteks yang berbeda.
  4. StringReader dan StringWriter adalah antarmuka yang ditempatkan dalam paket terpisah dari StringProcessor . Kami mencapai inversi dependensi, dan juga lebih mudah untuk mengganti implementasi StringReader dan StringWriter . StringProcessor juga dapat digunakan kembali dalam konteks yang berbeda.

Dari semua skenario di atas, hanya item 3 dan 4 yang merupakan implementasi DIP yang valid.

3.2. Mendefinisikan Kepemilikan Abstraksi

Item 3 adalah implementasi DIP langsung, di mana komponen tingkat tinggi dan abstraksi ditempatkan dalam paket yang sama. Oleh karena itu, komponen tingkat tinggi memiliki abstraksi . Dalam implementasi ini, komponen tingkat tinggi bertanggung jawab untuk menentukan protokol abstrak yang digunakan untuk berinteraksi dengan komponen tingkat rendah.

Demikian juga, item 4 adalah implementasi DIP yang lebih dipisahkan. Dalam varian pola ini, baik komponen tingkat tinggi maupun yang tingkat rendah tidak memiliki abstraksi .

Abstraksi ditempatkan dalam lapisan terpisah, yang memfasilitasi peralihan komponen tingkat rendah. Pada saat yang sama, semua komponen diisolasi satu sama lain, yang menghasilkan enkapsulasi yang lebih kuat.

3.3. Memilih Tingkat Abstraksi yang Tepat

Dalam kebanyakan kasus, memilih abstraksi yang akan digunakan komponen tingkat tinggi seharusnya cukup mudah, tetapi dengan satu peringatan yang perlu diperhatikan: tingkat abstraksi.

Pada contoh di atas, kami menggunakan DI untuk memasukkan tipe StringReader ke dalam kelas StringProcessor . Ini akan efektif selama level abstraksi StringReader dekat dengan domain StringProcessor .

Sebaliknya, kita hanya akan kehilangan manfaat intrinsik DIP jika StringReader , misalnya, adalah objek File yang membaca nilai String dari sebuah file. Dalam hal ini, level abstraksi StringReader akan jauh lebih rendah daripada level domain StringProcessor .

Sederhananya, tingkat abstraksi yang akan digunakan komponen tingkat tinggi untuk beroperasi dengan komponen tingkat rendah harus selalu dekat dengan domain sebelumnya .

4. Implementasi Java 8

Kami sudah melihat secara mendalam konsep-konsep utama DIP, jadi sekarang kami akan menjelajahi beberapa implementasi praktis dari pola di Java 8.

4.1. Penerapan DIP langsung

Let's create a demo application that fetches some customers from the persistence layer and processes them in some additional way.

The layer's underlying storage is usually a database, but to keep the code simple, here we'll use a plain Map.

Let's start by defining the high-level component:

public class CustomerService { private final CustomerDao customerDao; // standard constructor / getter public Optional findById(int id) { return customerDao.findById(id); } public List findAll() { return customerDao.findAll(); } }

As we can see, the CustomerService class implements the findById() and findAll() methods, which fetch customers from the persistence layer using a simple DAO implementation. Of course, we could've encapsulated more functionality in the class, but let's keep it like this for simplicity's sake.

In this case, the CustomerDao type is the abstraction that CustomerService uses for consuming the low-level component.

Since this a direct DIP implementation, let's define the abstraction as an interface in the same package of CustomerService:

public interface CustomerDao { Optional findById(int id); List findAll(); } 

By placing the abstraction in the same package of the high-level component, we're making the component responsible for owning the abstraction. This implementation detail is what really inverts the dependency between the high-level component and the low-level one.

In addition, the level of abstraction of CustomerDao is close to the one of CustomerService, which is also required for a good DIP implementation.

Now, let's create the low-level component in a different package. In this case, it's just a basic CustomerDao implementation:

public class SimpleCustomerDao implements CustomerDao { // standard constructor / getter @Override public Optional findById(int id) { return Optional.ofNullable(customers.get(id)); } @Override public List findAll() { return new ArrayList(customers.values()); } }

Finally, let's create a unit test to check the CustomerService class' functionality:

@Before public void setUpCustomerServiceInstance() { var customers = new HashMap(); customers.put(1, new Customer("John")); customers.put(2, new Customer("Susan")); customerService = new CustomerService(new SimpleCustomerDao(customers)); } @Test public void givenCustomerServiceInstance_whenCalledFindById_thenCorrect() { assertThat(customerService.findById(1)).isInstanceOf(Optional.class); } @Test public void givenCustomerServiceInstance_whenCalledFindAll_thenCorrect() { assertThat(customerService.findAll()).isInstanceOf(List.class); } @Test public void givenCustomerServiceInstance_whenCalledFindByIdWithNullCustomer_thenCorrect() { var customers = new HashMap(); customers.put(1, null); customerService = new CustomerService(new SimpleCustomerDao(customers)); Customer customer = customerService.findById(1).orElseGet(() -> new Customer("Non-existing customer")); assertThat(customer.getName()).isEqualTo("Non-existing customer"); }

The unit test exercises the CustomerService API. And, it also shows how to manually inject the abstraction into the high-level component. In most cases, we'd use some kind of DI container or framework to accomplish this.

Additionally, the following diagram shows the structure of our demo application, from a high-level to a low-level package perspective:

4.2. Alternative DIP Implementation

As we discussed before, it's possible to use an alternative DIP implementation, where we place the high-level components, the abstractions, and the low-level ones in different packages.

For obvious reasons, this variant is more flexible, yields better encapsulation of the components, and makes it easier to replace the low-level components.

Of course, implementing this variant of the pattern boils down to just placing CustomerService, MapCustomerDao, and CustomerDao in separate packages.

Therefore, a diagram is sufficient for showing how each component is laid out with this implementation:

5. Java 11 Modular Implementation

It's fairly easy to refactor our demo application into a modular one.

This is a really nice way to demonstrate how the JPMS enforces best programming practices, including strong encapsulation, abstraction, and component reuse through the DIP.

We don't need to reimplement our sample components from scratch. Hence, modularizing our sample application is just a matter of placing each component file in a separate module, along with the corresponding module descriptor.

Here's how the modular project structure will look:

project base directory (could be anything, like dipmodular) |- com.baeldung.dip.services module-info.java   |- com |- baeldung |- dip |- services CustomerService.java |- com.baeldung.dip.daos module-info.java   |- com |- baeldung |- dip |- daos CustomerDao.java |- com.baeldung.dip.daoimplementations module-info.java |- com |- baeldung |- dip |- daoimplementations SimpleCustomerDao.java |- com.baeldung.dip.entities module-info.java |- com |- baeldung |- dip |- entities Customer.java |- com.baeldung.dip.mainapp module-info.java |- com |- baeldung |- dip |- mainapp MainApplication.java 

5.1. The High-Level Component Module

Let's start by placing the CustomerService class in its own module.

We'll create this module in the root directory com.baeldung.dip.services, and add the module descriptor, module-info.java:

module com.baeldung.dip.services { requires com.baeldung.dip.entities; requires com.baeldung.dip.daos; uses com.baeldung.dip.daos.CustomerDao; exports com.baeldung.dip.services; }

For obvious reasons, we won't go into the details on how the JPMS works. Even so, it's clear to see the module dependencies just by looking at the requires directives.

The most relevant detail worth noting here is the uses directive. It states that the module is a client module that consumes an implementation of the CustomerDao interface.

Of course, we still need to place the high-level component, the CustomerService class, in this module. So, within the root directory com.baeldung.dip.services, let's create the following package-like directory structure: com/baeldung/dip/services.

Finally, let's place the CustomerService.java file in that directory.

5.2. The Abstraction Module

Likewise, we need to place the CustomerDao interface in its own module. Therefore, let's create the module in the root directory com.baeldung.dip.daos, and add the module descriptor:

module com.baeldung.dip.daos { requires com.baeldung.dip.entities; exports com.baeldung.dip.daos; }

Now, let's navigate to the com.baeldung.dip.daos directory and create the following directory structure: com/baeldung/dip/daos. Let's place the CustomerDao.java file in that directory.

5.3. The Low-Level Component Module

Logically, we need to put the low-level component, SimpleCustomerDao, in a separate module, too. As expected, the process looks very similar to what we just did with the other modules.

Let's create the new module in the root directory com.baeldung.dip.daoimplementations, and include the module descriptor:

module com.baeldung.dip.daoimplementations { requires com.baeldung.dip.entities; requires com.baeldung.dip.daos; provides com.baeldung.dip.daos.CustomerDao with com.baeldung.dip.daoimplementations.SimpleCustomerDao; exports com.baeldung.dip.daoimplementations; }

In a JPMS context, this is a service provider module, since it declares the provides and with directives.

In this case, the module makes the CustomerDao service available to one or more consumer modules, through the SimpleCustomerDao implementation.

Let's keep in mind that our consumer module, com.baeldung.dip.services, consumes this service through the uses directive.

This clearly shows how simple it is to have a direct DIP implementation with the JPMS, by just defining consumers, service providers, and abstractions in different modules.

Likewise, we need to place the SimpleCustomerDao.java file in this new module. Let's navigate to the com.baeldung.dip.daoimplementations directory, and create a new package-like directory structure with this name: com/baeldung/dip/daoimplementations.

Finally, let's place the SimpleCustomerDao.java file in the directory.

5.4. The Entity Module

Additionally, we have to create another module where we can place the Customer.java class. As we did before, let's create the root directory com.baeldung.dip.entities and include the module descriptor:

module com.baeldung.dip.entities { exports com.baeldung.dip.entities; }

In the package's root directory, let's create the directory com/baeldung/dip/entities and add the following Customer.java file:

public class Customer { private final String name; // standard constructor / getter / toString }

5.5. The Main Application Module

Next, we need to create an additional module that allows us to define our demo application's entry point. Therefore, let's create another root directory com.baeldung.dip.mainapp and place in it the module descriptor:

module com.baeldung.dip.mainapp { requires com.baeldung.dip.entities; requires com.baeldung.dip.daos; requires com.baeldung.dip.daoimplementations; requires com.baeldung.dip.services; exports com.baeldung.dip.mainapp; }

Now, let's navigate to the module's root directory, and create the following directory structure: com/baeldung/dip/mainapp. In that directory, let's add a MainApplication.java file, which simply implements a main() method:

public class MainApplication { public static void main(String args[]) { var customers = new HashMap(); customers.put(1, new Customer("John")); customers.put(2, new Customer("Susan")); CustomerService customerService = new CustomerService(new SimpleCustomerDao(customers)); customerService.findAll().forEach(System.out::println); } }

Terakhir, mari kita kompilasi dan jalankan aplikasi demo - baik dari dalam IDE kita atau dari konsol perintah.

Seperti yang diharapkan, kita akan melihat daftar objek Pelanggan yang dicetak ke konsol saat aplikasi dimulai:

Customer{name=John} Customer{name=Susan} 

Selain itu, diagram berikut menunjukkan dependensi setiap modul aplikasi:

6. Kesimpulan

Dalam tutorial ini, kami mendalami konsep-konsep kunci DIP, dan kami juga menunjukkan implementasi pola yang berbeda di Java 8 dan Java 11 , dengan yang terakhir menggunakan JPMS.

Semua contoh untuk implementasi Java 8 DIP dan implementasi Java 11 tersedia di GitHub.