0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WebAssembly Component Modelを色々な言語で実装し、Wasmtime C++ API経由で呼び出してみた

0
Posted at

1. 概要

WebAssemblyについて勉強していて、Component Model向けにいくつかの言語で実装する方法を勉強したので、まとめたいと思います。
GitHubにアップロードしていますので、細かく知りたい方や自身の環境でも動かしてみたい方はご覧ください。

2. WebAssemblyとは? Component Modelとは?

2026年4月に詳しく説明した記事が投稿されていますので、そちらをご覧ください。

どこでも使えるWebassemnly! Pythonでも使えるWITについて

3. 構成

以下の流れで進めます。

  1. ゲスト側で共通のWITを定義
  2. WITに記述された関数を、合計5つの言語で実装
  3. ホスト側(C++)からそれぞれで実装された関数を呼び出す

なお、ゲスト側の言語には以下を用います。

  • C++
  • C#
  • Python
  • Rust
  • TypeScript

4. WITを定義

共通で使用するWITを定義します。今回記述するのは、32bit signedの整数を受け取り文字列を返す関数です。

utility.wit
package app:utility;

interface info {
    get-message: func(number: s32) -> string;
}

world utility {
    export info;
}

5. ゲスト側を実装

Componentをビルドするためには、ざっくり分けて2段階の処理が必要になります。

  1. WITファイルからバインディングコードを生成
  2. WASMファイルを生成

ツールが充実している言語だと、コマンド1つで2つまとめて実行してしまうものもあります。

C++

まずはWITファイルからバインディングコードを生成します。生成にはwit-bindgenを使用します。

wit-bindgen.exe cpp utility.wit --out-dir <ソースコード出力先>

次に、CMakeを使ってビルド環境を構築します。

CMakePresets.json
{
  "version": 3,
  "configurePresets": [
    {
      "name": "wasi",
      "generator": "Ninja",
      "displayName": "WASI SDK (wasm32-wasip2)",
      "binaryDir": "${sourceDir}/build",
      "cacheVariables": {
        "CMAKE_CXX_STANDARD": "20",
        "CMAKE_CXX_STANDARD_REQUIRED": "ON",
        "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../../../tools/wasi-sdk/share/cmake/wasi-sdk-p2.cmake",
        "CMAKE_MAKE_PROGRAM": "${sourceDir}/../../../tools/ninja/ninja.exe",
        "CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
        "CMAKE_BUILD_TYPE": "Debug"
      }
    }
  ],
  "buildPresets": [
    {
      "name": "wasi",
      "configurePreset": "wasi"
    }
  ]
}

WASI SDKはこちらからダウンロードします。Component Model向けにビルドするため、ここではPreview 2に対応したwasi-sdk-p2.cmakeを使用します。Preview 3もComponent Modelに対応しているはずですが、慣れてないのでPreview 2の方で。。。

CMakeLists.txt
cmake_minimum_required(VERSION 3.31)
project(${TARGET_NAME} CXX)

set(CMAKE_SYSROOT "${WASI_SDK_PREFIX}/share/wasi-sysroot")

file(GLOB SRC_FILES
	"${CMAKE_SOURCE_DIR}/*.cpp"
	"${CMAKE_SOURCE_DIR}/wit/*.cpp"
)
add_executable(${TARGET_NAME} ${SRC_FILES})

target_compile_options(${TARGET_NAME} PRIVATE
    -O3
    -g
)

target_link_options(${TARGET_NAME} PRIVATE
    -mexec-model=reactor
)

file(GLOB WIT_OBJECTS
    "${CMAKE_SOURCE_DIR}/wit/*.o"
)
target_link_libraries(${TARGET_NAME} PRIVATE
    ${WIT_OBJECTS}
)

set_target_properties(${TARGET_NAME} PROPERTIES SUFFIX ".wasm")

install(FILES
	"${CMAKE_BINARY_DIR}/${TARGET_NAME}.wasm"
	DESTINATION "${HOST_OUTPUT_PATH}"
)

HOST_OUTPUT_PATHとTARGET_NAMEはbatファイルからCMakeを実行するときに変数として渡しています。WASI_SDK_PREFIXはWASI SDKのCMakeツールチェーンによって設定されます。

Component.cpp
#include "wit/utility_cpp.h"

namespace exports::app::utility::info {

wit::string GetMessage(int32_t number) {
    std::string message = "This is C++ function, number = "
        + std::to_string(number);
    return wit::string::from_view(message);
}

} // namespace exports::app::utility::info

そして最後はWITで記述したget-messageに対応する関数を実装します。後はビルドを実行するだけでWASMファイルが出来上がります。

C#

まずは.NET 10+ SDKをインストールし、さらにcomponentize-dotnetをインストールします。

dotnet new install BytecodeAlliance.Componentize.DotNet.Templates

C#の場合はバインディングコードの生成とビルドを同時に実行できるようになっているため、一度プロジェクトを作成してしまえば依存するライブラリなどはビルド時に収集されます。そしてそのプロジェクトは以下のように作成します。

Component.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <RootNamespace>component</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
    <UseAppHost>false</UseAppHost>
    <PublishTrimmed>true</PublishTrimmed>
    <InvariantGlobalization>true</InvariantGlobalization>
    <SelfContained>true</SelfContained>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BytecodeAlliance.Componentize.DotNet.Wasm.SDK" Version="0.8.0-preview*" />
    <PackageReference Include="runtime.win-x64.microsoft.dotnet.ilcompiler.llvm" Version="10.0.0-rc.1.26306.1" />
  </ItemGroup>

  <ItemGroup>
    <Wit Update="wit/utility.wit" World="utility" />
  </ItemGroup>
  
  <Target Name="CopyWasmAfterPublish" AfterTargets="Publish">
    <ItemGroup>
      <WasmFiles Include="$(PublishDir)/*.wasm" />
    </ItemGroup>

    <Message Text="Copying wasm to host..." Importance="high" />

    <Copy
      SourceFiles="@(WasmFiles)"
      DestinationFiles="@(WasmFiles->'$(ProjectDir)../../host/wasm/Component_csharp.wasm')"
      SkipUnchangedFiles="true" />
  </Target>
</Project>
nuget.config
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <!--To inherit the global NuGet package sources remove the <clear/> line below -->
    <clear />
    <add key="dotnet-experimental" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json" />
    <add key="nuget" value="https://api.nuget.org/v3/index.json" />
  </packageSources>
</configuration>

上記の設定ファイルは、以下のコマンドを実行して生成されるファイルの、ファイルパスなどを一部修正することで容易に準備できます。

dotnet new componentize.wasi.lib -o <プロジェクトのパス>

後は関数を実装してdotnet build <プロジェクトのパス>を実行するとWASMファイルが生成されます。

InfoImpl.cs
namespace UtilityWorld.wit.Exports.app.utility;

public class InfoExportsImpl : IInfoExports
{
    public static string GetMessage(int number)
    {
        return $"This is C# function, number = {number}";
    }
}

Python

Pythonはバインディングコード生成とビルドの両方にcomponentize-pyを使用します。

pip install componentize-py

バインディングコードの生成コマンドがこちらです。

componentize-py --wit-path <WITファイルのあるフォルダ> --world utility bindings <ソースファイル出力先>

そして関数を実装し、

Component.py
from wit.wit_world import exports

class Info(exports.Info):
    def get_message(self, number: int) -> str:
        return f'This is Python functon, number = {number}'

以下のコマンドでビルドするとWASMファイルが生成されます。

componentize-py ^
	--wit-path wit\utility.wit ^
	--world utility ^
	componentize <関数を実装したモジュール> ^
	-o <WASMファイル出力先>

Rust

Rustもバインディングコード生成とビルドを同時に実行できるようになっています。まずはcargo-componentをインストールします。

cargo install cargo-component

そしてComponent Model向けのプロジェクトを生成し、

cargo component new --lib <プロジェクト名>

生成されたlib.rsに関数を実装します。

lib.rs
#[allow(warnings)]
mod bindings;

use bindings::exports::app::utility::info::Guest;

struct Component;

impl Guest for Component {
    fn get_message(number: i32) -> String {
        format!("This is Rust function, number = {}", number)
    }
}

bindings::export!(Component with_types_in bindings);

後はビルドを実行するとプロジェクト内のtarget\wasm32-wasip1\debugの中にWASMファイルが生成されます。

cargo component build

TypeScript

TypeScriptはjcoを使用してバインディングコード生成とビルドを同時に実行していて、インストールする際は以下のコマンドを実行します。
ここで注意が必要で、--forceを付けないと、jcoが内部で依存している@oxc-minify/binding-win32-x64-msvcを取得できずエラーが発生してしまいます。

npm install -g @bytecodealliance/jco --force

次は開発環境を作成し、

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "strict": true
  }
}
package.json
{
  "name": "Component",
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "build": "npx tsc --noEmit && jco componentize src/Component.ts --wit wit/utility.wit --out ./../../host/wasm/Component_ts.wasm"
  }
}

関数を実装します。その後はpackage.jsonで定義したビルドコマンドを呼び出すことでWASMファイルが生成されます。

Component.ts
export const info = {
  getMessage(num: number): string {
    return `This is TypeScript function, number = ${num}`;
  }
};

6. ホスト側を実装

