Dependency Injection ở Compile-Time trong Go với Google Wire: Hướng Dẫn Thực Hành

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Bối Cảnh & Lý Do

Khi khởi tạo một backend Go mới, việc kết nối (wiring) các struct thủ công khá đơn giản. Bạn khởi tạo một connection pool kết nối cơ sở dữ liệu, truyền nó vào repository, bọc repository đó trong service, rồi đăng ký các HTTP handler. Nhưng khi service phình to lên 25 hoặc 30 dependency, main.go nhanh chóng biến thành một mớ bòng bong 300 dòng khó kiểm soát. Chỉ cần một constructor thay đổi là kéo theo hàng loạt chỉnh sửa ở hàng chục nơi gọi hàm.

Các hệ sinh thái như Java (Spring) hay Node.js (NestJS) giải quyết vấn đề này bằng reflection khi runtime và service locator. Các lập trình viên Go cũng thường tìm đến các công cụ dựa trên reflection, nổi bật nhất là fx và dig của Uber. Chúng hoạt động tốt, nhưng DI ở runtime mang lại một rủi ro tai hại: các dependency bị thiếu vẫn compile bình thường, để rồi panic và crash ngay lúc khởi động khi deploy lên staging hoặc production.

Google Wire chọn một hướng tiếp cận hoàn toàn khác. Wire là một công cụ sinh mã (code generator), chứ không phải là runtime container. Nó phân tích các constructor Go chuẩn ở thời điểm biên dịch (compile time) và xuất ra mã nguồn Go tường minh, chuẩn idiomatic để liên kết các component lại với nhau. Nếu bạn tạo ra một circular dependency (phụ thuộc vòng) hoặc quên truyền một tham số, quá trình biên dịch sẽ dừng ngay lập tức trên máy bạn. Việc bắt lỗi dependency hỏng chỉ trong 2 giây build ở local tốt hơn nhiều so với việc phải điều tra sự cố crash lúc nửa đêm trên Kubernetes.

Cài Đặt

Wire bao gồm hai thành phần: một thư viện runtime cung cấp các helper type như wire.Build, và một công cụ CLI giúp phân tích mã nguồn để tạo ra các injector.

Đầu tiên, hãy khởi tạo một Go module nếu bạn chưa có sẵn workspace:

mkdir go-wire-demo && cd go-wire-demo
go mod init github.com/example/go-wire-demo

Cài đặt CLI sinh mã của Wire vào thư mục Go bin của bạn:

go install github.com/google/wire/cmd/wire@latest

Đảm bảo shell của bạn có thể tìm thấy các binary của Go bằng cách thêm $GOPATH/bin vào biến PATH của hệ thống:

export PATH=$PATH:$(go env GOPATH)/bin
wire help

Cuối cùng, thêm package runtime của Wire vào go.mod:

go get github.com/google/wire@latest

Cấu Hình

Wire xoay quanh hai khái niệm cơ bản:

  • Provider: Các hàm constructor Go thông thường trả về giá trị cùng error tùy chọn (ví dụ: NewUserRepository(cfg *Config) (*UserRepository, error)).
  • Injector: Các khai báo hàm mà trong đó bạn gọi wire.Build(...) để chỉ định struct đích nào Wire cần lắp ráp.

1. Thiết Lập Các Domain Component

Tạo một service nhỏ gồm một struct cấu hình, một user repository và một notification service. Lưu đoạn code này vào file app.go:

package main

import (
	"errors"
	"fmt"
)

type Config struct {
	DatabaseURL string
}

func NewConfig() *Config {
	return &Config{
		DatabaseURL: "postgres://user:secret@localhost:5432/production_db",
	}
}

type UserRepository struct {
	Config *Config
}

func NewUserRepository(cfg *Config) (*UserRepository, error) {
	if cfg.DatabaseURL == "" {
		return nil, errors.New("database URL không được để trống")
	}
	return &UserRepository{Config: cfg}, nil
}

type NotificationService struct {
	Repo *UserRepository
}

func NewNotificationService(repo *UserRepository) *NotificationService {
	return &NotificationService{Repo: repo}
}

type App struct {
	Notifier *NotificationService
}

func NewApp(notifier *NotificationService) *App {
	return &App{Notifier: notifier}
}

func (a *App) Start() {
	fmt.Printf("Ứng dụng đang chạy với DB đã kết nối: %s\n", a.Notifier.Repo.Config.DatabaseURL)
}

2. Viết Định Nghĩa Wire Injector

Tiếp theo, hãy định nghĩa chữ ký injector trong wire.go. Chú ý build tag ở dòng 1:

//go:build wireinject
// +build wireinject

package main

import (
	"github.com/google/wire"
)

func InitializeApp() (*App, error) {
	wire.Build(
		NewConfig,
		NewUserRepository,
		NewNotificationService,
		NewApp,
	)
	return nil, nil
}

Ràng buộc //go:build wireinject báo cho trình biên dịch Go tiêu chuẩn bỏ qua file này trong các lần build thông thường. Việc trả về nil, nil chỉ là placeholder để thỏa mãn compiler; Wire sẽ thay thế toàn bộ thân hàm này trong quá trình sinh mã.

3. Tổ Chức Bằng Provider Set

Khi dự án phát triển vượt quá 10 hoặc 15 constructor, việc truyền từng hàm vào một lệnh gọi wire.Build duy nhất sẽ trở nên lộn xộn. Hãy nhóm các constructor liên quan lại với nhau bằng wire.NewSet:

// Trong một package dùng chung hoặc trong wire.go
var ServiceSet = wire.NewSet(
	NewConfig,
	NewUserRepository,
	NewNotificationService,
)

Sau đó, bạn có thể truyền trực tiếp ServiceSet vào injector cùng với các dependency gốc:

func InitializeApp() (*App, error) {
	wire.Build(ServiceSet, NewApp)
	return nil, nil
}

4. Sinh Mã Injection

Chạy lệnh wire trong thư mục dự án của bạn:

wire

Wire sẽ kiểm tra đồ thị constructor, khớp kiểu trả về với tham số và tạo ra file wire_gen.go:

// Mã được sinh bởi Wire. KHÔNG CHỈNH SỬA.

//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject

package main

func InitializeApp() (*App, error) {
	config := NewConfig()
	userRepository, err := NewUserRepository(config)
	if err != nil {
		return nil, err
	}
	notificationService := NewNotificationService(userRepository)
	app := NewApp(notificationService)
	return app, nil
}

Kết quả xuất ra là mã Go thuần túy. Nó sắp xếp thứ tự gọi constructor một cách chính xác, xử lý lan truyền lỗi (error bubbling) và hoàn toàn không gây tốn chi phí reflection khi runtime.

5. Viết Điểm Khởi Đầu (Entry Point)

Sau khi việc sinh mã hoàn tất, hãy kết nối điểm khởi đầu của ứng dụng trong main.go:

package main

import (
	"log"
)

func main() {
	app, err := InitializeApp()
	if err != nil {
		log.Fatalf("Khởi tạo ứng dụng thất bại: %v", err)
	}

	app.Start()
}

Kiểm Tra & Giám Sát

Bây giờ hãy kiểm tra xem các đảm bảo ở thời điểm biên dịch có hoạt động đúng như quảng cáo hay không.

1. Build và Chạy Service

Chạy chương trình bằng các công cụ Go chuẩn:

go run .

Terminal xác nhận toàn bộ cây phụ thuộc đã được khởi tạo mà không cần kết nối thủ công trong main.go:

Ứng dụng đang chạy với DB đã kết nối: postgres://user:secret@localhost:5432/production_db

2. Xác Minh Lỗi ở Compile-Time

Để kiểm tra tính an toàn ở thời điểm build, hãy giả lập một dependency bị thiếu. Comment dòng NewConfig trong wire.go:

func InitializeApp() (*App, error) {
	wire.Build(
		// Đã bỏ NewConfig
		NewUserRepository,
		NewNotificationService,
		NewApp,
	)
	return nil, nil
}

Chạy lại CLI:

wire

Quá trình sinh mã thất bại ngay lập tức với thông báo chẩn đoán chính xác:

wire: /path/to/go-wire-demo/wire.go:10:1: inject InitializeApp: không tìm thấy provider cho *main.Config
	cần bởi *main.UserRepository trong provider "NewUserRepository" (/path/to/go-wire-demo/app.go:21:1)

Bạn phát hiện ra cấu hình sai chỉ trong 50 mili giây thay vì gặp phải sự cố crash do con trỏ chưa khởi tạo trên môi trường production.

3. Tích Hợp Vào CI Pipeline

Để ngăn lập trình viên commit các file wire_gen.go bị lỗi thời hoặc bị chỉnh sửa thủ công, hãy thêm một bước kiểm tra vào pipeline tích hợp liên tục (CI) của bạn:

# Bước trong CI (ví dụ: GitHub Actions)
go install github.com/google/wire/cmd/wire@latest
wire ./...
git diff --exit-code wire_gen.go

Lệnh này sẽ chạy quá trình sinh mã dựa trên các file nguồn đã commit. Nếu có ai đó sửa đổi chữ ký của provider mà không chạy wire ở local, git diff --exit-code sẽ phát hiện các thay đổi chưa được commit và chặn pull request đó.

Share: