Spring Core & Boot/Resource abstraction và @SpringBootApplication — nạp resource và bootstrap container
12/41
Bài 12 / 41~13 phútContainer InternalsMiễn phí lượt xem

Resource abstraction và @SpringBootApplication — nạp resource và bootstrap container

Bài này bóc 2 thứ liên quan chặt đến nhau: Resource interface trừu tượng hoá classpath/file/URL thành một API duy nhất, và @SpringBootApplication = 3 annotation hợp lại bootstrap container không magic. Hiểu 2 cơ chế này giải thích vì sao classpath: hay dùng nhất khi đóng gói jar, vì sao đặt App ở root package, và vì sao Spring Boot chỉ cần 1 context duy nhất.

TL;DR: Resource là interface trừu tượng hoá nguồn dữ liệu — classpath:, file:, https: đều dùng cùng API getInputStream(), không cần nhớ FileInputStream hay getResourceAsStream. Prefix classpath*: (có dấu sao) scan qua nhiều jar. @SpringBootApplication không magic — nó là meta-annotation gộp 3 thứ: @SpringBootConfiguration (lớp config), @EnableAutoConfiguration (kích hoạt autoconfig), @ComponentScan (scan bean trong package). Vì @ComponentScan mặc định scan từ package chứa class, đặt App.java ở root package là quy tắc, không phải thói quen.

Bài trước (BeanFactory vs ApplicationContext) đã giải thích container là gì bên dưới — hai map beanDefinitionMapsingletonObjects. Câu hỏi tiếp theo: container được bootstrap thế nào, và tài nguyên bên ngoài (file, template, config) được nạp vào ra sao? Đó là hai cơ chế của bài này.

1. Resource abstraction — một API, nhiều nguồn

1.1 Vấn đề trước khi có Resource

Trước khi Spring thêm Resource, Java có 3 API khác nhau để đọc dữ liệu tuỳ nguồn:

NguồnJava APIVấn đề
File trên disknew FileInputStream("config.json")Chỉ work với file system, fail hoàn toàn trong jar
ClasspathgetClass().getResourceAsStream("/config.json")Trả null nếu không tìm thấy, không throw lỗi
URL remotenew URL("https://...").openStream()API khác biệt hoàn toàn

Khi code dùng new File("config.json") hay new FileInputStream(...), nó giả định resource là file thực trên disk. Điều này hoạt động khi chạy trong IDE nhưng vỡ khi đóng gói jar — resource trong jar không phải file hệ thống, JVM không thể mở qua FileInputStream.

1.2 Resource interface — trừu tượng hoá thống nhất

Spring giải quyết bằng interface Resource (package org.springframework.core.io):

// File: org/springframework/core/io/Resource.java (rut gon)
public interface Resource extends InputStreamSource {
    InputStream getInputStream() throws IOException;  // <-- API duy nhat can biet
    boolean exists();
    boolean isReadable();
    boolean isFile();
    URL getURL() throws IOException;
    URI getURI() throws IOException;
    File getFile() throws UnsupportedOperationException; // chi classpath tren disk/file resource
    String getFilename();
    String getDescription(); // mo ta cho logging
}

Mọi loại resource đều implement interface này — code của bạn chỉ nhận Resource, gọi getInputStream(), không quan tâm nguồn là đâu:

@Service
public class TemplateLoader {

    // Spring tu resolve prefix -> chon implementation phu hop
    @Value("classpath:templates/welcome.html")
    private Resource welcomeTemplate;

    @Value("file:/etc/app/config.json")
    private Resource externalConfig;

    @Value("https://cdn.example.com/schema.json")
    private Resource remoteSchema;

    public String readContent(Resource r) throws IOException {
        // Cung API cho ca 3 nguon tren
        try (var reader = new InputStreamReader(r.getInputStream(), StandardCharsets.UTF_8)) {
            return new BufferedReader(reader).lines().collect(Collectors.joining("\n"));
        }
    }
}

1.3 Cơ chế bên dưới — prefix quyết định implementation

Khi Spring thấy @Value("classpath:templates/welcome.html"), nó gọi ResourceLoader.getResource(location). ResourceLoader kiểm tra prefix của chuỗi rồi tạo implementation phù hợp:

flowchart TB
    Input["@Value location string"] --> RL["ResourceLoader.getResource(location)"]
    RL --> CheckPrefix{"prefix?"}
    CheckPrefix -->|"classpath:"| CPR["ClassPathResource<br/>(doc classpath, work ca trong jar)"]
    CheckPrefix -->|"file:"| FSR["FileSystemResource<br/>(doc file tren disk)"]
    CheckPrefix -->|"https: / http:"| URL["UrlResource<br/>(mo URL remote)"]
    CheckPrefix -->|"khong prefix"| CTX["context-relative<br/>(tuoy ApplicationContext)"]

    style CPR fill:#d1fae5

3 prefix chính và khi nào dùng:

PrefixImplementationKhi nào dùng
classpath:ClassPathResourceTemplate, schema, default config — đóng gói trong jar cùng app. Hay dùng nhất.
file:FileSystemResourceConfig ngoài jar, ví dụ Docker volume mount, K8s ConfigMap.
https: / http:UrlResourceRemote resource, hiếm dùng — phụ thuộc network lúc startup.

1.4 Vì sao classpath: hay dùng nhất

Lý do là triết lý "đóng gói một artifact" — deploy 1 file jar duy nhất chứa tất cả:

  1. Không phụ thuộc file system ngoài — jar chạy ở bất kỳ đâu (dev machine, CI runner, container) đều tìm được resource.
  2. Test và production cùng API — IDE chạy với target/classes/, jar chạy với classpath bên trong; ClassPathResource xử lý cả hai.
  3. Immutable — resource trong jar không bị sửa ngoài ý muốn sau deploy.

So sánh với file:: dùng khi bạn muốn config thay đổi mà không rebuild jar — ví dụ K8s mount ConfigMap vào /etc/app/config.json. Đây là pattern hợp lý cho secret hay config khác nhau giữa môi trường.

Pitfall — đọc file qua new File() trong Spring app

Dùng new File("config.json") hoặc Files.readString(Path.of("config.json")) chỉ hoạt động khi chạy từ IDE vì working directory có file đó. Khi đóng gói jar và chạy từ thư mục khác — hoặc khi resource nằm trong jar — Path.of(...) không tìm thấy, throw NoSuchFileException.

Thay bằng: @Value("classpath:config.json") Resource r rồi đọc qua r.getInputStream().

1.5 classpath*: — scan qua nhiều jar

Prefix classpath: (không sao) chỉ tìm lần đầu khớp trên classpath. Prefix classpath*: (có dấu sao) tìm tất cả khớp trên toàn bộ classpath, kể cả nhiều jar:

@Component
public class PluginRegistry {

    @Autowired
    private ResourcePatternResolver resolver;

    public List<Resource> loadAllPluginDescriptors() throws IOException {
        // Tim tat ca file plugin.json trong moi jar tren classpath
        Resource[] resources = resolver.getResources("classpath*:META-INF/plugin.json");
        return Arrays.asList(resources);
    }
}

Đây chính xác là cơ chế Spring Boot dùng để gom META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports từ tất cả starter jar — từng starter đóng gói file này, Boot scan classpath*: để lấy hết.

flowchart LR
    subgraph CP["Classpath (nhieu jar)"]
        J1["spring-boot-autoconfigure.jar<br/>META-INF/spring/Auto...imports"]
        J2["spring-data-jpa.jar<br/>META-INF/spring/Auto...imports"]
        J3["app.jar<br/>(khong co file nay)"]
    end
    Scan["classpath*:META-INF/spring/Auto...imports"] -->|"gom het"| Result["[Resource, Resource]"]
    J1 --> Scan
    J2 --> Scan
    J3 --> Scan

    style Result fill:#d1fae5

