(技术积累)(Part 1)How does Wasm module interact with JavaScripte in browser?

(Part 1)How does Wasm module interact with JavaScripte in browser?

Reference:

  • WebAssembly in Action. Chapter 4.1

Background

假设一个公司有一个用C++开发的销售程序,但想开发一个网页端销售接口用于对用户输入的数据进行验证,例如:

整个业务系统的逻辑如下:

用户填写的数据要在浏览器端和服务器端都进行验证,理由如下:

  1. 使用户端响应更快;
  2. 减少服务端负载;

尽管在浏览器中验证用户数据很有帮助,但不能假定数据在到达服务器时就是完美的;有一些方法可以绕过浏览器的验证检查。无论数据是无意提交的还是用户有意提交的,您都不希望冒险向数据库中添加不良数据。无论浏览器中的验证有多好,服务器端代码都必须始终验证它接收到的数据。

在浏览器端,我们需要构建的验证功能有:

实现该功能的C++代码如下:

  1. 检测一个值是否为空:
int ValidateValueProvided(const char* value, const char* error_message, char* return_error_message){
    // If the string is null or the first character is the null terminator then the string is empty
    if ((value == NULL) || (value[0] == '\0')){
        strcpy(return_error_message, error_message);
        return 0;
    }
    // Everything is ok
    return 1;
}
  1. 检测Name属性是否合理(非空,长度限制):
int ValidateName(char* name, int maximum_length, char* return_error_message){
    // Validation 1: A name must be provided
    if (ValidateValueProvided(name, "A Product Name must be provided.", return_error_message) == 0){ return 0; }
    // Validation 2: A name must not exceed the specified length
    if (strlen(name) > maximum_length){
        strcpy(return_error_message, "The Product Name is too long.");
        return 0;
    }
    // Everything is ok (no issues with the name)
    return 1;
}
  1. 检查目录ID是否存在:
int IsCategoryIdInArray(char* selected_category_id, int* valid_category_ids, int array_length){
    // Loop through the array of valid ids that were passed in...
    int category_id = atoi(selected_category_id);
    for (int index = 0; index < array_length; index++){
      // If the selected id is in the array then...
      if (valid_category_ids[index] == category_id){
        // The user has a valid selection so exit now
        return 1;
      }
    }
    // We did not find the category id in the array
    return 0;
  }
  1. 检查Category属性是否合理:
int ValidateCategory(char* category_id, int* valid_category_ids, int array_length, char* return_error_message){
    // Validation 1: A Category ID must be selected
    if (ValidateValueProvided(category_id, "A Product Category must be selected.", return_error_message) == 0){
      return 0;
    }
    // Validation 2: A list of valid Category IDs must be passed in
    if ((valid_category_ids == NULL) || (array_length == 0)){
      strcpy(return_error_message, "There are no Product Categories available.");
      return 0;
    }
    // Validation 3: The selected Category ID must match one of the IDs provided
    if (IsCategoryIdInArray(category_id, valid_category_ids, array_length) == 0){
      strcpy(return_error_message, "The selected Product Category is not valid.");
      return 0;
    }
    // Everything is ok (no issues with the category id)
    return 1;
  }

Emscripten

我们要使用上述代码中的ValidateNameValidateCategory两个函数来验证用户在网页端的输入是否合理,这就必然涉及到Wasm与JS交互的问题,为了防止C++重命名函数导致JS无法调用,这里需要在被编译的函数外包上如下宏:

#ifdef __cplusplus
extern "C" { // So that the C++ compiler does not rename our function names
#endif
    //...
#ifdef __cplusplus
}
#endif

另外,需要在这两个函数的前面加上如下的宏来使其自动变为export function:

#ifdef __EMSCRIPTEN__ 
EMSCRIPTEN_KEEPALIVE 
#endif

当然,在编译时也可以通过命令行EXPORTED_FUNCIONTS参数来指定export function。

在文件的开头,加上Emscripten相关头文件:

#ifdef __EMSCRIPTEN__
  #include <emscripten.h>
#endif

Compilation

emcc validate.cpp -o validate.js -s EXPORTED_RUNTIME_METHODS=['ccall','UTF8ToString']

EXPORTED_RUNTIME_METHODS指定了Emscripten helper functions:ccall,UTF8ToString

Web page

<!DOCTYPE html>
<html>
  <head>
    <title>Edit Product</title>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.0/umd/popper.min.js"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.0/js/bootstrap.min.js"></script>
  </head>
  <body onload="initializePage()">
    <div class="container">
      <h1>Edit Product</h1>

      <div id="errorMessage" class="alert alert-danger" role="alert" style="display:none;">
      </div>

      <div class="form-group">
        <label for="name">Name:</label>
        <input type="text" class="form-control" id="name">
      </div>
      <div class="form-group">
        <label for="category">Category:</label>
        <select class="custom-select" id="category">
          <option value="0"></option>
          <option value="100">Jeans</option>
          <option value="101">Dress Pants</option>
        </select>
      </div>
 
      <button type="button" class="btn btn-primary" onclick="onClickSave()">Save</button>
    </div>

    <script src="editproduct.js"></script>
    <script src="validate.js"></script>
  </body>
</html>

Creating the JavaScript that will interact with the module

这里构建一个editproduct.js,用于Wasm和html之间的交互.

  1. 初始化数据显示:
const initialData = { 
    name: "Women's Mid Rise Skinny Jeans", 
    categoryId: "100", 
}
  1. 定义常量MAXIMUM_NAME_LENGTHVALID_CATEGORY_IDS用来表示name的最大长度和有效目录ID数组:
const MAXIMUM_NAME_LENGTH = 50; 
const VALID_CATEGORY_IDS = [100, 101];
  1. 补充HTML中的initializePage函数,使其用initialData填充表单:
function initializePage() {
  // 初始化name
  document.getElementById("name").value = initialData.name;
  const category = document.getElementById("category");
  const count = category.length;
  for (let index = 0; index < count; index++) {
    // 如果value合理,则初始化Index,让Select选中这个键
    if (category[index].value === initialData.categoryId) {
      category.selectedIndex = index;
      break;
    }
  }
}
  1. 定义函数getSelectedCategoryId来获取category中被选中的项:
function getSelectedCategoryId() {
  const category = document.getElementById("category");
  const index = category.selectedIndex;
  if (index !== -1) { return category[index].value; }
  return "0";
}
  1. 在HTML文件中,设置了一个名为errorMessage的初始不可见的div块,这里要定义函数setErrorMessage来将错误信息显示到这个div上:
function setErrorMessage(error) {
  const errorMessage = document.getElementById("errorMessage");
  errorMessage.innerText = error; 
  errorMessage.style.display = (error === "" ? "none" : "");
}
  1. 补充onClickSave函数,使其在被点击时调用ValidateNameValidateCategory两个函数来验证用户在网页端的输入是否合理,并根据结果向服务器发送信息/报错:

这里,JS需要传递给ValidateName一个buffer name和一个错误信息指针,其会读取name中的内容并将可能的错误信息写如错误指针处的内存。

注意到C++中的ValidateNameValidateCategory都会使用char指针来传递错误信息,但由于Wasm只支持四种基本类型的数据,故想要把这个信息传给JS代码就需要使用memory。

Emscripten plumbing code提供了符合C标准的_malloc_free函数来操纵Wasm的内存:

function onClickSave() {
  let errorMessage = "";
  const errorMessagePointer = Module._malloc(256);
  //从HTML中获取用户输入的值
  const name = document.getElementById("name").value;
  const categoryId = getSelectedCategoryId();

  if (!validateName(name, errorMessagePointer) ||
      !validateCategory(categoryId, errorMessagePointer)) {
    errorMessage = Module.UTF8ToString(errorMessagePointer);
  } 
  Module._free(errorMessagePointer);
  setErrorMessage(errorMessage);
  if (errorMessage === "") {
    // everything is ok...we can pass the data to the server-side code
  }
}

其中,UTF8ToString函数用于从Wasm内存中读取字符串。

注意此时的ValidateNameValidateCategory都还是JS函数,我们这里使用与Wasm同名的包装函数,通过Emscripten helper function ccall来调用Wasm中的函数:

function validateName(name, errorMessagePointer) {
  const isValid = Module.ccall('ValidateName',
      'number',
      ['string', 'number', 'number'],
      [name, MAXIMUM_NAME_LENGTH, errorMessagePointer]);

  return (isValid === 1);
}

通过开头定义的全局变量来给validateCategory传递数组长度等信息:

function validateCategory(categoryId, errorMessagePointer) {
  const arrayLength = VALID_CATEGORY_IDS.length;
  const bytesPerElement = Module.HEAP32.BYTES_PER_ELEMENT;
  const arrayPointer = Module._malloc((arrayLength * bytesPerElement));
  // 为VALID_CATEGORY_IDS分配一块内存并将其设置为数组的内容
  Module.HEAP32.set(VALID_CATEGORY_IDS, (arrayPointer / bytesPerElement));

  const isValid = Module.ccall('ValidateCategory', 
      'number',
      ['string', 'number', 'number', 'number'],
      [categoryId, arrayPointer, arrayLength, errorMessagePointer]);

  Module._free(arrayPointer);

  return (isValid === 1);
}

以上就是editproduct.js的全部内容,validate.js会将Wasm模块初始化,而editproduct.js会初始化界面、在用户点击按钮后调用Wasm函数,返回判断结果,充当Wasm模块与浏览器之间的桥梁。