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 wasm gần đây nhất, tôi đã nói về cách biên dịch thư viện C thành wasm để bạn có thể sử dụng nó trên web. Một điều nổi bật với tôi (và với nhiều độc giả) là bạn phải khai báo theo cách thủ công những chức năng của mô-đun wasm mà bạn đang sử dụng thô và hơi khó hiểu. Để bạn suy ngẫm, đây là đoạn mã mà tôi đang nói đế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 được đánh dấu bằng EMSCRIPTEN_KEEPALIVE, loại dữ liệu trả về của các hàm đó 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ợ các chuỗi và yêu cầu bạn phải di chuyển các phần bộ nhớ theo cách thủ công, khiến nhiều API thư viện trở nên rất khó sử dụng. Không có cách nào tốt hơn không? Tại sao lại có, nếu không bài viết này sẽ nói về điều gì?

Xuyên tạc tên C++

Mặc dù trải nghiệm của nhà phát triển đủ lý do để xây dựng một công cụ giúp thực hiện các liên kết này, nhưng thực sự 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 đó, trình liên kết sẽ xử lý việc hợp nhất tất cả các tệp đối tượng được gọi là này lại 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 mà chúng tôi cung cấp dưới dạng chuỗi cho cwrap().

Mặt khác, C++ hỗ trợ tính năng 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ụ: các tham số được nhập khác nhau). Ở cấp độ trình biên dịch, một tên hay như add sẽ được gắn gọn thành tên để mã hoá chữ ký trong tên hàm cho trình liên kết. Do đó, chúng tôi sẽ không thể tra cứu hàm bằng tên của mình nữa.

Nhập liên kết

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 các hàm, enum, lớp hoặc loại giá trị mà mình định sử dụng trong JavaScript. Hãy bắt đầu đơn giản bằng một số hàm đơn giản:

#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 tôi không sử dụng emscripten.h nữa, vì chúng ta không phải chú thích hàm bằng EMSCRIPTEN_KEEPALIVE nữa. Thay vào đó, chúng ta có phần EMSCRIPTEN_BINDINGS, trong đó chúng ta liệt kê các tên mà chúng ta muốn hiển thị hàm của mình với JavaScript.

Để biên dịch tệp này, chúng ta có thể sử dụng cách thiết lập tương tự (hoặc nếu bạn muốn, cùng 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

Giờ đây, bạn chỉ cần tải xong 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 có thể thấy, chúng ta không 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 thủ công các phần bộ nhớ để các chuỗi hoạt động! embind cung cấp cho bạn tính năng miễn phí, cùng với việc kiểm tra loại:

Lỗi Công cụ cho nhà phát triển 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á tốt vì chúng tôi có thể sớm phát hiện được một số lỗi thay vì phải xử lý các lỗi wasm đôi khi khá khó sử dụng.

Đối tượng

Nhiều hàm khởi tạo và hàm JavaScript sử dụng các đối tượng tuỳ chọn. Đó là một mẫu tốt trong JavaScript, nhưng cực kỳ tẻ nhạt khi nhận ra trong wasm theo cách thủ công. Liên kết cũng có thể hữu ích trong trường hợp này!

Ví dụ: tôi vừa nghĩ ra hàm C++ cực kỳ hữu ích này, giúp xử lý các chuỗi của tôi và tôi muốn sử dụng nó 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 tuỳ chọn của hàm processMessage(). Trong khối EMSCRIPTEN_BINDINGS, tôi có thể sử 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ảng. Tôi cũng liên kết hàm processMessage() và phần còn lại là thần kỳ được kết hợp. Giờ đây, tôi có thể gọi hàm processMessage() từ JavaScript mà không cần bất kỳ mã nguyên mẫu nào:

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

Lớp

Để hoàn thiện, tôi cũng sẽ 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ự phối hợp với các lớp ES6. Có thể bây giờ bạn sẽ bắt đầu thấy 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, đâ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 phân tách các tệp đầu vào thành 2 nhóm: một nhóm cho C và một nhóm cho tệp C++ và tăng cườ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 mang đến cho bạn những cải tiến tuyệt vời về 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 mọi tuỳ chọn kết hợp cung cấp. Nếu quan tâm, bạn nên tiếp tục với tài liệu về embind. Xin lưu ý rằng việc sử dụng phương thức kết hợp 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 đa 11k khi gzip — đáng chú ý nhất là ở các mô-đun nhỏ. Nếu bạn chỉ có bề mặt wasm rất nhỏ, embind có thể tốn kém hơn so với giá trị trong môi trường sản xuất! Tuy nhiên, chắc chắn bạn nên thử.