20年Rubyを触ってきて初めて踏んだattr_readerのケツカンマ問題、あるいはdefの返り値がシンボルであることの問題

今日Rubyを書いていて、なんじゃこれと思った動作があった。

試しに以下のコードを実行してみて欲しい。

class Foo
  attr_reader :hoge, :fuga,

  def initialize(a, b)
    p a
    p b
  end
end

Foo.new

実行してみると分かるが、これには例外が出ない。

initializeで定義した必須引数はどうなったのか?

よくよく見るとattr_readerの引数の末尾に,がある。

つまり、このコードは分かりやすく書くと以下の様になる。

class Foo
  attr_reader(:hoge, :fuga, def initialize(a, b)
      p a
      p b
  end)
end

Foo.new

このコードを更に分かり易く書くとこうなる。

class Foo
  def initialize(a, b)
    p a
    p b
  end

  attr_reader :hoge, :fuga, :initialize
end


Foo.new

initializeが定義され次の瞬間にattr_readerで上書きされてdef initialize; @initialize; end相当の状態になる。

そりゃそうだという感じだし、何もおかしなことはない。ないのだが!これは結構怖い。

という訳で、attr_なんちゃらの引数のケツカンマには気を付けよう。 (というかシンボルを受け取ってメソッドを定義する類のメタプロコード全般)

自分で書く分には意外とこうはならないのだが、copilotみたいな奴の補完でざっくりと書いてしまうと、ケツカンマ以外は問題無い上にシンタックスエラーも出ないので、「はあ?!」という動きになってしまう。というかなった。

バルダーズゲート3が素晴らしかったのでオススメポイントを書く

年末からプレイしていたバルダーズゲート3をようやくクリアした。大体130〜140時間ぐらいかかったと思う。最近のゲーム体験としては、トップクラスに入る神ゲーだった。ここ数年だとOuter Wildsに匹敵するレベル。ゲーム史に残るRPGと言っても過言ではない。

ただこの作品は、とにかく序盤がハードなのと自由度が異常なので、慣れない人にはかなり取っ付き辛いらしい。それを差し置いてもオススメしたいので、どういうところがオススメなのかと何がハードなのかをちょっと書いてみようと思う。

前提として、バルダーズゲートシリーズはTRPGのダンジョンズ&ドラゴンズの世界観に基いたゲームで、ちなみに自分は元々D&Dは存在ぐらいは知っていたしTRPGについてはある程度の知識と経験がある程度である。

結論から書いておくと「自由度」と「分かりやすさ・親切さ」のトレードオフを受け入れられるかどうかが、このゲームの肝だと思う。

TRPG感の再現

流石にコンピューターゲームなので限界はあるのだが、会話や行動の選択に対してとにかく自由度が異常に高いし、その場の会話だけでなくちゃんと続きの展開や有利不利に影響する。そして場合に依っては取得している技能によってダイス判定が入る。得意なことはキャラごとに違うので、仲間と旅をすることにちゃんと意味が感じられる。会話の流れ次第では良い人間にもなれるし、めちゃくちゃ嫌な奴にもなれる。ちゃんと一貫したキャラ設定を想像してプレイしていると没入感が高まってとても良い体験が得られる。この辺りのロールプレイ感はTRPGの良さを上手く活かせてるなあと感じる。

悪事的なことだと、めちゃくちゃ高値を吹っかけてくるキャラの話を一回断わった後で部屋に忍び込んだりスリで入手したアイテムで突破したり、商人にワイロを渡して評価を上げた後にスリで金を取り戻すとか悪辣なこともできる。こういう自由度に対して頭を捻るのが楽しい。ペテンにかけて信用させてから後ろから刺すとか。

一方で、分かり易い解決策は必ずしも楽ではないし、報酬が良いとも限らないので、効率プレイ大好きみたいな人には余り楽しくないかもしれない。

探索とクエスト展開のバリエーション

バルダーズゲート3は昨今のオープンワールドRPGの様な探索とサブクエスト受注みたいな流れはあるのだが、その手のゲームの様にマップ上に依頼者のマーカーが出てたりとかは一切無い。町の人と喋ってたらいきなり問題に巻き込まれたり、頼みごとをされたりするし、その辺に落ちてるメモからクエストが開始することが多々ある。(目的地が明確な時はマーカーが出る)

なので、クエストラインの途中から合流したり、いきなり解決に近いところから始まったりするし、あるクエストが別クエストの目的と最終的に重なるケースもある。なので開始時期がズレるとイベントそのものが消失したり、報酬を貰い損ねたりする。

これも自由度とある意味で不親切な所のトレードオフで、自分としては世界を探索してそこに関わっている感じが強く感じられるので好きだったが、この点に関してはクエスト発生ポイントに関しては攻略情報を確認して、後は流れに任せるみたいなやり方がバランスよく楽しめるのかなーと思う。

めちゃくちゃ長い期間継続するクエストとか、探し物をするクエストは実際何も攻略情報が無いと、いつまでかかっても終わらないみたいな状態になるので、諦めてある程度割り切った方がいい。

とにかく選択を迫られたり取り返しがつかないことが多いので、いやそういうつもりじゃなかったとか、ここでそのダイス運は勘弁してくれ、というのを避けるために手癖でクイックセーブする様にしておくことをオススメする。盗みに入る前にセーブしとくとかちょっとズルいけど自分としては許容範囲。オートセーブポイントはかなり少ないので、セーブを忘れているとやり直したくなっても結構前に戻されたりする。まあ相当後になってからじゃないと選択の結果がどうなるか分からないケースもあるので、そういう時は諦めよう。

