CMake の C++ プロジェクトを nodejs, TypeScript の世界に持っていく
ポエム
プライベートの開発環境は Qt with C++ を cmake でプロジェクト管理というスタイルだったのだが、いかんせんウェブエンジニアである本職とあまりに乖離していたので、もう少し本職に寄せておきたかった。最近は WebAssembly なるものもあり聞けば C++ のコードを WebAssembly にコンパイルして JavaScript から扱うことができるという。なので自作の C++ ライブラリを JavaScript で利用できる形にして、さらに nodejs プロジェクトで TypeScript から呼び出せるようにすれば、 GUI は web の世界に持ってこられるだろうと思い、今回は C++ を WebAssembly にして nodejs, TypeScript の世界に持ってくることにした。
やったこと
いままで Windows 環境で Windows なりの環境構築が厳しくなってきたので、 WSL2 をインストールして WSL から Ubuntu 使って開発することにしたので特筆なければ下記でやった内容は WSL, Ubuntu でやったことと思ってください。 10 年前後昔の環境でやってたらさすがになあ。
emscripten
cmake プロジェクトを WebAssembly (以下 wasm)にコンパイルするのに必要。細かいコマンドまで見ると面倒だが試しに使う分にはインストール後 cmake のオプションからパスを指定するくらいなのであまり意識しなくて良かった。
インストールに関しては難しいことはない。公式のコマンドコピペでおわり。
CLion
こちらの方のセットアップをそのまま利用した。 CLion だけはまだ WSL での開発になっていないのでここは Windows で実施した。 emscripten に JavaScript にしたい C/C++ の機能に目印をつける役割のコードは C/C++ で書くので設定が必要。
C++
emscripten の機能である embind を利用して JavaScript にどの機能を使えるようにしていくか書いていく。例えば時系列のデータを格納する Contour
クラスだと下記のような感じ。
// Copyright 2022 Hal@shurabaP. All rights reserved. // Use of this source code is governed by a MIT style // license that can be found in the LICENSE file. #include "data/Contour.hpp" #include <emscripten/bind.h> using namespace emscripten; EMSCRIPTEN_BINDINGS(ContourBindings) { class_<uzume::vocoder::Contour>("Contour") .smart_ptr<std::shared_ptr<uzume::vocoder::Contour>>("shared_ptr<uzume::vocoder::Contour>") .constructor(&std::make_shared<uzume::vocoder::Contour, double, double>) .function("at", &uzume::vocoder::Contour::at) .function("msLength", &uzume::vocoder::Contour::msLength) .property("length", &uzume::vocoder::Contour::length); }
これは割と公式ドキュメント通りなので困らない。インタフェース的にメソッドがいくつか足りない気がするが本筋でないので今回は対処していない。
CMakeLists.txt
cmake_minimum_required(VERSION 3.1) project(uzumejs) include(ExternalProject) file(GLOB_RECURSE VOCODER_SOURCES ./lib/uzume_vocoder/src/*.cpp) file(GLOB_RECURSE VOCODER_HEADERS ./lib/uzume_vocoder/src/*.hpp) include_directories(uzumejs PRIVATE ./lib/uzume_vocoder/src/) add_executable(uzumejs ... # 割愛 ) if (EMSCRIPTEN) set(CMAKE_EXECUTABLE_SUFFIX ".js") set(lflags "--pre-js ${CMAKE_SOURCE_DIR}/src/resources/pre.js --post-js ${CMAKE_SOURCE_DIR}/src/resources/post.js -s WASM=1 -s MODULARIZE -s 'EXPORT_NAME=\"factory\"' --no-entry --bind") set_target_properties(uzumejs PROPERTIES LINK_FLAGS ${lflags}) endif()
ここは結構厄介だった。ポイントを追って書いていく。
- set(CMAKE_EXECUTABLE_SUFFIX ".js")
emscripten を通したコンパイルは実行ファイルを作ってくれるわけだが、ネイティブと違って JavaScript の世界では実行ファイルはこれ、という明確なのは指定しづらい。のでコンパイル結果の拡張子でどのファイルを出力させるのかを emscripten にお伝えする必要がある。
suffix | 出力内容 |
---|---|
html | HTML + JavaScript + wasm |
js | JavaScript + wasm |
さらに行くと wasm だけ出力するオプションもあるのだが今回は割愛する。 .html
が指定されたときには出力された HTML が そのまま C++ の main を実行するコンソールやスクリーンを提供してくれて、 HTML, JavaScript, wasm をホスティングすれば C/C++ のコードがそのままブラウザ上で実行できるよう出力される。私にとっては高機能だがとりあえず C/C++ のコードをどーんと web に持っていきたいならこれで良さそう。 .js
を選ぶと、 HTML を除いたものが作成される。私は nodejs で利用したいので今回は .js
にしている。.js
ファイルは html 上で動作することを前提にしているように見えるので、 nodejs に持っていくには高機能に見えるが面倒ないので一旦このまま使うことにした。(@types/emscripten
を利用すると全機能使えそうだが今回は面倒なので見送り)
- --pre-js ${CMAKE_SOURCE_DIR}/src/resources/pre.js --post-js ${CMAKE_SOURCE_DIR}/src/resources/post.js
前述の .js
ファイルの前後になにか書きたいときにはこんな感じでファイルを直接指定できる。今回は空のファイルを置いた。
- -s MODULARIZE -s 'EXPORT_NAME=\"factory\"'
ここが結構なハマりポインツで、どうも HTML とセットの利用用途がメインなのか .js
ファイルはもともと他のファイルから利用できない形になっている。そのため、このオプションを指定して .js
内部の Module
を作成してくれるファクトリメソッドを外部に公開する必要がある。今回の指定だと export function factory();
が .js
ファイルに埋め込まれる。これに気づくまで時間がかかった。ここで factory
を指定しているのは意味があって tsembind
が生成する型定義ファイルは暗黙にここが factory
指定されている前提に合わせている。
tsembind
npm i -g tsembind
インストールしたら cmake プロジェクトから出てきた .js
, .wasm
ファイルのあるディレクトリで下記のようにすると標準出力に TypeScript の型定義が出力される。
tsembind YOUR_JS_NAME.js
これでよしなな名前のファイルに書き込めば、私のプロジェクトだと uzumejs.d.ts
, uzumejs.js
, uzumejs.d.ts
の TypeScript から利用するのに必要な 3 ファイルが手に入る。
nodejs
上述で作られたファイルなどをあわせて下記のような形のツリー構造にする。
projectRootDir/ + pkg | + uzumejs.d.ts | + uzumejs.js | + uzumejs.wasm + src + index.ts
TypeScript のプロジェクトの構築は下記の感じ。とりあえず動かす用途なので細かい設定はしていない。真面目にパッケージ化するとかやるなら webpack の設定が必要そうだなーと思っているが初心者なので動くのを優先した。
index.ts を下記のようにした。
import uzumejs from "../pkg/uzumejs" uzumejs().then(uzume => { const contour = new uzume.Contour(1000.0, 2.0); console.log(contour.msLength()); })
shuraba_p@MyComputer:~/projects/uzumejs$ npx ts-node src/index.ts
1002
というわけで C++ で書かれた機能が TypeScript のコードから動いた。やったね。
終わりに
nodejs 初心者なので本当に手間取った。あと私の開発環境古すぎ???問題があってだいぶ更新をすることになった。まあ悪いことではないがちょっと開発自体からおいていかれている感があってつらい。とりあえずこれで nodejs から動かしながら C++ のコードを修正したり、 nodejs 上で GUI 作ったりのスタートラインに立てた。なんかそう書くと全然先が長くて厳しいな。とりあえず現状のものは下記のレポジトリに置いた。久々にきついのをやったが子育てと仕事しながらならこんなもんか。