"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.
Bài tập làm thử
Bài 1 (đọc hiểu). Cho đoạn mã host dùng package plugin:
p, _ := plugin.Open("plugins/hoa.so")
sym, _ := p.Lookup("Plugin")
bd := sym.(BanDich)
bd.Dich("xin chào")
Vì sao bài viết gọi đây là "nạp động thật", khác với registry pattern?
Đáp án
Vì host không cần biết plugin lúc biên dịch — nó mở file .so bất kỳ tại đường dẫn cho trước (plugin.Open), tra symbol theo tên chuỗi (Lookup("Plugin")) lúc chạy, rồi mới ép kiểu (sym.(BanDich)) sang interface. Bạn có thể thả một file .so mới vào thư mục và host nạp được mà không cần biên dịch lại host — khác registry, nơi mọi plugin đã được biên dịch tĩnh sẵn vào cùng một binary từ đầu.
Bài 2 (sửa lỗi). Một plugin trong registry pattern được viết như sau nhưng không bao giờ xuất hiện trong danh sách plugin đã đăng ký khi host chạy:
package upperplugin
type upper struct{}
func (upper) Ten() string { return "upper" }
func (upper) Loc(s string) string { return strings.ToUpper(s) }
func KhoiTao() { DangKy(upper{}) }
// main.go
import _ "myapp/upperplugin"
Vì sao plugin này không được đăng ký, và sửa thế nào?
Đáp án
Vấn đề: đăng ký nằm trong hàm KhoiTao() thông thường, nhưng không có ai gọi KhoiTao() cả — import _ chỉ kích hoạt việc nạp package (chạy các hàm init()), không tự động gọi các hàm khác. Sửa: đổi tên hàm thành init():
func init() { DangKy(upper{}) }
Đúng như bài viết: "Mỗi plugin tự đăng ký khi package nạp... import _ "..." là đủ để kích hoạt" — cơ chế này hoạt động vì Go tự động chạy init() của mọi package được import (kể cả import trắng _), không cần gọi tường minh.
Bài 3 (vận dụng). Viết một plugin reverse mới cho registry, đảo ngược chuỗi ký tự, và đoạn init() để tự đăng ký nó — theo đúng interface BoLoc:
type BoLoc interface{ Ten() string; Loc(string) string }
Đáp án
type reverse struct{}
func (reverse) Ten() string { return "reverse" }
func (reverse) Loc(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
func init() { DangKy(reverse{}) }
Chỉ cần import package này (kể cả bằng import _) là init() tự chạy và reverse{} được đăng ký vào registry — đúng cơ chế "thêm plugin bằng thêm 1 file + import gạch dưới" mà bài viết mô tả.
Bài 4 (bẫy/đánh đổi). Đo thật cho thấy thử build plugin .so cho Windows báo lỗi -buildmode=plugin not supported on windows/arm64. Nếu một đội đang phát triển đa nền tảng (cả Windows lẫn Linux) muốn có kiến trúc mở rộng bằng plugin, họ nên chọn cách nào trong hai cách bài viết nêu, và vì sao?
Đáp án
Họ nên chọn registry pattern (biên dịch tĩnh), vì package plugin với .so chỉ chạy được trên Linux/macOS — hoàn toàn không hỗ trợ Windows, nên không thể dùng cho một đội cần build đa nền tảng. Registry biên dịch mọi plugin đã biết trước vào cùng một binary tĩnh, chạy được trên mọi hệ điều hành Go hỗ trợ. Nếu thực sự cần nạp plugin không biết trước lúc build và vẫn muốn đa nền tảng, bài viết gợi ý dùng tiến trình con giao tiếp qua RPC (như hashicorp/go-plugin) thay vì .so.
Bài 5 (đọc hiểu/đánh đổi). Bài viết nói "RPC-based plugin đổi độ trễ lấy sự bền vững". Giải thích đánh đổi này, và cho một ví dụ khi đánh đổi đó đáng và một ví dụ khi không đáng.
Đáp án
Plugin chạy như tiến trình riêng giao tiếp qua RPC (kiểu go-plugin) phải serialize dữ liệu và giao tiếp liên tiến trình (IPC) mỗi lời gọi — chậm hơn nhiều so với gọi hàm trực tiếp trong cùng một tiến trình. Đổi lại, plugin bị cô lập: nó crash không làm sập host, không bị ràng buộc phải cùng phiên bản Go/dependency, và có thể chạy sandbox riêng cho bảo mật. Đáng đánh đổi với plugin gọi thưa — ví dụ một provider cấu hình chỉ gọi vài lần lúc khởi động (như Terraform provider). Không đáng với plugin nằm trên đường nóng, gọi hàng triệu lần mỗi giây, vì chi phí serialize+IPC mỗi lời gọi sẽ trở thành nút thắt hiệu năng nghiêm trọng.
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.