この記事はC++ Advent Calender2024 15日目の記事です。
はじめに
最近流行りのWeb系+Rust向けGUIフレームワーク Tauri にC++を組み込んで
Web系をフロント、C++をバックエンドにして動くリッチなGUIを作ってやろうぜ!という趣旨の記事です。
GTKやQt、MFCは素晴らしいフレームワークですが、昨今のWebチックなモダンGUIを作ろうとすると、どうしても一手間かかってしまう事が多いと思います。
そんな時のいち代替手段として、Tauri + C++という選択手段ができればいいな…と思い記載しました。
なお、今回TauriにC++を組み込むにあたっては、cxxクレートを利用します。
本記事の想定知識
C++に加え、以下の簡単な知識を前提に記載しています。
- Rustの簡単な知識(ビルド構成など)
- Tauriの概要知識
cxxクレートについて
Tauriは(大変ありがたいことに)巷へ素晴らしい解説記事がたくさん出回っているため説明を省略するとして
cxxクレートの日本語解説記事は少ないようにも思えたので、簡単に解説します。
概要
Rustで開発されているクレート(ライブラリ)の一つです。
RustとC++をシームレスに繋ぐことができます。
例えば以下のようなC++コードがあったとします。
#include <iostream>
void print(const char *str) {
std::cout << str << std::endl;
}
cxxクレートを用いると、ヘッダーの指定と関数宣言だけ書いてやれば
RustからC++を簡単に呼び出すことができます。
use std::ffi::c_char;
#[cxx::bridge]
mod ffi {
unsafe extern "C++" {
include!("Print.hpp");
unsafe fn print(str: *const c_char);
}
}
fn main() {
let hello = "Hello C++";
let c_str = CString::new(hello);
unsafe {
ffi::print(c_str.as_ptr());
}
}
上記は簡素な例ですが
他にも幾つかの標準C++、Rustライブラリを相互に渡しあったり、C++名前空間内に存在する関数をRustから直接呼び出すこともできます。
(より詳しくは、公式readmeやドキュメントに書かれています。)
https://crates.io/crates/cxx
https://cxx.rs/bindings.html
C++ビルドツールとの組み合わせ
ところで、公式readmeに書かれている例ですと
C++ファイルは、Rustビルドの中で一緒にコンパイルされます。
// build.rs
fn main() {
cxx_build::bridge("src/main.rs")
.file("src/demo.cc") // ここで組み込むC++ソースを指定
.std("c++11")
.compile("cxxbridge-demo");
println!("cargo:rerun-if-changed=src/main.rs");
println!("cargo:rerun-if-changed=src/demo.cc");
println!("cargo:rerun-if-changed=include/demo.h");
}
簡素なコードであればこれで問題ないのですが
C++をがっつり書くのならば、インテリセンスやclang-format等の都合で
自前のビルドツールを使いたいことの方が多いかと思います。(autoconfとかcmakeとかmsbuildとか…)
そこで、Rust側でC++ファイルをコンパイルするのではなく
C++側は独立してビルドしつつ、Rust側はライブラリをリンクするだけの構成に変更することでビルド環境を分離します。
build.rs(Rustのビルド用スクリプト)を以下のように変更ください。
use std::env;
fn main() {
// C++側のインクルードディレクトリパスを。
// (今回は、Rustビルドディレクトリ以下「src-cpp/include」にあるものと仮定する)
let project_root = env::var("CARGO_MANIFEST_DIR").unwrap();
let cxx_include_dir = format!("{}/src-cpp/include", project_root);
cxx_build::bridge("src/lib.rs") // returns a cc::Build
.std("c++11")
.include(cxx_include_dir)
.compile("cxxbridge-demo");
tauri_build::build();
// リンク対象のライブラリ名と、対象フォルダパスを指定する。
// (今回は「src-cpp/build」に「tauri-cpp」ライブラリがあると仮定)
println!("cargo:rustc-link-lib=tauri-cpp");
println!("cargo:rustc-link-search=./src-cpp/build");
}
上記としておけば、Rustはcxx_include_dir
よりビルドに必要なC/C++ヘッダーファイルを参照しつつ
最後にC++側ライブラリをリンクしてくれるようになります。
上記は簡素な例ですが、公式ドキュメントにはより洗礼された例が多く記載されています。
https://cxx.rs/build/cmake.html
特にRust-C++を双方向で呼び出しあう場合、本稿の内容では不足しそうなため
もし実用される際は一読されたほうが良いかもしれません。
TauriにC++を組み込む
いよいよ本題に入っていきましょう。
前記したcxxクレートを用いて、Tauri(Rust)側にC++を組み込んで動かしてみます。
今回はあくまでサンプルとして、簡素なシンプルバイナリファイルビューアを実装してみます。
(以下は完成図)
ファイルをドラッグ&ドロップすることで、中身をバイナリエディタ表示できるツールです。
次項より手順を記載していきます。
1.Tauriプロジェクトの準備
公式手順を元に、Tauriプロジェクトを立ち上げます。
https://v2.tauri.app/start/create-project/
ここでは筆者の趣味でpnpm + React + TSを選択していますが、好みに合わせて選択ください。
> pnpm create tauri-app
✔ Project name · tauri-cpp
✔ Identifier · com.tauri-cpp.app
✔ Choose which language to use for your frontend · TypeScript / JavaScript - (pnpm, yarn, npm, deno, bun)
✔ Choose your package manager · pnpm
✔ Choose your UI template · React - (https://react.dev/)
✔ Choose your UI flavor · TypeScript
2.C++実装
続いてC++側を実装していきます。
前述したcxxクレートを用いて、フロントエンド層のデータをC++まで送ります。
最終的にフロント-C++間で送受信できればどのような形式でも良いのですが、本サンプルでは以下のように実装してみました。
TS側は関数名と引数を所定のフォーマットで送り、C++側は内容に応じた関数を呼び出すイメージです。
Rustはデータの中継やハンドリングのみを行います。
C++側の受け取り口は以下のような感じにしておきます。
Responce InvokeCpp(const unsigned char* data, std::size_t len) {
std::string_view funcname(reinterpret_cast<const char*>(data), len);
std::span<const unsigned char> span_data(data + funcname.length() + 1,
len - funcname.length() - 1);
try {
// 関数名に対応する関数の呼び出し
if (funcname == "Read") {
return Invoke(std::function(Read), span_data);
}
if (funcname == "FileSize") {
return Invoke(std::function(FileSize), span_data);
}
std::cerr << "function not found: " << funcname << std::endl;
} catch (BadConvertionArguments&) {
std::cout << "convert argument error!!" << std::endl;
}
return {nullptr, 0};
}
Invokeは以下のような感じです。
データの中身をstd::stringか整数型に変換しつつ、最終的に目的の関数を呼び出します。
引数の変換に失敗したら(かなり雑ですが…)BadConvertionArguments例外を出すようにします。
template <std::integral T>
static T ReadLittleEndianValue(std::span<const unsigned char> data,
std::size_t length) {
const std::size_t n = std::min(length, sizeof(T));
T result = 0;
for (std::size_t i = 0; i < n; ++i) {
result |= static_cast<T>(data[i]) << i * CHAR_BIT;
}
return result;
}
// ひとまずstd::string、整数型のみサポート
template <std::same_as<std::string> T>
std::string Convert(std::span<const unsigned char>& data) {
auto data_len = ReadLittleEndianValue<uint32_t>(data, data.size());
data = data.subspan(4);
std::string result{reinterpret_cast<const char*>(data.data()), data_len};
data = data.subspan(data_len);
return result;
}
template <std::integral T>
T Convert(std::span<const unsigned char>& data) {
const auto data_len = ReadLittleEndianValue<uint32_t>(data, data.size());
if (data.size() < data_len) {
throw BadConvertionArguments{};
}
data = data.subspan(4);
const auto result = ReadLittleEndianValue<std::uint64_t>(data, data_len);
data = data.subspan(data_len);
return result;
}
template <typename... Args>
static Responce Invoke(Responce (*f)(Args...),
std::span<const unsigned char> data) {
std::tuple args = {Convert<Args>(data)...};
return std::apply(f, args);
}
cvもreferenceも考慮していない雑なソースですが、とりあえずサンプルとしてこんな感じにしてしまってます。
あとは必要なファイル操作系関数を用意してやれば、ひとまずC++実装は完了です。
2.Rust側実装
次にRust側の実装に入ります。
まずはcargo.tomlにcxxクレートを追加します。
[dependencies]
...
cxx = "1.0" ←これと
[build-dependencies]
...
cxx-build = "1.0" ←これを追加
これが終わったら、cxxクレートでC++との橋渡しハンドラを実装し
Tauri側のハンドラとして登録します。
今回は横流しするだけなので、非常にシンプルです。
#[cxx::bridge]
mod ffi {
struct Responce {
result: *const u8,
size: usize,
}
unsafe extern "C++" {
type Responce;
include!("src/TauriCpp.hpp");
unsafe fn InvokeCpp(data: *const u8, size: usize) -> Responce;
unsafe fn FreeResponcePtr(data: *const u8);
}
}
#[tauri::command]
fn invoke_cpp(data: Vec<u8>) -> Option<Vec<u8>> {
unsafe {
let res = ffi::InvokeCpp(data.as_ptr(), data.len());
if res.result == std::ptr::null() {
return None;
}
let result = std::slice::from_raw_parts(res.result, res.size).to_vec();
// const *u8(const std::uint8_t *)はC++側で確保しているので、ここで解放する
ffi::FreeResponcePtr(res.result);
Some(result)
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![invoke_cpp]) // ハンドラ追加
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
書き終わったら、build.rsの変更も忘れずに行っておきます。
4.Typescript側実装
最後にTS側を実装します。コード全部載せてると長いので省略しますが、大体以下のような構成で作成しました。
全文は以下にあります。
筆者はweb趣味レベルの都合、多少の雑多さは多めに見ていただけると助かります(予防線)
https://github.com/kizul322/tauri-cpp/tree/main/src
ビルド
すべての実装が終わったら準備完了です。
以下の手順でビルドしていきます。
なお、今回C++ビルドはcmake+Ninjaを想定しています。
# まずはC++ビルド
cd ./src-cpp && cmake -GNinja -B./build && cd ./build && cmake --build
# 終わったらtauriビルドを行う
pnpm run tauri dev
うまく動けば成功です。
終わりに
本記事のソースはすべて以下に配置しています。
(一発ネタの都合、メンテナンスの予定はありません。年越し後にアーカイブ化しようと思っています)
https://github.com/kizul322/tauri-cpp
なんかC++アドベントカレンダーなのに、やや他言語寄りな内容になってしまったかなーとか
肝心のC++周りが雑過ぎるなーとか、皆さん色々ためになる記事書いてくださってるのに自分だけ雑な一発ネタ感あるなぁとか色々思ったりもしたのですが
ロマサガ2リメイクやってたら時間がなくなったのと 私の技術不足もあり中々手間取ってしまったため
とりあえずサンプル程度の一発ネタとして本稿を締めさせていただこうと思います。
また前記のコードはCC0としていますので、もし活用できそうであればお好きなように遊んでいただければ幸いです。
(頑張れば、Tauriを丸ごと静的ライブラリにパッケージしつつ、C++側に置いたmain関数から起動するなんてこともできた気がします。)
では少し早いですが、皆さん良いクリスマスを!