[雑多] TypeScript + React + webpack + WebAssembly(C++)でちょっとしたツールを作った話

先日ちょっとしたツールを作ったので、そのときにハマったポイントをメモしておきます。数年前に比べてこの辺の技術がかなり進んでいるので、ハマリポイントも少なかったような気がします(Emscriptenを除く)。

作ったもの

ソフィーのアトリエ2 調合パターン検索ツールです。アトリエシリーズは美少女がキャッキャする様々なアイテムを調合しながらより強力なアイテムを生成し、それらを用いて戦っていくゲームです。

調合の素材には「特性」と呼ばれる、ランダムに付与される様々な効果があり、調合をすることでそれらをアイテムに引き継いだり、あるいは組み合わせでより強力な特性に進化させて行く必要があります。そのため、必要な特性を持った素材を組み合わせて特定のアイテムを調合するという作業が必要になるのですが、これを考え始めると意外と時間がかかってしまいます。そこでその組み合わせを網羅的に検索できるツールを作りました。

ゲームそのものの詳しい紹介は公式ページをどうぞ

WebAssembly技術選定

まずはWebAssembly側の話。どの言語を使ってプログラムを書くかです。選択肢はたくさんありますが、多分以下の4つくらいが現実的な選択かなと思います。

Rust

一番熱くてナウいやつ。他の選択肢に比べ開発環境が整っており、手軽にコードを書きコンパイルすることができます。一方で言語自体の面倒くささが尋常じゃないので、今回みたいにちょっとしたツールを作るにはオーバーすぎるスペック。また安全なコードが書ける一方で危険なコードは書きにくいので、メモリを生のままポインタでゴリゴリいじっていくようなコードを書くのにはもちろん向いてないです。今回は処理が処理だけにそのへんをかなり攻めているので不採用。

あとすげーイケてないなと思ったのが、生成したバイナリに開発環境のパスが混ざること。panic関連っぽい。一応issueはあるけど直す気はなさそうなのが残念。ビルド用環境作るのとか面倒だよね。

もちろん言語としては素晴らしいし、扱っていて楽しい言語ではあるので、一度触ってみるといいと思います。特にC++使っている人。

ちなみに昔作ったこの言語はRustで書いてます。

AssemblyScript

TypeScriptのサブセット。TypeScriptを速くしたいね、っていう話らしいけど、いうほどTypeScriptを速くしたい需要ってあるのかな。あれは言語として素晴らしいというより、JavaScriptとかいう混沌とした世界に型という救いを(無理やり)ねじ込んだところに価値があると思うんだけどね。個人的には好き好んで使いたい言語ではないのでパス。

Go

Gopher君が実体。実はあんまり使ったことがない言語なので使ってみようかなと思ったけど、今回の用途にはあまり向いていなさそうなのでパス。これも個人的な感想なんだけど、言語として優等生すぎて書いていてもあんまり楽しくないのが残念。もちろんそういう設計だしいいことなんだけど、仕事ならともかく趣味ならわざわざ選ばないかな、という印象。Java使ってるときの感覚に近い。

C/C++(Emscripten)

そう、つまり我々が必要としているのは「制御された混沌」である。与えられた環境下で好き勝手暴れ、考えうるすべての手段を以て敵を薙ぎ払う事のできる言語こそ至高である。そこでC/C++の出番というわけだ。幸い(あるいは不幸にも)C++でのそれなりの開発経験はある。ということで今回はC++を使うことにしました。本当はRustで途中まで書いてたんだけど、先に書いた問題が解決できなくてやめました。あと出力されたバイナリのサイズも大きかった気がします。

ただEmscriptenを使ったWebAssembly開発はビルド環境の構築がけっこう大変なので注意。日本語の資料が少ないので、地道に英語圏の情報を集めましょう。

フロントエンド技術選定

UIの開発に何を使うか決めます。現状ほぼ一択なのが良くないところですね。ちなみに個人的にはmithril.jsが好き、だけど死にかけ。後継のMaquetteもあるけどこっちは完全にやる気ないみたい。悲しいね。

React

言わずと知れた、仮想DOMを使ったUI開発用ライブラリ。ぶっちゃけそんなに優れているとは思いませんが、Reactをベースとしたライブラリが多数あるため使用せざるを得ないのが現状。状態管理がめんどくさいけど今回はあんまりそういうのがないのでよかったかな。

preact

Google謹製のミニマムなReactクローン。この辺のライブラリの中ではシンプルで好きなんだけど、React互換レイヤを導入するとReact並に大きくなるのでメリットが殆どない。あとiOSにある入力系のバグに対応されていないのでスマホ向けは厳しいかな。いいライブラリだけど結局「Reactでいいじゃん」ってなる悲しさ。