5つの言語でWASMファイルが生成できたところで、次はそれらから関数を呼び出すホスト側を実装していきます。
ホスト側はC++を使って実装し、ランタイムにはWasmtimeを使用します。
ファイル構成は以下の通りです。

│  CMakeLists.txt
│  CMakePresets.json
│  
├─build
│  
├─src
│      Loader.cpp
│      Loader.hpp
│      Main.cpp
│      Wrapper.cpp
│      Wrapper.hpp
│      
└─wasm
        Component_cpp.wasm
        Component_csharp.wasm
        Component_python.wasm
        Component_rust.wasm
        Component_ts.wasm
フォルダ 概要
build CMakeによって生成されたビルド成果物置き場
src ソースファイル置き場
wasm ゲスト言語で作ったWASMファイル置き場

まずはCMakeの設定です。Visual Studio 2022を使って実装します。

CMakePresets.json
{
  "version": 3,
  "configurePresets": [
    {
      "name": "host",
      "generator": "Visual Studio 17 2022",
      "displayName": "Host Application",
      "binaryDir": "${sourceDir}/build",
      "cacheVariables": {
        "CMAKE_CXX_STANDARD": "20",
        "CMAKE_CXX_STANDARD_REQUIRED": "ON"
      }
    }
  ],
  "buildPresets": [
    {
      "name": "host",
      "configurePreset": "host"
    }
  ]
}
CMakeLists.txt
cmake_minimum_required(VERSION 3.31)
set(TARGET_NAME HostApp)
project(${TARGET_NAME} CXX)

file(GLOB SRC_FILES
	"${CMAKE_SOURCE_DIR}/src/*.cpp"
	"${CMAKE_SOURCE_DIR}/src/*.hpp"
)
add_executable(${TARGET_NAME} ${SRC_FILES})

set(WASMTIME_PATH "${TOOLS_PATH}/wasmtime")
target_include_directories(${TARGET_NAME} PRIVATE
	"${WASMTIME_PATH}/include"
)
target_link_libraries(${TARGET_NAME} PRIVATE
	"${WASMTIME_PATH}/lib/wasmtime.dll.lib"
)

# wasmファイルの置き場所を参照できるように定義
target_compile_definitions(${TARGET_NAME} PRIVATE
    -DWASM_PATH="${CMAKE_SOURCE_DIR}/wasm"
)

# main関数を持つプログラムをスタートアップに設定
set_property(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT ${TARGET_NAME})

# 動的リンクするライブラリをコピー
add_custom_command(TARGET ${TARGET_NAME} POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy_if_different
    "${WASMTIME_PATH}/lib/wasmtime.dll"
    $<TARGET_FILE_DIR:${TARGET_NAME}>
)

上記のtarget_link_librariesにはwasmtime.dll.libを設定していますが、Wasmtimeの中にlibファイルは2つあります。1つは今使っているもので、動的リンクで利用する際に設定します。そしてもう片方のwasmtime.libは静的リンクで利用する際に設定します。どちらを使うのかは自由ですが、Windows環境で静的リンクにしたい場合はCMakeでLIBWASM_STATICを定義しておく必要があります。

CMakeを使って環境構築できたので、次は各ソースファイルについて説明します。

モジュール 役割
Main エントリーポイント
Loader WASMファイルの読み込み
Wrapper WASMからの関数呼び出し
Loader.cpp
#include <filesystem>
#include <fstream>
#include <iostream>

#include <wasmtime/config.hh>
#include <wasmtime/component/component.hh>
#include <wasmtime/component/linker.hh>

#include "Loader.hpp"

using namespace std;
using namespace wasmtime;
namespace fs = std::filesystem;

// wasmファイルを読み込んで初期化します
map<string, component::Instance> Loader::load() {
	map<string, component::Instance> instanceMap;
	Config config;
	config.wasm_component_model(true);
	this->engine = make_unique<Engine>(std::move(config));
	this->store = make_unique<Store>(*this->engine);
	WasiConfig wasiConfig;
	if (!this->store->context().set_wasi(std::move(wasiConfig))) {
		cout << " Failed to set WASI config" << endl;
		return instanceMap;
	}
	component::Linker linker(*this->engine);
	if (!linker.add_wasip2()) {
		cout << " Failed to add WASI Preview 2" << endl;
		return instanceMap;
	}
	if (!linker.add_wasi_http()) { // TypeScriptで生成したwasmのインスタンス化に必要
		cout << " Failed to add WASI HTTP" << endl;
		return instanceMap;
	}

	// wasmファイルを読み込みます
	for (const auto& entry : fs::directory_iterator(WASM_PATH)) {
		if (!entry.is_regular_file()) {
			continue;
		}
		if (entry.path().extension() != ".wasm") {
			continue;
		}
		// ファイルからバイナリデータを取得
		auto binary = this->loadFile(fs::absolute(entry.path()).string());
		if (!binary.has_value()) {
			cout << "Failed to open " << entry.path().filename() << endl;
			continue;
		}
		// ファイルを読み込みコンパイル
		auto component = component::Component::compile(*this->engine, binary.value());
		if (!component) {
			cout << "Failed to compile " << entry.path().filename()
				<< ": " << component.err().message() << endl;
			continue;
		}
		// インスタンス化
		auto instance = linker.instantiate(*this->store, component.ok());
		if (!instance) {
			cout << "Failed to instantiate " << entry.path().filename()
				<< ": " << instance.err().message() << endl;
			continue;
		}
		instanceMap.try_emplace(
			entry.path().filename().string(), std::move(instance.ok()));
	}
	return instanceMap;
}

