+19

Spring IoC container - Tự viết một cái @Autowire như Spring Boot

Dependency inversion - Dependency injection - Inversion of Control

Có chắc bạn thật sự hiểu rõ về 3 khái niệm trên? Bạn có tự tin có thể áp dụng 3 principle/pattern trên để triển khai một library tương tự như Spring Boot không?

Chỉ tập trung vào Spring core feature với Spring context và bean management. Chứ cả Spring Boot thì nhiều component lắm, về cơ bản chỉ cần làm được "trái tim" của Spring là được rồi.

Mình có cơ hội được nói chuyện (phỏng vấn) với cũng kha khá các bạn trẻ có lớn tuổi cũng có, đa số khi hỏi về DI thì đều nhận được câu mở đầu đại khái DI là trái tim của Spring, cụ thể là Spring IoC container. Nhưng cụ thể trái tim ấy hoạt động thế nào, triển khai ra sao thì không nhiều bạn nắm được. Vậy cùng tìm hiểu mổ xẻ xem trái tim này nó thật sự là cái gì và làm thử một cái đơn giản xem có được không?

Ok, gét gô!

1) Dependency inversion

Có rất nhiều bài viết về Dependency inversion rồi, các bạn có thể tìm đọc thêm nhé. Mình đi vào phần chính luôn.

Đúng theo thể lệ của chương trình, mình xin phép gửi lời chúc mừng nằm mới đến tất cả mọi người. Chúc mừng năm mới sức khoẻ, vui vẻ, bình an, hạnh phúc!

1.1) Problem

Nếu bạn đã quen với phong cách viết bài của mình thì chắc chắn không còn xa lạ gì với việc lấy ví dụ ngoài đời thực. Nó thực tế, trực quan, từ đó giúp chúng ta dễ thẩm thấu hơn so với việc đọc một mớ lí thuyết dài dòng mà chả hiểu gì.

Bất cứ giải pháp nào được sinh ra đều để giải quyết một vấn đề nào đó đang tồn tại. Và kết quả của giải pháp đó có thể có 3 trường hợp:

  • Thứ nhất: solution hoàn hảo, không tạo ra bất kì một lỗ hổng hay nhược điểm nào.
  • Thứ hai: solution chấp nhận được, nó giải quyết được vấn đề đang có. Nhưng nó cũng tạo ra một vài vấn đề khác, nhưng vấn đề mới là chấp nhận được và trong tầm kiểm soát.
  • Cuối cùng: solution thất bại, đống sh!t x2.

Và chắc chắn rồi, không gì là hoàn hảo, đúng nhận sai cãi nào, nói nhanh gọn là cái gì cũng có 2 mặt.

Thông cảm bài biết phải đủ dài mới được duyệt nên hơi lan man. Vào vấn đề chính này.

Mình đi ngược một chút, đi từ vấn đề trước khi giải thích cụ tỉ DI là gì mà nó lại quan trọng đến thế, được coi là trái tim của cả một framework Spring to đồ sộ.

Thảo sau chục năm năm làm Senior SA ở Ađâynè nhưng vẫn chẳng giàu lên được, đời IT sao chông chênh quá. Thảo quyết tâm đổi đời bằng cách nhảy ra mở công ty riêng Akiakìa chuyên phân phối iPhone cho thị trường miền Bắc. Thảo hi vọng một ngày ông trời sẽ mỉm cười.. với cái cuộc đời đầy đáng thương của em (thương em lắm).

Các linh kiện để lắp ráp nên con iPhone đều không được sản xuất ở Akiakìa, và càng không phải là Apple:

  • Màn hình được sản xuất bởi Samsung hoặc LG.
  • Pin được sản xuất bởi Remax hoặc Orizin hoặc Pisen.
  • Chip được sản xuất bởi Intel hoặc Samsung.

Sau cùng, tất cả các sản phẩm được tập kết về nhà Thảo... à nhầm nhà máy để tiến hành lắp ráp.

Tương tự trong lập trình, cụ thể là theo OOP, các chúng ta nên chia tách các object tương ứng với từng class, và các class đó sẽ có quan hệ với nhau.

Ví dụ, chiếc iPhone bao gồm các thiết bị phần cứng RAM, Chip được thể hiện như sau:

@Getter
@AllArgsConstructor
class Ram {
    
    private String producer;
    private String specification;
    
}

@Getter
@AllArgsConstructor
class Cpu {
    
    private String producer;
    private String specification;
    
}

@Slf4j
class SmartPhone {
    
    private Ram ram;
    private Cpu cpu;
    
    public void printSpecification() {
        log.info("RAM: {} is produced by: {}", ram.getSpecification(), ram.getProducer());
        log.info("CPU: {} is produced by: {}", cpu.getSpecification(), cpu.getProducer());
    }
}


public class Application {
    
    public static void main(String[] args) {
        var iPhone = createNewIphone();
        iPhone.printSpecification();
    }
    
    private static SmartPhone createNewIphone() {
        return new SmartPhone();
    }
    
}

Khi chạy đoạn code trên thì kết quả là NPE (Null Pointer Exception). Chúng ta cần initialize giá trị cho 2 variables ramchip.

Mình tin đa số các bạn đều biết lombok và các annotation @Getter @AllArgsConstructor nên sử dụng luôn cho code ngắn gọn.

Một cách thức ăn liền là tạo constructor và sử dụng keywork new để init ramchip. Sửa đoạn code trên như sau:

@Slf4j
class SmartPhone {
    
    private Ram ram;
    private Cpu cpu;
    
    SmartPhone() {
        ram = new Ram("Kingston", "16GB");
        chip = new Chip("Intel", "2.8Ghz");
    }
    
    public void printSpecification() {
        log.info("RAM: {} is produced by: {}", ram.getSpecification(), ram.getProducer());
        log.info("CPU: {} is produced by: {}", cpu.getSpecification(), cpu.getProducer());
    }
}

Hết lỗi luôn, tuyệt vời ông mặt zời. Thế nhưng mọi chuyện chưa dừng lại ở đấy.

Quay lại ví dụ, chiếc iPhone cần đảm bảo được thiết kế để phù hợp với chip được sản xuất của cả SamsungIntel, các thiết bị phần cứng khác cũng tương tự. Ví dụ khách hàng nhờ Thảo nâng cấp RAM cho iPhone, Thảo chỉ việc nhẹ nhàng tháo RAM cũ và lắp RAM mới thế là xong.

Nhưng với lập trình thì sao, đoạn code trên nếu muốn thay RAM thì làm thế nào?

  • Vào sửa code new Ram("16GB") thành new Ram("64GB").

Có vẻ không ổn. Nếu muốn có một chiếc smart phone khác với RAM 8GB thì sao, sửa code và re-deploy à? Hoặc muốn sửa thông số khi runtime thay vì compile time thì sao?

  • SmartPhone đang phụ thuộc trực tiếp vào RamCpu. Rất khó thay đổi giá trị trong quá trình runtime.

Ví dụ trên là trường hợp đơn giản, thử tưởng tượng đến lúc thực hiện cho class SmartPhone, nếu code không khéo có khi phải test cả class RamCpu, trong khi mục đích chính chỉ muốn test class SmartPhone.

Một ví dụ khác, bạn muốn test accountingService, accountingService lúc này phụ thuộc vào paymentService. Mà paymentService gọi đến 3rd-party, tính phí trên mỗi request. Nếu viết code như trên, khởi tạo paymentService để test accountingService, như vậy tiền chạy test khéo hơn cả tiền chạy sản phẩm thật.

Việc code như trên khiến code khó sửa đổi/mở rộng. Việc thực hiện UT trở nên.. phức tạp hơn, tốn kém hơn.

Ok. Vấn đề đã có rồi, vậy giải pháp như nào?

1.2) Solution

Tập trung suy nghĩ xem bài toán này trên thực tế được giải quyết thế nào trước khi đến việc code như thế nào nhé.

Hãy coi Apple đã có sẵn bộ khung iPhone, là class SmartPhone. Apple không trực tiếp sản xuất RAM, Chip, mà outsource cho 3rd-party. Như vậy nói ngắn gọn object iPhone đang depend (phụ thuộc) vào 2 objects ramchip. Tất nhiên sẽ có nhiều hãng sản xuất chip cho Apple, chỉ cần tương thích là được. Chip và ram lúc này là dependency (đối tượng phụ thuộc) của iPhone.

Apple đã có sẵn dây chuyền, chỉ chờ các linh kiện về đầy đủ là tiến hành lắp ráp thôi. Hãy tập trung vào câu chờ các linh kiện về đẩy đủ để tiến hành lắp ráp. Tức là sao?

  • Hãy coi dây chuyền lắp ráp giống như method printSpecification(), mọi thứ đều ready, code ngon lành sạch sẽ rồi, chỉ chờ ramchip được initialize là mọi thứ hoạt động một cách hoàn hảo.
  • Việc initialize object lúc này là của Samsung hoặc Intel, sau đó một con CPU hoàn chỉnh được ship đến cho Apple. Tất nhiên trong lập trình thì lấy đâu ra Samsung hay là Intel. Chúng ta tạm coi nó là một service đặc biệt có nhiệm vụ khởi tạo và vận chuyển các object đưa chúng đến nơi cần đến. Hay nói cách khác là inject các dependency vào object. Phần này đặc biệt quan trọng, note lại nhé. Và dependency injection chỉ đơn giản như vậy thôi.

Túm cái váy lại, việc khởi tạo các dependency không nằm trực tiếp tại object mà nó nằm bên ngoài object đó. Sau khi khởi tạo xong, việc cần làm là inject nó vào object chính để mọi thứ hoạt động đúng như mong muốn.

Trong trường hợp muốn nâng cấp ram, việc cần làm là inject một object ram rồi mới truyền vào smartPhone thay thế cho ram hiện tại, thế là ok.

@Slf4j
class SmartPhone {
    
    private Ram ram;
    private Cpu cpu;
    
    // Constructor injection
    public SmartPhone(final Ram ram, final Cpu cpu) {
        this.ram = ram;
        this.cpu = cpu;
    }
    
    // Setter injection
    public void setRam(final Ram ram) {
        this.ram = ram;
    }
    
    // Setter injection
    public void setCpu(final Cpu cpu) {
        this.cpu = cpu;
    }
    
    public void printSpecification() {
        log.info("RAM: {} is produced by: {}", ram.getSpecification(), ram.getProducer());
        log.info("CPU: {} is produced by: {}", cpu.getSpecification(), cpu.getProducer());
    }
}


public class Application {
    
    public static void main(String[] args) {
        
        // initiate dependency
        var ram = new Ram("Kingston", "16GB");
        var cpu = new Cpu("Intel", "2.8Ghz");
        
        var iPhone = createNewIphone(ram, cpu);
        iPhone.printSpecification();
        
        // initiate new ram and replace the current one
        var newRam = new Ram("Kingston", "64GB");
        iPhone.setRam(newRam);
        iPhone.printSpecification();
    }
    
    private static SmartPhone createNewIphone(Ram ram, Cpu cpu) {
        return new SmartPhone(ram, cpu);
    }
    
}

Các object ramcpu được tạo ở một nơi nào đó (chưa cần quan tâm đến) và được inject (tiêm 😂) vào object smartPhone thông qua constructor hoặc setter method. Cũng chẳng có gì thần kì phức tạp ở đây đúng không.

Trong thực tế, các class có thể không phụ thuộc trực tiếp với nhau như ví dụ trên mà sẽ thông qua interface.

1.3) Inversion of Control

Ok, đến đây thì tạm hiểu problem và solution rồi. Với ví dụ trên, việc initiate dependency do chính chúng ta thực hiện bằng cách sử dụng từ khoá new.

var ram = new Ram("Kingston", "16GB");
var cpu = new Cpu("Intel", "2.8Ghz");

Có cách nào xịn sò hơn, thay vì phải tự new object, có ai đấy new và tự inject vào smartPhone luôn được không?

Hỏi thế thì tất nhiên là có rồi, và đó chính là Inversion of Control. Thay vì phải tự khởi tạo object, kiểm soát, điều khiển... thì outsource cho đứa khác làm hộ, mình chỉ việc dùng thôi.

Nhưng vấn đề là ai làm điều đó? À thì tất nhiên là các DI Framework rồi, trong đó có Spring IoC container.

Rất nhiều các ứng viên khi mình hỏi về DI đều mention đến Spring context, Spring IoC container, nào là nơi chứa các bean, rồi thì được inject này nọ, có ứng viên còn nhầm lẫn với bean scope, nói cả về việc scope singleton không cần khởi tạo nhiều lần?

Mình hỏi kĩ hơn vì sao lại liên quan đến bean scope gì ở đây? Mỗi lần gọi một instance mới thì sao, có vấn đề gì? Hoặc những câu hỏi kĩ hơn rằng việc Spring khởi tạo bean thế nào, inject bean ra sao? Thì đa số chỉ dừng lại chung chung với một câu trả lời: mình không rõ.

Những câu hỏi trên không liên quan đến framework, nếu chúng ta hiểu rõ vấn đề, nắm rõ ngôn ngữ mình đang làm (cụ thể là Java) thì không có gì khó khăn để trả lời.

Ok. Vậy idea của IoC là gì? Như mình đã đề cập ở trên, hãy hiểu nó theo một cách thật đơn giản, đừng phức tạp lên làm gì, bao gồm 2 phần:

  • Khởi tạo object: không cần tự khởi tạo với từ khoá new.
  • Inject dependency vào object cần được inject.

Với Spring, việc chúng ta cần làm là gắn các annotation @Service / @Controller / @Repository (tất cà các annotation này đều là @Component) cho các class và sử dụng @Autowire để Spring inject các object đó vào field tương ứng.

Một lưu ý là chúng ta nên ưu tiên sử dụng constructor injection thay vì field/setter injection nhé.

Nghe hấp dẫn chưa, bắt tay vào làm một cái giống giống IoC container thôi.

2) Practice

Làm đơn giản nhất có thể thôi nhé 😂. Project này sử dụng Java 17 và org.reflections:relections:0.10.2.

Mình tạo 3 annotation tương tự như Spring:

  • @Component
  • @PostConstruct
  • @Autowire

Với ví dụ này, mình chỉ làm field injection cho nhanh, các bạn có thể tự practice với constructor injection và setter injection nếu muốn. Tự thực hành sẽ thấy vui hơn đấy.

Application sẽ tự động tìm và init các object cần thiết, sau đó inject các dependency vào các bean. Code chính ở class ContextLoader.

Mình giải thích nhanh các bước hoạt động của ContextLoader:

  • Là singleton có nhiệm vụ lưu trữ các bean, bao gồm 2 public method:
    • load(): tìm và khởi tạo các bean.
    • getBean(): tìm bean theo class.
  • Một Map<String, Object> để lưu trữ bean name và bean tương ứng.
  • Method load() thực hiện các bước như sau:
    • Tìm tất cả các class có annotation @Component.
    • Thực hiện tạo instance mới cho các class đó và lưu trữ tại map.
    • Duyệt map, tìm tất cả dependency của object và set giá trị cho field. Sau đó tìm method có @PostConstruct và invoke method.
    • Cuối cùng, execute bean có implement interface Runner.

Giải thích chi tiết thì dài dòng lắm. Nhảy vào xem code luôn cho nhanh nhé tại đây. Kĩ thuật chính mình sử dụng là reflection. Có chỗ nào không hiểu, cần giải thích thêm thì cứ comment bên dưới và mình sẽ trả lời nhé.

Xin cảm ơn mọi người đã theo dõi.

© Dat Bui | Buy me a coffee & give your kindness to the world


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.