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:

Cùng một lời gọi ResourceLoader.getResource rẽ ra bốn implementation tuỳ prefix trong chuỗi: classpath cho ClassPathResource, file cho FileSystemResource, http hoặc https cho UrlResource, không prefix thì tuỳ ApplicationContext đang dùng

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.

Lời gọi getResources với prefix classpath sao quét ba jar trên classpath, hai jar có file AutoConfiguration.imports đóng góp vào kết quả còn jar không có file thì bị bỏ qua, cho ra mảng Resource hai phần tử

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.

Ba vai trò này hiện ra đúng vị trí của chúng trong chuỗi khởi động ở sơ đồ cuối bài.

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.

Đối chiếu hai kiến trúc context: Spring Boot gom service, repository, controller và DispatcherServlet vào một root context duy nhất; Spring MVC cũ tách web context con chứa controller tra ngược lên root context cha chứa service

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

Chuỗi khởi động từ SpringApplication.run qua bước bung @SpringBootApplication thành ba annotation, rồi @ComponentScan ghi vào beanDefinitionMap, @EnableAutoConfiguration đọc file imports và lọc @Conditional, refresh tạo singleton, cuối cùng container sẵn sàng để @Value resolve resource

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
0/5 câu đã trả lời
  1. 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.
  2. Q2
    Khác biệt giữa classpath:classpath*: là gì? Cho ví dụ use case khi cần dùng classpath*:.
  3. 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?
  4. 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?
  5. 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.

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

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Bài tiếp theo

Mini-challenge: tự build mini IoC container 80 dòng