// Wasmtime Storeを取得します
wasmtime::Store& Loader::getStore() {
	return *this->store;
}

// ファイルからバイナリデータを取得します
optional<vector<uint8_t>> Loader::loadFile(const string& filePath) {
	std::ifstream ifs(filePath, ios::binary);
	if (!ifs) {
		return nullopt;
	}
	return vector<uint8_t>(
		istreambuf_iterator<char>(ifs), istreambuf_iterator<char>());
}

ファイル参照→バイナリデータ取得→コンパイル→インスタンス化の流れです。

Wrapper.cpp
#include <wasmtime/component/val.hh>

#include "Wrapper.hpp"

using namespace std;
using namespace wasmtime;

// インスタンス化したwasmデータをセットします
void Wrapper::setWasmInstance(map<string, component::Instance>&& inInstances) {
	this->instances = std::move(inInstances);
}

// wasmの関数を呼び出し、メッセージを取得します
vector<string> Wrapper::getMessages(Store& store) {
	vector<string> messages;
	int32_t index = 0;
	for (const auto& [name, instance] : this->instances) {
		auto infoInterfaceIndex = instance.get_export_index(store, nullptr, "app:utility/info");
		if (!infoInterfaceIndex.has_value()) {
			continue;
		}
		auto functioneIndex = instance.get_export_index(
			store, &infoInterfaceIndex.value(), "get-message");
		if (!functioneIndex.has_value()) {
			continue;
		}
		auto func = instance.get_func(store, functioneIndex.value());
		if (!func.has_value()) {
			continue;
		}
		vector<component::Val> params = {
			component::Val(++index)
		};
		vector<component::Val> results = {
			component::Val::string("")
		};
		if (!func.value().call(store, params, results)) {
			continue;
		}
		messages.push_back(name + "\t: " + string(results.at(0).get_string()));
	}
	return messages;
}

WASMファイルのインスタンスから関数を呼び出す処理です。
まずrootからアクセスできるapp:utility/infoのindexを取得してから、さらにその中のget-message関数のindexを取得します。
そしてget_funcで関数を取得出来たら、vector<component::Val>で引数リストと戻り値リストを準備してfunc.value().callを呼び出します。上手く動いていれば、戻り値用に準備したvectorへ値が格納されています。

Main.cpp
#include <iostream>

#include "Loader.hpp"
#include "Wrapper.hpp"

using namespace std;

int main() {
	Loader loader;
	auto instances = loader.load();

	Wrapper wrapper;
	wrapper.setWasmInstance(std::move(instances));
	auto messages = wrapper.getMessages(loader.getStore());

	for (auto& message : messages) {
		cout << message << endl;
	}

	return 0;
}

これらを組み合わせて実行すると、こんなログが出力されます。

Component_cpp.wasm      : This is C++ function, number = 1
Component_csharp.wasm   : This is C# function, number = 2
Component_python.wasm   : This is Python functon, number = 3
Component_rust.wasm     : This is Rust function, number = 4
Component_ts.wasm       : This is TypeScript function, number = 5

各言語で作成した文字列が取得できて、さらに引数の受け渡しができていることが分かります。

7. 注意点

WITファイルでは関数のimportを記述することができ、それを使うとホスト側で実装した関数をゲスト側からコールバックしてもらうことが可能です。しかし残念ながら、Wasmtime C++ APIではコールバック関数の登録を容易にする仕組みが整備されていません。
バイナリデータを直接操作すれば可能かもしれませんが、ABIの仕様を理解するのはイチ利用者にとってハードルが高過ぎます。

8. まとめ

Component ModelのWASMファイルを5つの言語で作成し、それらすべてを共通のランタイムから利用することができました。
このように言語の垣根を越えて一つのプログラムを作るというのは面白いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?