kickflowプロダクト開発本部の小本です。
今回はRubyからJavaScriptコードを実行する方法を調べてみました。
なぜRubyからJavaScriptコードを実行したいのか?
kickflow(キックフロー)は、運用・メンテナンスの課題を解決する「圧倒的に使いやすい」クラウドワークフローです。
kickflowでは使いやすさの一環として、入力フォームに「自動計算」という機能を備えています。
自動計算にはExcelのような計算式を書くことができ、「ユーザーが入力した『単価』『個数』から『合計金額』を計算」といったことができます。
自動計算ではif
やand
といった論理計算や、日付計算、JSONPathなども使用でき、やや複雑な処理ができます。
計算式の例
# 数値型のフィールド $uprice (単価)と $quantity (個数)から合計金額を計算する $uprice * $quantity # テキスト型の $text フィールドが空ではない場合に "Not empty" を、空の場合は "Empty" を返す if(not(isblank($text)), "Not empty", "Empty") # 日付型の$startフィールドと$endフィールドから日数を計算する datedif($start, $end, "D") # 申請チームのフルパスを返す json_lookup($authorTeam, "$.fullName")
これらはブラウザ上で JavaScript で実装しているので、サーバーと通信が不要で、ユーザーの入力が直ぐに反映されます。
一方、kickflowでは外部アプリケーションとの連携用にREST APIを提供しております。こちらでも同様の計算機能を使えるのですが、REST APIは Rubyで実装しているため、自動計算をRubyで再実装しています。
ご想像の通り、ときどきブラウザとREST APIで差異が発生して困っています。特に数値演算周りで起きがち。
そこで、REST API側(Ruby)でもフロントエンドと同じJavaScriptコードを使ってバリデーションを実行できれば、結果に差異が生じることもないと考えました。
なお、「Wasmを使ってフロントエンドでRubyコードを動かす」ことも考えられますが、後述するように現実的ではないと思います。
execjs
RubyからJavaScriptを実行するためのgemです。
かつてはRuby on Railsを標準構成でインストールするとexecjsもインストールされていました。当時のRailsではCoffeeScriptを推奨しており、CoffeeScriptコードをJavaScriptコードにコンパイルするにはJavaScriptの実行環境が必要だったのです。
execjsは自分自身にはJavaScriptインタープリタを含んでおらず、実行中のマシンからインタープリタを探してきます。
- Windowsなら→ WSH(JScript)
- macOSなら→Apple JavaScriptCore
といった形です。また、Node.jsのようなインタープリタや、mini_racerなどのgemがインストールされていればそれを使うこともできます。
実行例(execjsのREADMEより)
require "execjs" require "net/http" source = Net::HTTP.get(URI("https://coffeescript.org/browser-compiler-legacy/coffeescript.js")) context = ExecJS.compile(source) context.call("CoffeeScript.compile", "square = (x) -> x * x", bare: true) # => "var square;\nsquare = function(x) {\n return x * x;\n};"
execjsのメリットは柔軟性です。
- JavaScriptインタープリタを追加でインストールしなくもよい
- 共通のインターフェースにより、JavaScriptインタープリタの違いを意識せずにすむ
RailsではCoffeeScriptがコンパイルできさえすればよかったので、自動でインタープリタを選んでくれるexecjsは便利でした。
一方で、個別のアプリケーションでRubyからJavaScriptを実行する目的には execjs は向いていません。 どのインタープリタを使うかは決まっている場合にはexecjsは余分なオーバーヘッドになるためです。
外部コマンドとしてNode.js(JavaScript処理系)を実行する
マシンにNode.jsをインストールし、RubyからOpen3を使って実行する方法です。
Rubyから外部コマンドを実行する方法は、Kernel.#systemやバッククォートなど複数ありますが、ありますが、標準ライブラリではOpen3が最もリッチです。
JSのコードとは標準入力・標準出力経由でコードをやり取りします。
#!/usr/bin/ruby require 'open3' require 'tempfile' path_to_node = "/usr/bin/node" # node コマンドのパス node_dir = "/usr/src/app" # package.json や node_modules/ があるディレクトリ # 実行したいJavaScriptコード script = <<~JS const { transform } = require('@moneyforward/stream-util'); (async () => { let count = 0 for await (const line of process.stdin.pipe(new transform.Lines())) { console.log(`hello ${line.trim()}!`) // 標準出力に出力する count += 1 } console.error(`count=${count}`) // 標準エラー出力に出力する })() JS # JavaScriptコードに渡す標準入力データ stdin_data = <<~DATA 太郎 次郎 三郎 DATA # スクリプトを一時ファイルに書き込み実行する # # nodeコマンドは、スクリプトが置かれているディレクトリまたは親ディレクトリから node_modules/ を探すため、 # スクリプトは node_modules/ と同じディレクトリに置く必要がある。 # Tempfile.createでは、第2引数で一時ファイルを作成するディレクトリを指定できる。 Tempfile.create(["script", ".js"], node_dir) do |fp| File.write(fp.path, script) # スクリプトを一時ファイルに書き込む env = {} # 子プロセスに環境変数を与えたければ、このHashに追加する opts = { stdin_data: stdin_data, # 標準入力に渡すデータ unsetenv_others: true, # envで与えた以外の環境変数を子プロセスに引き継がない chdir: node_dir, # 子プロセスのカレントディレクトリ # 他、必要に応じてオプションを指定する # https://docs.ruby-lang.org/ja/latest/method/Kernel/m/spawn.html } # nodeスクリプトを実行する。 # 標準出力、標準エラー出力、終了ステータスを取得する。 stdout, stderr, status = Open3.capture3(env, path_to_node, fp.path, **opts) # 標準出力の内容 puts "STDOUT" puts stdout puts # 標準エラー出力の内容 puts "STDERR" puts stderr puts # 終了ステータス。Process::Status オブジェクト。 # https://docs.ruby-lang.org/ja/latest/class/Process=3a=3aStatus.html puts "STATUS" if status.success? puts "success" else puts "failure" end end
mini_racer
RubyからV8 JavaScriptエンジンを使うためのgemです。
かつてはtherubyracerというgemが最大手でしたがメンテされなくなったこと・多機能で使いにくかったことから、mini_racerが開発されました。
mini_racerでは「Rubyの値をJavaScriptに渡す」「JavaScriptの式を評価する」といった機能が提供されます。
Node.jsを使う例に比べ、コマンドラインや一時ファイルを扱う必要が無い分、高速に実行できることが期待できます。 一方で、mini_racerはあくまでV8のラッパーなので、npmでインストールしたライブラリを使うといったNode.js特有の機能は使えません。
インストール
bundle add mini_racer
実行例
require 'mini_racer' # JavaScriptのコードを実行するためのコンテキストを作成 context = MiniRacer::Context.new # Rubyの配列や文字列をJavaScriptに渡す # 関数名とlambdaを渡す context.attach('data', -> { ["太郎", "次郎", "三郎"] }) # JavaScriptのコードを実行 output = context.eval(%q{data().map((name) => `hello ${name}`)}) p output # => ["hello 太郎", "hello 次郎", "hello 三郎"]
なお、mini_racerにはプロセスのフォークにまつわる問題があります。 unicorn や puma のクラスターモードを使っている場合は、所定の設定をする必要があります。
WebAssembly
「異なる環境で同じコードを動かす」というテーマではWebAssembly(Wasm)が近年発達してきています。
RubyとJavaScriptについては、処理系とコードをWasmにコンパイルしブラウザ上で実行できるツールがあります。
- ruby.wasm: RubyコードをWasm処理系で実行できる
- javy: JavaScriptコードをWasm処理系で実行できる
しかし、これらはいずれも「Ruby/JavaScriptコードをWasmコードにコンパイルする」のではなく、「C言語で書かれたRuby/JavaScriptの処理系(インタープリター)をWasmコードにコンパイルし、それを使ってでRuby/JavaScriptコードを実行する」というツールです。そのため、生成するWasmコードは処理系を含んだ巨大なものになり、入力のバリデーション程度に使うには適さないように思われます。
また、RustやC/C++ならシンプルなWasmコードを生成できますが、kickflowの既存のコードはRubyとJavaScriptで書かれており、それをRustやC++で書き直すのはコストが高いと思われます。
Opal(トランスパイラ)
コードをRuby→JavaScriptと変換するトランスパイラです。
こちらは、変換後のJavaScriptコードがRubyと全く同じ動作をするわけではなく、例えば数値にはJavaScriptのNumber型が使われるため、Rubyと計算結果が異なることがあります。そのため、今回の目的には不適当です。
他にもRubyとJavaScriptのトランスパイラはいくつかあるようですが、広く使われているものは無さそうです。
その他の選択肢
外部コマンドとしてJavaScript処理系を実行する方法では、Node.js以外のJavaScript処理系(有名どころではDenoやBun)も同じ方法で実行できます。
mini_racerはV8のバインディングですが、他のJavaScript処理系のバインディングもあります。例えば、軽量な処理系として有名なQuickJSのバインディングquickjs.rbがあります。
We are hiring!
kickflow(キックフロー)は、運用・メンテナンスの課題を解決する「圧倒的に使いやすい」クラウドワークフローです。
サービスを開発・運用する仲間を募集しています。株式会社kickflowはソフトウェアエンジニアリングの力で社会の課題をどんどん解決していく会社です。こうした仕事に楽しさとやりがいを感じるという方は、カジュアル面談、ご応募お待ちしています!