Kumi:RubyとJavaScriptの両方で動く計算ロジック
私が手がけるプロジェクトには、ウェブショップや請求システムがたくさんあります。そこで毎回出てくるのが同じ問題です。料金の計算ロジックはサーバー側にありますが、お客さんは数量を変えながらブラウザで合計金額を見たいのです。そのためルールをRubyとJavaScriptで二度書くことになり、いずれ二つの計算結果が食い違います。
André MutaさんのKumiは、この問題に正面から取り組むRubyのgemです。税金のルールや料金、スコアのような計算ロジックのための宣言的なDSLで、ひとつのスキーマから素のRubyコードとJavaScriptコードを生成します。実際に試してみて、とても気に入りました。
Kumiでできること
入力データの形と、計算したい値を書くだけです。ループや計算の順番は自分で書きません。コンパイラが依存関係を調べて型をチェックし、それぞれの言語向けに、依存ライブラリのないシンプルなコードを生成します。
普通のRubyメソッドと違う点は、主に二つあります。
- データの形に合わせたブロードキャスト。 入力が明細の配列なら、
quantity * unit_priceのような式は自動的に明細ごとに計算されます。fn(:sum, ...)のような関数で、ひとつの値にまとめ直せます。ネストした配列でも同じように動きます。 - スキーマ定義時のチェック。 型のエラー、循環する依存関係、決して真にならない条件は、本番でお客さんがその処理に当たったときではなく、スキーマを読み込んだ時点で報告されます。
インストールはこちらです。
gem install kumi
Ruby 3.1以降が必要で、ライセンスはMITです。現在のバージョンは2026年6月の0.1.0です。READMEではプロジェクトは実験段階とされていて、公開APIは今後変わる可能性があります。
例:スイスの請求書
試しに、小さな請求書を書いてみました。10個以上の明細には10%のまとめ買い割引をつけ、スイスの付加価値税8.1%を加え、合計はスイスの現金払いで一般的な5ラッペン単位に丸めます。
require "kumi"
module Invoice
extend Kumi::Schema
schema do
input do
array :items do
hash :item do
integer :quantity
decimal :unit_price
end
end
end
trait :bulk, input.items.item.quantity >= 10
let :gross, input.items.item.quantity * input.items.item.unit_price
value :line_totals, select(bulk, gross * 0.9, gross)
value :subtotal, fn(:sum, line_totals)
value :vat, subtotal * 0.081
# no round() yet: scale to 5 Rappen, add 0.5, truncate
value :total, fn(:to_integer, (subtotal + vat) * 20 + 0.5) / 20.0
end
end
result = Invoice.from(items: [
{ quantity: 2, unit_price: 50.0 },
{ quantity: 12, unit_price: 10.0 }
])
result[:line_totals] # => [100.0, 108.0]
result[:subtotal] # => 208.0
result[:vat] # => 16.848
result[:total] # => 224.85
ポイントをいくつか挙げます。
traitは条件を宣言します。input.items.item.quantityを参照しているので、明細ごとに評価されます。letは途中の値、valueは結果から読み出せる出力です。select(条件, 真のとき, 偽のとき)は明細ごとに値を選びます。条件が多い場合はvalue ... do on ... base ... endという書き方もあります。- ループはどこにもありませんが、
line_totalsは明細ごとの配列になり、subtotalはひとつの数値になります。
同じロジックをJavaScriptで
ウェブショップにとって一番おもしろいのはここです。一回の呼び出しでJavaScriptのモジュールが書き出されます。
Invoice.write_source("invoice.mjs", platform: :javascript)
value、let、trait はそれぞれエクスポートされた関数になります。生成された _total をそのまま載せます。
export function _total(input) {
let t88 = input["items"];
let acc95 = 0;
for (let items_i90 = 0; items_i90 < t88.length; items_i90++) {
let items_el89 = t88[items_i90];
let t91 = items_el89["quantity"];
let t92 = 10;
let t41 = t91 >= t92;
let t93 = items_el89["unit_price"];
let t48 = t91 * t93;
let t94 = 0.9;
let t51 = t48 * t94;
let t59 = t41 ? t51 : t48;
acc95 += t59;
}
let t96 = 0.081;
let t87 = acc95 * t96;
let t28 = acc95 + t87;
let t97 = 20;
let t30 = t28 * t97;
let t98 = 0.5;
let t32 = t30 + t98;
let t33 = __core_to_integer(t32);
let t99 = 20.0;
let t35 = t33 / t99;
return t35;
}
生成コードに求めたいのは、まさにこういう形です。ひとつのループの中で割引・合計・付加価値税・丸めまで済ませていて、裏で動くライブラリもインタプリタもありません。読んで理解でき、デバッガで一行ずつ追え、そのまま配布できます。同じデータでNode.jsから実行したところ、Rubyとまったく同じ [100, 108]、208、16.848、224.85 という結果になりました。
ruby.wasmでのKumi
私はRuby Koans in the Browserなどでruby.wasmをよく使っているので、KumiそのものがWebAssemblyにコンパイルされたRubyの中でも動くのか試してみました。数行の準備をすれば、ちゃんと動きます。
# before require "kumi"
ENV["KUMI_PASS_BUDGET_MS"] = "0" # no threads in WebAssembly: no compile time limit
require "tmpdir"
Dir.mkdir("/tmp") unless Dir.exist?("/tmp")
def Dir.tmpdir = "/tmp" # a place for Kumi's code cache
require "kumi"
Kumiはコンパイラの各パスに Timeout を使った時間制限をかけていますが、これはスレッドを起動し、ruby.wasmにはスレッドがありません。環境変数でこの制限をオフにします。また、Kumiは生成したRubyコードをキャッシュ用のディレクトリに書き出してから読み込みますが、ブラウザのインメモリのファイルシステムでは、Rubyが自分で一時ディレクトリを見つけられません。
この準備をすれば、上の請求書の例はruby.wasmでもまったく同じ 224.85 という結果になります。ruby.wasm 2.10.1(Ruby 4.0)をNode.jsで動かし、ruby.wasmがブラウザで使うのと同じインメモリのファイルシステムで試しました。Kumiは純粋なRubyで書かれていて、依存しているzeitwerkとmutex_mもそのまま読み込めます。
つまり、ルールをブラウザに持っていく方法は二つあります。いちばん軽いのはJavaScriptへのエクスポートです。フロントエンドですでにRubyをWebAssemblyで動かしているなら、その場でKumiのスキーマを定義して実行することもできます。
ミスを早めに見つける
Kumiはスキーマを定義した時点で条件を解析します。年齢が0から150の範囲だと宣言したうえで、決して真にならない条件を書くと、スキーマの読み込みが失敗します。
schema do
input { integer :age, domain: 0..150 }
trait :impossible_age, input.age == 200
end
# Kumi::Core::Errors::SemanticError: conjunction `impossible_age` is impossible
業務ルールでこういう条件があるのは、たいてい打ち間違いか要件の誤解です。何か月も後になって割引が一度も適用されていなかったと気づくのではなく、Kumiはコードを読み込んだ瞬間に教えてくれます。これだけでも試す価値があります。
ルールを組み合わせる
import :tax, from: TaxPolicy のように、スキーマから別のスキーマを取り込めます。ひとつの金額しか知らないルールを注文の各明細に適用でき、コンパイラはそれを生成コードに直接埋め込みます。詳しくはスキーマのインポートに関するドキュメント(英語)をご覧ください。
知っておくと便利なこと
- 若く、成長の速いプロジェクトです。 現在のバージョンは0.1.0で、READMEではAPIが実験段階とされているので、Gemfileでバージョンを固定しておきましょう。変更履歴を見ると着実に進化していて、最近のリリースではループ融合を備えた新しいコンパイラパイプラインや、わかりやすいエラーメッセージを出す新しいパーサーが入りました。
- 丸め。
round関数はまだありませんが、上のto_integerを使う方法で正の金額なら問題なく丸められ、RubyでもJavaScriptでも同じ結果になります。 - 金額。 結果は浮動小数点数なので、価格は例のように最後に丸めています。
ブラウザで試す
Kumiの感じをつかむには、インタラクティブなプレイグラウンドが一番の近道です。言語の入門のほか、アメリカの税金計算や1万人分の給与計算のような大きめの例もあります。
Kumiは、同じ計算をサーバーとブラウザの両方で動かしたい場面にぴったりです。料金計算、見積もりの設定画面、送料の計算など。ひとつのスキーマ、二つの言語、同じ結果。自分のプロジェクトで使うのが楽しみですし、これからの発展にも期待しています。