ブラウザでRubyを動かしてみた
WED株式会社でバックエンドだったりフロントエンドだったりのエンジニアをやっている宮崎です。
基本的にはONEという、レシートがお金に変わるサービスに関わる開発全般を行なっています。
何をしたの?
Rubyをブラウザで動かすことは人類共通の夢であることに疑いの余地はないと思います。
それを実現し、ONEの管理画面という実際のプロダクトで使用しました。
なぜそんなことをしたの?
ONEと正規表現
ONEというサービスはバックエンドに Ruby on Rails を採用しています。
レシートをOCRして買い取りできるか判定するという性質上、正規表現によるバリデーションが欠かせません。
加えて、OCRは常に正確というわけではなく、撮影の環境や撮り方・角度・フォントの種類に結果がかなり左右されます。
となると、バリデーションに用いる正規表現は極めて複雑になります。
例えば パイナップルジュース
あるいは パイナップル果汁
という文字列をバリデーションしたいとき、OCRの読み間違えを考慮した正規表現は下記のようになります。
(果汁
という文字列も果シナ
とかOCRが読み間違えそうですが、この場ではしんどいので割愛します)
[パバnハヘ八][゜°゚゛"\"゙,]?[イィ亻彳1了ク𠂊][ナメ][ツッ少斗リ][ブプアフ77][゜°゚゛"\"゙,]?[ル儿几レwノ]レ?([ジシ氵][゜°゚゛"\"゙,]?[ユュマ龴エ工卫1][ー―-‒–—−一ー][スヌ]|果汁)
この複雑極まる正規表現は、案件ごとにエンジニアではないメンバーによって設定されています。
エンジニアではないということは、気軽にRubyのスクリプトを書いて正規表現の挙動をテストすることが難しいということです。
(そもそも正規表現書いてる時点で弊社の非エンジニア強すぎじゃない?とは常々思っていますが、それはまた別のお話)
管理画面での解決
そんな状況が続くと、必然的にテストされていない正規表現が予想外の挙動をします。
買い取りたくないものを買い取ってしまったり、買い取るべきものを弾いてしまったりします。
そこで白羽の矢が立ったというか、私が勝手に立てたというか、こっそり機能追加して事後承諾を得たというかしたのが、ONEの管理画面です。
管理画面の歴史はサービス開始時期に比してかなり浅く、ようやくブラウザのUI上で正規表現が設定できるようになった矢先だったため、
以前からあるこの問題を解決するにはちょうどいいタイミングでした。
実装
まず考えたのがinput要素に入力した文字列を、JavaScriptの正規表現として解釈するという方法でした。
とはいえこの方法には2つ問題点があります。
- JavaScriptの正規表現はどこまでいってもRubyの正規表現ではない
- やっててあんまりおもしろくない
1については前述の通り、 Ruby on Rails を採用している以上、実際のプロダクトで動くのはRubyの正規表現です。大枠は同じだと思いますが、言語ごとの細かな違いにより将来バグを生むかもしれません。
Rubyの正規表現の力を最大限利用した文字列を打ち込んでくる、過激な(褒め言葉)非エンジニアが将来現れないとも限りません。
というのは建前で、実際には2です。せっかくRuby 3.2からWASMに対応してくれているので、環境は整っていました。
今まで業務でもプライベートでも触れてこなかったWASMを触る絶好の機会でもありました。
ということでコードです。
export const useRuby = () => {
const { data: vm } = useSWR(
'ruby',
async () => {
const response = await fetch(
'https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/ruby.wasm',
)
const buffer = await response.arrayBuffer()
const mod = await WebAssembly.compile(buffer)
const { vm, instance } = await DefaultRubyVM(mod)
;(instance.exports.memory as any).grow(9000)
console.log('Ruby VM initialized')
return vm
},
{
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
},
)
return vm
}
SWRと組み合わせてカスタムフックとして実装しました。取得したレスポンスをいじり尽くして返すのはどうなの?という気持ちは私も抱きましたが、これが一番きれいになりました。
SWRのオプションは再fetchを防ぐものにしています。当初素のfetchをuseEffect内で使ったりしていたんですが、(実は上記のコードも2023/11/28時点でマージしていないので、実際に動いているのは当初
の方ではあるんですが)
ストアライブラリなどと組み合わせて制限しようとしても複数回のfetchとRuby VMの初期化が走ってしまい、CPUとメモリにダメージを与えてくれました。
それならいっそSWRを利用してfetchが必ず一回になるよう制限し、初期化もその中で実行してしまえばよいのでは?と思い、この形に落ち着いています。
これがベストだとは思っていませんが思いつかないので、よりよい方法があればXのDM等でぜひ教えていただきたいです。
意外と直接的にRubyをブラウザ上で動かしている例が少なく、我々の用途に合う実装方法を探すまでが一番時間がかかりました。人類共通の夢なのにおかしいですね。
このフックが最終的にreturnするのはRubyのVMです。なので呼び出し側で好きなようにコードを渡して実行できるようになっています。
呼び出し側の一例として下記のコードを掲載します。
Regexp.newの引数は、そのまま入れると正規表現として正しくない値となって落ちてしまう可能性があるので、適宜エスケープなどしながら使うといいです。
const ruby = useRuby()
const result = ruby?.eval(
`
regexp = Regexp.new('${pattern}')
regexp.match?('${inputValue}')
`,
)
const resultValue = JSON.parse(result?.toString() ?? '[]')
result
は TypeScriptの RbValue
という型です。call, toString, toJSというメソッドが定義されていますが、このうちRubyに実行させた結果を取り出すのはtoString, toJSです。
用途に合わせて使ってみてください。(ちなみにtoJSはエラーしか起きたことがないです。調査の時間がほしい)
実際に動いている画面
許可リスト
は正規表現にマッチすること、拒否リスト
はマッチしないことをチェックしています。
Discussion