ruby.wasm で MySQL Parameters を作り直した

プライベートでは基本的に誰の役にも立たないプログラムを作ってるんだけど、たまにうっかり MySQL Parameters みたいな役に立つものを作ってしまう。

MySQL Parameters は5年くらい前に Vue.js の勉強のために作ってみたんだけど、結局そのまま Vue.js は触らず放置状態だった。MySQL の新しいバージョンが出るたびにデータは更新してたけど。

ruby.wasm で Ruby が WebAssembly 上で動くようになり、ブラウザ上で JavaScript の代わりに使えるようになったんで、MySQL Parameters を Ruby で作り直してみた。

ruby.wasm

ruby.wasm のページに載ってるけど、これだけでブラウザ上で Ruby が動く。簡単。

<html>
  <script src="https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@1.0.1/dist/browser.script.iife.js"></script>
  <script type="text/ruby">
    puts "Hello, world!"
  </script>
</html>

HTML内の <script>〜</script> にベタに Ruby を書くのもつらいので、別ファイルにしてそれを読み込むようにした。

<html>
  <head>
    <script src="https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@1.0.1/dist/browser.script.iife.js"></script>
    <script type="text/ruby" src="hoge.rb"></script>
  </head>
  <body>
    ...
    <script type="text/ruby">
      fuga
    </script>
  </body>
</html>

こんな風にしたら hoge.rb を読み込んだ状態で fuga を実行してくれる。

p や puts 等の標準出力への出力はブラウザのコンソールに出力される。デバッグに便利。

JavaScript の機能を使う

require 'js' とすると JavaScript の機能を使うことができるようになる。

JS.eval で JavaScript を実行できる。

require 'js'
JS.eval('alert("hoge")')

JS.global 経由で JavaScript のグローバルなオブジェクトや関数を取得できる。

JS.global.alert('hoge')

JavaScript のオブジェクトや戻り値は Ruby ではすべて JS::Object クラスのインスタンスになってる。

n = JS.eval('return 123')     #=> JS::Object (123)
n.typeof                      #=> "number"
s = JS.eval('return "hoge"')  #=> JS::Object ("hoge")
s.typeof                      #=> "string"

そのままでは Ruby では扱いにくいので必要に応じて to_ito_s 等で変換する。

JS::Object#[] でプロパティを取得&設定できる。プロパティ名は文字列でもシンボルでも可能っぽい。JavaScript みたいに obj.propname では参照できない。Ruby だとこれはメソッド呼び出しになっちゃうので。

JS::Object#call で JavaScript の関数を呼ぶことができる。Ruby オブジェクトに存在しないメソッドを呼んだ場合は JavaScript の同名関数に変換してくれるので便利。

s = JS.eval('return "hoge"')  #=> JS::Object ("hoge")
s[:length]                    #=> JS::Object (4)
s.call(:charAt, 2)            #=> JS::Object ("g")
s.charAt(2)                   #=> JS::Object ("g")

あと JavaScript の null は Ruby は nil じゃなくて JS::Null として見える。同様に undefinedJS::Undefined。これらも JS::Object のインスタンス。

DOM 操作

Ruby にブラウザフロントエンドのフレームワークなんて当然あるわけないので、いにしえのコテコテな DOM 操作。メソッド名が JavaScript 風のキャメルケースなのでちょっと気持ち悪い。

require 'js'
Document = JS.global[:document]
hoge = Document.getElementById('hoge') # id=hoge 要素を取得
fuga = Document.createElement('div')   # div 要素を作成
fuga[:id] = 'fuga'                     # id=fuga を設定
hoge.appendChild(fuga)                 # fuga を hoge の子とする

上に書いたように戻り値は全部 JS::Object なので、たとえばある要素の子要素リストに Ruby からアクセスするには、こんな風にしないといけない。

children = JS.global[:document].getElementById('hoge')[:children]
children[:length].to_i.times do |i|
  p children[i][:tagName]
end

あんまり Ruby ぽくないので、何かしらのラッパークラスとかを用意したほうがいいかもしれない。

イベント処理

要素にイベントを設定する場合はこんな感じ。JavaScript で関数型の引数は Ruby では Proc で渡す。

element.addEventListener('change', ->(event){p event[:target][:value]})

Proc の代わりにブロックで指定することもできる。

element.addEventListener('change') do |event|
  p event[:target][:value]
end

Ruby っぽくて良い。

Promise

Promise も JavaScript オブジェクトなので、Ruby から使うことができる。

JS::Object.undef_method(:then)   # then メソッドを削除
Promise = JS.global[:Promise]
Promise.resolve(123).then do |a|
  pp a  #=> 123
end

そのままだと then で Ruby の Object#then が呼ばれちゃうので削除してる。まあ削除しなくても .call(:then) とすれば呼ぶことはできるんだけども。

fetch で外部の JSON ファイルを読み込んで Ruby の Hash にするにはこんな感じ。

require 'json'
JS.global.fetch('hoge.json').then do |res|
  # ①
  res.json.then do |obj|
    # ②
    pp JSON.parse(JS.global[:JSON].stringify(obj).to_s)
  end
  # ③
end
# ④

Promise は非同期処理なので、④→①→③→② の順に実行される

JS::Object#await を使えば JavaScript の await と同じように Promise 処理を待つことができる。ただし、rubyVM.evalAsync() 内で動かす必要がある。

def hoge
  res = JS.global.fetch('hoge.json').await
  obj = res.json.await
  p JSON.parse(JS.global[:JSON].stringify(obj).to_s)
end
JS.global[:rubyVM].evalAsync('hoge')

でもすぐに SystemStackError: stack level too deep エラーが出てしまう。たとえば pp メソッドを使うだけでもエラーになる。

https://github.com/ruby/ruby.wasm/issues/133 によると、evalAsync は Fiber を使って await を実装してて、ruby.wasm では Fiber のスタックサイズが小さいために発生しやすいらしい。

回避策はあるみたいだけど面倒そうなので MySQL Parameters では await は使わなかった。

おまけ

さすがに Vue.js のときより遅くなったので、処理に時間が掛かる場合にはイルカをくるくる回すようにしてみた。

JavaScript の時はなんとなく面倒であんまりいじる気が起きなかったんだけど、Ruby になったので色々いじってみるかなーと思ってたりする。

この続きはcodocで購入