Spring Core & Boot/Custom Starter — tự viết starter nội bộ và quản lý supply chain
29/41
Bài 29 / 41~14 phútSpring Boot & Auto-configurationMiễn phí lượt xem

Custom Starter — tự viết starter nội bộ và quản lý supply chain

Starter không chỉ là thứ Pivotal viết. Bài này hướng dẫn tự xây custom starter cho team: cấu trúc 2-module (autoconfigure + starter), file AutoConfiguration.imports, naming convention; sau đó bóc supply chain security: CVE scan, SBOM, và chiến lược upgrade để không bị Log4Shell lần 2.

TL;DR: Custom starter gồm 2 module riêng biệt: *-spring-boot-autoconfigure (chứa @AutoConfiguration class + @ConfigurationProperties) và *-spring-boot-starter (jar rỗng, chỉ pull autoconfigure + runtime lib). Boot load autoconfig nhờ file META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports trong mỗi jar classpath. Naming convention: starter chính chủ Pivotal dùng spring-boot-starter-X, third-party và nội bộ dùng X-spring-boot-starter (đảo namespace). Supply chain: mỗi starter kéo 30-100 transitive jar; CVE scan với OWASP Dependency-Check (CVSS vượt 7 → build fail), SBOM qua /actuator/sbom (Boot 3.3+), upgrade theo timeline hỗ trợ để không dẫm vào Log4Shell kiểu khác.

Bài 02 — Starter Dependencies đã bóc spring-boot-starter-web và giới thiệu cấu trúc custom starter ở mức outline. Bài này đào sâu vào cơ chế load autoconfig, lý do tách 2 module, và toàn bộ vòng đời supply chain từ viết đến vận hành production.

1. Vì sao tách autoconfigure và starter thành 2 module

Câu hỏi đầu tiên người mới thường đặt ra: tại sao không viết một module duy nhất chứa cả autoconfig code lẫn dependency declaration? Pivotal tách 2 module vì 2 use case khác nhau:

ModuleNội dungNgười dùng
*-spring-boot-autoconfigure@AutoConfiguration, @ConfigurationProperties, condition logicLibrary developer, hoặc service cần autoconfigure mà không pull starter
*-spring-boot-starterJar rỗng class; chỉ pom.xml pull autoconfigure + runtime libApplication developer — 1 dependency duy nhất

Tách rời cho phép lib author control dependency graph một cách tinh tế. Ví dụ cụ thể: một service muốn dùng autoconfig của acme-tracing nhưng đã có acme-tracing-core qua đường khác — có thể khai báo chỉ acme-tracing-spring-boot-autoconfigure, không pull cả starter và tránh duplicate dependency. Nếu gộp 1 module, không có cách nào tách.

Lý do thứ hai: optional dependency. autoconfigure module khai báo acme-tracing-core dạng optional=true (không transitive), vì autoconfig chỉ activate khi class đó có trên classpath — @ConditionalOnClass. Nếu gộp starter, optional dependency sẽ không pull runtime lib cho user. Starter giải quyết bằng cách kéo cả hai dependency một lần.

Đối chiếu hai phương án đóng gói lib acme-tracing-core: cột trái gộp làm một module thì hoặc khai core bắt buộc khiến mọi consumer bị kéo theo core kể cả service không dùng tracing, hoặc khai core optional nên consumer không có core và điều kiện luôn false im lặng; cột phải tách hai module thì autoconfigure giữ logic và khai core optional, còn starter là jar rỗng pull explicit cả hai, nhờ đó consumer khai đúng một dependency mà điều kiện classpath vẫn có nghĩa

Quan hệ optional=true trên autoconfigurecore: khi ai đó khai báo chỉ autoconfigure mà không có core@ConditionalOnClass(Tracer.class) false → autoconfig bỏ qua. Không crash, không bean thừa. Đó chính là thứ phương án gộp một module không làm được: nó buộc phải chọn một trong hai vế, mà vế nào cũng làm điều kiện classpath mất ý nghĩa.

2. Cơ chế load — AutoConfiguration.imports