(マックス難易度だとシミュレーションゲームの鉄人モードみたいなのがあって、セーブ&ロードが禁止されるという非常にシビアなモードもある)

戦闘のシビアさと戦略性

戦闘は昔のゲームだとタクティクスオウガとかFFタクティクスに近い。ターン制で位置を移動して近接、遠距離、魔法などを選択して戦う。高所の概念や明るさや地形効果などもあるし、その辺に落ちてるオブジェクトも大抵攻撃可能だし、即席攻撃というシステムで拾って投げたりできる。敵の死体をぶん投げることもできるし、高所から突き落とすこともできる。

普通に殴って魔法撃ってもちゃんと楽しいが、濡らして電撃かけるとダメージ2倍になったり、火薬に火炎ぶつけて爆発させたり、飲んだくれてるコボルトの零したアルコールに火を付けるとか、そういう楽しみ方が出来るので、戦略性もかなり高い戦闘が楽しめる。

敵の近くで隠れて、沈黙フィールド貼ってから暗殺したり、バフかけてから奇襲して有利に進めたりできる。逆に敵が待ち伏せしていて知覚判定に成功しないと、見えないとかもある。

一方で、そういうことに関するガイドは余り無いので、何が出来るかは多少攻略情報を参照した方がいいかもしれない。

この作品では、特に序盤の戦闘がきついのが挫折を生みがちなポイントである。ラノベゴブリンスレイヤーではないが序盤のゴブリン集落に何も考えずに突っ込むと、難易度ノーマルでも余裕で全滅するし1戦1戦で全力出さないと普通に死ぬぐらいの戦闘になる。特に数の暴力に負けるので、序盤から上手く状況をコントロールする必要に迫られるのだが、序盤なので何が出来るのか自分が分かってないという辛い状態が続く。

多分、ここで心折れる人が多少居ると思うのだが、ここを突破すると割と楽になるので、諦めるのは勿体ないなと思う。

これに関しては、諦めて難易度を一時的にイージーまで下げるというのもアリだと思う。自分は最初昨今のゲームの難易度だとハードぐらいがちょうどだろと舐めてかかったら、全然無理でノーマルに変更してギリギリみたいな感じだった。まあ、これもやり応えの裏返しみたいなものではある。

中盤移行はやれることが増えて、強力なバフも使える様になるので慣れてくると大分戦闘は楽になる。基本的に守るより攻めた方がいい。先手必勝でとにかく数を減らすか強敵をぶちのめすのが定石だと思う。

後、何回かやらかしてリトライした点として、盗みがバレて戦闘になったり、範囲魔法でNPC巻き添えにするとそのまま死んで取り返しが付かなくなったりするので、その辺りはちゃんと考えて戦う必要がある。

序盤に覚えておいた方がいいTips

戦闘に関わらず、このゲームはRTボタンのメニューからいつでもターン制モードに入れる。隠密行動で仲間を順番に移動させたり罠を解除するまで仲間を待たせたり、敵の視線を回避する時には必須なのだが、自分は最初これを知らなかったので、無駄に罠を踏んだりした。

また、戦闘中に仲間のターンが連続している場合、行動順は自由で行動完了前に切り替えたりできるので、先に移動して位置をズラしてから範囲攻撃とかも出来る。これも最初気付かなくても行動を無駄にしたりしていた。

クラス選択とレベル

キャラメイク時にクラスを選択するのだが、クラスの選択によって基本能力値が決まるシステムになっている。クラスの幅は結構広いのだが、このゲームは魔法がとにかく分かりにくい。使う魔法を習得魔法の中から「準備」して利用可能状態にしておかないと使えないクラスと、常に習得魔法全てが使えるがそもそも習得できる魔法数が少ないクラスとがあり、また魔法使用回数を回復する方法もかなり限られている。なので、使い所はある程度考える必要があるし、クラスによっては習得魔法を厳選しないと非常に辛いことになる。

この辺のシステムは、TRPGのD&Dの方のシステムをある程度踏襲した作りになってるのかなーというイメージ。魔法スロットや事前準備の概念はその辺から来てると思う。この辺りもゴブリンスレイヤーの術士のイメージが近い。(もちろんD&Dの方が元祖だが)

また、このゲームは最大レベルが12なので、1レベル上がるだけでめちゃくちゃ強さが変化する。特にLV5〜6ぐらいになるとかなりスキルが揃うので、大分便利になる。

クラス選択では基本クラスに加えて、レベルアップ時に別のクラスを取得してレベルを上げることができる。12LVの中でやりくりすることになるので、最終的にファイターLV9とローグLV3みたいなことになる。シナジーの大きい組み合わせもあるので、特化したスキルが覚えられなくなるが上手く組み合わせるとめちゃくちゃ強くなる。まあ強さを重視すると、ある程度お決まりのパターンはあると思う。投擲バーバリアン+ローグ(シーフ)とかモンク+ローグ(シーフ)とかめちゃくちゃ強かった。

そして、100ゴールドでリスペックするシステムがあってクラス変更とレベルの上げ直しが可能なので、かなり簡単にスキルの組替えが出来るので、中盤ぐらいからガンガン状況に合わせてクラスを変えたり、各クラスで出来ることを調べて自分なりのビルドを考えられる様になる。流石に、色々なクラスで何が出来る様になるのかを自分で全部調べるのはしんどかったので、クラス情報に関しては中盤辺りからめちゃくちゃ攻略情報を参照した。

後、主要な登場キャラのクラスチェンジも可能だが、主要キャラのシナリオとクラスが関連しているので、主要キャラのクラスをゴリゴリ変えるとちょっと世界観に影響が出るかもしれない。自分は基本クラスは変えない様にプレイしていた。