2. Tạo context thủ công — hiểu trước khi dùng Boot

Trước khi nói về @SpringBootApplication, một bài tập giúp thấy rõ container là gì:

@Configuration
@ComponentScan(basePackages = "com.olhub.demo")
public class AppConfig {

    @Bean
    public DataSource dataSource() {
        // Tao bean DataSource khai bao tuong minh
        var cfg = new HikariConfig();
        cfg.setJdbcUrl("jdbc:postgresql://localhost/demo");
        return new HikariDataSource(cfg);
    }
}

public class ManualBootstrap {

    public static void main(String[] args) {
        // try-with-resources: tu dong goi close() cuoi block
        try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
            var orderService = ctx.getBean(OrderService.class);
            orderService.placeOrder(/* ... */);
        } // ctx.close() -> @PreDestroy -> shutdown bean
    }
}

Bốn điều xảy ra trong 3 dòng:

  1. new AnnotationConfigApplicationContext(AppConfig.class) — tạo context, đọc AppConfig, tự động gọi refresh() trong constructor.
  2. refresh() — scan @ComponentScan, đăng ký beanDefinitionMap, instantiate tất cả singleton vào singletonObjects (đã giải thích trong bài 01).
  3. ctx.getBean(OrderService.class) — tra singletonObjects map, trả bean đã tạo sẵn.
  4. try-with-resourcesclose() tự chạy @PreDestroy callback, giải phóng resource (connection pool, thread pool...).

Spring Boot làm đúng 4 thứ này, chỉ thêm: banner, logging, environment setup, embedded server, autoconfig. Hiểu "Spring tối giản" trên thì Spring Boot không còn là hộp đen.

try-with-resources là bắt buộc khi tự tạo context

Nếu không dùng try (var ctx = ...) và không gọi ctx.close() thủ công, container không bao giờ shutdown sạch: @PreDestroy không chạy, connection pool không đóng, thread không dừng. Trong production code bạn ít khi tự tạo context — Spring Boot quản lý lifecycle. Nhưng trong script, test hoặc tool, quên close() là resource leak.

3. @SpringBootApplication = 3 annotation, không magic

Annotation này trông như một từ khoá đặc biệt nhưng thực ra chỉ là meta-annotation:

// File: org/springframework/boot/autoconfigure/SpringBootApplication.java (rut gon)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration       // (1)
@EnableAutoConfiguration       // (2)
@ComponentScan(/* ... */)      // (3)
public @interface SpringBootApplication {
    // cho phep tuy bien scan package, exclude autoconfig
    Class<?>[] exclude() default {};
    String[] scanBasePackages() default {};
}

Nếu tách ra, code chạy y hệt:

// Tuong duong 100% voi @SpringBootApplication
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}

3.1 Ba annotation và vai trò từng cái

@SpringBootConfiguration — marker khai báo "đây là entry config class". Nó extends @Configuration, nên class App có thể chứa @Bean method như bất kỳ @Configuration class nào khác. Chức năng đặc biệt thêm: Spring Boot dùng marker này để tìm entry class khi test với @SpringBootTest.

@EnableAutoConfiguration — kích hoạt cơ chế autoconfig của Spring Boot. Annotation này trigger AutoConfigurationImportSelector, scanner dùng classpath*: scan tất cả file META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports từ jar trên classpath, đọc danh sách @AutoConfiguration class, lọc qua @Conditional (chỉ register bean khi đủ điều kiện — ví dụ @ConditionalOnClass(DataSource.class) chỉ config datasource khi có driver trên classpath). Xem chi tiết trong Auto-configuration deep dive.

@ComponentScan — scan từ package chứa class annotated trở xuống, tìm mọi class có stereotype annotation (@Component, @Service, @Repository, @Controller, @RestController...) rồi đăng ký vào beanDefinitionMap. Đây là cơ chế "tự động phát hiện bean" thay vì khai báo từng cái bằng @Bean.