Trước khi viết code, cần hiểu Spring Boot phát hiện autoconfig từ đâu. Đây là cơ chế nền Boot 2.7+ (thay thế spring.factories):

Khi SpringApplication.run() chạy, AutoConfigurationImportSelector quét toàn bộ jar trên classpath tìm file:

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

File này là danh sách plain text, mỗi dòng 1 fully-qualified class name:

com.acme.tracing.TracingAutoConfiguration

Boot gom tất cả class từ mọi jar, áp @ConditionalOn* filter, rồi register class còn sót. Toàn bộ cơ chế này được đào sâu ở bài @EnableAutoConfiguration. Ở đây cần nhớ: không có file này → autoconfig không bao giờ chạy, dù class có @AutoConfiguration đầy đủ.

Pitfall: quên file AutoConfiguration.imports

Lỗi phổ biến nhất khi viết custom starter: class TracingAutoConfiguration có đủ annotation nhưng service dùng starter không thấy bean nào. Kiểm tra ngay file META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports có tồn tại trong jar built không, và class name có đúng chính tả không. Chạy /actuator/conditions (xem bài 04) để xác nhận autoconfig có được load không.

3. Viết custom starter từng bước

Ví dụ: team enterprise cần mọi service Spring Boot auto-configure distributed tracing endpoint giống nhau, với property acme.tracing.* chuẩn hoá.

3.1 Cấu trúc thư mục

acme-tracing-spring-boot-starter/
├── pom.xml                                   (jar rong, chi pull dependency)
└── src/  (khong co Java code)

acme-tracing-spring-boot-autoconfigure/
├── pom.xml
└── src/main/
    ├── java/com/acme/tracing/
    │   ├── TracingAutoConfiguration.java
    │   └── TracingProperties.java
    └── resources/
        └── META-INF/
            └── spring/
                └── org.springframework.boot.autoconfigure.AutoConfiguration.imports

3.2 Module autoconfigure — pom.xml

<project>
    <artifactId>acme-tracing-spring-boot-autoconfigure</artifactId>

    <dependencies>
        <!-- Spring Boot autoconfigure: @AutoConfiguration, @ConditionalOn*, @ConfigurationProperties -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-autoconfigure</artifactId>
        </dependency>

        <!-- optional=true: khong transitive sang consumer -->
        <!-- @ConditionalOnClass(Tracer.class) check runtime co class nay khong -->
        <dependency>
            <groupId>com.acme</groupId>
            <artifactId>acme-tracing-core</artifactId>
            <version>1.0.0</version>
            <optional>true</optional>
        </dependency>

        <!-- Annotation processor de generate spring-configuration-metadata.json -->
        <!-- -> IDE autocomplete cho acme.tracing.* property -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
</project>

3.3 TracingProperties — bind config

@ConfigurationProperties (được bóc ở bài 04 — Externalized Configuration) cho phép bind prefix acme.tracing vào POJO, với validation và relax binding (env var ACME_TRACING_ENDPOINT tự map):

@ConfigurationProperties(prefix = "acme.tracing")
@Validated
public class TracingProperties {
    private boolean enabled = true;

    /** Endpoint nhan span. Default: internal endpoint. */
    @NotBlank
    private String endpoint = "https://tracing.acme.internal";

    /** Ten service xuat hien tren dashboard tracing. Bat buoc set. */
    @NotBlank
    private String serviceName;

    // getters / setters
}

3.4 TracingAutoConfiguration — logic conditional

@AutoConfiguration
@ConditionalOnClass(Tracer.class)                                  // chi chay khi acme-tracing-core co tren classpath
@EnableConfigurationProperties(TracingProperties.class)
@ConditionalOnProperty(
    name = "acme.tracing.enabled",
    havingValue = "true",
    matchIfMissing = true                                          // default on, opt-out qua property
)
public class TracingAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean                                      // user co the override bang bean rieng
    public Tracer tracer(TracingProperties props) {
        return Tracer.builder()
            .endpoint(props.getEndpoint())
            .serviceName(props.getServiceName())
            .build();
    }
}