前述した様にクエスト展開の自由度の高さで、戦闘以外の技能や魔法が有効なケースもかなり多いので、戦闘特化以外のキャラ育成や万能タイプのキャラを作るのにもちゃんと意味があるのはとても良い点だったと思う。特に盗みと会話スキルはめちゃくちゃ利用頻度が高いし、動物と会話したり死体から情報を得る魔法もしばしば便利なので誰かが使える状態になっていると有用な情報やアイテムの場所が分かったりする。この世界の動物はかなり賢いので喋ると結構面白い。やたら哲学的な猫とか、100年ぐらい生きてそうな牛とかが居る。

世界観の理解の助けになるもの

正直、D&Dを今からがっつりプレイするのは、そういうコミュニティに居ないとかなりきついので、wikipediaでD&DのForgetton Realmという世界について調べて世界設定やゲームシステムやアラインメント(人格特性みたいなもの、所謂「秩序」か「混沌」か、「善」か「悪」かの概念)について調べておくと取っ付き易くなると思う。他には話に結構絡んでくる九層地獄の設定とか。

後、去年だか一昨年だかに公開されていたD&Dの映画は世界観がほぼ共通なので、プレイ前後で映画を見ると共通の語彙や人名が出てきて面白い。例えば映画に出てきたメインキャラのウィザードの祖先のエルミンスターという大魔導師の名前とかハーパーとかサーイ人がどんな奴らかとかは、ちゃんとゲーム中に出てくるし、ドルイドの能力とかも共通している。ゲームをやってから映画を見ると、かなりD&Dの要素を映画に詰め込んでることが分かって相乗的に楽しめた。

総評

簡単にバルダーズゲート3の良い点と取っ付き辛い点や分かりにくい点について書いてみたが、全体としてこのゲームは本当によくできていて、自分の中ではRPGというジャンルでは現状最高峰に位置するゲームだったと思う。特に序盤の辛さで心折れると非常に勿体ないなと思うので、頑張って乗り越えてプレイして欲しい。

ゲーム的な問題点として大きかったのはセーブ&ロードが遅い点。結構ロードしたくなるケースは多いしリトライしたいことも多いんだけど、ここでちょっと待つのでプレイ時間が伸びる。結構これで時間食ってると思う。スパイダーマン2の異常なファストトラベルの速さとかを体験した後だったので、これに関してはちょっとストレスがあった。

後は大抵が「自由度」とのトレードオフなので、冒頭にも書いたがそこを楽しめるかどうかが一番重要なポイントだと思う。自分としてはゲームというのは自分で操作しているから良いのであって、そこで自分なりの関わり方を選択できるこういう作品はゲームの醍醐味を十全に活かしていてとても良いと思う。

久しぶりに人に語りたくなるゲームだったので、珍しくこういうネタで記事を書いてみたが、気になった人は是非プレイしてみて欲しい。(実はOuter Wildsの時も語りたいことは一杯あったのだが、何書いてもネタバレになりそうで「とにかくやれ」としか言えなかったw)

tree-sitter-rbsのサポートがnvim-treesitterにマージされました

先々週ぐらいからちょっとづつ作業してテストケースを追加したりしていたtree-sitter-rbsの実装が一段落して、普通に使う分には大抵のrbsはパースできるだろう、というところまで出来たかなと思ったので、nvim-treesitterにパーサー追加のプルリクを出しました。

爆速でレビューしてもらえたので、プルリク出してから数時間で無事マージされ、tree-sitter-rbsが簡単に導入可能になりました。 🎉

久しぶりにちゃんとOSSっぽいことやったかなという感じです。

nvim-treesitterはneovim上でtree-sitterで構築されたパーサーを使ってシンタックスハイライトを行う時に必要になるものです。 従来の正規表現ベースのhighlight記法よりも、より文脈に依存したシンタックスハイライトが可能になったり、シンタックスツリーに対するクエリが可能になることを利用したアウトラインプラグインやコードブロック操作のプラグインに対応可能になります。

(既にめちゃくちゃ利用されてると思うんですが、一応neovimの機能としてはまだexperimentalではあるらしい。)

今回、nvim-treesitterの中にtree-sitter-rbsの情報が取り込まれたので、nvim-treesitterのTSInstall rbsコマンドでrbsのパーサーがインストールできる様になり、追加のプラグイン無しでリッチなシンタックスハイライトが使える様になりました。

neovimでrbsを書いてrubyに型を付けたいという方は、是非試してみてください。(まだバグがありそうな気はする)

ちなみにrbsにはannotationという仕様があるんですが、どうやって使うものか全然分かってないので、この記法だけはまだサポートしていません。どうもsteepにあるサンプルを見る限りでは、メソッドの頭に付与して副作用の有無といったメタデータを指定する類のものの様ですが……。

rbsのtree-sitterパーサを書いて、neovimのシンタックスハイライトに利用する

皆さん型書いてますか?私はそもそもRubyを書いていません!

とはいえ、最近Kaigi on RailsやRubyWorldとカンファレンスが続いていたので、ちょっとやる気を出してrbsを書くためのエコシステムに貢献しようと思い、rbs用のtree-sitterパーサを書いてみました。パーサ流行ってますからね。

github.com

READMEにしたがってnvim-treesitterでパーサをインストールし、このリポジトリをneovimプラグインとしてインストールすれば、rbsシンタックスハイライトがイカした感じになります。

しかし、しかしながらですね、これ半年ぐらい前に調べた時には誰も書いてなかったんですが、8割ぐらい書いた所で、既に別のtree-sitter-rbsがあることに気付いたんですよね……。

