Vấn đề: Khoảng cách về UX trong các công cụ dòng lệnh
Các công cụ Go tiêu chuẩn như flag hay Cobra là những “ngựa chiến” của hệ sinh thái này. Chúng xử lý các đối số (arguments) một cách hoàn hảo, nhưng thường khiến người dùng phải nhìn chằm chằm vào những bức tường văn bản tĩnh. Đối với các tác vụ đơn giản, điều này vẫn ổn. Tuy nhiên, khi công cụ của bạn yêu cầu đầu vào phức tạp từ người dùng—như chọn tài nguyên hoặc theo dõi dữ liệu trực tiếp—trải nghiệm bắt đầu trở nên lỗi thời và thô kệch.
Gần đây, tôi đã xây dựng một công cụ triển khai cho một nhóm quản lý hơn 60 microservices trên nhiều cụm (cluster) khác nhau. Việc yêu cầu một kỹ sư DevOps sao chép-dán mã ID triển khai dài 32 ký tự không chỉ chậm chạp mà còn là một “lời mời gọi” cho những sự cố production vào lúc 2 giờ sáng.
Họ cần một giao diện mang lại cảm giác sống động. Họ muốn cuộn qua danh sách, lọc kết quả tức thì và nhận được xác nhận trực quan trước khi nhấn ‘triển khai’. Việc viết code này từ đầu bằng các mã ANSI escape thô là một kiểu “địa ngục” riêng biệt, nơi bạn phải theo dõi tọa độ con trỏ và xóa các dòng một cách thủ công. Hầu hết các nhà phát triển đều bỏ cuộc trước khi kịp hoàn thành menu đầu tiên.
Nguyên nhân gốc rễ: Tại sao trạng thái Terminal lại là một cơn ác mộng
Các CLI tương tác rất khó xây dựng vì chúng không đi theo một lộ trình tuyến tính. Một script tiêu chuẩn chạy từ trên xuống dưới: đọc đầu vào, thực thi logic, in kết quả và thoát. Một Giao diện Người dùng Terminal (TUI) hoạt động giống một trò chơi điện tử hơn. Nó chạy một vòng lặp liên tục, phải xử lý đầu vào và vẽ lại màn hình ít nhất 60 lần mỗi giây để mang lại cảm giác mượt mà.
Việc quản lý vòng lặp này một cách thủ công tạo ra ba rào cản lớn:
- Xung đột sự kiện (Event Collisions): Bạn phải xử lý các lần nhấn phím, thay đổi kích thước cửa sổ và hoàn thành tiến trình chạy ngầm cùng lúc mà không làm treo giao diện (UI).
- Nhấp nháy màn hình (Screen Flickering): Nếu bạn xóa toàn bộ màn hình và vẽ lại mọi thứ ở mỗi khung hình, terminal sẽ bị nhấp nháy dữ dội. Bạn cần một cách để chỉ cập nhật những ký tự thực sự thay đổi.
- Mất đồng bộ trạng thái (State Desync): Dữ liệu nội bộ của bạn rất dễ bị mất đồng bộ với những gì người dùng nhìn thấy trên màn hình, đặc biệt là khi xử lý các lệnh gọi API bất đồng bộ.
Đánh giá các lựa chọn
Khi tôi tìm kiếm một cách tốt hơn để xử lý vấn đề này trong Go, tôi đã thấy ba hướng đi riêng biệt:
1. Mã ANSI thô
Bạn có thể in các chuỗi thủ công như \033[2J để xóa màn hình hoặc \033[H để di chuyển con trỏ. Điều này mang lại cho bạn toàn quyền kiểm soát nhưng không có sự trừu tượng hóa. Nó giống như việc cố gắng xây dựng một ứng dụng web hiện đại bằng cách tính toán thủ công độ lệch pixel cho từng chữ cái. Cách này không thể mở rộng được.
2. Các thư viện mệnh lệnh (Tview/Termbox)
Các thư viện này cung cấp các widget như nút bấm và biểu mẫu. Chúng hoạt động tốt cho các bố cục đơn giản, nhưng thường phụ thuộc vào các hàm callback lồng nhau sâu sắc. Khi ứng dụng của bạn tăng lên hơn 1.000 dòng code, việc theo dõi callback nào đã cập nhật biến nào sẽ trở thành một trải nghiệm gây ức chế.
3. Bubble Tea (Kiến trúc Elm)
Bubble Tea, được xây dựng bởi đội ngũ tại Charm, sử dụng Kiến trúc Elm (The Elm Architecture – TEA). Thay vì ra lệnh cho terminal phải thay đổi như thế nào, bạn mô tả giao diện sẽ trông như thế nào ứng với một trạng thái nhất định. Nó mang tính hàm (functional), dễ dự đoán và cực kỳ dễ kiểm thử. Sau khi sử dụng nó để xây dựng một vài công cụ nội bộ, tôi thấy đây là cách duy nhất để giữ cho mã nguồn TUI phức tạp luôn có thể bảo trì.
Triển khai Kiến trúc Elm
Bubble Tea chia ứng dụng của bạn thành ba phần riêng biệt: Model (dữ liệu của bạn), Update (logic của bạn) và View (giao diện của bạn). Sự phân tách này giữ cho mã nguồn của bạn sạch sẽ ngay cả khi các tính năng ngày càng nhiều lên.
Bắt đầu
Khởi tạo dự án của bạn và tải framework về:
go mod init deploy-tool
go get github.com/charmbracelet/bubbletea
1. Model: Nguồn dữ liệu tin cậy
Model là một struct đơn giản. Nó chứa mọi mẩu dữ liệu mà UI của bạn cần. Nếu bạn đang xây dựng một bộ chọn dịch vụ, model của bạn sẽ theo dõi danh sách, lựa chọn hiện tại và các mục nào đã được đánh dấu.
type model struct {
services []string
cursor int
checked map[int]bool
}
2. Hàm Update: Xử lý sự kiện
Hàm Update là bộ não của ứng dụng. Nó nhận một tin nhắn (message) gửi đến—như một lần nhấn phím hoặc phản hồi từ API—và trả về một phiên bản mới của model. Đây là nơi bạn xử lý logic điều hướng. Hãy chú ý xem câu lệnh switch vẫn giữ được sự gọn gàng như thế nào:
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 { m.cursor-- }
case "down", "j":
if m.cursor < len(m.services)-1 { m.cursor++ }
case "enter", " ":
m.checked[m.cursor] = !m.checked[m.cursor]
}
}
return m, nil
}
3. View: Định dạng thuần túy
Hàm View là một bộ chuyển đổi đơn giản. Nó đọc trạng thái hiện tại và trả về một chuỗi ký tự. Nó không sửa đổi dữ liệu; nó chỉ hiển thị dữ liệu đó. Vì nó là một hàm thuần túy (pure function), bạn có thể tin tưởng rằng UI luôn khớp với dữ liệu của mình.
func (m model) View() string {
s := "Chọn các dịch vụ để triển khai:\n\n"
for i, service := range m.services {
cursor := " "
if m.cursor == i { cursor = ">" }
checked := " "
if m.checked[i] { checked = "x" }
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, service)
}
return s + "\nNhấn q để thoát.\n"
}
Tại sao điều này lại quan trọng đối với các công cụ Production
Khả năng dự đoán là lợi ích lớn nhất ở đây. Vì Update chỉ là một hàm nhận tin nhắn và trả về trạng thái, bạn có thể viết unit test cho UI của mình mà không cần mở terminal. Bạn có thể mô phỏng 10 lần nhấn “mũi tên xuống” và khẳng định rằng con trỏ đã di chuyển đúng 10 khoảng trống. Mức độ tin cậy này là không thể đối với các thư viện TUI theo phong cách mệnh lệnh truyền thống.
Kiến trúc này cũng ngăn chặn tình trạng “code mì ống” (spaghetti code) khi các yêu cầu thay đổi. Khi tôi cần thêm thanh tìm kiếm để lọc 60 microservices đó, tôi không phải chạm vào phần xử lý bàn phím hay logic hiển thị. Tôi chỉ cần thêm một chuỗi filter vào Model, cập nhật nó trong hàm Update và sử dụng nó để lọc danh sách trong View. Logic vẫn được cô lập và dễ hiểu.
Các tác vụ bất đồng bộ và Tác dụng phụ
Các ứng dụng thực tế cần giao tiếp với API hoặc cơ sở dữ liệu. Bubble Tea xử lý việc này bằng cách sử dụng tea.Cmd. Một command là một tác vụ chạy ngầm, cuối cùng sẽ gửi một tin nhắn ngược lại vòng lặp Update của bạn. Điều này giúp UI luôn phản hồi nhanh chóng. Người dùng vẫn có thể điều hướng menu trong khi một yêu cầu mạng mất 500ms đang chạy ngầm. Không còn hiện tượng màn hình bị đóng băng khi chờ phản hồi từ máy chủ.
func (m model) Init() tea.Cmd {
return fetchServicesFromServer // Tác vụ chạy ngầm không gây nghẽn (non-blocking)
}
Lời kết
Các công cụ Go chuyên nghiệp không nhất thiết phải bị giới hạn trong những văn bản tĩnh nhàm chán. Bằng cách sử dụng Bubble Tea và Kiến trúc Elm, bạn có thể xây dựng các ứng dụng tương tác mạnh mẽ, dễ kiểm thử và thực sự dễ chịu khi sử dụng. Các công cụ nội bộ thường là phần mềm được sử dụng nhiều nhất trong một tổ chức. Việc mang lại cho chúng một giao diện bóng bẩy, trực quan sẽ giúp giảm thiểu sai sót và làm cho trải nghiệm phát triển tốt hơn đáng kể.

