Command Palette

Search for a command to run...

[Spring Boot Basics] IoC và Dependency Injection trong Spring: vì sao không còn tự new object

Bài này mở đầu Chương 1, và là bài mà toàn bộ phần còn lại của series đứng lên. Về bản chất, Spring là một container dựng object hộ bạn. Mọi thứ khác — stereotype, auto-configuration, transaction, security — đều xây trên đúng một sự thật đó. Nếu sự thật đó không thấm, phần còn lại chỉ còn là một danh sách annotation để học thuộc và không debug nổi.

Nên đây không phải bài dạo qua các annotation. Đây là một object graph nhỏ, được viết ba lần: tự new ở mọi tầng, rồi nối tay trong main mà không có framework nào, rồi để Spring nối. Mọi đoạn code bên dưới đều đã được compile và chạy trên OpenJDK 21.0.6 với Spring Boot 4.1.1 (Spring Framework 7.0.9) và Gradle 9.7.1; mọi output, stack trace, thông báo của compiler và test report đều copy thẳng từ terminal.

Từ khóa new bị gạch chéo, và một class nhận collaborator từ bên ngoài

Ba phiên bản nằm trong ba package của cùng một project — com.example.demo.selfbuilt, com.example.demo.wiredcom.example.demo.order — để bạn giữ được cả ba cạnh nhau và chạy cái nào cũng được.

Một object graph dựng bằng new

Bốn class, gần như là graph nhỏ nhất mà vẫn còn thành thật với một ứng dụng thật. Controller cần service, service cần repository, repository cần config, và config lấy từ environment.

DataSourceConfig.java
public class DataSourceConfig {
 
    private final String url;
 
    public DataSourceConfig() {
        String value = System.getenv("ORDERS_DB_URL");
        if (value == null) {
            throw new IllegalStateException("ORDERS_DB_URL is not set");
        }
        this.url = value;
        System.out.println("    built DataSourceConfig -> " + value);
    }
 
    public String url() {
        return url;
    }
}
JdbcOrderRepository.java
public class JdbcOrderRepository {
 
    private final DataSourceConfig config = new DataSourceConfig();
 
    public JdbcOrderRepository() {
        System.out.println("    built JdbcOrderRepository on " + config.url());
    }
 
    public List<Order> findByCustomer(String customer) {
        // a real SELECT against config.url() would go here
        return List.of(new Order("A-1", customer, 2500), new Order("A-2", customer, 1750));
    }
}
OrderService.java
public class OrderService {
 
    private final JdbcOrderRepository repository = new JdbcOrderRepository();
 
    public OrderService() {
        System.out.println("    built OrderService");
    }
 
    public long totalFor(String customer) {
        return repository.findByCustomer(customer).stream().mapToLong(Order::amountCents).sum();
    }
}
OrderController.java
public class OrderController {
 
    private final OrderService service = new OrderService();
 
    public OrderController() {
        System.out.println("    built OrderController");
    }
 
    public String total(String customer) {
        return customer + " owes " + service.totalFor(customer) + " cents";
    }
}

Không có gì lạ ở đây cả. Đây đúng là kiểu code gần như ai cũng viết đầu tiên, và nó chạy được:

Main.java
public class Main {
    public static void main(String[] args) {
        System.out.println("asking for one controller:");
        OrderController controller = new OrderController();
        System.out.println(controller.total("ada"));
    }
}
Text
asking for one controller:
    built DataSourceConfig -> jdbc:postgresql://localhost:5432/orders
    built JdbcOrderRepository on jdbc:postgresql://localhost:5432/orders
    built OrderService
    built OrderController
ada owes 4250 cents

Bản thân output đó đã là toàn bộ vấn đề, in ra thành chữ. Đọc lại một lần nữa: chương trình xin một object và nhận về bốn. Ba hậu quả đi theo, và không cái nào là chuyện khẩu vị.

Dựng đỉnh graph là dựng toàn bộ graph

new OrderController() là một statement, và nó vừa dựng ra một database config. Controller không cần DataSourceConfig — nó không nhắc tới, không import, thậm chí không gọi tên được — vậy mà nó không tồn tại nổi nếu thiếu cái đó. Dependency này không được khai báo ở đâu cả; nó bị hàn cứng vào, cách đó ba tầng.

Bỏ environment variable đi thì mối hàn hiện nguyên hình dưới dạng stack trace:

