"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.

Ảnh chụp đoạn mã Go nền tối minh hoạ plugin architecture trong Go hai lối và vì sao Go ít nạp plugin lúc chạy, cách 1 package plugin chính thức nạp so lúc chạy plugin build thành so export symbol qua biến chữ HOA var Plugin bandich host tra bằng Lookup Plugin build go build buildmode plugin o hoa so hoa go host nạp so lúc chạy không biết plugin lúc biên dịch p bằng plugin Open plugins hoa so sym bằng p Lookup Plugin tra symbol theo tên bd bằng sym BanDich ép sang interface host bd Dich xin chào, cách 2 registry pattern plugin tĩnh tự đăng ký type BoLoc interface Ten string Loc string string var registry bằng map string BoLoc func DangKy b BoLoc registry b Ten bằng b mỗi plugin tự đăng ký khi package nạp func init DangKy upper import gạch dưới là đủ để nạp host chọn plugin theo tên lúc chạy b bằng registry upper b Loc chào, ràng buộc khắc nghiệt của package plugin chỉ Linux macOS không có trên Windows đo thật lỗi build plugin và host phải cùng phiên bản Go cùng deps chính xác không unload được nạp rồi ở lại tới hết đời tiến trình cần 2 file binary chính cộng so riêng mất binary tĩnh 1 file, vì sao Go ít dùng plugin so đa số dự án Go chọn registry biên dịch tĩnh 1 binary an toàn di động mở rộng động thật sự thường qua tiến trình con exec hoặc RPC như HashiCorp go-plugin không dùng so

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:

Ảnh chụp bảng kết quả đo thật nền tối plugin so nạp lúc chạy được nhưng registry tĩnh gọn và di động hơn, go build buildmode plugin cộng go run Go 1.23 arm64 10 core, cách 1 nạp plugin so lúc chạy go build buildmode plugin o hoa so sinh so 2,1 MB go run main go plugin so HOA xin chào host nạp so Lookup gọi qua interface host không cần biết plugin lúc biên dịch nạp file so lúc chạy, cách 2 registry nhiều plugin tự đăng ký qua init Plugin đã đăng ký 2 reverse loc Golang bằng gnaloG upper loc Golang bằng GOLANG Dùng plugin upper CHÀO BẠN host chọn theo tên lúc chạy thêm plugin bằng thêm 1 file cộng import gạch dưới init tự đăng ký, ràng buộc package plugin đo thật GOOS windows go build buildmode plugin buildmode plugin not supported on windows arm64 plugin so chỉ Linux macOS không có trên Windows registry 1 binary tĩnh 2.233.789 byte 1 file chạy mọi nơi host so binary chính cộng hoa so 2.114.202 byte riêng, cốt lõi plugin so nạp lúc chạy thật plugin Open cộng Lookup nhưng ràng buộc chỉ Linux macOS cùng Go version cộng deps không unload registry interface cộng init tự đăng ký 1 binary tĩnh an toàn chọn đa số Go dùng registry so chỉ khi thật cần nạp động động thật thường qua tiến trình con RPC go-plugin không so

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 .so trê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 .so rồ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 .so riê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ề

  1. Go có package plugin nạp .so lúc chạy: plugin.Open + Lookup tra symbol theo tên rồi ép sang interface host (đo thật nạp hoa.so gọi được [HOA] xin chào) — nạp động thật, không cần biết plugin lúc biên dịch.
  2. 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 .so riêng — phá vỡ ưu thế một-binary.
  3. Đ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.