Ba annotation quan trọng giải thích cơ chế:

  • @ConditionalOnClass: Boot dùng ASM bytecode reader (không Class.forName) để check class tồn tại — nếu acme-tracing-core không có trên classpath, condition false, toàn bộ autoconfig bỏ qua mà không crash.
  • @ConditionalOnMissingBean: đây là "cơ chế nhường chỗ" — nếu service tự define bean Tracer, Boot không tạo bean mặc định. Người dùng luôn thắng.
  • @ConditionalOnProperty với matchIfMissing = true: mặc định bật, opt-out bằng acme.tracing.enabled=false. Pattern an toàn hơn matchIfMissing = false (mặc định tắt) cho lib infrastructure.

3.5 File AutoConfiguration.imports

# src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.acme.tracing.TracingAutoConfiguration

Một dòng, một class. Nếu có nhiều autoconfig, mỗi class 1 dòng. Comment bằng #.

3.6 Module starter — pom.xml rỗng class

<project>
    <artifactId>acme-tracing-spring-boot-starter</artifactId>

    <dependencies>
        <!-- autoconfigure module chua logic -->
        <dependency>
            <groupId>com.acme</groupId>
            <artifactId>acme-tracing-spring-boot-autoconfigure</artifactId>
            <version>1.0.0</version>
        </dependency>
        <!-- runtime lib: starter pull explicit de consumer khong phai khai bao rieng -->
        <dependency>
            <groupId>com.acme</groupId>
            <artifactId>acme-tracing-core</artifactId>
            <version>1.0.0</version>
        </dependency>
    </dependencies>
</project>