Text
asking for one controller:
Exception in thread "main" java.lang.IllegalStateException: ORDERS_DB_URL is not set
	at com.example.demo.selfbuilt.DataSourceConfig.<init>(DataSourceConfig.java:10)
	at com.example.demo.selfbuilt.JdbcOrderRepository.<init>(JdbcOrderRepository.java:7)
	at com.example.demo.selfbuilt.OrderService.<init>(OrderService.java:5)
	at com.example.demo.selfbuilt.OrderController.<init>(OrderController.java:5)
	at com.example.demo.selfbuilt.Main.main(Main.java:6)

Bốn frame <init> cho một chữ new. Thứ tự dựng object đã rò ra khỏi từng class và chui vào graph của các type: cách duy nhất để OrderController tồn tại là làm cho toàn bộ nhánh bên dưới nó thành công trước, đúng thứ tự đó, và mọi người gọi đều phải trả cái giá ấy dù có cần tới đáy graph hay không.

Đổi implementation nghĩa là phải sửa chính class đang dùng nó

Giả sử bạn muốn một repository chạy trong bộ nhớ — cho test, cho demo, cho một khách hàng không có Postgres. Viết ra:

InMemoryOrderRepository.java
public class InMemoryOrderRepository {
 
    public List<Order> findByCustomer(String customer) {
        return List.of(new Order("M-1", customer, 999));
    }
}

rồi thử đưa nó vào service:

Java
OrderService service = new OrderService(new InMemoryOrderRepository());
Text
SwapAttempt.java:5: error: constructor OrderService in class OrderService cannot be applied to given types;
        OrderService service = new OrderService(new InMemoryOrderRepository());
                               ^
  required: no arguments
  found:    InMemoryOrderRepository
  reason: actual and formal argument lists differ in length
1 error

Không có đường vào. OrderService không chừa một khe hở nào để một repository khác đi vào được, bởi vì chính nó đã tự quyết định repository của nó là cái gì. Cách sửa duy nhất là sửa OrderService — một class mà bạn không hề muốn đổi hành vi — và mỗi người dùng muốn một lựa chọn khác lại ép thêm một lần sửa vào đúng file đó. Hai nơi gọi với hai nhu cầu khác nhau thì không thể cùng được đáp ứng.

Không thể test một method mà không dựng cả graph

totalFor chỉ là một phép cộng trên một list. Đây là một test JUnit 5 hết sức bình thường cho nó:

OrderServiceTest.java
class OrderServiceTest {
 
    @Test
    void totalsTheCustomersOrders() {
        OrderService service = new OrderService();
        assertEquals(4250, service.totalFor("ada"));
    }
}
Text
> Task :test FAILED
 
OrderServiceTest > totalsTheCustomersOrders() FAILED
    java.lang.IllegalStateException at OrderServiceTest.java:11
 
1 test completed, 1 failed

Report nói rõ chuyện gì đã xảy ra:

Text
java.lang.IllegalStateException: ORDERS_DB_URL is not set
	at com.example.demo.selfbuilt.DataSourceConfig.<init>(DataSourceConfig.java:10)
	at com.example.demo.selfbuilt.JdbcOrderRepository.<init>(JdbcOrderRepository.java:7)
	at com.example.demo.selfbuilt.OrderService.<init>(OrderService.java:5)
	at com.example.demo.selfbuilt.OrderServiceTest.totalsTheCustomersOrders(OrderServiceTest.java:11)

Một test cho phép cộng lại fail vì thiếu database URL. Đó là cái giá khi hai vấn đề đầu tiên đến cùng lúc: muốn chạm tới một method thì phải dựng nguyên graph, mà graph thì không thương lượng được. Trong codebase thật, đây chính là lúc ai đó dựng một database cho test, hoặc một Docker container, hoặc gắn @SpringBootTest lên một unit test — một đống máy móc mua về chỉ để bù cho một chữ new.

Vẫn bốn object đó vẽ hai lần: lồng vào nhau, và tách rời nhận qua constructor

Inversion of Control là nguyên tắc

Cách sửa có tên hẳn hoi, và rất đáng tách cái tên ra khỏi cách hiện thực nó, vì phần lớn tutorial trộn cả hai thành một chữ.

Inversion of Control là một nguyên tắc thiết kế chung: luồng điều khiển chuyển từ code của bạn sang framework. Bình thường chương trình của bạn là người gọi — bắt đầu ở main, quyết định dựng cái gì, gọi vào library, và điều khiển toàn bộ tiến trình từ đầu tới cuối. Với Inversion of Control, một thứ khác cầm lái, còn code của bạn là thứ bị gọi. Đây chính là Hollywood Principle: đừng gọi chúng tôi, chúng tôi sẽ gọi bạn.

