Spring Core & Boot/Mini-challenge: tự build mini IoC container 80 dòng
13/41
Bài 13 / 41~30 phútContainer InternalsMiễn phí lượt xem

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

Bài thực hành khép lại Module 1 — code 1 IoC container đơn giản đủ tính năng: scan @Component, resolve dependency qua constructor, lifecycle @PostConstruct. Chứng minh Spring không phải magic — chỉ là reflection + topological sort + map cache.

TL;DR: Bài lab khép lại module container internals: bạn tự code một IoC container ~80-100 dòng Java thuần — scan classpath tìm @Component, build dependency graph từ constructor signature, topological sort (Kahn) để tính thứ tự khởi tạo, rồi gọi @PostConstruct. Làm xong, bạn tự chứng minh Spring không phải magic: chỉ là reflection + graph + map cache. Circular dependency lộ ra đúng ở bước sort — cùng cách Spring fail-fast tại startup.

Các module trước của course đã bóc tách đủ tầng: IoC principle, DI implementation, ApplicationContext nội tại, lifecycle 9 bước, scope, 3 cách khai báo bean. Bài cuối này không thêm khái niệm mới — bạn tự code 1 IoC container đơn giản từ scratch để chứng minh: Spring không phải magic, chỉ là reflection + topological sort + map.

Kết thúc bài, bạn có 1 mini container ~80-100 dòng Java thuần. Nó scan classpath tìm @Component, instantiate với constructor injection, gọi @PostConstruct, expose qua getBean(). So sánh với Spring source thật, bạn sẽ thấy Spring làm chính xác việc này — chỉ với nhiều edge case hơn, BPP, AOP, scope, profile.

🎯 Đề bài

Build class MiniContainer với 3 method public:

public class MiniContainer {
    public MiniContainer(String basePackage) { /* scan + register definitions */ }
    public void start() { /* instantiate all + call @PostConstruct */ }
    public <T> T getBean(Class<T> type) { /* lookup by type */ }
}

Yêu cầu cụ thể:

1. Annotation tự định nghĩa

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Component {}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PostConstruct {}

(Không dùng org.springframework.* hay jakarta.annotation.* — viết lại từ đầu để hiểu cơ chế.)

2. Component scan

new MiniContainer("com.demo") quét classpath, tìm class có @Component trong package com.demo và sub-package. Lưu metadata vào Map<Class<?>, BeanDefinition>.

3. Constructor injection

Chỉ hỗ trợ 1 constructor public — Spring có thể nhiều constructor + @Autowired, mini container đơn giản hoá. Mỗi parameter resolve theo type (tìm bean cùng type trong registry).

4. Topological sort

Khi start(), container tính thứ tự instantiate sao cho dependency của 1 bean được tạo trước nó. Nếu phát hiện circular dependency, container throw IllegalStateException("Circular dependency detected").

Vòng A→B→C→A bị lộ ngay trong lúc sort: không node nào còn lại có đủ dependency đã tạo, nên thuật toán kẹt — đó chính là tín hiệu cycle:

flowchart LR
    A -->|"constructor can B"| B
    B -->|"constructor can C"| C
    C -->|"constructor can A"| A
    K["Kahn: khong node nao<br/>het dependency -- cycle!"]
    style K fill:#fee2e2,stroke:#ef4444

5. @PostConstruct

Sau khi instantiate xong tất cả bean, gọi method có @PostConstruct (không cần void/0-param check phức tạp — giả định method đúng signature).

6. Singleton scope

Tất cả bean là singleton — getBean() return cached instance.

7. Test case

@Component
class GreetingService {
    public String greet(String name) { return "Hello, " + name; }
}

@Component
class UserService {
    private final GreetingService greeting;
    private boolean initialized;

    public UserService(GreetingService greeting) {
        this.greeting = greeting;
    }

    @PostConstruct
    void init() { initialized = true; }

    public String welcome(String name) {
        if (!initialized) throw new IllegalStateException("not initialized");
        return greeting.greet(name);
    }
}

public class App {
    public static void main(String[] args) throws Exception {
        var container = new MiniContainer("com.demo");
        container.start();

        UserService us = container.getBean(UserService.class);
        System.out.println(us.welcome("OLHub"));   // Hello, OLHub
    }
}

Output mẫu:

Scanned 2 components
Instantiated GreetingService
Instantiated UserService
Called @PostConstruct on UserService
Hello, OLHub

🔍 Phân tích I-P-O

Input: Annotation @Component, @PostConstruct trên class trong package được scan.

Processing:

  1. Scan — đọc all class file trong package, lọc class có @Component annotation.
  2. Build dependency graph — mỗi class node, edge từ class A sang class B nếu A's constructor có parameter type B.
  3. Topological sort — DFS/BFS từ leaf node. Phát hiện cycle.
  4. Instantiate theo thứ tự — gọi constructor với bean cache của dependency. Lưu vào registry.
  5. Lifecycle — gọi @PostConstruct sau khi instantiate xong.

Output: Method getBean(Class) trả instance.

Toàn bộ pipeline build container — chính là phiên bản rút gọn của refresh():

flowchart LR
    S["1. Scan classpath<br/>loc @Component"] --> G["2. Build dependency graph<br/>tu constructor param"]
    G --> T["3. Topological sort<br/>(Kahn)"]
    T --> I["4. Instantiate theo thu tu<br/>resolve dependency tu cache"]
    I --> P["5. Goi @PostConstruct"]
    T -->|"khong tim duoc<br/>node ready"| E["IllegalStateException:<br/>Circular dependency"]
    style E fill:#fee2e2,stroke:#ef4444

📦 Concept dùng trong bài

ConceptBài nguồnDùng ở đây
IoC principleIoC & DIContainer "drives", code đăng ký intent
Constructor injectionDependency InjectionResolve dependency qua param type
ApplicationContextBeanFactory vs ApplicationContextMini ApplicationContext = MiniContainer
Bean lifecycleBean lifecycle phasesInstantiate → populate (qua constructor) → @PostConstruct
Singleton scopeSingleton & PrototypeMap<Class, Object> cache 1 instance / type
@Component scanStereotypes & component scanQuét annotation tự định nghĩa

▶️ Starter code

import java.io.*;
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.net.URL;
import java.util.*;
import java.util.stream.*;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@interface Component {}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@interface PostConstruct {}

public class MiniContainer {

    private final String basePackage;
    private final Map<Class<?>, Object> beans = new LinkedHashMap<>();
    private final List<Class<?>> definitions = new ArrayList<>();

    public MiniContainer(String basePackage) throws Exception {
        this.basePackage = basePackage;
        scan();
    }

    private void scan() throws Exception {
        // TODO: doc class file trong basePackage, loc class co @Component, them vao definitions
    }

    public void start() throws Exception {
        // TODO:
        // 1. topological sort definitions
        // 2. instantiate theo thu tu, resolve dependency tu beans cache
        // 3. goi @PostConstruct method
    }

    @SuppressWarnings("unchecked")
    public <T> T getBean(Class<T> type) {
        // TODO: tim trong beans, return instance
        return null;
    }
}

Dành 30 phút tự làm. Hint ở dưới khi kẹt.

💡 Gợi ý

💡 Gợi ý — đọc khi bị kẹt

Scan classpath — đọc tất cả class file trong package qua ClassLoader:

private void scan() throws Exception {
    String path = basePackage.replace('.', '/');
    URL root = Thread.currentThread().getContextClassLoader().getResource(path);
    if (root == null) throw new IllegalStateException("Package not found: " + basePackage);

    File dir = new File(root.toURI());
    for (File file : Objects.requireNonNull(dir.listFiles())) {
        if (file.getName().endsWith(".class")) {
            String className = basePackage + "." + file.getName().replace(".class", "");
            Class<?> clazz = Class.forName(className);
            if (clazz.isAnnotationPresent(Component.class)) {
                definitions.add(clazz);
            }
        }
    }
    System.out.println("Scanned " + definitions.size() + " components");
}

(Đơn giản hoá — không recurse sub-package. Bài này chỉ scan 1 level. Spring làm full recursive với ASM bytecode reader.)

Topological sort — Kahn's algorithm, 3 bước (code đầy đủ trong Lời giải):

  • Build map class sang set dependency từ getConstructors()[0].getParameterTypes() — chỉ giữ type có trong definitions.
  • Lặp: tìm class có mọi dependency đã nằm trong sorted; thêm nó vào sorted, xoá khỏi map.
  • Map chưa rỗng mà không class nào "ready" — đó là cycle: throw IllegalStateException("Circular dependency detected").

Instantiate + lifecycle — duyệt danh sách đã sort: lấy getConstructors()[0], map từng param type sang instance trong beans cache, gọi newInstance(args), put vào beans; sau đó duyệt getDeclaredMethods() tìm @PostConstructinvoke (nhớ setAccessible(true)).

getBean — tra beans.get(type); null thì throw IllegalStateException("No bean of type ...").

✅ Lời giải

✅ Lời giải đầy đủ — xem sau khi đã thử
import java.io.*;
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.net.URL;
import java.util.*;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@interface Component {}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@interface PostConstruct {}

public class MiniContainer {

    private final String basePackage;
    private final Map<Class<?>, Object> beans = new LinkedHashMap<>();
    private final List<Class<?>> definitions = new ArrayList<>();

    public MiniContainer(String basePackage) throws Exception {
        this.basePackage = basePackage;
        scan();
    }

    private void scan() throws Exception {
        String path = basePackage.replace('.', '/');
        URL root = Thread.currentThread().getContextClassLoader().getResource(path);
        if (root == null) {
            throw new IllegalStateException("Package not found: " + basePackage);
        }
        File dir = new File(root.toURI());
        File[] files = Objects.requireNonNull(dir.listFiles());
        for (File f : files) {
            if (!f.getName().endsWith(".class")) continue;
            String className = basePackage + "." + f.getName().replace(".class", "");
            Class<?> clazz = Class.forName(className);
            if (clazz.isAnnotationPresent(Component.class)) {
                definitions.add(clazz);
            }
        }
        System.out.println("Scanned " + definitions.size() + " components");
    }

    public void start() throws Exception {
        for (Class<?> c : topologicalSort()) {
            Constructor<?> ctor = c.getConstructors()[0];
            Class<?>[] paramTypes = ctor.getParameterTypes();
            Object[] args = new Object[paramTypes.length];
            for (int i = 0; i < paramTypes.length; i++) {
                args[i] = beans.get(paramTypes[i]);
                if (args[i] == null) {
                    throw new IllegalStateException(
                        "Cannot resolve dependency " + paramTypes[i].getName() +
                        " for " + c.getName());
                }
            }
            Object instance = ctor.newInstance(args);
            beans.put(c, instance);
            System.out.println("Instantiated " + c.getSimpleName());
            invokePostConstruct(c, instance);
        }
    }

    private List<Class<?>> topologicalSort() {
        Map<Class<?>, Set<Class<?>>> remaining = new LinkedHashMap<>();
        for (Class<?> c : definitions) {
            Constructor<?> ctor = c.getConstructors()[0];
            Set<Class<?>> dependencies = new HashSet<>();
            for (Class<?> p : ctor.getParameterTypes()) {
                if (definitions.contains(p)) dependencies.add(p);
            }
            remaining.put(c, dependencies);
        }

        List<Class<?>> sorted = new ArrayList<>();
        while (!remaining.isEmpty()) {
            Class<?> ready = null;
            for (Map.Entry<Class<?>, Set<Class<?>>> e : remaining.entrySet()) {
                if (sorted.containsAll(e.getValue())) {
                    ready = e.getKey();
                    break;
                }
            }
            if (ready == null) {
                throw new IllegalStateException(
                    "Circular dependency detected among: " + remaining.keySet());
            }
            sorted.add(ready);
            remaining.remove(ready);
        }
        return sorted;
    }

    private void invokePostConstruct(Class<?> c, Object instance) throws Exception {
        for (Method m : c.getDeclaredMethods()) {
            if (m.isAnnotationPresent(PostConstruct.class)) {
                m.setAccessible(true);
                m.invoke(instance);
                System.out.println("Called @PostConstruct on " + c.getSimpleName());
            }
        }
    }

    @SuppressWarnings("unchecked")
    public <T> T getBean(Class<T> type) {
        Object bean = beans.get(type);
        if (bean == null) {
            throw new IllegalStateException("No bean of type " + type.getName());
        }
        return (T) bean;
    }
}

Test driver:

package com.demo;

@Component
class GreetingService {
    public String greet(String name) { return "Hello, " + name; }
}

@Component
class UserService {
    private final GreetingService greeting;
    private boolean initialized;

    public UserService(GreetingService greeting) {
        this.greeting = greeting;
    }

    @PostConstruct
    void init() { this.initialized = true; }

    public String welcome(String name) {
        if (!initialized) throw new IllegalStateException("not initialized");
        return greeting.greet(name);
    }
}

public class App {
    public static void main(String[] args) throws Exception {
        var container = new MiniContainer("com.demo");
        container.start();
        UserService us = container.getBean(UserService.class);
        System.out.println(us.welcome("OLHub"));
    }
}

Output:

Scanned 2 components
Instantiated GreetingService
Instantiated UserService
Called @PostConstruct on UserService
Hello, OLHub

Giải thích từng phần:

  • scan() — dùng ClassLoader.getResource(path) để lấy URL của package, list file .class, load qua Class.forName, kiểm tra annotation. Đơn giản hoá so với Spring (Spring dùng ASM bytecode reader để không phải Class.forName toàn bộ — hiệu năng tốt hơn cho classpath lớn).

  • topologicalSort() — Kahn's algorithm: tìm node có tất cả dependency đã sorted (in-degree = 0 trong sub-graph còn lại), thêm vào sorted, loại khỏi remaining. Lặp đến khi cạn hoặc không tìm được node ready (= cycle).

  • start() — duyệt sorted, lấy constructor đầu tiên, resolve param từ beans cache, gọi newInstance. Hậu init: tìm method có @PostConstruct và invoke.

  • getBean() — direct map lookup. Spring có overload theo name, qualifier, phức tạp hơn nhiều.

🎓 Mở rộng

Mức 1 — Field injection với @Autowired:

Thêm annotation @Autowired, sau khi newInstance thì duyệt field có annotation, set value từ cache. Lưu ý: field có thể private → cần setAccessible(true).

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@interface Autowired {}

private void injectFields(Class<?> c, Object instance) throws Exception {
    for (Field f : c.getDeclaredFields()) {
        if (f.isAnnotationPresent(Autowired.class)) {
            f.setAccessible(true);
            f.set(instance, beans.get(f.getType()));
        }
    }
}

Mức 2 — @PreDestroy và shutdown:

Thêm method close(), gọi method @PreDestroy theo thứ tự ngược dependency (LIFO). Wrap container trong AutoCloseable để dùng try-with-resources.

Mức 3 — Recursive scan sub-package:

Hiện tại chỉ scan 1 level. Convert scan() thành scanRecursive(File dir, String currentPackage) để duyệt sub-folder.

Mức 4 — Resolve theo interface:

Hiện tại bean lookup theo class concrete. Spring resolve theo interface — vd @Autowired PaymentGateway tìm bean implement PaymentGateway. Thêm logic: khi resolve param type là interface, scan registry tìm bean class implement interface đó.

private Object resolve(Class<?> type) {
    Object exact = beans.get(type);
    if (exact != null) return exact;
    // Tim bean implement interface
    return beans.entrySet().stream()
        .filter(e -> type.isAssignableFrom(e.getKey()))
        .map(Map.Entry::getValue)
        .findFirst()
        .orElseThrow(() -> new IllegalStateException("No bean for " + type.getName()));
}

Pitfall: nhiều bean implement cùng interface → ambiguity. Spring giải bằng @Primary/@Qualifier. Mức này tự thêm.

Mức 5 — @Configuration + @Bean method:

Hỗ trợ class @Configuration chứa method @Bean. Container scan thấy @Configuration, instantiate config, invoke từng @Bean method, register kết quả. Không cần CGLIB enhancement (đơn giản hoá — chấp nhận lite mode).

✅ Checklist hoàn thành

Đối chiếu trước khi xem lời giải là "đạt":

  • scan() tìm đúng mọi class có @Component trong package (output "Scanned 2 components").
  • Constructor injection resolve dependency theo param type, không hardcode thứ tự.
  • Topological sort cho thứ tự đúng: GreetingService instantiate trước UserService.
  • Thêm vòng A→B→C→A vào test, container throw IllegalStateException thay vì treo hoặc StackOverflowError.
  • @PostConstruct chạy sau khi instantiate — welcome() không ném "not initialized".
  • getBean() trả về cùng một instance qua nhiều lần gọi (singleton).

✨ Điều bạn vừa làm được

Hoàn thành mini-challenge này, bạn đã:

  • Bóc lớp magic của Spring: container chỉ là classpath scanner + reflection + topological sort + map cache. Không có gì huyền bí.
  • Hiểu vì sao circular dependency phát hiện được tại startup: topological sort không complete được.
  • Hiểu vì sao constructor injection ưu việt: container có thể tính graph từ constructor signature ngay từ class load — không cần "lazy resolve".
  • Hiểu lifecycle order: instantiate xong toàn bộ rồi mới gọi @PostConstruct. Có lý do — @PostConstruct có thể gọi method của bean khác (đã được populate field), nếu chưa init xong tất cả → NPE.
  • Đo được khoảng cách đến Spring thật: ~80-100 dòng vs hàng trăm nghìn dòng. Spring thêm: BPP, AOP proxy, scope multi-tier, qualifier, conditional, profile, externalized config, event publisher, ... Toàn lớp xây trên cùng nền tảng đơn giản này.

Tự kiểm tra

Tự kiểm tra
Q1
Kahn's algorithm phát hiện circular dependency tại điểm nào? Vì sao Spring thật không sort toàn cục như mini container mà vẫn bắt được cycle?

Kahn kẹt khi không còn node nào có toàn bộ dependency đã nằm trong danh sách sorted — tập remaining khác rỗng nhưng không node nào "ready". Đó chính là dấu hiệu cycle.

Spring không tính trước thứ tự toàn cục: nó resolve đệ quy qua getBean — gặp dependency chưa có thì tạo ngay lúc đó. Cycle được bắt bằng singletonsCurrentlyInCreation: nếu bean đang trong quá trình tạo lại bị yêu cầu tạo lần nữa, Spring throw BeanCurrentlyInCreationException. Cách lười này cho phép lazy bean, prototype, và three-level cache hoạt động — sort tĩnh toàn cục thì không.

Q2
Mini container chỉ hỗ trợ constructor injection nên bắt buộc phải topological sort. Vì sao field injection cho phép Spring tạo bean theo thứ tự lỏng hơn?

Constructor injection đòi mọi dependency tồn tại sẵn ngay lúc newInstance — không có object thì không gọi được constructor, nên thứ tự tạo phải đúng tuyệt đối từ đầu.

Field injection tách hai pha: tạo object rỗng trước (constructor không cần dependency), inject sau. Spring tận dụng điều này với three-level cache — expose early reference của bean chưa hoàn chỉnh để bean khác inject, nhờ đó giải được cả vòng lặp field/setter. Đây cũng là lý do constructor circular không giải được: chưa có this để expose.

Q3
Vì sao Spring scan classpath bằng ASM bytecode reader thay vì Class.forName như mini container?

Class.forName load và initialize class: chạy static initializer, cấp phát metaspace — cho cả hàng nghìn class không phải component. Trên classpath lớn, điều này chậm và có side effect (static block có thể mở connection, đọc file).

ASM chỉ đọc bytecode của class file để xem header + annotation, không load class vào JVM. Spring chỉ Class.forName đúng những class thật sự là bean. Mini container chấp nhận đơn giản hoá vì chỉ scan vài class demo.

Q4
Lời giải gọi invokePostConstruct ngay sau khi instantiate từng bean (trong loop). Điều gì đảm bảo @PostConstruct của một bean có thể gọi method của dependency một cách an toàn?

Thứ tự topological: khi bean X được instantiate, mọi dependency của X đã instantiate xong và đã chạy @PostConstruct của chính chúng (chúng đứng trước X trong danh sách sorted).

Lưu ý khác biệt với Spring: Spring cũng đảm bảo dependency sẵn sàng trước khi init, nhưng theo cách đệ quy per-bean chứ không phải sort trước. Bất biến chung của cả hai: init logic chỉ chạy khi dependency đã dùng được — cùng nguyên tắc với 9 giai đoạn lifecycle ở bài Bean lifecycle phases.

Chúc mừng — bạn đã nắm vững phần IoC container! Bạn đã có nền tảng IoC vững chắc. Phần Spring Boot Foundations (các module cuối course) sẽ bóc tách: auto-configuration làm gì, starter đóng gói cái gì, profile và externalized config — toàn bộ "công thức nấu sẵn" mà Spring Boot thêm vào Spring Framework.

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