Không có Java source, không có src/main/java. Jar built chỉ chứa META-INF/maven/*/pom.xmlpom.properties.

3.7 Cách service dùng

<!-- Service chỉ cần 1 dependency -->
<dependency>
    <groupId>com.acme</groupId>
    <artifactId>acme-tracing-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
# application.yml
acme:
  tracing:
    service-name: order-service   # bat buoc
    # endpoint: default tu TracingProperties
    # enabled: true (default)

Boot startup: scan classpath → tìm AutoConfiguration.imports trong acme-tracing-spring-boot-autoconfigure.jar → load TracingAutoConfiguration@ConditionalOnClass(Tracer.class) pass (starter đã pull core) → @ConditionalOnMissingBean pass (service chưa define bean) → Tracer bean được tạo với config từ application.yml.

4. Naming convention — tại sao đảo namespace

Spring Boot Reference đặt quy tắc rõ ràng:

Loại starterConventionVí dụ
Official (Pivotal/Spring)spring-boot-starter-Xspring-boot-starter-web, spring-boot-starter-data-jpa
Third-party / nội bộX-spring-boot-starteracme-tracing-spring-boot-starter, springdoc-openapi-starter-webmvc-ui

Lý do đảo namespace: nếu third-party dùng prefix spring-boot-starter-acme-tracing, người dùng dễ nhầm đây là starter Pivotal maintain — nhầm kỳ vọng về support, security patch, compatibility. Bằng cách đặt tên riêng đứng trước (acme-), rõ ràng đây là starter bên ngoài hệ sinh thái chính thức.

Quy tắc tương tự áp với autoconfigure module: acme-tracing-spring-boot-autoconfigure (không phải spring-boot-autoconfigure-acme-tracing).

5. Supply chain security — vì sao quan tâm

Mỗi custom starter kéo acme-tracing-core + spring-boot-autoconfigure — vốn đã pull nhiều transitive jar. Mỗi jar là potential CVE. Tháng 12/2021, Log4Shell (CVE-2021-44228) — RCE critical trong Log4j2 — ảnh hưởng hầu như mọi app Java production. Spring Boot 2.6.2 patch trong 48 giờ, nhưng team nào không track dependency version đã mất nhiều ngày để tìm ra mình có bị ảnh hưởng không.

Supply chain attack không phải lý thuyết. Khi starter pull 50-100 transitive jar, bạn chịu trách nhiệm với mọi jar trong graph đó, không chỉ jar bạn khai báo trực tiếp.

5.1 CVE scan — OWASP Dependency-Check

OWASP Dependency-Check là tool phổ biến nhất cho Java Maven/Gradle. Nó tra cứu NVD (National Vulnerability Database) cho mọi transitive dependency:

<plugin>
    <groupId>org.owasp</groupId>
    <artifactId>dependency-check-maven</artifactId>
    <version>10.0.4</version>
    <configuration>
        <!-- Build fail khi co CVE CVSS vuot 7.0 (High/Critical) -->
        <failBuildOnCVSS>7</failBuildOnCVSS>
        <!-- Suppress false-positive da xem xet + co ghi chu ly do -->
        <suppressionFile>dependency-check-suppressions.xml</suppressionFile>
    </configuration>
    <executions>
        <execution>
            <goals><goal>check</goal></goals>
        </execution>
    </executions>
</plugin>

Chạy trong CI pipeline: mvn dependency-check:check. Build fail nếu transitive dependency có CVE CVSS cao hơn 7.0. File suppression lưu false-positive đã review có ghi chú ngày review và lý do — không suppress mù.

GitHub Dependabot cung cấp alert miễn phí cho repo public và private. Snyk có dashboard enterprise với license scan (không chỉ CVE).

5.2 SBOM — Software Bill of Materials

SBOM (Software Bill of Materials) là bản kê khai toàn bộ component của artifact: tên, version, license, dependency tree. Chuẩn phổ biến: CycloneDX và SPDX.

Spring Boot 3.3+ generate SBOM tự động qua Actuator:

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: sbom

Endpoint /actuator/sbom trả JSON CycloneDX listing toàn bộ jar trong runtime classpath. Dùng cho:

  • Audit compliance (SOC 2, ISO 27001): chứng minh bạn biết mình dùng gì.
  • Incident response: khi CVE mới xuất hiện, tra SBOM ngay để biết service nào bị ảnh hưởng.
  • License audit: phát hiện lib GPL trong product commercial.

Với Maven, có thể generate SBOM lúc build (không cần runtime):

mvn org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom
# Output: target/bom.xml (CycloneDX format)

5.3 Chiến lược upgrade — không để dẫm vào Log4Shell lần 2

Pipeline chuẩn có bốn chặng: Renovate hoặc Dependabot tự tạo PR khi có version mới → CI chạy full test kèm dependency-check → human review đọc breaking change → deploy theo schedule. Phần khó không nằm ở pipeline mà ở nhịp deploy:

  • Minor/patch bump (Boot 3.4.x → 3.4.y): patch CVE, ít risk. Auto-merge sau CI green với human spot-check.
  • Major bump (Boot 3.x → 4.x): breaking change. Plan 2-3 sprint, đọc migration guide, test regression.
  • 1 dependency / sprint: không batch nhiều dependency upgrade cùng lúc — rollback phức tạp khi có vấn đề.
  • CVE emergency (CVSS vượt 9.0 như Log4Shell): upgrade ngay, không chờ sprint cycle. Có pipeline fast-track riêng.

Spring Boot release lifecycle cần nắm để plan upgrade:

VersionOSS support kết thúcCommercial support kết thúc
Boot 3.32025-052027-05
Boot 3.42025-112027-11
Boot 3.52026-052028-05

Khi OSS support kết thúc, security patch không được backport. Chạy version unsupported = chấp nhận rủi ro CVE không được vá.

5.4 Minimize attack surface — exclude transitive không dùng

Mỗi jar removed = giảm 1 CVE potential + giảm image size. Exclude có chủ đích:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <!-- Khong dung XML endpoint, giam attack surface -->
            <groupId>com.fasterxml.jackson.dataformat</groupId>
            <artifactId>jackson-dataformat-xml</artifactId>
        </exclusion>
        <exclusion>
            <!-- Khong dung JSP/EL, Tomcat EL parser khong can -->
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-el</artifactId>
        </exclusion>
    </exclusions>
</dependency>

Khi viết custom starter, cũng áp quy tắc này trong autoconfigure module: khai báo chính xác dependency cần, không kéo thêm transitive thừa.

6. Cơ chế bên dưới — tại sao @ConditionalOnMissingBean là "nhường chỗ"

Đây là mấu chốt khiến custom starter không bao giờ override bean của application. Khi Boot xử lý TracingAutoConfiguration, nó hỏi đúng một câu — đã có bean kiểu Tracer trong context chưa? Có rồi thì bỏ qua @Bean mặc định và giữ nguyên bean của user; chưa có thì mới tạo Tracer từ TracingProperties. Bài 05 mổ kỹ câu hỏi này, kể cả chỗ nó phụ thuộc thứ tự evaluate.

Cơ chế này đảm bảo library không bao giờ cướp bean của application. Người dùng luôn có quyền override bằng cách define bean cùng type. Đây là thiết kế "convention over configuration" — default tốt sẵn, nhưng override dễ dàng không cần subclass hay extend.

Lý do Boot dùng cơ chế này thay vì @Primary: @Primary chỉ giải quyết ambiguity khi có nhiều bean, không ngăn bean thừa. @ConditionalOnMissingBean không tạo bean ngay từ đầu nếu không cần — sạch hơn, ít side effect hơn.

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

  • Bài 02 — Starter Dependencies: nền tảng hiểu BOM, transitive dependency tree, và outline của custom starter. Bài này đào sâu hơn vào cơ chế load autoconfig và supply chain.
  • Bài 04 — @EnableAutoConfiguration: AutoConfiguration.imports chỉ là file text — cơ chế AutoConfigurationImportSelector đọc và filter nó (ASM bytecode, @ConditionalOn* evaluation) được bóc hoàn toàn ở bài đó. Custom starter bạn vừa viết chạy đúng cơ chế này.
  • Bài 02 — Externalized Configuration: @ConfigurationProperties trong TracingProperties là type-safe binding — cách Boot map property acme.tracing.* vào POJO, validation, relax binding cho K8s env var.
  • Container Internals: bean Tracer do autoconfig tạo ra sống trong singletonObjects map của DefaultListableBeanFactory — hiểu container giúp debug "tại sao bean không tạo" sâu hơn chỉ nhìn autoconfig conditions.

Tóm tắt

  • Custom starter tách 2 module (*-autoconfigure + *-starter) để tách concern: autoconfigure chứa logic, starter chứa dependency declaration. Tách cho phép consumer chọn pull chỉ autoconfigure nếu cần.
  • *-autoconfigure khai báo runtime lib dạng optional=true — không transitive; *-starter pull explicit cả 2 module để consumer có đủ.
  • File META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports là entry point Boot scan. Thiếu file này → autoconfig không chạy dù annotation đầy đủ.
  • @ConditionalOnMissingBean là "cơ chế nhường chỗ": user define bean riêng → Boot không tạo default. User luôn thắng.
  • Naming convention: official dùng spring-boot-starter-X; third-party và nội bộ dùng X-spring-boot-starter để tránh nhầm nguồn gốc.
  • Supply chain: mỗi starter kéo 50-100 transitive jar. OWASP Dependency-Check scan CVE (build fail khi CVSS vượt 7), SBOM qua /actuator/sbom cho audit, upgrade theo sprint cycle với fast-track cho CVE emergency.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Tại sao custom starter tách thành 2 module riêng biệt (*-autoconfigure*-starter) thay vì gộp 1? Lợi ích cụ thể là gì?
  2. Q2
    File META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports có vai trò gì? Điều gì xảy ra nếu thiếu file này khi viết custom starter?
  3. Q3
    @ConditionalOnMissingBean trên bean trong autoconfig có ý nghĩa gì? Nó khác @Primary ra sao?
  4. Q4
    Naming convention của custom starter là gì? Vì sao third-party không nên đặt tên theo format spring-boot-starter-X?
  5. Q5
    Team bạn discover transitive dependency trong starter có CVE CVSS 8.5 (High). Describe quy trình xử lý từ discovery đến resolution, bao gồm tool và decision points.

Bài tiếp theo: @EnableAutoConfiguration & AutoConfiguration.imports

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?

Ôn phỏng vấn

Bài này trả lời được các câu phỏng vấn sau — tự trả lời thử trước khi mở đáp án.

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

Spring Boot Auto-Configuration hoạt động thế nào?