Thật ra bạn đã sống trong nguyên tắc này từ lâu. Servlet container gọi doGet của bạn; JUnit gọi các method @Test của bạn; cái Comparator bạn truyền vào sort là do sort gọi, không phải bạn gọi. Trong mọi trường hợp, bạn đưa code ra và một thứ khác quyết định khi nào nó chạy.

Cái mà Spring đảo ngược thì hẹp và cụ thể hơn: việc dựng object và việc tìm object. Bạn không còn viết code dựng object và đi tìm collaborator nữa. Container dựng chúng, đưa chúng cho nhau, rồi gọi ngược vào class của bạn khi có việc cần làm.

main của bạn điều khiển việc dựng object, so với container điều khiển và gọi ngược vào code của bạn

Có hai điều rút ra từ bức hình mà rất dễ bỏ sót.

Inversion of Control rộng hơn Spring và rộng hơn dependency injection. Một template method gọi vào phần override của bạn, một event loop gọi handler của bạn, một build tool gọi task của bạn — tất cả đều là inversion of control mà chẳng có container nào cả.

Và Inversion of Control là một nguyên tắc, không phải một cơ chế. Nó nói điều gì cần đúng, chứ không nói làm thế nào để đạt được. Cơ chế nằm ở phần sau.

Dependency Injection là kỹ thuật

Dependency Injection là kỹ thuật cụ thể hiện thực Inversion of Control cho việc dựng object: một object nhận collaborator từ bên ngoài thay vì tự tạo ra chúng. Chỉ có vậy thôi. Martin Fowler đặt ra cái tên này năm 2004 đúng vì "Inversion of Control" quá rộng, không nói rõ được người ta đang muốn đảo ngược cái gì.

Trong code, mỗi class chỉ đổi đúng một chỗ — field thôi không còn tự khởi tạo mà trở thành parameter:

OrderService.java
public class OrderService {
 
    private final OrderRepository repository;
 
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
 
    public long totalFor(String customer) {
        return repository.findByCustomer(customer).stream().mapToLong(Order::amountCents).sum();
    }
}

Ba thứ đã thay đổi, và mỗi thứ đều đáng giá.

Type của field giờ là interface OrderRepository, nên service gắn với một khả năng chứ không gắn với một class cụ thể. Collaborator đến qua constructor parameter, nên dependency trở thành một phần của signature công khai — bạn không còn dựng được OrderService mà giấu đi chuyện nó cần một repository. Và vì parameter chỉ gán một lần, field có thể để final, nên object hoặc là hoàn chỉnh ngay lúc tồn tại, hoặc là không tồn tại.

Cái constructor parameter đó là một chỗ nối (seam): một điểm mà object graph có thể tháo ra và lắp lại theo cách khác. Mọi thứ hay ho trong phần còn lại của bài đều đến từ việc chỗ nối đó tồn tại.

Có ba nơi một object có thể nhận dependency — constructor, setter và chính field — và nên chọn cái nào là một tranh luận thật sự có câu trả lời đúng. Đó là chủ đề của bài sau nữa; toàn bộ bài này chỉ dùng constructor injection và không dùng gì khác.

Nối cùng graph đó bằng tay, không dùng framework

Trước khi Spring xuất hiện, hãy làm bước trung gian, vì đây mới là bước làm ý tưởng thấm. Cho cả bốn class nhận dependency qua constructor, rồi tự viết phần nối:

Assembly.java
public class Assembly {
 
    public static void main(String[] args) {
        System.out.println("assembling by hand:");
 
        String url = System.getenv().getOrDefault("ORDERS_DB_URL", "jdbc:postgresql://localhost:5432/orders");
 
        DataSourceConfig config = new DataSourceConfig(url);
        OrderRepository repository = new JdbcOrderRepository(config);
        OrderService service = new OrderService(repository);
        OrderController controller = new OrderController(service);
 
        System.out.println(controller.total("ada"));
    }
}

Compile bằng đúng javac và chạy bằng đúng java:

Bash
javac -d out $(find src/main/java/com/example/demo/wired -name '*.java')
java -cp out com.example.demo.wired.Assembly
Text
assembling by hand:
    built DataSourceConfig -> jdbc:postgresql://localhost:5432/orders
    built JdbcOrderRepository on jdbc:postgresql://localhost:5432/orders
    built OrderService with JdbcOrderRepository
    built OrderController
ada owes 4250 cents

Classpath là tám file class, không một dòng framework nào:

Text
out/com/example/demo/wired/Assembly.class
out/com/example/demo/wired/DataSourceConfig.class
out/com/example/demo/wired/InMemoryOrderRepository.class
out/com/example/demo/wired/JdbcOrderRepository.class
out/com/example/demo/wired/Order.class
out/com/example/demo/wired/OrderController.class
out/com/example/demo/wired/OrderRepository.class
out/com/example/demo/wired/OrderService.class

Hãy nhìn xem những gì đã được sửa. Chỉ mỗi main biết hình dạng của graph, nên không class nào dựng thứ mà nó không dùng. OrderService nhận được bất kỳ OrderRepository nào, nên test được mà không cần database. Đổi implementation là một dòng, trong một file, và không class nào đang dùng repository bị đụng tới:

Java
OrderRepository repository = new InMemoryOrderRepository();
OrderService service = new OrderService(repository);
OrderController controller = new OrderController(service);
Text
assembling by hand:
    built InMemoryOrderRepository (no database)
    built OrderService with InMemoryOrderRepository
    built OrderController
ada owes 999 cents

Mọi lợi ích của bài này đều đã có mặt ở đây, mà Spring còn chưa được nhắc tới. Dependency injection là một kỹ thuật thiết kế, không phải một tính năng của framework. Cái còn lại chỉ đúng một thứ: main này có bốn dòng cho bốn object, còn ứng dụng thật có hàng trăm object, theo một thứ tự dependency mà không ai muốn bảo trì bằng tay, và chỉ cần chèn thêm một collaborator là phải sửa lại phần lắp ráp của tất cả những gì nằm sau nó.

Đúng cái sự lặt vặt đó là toàn bộ công việc của container. Spring không phải là ý tưởng; Spring là cái máy gõ hộ bạn.

Vẫn graph đó, để Spring nối

Vẫn bốn class đó, mỗi class thêm một annotation và không một dòng nối nào:

DataSourceConfig.java
@Component
public class DataSourceConfig {
 
    private final String url;
 
    public DataSourceConfig() {
        this.url = System.getenv().getOrDefault("ORDERS_DB_URL", "jdbc:postgresql://localhost:5432/orders");
    }
 
    public String url() {
        return url;
    }
}
JdbcOrderRepository.java
@Repository
public class JdbcOrderRepository implements OrderRepository {
 
    private final DataSourceConfig config;
 
    public JdbcOrderRepository(DataSourceConfig config) {
        this.config = config;
    }
 
    @Override
    public List<Order> findByCustomer(String customer) {
        return List.of(new Order("A-1", customer, 2500), new Order("A-2", customer, 1750));
    }
}
OrderService.java
@Service
public class OrderService {
 
    private final OrderRepository repository;
 
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
 
    public long totalFor(String customer) {
        return repository.findByCustomer(customer).stream().mapToLong(Order::amountCents).sum();
    }
}
OrderController.java
@RestController
public class OrderController {
 
    private final OrderService service;
 
    public OrderController(OrderService service) {
        this.service = service;
    }
 
    @GetMapping("/orders/{customer}/total")
    public String total(@PathVariable String customer) {
        return customer + " owes " + service.totalFor(customer) + " cents";
    }
}

Bốn annotation đánh dấu những class mà container được phép dựng; mỗi cái nghĩa là gì và chúng được tìm ra bằng cách nào là chủ đề của bài sau, còn quy tắc duy nhất bạn cần hôm nay là quy tắc của bài trước — chúng phải nằm dưới package của class @SpringBootApplication. Cũng để ý thứ không có ở đây: không có @Autowired. Từ Spring Framework 4.3, class chỉ có đúng một constructor thì constructor đó được dùng để inject một cách tự động. Annotation đó, và chuyện gì xảy ra khi container phải chọn giữa nhiều ứng viên, thuộc về bài sau nữa.

Ở đây DataSourceConfig vẫn tự đọc environment; chuyển việc đó sang phần config là nhiệm vụ của @Configuration@Bean, hai bài nữa.

Chứng minh container thật sự đã nối

Nói suông rằng Spring nối hộ thì dễ. Chứng minh nó chỉ tốn khoảng một dòng mỗi class. Mỗi constructor in ra identity của chính nó và identity của thứ nó nhận được:

Java
public OrderService(OrderRepository repository) {
    this.repository = repository;
    System.out.println("    OrderService#" + Integer.toHexString(System.identityHashCode(this))
            + " built, received " + repository.getClass().getSimpleName()
            + "#" + Integer.toHexString(System.identityHashCode(repository)));
}
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8085
Text
o.s.boot.tomcat.TomcatWebServer          : Tomcat initialized with port 8085 (http)
b.w.c.s.WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 271 ms
    DataSourceConfig#57402ba1 built, url=jdbc:postgresql://localhost:5432/orders
    JdbcOrderRepository#5c534b5b built, received DataSourceConfig#57402ba1
    OrderService#14229fa7 built, received JdbcOrderRepository#5c534b5b
    OrderController#7158daf2 built, received OrderService#14229fa7
o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8085 (http)
com.example.demo.DemoApplication         : Started DemoApplication in 0.56 seconds

Hãy đọc dãy identity đó theo chiều dọc. DataSourceConfig#57402ba1 được dựng trước; repository báo là nhận đúng instance đó; service báo nhận đúng repository mà dòng trên vừa dựng; controller báo nhận đúng service đó. Container đã dựng graph từ dưới lên, theo thứ tự dependency, và đưa từng object vào object kế tiếp — đúng bốn dòng mà Assembly.main đã chạy, chỉ khác là không ai phải gõ chúng.

Có ba chi tiết trong log đáng gọi tên. Thứ tự là thứ tự dependency, không phải thứ tự trong source hay thứ tự alphabet: Spring tự suy ra mỗi constructor cần gì rồi dựng cái đó trước. Toàn bộ chuyện này xảy ra lúc khởi động, trước khi port mở, nên tới lúc một request có thể tới thì graph đã xong. Và repository.getClass().getSimpleName() in ra JdbcOrderRepository, không phải một cái tên $Proxy nào — thứ được inject vào là object trần, không phải một lớp bọc.

Endpoint chạy được, và đó là phần ít thú vị nhất trong tất cả những thứ trên:

Bash
curl -s http://localhost:8085/orders/ada/total
Text
ada owes 4250 cents

Dependency injection thật sự đem lại gì

Ba cái lợi, cái nào cũng được chứng minh chứ không chỉ tuyên bố.

Testability: một stub cắm vào chỗ nối constructor

Chỗ nối nhận bất cứ thứ gì implement interface, nên test có thể đưa vào bảy dòng Java thay vì một database:

OrderServiceTest.java
class OrderServiceTest {
 
    /** A stub repository: seven lines, no database, no Spring. */
    static class StubOrderRepository implements OrderRepository {
        @Override
        public List<Order> findByCustomer(String customer) {
            return List.of(new Order("S-1", customer, 1000), new Order("S-2", customer, 250));
        }
    }
 
    @Test
    void totalsTheCustomersOrders() {
        OrderService service = new OrderService(new StubOrderRepository());
        assertEquals(1250, service.totalFor("ada"));
    }
 
    @Test
    void totalsZeroWhenTheCustomerHasNoOrders() {
        OrderService service = new OrderService(customer -> List.of());
        assertEquals(0, service.totalFor("nobody"));
    }
}
Text
> Task :test
 
OrderServiceTest > totalsTheCustomersOrders() PASSED
 
OrderServiceTest > totalsZeroWhenTheCustomerHasNoOrders() PASSED
 
BUILD SUCCESSFUL in 979ms

Ở đây không có @SpringBootTest, không có context, không có annotation nào của framework ngoài @Test của chính JUnit — hai test cộng lại mất 0.013 đến 0.022 giây thời gian test qua nhiều lần chạy, con số này mang tính tham khảo chứ không phải benchmark. Test thứ hai là cùng một ý đẩy tới tận cùng: vì OrderRepository chỉ có một method, nó là một functional interface, và stub có thể viết luôn bằng lambda.

Hãy so với phiên bản của chính test này ở phần đầu bài, cái cần một database URL để tính 1000 + 250.

Cùng một service với chuỗi repository thật cắm vào, so với một stub tự viết cắm vào đúng chỗ nối đó

Đổi implementation mà không đụng vào class đang dùng nó

Viết implementation thứ hai của cùng interface rồi gắn annotation cho nó — vẫn với constructor in identity như các class kia, ở đây lược bớt cho gọn:

InMemoryOrderRepository.java
@Repository
public class InMemoryOrderRepository implements OrderRepository {
 
    @Override
    public List<Order> findByCustomer(String customer) {
        return List.of(new Order("M-1", customer, 999));
    }
}

Rồi bỏ @Repository khỏi JdbcOrderRepository để chỉ còn đúng một ứng viên, và khởi động lại. OrderService.java không hề được mở ra — checksum trước và sau vẫn là cùng một file:

