Làm chủ Gleam: Xây dựng Hệ thống Phân tán Chịu lỗi với Static Typing trên BEAM VM

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

BEAM VM đã chạy các hệ thống production với uptime gần như hoàn hảo suốt nhiều thập kỷ. Erlang đã tạo ra WhatsApp xử lý 900 triệu người dùng chỉ với đội ngũ 50 kỹ sư. Elixir đã đưa BEAM đến gần hơn với một thế hệ lập trình viên Ruby. Cả hai đều dùng dynamic typing — một lớp bug hoàn toàn chỉ xuất hiện tại runtime, trên production, khi người dùng đã bị ảnh hưởng rồi.

Gleam giải quyết vấn đề đó. Đây là ngôn ngữ lập trình hàm có static typing, biên dịch sang BEAM bytecode (và JavaScript), mang lại các process nhẹ, supervision tree chịu lỗi và concurrency dựa trên actor — tất cả dưới một hệ thống kiểu dữ liệu bắt lỗi ngay tại compile time.

Sau khi vận hành các dịch vụ phân tán trên stack Erlang, Go và Elixir, một mẫu hình cứ lặp đi lặp lại: lỗi kiểu dữ liệu tại runtime là bug khiến bạn bị gọi dậy lúc 3 giờ sáng. Chúng ẩn nấp trong các code path hiếm khi được kiểm tra, qua mặt được testing, và chỉ lộ ra dưới tải production thực tế. Gleam tấn công thẳng vào vấn đề đó — static typing được tích hợp vào ngôn ngữ từ đầu, không phải gắn vào như một tính năng phụ.

So sánh Cách tiếp cận — Gleam, Elixir và Erlang Đặt cạnh nhau

Phần lớn sự cố hệ thống phân tán bắt nguồn từ hai điều: các giả định sai được nhúng vào code, và các process không tự phục hồi khi crash. Sự lựa chọn ngôn ngữ quyết định bạn có thể phòng thủ trước cả hai nguy cơ này tốt đến mức nào.

Erlang — Chuẩn vàng về Độ tin cậy, Khó mà Yêu được

Erlang là ngôn ngữ BEAM gốc ban đầu. Nó chạy hạ tầng viễn thông, message queue và các cơ sở dữ liệu như RabbitMQ và CouchDB. Mô hình process của nó vô song. Nhưng cú pháp khá xa lạ với hầu hết lập trình viên, hệ thống kiểu dữ liệu tùy chọn và chưa đầy đủ (Dialyzer giúp ích nhưng không bắt buộc), và đường cong học tập cực kỳ dốc.

Elixir — Cú pháp Đẹp, Lỗi Kiểu dữ liệu tại Runtime

Elixir đã giải quyết vấn đề ergonomics của Erlang với cú pháp gần giống Ruby, hệ sinh thái xuất sắc (Phoenix, Ecto, LiveView) và cộng đồng phát triển mạnh. Nhưng nó vẫn là dynamic typing. Bạn có thể viết String.upcase(42) và chỉ phát hiện ra lỗi khi code path đó thực thi trên production — có thể vào lúc 2 giờ sáng thứ Sáu.

Gleam — Static Typing trên BEAM

Gleam chọn con đường thứ ba. Cú pháp lấy cảm hứng từ ML, hệ thống kiểu Hindley-Milner (tương tự type inference của Haskell và Rust) và khả năng tương tác hoàn toàn với code Erlang và Elixir. Đảm bảo tại compile-time cộng với đặc tính runtime của BEAM. Không phải đánh đổi — có cả hai cùng lúc.

Hãy so sánh cách xử lý lỗi trên ba ngôn ngữ này:

% Erlang — không có ràng buộc từ compiler tại nơi gọi hàm
divide(A, B) ->
    case B of
        0 -> {error, division_by_zero};
        _ -> {ok, A div B}
    end.
# Elixir — cấu trúc tương tự, vẫn là dynamically typed
def divide(a, b) do
  case b do
    0 -> {:error, :division_by_zero}
    _ -> {:ok, div(a, b)}
  end
end
// Gleam — compiler bắt buộc phải xử lý cả Ok lẫn Error
pub fn divide(a: Int, b: Int) -> Result(Int, String) {
  case b {
    0 -> Error("Chia cho không")
    _ -> Ok(a / b)
  }
}

Gọi divide() trong Gleam và bỏ qua trường hợp Error. Code sẽ không biên dịch được. Không phải cảnh báo — mà là dừng cứng trước khi một byte nào đó được triển khai lên production.

Ưu và Nhược điểm của Gleam

Điều Gleam Làm Đúng

  • Kiểm tra đầy đủ tại compile-time: Pattern match phải bao phủ tất cả các trường hợp. Bỏ sót một nhánh và compiler sẽ báo cho bạn trước khi deploy.
  • Tương tác đầy đủ với hệ sinh thái Erlang: Gọi bất kỳ thư viện Erlang hay Elixir nào từ Gleam qua FFI. RabbitMQ client, database driver, Phoenix — tất cả đều có thể sử dụng. Bạn không phải bắt đầu từ đầu.
  • Biên dịch sang JavaScript: Gleam cũng biên dịch sang JavaScript, cho phép bạn chia sẻ business logic giữa backend và frontend với một codebase duy nhất.
  • Thông báo lỗi từ compiler dễ đọc: Các thông báo lỗi được viết để con người hiểu được, không chỉ để IDE phân tích. Chúng cho bạn biết sai ở đâu và thường gợi ý cách sửa.
  • Bắt buộc dùng argument có nhãn: Gọi hàm nhiều tham số yêu cầu nhãn tại nơi gọi, khiến code tự ghi lại tài liệu mà không cần thêm comment.

Những Hạn chế Hiện tại của Gleam

  • Hệ sinh thái nhỏ nhưng đang phát triển: So với kho package trưởng thành của Elixir, số lượng thư viện của Gleam còn hạn chế. Hãy chuẩn bị tinh thần dùng FFI vào Erlang cho một số thứ.
  • Abstraction OTP còn mới hơn: Thư viện gleam_otp bọc GenServer và Supervisor, nhưng ergonomics chưa được trau chuốt như của Elixir.
  • Không có macro: Gleam cố tình tránh metaprogramming. Nếu codebase của bạn phụ thuộc nhiều vào macro Elixir, bạn sẽ cần thay đổi cách tư duy.
  • Tooling vẫn đang trưởng thành: VS Code và Zed đã có extension, nhưng hệ sinh thái chưa được hoàn thiện như tooling của Go hay Rust.

Cài đặt Được khuyến nghị

Erlang cần được cài trước (khuyến nghị OTP 25+), sau đó mới cài Gleam.

# macOS với Homebrew
brew install erlang gleam

# Ubuntu/Debian — cài Erlang trước, rồi mới cài binary Gleam
apt install erlang
# Tải binary Gleam từ trang GitHub releases
# hoặc dùng asdf để quản lý phiên bản:
asdf plugin add gleam
asdf install gleam latest
asdf global gleam latest

# Kiểm tra cả hai đã hoạt động
gleam --version
erl --version

Tạo project mới và thêm các dependency OTP:

gleam new fault_tolerant_worker
cd fault_tolerant_worker

Chỉnh sửa gleam.toml để thêm các package OTP:

[dependencies]
gleam_stdlib = ">= 0.34.0 and < 2.0.0"
gleam_otp = ">= 0.10.0 and < 1.0.0"
gleam_erlang = ">= 0.25.0 and < 1.0.0"
gleam deps download

Hướng dẫn Triển khai — Xây dựng Actor Chịu lỗi từ Đầu

Đơn vị cơ bản của fault tolerance trên BEAM là actor — một process nhẹ với trạng thái độc lập và hộp thư nhận message. Đây là cách xây dựng một actor trong Gleam.

Định nghĩa Actor

Mỗi actor cần một kiểu message (kiểu dữ liệu đại số), một trạng thái và một hàm xử lý. Hệ thống kiểu đảm bảo mọi biến thể message đều được xử lý:

// src/counter.gleam
import gleam/erlang/process
import gleam/otp/actor

pub type Message {
  Increment(amount: Int)
  Reset
  GetCount(reply_to: process.Subject(Int))
}

pub fn start() -> Result(process.Subject(Message), actor.StartError) {
  actor.start(0, handle_message)
}

fn handle_message(
  message: Message,
  count: Int,
) -> actor.Next(Message, Int) {
  case message {
    Increment(amount) -> actor.continue(count + amount)
    Reset -> actor.continue(0)
    GetCount(client) -> {
      process.send(client, count)
      actor.continue(count)
    }
  }
}

Thêm một biến thể mới vào Message mà quên xử lý nó trong handle_message. Compiler sẽ từ chối build. Đó chính là exhaustiveness checking — phát hiện một sự cố production tương lai trước khi code chạy.

Sử dụng Actor

// src/fault_tolerant_worker.gleam
import gleam/erlang/process
import gleam/io
import gleam/int
import counter

pub fn main() {
  let assert Ok(counter_pid) = counter.start()

  // Các message fire-and-forget
  process.send(counter_pid, counter.Increment(amount: 5))
  process.send(counter_pid, counter.Increment(amount: 3))

  // Gọi đồng bộ — gửi reply subject, chờ phản hồi
  let reply_subject = process.new_subject()
  process.send(counter_pid, counter.GetCount(reply_to: reply_subject))
  let count = process.receive(reply_subject, 1000)

  case count {
    Ok(n) -> io.println("Số đếm: " <> int.to_string(n))
    Error(_) -> io.println("Hết thời gian chờ số đếm")
  }
}
gleam run
# Số đếm: 8

Thêm Supervisor để Tự động Phục hồi

Supervision chính là nơi các hệ thống BEAM tạo dựng danh tiếng. Nếu counter actor bị crash — do bug, message bất ngờ hoặc lỗi tài nguyên — supervisor sẽ tự động khởi động lại nó với trạng thái mới:

// src/app_supervisor.gleam
import gleam/otp/supervisor
import counter

pub fn start() {
  supervisor.start(fn(children) {
    children
    |> supervisor.add(
      supervisor.worker(fn(_) { counter.start() })
    )
  })
}

Đây là triết lý “let it crash” (cứ để crash) làm cho các hệ thống BEAM trở nên bền bỉ đến vậy. Hãy bỏ qua cách xử lý lỗi phòng thủ rải rác khắp mọi hàm. Thay vào đó, hãy xây dựng các phân cấp process tự phục hồi. Một actor gặp trạng thái bất ngờ, crash sạch sẽ, supervisor phát hiện và một instance mới đã chạy — thường là trước khi ai đó bị gọi dậy.

Gọi Trực tiếp Thư viện Erlang

Khi Gleam không có wrapper native cho một thư viện Erlang, FFI chỉ cần một dòng:

// Kết nối trực tiếp với module :timer của Erlang
@external(erlang, "timer", "sleep")
pub fn sleep(milliseconds: Int) -> Nil

// Bây giờ có thể dùng như bất kỳ hàm Gleam nào
pub fn delayed_reset(counter_pid) {
  sleep(5000)
  process.send(counter_pid, counter.Reset)
}

Gleam Có phải Lựa chọn Phù hợp cho Dự án Tiếp theo của Bạn?

Gleam 1.0 đã ra mắt vào tháng 3 năm 2024. Đặc tả ngôn ngữ core đã ổn định và được khóa lại. Với các hệ thống phân tán greenfield nơi bạn muốn high concurrency, cô lập lỗi và hot code reloading — với static type safety bên trên — Gleam là một lựa chọn production thực sự ngay hôm nay.

Nếu team của bạn đã dùng Elixir và dynamic typing chưa gây ra vấn đề gì, thì không có lý do cấp thiết để chuyển sang. Nhưng nếu bạn đang bắt đầu một hệ thống phân tán mới từ đầu, hoặc lỗi kiểu dữ liệu tại runtime đã từng khiến bạn điêu đứng, Gleam đang đứng ở vị trí mà không ngôn ngữ nào khác có được. Static typing. Process isolation. Supervision tree. Tất cả trong một gói.

Rust cho bạn type safety nhưng không có mô hình process nhẹ của BEAM. Go cho bạn concurrency vững chắc nhưng không có supervision tree chịu lỗi. Haskell cho bạn hệ thống kiểu mạnh mẽ nhưng không có hàng thập kỷ hạ tầng hệ thống phân tán. Gleam cho bạn cả ba — và một khi bạn đã vận hành một hệ thống tự phục hồi sau crash, mọi lựa chọn thay thế sẽ bắt đầu cảm thấy như những sự thỏa hiệp.

Share: