1. 概要
WebAssemblyを実装するための環境であるEmscriptenを使いコールバックを実装します。つまり、JavaScriptで定義した関数をWebAssemblyの方へ登録して、呼び出されるようにします。
2. 開発環境
環境構築は以前投稿したこちらをベースに作成します。CMakeLists.txtは以下の通りです。SDKやソースファイルのパスは異なっていますが、それらはプロジェクトによって変わるものなので、説明は省略します。
cmake_minimum_required(VERSION 3.31)
project(WasmProject)
set(TARGET_NAME "WASM")
set(APP_PATH "${CMAKE_SOURCE_DIR}/../Application")
set(THIRD_PATH "${CMAKE_SOURCE_DIR}/../3rd")
set(EMSDK_PATH "${THIRD_PATH}/emsdk")
file(GLOB SRC_FILES
"${CMAKE_SOURCE_DIR}/src/*.cpp"
"${CMAKE_SOURCE_DIR}/src/*.h"
"${CMAKE_SOURCE_DIR}/src/CallbackTest/*.cpp"
"${CMAKE_SOURCE_DIR}/src/CallbackTest/*.h"
)
add_executable(${TARGET_NAME} ${SRC_FILES})
target_include_directories(${TARGET_NAME} PRIVATE
"${EMSDK_PATH}/upstream/emscripten/cache/sysroot/include"
)
target_compile_options(${TARGET_NAME} PRIVATE -g)
target_link_options(${TARGET_NAME} PRIVATE
"-g"
"--bind"
"-sMODULARIZE=1"
"-sEXPORT_NAME=${TARGET_NAME}"
"-sALLOW_MEMORY_GROWTH=1"
"--no-entry"
)
set_target_properties(${TARGET_NAME} PROPERTIES SUFFIX ".js")
set(OUTPUT_DIST_DIR "${APP_PATH}/wasm")
install(FILES
"${CMAKE_BINARY_DIR}/${TARGET_NAME}.js"
"${CMAKE_BINARY_DIR}/${TARGET_NAME}.wasm"
DESTINATION "${OUTPUT_DIST_DIR}"
)
注意点はtarget_link_optionsです。
- "--bind"は、後述のバインディング機能を使用するためのオプションです
- "-sALLOW_MEMORY_GROWTH=1"は、メモリ使用量の上限を無くすための設定です
3. ソースコード(C++)
以下の構成で実装します。
モジュール | 役割 |
---|---|
Provider | JavaScript側に公開する関数を定義します。 |
AppWrapper | Providerから呼び出されるクラスで、 ビジネスロジックを実装します。 |
CallbackUtility | コールバック関数の登録及び呼び出しを行います |
Parameters | コールバック関数に渡す引数の情報を管理します。 |
FunctionId | 関数登録に使用するIDを定義します。 |
3.1 Provider.cpp
#include <emscripten/bind.h>
#include <emscripten/emscripten.h>
#include "AppWrapper.h"
using namespace std;
using namespace emscripten;
void update() {
AppWrapper::getInstance().update();
}
void startCallbackTest(uint16_t ID, val func) {
AppWrapper::getInstance().startCallbackTest(ID, func);
}
EMSCRIPTEN_BINDINGS(my_module) {
emscripten::function("update", &update);
emscripten::function("startCallbackTest", &startCallbackTest);
}
EMSCRIPTEN_BINDINGSを使用して"update", "startCallback"の2つを公開しています。
以前はextern "C"とEMSCRIPTEN_KEEPALIVEを使っていましたが、それだとstringやemscripten::valのようなC++用の機能が使用できません。そのため、バインディングと呼ばれる機能を使います。EMSCRIPTEN_BINDINGSが関数をバインディングするための記述です。
引数のvalはC++でJavaScriptのオブジェクトを操作するためのクラスで、関数オブジェクトを渡すために利用しています。
3.2 AppWrapperクラス
#pragma once
#include <emscripten/bind.h>
class AppWrapper {
private:
// インスタンス
static AppWrapper* mInstance;
public:
// インスタンス取得
static AppWrapper& getInstance();
// インスタンス解放
static void releaseInstance();
// 定期処理
void update();
// CallbackTestの処理を開始します
void startCallbackTest(uint16_t ID, emscripten::val func);
private:
// コンストラクタ
AppWrapper();
// デストラクタ
virtual ~AppWrapper();
};
#include <string>
#include "AppWrapper.h"
#include "CallbackUtility.h"
#include "FunctionId.h"
using namespace std;
using namespace emscripten;
AppWrapper* AppWrapper::mInstance = nullptr;
// インスタンス取得
AppWrapper::AppWrapper() {
}
// インスタンス解放
AppWrapper::~AppWrapper() {
}
// インスタンス取得
AppWrapper& AppWrapper::getInstance() {
if (mInstance == nullptr) {
mInstance = new AppWrapper();
}
return *mInstance;
}
// インスタンス解放
void AppWrapper::releaseInstance() {
if (mInstance != nullptr) {
delete mInstance;
mInstance = nullptr;
}
}
// 定期処理
void AppWrapper::update() {
static bool arg1 = false;
static int arg2 = 0;
static string arg3 = "";
arg1 = !arg1;
++arg2;
arg3 = to_string(arg1) + "_" + to_string(arg2);
Parameters params;
params.addBool(arg1);
params.addNumber(arg2);
params.addString(arg3);
CallbackUtility::call(FUNCTION_ID_SAMPLE, params);
}
// Callback登録
void AppWrapper::startCallbackTest(uint16_t ID, val func) {
CallbackUtility::registerFunction(ID, func);
}
CallbackUtility::callでコールバック関数の呼び出しが行われます。
3.3 CallbackUtilityクラス
#pragma once
#include <map>
#include <emscripten/bind.h>
#include "Parameters.h"
class CallbackUtility {
private:
// 関数登録用ID
typedef uint16_t FUNCTION_ID;
private:
// 登録された関数群
static std::map<FUNCTION_ID, emscripten::val> mFunctionMap;
public:
// 関数登録
static void registerFunction(FUNCTION_ID ID, emscripten::val func);
// 関数削除
static void removeFunction(FUNCTION_ID ID);
// 関数呼び出し
static void call(FUNCTION_ID ID, const Parameters& parameters);
private:
// コンストラクタ
CallbackUtility();
// デストラクタ
virtual ~CallbackUtility();
};
#include "CallbackUtility.h"
using namespace std;
using namespace emscripten;
map<CallbackUtility::FUNCTION_ID, val> CallbackUtility::mFunctionMap;
CallbackUtility::CallbackUtility() {
}
CallbackUtility::~CallbackUtility() {
}
void CallbackUtility::registerFunction(FUNCTION_ID ID, val func) {
mFunctionMap[ID] = func;
}
void CallbackUtility::removeFunction(FUNCTION_ID ID) {
if (mFunctionMap.count(ID) != 0) {
mFunctionMap.erase(ID);
}
}
void CallbackUtility::call(FUNCTION_ID ID, const Parameters& parameters) {
if (mFunctionMap.count(ID) != 0) {
const auto& parameterList = parameters.getParameterList();
val args = val::array();
for (size_t index = 0; index < parameterList.size(); ++index) {
const auto& value = parameterList[index];
switch (value.first) {
case Parameters::ValueType::BOOLEAN:
args.set(index, value.second.valueBool);
break;
case Parameters::ValueType::NUMBER:
args.set(index, value.second.valueNumber);
break;
case Parameters::ValueType::STRING:
args.set(index, value.second.valueString);
break;
}
}
mFunctionMap[ID].call<val>("apply", val::undefined(), args);
}
}
call関数に対して呼び出す関数のID及びその引数となるParameterを渡すことで、JavaScript側の関数を呼び出します。実際に呼び出しているのは最後の
mFunctionMap[name].call<val>("apply", val::undefined(), args);
の部分です。こう記述することで、登録された関数にParametersの情報を引数として渡し呼び出すことができます。
3.4 Parametersクラス
#pragma once
#include <string>
#include <utility>
#include <vector>
class Parameters {
public:
// ブラウザ側へ通知するパラメータのデータ型
enum class ValueType : int {
BOOLEAN,
NUMBER,
STRING,
};
private:
// パラメータ管理情報
class Parameter {
public:
bool valueBool;
int valueNumber;
std::string valueString;
public:
Parameter()
: valueBool(false)
, valueNumber(0)
, valueString("") {
}
virtual ~Parameter() {
}
};
private:
// ブラウザへ通知するパラメータ群
std::vector<std::pair<ValueType, Parameter>> mParameterList;
public:
// コンストラクタ
Parameters();
// デストラクタ
virtual ~Parameters();
// Boolean型のパラメータを追加
void addBool(bool value);
// Number型のパラメータを追加
void addNumber(int value);
// String型のパラメータを追加
void addString(const std::string& value);
// パラメータ群を取得
const std::vector<std::pair<ValueType, Parameter>>& getParameterList() const;
};
#include "Parameters.h"
using namespace std;
Parameters::Parameters() {
}
Parameters::~Parameters() {
}
void Parameters::addBool(bool value) {
mParameterList.push_back({ValueType::BOOLEAN, Parameter()});
mParameterList[mParameterList.size() - 1].second.valueBool = value;
}
void Parameters::addNumber(int value) {
mParameterList.push_back({ValueType::NUMBER, Parameter()});
mParameterList[mParameterList.size() - 1].second.valueNumber = value;
}
void Parameters::addString(const string& value) {
mParameterList.push_back({ValueType::STRING, Parameter()});
mParameterList[mParameterList.size() - 1].second.valueString = value;
}
const vector<pair<Parameters::ValueType, Parameters::Parameter>>&
Parameters::getParameterList() const {
return mParameterList;
}
今回は引数としてNumber, String, Booleanを渡す仕組みを作成しますので、その3パターンの引数をセットできる仕組みを実装しています。
3.5 FunctionId
#pragma once
/*
* Name = Sample
* Parameter 1 = arg1: boolean
* Parameter 2 = arg2: number
* Parameter 3 = arg3: string
* Return = void
*/
#define FUNCTION_ID_SAMPLE 0x0001
登録する関数のIDを定義します。またコメントにて、名称・引数・戻り値を記載しています。
4. ソースコード(TypeScript)
WebAssemblyはJavaScriptでも呼び出すことが可能ですが、Angularで作ったコードを流用しているので、TypeScriptで実装します。
こちらで作ったコードをベースにしています。
4.1 型定義ファイル
declare module 'wasm/WASM.js' {
const WASM: () => Promise<{
update: () => void;
startCallbackTest: (ID: number, callback: (arg1: boolean, arg2: number, arg3: string) => void) => void;
}>;
export default WASM;
}
WebAssemblyで公開されている関数の型定義です。バインディングを使って実装する場合は、接頭語の"_"は不要になるみたいです。
4.2 関数ID
export const FUNCTION_ID = {
SAMPLE: 0x0001,
} as const;
ソースコード(C++)に記載しているFunctionId.hと同じ定義をTypeScriptでも記載しています。
4.3 wasm呼び出しクラス
import WASM from 'wasm/WASM.js'
export class WasmWrapper {
private mInstance: Awaited<ReturnType<typeof WASM>> | undefined = undefined;
public load(): Promise<void> {
if (this.mInstance) {
return Promise.resolve();
}
return WASM().then((result) => {
this.mInstance = result;
});
}
public update() {
if (this.mInstance) {
this.mInstance.update();
}
}
public startCallback(ID: number, callback: (arg1: boolean, arg2: number, arg3: string) => void): boolean {
let result: boolean = false;
if (this.mInstance) {
this.mInstance.startCallbackTest(ID, callback);
result = true;
}
return result;
}
}
ベースにしたソースコードのwasm.tsと大して変わっていません。WebAssemblyで公開されているupdateとstartCallbackの定義を追加したぐらいです。
4.4 ビジネスロジック
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { WasmWrapper } from './WasmWrapper';
import { FUNCTION_ID } from 'types/WASM_FunctionId';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
title = 'Application';
private wasm = new WasmWrapper();
constructor() {
this.wasm.load().then(() => {
this.wasm.startCallback(FUNCTION_ID.SAMPLE, this.callback.bind(this));
setInterval(() => { this.update(); }, 1000);
});
}
private callback(arg1: boolean, arg2: number, arg3: string) {
console.log(`arg1 = ${arg1}, arg2 = ${arg2}, arg3 = ${arg3}`);
}
private update() {
this.wasm.update();
}
}
コンストラクタでwasmファイルの読み込みが終わった後、コールバック処理の開始とupdate関数の定期呼び出し設定を行っています。
5. 結果
開発者ツールからコンソールを見ると、以下のようなログが出力されていることが確認できます。これらはC++で実装したAppWrapper::updateから渡している値です。
app.component.ts:24 arg1 = true, arg2 = 1, arg3 = 1_1
app.component.ts:24 arg1 = false, arg2 = 2, arg3 = 0_2
app.component.ts:24 arg1 = true, arg2 = 3, arg3 = 1_3
app.component.ts:24 arg1 = false, arg2 = 4, arg3 = 0_4
app.component.ts:24 arg1 = true, arg2 = 5, arg3 = 1_5
app.component.ts:24 arg1 = false, arg2 = 6, arg3 = 0_6
app.component.ts:24 arg1 = true, arg2 = 7, arg3 = 1_7
app.component.ts:24 arg1 = false, arg2 = 8, arg3 = 0_8
app.component.ts:24 arg1 = true, arg2 = 9, arg3 = 1_9
app.component.ts:24 arg1 = false, arg2 = 10, arg3 = 0_10
6. まとめ
登録する関数の型情報をどうやって定義するかで一番悩みました。今回作ったのは
mFunctionMap[name].call<val>("apply", val::undefined(), args);
という実装方法でしたが、ここに至るまで色々苦労しました。コールバックを実装する他の手段には、WebAssemblyで提供されるaddFunction関数があります。しかしこれだと、以下のように関数登録時にシグネチャを表す文字列を指定する必要があり、面倒に感じました。
addFunction(function(value) {
console.log("Called from C/C++ with value:", value);
}, 'vi'); // 'vi' = void(int)
もっと柔軟に、そして保守しやすい形を目指した結果、今回のようなCallbackUtilityクラスにIDと引数情報を渡すだけで済むような実装となりました。また、TypeScriptで実装しているため型の静的チェックが働き、ビルド時にエラーが回避できるのも助けになっています。
7. 今後
FunctionId.hやWASM.d.tsは手動で作成しているとミスを招きますので、今度はこれらのソースコードを自動生成する仕組みを作ってみたいと思います。