Bash
md5 -q src/main/java/com/example/demo/order/OrderService.java
Text
ed6d5783fbb7c6c5d4dabe69031a7919
ed6d5783fbb7c6c5d4dabe69031a7919
Text
    DataSourceConfig#1b9776f5 built, url=jdbc:postgresql://localhost:5432/orders
    InMemoryOrderRepository#1dd7796b built, no database
    OrderService#a18649a built, received InMemoryOrderRepository#1dd7796b
    OrderController#396639b built, received OrderService#a18649a
Bash
curl -s http://localhost:8085/orders/ada/total
Text
ada owes 999 cents

Hành vi của endpoint đã đổi mà không class tiêu thụ nào bị sửa. Để ý dòng đầu của log: DataSourceConfig vẫn còn annotation nên container vẫn dựng nó dù chẳng ai inject nữa — container dựng thứ nó được khai báo, không phải thứ đang có ai cần.

Để cả hai implementation cùng có annotation lại là chuyện khác: hai ứng viên cho một parameter, container không tự quyết được. Giải quyết chuyện đó là việc của @Primary@Qualifier, thuộc bài sau nữa.

Một chỗ duy nhất biết application được lắp ráp thế nào

Ở phiên bản đầu, hiểu biết về việc ứng dụng ghép lại với nhau ra sao bị bôi ra khắp bốn file, mỗi chỗ một chữ new, không có nơi nào để nhìn vào cho đủ. Ở phiên bản nối tay, nó nằm trong Assembly.main, và bạn đọc được toàn bộ kiến trúc chương trình trong bốn dòng. Ở phiên bản Spring, nó nằm trong container, suy ra từ chính các constructor.

Đó là một chuyển dịch thật sự và rất đáng nói thẳng: những mũi tên dependency trong thiết kế của bạn trở thành signature của constructor trong code. OrderService(OrderRepository) không phải tài liệu mô tả một quyết định thiết kế; nó chính là quyết định thiết kế đó, được compiler bắt buộc, đọc được mà không cần chạy gì cả.

Dependency injection phải trả giá bằng gì

Một danh sách thành thật, vì cái nào cũng có thật.

Gián tiếp. repository.findByCustomer(...) không còn cho bạn biết class nào thực sự chạy. Bạn đọc một interface, còn implementation được chọn ở một nơi bạn không nhìn tới. Với project nhỏ thì đây thuần túy là chi phí; nó chỉ hoàn vốn khi thật sự có nhiều hơn một implementation hợp lý, hoặc có một test cần chen vào giữa lời gọi.

Lỗi dời từ lúc compile sang lúc khởi động. Đây là đánh đổi làm người ta bất ngờ nhất, và cả hai nửa đều nhìn thấy được. Với new, nối sai là lỗi compiler như ở phần hai — javac từ chối file. Với container, đúng cái sai đó compile ngon lành:

Bash
./gradlew compileJava
Text
> Task :compileJava
 
BUILD SUCCESSFUL in 355ms

rồi mới fail khi ứng dụng khởi động:

Text
***************************
APPLICATION FAILED TO START
***************************
 
Description:
 
Parameter 0 of constructor in com.example.demo.order.OrderService required a bean of type 'com.example.demo.order.OrderRepository' that could not be found.
 
 
Action:
 
Consider defining a bean of type 'com.example.demo.order.OrderRepository' in your configuration.

Boot đã cố hết sức để thông báo đó dễ đọc, và nó nêu đúng constructor, đúng vị trí parameter và đúng type còn thiếu. Nhưng đó vẫn là một lỗi runtime ở chỗ trước kia bạn có lỗi compile. Điểm bù lại là nó xảy ra lúc khởi động chứ không phải ở request đầu tiên, nên nó fail ngay trong giây đầu của mọi lần chạy test và mọi lần deploy.

⚠️ Lỗi ở trên sinh ra chỉ vì xóa một annotation. Không có gì khác trong code sai cả, và output của compiler không hé lộ một chút nào. Lỗi nối trong container là lỗi cấu hình, và chỉ tìm ra được bằng cách khởi động một thứ gì đó lên.

"Cái bean này ở đâu ra?" Với graph bốn object ở trên thì câu trả lời hiển nhiên. Với một ứng dụng vài trăm object, cộng thêm những gì auto-configuration góp vào, truy ngược xem một instance cụ thể được tạo ở đâu là một việc thật sự, có hẳn công cụ đi kèm. Trả lời được câu hỏi đó chính là thứ mấy bài tới dạy bạn.

Học nó không miễn phí. Dependency injection bắt người mới chấp nhận rằng object xuất hiện từ một nơi họ không nhìn thấy, và điều đó thật sự khó hơn đọc một chữ new. Assembly nối tay ở trên là liều thuốc giải: khi container làm bạn rối, hãy viết bốn dòng đó ra bằng tay, phần lớn cái rối sẽ tự tan.

Và nó không đòi phải có Spring. Package wired đã chứng minh điều đó bằng một classpath tám file. Nếu chỉ lấy được một thứ từ bài này, hãy lấy thứ đó: dependency injection là một cách viết class, còn Spring là cái máy tùy chọn để lo phần lắp ráp. Áp dụng nó cho code sẽ chẳng bao giờ gặp container vẫn hoàn toàn đáng.

Từ vựng cho phần còn lại của chương này

Tám từ dưới đây được dùng liên tục và hiếm khi được định nghĩa. Đây là nghĩa của chúng trong series này.

Thuật ngữNghĩa
Inversion of Control (IoC)Nguyên tắc: framework gọi code của bạn, thay vì code của bạn gọi framework. Rộng hơn Spring — callback, template method và event loop đều là IoC.
Dependency Injection (DI)Kỹ thuật hiện thực IoC cho việc dựng object: một object nhận collaborator từ bên ngoài thay vì tự tạo.
ContainerThứ đứng ra inject. Nó đọc các class của bạn, suy ra mỗi constructor cần gì, dựng object theo thứ tự dependency và giữ chúng. Container của Spring gọi là application context.
BeanMột object do container dựng và quản lý. Khác object thường đúng một điểm: ai là người gọi new.
DependencyThứ một class cần để làm việc được. OrderService phụ thuộc vào OrderRepository. Đừng nhầm với dependency trong build.gradle, cái đó là một file jar.
CollaboratorCùng quan hệ đó nhưng gọi từ phía bên kia: object được inject vào. OrderRepository là collaborator của OrderService.
WiringViệc quyết định object nào được truyền vào constructor nào. Do main làm ở bản nối tay, do container làm ở bản Spring.
Object graphToàn bộ object của bạn cộng với các reference giữa chúng. Ở trên là bốn node ba cạnh; trong một service thật là vài trăm node.

FAQ

IoC và Dependency Injection có phải là một không?

Không, và nên giữ sự phân biệt này. Inversion of Control là nguyên tắc rằng luồng điều khiển đi từ framework vào code của bạn; dependency injection là một kỹ thuật để đạt được điều đó, áp dụng riêng cho chuyện object lấy collaborator ở đâu. Mọi lần dùng DI đều là IoC, nhưng servlet container gọi doGet, JUnit gọi method @Test, hay sort gọi Comparator của bạn đều là IoC mà không có injection nào cả.

Có bắt buộc phải dùng Spring mới làm được dependency injection không?

Không. Package wired trong bài này dùng constructor injection từ đầu tới cuối và classpath của nó là tám file class, không framework nào. DI là một cách thiết kế class; Spring tự động hóa bước lắp ráp khi số lượng object đã nhiều tới mức làm tay thì mệt. Rất nhiều code thiết kế tốt vẫn dùng DI với một composition root viết tay và không container nào.

Vì sao constructor của tôi chạy được mà không cần @Autowired?

Từ Spring Framework 4.3, khi một class có đúng một constructor thì container dùng luôn constructor đó để inject, không cần annotation nào. Có từ hai constructor trở lên thì container không đoán được, và bạn phải đánh dấu cái nào được dùng. Annotation đó cùng các quy tắc của nó là chủ đề của bài sau nữa.

Trong ứng dụng Spring thì gọi new có sai không?

Không — chỉ sai với những thứ đáng lẽ container phải sở hữu. Value object, record, DTO, exception, collection và local variable vẫn được tạo bằng new liên tục và hoàn toàn đúng; new Order("A-1", customer, 2500) xuất hiện ngay trong các đoạn code ở trên. Thứ không nên tạo bằng new là collaborator: một service, một repository, một client, hay bất cứ thứ gì có dependency của riêng nó hoặc sống lâu hơn một lời gọi method.

Vì sao code compile được nhưng khi khởi động lại báo "required a bean of type ... that could not be found"?

Vì compiler kiểm tra type còn container kiểm tra wiring, và hai việc đó chạy ở hai thời điểm khác nhau. Thông báo đó nghĩa là một constructor đòi một type mà không object nào do container quản lý cung cấp được. Nguyên nhân thường gặp là class implementation thiếu annotation stereotype, hoặc class nằm ngoài nhánh package được scan. Bài tiếp theo nói đúng về chuyện này.

Đi qua container có làm ứng dụng chậm hơn không?

Phần việc của container chỉ xảy ra một lần, lúc khởi động: trong log ở trên là bốn object nằm giữa các bean của chính Boot, trong tổng thời gian khởi động 0.56 giây. Sau đó, một collaborator được inject chỉ là một field final giữ một reference bình thường — log cho thấy object được inject in ra là JdbcOrderRepository chứ không phải một class proxy — nên gọi nó tốn đúng bằng gọi một object bất kỳ. Những tính năng xây trên container như transaction hay security thì có thêm proxy, và chúng có cái giá riêng; còn injection thuần thì không.

Kết luận

Một object tự new collaborator của nó là một object đã quyết định thay cho tất cả những ai sẽ dùng nó sau này. Phần demo ở trên cho thấy quyết định đó đắt theo ba cách riêng biệt: một new OrderController() dựng ra bốn object rồi fail vì một database URL mà nó chưa bao giờ hỏi tới; không thể đưa một repository khác vào mà không sửa chính class đang tiêu thụ nó; và test một method cộng số lại cần dựng nguyên graph.

Inversion of Control là nguyên tắc sửa chuyện này — framework gọi bạn, bạn không gọi framework. Dependency injection là kỹ thuật hiện thực nó cho việc dựng object: collaborator đi vào qua constructor, biến mỗi constructor thành một chỗ nối. Assembly nối tay chứng minh rằng chỉ riêng chỗ nối đó đã mua được testability và khả năng thay thế, không cần framework nào. Đóng góp của Spring là làm phần lắp ráp đó cho hàng trăm object thay vì bốn, theo đúng thứ tự dependency, ngay lúc khởi động, và kêu lên khi thiếu một mảnh.

Bài tiếp theo mở chính cái container ra: Bean và ApplicationContext — @Component, @Service, @Repository, @Controller, và component scanning, thứ quyết định class nào của bạn là class mà container từng nghe nói tới.

Bài viết liên quan

[Spring Boot Basics] Gọi API bên ngoài với RestClient trong Spring Boot: GET, POST, xử lý lỗi và timeout

Gọi HTTP API bên ngoài từ Spring Boot 4.1.1 bằng RestClient, kiểm chứng với một stub chạy local: RestClient so với RestTemplate, WebClient và @HttpExchange, spring-boot-starter-restclient và RestClient.Builder được auto-configure, GET vào record và list, toEntity, encode query parameter, POST, PUT và DELETE, message thật của HttpClientErrorException, onStatus, defaultStatusHandler và exchange, connect timeout và read timeout mặc định lẫn khi cấu hình bằng spring.http.clients được đo thực tế, interceptor để log, và chuyển lỗi upstream thành 502, 503 và 504.

[Spring Boot Basics] Bean và ApplicationContext trong Spring: @Component, stereotype và component scanning

Bean trong Spring thực chất là gì và object nào không bao giờ nên là bean, chứng minh @Component là meta-annotation nằm dưới @Service, @Repository và @Controller, mỗi stereotype thật sự thêm gì lúc runtime, component scanning biến file .class thành BeanDefinition ra sao, quy tắc đặt tên bean kể cả trường hợp hai chữ hoa, API của ApplicationContext cùng số bean thật của một app Spring Boot 4.1.1, và ba kiểu đăng ký bean thất bại kèm thông báo lỗi thật.

[Spring Boot Basics] @Configuration và @Bean trong Spring: khi nào dùng thay cho annotation stereotype

Vì sao @Component không đăng ký được một class nằm trong jar của thư viện khác, cách @Bean factory method trên @Configuration class làm thay, bảng quyết định chọn giữa hai cách, và proxyBeanMethods chứng minh bằng identity hash code thật trên Spring Boot 4.1.1 — full mode trả về một singleton dùng chung, lite mode tạo ba object riêng biệt — cùng @Import, static @Bean method và ba lỗi dựng lại kèm message thật.

[Spring Boot Basics] Profile và thứ tự ưu tiên cấu hình trong Spring Boot: biến môi trường và tham số dòng lệnh

Profile và thứ tự ưu tiên cấu hình của Spring Boot 4.1.1, đo trên jar đã đóng gói: application-dev.yml được merge lên application.yml theo từng key, mọi cách set spring.profiles.active và profile nào thắng khi hai profile cùng active, spring.profiles.default, file nhiều document, profile group và @Profile expression, thứ tự property source thật từ command-line argument xuống tới @PropertySource, nơi Spring Boot tìm file config, environment variable, spring.config.import và lỗi khởi động mà từng sai lầm gây ra.