flowchart TB
    SBA["@SpringBootApplication<br/>com.olhub.App"]
    SBC["@SpringBootConfiguration<br/>= class nay la config class<br/>+ entry point cho @SpringBootTest"]
    EAC["@EnableAutoConfiguration<br/>-> doc Auto...imports tu moi jar<br/>-> register bean conditional"]
    CS["@ComponentScan<br/>-> scan com.olhub.** <br/>-> phat hien @Component, @Service..."]

    SBA --> SBC
    SBA --> EAC
    SBA --> CS

    style SBA fill:#fef3c7

3.2 Vì sao đặt App ở root package — cơ chế, không phải convention

@ComponentScan mặc định scan package chứa class được annotate và tất cả sub-package. Không có tham số basePackages → scan từ package của App.java trở xuống.

com.olhub/                          <- root package
  App.java                          <- @SpringBootApplication o day
  domain/
    OrderService.java               <- @Service -> DUOC scan
  api/
    OrderController.java            <- @RestController -> DUOC scan
  payment/
    StripeGateway.java              <- @Service -> DUOC scan

Nếu App.java nằm sai vị trí:

com.olhub/
  domain/
    OrderService.java               <- @Service -> BI MISS
  api/
    App.java                        <- @SpringBootApplication (SAI vi tri)
    OrderController.java            <- @RestController -> duoc scan
  payment/
    StripeGateway.java              <- @Service -> BI MISS

@ComponentScan chỉ scan com.olhub.api và sub-package của nó. OrderServiceStripeGateway ở package song song không được scan → NoSuchBeanDefinitionException khi resolve dependency.

Pitfall — App ở sub-package miss bean

Đây là pitfall phổ biến khi refactor cấu trúc package. Triệu chứng: app khởi động OK nhưng một số bean nhất định không tìm thấy (NoSuchBeanDefinitionException), hoặc DI fail với "No qualifying bean of type X".

Kiểm tra nhanh: @SpringBootApplication đặt ở package nào? Mọi @Service/@Component có nằm trong package đó hoặc sub-package không?

Fix chuẩn: move App.java lên root package. Workaround (không khuyến nghị): @SpringBootApplication(scanBasePackages = "com.olhub").

3.3 Vì sao Spring Boot chỉ 1 context

Spring MVC truyền thống (không Boot) dùng hai context lồng nhau (parent-child):

  • Root context (load bởi ContextLoaderListener): chứa service, repository, datasource — dùng chung cho toàn app.
  • DispatcherServlet context (child): chứa controller, view resolver — scope của web layer. Có thể inject bean của root context.

Spring Boot đơn giản hoá: chỉ 1 root context chứa tất cả — service, repository, controller. DispatcherServlet được register vào context đó thay vì tạo context riêng. Lý do: overhead của 2 context không đáng khi app là monolith đơn giản, và 2 context dễ gây nhầm lẫn về scope bean.

flowchart TB
    subgraph Boot["Spring Boot -- 1 context"]
        RC["Root Context<br/>(DefaultListableBeanFactory)"]
        S["@Service, @Repository"]
        C["@Controller, @RestController"]
        DS["DispatcherServlet<br/>(registered as bean)"]
        RC --> S
        RC --> C
        RC --> DS
    end

    subgraph Legacy["Spring MVC legacy -- 2 context"]
        Root["Root Context<br/>Service, Repository"]
        Web["Web Context (child)<br/>Controller, ViewResolver"]
        Web -.->|"parent lookup"| Root
    end

Pattern parent-child vẫn gặp ở: Spring Cloud Bootstrap context (load config server trước app context), legacy codebase Spring MVC chưa migrate Boot. Với Spring Boot app thông thường, bạn không cần quan tâm.

Cơ chế bên dưới — Resource và bootstrap trong một bức tranh

