WebAssembly ngoài trình duyệt: WASI và server-side Wasm
1. Tổng quan
WebAssembly (Wasm) ra đời để chạy code hiệu năng cao trong trình duyệt. Nhưng các đặc tính của nó (nhỏ gọn, sandbox chặt, không phụ thuộc kiến trúc CPU) cũng rất hấp dẫn ở phía server.
Trong bài này, bạn sẽ hiểu WASI là gì và vì sao server-side Wasm đang được quan tâm. Bạn sẽ build một module Wasm, chạy nó bằng runtime dòng lệnh, nhúng nó vào ứng dụng Java 21 và viết test. Cuối bài là các trường hợp không nên dùng Wasm.
2. Vì sao WebAssembly cần WASI?
2.1. Wasm “trần” không làm được gì với thế giới bên ngoài
Một module Wasm thuần chỉ biết tính toán trên bộ nhớ tuyến tính (linear memory) của nó. Nó không thể tự mở file, đọc biến môi trường, lấy giờ hệ thống hay mở socket. Mọi tương tác với bên ngoài phải đi qua các hàm được host (môi trường chạy module) cung cấp dưới dạng import.
Trong trình duyệt, host là JavaScript. Ngoài trình duyệt thì chưa có host nào được thống nhất. Nếu mỗi runtime tự định nghĩa một bộ API riêng, module build cho runtime này sẽ không chạy được trên runtime khác.
2.2. WASI: giao diện hệ thống chuẩn cho Wasm
WASI (WebAssembly System Interface) là một bộ API chuẩn cho các thao tác kiểu hệ điều hành: file, clock, random, biến môi trường, tham số dòng lệnh, và ở các bản mới hơn là network. Module được compile nhắm tới WASI có thể chạy trên bất kỳ runtime nào hỗ trợ WASI, ví dụ Wasmtime, WasmEdge, Wasmer, hoặc runtime nhúng trong ứng dụng của bạn.
Điểm khác biệt quan trọng nhất là mô hình capability. Module không mặc định có quyền gì cả. Host phải cấp quyền tường minh, chẳng hạn “được đọc thư mục ./data”. Thiếu quyền thì lời gọi thất bại. Đây là cách làm ngược với process thông thường, vốn kế thừa toàn bộ quyền của user chạy nó.
2.3. WASI Preview 1, Preview 2 và Component Model
WASI đã trải qua nhiều giai đoạn. Bạn sẽ gặp hai tên phổ biến:
- Preview 1 (wasip1): API dạng hàm C-like, được hỗ trợ rộng rãi nhất.
- Preview 2 (wasip2): xây trên Component Model. Giao diện được mô tả bằng ngôn ngữ IDL tên là WIT. Các component viết bằng ngôn ngữ khác nhau có thể gọi nhau qua kiểu dữ liệu giàu hơn như string, record, list, thay vì chỉ số nguyên.
Mức độ hỗ trợ khác nhau tùy runtime và toolchain. Trước khi chọn, bạn nên kiểm tra tài liệu của runtime mình định dùng.
3. Vì sao server-side Wasm đang được quan tâm?
Có bốn lý do chính:
- Sandbox mặc định: Module không thể truy cập bộ nhớ ngoài vùng của nó và không có quyền hệ thống nếu không được cấp. Điều này rất phù hợp để chạy code không tin cậy, như plugin của khách hàng hay script do người dùng tải lên.
- Khởi động nhanh, footprint nhỏ: Module thường nhẹ và khởi tạo nhanh hơn nhiều so với một container hay một JVM mới. Vì vậy Wasm hợp với serverless và edge, nơi mỗi request có thể cần một instance riêng.
- Portable: Một file
.wasmchạy trên x86 hay ARM, Linux hay macOS, miễn là có runtime. - Đa ngôn ngữ: Rust, C/C++, Go, Zig, AssemblyScript… đều có thể compile ra Wasm. Ứng dụng Java của bạn có thể nạp logic viết bằng ngôn ngữ khác mà không cần JNI.
So sánh nhanh với container:
| Tiêu chí | Container | Wasm + WASI |
|---|---|---|
| Đơn vị đóng gói | Image chứa cả userland OS | Một file .wasm |
| Cách ly | Namespace/cgroup của kernel | Sandbox của runtime + capability |
| Phụ thuộc kiến trúc | Cần image theo arch | Không |
| Hệ sinh thái | Rất trưởng thành | Đang phát triển |
Wasm không thay thế container. Hai thứ thường bổ sung cho nhau: bạn vẫn có thể chạy runtime Wasm bên trong container.
4. Build và chạy một chương trình WASI
Ví dụ dùng Rust vì toolchain Wasm của Rust khá thuận tiện. Ý tưởng tương tự áp dụng cho các ngôn ngữ khác.
4.1. Chương trình đọc file đơn hàng
// src/main.rs
use std::fs;
fn main() {
match fs::read_to_string("orders.csv") {
Ok(content) => {
let total: f64 = content
.lines()
.filter_map(|line| line.split(',').nth(1))
.filter_map(|amount| amount.trim().parse::<f64>().ok())
.sum();
println!("Tong gia tri don hang: {total}");
}
Err(e) => eprintln!("Khong doc duoc file: {e}"),
}
}
Chương trình đọc orders.csv, lấy cột thứ hai (số tiền) và cộng lại. Code không có gì đặc biệt cho Wasm: std::fs sẽ được map sang lời gọi WASI khi compile.
Thêm target và build:
rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1
Kết quả là file target/wasm32-wasip1/release/order-report.wasm (tên theo package của bạn).
4.2. Chạy bằng Wasmtime và thấy capability hoạt động
Tạo file dữ liệu:
ORD-1,120.5
ORD-2,79.5
ORD-3,300
Chạy không cấp quyền thư mục:
wasmtime run target/wasm32-wasip1/release/order-report.wasm
Khong doc duoc file: failed to find a pre-opened file descriptor through which "orders.csv" could be opened
Module chạy được nhưng không thấy file nào. Nó không có capability với filesystem. Giờ cấp quyền thư mục hiện tại:
wasmtime run --dir=. target/wasm32-wasip1/release/order-report.wasm
Tong gia tri don hang: 500
Cờ --dir “pre-open” thư mục cho module. Module chỉ thấy đúng thư mục đó, không thấy /etc hay home directory của bạn. Nội dung thông báo lỗi có thể khác giữa các phiên bản runtime, nhưng hành vi thì như nhau.
Lưu ý: Cú pháp cờ (
--dir,--env, ánh xạ thư mục host sang guest) có thể thay đổi giữa các phiên bản Wasmtime. Hãy kiểm trawasmtime run --helptrên máy bạn.
5. Nhúng Wasm vào ứng dụng Java
Server-side Wasm không chỉ là chạy file .wasm từ CLI. Một cách dùng rất thực tế là nhúng runtime vào ứng dụng hiện có để chạy plugin hay logic do bên thứ ba cung cấp.
Với Java, có hai hướng:
- Binding tới runtime native (Wasmtime, WasmEdge) qua JNI hoặc FFM.
- Dùng runtime viết hoàn toàn bằng Java, ví dụ Chicory. Không cần thư viện native, nên dễ đóng gói và chạy ở mọi nơi có JVM.
Ví dụ dưới dùng Chicory.
5.1. Module tính phí vận chuyển
Giả sử đối tác cung cấp logic tính phí ship dưới dạng Wasm:
// src/lib.rs, Cargo.toml có: [lib] crate-type = ["cdylib"]
#[no_mangle]
pub extern "C" fn shipping_fee(weight_grams: i32, distance_km: i32) -> i32 {
let base = 15_000;
let by_weight = (weight_grams / 500) * 5_000;
let by_distance = distance_km * 1_000;
base + by_weight + by_distance
}
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/release/shipping.wasm src/main/resources/
Module này không dùng WASI. Nó chỉ tính toán, nên target wasm32-unknown-unknown là đủ và module không cần import gì.
5.2. Dependency Maven
<properties>
<java.version>21</java.version>
<!-- Kiểm tra phiên bản mới nhất trên Maven Central -->
<chicory.version>REPLACE_WITH_LATEST</chicory.version>
</properties>
<dependencies>
<dependency>
<groupId>com.dylibso.chicory</groupId>
<artifactId>runtime</artifactId>
<version>${chicory.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Nếu dùng Spring Boot 3.x, phiên bản của JUnit và AssertJ đã được quản lý sẵn qua spring-boot-starter-test.
5.3. Gọi hàm Wasm từ Java
package dev.nonla.shipping;
import com.dylibso.chicory.runtime.ExportFunction;
import com.dylibso.chicory.runtime.Instance;
import com.dylibso.chicory.wasm.Parser;
import java.io.InputStream;
public class WasmShippingCalculator {
private final ExportFunction shippingFee;
public WasmShippingCalculator(String resource) {
try (InputStream wasm = getClass().getResourceAsStream(resource)) {
var module = Parser.parse(wasm);
Instance instance = Instance.builder(module).build();
this.shippingFee = instance.export("shipping_fee");
} catch (Exception e) {
throw new IllegalStateException("Không nạp được module " + resource, e);
}
}
public int fee(int weightGrams, int distanceKm) {
long[] result = shippingFee.apply(weightGrams, distanceKm);
return (int) result[0];
}
}
Giải thích:
Parser.parse(...)đọc bytecode Wasm thành mô hình module.Instance.builder(module).build()tạo một instance có bộ nhớ riêng. Module không import gì nên không cần cấp host function.instance.export("shipping_fee")lấy hàm được export. Tên phải khớp với#[no_mangle]bên Rust.apply(...)nhận và trả vềlong[], vì kiểu số của Wasm (i32,i64…) được biểu diễn chung dưới dạnglong. Bạn tự ép kiểu vềint.
Chạy thử:
var calculator = new WasmShippingCalculator("/shipping.wasm");
System.out.println(calculator.fee(1_200, 10));
35000
Kết quả gồm 15.000 phí cơ bản, cộng 2 × 5.000 theo cân nặng (1.200 / 500 = 2), cộng 10 × 1.000 theo quãng đường.
5.4. Test hành vi
package dev.nonla.shipping;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class WasmShippingCalculatorTest {
static WasmShippingCalculator calculator;
@BeforeAll
static void setUp() {
calculator = new WasmShippingCalculator("/shipping.wasm");
}
@Test
void givenLightParcelNearby_whenFee_thenOnlyBaseAndDistance() {
assertThat(calculator.fee(300, 2)).isEqualTo(17_000);
}
@Test
void givenHeavyParcelFarAway_whenFee_thenAllComponentsAdded() {
assertThat(calculator.fee(2_000, 50)).isEqualTo(15_000 + 20_000 + 50_000);
}
@Test
void givenMissingModule_whenCreate_thenFailFast() {
assertThatThrownBy(() -> new WasmShippingCalculator("/not-found.wasm"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("not-found.wasm");
}
}
Test thứ ba đảm bảo ứng dụng báo lỗi ngay khi khởi động nếu thiếu module, thay vì chờ tới request đầu tiên mới hỏng.
Lưu ý: Một
Instancecó bộ nhớ và trạng thái riêng. Đừng mặc định là chia sẻ an toàn giữa nhiều thread. Với module thuần tính toán, cách đơn giản nhất là tạo instance theo request, hoặc dùng pool instance. Module đã parse thì có thể tái sử dụng để tạo nhiều instance.
5.5. Khi module cần WASI
Nếu plugin cần đọc file hay in ra stdout, nó sẽ import các hàm WASI. Bạn phải cung cấp các hàm này khi tạo instance. Chicory có module wasi riêng cho việc đó. Nguyên tắc vẫn là capability: chỉ cấp đúng thư mục, biến môi trường và stdout/stderr mà plugin thật sự cần. Đừng “inherit” toàn bộ môi trường của host cho tiện.
6. Các mô hình áp dụng thực tế
- Hệ thống plugin: Cho khách hàng hoặc đối tác tùy biến logic (tính giá, kiểm tra quy tắc, biến đổi dữ liệu) mà không phải chạy code của họ trực tiếp trong JVM.
- Policy engine: Đóng gói các quy tắc phân quyền thành module Wasm để dùng chung giữa nhiều service viết bằng ngôn ngữ khác nhau.
- Serverless và edge function: Mỗi request chạy trong một instance cô lập, khởi động nhanh.
- Tái sử dụng thư viện native: Compile một thư viện C/Rust (parser, codec, thuật toán) ra Wasm và gọi từ Java, tránh JNI cùng rủi ro crash cả JVM.
- Extension cho hạ tầng: Một số proxy, database và message broker cho phép mở rộng bằng Wasm.
7. Khi nào KHÔNG nên dùng
Wasm phía server hấp dẫn, nhưng không phải lúc nào cũng là lựa chọn đúng:
- Logic đã viết tốt bằng Java và không cần cách ly: Thêm một lớp Wasm chỉ làm tăng độ phức tạp, chi phí chuyển dữ liệu và khó debug.
- Workload cần đa luồng nặng, GPU hoặc SIMD chuyên sâu: Hỗ trợ thread và các tính năng phần cứng trong Wasm vẫn còn hạn chế, và không đồng đều giữa các runtime.
- Ứng dụng phụ thuộc nhiều vào network và hệ sinh thái thư viện: Socket, TLS, driver database qua WASI chưa đầy đủ như trên một OS thật. Rất nhiều thư viện chưa compile được sang Wasm.
- Gọi qua lại với lượng dữ liệu lớn: Mỗi lần truyền string hay mảng giữa host và guest đều phải copy vào linear memory. Nếu gọi hàm Wasm hàng triệu lần với payload lớn, chi phí này đáng kể. Hãy gom việc thành lời gọi lớn, ít lần.
- Nghĩ rằng sandbox đồng nghĩa với an toàn tuyệt đối: Wasm cách ly bộ nhớ, nhưng không tự chặn vòng lặp vô hạn hay việc tiêu tốn CPU. Bạn vẫn cần timeout, giới hạn bộ nhớ, và cơ chế ngắt thực thi nếu runtime hỗ trợ (fuel, epoch interruption…).
- Đội ngũ chưa sẵn sàng: Toolchain, debugger và observability cho Wasm vẫn đang phát triển. Đừng đưa vào hệ thống quan trọng chỉ vì nó đang “hot”.
Một số lỗi hay gặp:
- Build nhầm target. Module dùng
std::fshayprintln!cần target WASI. Module thuần tính toán thì dùngwasm32-unknown-unknown. - Quên
#[no_mangle]hoặcextern "C", khiến hàm không xuất hiện trong danh sách export. - Nhầm lẫn kiểu: Wasm chỉ có kiểu số nguyên thủy. Muốn truyền string, bạn phải tự quản lý cấp phát trong linear memory, hoặc dùng Component Model với WIT.
- Cấp quyền quá rộng (
--dir=/, inherit toàn bộ env), làm mất đi lợi ích lớn nhất của WASI.
8. Kết luận
WASI biến WebAssembly từ công nghệ của trình duyệt thành một định dạng thực thi portable, nhẹ và sandbox chặt cho phía server. Mô hình capability (không có quyền nào nếu không được cấp) là điểm khác biệt cốt lõi so với process và container truyền thống.
Trong bài, bạn đã build một chương trình WASI và thấy quyền filesystem được kiểm soát ra sao. Bạn cũng đã nhúng một module Wasm vào ứng dụng Java 21 bằng runtime thuần Java và viết test cho nó.
Hãy cân nhắc Wasm khi bạn cần chạy code không tin cậy, cần plugin đa ngôn ngữ hoặc cần khởi động cực nhanh. Còn nếu bài toán chỉ là logic nghiệp vụ thông thường trong một service Java, giải pháp đơn giản nhất vẫn thường là tốt nhất.