"Làm sao mở rộng chương trình bằng các thành phần thêm vào mà không sửa lõi?" — câu hỏi plugin architecture giải. Ngôn ngữ động (Python, JS) nạp module lúc chạy dễ dàng. Go, với biên dịch tĩnh và triết lý một-binary, tiếp cận khác hẳn. Go có một package plugin chính thức nạp .so lúc chạy — nhưng đa số dự án Go lại không dùng nó, mà chọn một mẫu tĩnh gọi là registry. Bài này dựng cả hai chạy được, đo thật ràng buộc của package plugin, và giải thích vì sao cộng đồng Go phần lớn tránh nạp động thật.
Cách 1: package plugin — nạp .so lúc chạy
Go có package plugin cho nạp thư viện chia sẻ (.so) lúc chạy. Plugin build với cờ đặc biệt và export symbol qua biến/hàm chữ hoa:
// plugins/hoa.go — build: go build -buildmode=plugin -o hoa.so
var Plugin bandich // host tra bằng Lookup("Plugin")
Host mở file .so và tra symbol theo tên lúc chạy — không cần biết plugin lúc biên dịch:
p, _ := plugin.Open("plugins/hoa.so")
sym, _ := p.Lookup("Plugin") // tra symbol theo tên
bd := sym.(BanDich) // ép sang interface host định nghĩa
bd.Dich("xin chào")
Đo thật (Go 1.23): build hoa.so (2,1 MB), rồi host nạp và gọi được — [plugin .so] [HOA] xin chào. Đây là nạp động thật: bạn có thể thả một file .so mới vào thư mục và host nạp nó mà không cần biên dịch lại.

Hình 1: Package plugin (plugin.Open + Lookup) nạp .so lúc chạy. Registry pattern dùng interface + init() tự đăng ký, biên dịch tĩnh. Package plugin có ràng buộc khắc nghiệt: chỉ Linux/macOS, cùng phiên bản Go và deps.
Cách 2: registry — plugin tĩnh tự đăng ký
Mẫu phổ biến hơn nhiều trong Go: plugin được biên dịch tĩnh vào binary, và tự đăng ký vào một registry chung qua hàm init(). Không nạp động, nhưng mở rộng bằng cách thêm file:
type BoLoc interface{ Ten() string; Loc(string) string }
var registry = map[string]BoLoc{}
func DangKy(b BoLoc) { registry[b.Ten()] = b }
// Mỗi plugin tự đăng ký khi package được nạp:
func init() { DangKy(upper{}) } // import _ "..." là đủ để kích hoạt
Đo thật: hai plugin (upper, reverse) tự đăng ký, host chọn theo tên lúc chạy:

Hình 2: Plugin .so nạp lúc chạy thành công ([HOA] xin chào). Registry: 2 plugin tự đăng ký, host chọn theo tên (upper→GOLANG, reverse→gnaloG). Ràng buộc đo thật: -buildmode=plugin not supported on windows. Registry cho 1 binary tĩnh (2,2 MB) so với .so cần binary chính + file .so riêng.
Plugin đã đăng ký: 2 — thêm một plugin chỉ là thêm một file với init() và import _. Host chọn upper lúc chạy, Loc("chào bạn") ra CHÀO BẠN. Tất cả nằm trong một binary tĩnh (2,2 MB), chạy mọi nơi.
Vì sao Go ít dùng plugin .so
Package plugin có ràng buộc khắc nghiệt, và đo thật làm rõ chúng:
- Chỉ Linux/macOS. Thử build cho Windows:
-buildmode=plugin not supported on windows/arm64. Không có plugin.sotrên Windows. - Plugin và host phải cùng phiên bản Go và cùng deps chính xác. Lệch một phiên bản thư viện là plugin không nạp được — cực kỳ mong manh khi triển khai.
- Không unload được. Nạp
.sorồi nó ở lại tới hết đời tiến trình; không có cơ chế gỡ. - Mất binary tĩnh một file. Cần binary chính và file
.soriêng (2,1 MB), phá vỡ ưu thế một-binary của Go.
Vì những lý do này, đa số dự án Go dùng registry (tĩnh). Và khi thật sự cần mở rộng động — plugin của bên thứ ba, sandbox — cộng đồng thường dùng tiến trình con giao tiếp qua RPC (như thư viện hashicorp/go-plugin mà Terraform dùng) thay vì .so: mỗi plugin là một binary riêng chạy như tiến trình con, host nói chuyện qua gRPC. Cách này an toàn hơn (plugin crash không sập host), không lệ thuộc phiên bản, và chạy mọi nền tảng.
Đánh đổi cần cân nhắc
Registry là lựa chọn mặc định đúng cho hầu hết trường hợp. Nếu tập plugin biết trước lúc build (các bộ lọc, driver, codec trong chính dự án của bạn), registry cho tất cả lợi ích của kiến trúc plugin — tách rời, dễ thêm, chọn lúc chạy — mà giữ một binary tĩnh và an toàn kiểu. Đây là cách database/sql đăng ký driver, image đăng ký decoder. Đừng với package plugin nếu registry đủ.
Chỉ dùng package plugin khi thật sự phải nạp code không biết lúc build. Ví dụ hiếm: một nền tảng cho phép người dùng thả plugin vào mà không rebuild host. Ngay cả khi đó, cân nhắc kỹ ràng buộc phiên bản — nó khiến vận hành đau đầu. Nhiều đội chọn RPC-based plugin (go-plugin) thay thế vì nó tránh được mọi ràng buộc của .so.
RPC-based plugin đổi độ trễ lấy sự bền vững. Plugin chạy như tiến trình riêng giao tiếp qua RPC có chi phí serialize + IPC mỗi lời gọi (chậm hơn nhiều lời gọi hàm trực tiếp), nhưng đổi lại là cô lập lỗi, độc lập phiên bản, và bảo mật (sandbox tiến trình). Với plugin gọi thưa (config, provider), đánh đổi này rất đáng; với plugin trên đường nóng gọi triệu lần/giây thì không.
Ba ý mang về
- Go có package plugin nạp
.solúc chạy:plugin.Open+Lookuptra symbol theo tên rồi ép sang interface host (đo thật nạphoa.sogọi được[HOA] xin chào) — nạp động thật, không cần biết plugin lúc biên dịch. - Nhưng package plugin ràng buộc khắc nghiệt: đo thật chỉ Linux/macOS (
not supported on windows), phải cùng phiên bản Go và deps, không unload được, và cần file.soriêng — phá vỡ ưu thế một-binary. - Đa số Go dùng registry pattern (tĩnh): interface +
init()tự đăng ký cho một binary tĩnh gọn, an toàn, di động (đo thật 2 plugin tự đăng ký, chọn theo tên lúc chạy) — và mở rộng động thật sự thường qua tiến trình con/RPC (go-plugin), không dùng.so.
Phần sau ta chuyển sang giao tiếp giữa các service bằng một giao thức hiệu năng cao: Phần sau dựng một gRPC unary service từ đầu trong Go — định nghĩa protobuf, sinh mã, và so với REST/JSON về tốc độ lẫn kiểu an toàn.