flowchart TB
    Main["SpringApplication.run(App.class)"]
    Parse["Phan tich @SpringBootApplication<br/>= @SpringBootConfiguration<br/>+ @EnableAutoConfiguration<br/>+ @ComponentScan"]
    Scan["@ComponentScan: scan com.olhub.**<br/>Phat hien @Component, @Service...<br/>-> dang ky beanDefinitionMap"]
    Auto["@EnableAutoConfiguration:<br/>classpath*: Auto...imports<br/>Filter @Conditional<br/>-> register them bean"]
    Refresh["refresh() - instantiate singletons<br/>(doc bai BeanFactory vs AC)"]
    Ready["Container ready<br/>Resource inject qua @Value"]

    Main --> Parse --> Scan --> Auto --> Refresh --> Ready

    RL["ResourceLoader<br/>classpath: -> ClassPathResource<br/>file: -> FileSystemResource<br/>https: -> UrlResource"]
    Ready -->|"@Value('classpath:...')"| RL

Liên hệ các bài khác

Bài này là mảnh thứ 3 trong chuỗi container:

  • BeanFactory vs ApplicationContext — container bên dưới là gì: hai map beanDefinitionMap + singletonObjects, cơ chế getBean, delegation. Bài hiện tại giải thích làm thế nào container được bootstrapresource được nạp vào.
  • @Component và @ComponentScan@ComponentScan phát hiện stereotype annotation và đăng ký vào beanDefinitionMap. Bài đó đào sâu cơ chế scan: filter, basePackages, conflict resolution khi nhiều bean cùng type.
  • Auto-configuration deep dive@EnableAutoConfiguration trong bài này chỉ được giới thiệu; bài kia đào sâu AutoConfigurationImportSelector, conditional bean, và cách override autoconfig.

Tóm tắt

  • Resource interface trừu tượng hoá classpath:, file:, https: thành cùng một API getInputStream() — code không cần biết nguồn.
  • classpath: hay dùng nhất vì đóng gói trong jar — deploy 1 artifact, không phụ thuộc file system ngoài.
  • classpath*: (có dấu sao) scan qua nhiều jar — cơ chế Spring Boot dùng để gom autoconfig từ tất cả starter.
  • Đọc resource qua new File(...) hoặc Files.readString(Path.of(...)) hoạt động trong IDE nhưng fail trong jar.
  • @SpringBootApplication = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan. Không magic.
  • @ComponentScan mặc định scan từ package chứa class annotated — App.java phải ở root package.
  • Spring Boot dùng 1 root context duy nhất; parent-child context chỉ gặp ở legacy Spring MVC.
  • Tạo context thủ công (AnnotationConfigApplicationContext) cần try-with-resources để close() chạy @PreDestroy.

Tự kiểm tra

Tự kiểm tra
Q1
Vì sao dùng @Value("classpath:config.json") Resource r thay vì Files.readString(Path.of("config.json")) trong Spring app? Trả lời theo cơ chế JVM + jar.

Path.of("config.json") tra file hệ thống dựa vào working directory của process. Khi chạy trong IDE, working directory thường là root project và file hiện diện ở đó. Khi đóng gói jar và chạy từ thư mục khác — hoặc khi file nằm bên trong jar — JVM không thể mở nó bằng FileInputStream hay Path vì đây không phải file thực trên disk, mà là entry trong zip archive.

ClassPathResource (phía sau classpath:) dùng ClassLoader.getResourceAsStream() — API duy nhất JVM hỗ trợ đọc resource bên trong jar. Nó hoạt động ở cả 2 môi trường: IDE chạy từ target/classes/, production chạy từ jar.

Nguyên tắc: mọi file đóng gói cùng app (template, schema, default config) phải đọc qua ClassPathResource. Chỉ dùng File/Path khi file nằm ngoài jar và vị trí trên disk là cố định.

Q2
Khác biệt giữa classpath:classpath*: là gì? Cho ví dụ use case khi cần dùng classpath*:.

classpath: tìm lần đầu khớp trên classpath — nếu nhiều jar có cùng đường dẫn, chỉ lấy entry đầu tiên theo thứ tự classpath. Trả về 1 Resource.

classpath*: tìm tất cả khớp trên toàn bộ classpath, kể cả nhiều jar khác nhau. Trả về Resource[].

Use case điển hình: plugin architecture — mỗi jar plugin đặt file META-INF/plugin.json mô tả mình. App dùng classpath*:META-INF/plugin.json để gom tất cả. Đây chính xác là cách Spring Boot gom META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports từ mọi starter jar để biết cần register những autoconfig class nào.

Q3
@SpringBootApplication "là" bao nhiêu annotation? Liệt kê và nêu vai trò từng cái. Nếu bỏ @ComponentScan đi, hệ quả gì xảy ra?

@SpringBootApplication là meta-annotation gộp 3 annotation:

  • @SpringBootConfiguration: marker khai báo class này là entry config class; extends @Configuration nên có thể chứa @Bean method; Spring Boot Test dùng marker này để tìm class test.
  • @EnableAutoConfiguration: kích hoạt autoconfig — scan classpath*: tìm AutoConfiguration.imports từ tất cả starter, register bean conditional.
  • @ComponentScan: scan package chứa class annotated và sub-package, phát hiện @Component/@Service/... đăng ký vào beanDefinitionMap.

Nếu bỏ @ComponentScan: không có bean nào được tự động phát hiện. Mọi service, repository, controller phải khai báo tường minh bằng @Bean method trong class @Configuration. App vẫn có thể chạy nhưng developer phải khai báo thủ công từng bean — tốn công và dễ quên.

Q4
Dự án có cấu trúc package: com.olhub.App (có @SpringBootApplication), com.olhub.order.OrderService (@Service), com.partner.payment.StripeGateway (@Service). Bean nào được scan tự động? Bean nào bị miss? Sửa thế nào?

@ComponentScan mặc định scan package chứa class annotated và sub-package. Appcom.olhub → scan com.olhub và tất cả sub-package.

Được scan: OrderServicecom.olhub.order — là sub-package của com.olhub.

Bị miss: StripeGatewaycom.partner.payment — package hoàn toàn khác, nằm ngoài phạm vi scan.

Cách sửa:

  • Move StripeGateway vào com.olhub.payment (thay đổi code partner). Đây là chuẩn nhất.
  • Hoặc mở rộng scan: @SpringBootApplication(scanBasePackages = {"com.olhub", "com.partner"}). Workaround khi không sở hữu code của partner.
  • Hoặc khai báo tường minh: @Bean method trong AppConfig tạo StripeGateway. Phù hợp khi bean là library third-party không thể annotate.
Q5
Vì sao Spring Boot dùng 1 context thay vì 2 context (root + servlet) như Spring MVC truyền thống? Liệt kê trường hợp pattern parent-child vẫn xuất hiện ngày nay.

Spring MVC truyền thống tách 2 context vì lý do lịch sử: ContextLoaderListener được thiết kế để load root context (service, repository) độc lập với web container, trong khi DispatcherServlet cần context riêng cho web layer. Cách này hợp lý khi app có thể deploy trên nhiều servlet khác nhau.

Spring Boot đơn giản hoá thành 1 root context vì: (1) monolith đơn giản không cần tách layer ở cấp context; (2) 2 context gây nhầm lẫn — bean ở root không inject được vào child nếu config sai chiều; (3) overhead không đáng kể so với lợi ích.

Pattern parent-child vẫn xuất hiện ở:

  • Spring Cloud Bootstrap context: load config server (Vault, Config Server) trước khi app context khởi động, đặt property vào parent để app context kế thừa.
  • Legacy Spring MVC codebase chưa migrate Boot — vẫn dùng web.xml + ContextLoaderListener.
  • Test phức tạp với @DirtiesContext tạo nhiều context trong một test suite.

Bài tiếp theo: Mini-challenge — tự build mini IoC container

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên