Spring Framework và IoC

Xem thử

Một công ty chuyển từ cổng thanh toán cũ sang cổng mới. Việc tưởng đơn giản: đổi một dòng. Nhưng 41 lớp trong dự án đều có đoạn private PaymentGateway gateway = new OldGateway(); ngay trong trường của chúng, và mỗi lớp phải được sửa, biên dịch và kiểm thử lại. Tệ hơn, bộ kiểm thử đơn vị vốn chạy trong 20 giây bắt đầu mất 15 phút, vì mỗi bài kiểm thử của dịch vụ đơn hàng thật sự gọi môi trường sandbox của cổng thanh toán. Hôm sau, một bài kiểm thử gửi nhầm lệnh tới cổng thật. Không có dòng code nào sai về mặt cú pháp: mọi lớp đều biên dịch và đều chạy. Cái sai là chỗ đặt quyết định "dùng bản cài đặt nào" trong chính lớp cần nó.

Bài này mở đầu chương Spring Core bằng đúng vấn đề đó. Inversion of Control (IoC) là đảo ngược quyền quyết định này: lớp chỉ khai báo nó cần gì, còn việc tạo và truyền vào do bên ngoài đảm nhận, ở ứng dụng là container Spring. Bạn sẽ chạy hai bản của cùng một dịch vụ đơn hàng, đo thứ tự container tạo các bean, và viết một bài kiểm thử không cần container nhờ đối tượng giả.

Sau bài này, bạn có thể

  • Giải thích IoC và Dependency Injection (DI) bằng hai bản code chạy được của cùng một dịch vụ.
  • Chỉ ra vì sao new trong lớp làm kiểm thử và thay thế khó, bằng một sự kiện đo được.
  • Mô tả thứ tự container tạo bean và đọc đồ thị phụ thuộc của nó bằng mã.
  • Kiểm thử một lớp dùng constructor injection mà không cần dựng container.
  • Kể tên các module của Spring Core thật sự có trên classpath và vai trò của chúng.

Chuẩn bị môi trường và dự án

Mọi thí nghiệm của bài chạy trong một dự án Maven nhỏ. Mục này nói rõ cần cài gì, khai báo thư viện nào và cấu hình ra sao; mã của từng lớp được trình bày ngay tại chỗ nó được dùng, kèm giải thích, nên bạn không cần mở nơi nào khác. Số liệu và thông báo lỗi trong bài được đo từ chính mã đó (JDK 17, Spring Framework 7.0.9). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 06-spring-core/01-ioc-va-di, chỉ để bạn clone cho nhanh nếu không muốn tự gõ lại.

Yêu cầu môi trường

Thành phầnPhiên bản đã kiểm chứngGhi chú
JDK17 trở lênjava -version
Maven3.9 trở lênmvn -v
Spring Framework7.0.9khai báo trong pom.xml
Dockerkhông cầnbài này không dùng cơ sở dữ liệu

Cấu trúc

text
01-ioc-va-di/
  pom.xml
  src/main/java/dev/canhdominich/spring/ioc/
    Events.java                  ghi lại và in các sự kiện (tạo bean, gọi thanh toán)
    PaymentService.java          interface thanh toán
    PaypalPayment.java           cài đặt thật, là một @Component
    FakePayment.java             bản giả dùng khi kiểm thử, không phải bean
    HardWiredOrderService.java   dịch vụ tự tạo phụ thuộc bằng new
    OrderService.java            dịch vụ nhận phụ thuộc qua constructor, là một @Service
    AppConfig.java               lớp cấu hình bật quét component
    IocDemo.java                 ba thí nghiệm: tự tạo, container, bản giả
    ModulesDemo.java             liệt kê các jar spring trên classpath

Khai báo thư viện: pom.xml

pom.xml

Dự án Maven Java 17 chỉ khai báo spring-context 7.0.9 (kéo theo spring-core, spring-beans, spring-aop và spring-expression), jakarta.annotation-api cho các annotation vòng đời, cùng JUnit và AssertJ cho test. Không cần cơ sở dữ liệu hay Docker. Plugin exec-maven-plugin chạy lớp chính bằng mvn -q compile exec:exec.

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>dev.canhdominich.javabackend</groupId>
    <artifactId>spring-ioc-va-di</artifactId>
    <version>1.0.0</version>
    <name>Spring Core - IoC va Dependency Injection</name>

    <properties>
        <maven.compiler.release>17</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <main.class>dev.canhdominich.spring.ioc.IocDemo</main.class>
        <spring.version>7.0.9</spring.version>
        <junit.version>6.0.3</junit.version>
        <assertj.version>3.27.7</assertj.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>jakarta.annotation</groupId>
            <artifactId>jakarta.annotation-api</artifactId>
            <version>3.0.0</version>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>${assertj.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.6</version>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.6.3</version>
                <configuration>
                    <executable>java</executable>
                    <arguments>
                        <argument>-classpath</argument>
                        <classpath/>
                        <argument>${main.class}</argument>
                    </arguments>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Khi lớp tự tạo phụ thuộc bằng new

Mọi thứ bắt đầu từ một interface PaymentService chỉ có một phương thức charge:

src/main/java/dev/canhdominich/spring/ioc/PaymentService.java

java
package dev.canhdominich.spring.ioc;

public interface PaymentService {

    String charge(int amountVnd);
}

Lớp Events ghi và in các sự kiện (tạo bean, gọi thanh toán) để các thí nghiệm có thứ tự đo được thay vì phỏng đoán; nó là công cụ của bài, không phải một phần của mẫu thiết kế:

src/main/java/dev/canhdominich/spring/ioc/Events.java

java
package dev.canhdominich.spring.ioc;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public final class Events {

    private static final List<String> LOG = Collections.synchronizedList(new ArrayList<>());

    private Events() {
    }

    public static void record(String event) {
        LOG.add(event);
        System.out.println("  event: " + event);
    }

    public static void clear() {
        LOG.clear();
    }

    public static List<String> all() {
        synchronized (LOG) {
            return new ArrayList<>(LOG);
        }
    }
}

PaypalPayment là cài đặt "thật" của cổng thanh toán. Mỗi lần được tạo và mỗi lần charge đều ghi một sự kiện. Annotation @Component đánh dấu nó là ứng viên để container quản lý; chưa cần quan tâm tới nó ở thí nghiệm đầu:

src/main/java/dev/canhdominich/spring/ioc/PaypalPayment.java

java
package dev.canhdominich.spring.ioc;

import org.springframework.stereotype.Component;

@Component
public class PaypalPayment implements PaymentService {

    public PaypalPayment() {
        Events.record("constructing PaypalPayment");
    }

    @Override
    public String charge(int amountVnd) {
        Events.record("PAYPAL charged " + amountVnd);
        return "paypal:" + amountVnd;
    }
}

HardWiredOrderService là kiểu viết ở đầu bài: dịch vụ tự gọi new PaypalPayment() trong trường của nó:

src/main/java/dev/canhdominich/spring/ioc/HardWiredOrderService.java

java
package dev.canhdominich.spring.ioc;

public class HardWiredOrderService {

    private final PaymentService payment = new PaypalPayment();

    public String place(int amountVnd) {
        return payment.charge(amountVnd);
    }
}

IocDemo chạy ba thí nghiệm liên tiếp; thí nghiệm đầu dùng HardWiredOrderService như trên:

src/main/java/dev/canhdominich/spring/ioc/IocDemo.java

java
package dev.canhdominich.spring.ioc;

import java.util.Arrays;
import java.util.List;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class IocDemo {

    public static void main(String[] args) {
        run();
    }

    public static void run() {
        withoutContainer();
        withContainer();
        withFake();
    }

    static String withoutContainer() {
        System.out.println("-- without a container: OrderService creates its own payment");
        Events.clear();
        String result = new HardWiredOrderService().place(100);
        System.out.println("result: " + result);
        return result;
    }

    static List<String> withContainer() {
        System.out.println("-- with a container: the container creates and wires the beans");
        Events.clear();
        try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class)) {
            String[] userBeans = Arrays.stream(context.getBeanDefinitionNames())
                    .filter(name -> !name.startsWith("org.springframework"))
                    .sorted()
                    .toArray(String[]::new);
            System.out.println("bean names: " + Arrays.toString(userBeans));
            System.out.println("orderService depends on: " + Arrays.toString(context.getBeanFactory().getDependenciesForBean("orderService")));
            System.out.println("paypalPayment is needed by: " + Arrays.toString(context.getBeanFactory().getDependentBeans("paypalPayment")));
            OrderService first = context.getBean(OrderService.class);
            OrderService second = context.getBean(OrderService.class);
            System.out.println("same instance twice: " + (first == second));
            System.out.println("result: " + first.place(100));
        }
        return Events.all();
    }

    static List<Integer> withFake() {
        System.out.println("-- no container at all: a fake payment goes through the constructor");
        FakePayment fake = new FakePayment();
        String result = new OrderService(fake).place(100);
        System.out.println("result: " + result + ", charges recorded by the fake: " + fake.charges());
        return fake.charges();
    }
}

Chạy bằng mvn -q compile exec:exec (lớp chính mặc định là IocDemo). Với thí nghiệm đầu (các dòng đầu của kết quả):

text
-- without a container: OrderService creates its own payment
  event: constructing PaypalPayment
  event: PAYPAL charged 100
result: paypal:100

Ngay cả khi bạn chỉ muốn kiểm thử logic của dịch vụ, nó vẫn tạo cổng thật và gọi cổng thật (hai sự kiện). Không có chỗ nào để người gọi (một bài kiểm thử, một ứng dụng khác) nói "lần này hãy dùng thứ khác".

Cách đọc hình: hàng trên: HardWiredOrderService tự gọi new, nên mọi lần chạy đều chạm cổng thật và đổi cổng nghĩa là sửa lớp. Hàng dưới: OrderService chỉ khai báo cần một PaymentService qua constructor; ai lắp ráp (container hay bài kiểm thử) quyết định bản cài đặt.

Khi container tạo và truyền phụ thuộc vào

OrderService làm cùng việc nhưng khai báo phụ thuộc ở constructor và chỉ biết interface PaymentService. Annotation @Service đánh dấu nó là một bean ở tầng nghiệp vụ. Với một constructor duy nhất, Spring tự dùng nó để tiêm phụ thuộc mà không cần @Autowired:

src/main/java/dev/canhdominich/spring/ioc/OrderService.java

java
package dev.canhdominich.spring.ioc;

import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private final PaymentService payment;

    public OrderService(PaymentService payment) {
        this.payment = payment;
        Events.record("constructing OrderService with " + payment.getClass().getSimpleName());
    }

    public String place(int amountVnd) {
        return payment.charge(amountVnd);
    }
}

AppConfig là lớp cấu hình tối thiểu: @Configuration báo đây là nguồn định nghĩa bean, và @ComponentScan bảo container quét package của lớp này, tìm các lớp có annotation như @Component và @Service:

src/main/java/dev/canhdominich/spring/ioc/AppConfig.java

java
package dev.canhdominich.spring.ioc;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan(basePackageClasses = AppConfig.class)
public class AppConfig {
}

Phương thức withContainer của IocDemo dựng AnnotationConfigApplicationContext từ AppConfig, in tên các bean, hỏi đồ thị phụ thuộc, kiểm tra getBean hai lần có trả cùng một đối tượng không, rồi gọi dịch vụ. Kết quả của thí nghiệm thứ hai:

text
-- with a container: the container creates and wires the beans
  event: constructing PaypalPayment
  event: constructing OrderService with PaypalPayment
bean names: [appConfig, orderService, paypalPayment]
orderService depends on: [paypalPayment]
paypalPayment is needed by: [orderService]
same instance twice: true
  event: PAYPAL charged 100
result: paypal:100

Đọc kết quả theo từng điểm:

  • Thứ tự tạo. PaypalPayment được tạo trước OrderService, dù bạn không viết dòng nào nói thế: container đọc constructor của OrderService, thấy nó cần một PaymentService, nên tạo bean đó trước rồi mới tạo OrderService và truyền vào (dòng constructing OrderService with PaypalPayment).
  • Ba bean của bạn. appConfig (chính lớp cấu hình cũng là một bean), orderService và paypalPayment. Danh sách đã lọc bỏ các bean nội bộ của Spring (tên bắt đầu bằng org.springframework) mà container tự đăng ký để xử lý annotation.
  • Đồ thị phụ thuộc có thể hỏi được. getDependenciesForBean("orderService") trả [paypalPayment], và chiều ngược lại getDependentBeans("paypalPayment") trả [orderService]. Đây là bản đồ mà container dùng để biết thứ tự tạo và thứ tự hủy.
  • Mặc định là một đối tượng duy nhất. getBean(OrderService.class) hai lần trả cùng một đối tượng (same instance twice: true); phần phạm vi (scope) sẽ nói kỹ ở bài vòng đời.

Cách đọc hình: từ trái sang phải rồi xuống hàng dưới: quét package tìm lớp, đọc constructor của OrderService, tạo PaypalPayment trước, truyền nó vào OrderService, giữ một bản của mỗi bean, ghi lại đồ thị phụ thuộc. Mỗi bước có sự kiện hoặc kết quả đo được tương ứng trong kết quả chạy.

Đang tải Animation…

Kiểm thử không cần container

Lợi ích đầu tiên của constructor injection nằm ở kiểm thử. FakePayment là một bản cài đặt PaymentService chỉ ghi lại các lần gọi vào một danh sách, không chạm hệ thống ngoài. Nó không có annotation nên không phải bean:

src/main/java/dev/canhdominich/spring/ioc/FakePayment.java

java
package dev.canhdominich.spring.ioc;

import java.util.ArrayList;
import java.util.List;

public class FakePayment implements PaymentService {

    private final List<Integer> charges = new ArrayList<>();

    @Override
    public String charge(int amountVnd) {
        charges.add(amountVnd);
        return "fake:" + amountVnd;
    }

    public List<Integer> charges() {
        return charges;
    }
}

Thí nghiệm thứ ba của IocDemo (phương thức withFake) không dựng container: nó gọi new OrderService(fake) thẳng, rồi kiểm tra điều bản giả ghi lại:

text
-- no container at all: a fake payment goes through the constructor
  event: constructing OrderService with FakePayment
result: fake:100, charges recorded by the fake: [100]

Dịch vụ chạy đúng logic với bản giả, ghi lại lần tính tiền 100, và không có dòng PAYPAL charged nào: không cổng thật, không container, không cấu hình. Một bài kiểm thử như vậy chạy trong vài mili giây. Đây là lý do thường nói constructor injection làm code "dễ kiểm thử": không cần phép màu nào của framework, chỉ cần hàm tạo nhận phụ thuộc là một tham số.

Spring gồm những module nào

Spring Framework chia thành nhiều artifact, và bạn chỉ thêm những gì cần. ModulesDemo đọc classpath của chính dự án và in các jar có tên bắt đầu bằng spring-:

src/main/java/dev/canhdominich/spring/ioc/ModulesDemo.java

java
package dev.canhdominich.spring.ioc;

import java.io.File;
import java.util.Arrays;
import java.util.List;

public class ModulesDemo {

    public static void main(String[] args) {
        run();
    }

    public static List<String> run() {
        List<String> jars = Arrays.stream(System.getProperty("java.class.path").split(File.pathSeparator))
                .map(path -> new File(path).getName())
                .filter(name -> name.startsWith("spring-"))
                .sorted()
                .toList();
        System.out.println("spring jars on the classpath:");
        jars.forEach(jar -> System.out.println("  " + jar));
        return jars;
    }
}

Dự án chỉ khai báo duy nhất spring-context (và jakarta.annotation-api cho các annotation vòng đời ở bài sau), vậy mà kết quả:

text
spring jars on the classpath:
  spring-aop-7.0.9.jar
  spring-beans-7.0.9.jar
  spring-context-7.0.9.jar
  spring-core-7.0.9.jar
  spring-expression-7.0.9.jar
JarVai trò
spring-coreTiện ích nền tảng mà mọi module khác dùng
spring-beansĐịnh nghĩa bean (công thức tạo đối tượng) và nhà máy tạo bean
spring-contextApplicationContext, quét component, các annotation như @Component, @Configuration
spring-aopNền tảng proxy; được kéo theo vì container dùng nó cho các tính năng nâng cao
spring-expressionNgôn ngữ biểu thức SpEL, bài vòng đời sẽ dùng

Web (MVC), truy cập dữ liệu và bảo mật là những artifact riêng không có trong danh sách trên, bạn thêm chúng khi chương tương ứng cần. Bốn jar spring-core, spring-beans, spring-context và spring-expression thường được gọi chung là Core Container. Danh sách này đo trên bản 7.0.9; các bản khác có thể có tập jar khác, nên hãy đo lại bằng chính ModulesDemo khi nâng cấp.

Cách đọc hình: spring-context (hàng trên, bên phải) phụ thuộc spring-beans và spring-core; hai ô nét đứt là các jar kéo theo thêm (spring-aop, spring-expression). Ô cuối nhắc rằng MVC, Data, Security không nằm trên classpath cho tới khi bạn thêm chúng.

Quyết định thiết kế và đánh đổi

Quyết địnhLựa chọnĐánh đổi
Nơi tạo phụ thuộcBên ngoài lớp (constructor nhận interface)Cần một chỗ lắp ráp (container hoặc test)
Kiểu phụ thuộcInterface, không phải lớp cụ thểThêm một interface cho mỗi phụ thuộc cần thay thế
Kiểm thử đơn vịTruyền đối tượng giả qua constructorPhải viết hoặc dựng đối tượng giả
Số lượng moduleChỉ thêm module cần dùngPhải biết module nào cho tính năng nào
Dùng containerCho ứng dụng thật, còn kiểm thử đơn vị thì không cầnHai cách dựng cùng một đối tượng cần được giữ nhất quán

Playbook: gặp sự cố ở lớp nền IoC thì làm gì

Triệu chứngKiểm tra đầu tiênNguyên nhân hay gặpCách xử lý
Bài kiểm thử đơn vị gọi dịch vụ thậtCó new cho phụ thuộc trong lớp khôngPhụ thuộc bị gắn cứngChuyển thành tham số constructor kiểu interface
Đổi cài đặt phải sửa nhiều lớpTìm các chỗ new của lớp cài đặtQuyết định nằm rải rácTập trung việc lắp ráp ở một chỗ (cấu hình)
Khởi động báo expected single matching bean but found 2Có hai bean cùng kiểuThêm một @Component cùng interfaceBài DI sau dạy @Qualifier và @Primary
Bean không được tạoLớp có annotation stereotype và nằm trong package được quét khôngSai package hoặc thiếu @ComponentScanKiểm tra basePackageClasses hoặc package của lớp cấu hình
Không biết bean nào cần bean nàoĐồ thị phụ thuộcNhiều bean, nhiều tầnggetDependenciesForBean, getDependentBeans

Lỗi thường gặp khi chạy lab

Triệu chứngNguyên nhânCách xử lý
mvn báo không tìm thấy jakarta.annotationThiếu thư viện khai báo trong pom.xmlChạy lại với mạng, hoặc xóa ~/.m2/repository/jakarta/annotation rồi chạy lại
Không thấy dòng event: nàoChạy sai lớp chínhDùng đúng lệnh mvn -q compile exec:exec

Bài thực hành

Làm trong dự án của bài (thư mục 06-spring-core/01-ioc-va-di). Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.

  1. Chạy thí nghiệm. mvn -q compile exec:exec. Tiêu chí: bạn thấy constructing PaypalPayment trước constructing OrderService with PaypalPayment, và same instance twice: true.
  2. Thêm một cổng thứ hai. Tạo lớp StripePayment implements PaymentService có @Component (trả "stripe:" + amountVnd) trong cùng package và chạy lại. Tiêu chí: container từ chối khởi động với lỗi NoUniqueBeanDefinitionException: ... expected single matching bean but found 2: paypalPayment,stripePayment; ghi lại lỗi xảy ra ở bước nào (khi khởi động, hay khi gọi place) và giải thích vì sao.
  3. Viết một bài kiểm thử không có container. Thêm một test gọi new OrderService(new FakePayment()) rồi place(250) và kiểm tra danh sách charges() bằng [250]. Tiêu chí: test xanh và không có sự kiện PAYPAL charged nào.
  4. Vẽ đồ thị. Thêm một lớp ReceiptService (@Service) nhận OrderService qua constructor, rồi in getDependenciesForBean("receiptService") và getDependentBeans("orderService"). Tiêu chí: hai danh sách khớp với các constructor bạn viết.
  5. Chạy bộ test (tùy chọn). Bộ test nằm trong bản sao lưu ở kho mã của khóa học; sau khi clone, mvn test cho Tests run: 4, Failures: 0, Errors: 0.

Hiểu lầm và giới hạn

  • "Dùng Spring nghĩa là dùng DI." DI là kỹ thuật: bạn có thể làm nó bằng tay (như withFake); container chỉ tự động hóa việc lắp ráp.
  • "IoC cần container." Constructor injection cho phép kiểm thử mà không có container nào.
  • "@Autowired bắt buộc trên constructor." Với một constructor duy nhất Spring tự dùng nó, như OrderService ở trên không có @Autowired.
  • Số đo thuộc về Spring Framework 7.0.9 trên JDK 17. Bài này chưa nói về các kiểu cấu hình khác (bài sau), các kiểu tiêm khác ngoài constructor (bài DI), vòng đời và phạm vi (bài sau nữa), và mọi tính năng web hay dữ liệu.

Tự kiểm tra

  1. Vì sao một bài kiểm thử của HardWiredOrderService vẫn gọi cổng thanh toán thật?
  2. Trong kết quả, vì sao constructing PaypalPayment đứng trước constructing OrderService?
  3. Bạn kiểm thử OrderService không cần container bằng cách nào?
  4. getDependenciesForBean và getDependentBeans cho biết điều gì, theo chiều nào?
  5. Thêm một lớp @Component thứ hai cùng implement PaymentService thì chuyện gì xảy ra, và vào lúc nào?

Gợi ý đáp án. (1) Lớp tự gọi new PaypalPayment() trong trường final, người gọi không thay được. (2) Constructor của OrderService cần một PaymentService, nên container tạo phụ thuộc đó trước. (3) Truyền một đối tượng giả vào constructor: new OrderService(new FakePayment()). (4) Cái đầu: các bean mà một bean cần; cái sau: các bean cần bean đó. (5) Container báo NoUniqueBeanDefinitionException ngay khi khởi động vì có hai ứng viên và không có chỉ dẫn chọn.

Điều cần nhớ

  • IoC chuyển quyết định "dùng bản cài đặt nào" ra khỏi lớp; DI là cách làm: nhận phụ thuộc qua constructor.
  • Container đọc constructor, tạo phụ thuộc trước, rồi truyền vào; đồ thị phụ thuộc hỏi được bằng mã.
  • Constructor injection cho phép kiểm thử bằng đối tượng giả, không cần container.
  • spring-context kéo theo năm jar trên bản 7.0.9; module web, dữ liệu và bảo mật là artifact riêng.

Đọc thêm

Bài tiếp theo đi sâu vào chính container: BeanFactory so với ApplicationContext, ba cách khai báo bean, và vì sao một số lỗi cấu hình chỉ lộ ra lúc khởi động.