Svelte

新進気鋭のUI開発環境。ライブラリではなく、コンパイラを含む総合的な開発環境です。Reactのように大きなランタイムを必要としないので、小さくて軽量な出力が特徴。ただやっぱり使いたいライブラリがReactに多いので出番はまだ少ないかなぁ、という印象。Reactはあまり好きではないので願わくはReactの牙城を崩してほしいところです。

Vanilla JS

素のJavaScriptをゴリゴリ書く。簡単なものを作るならなんだかんだいって一番ラクでうまくできるような気はしますが、今回は動きが結構多いのでパス。

React + TypeScript

後述しますが、React 17以降はJSXトランスパイラが刷新されたため、TypeScriptの設定が少しだけ面倒になっています。具体的にはtsconfig.jsonjsxの設定です。従来は以下のようにreactを設定していましたが、React 17以降は開発用と本番用で異なる値を設定する必要があります。

// tsconfig.json
{
  // 従来のReact
  "jsx": "react"
  
  // React 17以降の開発用ビルド
  "jsx": "react-jsxdev",
  
  // React 17以降の本番用ビルド
  "jsx": "react-jsx"
}

このため、下記のwebpackと合わせてtsconfig.jsonも環境に合わせて複数用意する必要があります。TypeScriptには設定ファイルを分割する機能があるので、これを使って開発用と本番用で設定ファイルを分けます

// tsconfig.base.json (共通設定)
{
  "compilerOptions": {
   ...
  },
  ...
}
// tsconfig.json (開発用)
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "jsx": "react-jsxdev",
    ...
  },
  ...
}
// tsconfig.prod.json (本番用)
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "jsx": "react-jsx",
    ...
  },
  ...
}

開発用の設定ファイル名をtsconfig.jsonにしているのは、VS Codeが設定ファイルを読みに行くためです。ファイルを分割したらWebpack側でも読み込む設定ファイルを切り替えるようにします(後述)。

鬼門その1: Webpack

Webフロントエンド界のサグラダ・ファミリアであり、九龍城砦であるWebpack。業界にいるものであればその名を聞くだけで恐れ慄くであろう、諸悪の根源。こいつのせいで昨今のフロントエンド開発の一歩目のハードルが劇的に上がっている。

が、使わないわけには行かないのも事実。ちなみに今回はWebpack 5系を使った場合の話なので、古いバージョンや新しいバージョンではもれなく動かないと思います。つかあまりにも互換性崩しすぎでしょ。

Development/Productionの切り替え

意外と悩ましいポイントだと思います。公式にはファイルを開発用と本番用に分け、共通部分を切り出したあとwebpack-mergeを使って開発用/本番用の設定をマージするという方法が推奨されていますが、柔軟性の欠片もない上めんどくさいし可読性が低いので個人的には好きではないです。

あまりいい方法ではないと思いますが、普通に関数としてエクスポートして各々の設定ファイルから呼び出すような形でやっています。

// webpack.common.js
module.exports.config = prod => ({
  mode: prod ? 'production' : 'development',
  devtool: prod ? undefined : 'source-map',
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        loader: 'ts-loader',
        exclude: /node_modules/,
        options: {
          // TypeScript設定ファイルの切り替え
          configFile: prod ? 'tsconfig.prod.json' : 'tsconfig.json'
        }
      },
      ...
    ]
  },
  ...
});
// webpack.dev.js
const { config } = require('./webpack.common');
module.exports = config(false);
// webpack.prod.js
const { config } = require('./webpack.common');
module.exports = config(true);

こんな感じで設定ファイルを書いたあと、

webpack --config webpack.prod.js

これで本番用のビルドができるという寸法です。

webpack-mergeは入れ子になった設定への対処がかなり面倒なので、この方法で乗り切るのが手軽だと思います。というか設定ファイル書くのに苦労するとか本質的じゃないよね。手を抜ける場所はちゃんと抜こう。

Web Workerへの対応

webpackでWebWorker向けのjsをビルドすると動かきません。これはwebpackがフロントエンド向けのビルドツールだからです。そのため、UI側とは別の設定を用意する必要があります。具体的にはtargetwebではなくwebworkerを指定します。

webpackにはエントリーポイントを複数設定する機能があります。entryにファイル名ではなくオブジェクトを書くあれですね。

// webpack.common.js
{
  // ファイルが一つだけのとき
  entry: './src/main.ts',
    
  // ファイルが複数のとき
  entry: {
    main: './src/main.ts',
    worker: './src/worker.ts'
  }
}

しかし、この方法では共通の設定が適用されてしまうため、UIとワーカーで出力設定を切り替えることができませ。したがって、UI用とワーカー用の2つの設定を別々に作ります。

// webpack.common.js

// UI用
const main = prod => ({
  entry: './src/main.ts',
  target: 'web',
  ...
});

// ワーカー用
const worker = prod => ({
  entry: './src/worker.ts',
  target: 'webworker',
  ...
});

// 両方をビルドする
module.exports.config = prod => ([ main(prod), worker(prod) ]);

こうすることで、両方のファイルに別々の設定を適用してビルドすることができます。

ライブラリをCDNから引っ張ってくる

webpackですべてのライブラリをバンドルするのであれば問題になりませんが、それだとバンドルされたファイルのサイズが大きくなってしまいます。Reactくらいであれば大したことはないですが、塵も積もれば山となります。可能ならCDNからライブラリを引っ張ってきましょう。

今回はReactくらいしか使っていないので、Reactでハマったポイントを紹介しておきます。まずはwebpackでCDNを使う方法から。

webpackにはexternalsという設定項目があり、この項目を設定することでグローバル変数として存在するライブラリをいい感じにモジュールにマッピングしてくれます。

// webpack.common.js
{
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM'
  }
}

Reactを使用する場合は↑の設定でOKです。これで従来どおりimport * React from 'react';という記述でビルドできます。あとはHTML側でライブラリを読み込めばOK。

<script defer crossorigin src="https://unpkg.com/react@17.0.2/umd/react.production.min.js"></script>
<script defer crossorigin src="https://unpkg.com/react-dom@17.0.2/umd/react-dom.production.min.js"></script>

ただし、JSXのトランスパイル方法が変更されたため、開発用ビルドの場合React 17以降はこれだけではビルドが通りません。そのため、開発時はローカルのReactを読みに行く必要があります。上記の方法の場合、package.json内にreactreact-domを追加する必要はありませんが、React 17以降で新しく追加されたJSXトランスパイラを利用する場合は、reactのみ追加する必要があるので注意してください。

鬼門その2: Emscripten

分量が多いので、別に記事を書きました。C/C++をEmscriptenでコンパイルするを参照してください。

C/C++

技量が試されるのはここです。今回作ったツールは総当たり的に解を求めるプログラムなので、アルゴリズムは非常にシンプルです。最適化の可能性としては、本質とは異なる部分をどう捌くか、という点が非常に大きかったように思います。具体的にはメモリのアロケーション回数を減らすとか、データの持ち方を工夫して処理数を減らしたりバイナリサイズを減らしたり、といったところです。特に今回作ったのはブラウザ上で動作するツールなので、バイナリに関しては極力小さくしておく必要があります。そういった観点から、(面倒だけど)細かい部分まで最適化しやすいC++はなかなか適切なのではないかと思います。

探索アルゴリズムは単純で、可能性のある選択肢を列挙して、最後に最適なものを選ぶという方法です。しかし、アトリエシリーズの調合パターンは意外と複雑で、普通の総当りでは無限にパターンが生成されてしまいます。したがって、適切なタイミングで枝刈り処理を入れ、なるべく不要な解を計算中に弾くような工夫をしてあります。

また、処理方法上一度確保したメモリは最後まで確保しておくので、メモリプールを大量に作ってアロケーション回数を減らしています。プールのチャンクサイズ次第では余分にメモリを消費することになりますが、昨今の環境であれば問題ないでしょう。最初はアロケーション回数数万、消費メモリは40MBを超えていましたが、アロケーションの最適化とキャッシュ戦略をちゃんと考えることで、複雑な探索パターンでも最終的に数百KB〜数MB程度の消費で済んでいるみたいです。

最終的なメモリデバッグにはValgrindをDebian上で動作させています。なぜDebianかというと、WebAssemblyは基本的に32ビット環境のため、デバッグ時も32ビット環境で動かしたほうが都合がいいからです。デバッグ時というかビルド時かな。要はstd::size_tのような環境に依存する型を利用したときに正しく動作するかどうかのチェックです。特に今回は結構攻めたコードを書いているので、そこらへんの型のサイズが変わったことによってアクセスするアドレスが変わったりすると厄介です。そういった点でも32ビット環境での動作確認が必要でした。

とはいえメモリ管理がシンプルになったのでバグる要素もあまりなく、1週間くらいでサッと書けたような気がします。Emscripten+CMakeでビルド環境を整える部分のほうが遥かに大変でした。

まとめ

ソフィーが一番かわいい。と思っていたけどプラフタもかわいい。

0 件のコメント :

コメントを投稿