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

Nó liên kết JS với wasm của bạn!

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

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 mà chúng ta đã đánh dấu bằng EMSCRIPTEN_KEEPALIVE, kiểu dữ liệu trả về của các hàm đó và kiểu của các đối số. 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ợ các chuỗi và yêu cầu bạn di chuyển các khối bộ nhớ theo cách thủ công, điều này khiến nhiều API thư viện rất tẻ nhạt khi sử dụng. Có cách nào hay hơn không? Có chứ, nếu không thì bài viết này sẽ nói về cái gì?

C++ name mangling (làm rối tên C++)

Mặc dù trải nghiệm của nhà phát triển là lý do đủ để tạo một công cụ giúp liên kết các thành phần này, nhưng thực ra có một lý do cấp bách 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 đó, một 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 này với nhau và chuyể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. Chúng ta sẽ cung cấp tên này dưới dạng một chuỗi cho cwrap().

Mặt khác, C++ hỗ trợ việc nạp chồng hàm, tức 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ụ: các tham số được nhập khác nhau). Ở cấp trình biên dịch, một tên đẹp như add sẽ được xử lý 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 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 kiểu 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 của tôi, chúng ta sẽ không đưa emscripten.h vào nữa, vì chúng ta không phải chú thích các hàm bằng EMSCRIPTEN_KEEPALIVE nữa. Thay vào đó, chúng ta có một phần EMSCRIPTEN_BINDINGS, trong đó chúng ta liệt kê các tên mà chúng ta muốn hiển thị các 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ờ, tất cả những gì còn lại là tạo một tệp HTML tải mô-đun wasm mới tạo của chúng ta:

<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 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 khi bạn mở hộp. Nhưng quan trọng hơn là chúng ta không phải lo lắng về việc sao chép thủ công các khối bộ nhớ để làm cho các chuỗi hoạt động! embind cung cấp cho bạn điều đó miễn phí, cùng với các loại kiểm tra:

DevTools gặp lỗi 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 khá tuyệt vời vì chúng ta có thể phát hiện sớm một số lỗi thay vì phải xử lý các lỗi wasm đôi khi khá khó xử lý.

Đối tượng

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

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

#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 lựa chọn của hàm processMessage(). Trong khối EMSCRIPTEN_BINDINGS, tôi có thể dùng value_object để làm cho JavaScript xem giá trị C++ này dưới dạng một đối tượng. Tôi cũng có thể dùng value_array nếu muốn dùng giá trị C++ này làm một mảng. Tôi cũng liên kết hàm processMessage() và phần còn lại là phép thuật embind. Giờ đây, tôi có thể gọi hàm processMessage() qua JavaScript mà không cần bất kỳ mã khởi tạo nào:

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

Lớp

Để cho đầy đủ, tôi cũng nên cho bạn thấy cách embind cho phép bạn hiển thị toàn bộ các lớp, điều này mang lại nhiều sự cộng hưởng với các lớp ES6. Có lẽ đến giờ bạn đã bắt đầu thấy một quy luật:

#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, điều này gần như giống với 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++ rồi tăng 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 giúp bạn cải thiện đáng kể trải nghiệm của 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 lựa chọn mà embind cung cấp. Nếu quan tâm, bạn nên tiếp tục tìm hiểu 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 dính 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 bề mặt wasm rất nhỏ, thì embind có thể tốn kém hơn giá trị của nó trong môi trường sản xuất! Tuy nhiên, bạn chắc chắn nên dùng thử.