Sự kết hợp của Emscripten

Tệp này liên kết JS với wasm của bạn!

Trong bài viết về wasm gần đây nhất, tôi đã nói về cách biên dịch thư viện C sang wasm để bạn có thể sử dụng thư viện đó trên web. Một điều nổi bật đối với tôi (và nhiều độc giả) là cách thô thiển và hơi khó xử mà bạn phải khai báo theo cách thủ công những hàm của mô-đun wasm mà bạn đang sử dụng. Để làm mới trí nhớ của bạn, đây là đoạn mã mà tôi đang đề cập:

const api = {
    version: Module.cwrap('version', 'number', []),
    create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
    destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};

Ở đây, chúng ta khai báo tên của các hàm được đánh dấu bằng EMSCRIPTEN_KEEPALIVE, loại dữ liệu trả về và loại đối số của các hàm đó. Sau đó, chúng ta có thể sử dụng các phương thức trên đối tượng api để gọi các hàm này. Tuy nhiên, việc sử dụng wasm theo cách này không hỗ trợ chuỗi và yêu cầu bạn di chuyển các đoạn bộ nhớ theo cách thủ công, khiến nhiều API thư viện rất khó sử dụng. Có cách nào tốt hơn không? Có chứ, nếu không thì bài viết này sẽ nói về cái gì?

Đánh tráo tên C++

Mặc dù trải nghiệm của nhà phát triển là lý do đủ để xây dựng một công cụ giúp ích cho các liên kết này, nhưng thực sự có một lý do cấp thiết hơn: Khi bạn biên dịch mã C hoặc C++, mỗi tệp sẽ được biên dịch riêng biệt. Sau đó, trình liên kết sẽ xử lý việc kết hợp tất cả các tệp đối tượng được gọi là này với nhau và biến chúng thành một tệp wasm. Với C, tên của các hàm vẫn có trong tệp đối tượng để trình liên kết sử dụng. Tất cả những gì bạn cần để có thể gọi một hàm C là tên, mà chúng ta sẽ cung cấp dưới dạng một chuỗi cho cwrap().

Mặt khác, C++ hỗ trợ nạp chồng hàm, nghĩa là bạn có thể triển khai cùng một hàm nhiều lần miễn là chữ ký khác nhau (ví dụ: tham số được nhập khác nhau). Ở cấp trình biên dịch, một tên đẹp như add sẽ bị mangled thành một tên mã hoá chữ ký trong tên hàm cho trình liên kết. Do đó, chúng ta sẽ không thể tra cứu hàm bằng tên của hàm nữa.

Nhập embind

embind là một phần của chuỗi công cụ Emscripten và cung cấp cho bạn một loạt các macro C++ cho phép bạn chú thích mã C++. Bạn có thể khai báo những hàm, enum, lớp hoặc loại giá trị mà bạn dự định sử dụng từ JavaScript. Hãy bắt đầu đơn giản với một số hàm thuần tuý:

#include <emscripten/bind.h>

using namespace emscripten;

double add(double a, double b) {
    return a + b;
}

std::string exclaim(std::string message) {
    return message + "!";
}

EMSCRIPTEN_BINDINGS(my_module) {
    function("add", &add);
    function("exclaim", &exclaim);
}

So với bài viết trước, chúng ta không còn thêm emscripten.h nữa vì không cần chú giải hàm bằng EMSCRIPTEN_KEEPALIVE nữa. Thay vào đó, chúng ta có một phần EMSCRIPTEN_BINDINGS trong đó liệt kê các tên mà chúng ta muốn hiển thị hàm cho JavaScript.

Để biên dịch tệp này, chúng ta có thể sử dụng cùng một chế độ thiết lập (hoặc nếu muốn, cùng một hình ảnh Docker) như trong bài viết trước. Để sử dụng embind, chúng ta thêm cờ --bind:

$ emcc --bind -O3 add.cpp

Bây giờ, bạn chỉ cần tạo một tệp HTML để tải mô-đun wasm mới tạo:

<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
    console.log(Module.add(1, 2.3));
    console.log(Module.exclaim("hello world"));
};
</script>

Như bạn có thể thấy, chúng ta không còn sử dụng cwrap() nữa. Tính năng này hoạt động ngay từ đầu. Nhưng quan trọng hơn, chúng ta không phải lo lắng về việc sao chép các khối bộ nhớ theo cách thủ công để các chuỗi hoạt động! embind cung cấp cho bạn tính năng này miễn phí, cùng với các hoạt động kiểm tra kiểu:

Lỗi DevTools khi bạn gọi một hàm có số lượng đối số không chính xác hoặc các đối số có loại không chính xác

Điều này rất tuyệt vì chúng ta có thể phát hiện sớm một số lỗi thay vì xử lý các lỗi wasm đôi khi khá khó xử lý.

Đối tượng

Nhiều hàm và hàm khởi tạo JavaScript sử dụng đối tượng tuỳ chọn. Đây là một mẫu tốt trong JavaScript, nhưng cực kỳ tẻ nhạt khi phải thực hiện theo cách thủ công trong wasm. embind cũng có thể giúp ích ở đây!

Ví dụ: tôi đã tạo ra một hàm C++ vô cùng hữu ích để xử lý các chuỗi của mình và tôi muốn sử dụng hàm này trên web một cách khẩn cấp. Sau đây là cách tôi thực hiện:

#include <emscripten/bind.h>
#include <algorithm>

using namespace emscripten;

struct ProcessMessageOpts {
    bool reverse;
    bool exclaim;
    int repeat;
};

std::string processMessage(std::string message, ProcessMessageOpts opts) {
    std::string copy = std::string(message);
    if(opts.reverse) {
    std::reverse(copy.begin(), copy.end());
    }
    if(opts.exclaim) {
    copy += "!";
    }
    std::string acc = std::string("");
    for(int i = 0; i < opts.repeat; i++) {
    acc += copy;
    }
    return acc;
}

EMSCRIPTEN_BINDINGS(my_module) {
    value_object<ProcessMessageOpts>("ProcessMessageOpts")
    .field("reverse", &ProcessMessageOpts::reverse)
    .field("exclaim", &ProcessMessageOpts::exclaim)
    .field("repeat", &ProcessMessageOpts::repeat);

    function("processMessage", &processMessage);
}

Tôi đang xác định một cấu trúc cho các tuỳ chọn của hàm processMessage(). Trong khối EMSCRIPTEN_BINDINGS, tôi có thể sử dụng value_object để khiến JavaScript xem giá trị C++ này là một đối tượng. Tôi cũng có thể sử dụng value_array nếu muốn sử dụng giá trị C++ này dưới dạng một mảng. Tôi cũng liên kết hàm processMessage() và phần còn lại là liên kết ma thuật. Giờ đây, tôi có thể gọi hàm processMessage() từ JavaScript mà không cần mã nguyên mẫu:

console.log(Module.processMessage(
    "hello world",
    {
    reverse: false,
    exclaim: true,
    repeat: 3
    }
)); // Prints "hello world!hello world!hello world!"

Lớp

Để đảm bảo tính toàn diện, tôi cũng nên cho bạn biết cách embind cho phép bạn hiển thị toàn bộ lớp, mang lại nhiều hiệu quả cộng hưởng với các lớp ES6. Có thể bạn đã bắt đầu thấy một mẫu:

#include <emscripten/bind.h>
#include <algorithm>

using namespace emscripten;

class Counter {
public:
    int counter;

    Counter(int init) :
    counter(init) {
    }

    void increase() {
    counter++;
    }

    int squareCounter() {
    return counter * counter;
    }
};

EMSCRIPTEN_BINDINGS(my_module) {
    class_<Counter>("Counter")
    .constructor<int>()
    .function("increase", &Counter::increase)
    .function("squareCounter", &Counter::squareCounter)
    .property("counter", &Counter::counter);
}

Về phía JavaScript, lớp này gần giống như một lớp gốc:

<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
    const c = new Module.Counter(22);
    console.log(c.counter); // prints 22
    c.increase();
    console.log(c.counter); // prints 23
    console.log(c.squareCounter()); // prints 529
};
</script>

Còn C thì sao?

embind được viết cho C++ và chỉ có thể dùng trong các tệp C++, nhưng điều đó không có nghĩa là bạn không thể liên kết với các tệp C! Để kết hợp C và C++, bạn chỉ cần tách các tệp đầu vào thành hai nhóm: Một nhóm cho tệp C và một nhóm cho tệp C++ và tăng cường các cờ CLI cho emcc như sau:

$ emcc --bind -O3 --std=c++11 a_c_file.c another_c_file.c -x c++ your_cpp_file.cpp

Kết luận

embind mang đến cho bạn những cải tiến đáng kể về trải nghiệm nhà phát triển khi làm việc với wasm và C/C++. Bài viết này không đề cập đến tất cả các tuỳ chọn mà embind cung cấp. Nếu bạn quan tâm, bạn nên tiếp tục với tài liệu của embind. Xin lưu ý rằng việc sử dụng embind có thể làm cho cả mô-đun wasm và mã kết nối JavaScript của bạn lớn hơn tới 11k khi được nén bằng gzip – đáng chú ý nhất là trên các mô-đun nhỏ. Nếu bạn chỉ có một nền tảng wasm rất nhỏ, thì embind có thể tốn nhiều chi phí hơn giá trị của nó trong môi trường sản xuất! Tuy nhiên, bạn vẫn nên thử.