github.com

まあ、せっかく作ったんで完全に同じ車輪の再発明だろうが、tree-sitterでパーサを書く練習だと思って一通り書くところまでしっかりやりました。

自分の方の特色としては、neovimフレンドリーなところですかね。ちゃんと動きそうならnvim-treesitter側に取り込んでもらうとこまでやりたいところです。

というかそもそもvim-rbsってプラグインがあって、概ねそれで十分なんですけどね……。

rbsのパーサって、Ruby本体に比べたらめちゃくちゃ簡単な方だと思うんですが、それでも結構大変だったというか、評価内容が衝突するところは一杯あって、tree-sitterでどうやって解決するのか結構悩みました。結果的に動くものは出来たはずなんですが、やり方として正しいのか本当のところよく分からんですね。

本当にlramaとかprismとか正気か?と思いますよ。凄さが実感できますね。

tree-sitterとは

ここから、そもそもtree-sitterって何?という人のためと、自分の備忘録のためにtree-sitterの解説を書いていきます。

tree-sitterとはrustとCで書かれているパーサジェネレータです。パーサジェネレータ自体は概ねrustなんですが、文法の記述はJavaScriptによるDSLで行います。

tree-sitter-cliをインストールしてtree-sitter generateコマンドを使うと雛形のプロジェクトが生成されます。

トップディレクトリにgrammer.jsというファイルがあるので、そのファイルに文法を記述していきます。

tree-sitterはGLR parsingアルゴリズムを基盤にしていて、概ねBNFみたいな感じで文法が記述できます。主なDSLは以下の通り。

  • 文字列 or regex: その文字列か正規表現にマッチする
  • seq(rule1, rule2, ...): 引数で指定されたルールが順番通りに全てマッチする
  • choice(rule1, rule2, ...): 引数で指定されたルールのいずれかにマッチする
  • repeat(rule): 引数で指定されたルールの0回以上の繰り返し
  • repeat1(rule): 引数で指定されたルールの1回以上の繰り返し
  • optional(rule): 引数で指定されたルールが0回か1回マッチする
  • token(rule): 引数で指定されたルールを一まとまりのトークンとして扱う。デフォルトの文字列は1文字づつ別々のトークンの集合として扱われる。
  • token.immediate(rule): スペースなどのextras要素を間に含まずに即座に表れるトークンに対してマッチする。

その他、precedance(優先順位)を指定するDSLがあります。prec(number,rule), prec.left([number], rule),prec.right([number], rule)`で、優先順位をしたり左結合優先か右結合優先かを指定できます。二項演算子の結合順序を指定したりなんかが典型的な使い方ですね。

JSなので当然再起を使ったり、任意の関数でラップしたりできます。

Tree-sitter|Creating Parsers

Goっぽい簡単な型宣言にマッチする文法は以下の用に書けます。

module.exports = grammer({
  rules: {
    type: $ => choice($.primitive_type, $.array_type),

    primitive_type: $ => choice("int", "bool", "string"),
    array_type: $ => seq("[", "]", $.type,
  }
});

こんな感じで$.rule_nameという記述で定義済みのルールを参照できます。関数にwrapされてるので定義順は自由です。これを積み重ねてルールを書いていきます。

今回はrbsリポジトリにあるsyntax.mdBNFを、ストレートにtree-sitter記法に書き起こしてconflictが起こるところを都度調整して直しつつルールを記述していきました。

tree-sitterにはexternal scannerという機能もあり、これを利用すると、CまたはC++レキサー処理のロジックを実装することができます。DSLでは表現し切れないパーサを実現したい場合は、こちらに処理を移譲することで何でもできそうです。 今回書いてみたrbsのパーサでは必要無いものでしたが、tree-sitter-rubyではC++で書かれたscannerが利用されています。

tree-sitterのテスト

tree-sitterにはパーサのunit testを行う仕組みが用意されています。 test/corpus/ディレクトリ以下にテキストファイルで以下の様に記述します。

==================
class type
==================

type foo = String

---

(program
 (type_alias_decl
  (alias_name
   (variable))
  (type
   (class_type
    (class_name
     (constant))))))

==================
namespaced class type
==================

type foo = Foo::Bar::Baz

---

(program
 (type_alias_decl
  (alias_name
   (variable))
  (type
   (class_type
    (class_name
     (namespace
      (namespace
       (constant))
      (constant))
     (constant))))))

この様に記述してtree-sitter testコマンドを実行すると、コードの断片をパースして、結果のシンタックスツリーを期待した構造と比較しテストできます。

tree-sitterによるシンタックスハイライト

tree-sitterにはqueryという仕組みがあります。(https://tree-sitter.github.io/tree-sitter/using-parsers#pattern-matching-with-queries)

このクエリを利用してマッチする構造にマーキングしていくことでハイライト対象を認識します。

queries/highlights.scmというファイルにそのルールを記述します。

tree-sitter-rbsのルールの一部を抜粋すると以下の様な形になります。

[
  "class"
  "module"
  "interface"
  "type"
  "def"
  "attr_reader"
  "attr_writer"
  "attr_accessor"
  "end"
  "alias"
] @keyword

[
 "def"
] @keyword.function

(include_member "include" @function.method)  
(extend_member "extend" @function.method)  
(prepend_member "prepend" @function.method)  

特定の文字列のトークンとマッチすれば@keywordというマークが付きます。include_memberというツリー構造の中で"include"が出てきた時には@function.methodというマークを付けます。

このクエリには構造マッチ以外に追加で条件を加えることもできます。

(
  (pair
    key: (property_identifier) @key-name
    value: (identifier) @value-name)
  (#eq? @key-name @value-name)
)

この例では、pairというツリー構造のkeyというフィールドとvalueというフィールドの中身が同じ時にだけマッチします。

どういうマークを付けるとneovimで利用できるかは https://github.com/nvim-treesitter/nvim-treesitter/blob/master/CONTRIBUTING.md に書かれています。

シンタックスハイライトのテスト

tree-sitterではシンタックスハイライトもテストが可能です。

test/highlight/ 以下にソースコードを記述します。この時ちょっと面白い書き方をします。

class Foo[A]
  # <- keyword
        # <- type
          # <- constant
  def foo: (String) -> String
   # <- keyword
      # <- function.method
              # <- type
                        # <- type
end

この様にして、下の行にコメントを追加し、そのコメントの開始地点に期待されるマークが何なのかを記述します。 一つ一つの指定がアサーションになり、期待値と一致しているかを検証してくれます。

neovimでハイライトを使う場合の注意点

neovimで利用する場合、nvim-treesitterというプラグインを使うのですが、nvim-treesitterは微妙にtree-sitterのプロジェクト構造と要求するファイルの位置が異なっています。

具体的にはrbsのパーサであれば、neovimのruntimepathのいずれかの中にqueries/rbs/highlights.scmという形で配置する必要があります。これが微妙に面倒臭いので、理想的にはnvim-treesitterにPRを出して取り込んでもらうのが良さそうです。

今のところ自分が書いたtree-sitter-rbsでは、queries/rbs/以下にクエリファイルを置きつつ、tree-sitter testで認識できる様にシンボリックリンクを貼って調整しています。 こうすることで、neovimのパッケージ管理マネージャーでインストールし、パーサをコンパイルするだけで利用できる様にしています。

最後に

今回、tree-sitterならDSLあるしrbsのパーサぐらいならサクっと書けるんじゃないかと思って書いてみましたが、それでもまともに動くものを作るのに2日ぐらいはかかりました。rubyのパーサなんか絶対自分じゃ書きたくないですねw 面倒臭過ぎる……。 そんなパーサをゴリゴリ改善していってくれているKevinさんと金子さんには尊敬の念を禁じ得ません。

tree-sitter-rubyはかなり頑張っているパーサなのですが、それでもパース不可能なケースはいくつかあります。こういったユニバーサルパーサ実現に向けた流れの中で、tree-sitter-rubyに相当するものが自動で生成される様な世界になるとテキストエディタ界隈にもメリットがありそうです。

Railsで秒間1000コミットを捌くにはどうすればいいのか (Kaigi on Railsのフリースペースより)

先日のKaigi on Rails中の雑談として @ima1zumi さんから、RDBに対して秒間1000コミットぐらいで処理が詰まってる場合ってどうするのが良いのか、という質問を受けまして、雑談の中で色々答えてたんですが、せっかくだから記事にまとめておこうと思います。 ちょっとしたKaigi Effectって感じですね。

今回のKaigi on Railsトークの中では、 数十億のレコードを持つ5年目サービスの設計と障害解決 by KNR - Kaigi on Rails 2023 の話なんかは割と関連がありますね。ユーザーの行動履歴というのは、ユーザー数 * N * タイムスパンで増えていくレコードなので、書き込みとデータ量が爆発しがちです。トランザクションで堅牢に処理しなければいけないケースもそこまで多くないので、RDBだと書き込みに対する処理が過剰なケースが多い。実際のところこの手のデータってイベントログなんですよね。ログ収集と分析基盤で処理できる方が良いんですが、今回のpixivさんの話では履歴情報が提供機能と密接に結びついているので、その辺り難しそうな感じでしたね。 この記事では、とりあえず機能要求については置いておいて、このデータ量を捌いて永続的なデータストアに入れる時にどういう考え方をしているのか、ということを書いていきます。

ちなみに、タイトルにRailsと入ってますが、ぶっちゃけ余りRails自体とは関係が無いですw 強いて言えばこれから書くのはWeb業界の話だということです。エンプラの世界だったらOracleに何億か払うといういつもの選択肢があります。

さて、まず第一に考えておかなければいけないのは、書き込みリクエストに対してどれぐらいのレイテンシで結果を返さなければいけないのか、という点です。

書き込みリクエストから結果の反映まで一定時間待てる場合 (かつ1000tpsが瞬間的なピークの場合)

ここでそれなりに待てるなら、非同期処理に逃がして書き込みペースを調整すれば問題ないでしょう。言い換えると結果整合性が取れていれば良い場合です。

その場合に使える道具としては、AWSのSQSやKinesis、Redisなどです。

書き込みが羃等なのであればSQSが安全かつスケールが簡単なのでオススメです。SQSは基本的にat least onceなので、稀に同じキューが重複して取得されてしまう可能性があります。 実際に起きるのはかなり稀ですが、それが許容できない場合はSQSのFIFOキューを使うか、Redisで頑張るか、SQSとRedisを組み合わせて現実的に発生しない程度に確率を下げるか、という策が取れます。

Redisを活用する場合は、Railsの世界で一番楽なのはsidekiq-proを使ってワーカーの数で書き込みペースを調整するという形かと思います。proじゃないと駄目なのは、通常のsidekiqだとジョブロストの可能性が無視できないので、安全に構成できないからです。

追記: ちょっと誤解を招きそうだなと思ったので追記しておきます。これは常にsidekiq-proを使うべきという話ではありません。非同期処理のよくあるユースケースの一つとして完了通知などが挙げられますが、こういった例では稀にジョブがロストしたとしても致命的な問題にならないのでそこまで気を使わなくていい場合があります。上記の例ではまずRDBに書いて安全な記録を残す手前の部分でsidekiqを使っているので、滅多に無いことでもジョブロストしたらデータの不整合や消失に繋がります。こういった万が一のジョブロストも避けたい場合はproを使った方がいいという感じですね。

ちなみに、Kinesisは単純なキューではなくてKafkaなどに近いので、複数のサービスでそのデータが必要とかでない限りは選択の優先順位は高くありません。SQSより遥かに扱うのが難しいサービスです。

書き込みリクエストから同期処理で結果を反映しなければならない場合、もしくは定常的に1000tps以上が必要な場合

問題なのはこのケースで、これについてはある意味非常に単純な3つの道を選ぶことになります。

札束を積むか、RDBを分割するか、RDB以外のものに書き込むか。

札束を積む

ワークロードに依りますが、Auroraに金を積めば秒間1000コミットぐらいは普通に処理できます。さっき職場のRDSのメトリックを見てみましたが秒間1000〜1500コミットぐらいは1インスタンスで普通に処理できていて、まだ余裕がある感じです。インスタンスサイズはr6g.16xlargeです。これはGravitonの最大サイズですが、r6iなら32xlargeまで札束を積めるので、本当にギリギリまで引っ張れば秒間4000コミットぐらいまでなら普通に処理できる可能性が高いです。 Kaigi on Rails中の雑談では、Auroraで何とかなるか微妙なラインかもなーと答えてしまいましたが、改めて弊社の様子を見る分にはよっぽど1コミットが重くない限りは十分お金で対処可能だと思いました。もし1コミットで影響するレコードが多い、ロック競合するパターンが多い、トランザクションにおけるisolationに依存した読み込みが非常に多い、といったケースだと、Auroraにお金を積むだけでは無理な可能性はあります。

RDBを分割する

ワークロードに合わせて利用するDBをアプリケーション側で切り替えるやり方です。ゲーム系では昔からよく聞く方法ですが、これは基本的に茨の道ですね。ワークロードのパターンによって垂直分割と水平分割のやり方があります。現在のRailsはmulti DB対応が組込まれているので、昔に比べれば本体のメンテナンスに乗ることができる分やり易くなったとは言えます。 しかし、それでも複数DBは本当に必要になるまでは回避したい選択です。もし改修要求の結果複数DBに跨るトランザクションやジョインが求められた場合は即破綻しますし、アプリケーション側でそれを上手いこと回避する様に実装しても高確率でバグから逃れられないでしょう。後からデータ不整合が発覚した場合の修正コストは大抵の場合、非常に高く付きます。加えて、後から分割数を増やすのがとても大変だという落とし穴もあります。 もし本当に必要になったのなら、ドメインモデルとしっかり向き合いましょう。将来のアプリケーションの成長まで加味して慎重にモデルの区分けを行う必要があります。というか、この辺りは実質的にマイクロサービス化と同じ考え方になると思います。

RDB以外のものに書く

これは本当の所はトランザクションが必要無い場合に採用可能です。この場合は各種分散データストアの導入を検討することになります。弊社ではKafkaやCassandraを利用している箇所がありますが、レプリカ書き込みを含めて秒間100万件ぐらいの書き込みが発生しています。例えばCassandraは読み込みよりも書き込みの方がスケールさせやすい作りになっていて、クラスタの台数を増やせばこれぐらいの書き込みペースは普通に捌くことができます。弊社ではそれなりにメモリとストレージを積んだ20台ぐらいで済んでます。当然、複数台のクラスタを管理するコストが増えるし、新しいミドルウェアの知識やパフォーマンスチューニングも必要だし、複数DBと実質的に同じ問題を抱えることになるので、開発業務に与える負荷は非常に高い選択肢です。しかし、求められる書き込み量の桁が2桁とか3桁とか上がってしまうと、RDBに書くという手段ではどうにもならなくなるのでやらざるを得ないという感じですね。この分野で他に使えそうなミドルウェア/サービスは、ScyllaDB、DynamoDB、BigTable辺りでしょうか。メモリキャッシュに近い方向ならHazelcastとかIgniteとかAerospikeみたいなのもあります。

どうしてもトランザクションが必要だが書き込み件数が秒間数万件を越えるという非常にハードな現場だった場合、SpannerやCockroachDB、TiDBなどの分散SQLデーターベースの採用を検討することになるかと思います。例えばCockroachDBはマルチリージョン構成も可能なグローバルスケールのサービス展開を想定したデータベースで、PostgreSQLプロトコルと互換性があります。つまりRailsからならpgドライバで接続可能という訳ですね。 この辺りの選択肢は、とにかく技術調査力が必要です。業界全体を見ても、ここまでのスケーラビリティが求められるOLTPというのはそう多くないので、知見が全然見つからないというのはザラです。自分でアーキテクチャをちゃんと理解し、狙ったパフォーマンスが出る様にチューニングと実験をし、運用ラインを整える必要があるでしょう。お金の力があれば、エンタープライズサポートに頼ることで開発負荷を下げることはできると思います。

現実的にどうするのか

ざっとまとめさせてもらいましたが、真の意味でオンライントランザクションが必要なのかどうかで、大量のアクセスを捌く難易度は大きく変わります。一番現実的なのは「本当にそこでユーザーを待たせてはいけないのか?」という話をちゃんとすることです。5分待たせて良いんだったら全然難易度が違ってきます(非同期処理化は別の複雑さに繋がる可能性はありますが)。 あくまで自分の感覚ですが、大量のアクセスがあって本当にオンライントランザクションが要るケースはそこまで多くないと思っています。現実的な開発に合わせて仕様をコントロールするのも重要な仕事です。

そして、顧客価値のためにどうしてもオンライントランザクションが要るとなったら、まずはAWSGoogleに金を積みましょう。分かり易くキャッシュが減ってしまいますが、実際はこれが一番安くつきますし、とにかくすぐに対応可能です。

残りの手段であるRDBの分割や分散DBの採用は、ぶっちゃけ個別の地獄にそれぞれ頑張って対処するしかないんですよね。気合入れて調査して実験して計測してチューニングして社内向けのドキュメントや勉強会を準備して、とそれ相応に時間とコストがかかるので、トラフィックが追い付かない内に次の一手が打てる様に準備しましょう。実際、RDBでは色々な意味でコストが合わなくなる時というのはしばしばやってきます。そういう時のための選択肢を常に調査して、手遅れになる前に対処することが大事です。まあ、それができれば苦労しないというやつですが……。

ちなみに、Repro株式会社では、こういったことに興味があるエンジニアを募集しております。ちょっとハードなトラフィックを扱う経験が積みたいという方は、カジュアル面談からでも良いので是非ご応募ください。(現職のことも書いてるので、一応やっとかんと……)

company.repro.io

Linux(SteamDeck)でWin用のゲームメモリ改造系ツールを動かす方法

Steam版のFinal Fantasy Pixel Remasterにはブーストモードが無い

FF5ピクリマのゲーム自体は結構前に買ってたんだけど、プレイし始めたのは最近で、Steam版にはブーストモードを含めたPS4/Switch版のアップデートが入ってないことを先週知ったところ。

まあ、別にそんな困らないんだけど、FF5は色々組み合わせて遊ぼうとするとアビリティ習得に結構レベリングが必要なので、ブーストできないのは結構面倒臭い。

で、流石にメジャーなPCゲームなんで探せば何かツールあるんじゃないかなーと思ったら案の定あった。

FLiNG Trainer - PC Game Cheats and Mods ってところにPCゲームのチートツールが色々集まってる。 FF5Final Fantasy V (Pixel Remaster) Trainer - FLiNG Trainer - PC Game Cheats and Mods にあった。 これを使えば獲得ABPのN倍が実現できそうである。

しかし、ここは割とマシな方なんだけどそうは言ってもこの手のチートツールは結構怪しいところあるので、実行は自己責任で。

メモリ改造ツールをLinuxで動かす

ここから本題。この種のツールは実行中のプロセスにアタッチして特定のメモリ領域を書き換えることによって成り立っている。

なので、Steamで起動しているFF5のプロセスがツール側から認識できる必要がある。

LinuxでSteamのゲームを起動する場合、Linuxネイティブで起動するものを除くと大抵はProtonというWineからforkしたWin APIの互換実装を利用することになる。

要はWine上で動いている訳だが、何も考えずにwineコマンドでこの手のツールを起動してもプロセスは認識できない。

WineはWINEPREFIXで指定されるディレクトリに擬似的にCドライブやレジストリの値を保存する。デフォルトは~/.wineだ。このWINEPREFIXごとに独立した環境として起動するのでWINEPREFIXを揃えてやる必要がある。

じゃあSteamで起動した時にはどうなっているのか。Linux上のSteamでWin向けのゲームをインストールするとゲームごとに独立したWINEPREFIXが自動的に作成される。例えば/mnt/steamがSteamのゲームインストール先として登録されていた場合、WINEPREFIXの場所は/mnt/steam/steamapps/compatdata/<game id>/pfxになる。今回のFF5ピクリマのケースだと/mnt/steam/steamapps/compatdata/1173810/pfxだ。ちなみにゲーム本体は/mnt/steam/common/<game title>にインストールされる。

SteamのGame IDの調べ方は色々あるが、手っ取り早いのはSteamライブラリからスクリーンショットの管理画面を開いて、そこから保存ディレクトリを開くこと。screenshotsの親ディレクトリがGame IDごとに分類されているはず。ググってもすぐに出てくるしCLIで取れる様にするツールもある。

という訳で、WINEPREFIXの指定場所が分かった。

後は、実行しているWineというかProtonを揃えておく必要がある。

Steamで利用しているProtonがどこにインストールされているかというとこれは結構マチマチなので、起動時にpsコマンドでも打ってそこかだそれっぽいのを探す方が良いかもしれない。

自分はProtonを更にforkしたProton-GEというやつを使っていて、その場合は~/.local/share/Steam/compatibilitytools.d/GE-Proton8-19/に入っていた。

この中の~/.local/share/Steam/compatibilitytools.d/GE-Proton8-19/files/bin/wineを使ってツールを起動する。最終的なコマンドとしては以下の様になる。

WINEPREFIX=/mnt/steam/steamapps/compatdata/<game id>/pfx ~/.local/share/Steam/compatibilitytools.d/GE-Proton8-19/files/bin/wine <trainer tool exe>

上手く環境が噛み合っていれば、ツール側からゲーム本体のプロセスが認識できる様になる。

他にもsteamtinkerlaunchのcustom commandを使って起動時に同時に別のexeを動かすなどの方法も使えそうだったのだが、どうも自分の環境ではツール側の起動に失敗する問題があった。流石にこの辺りは良く分からん。

後は、ツールのUIでポチポチやれば良い。自分はこれで一応上手くいった。

という訳で、今回も非常に需要の少なそうなGentoo LinuxでゲームをやるためのTips記事だった。

Wayland環境に移行してHyprlandを使ってみて3日程度経ったので感想を書く

ふと思い立って(テスト前に掃除したくなるやつ)、一度試して挫折したwayland環境移行を試してみようと再度やってみたら、割と使えそうな感じだったので常用目指して真面目に設定してみた。

今回はi3ベースのswayじゃなくてHyprlandというどっちかというとAwesomeに近いcompositorを使ってみた。ちなみにディストリはGentoo Linuxだ。

Hyprlandは割とビジュアルにこだわっていて、ウインドウを開いたり移動したりするとウニョウニョ動いて楽しい。 Hyprlandの大きな利点は、独自のxdg-desktop-portalが準備されていて、wayland環境でもウインドウ単位のスクリーンシェアがちゃんと動作すること。 swayだとxdg-desktop-portal-wlrを使うことになるんだが、これはモニタ単位のスクリーンシェアしか出来ない。これが不便だったので常用するのが辛かった。 一方でHyprlandは、xdg-desktop-portal-hyprlandが利用できて、このおかげでウインドウシェアが動く。(まあ、ハマりどころは多々あるが)

youtu.be

スクリーンシェアについて

現状、Hyprlandでスクリーンシェアをやるには以下のものが要る。

どうも起動の順番で上手く動かない時があるが、基本的にはpipewireのサービスとwireplumberのサービスを動かして、xdg-desktop-portal-hyprlandとxdg-desktop-portalの両方を動かす必要がある。xdg-desktop-portal-gtkはなんか勝手に起動した。

加えて、Hyprland起動時に環境変数を設定しておく。

env = QT_QPA_PLATFORM,wayland
env = QT_WAYLAND_DISABLE_WINDOWDECORATION,"1"
env = GTK_THEME,Adwaita:dark
env = MOZ_ENABLE_WAYLAND,1
env = XDG_SESSION_TYPE,wayland
env = XDG_SESSION_DESKTOP,Hyprland
env = XDG_CURRENT_DESKTOP,Hyprland
env = _JAVA_AWT_WM_NONREPARENTING,1

exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=hyprland XDG_SESSION_TYPE=wayland
exec-once = systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP

XDG_ほげほげの部分がスクリーンシェアに必要らしい。多分xdg-desktop-portalが詳細実装を探す時に使ってる。 後、_JAVA_AWT_WM_NONREPARENTINGが無いとIDEAの画面に何も出なくなる。これは辛い。 後はQT系アプリの調整とか、GTKのテーマ設定とか、Firefoxでwayland nativeの動作を有効にするとか、その辺のための設定。

最後のexec-onceはdbusとsystemdのuser設定に環境変数の内容を通知しておく。これでsystemdとかdbus回りで起動しているプロセスに環境変数が適用されるはず。

また注意点として、ディスプレイのcolor depthで10bit colorを利用しているとキャプチャが上手くいかないらしい点と、Xwayland経由のアプリケーションからだとウインドウが見えない点があるが、まあよく使う範囲では許容できる。

参考:

日本語入力(IME)

元々ibusibus-skkを使っていたのだが、これはwayland nativeなウインドウで日本語入力が効かないことがよくあった。 wayland compositor側のprotocol実装の不足かibus側のprotocol実装の不足のどちらかのせいだったと思う。

で、今回はfcitx5に乗り換えてみたところ、割とちゃんと日本語入力できる様になった。しばしば複数回変換した時の候補ウインドウがちゃんと出ないアプリがあるが、そこまで困らなくなった。一応、そういう時のためにホットキーでneovimを起動して書いた文字列をclipboardに即コピーできる様にしてあるが。 (Gentooは基本のリポジトリにfcitx5が無いので、サードパーティのoverlayを使う必要があるのが面倒だった)

Steam

自分はSteamのゲームも基本的にLinuxを使ってるが、昔Swayで試そうとした時はエラーになって駄目だった。 最近はSwayでもHyprlandでもちゃんと動作する様だ。 Steam自体はwayland nativeではなくXwayland経由で動く。

少なくともONIとADOFAIとサイバーパンク2077は動作確認できた。

動作感と現状の問題点

三大懸念だったスクリーンシェア、日本語入力、Steamが割と何とかなったので、数日使ってみて、動作感や安定性などを確認してみた。

全体的な動きとしてはwaylandの方が軽いというか、X上でpicom使うよりスムーズに動いている様に思う。 mpvでの動画再生とかもwaylandの方が安定していて、高画質動画に更にshaderかけたりした時でもdropが発生しにくくなった。

Firefoxはwayland対応が割とちゃんとしているのかXより動作が軽いしメモリ消費が少なくなった様に思う。

一方で、やはり全体的に微妙な不安定さがある。 しばしば特定のウインドウに対してキーボード入力が効かなくなることがある。ウインドウを閉じるホットキーも効かなくなるので、そうなると外からkillするしかない。

また、スクリーンロック中によく落ちたり固まったりする。特に長時間放置してdpms offが走ってディスプレイが切れた時に上手く復帰できないことが多い。変な固まり方をするとsshで入って再起動するしかなくなることがあった。swayidleとswaylockを使ってるが、これは別にswayに依存している訳ではないはずなので、多分Hyprland側がこなれていないんだろうと思う。

後は、スクリーンシェアの画質が良くないところも問題と言える。もうちょっと綺麗に取る方法無いのだろうか。pipewireとかwireplumber側の設定で調整できんのかな。この辺りが良く分からん。

総じてまだハマるところは色々あるが使えなくはないな、という感じ。

見た目自体は良いしキビキビ動くのでもうちょっと使ってみようと思う。何とかなりそうならメインPCはwaylandのまま頑張ってみたい。