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のまま頑張ってみたい。

RubyKaigi 2023 参加報告とちょっとエモい話

RubyKaigi 2023に参加してきました。

今回は長野県の松本での開催でした。

全体的な感想

今回は、会場のスポンサーブースの数や来場者が去年より格段に多く、かつてのRubyKaigiが戻ってきたことを強く感じました。

4, 5年ぶりぐらいに会う人も沢山居て、会う人会う人に「うおー、久しぶりです!」って言って回ってた気がします。 久しぶりに会う人と直接近況をやり取りできるのは、とても嬉しいことですね。

自分はあんまり写真撮らないタイプなのですが(食べ物と酒は除く)、今回は割と多くの #rubyfriends 写真を撮った気がする。 それぐらいはしゃいでいたと言えるのかもしれない。

(撮った写真を了解無く上げるのは、ちょっと気になったので写真は割愛)

とにかく、色々な人にまた会えたのが嬉しかった。そういうRubyKaigiでした。

セッションについて

今回は、パーサー周りのトークが妙に多く、世は正に大パーサー時代という感じでしたね。(RubyKaigiのトークって結構テーマが集中する傾向にあると思う)

mameさんやsoutaroさんと廊下で話していて、開発者体験を向上させて新しい言語に置いていかれない様にするためには、昨今LSPを避けては通れないというのは感じますし、そうなってくると絶対に必要なのが記述途中の不完全なソースコードを上手く扱う方法です。そりゃパーサーについて考える機会も増えるわな、という感じですね。

もちろん、その延長で別のRuby実装や別言語で書かれたRubyのためのツール(rubyfmtなど)のメンテナンス性の向上にも繋がるし、ホットなトピックになるのも自然という感じでした。

という訳で、今回聞いたのは、この辺りです。

Matz Keynote

今回は歴史の話がメインって感じでしたが、Ruby30thの時にも結構聞いた感じがするので、個人的にはもうちょっと未来の話が聞きたかったですね。 終盤の、ISO規格どこいった?!辺りの話は爆笑しましたがww エンジンかかってきたMatzの方が面白いのは間違いない。

The future vision of Ruby Parser

今回の主役の一人と言える、kanekoさんによるparser実装の話。 bisonというparser generatorをRubyで実装しなおしたlramaというプロダクトで置き換えるという非常にカッコいいことをやってるのが印象的です。 LALRパーサーのgenerator記法を少し拡張するだけで、やれることが大分増えるというのはスマートで良いなと思いました。 今回YARPの方の話は聞いていないのですが、YARPはRubyで手作りしたparserということで、結構スタイルの違いを感じますね。

なんと今回のRubyKaigi中にbisonへの置き換えを実際に達成して、kanekoさんは「牛殺し」の称号を獲得していましたw

"Ractor" reconsidered

キーノートでMatzが話していた、benefitをいかに提供するかということに繋がる話でした。 Ractorを実際に使ってみる上で、現実的に性能が上がる見込みが強くならないと、利用者が付いてきてくれずフィードバックが得にくくなって、改善も進みにくくなる、という問題にいかに対処するかという話が中心だったと思います。 特にRactorが複数あるとそれぞれのGCが競合してしまう問題は非常に辛い問題だと思いました。 改善すればかなり効果がありそうに思います。

後、M:NスレッドプロジェクトのMaNyにも触れていましたが、これも期待感があります。 一方で、やらなきゃいけないことのリストが半端なかったので、うおー大丈夫なのか……って感じもありました。

Power up your REPL life with types

ぺんさんによるkatakata_irbの紹介。 これについては、とにかく入れるだけで便利になるので、まず試してみようの一言ですね。

利用者側にとって簡単なのがとても素晴らしいなと思いました。

Lightning Talks

今回、CFPに応募したんですが、落ちてしまったので悔しかったというのが一番の感想ですね。 vim関係の話は大倉さんが話していたので枠が無かったというのもあったらしいですが、自分でもパンチが弱いなという感じだったので仕方ない。

Learn Ractor

実は寝坊してしまって、後半ちょっとしか聞けてない。 enumerationがあればRactorで並列化できる可能性を考えてもみても良い、という話だけ記憶に留めておきました。

Implementing "++" operator, stepping into parse.y

kanekoさんの発表に並ぶ、今大会ベストトークの一つ。 トークの流れがよく練られていてめちゃくちゃ面白かったですね。 それぞれのプロセスが、別々のジャンルの人間に刺さる様になっていて、私とモリスさんなんかはlocal_variable_setで爆笑していました。 parse.yのアクションからbinding呼んでlocal_variable_setを使うというのは、全然考えたことがなくて、やられたーと思いました。

アフターパーティでnobuさんが楽しそうにしおいさんと話していたのも、良い光景でした。

RubyGems on the watch

Maciejさんによる、RubyGemsセキュリティインシデントに間する発表。 リリース前のgem情報をGitHubから収集してbrandjackingを行う例とか、アップロード失敗を利用してCDNに悪意あるコードをキャッシュさせるとか、改竄コードが入る余地というのは現実としてあるということが理解できる話でした。 身につまされる内容というか、OSSの世界においては利用するコードが正しいかどうかをちゃんとチェックする責任は利用者にあるし、不用意な信用を前提にして行動をしてはいけないということを肝に銘じておきたい。

Revisiting TypeProf - IDE support as a primary feature

typeprofで開発者体験を上げるために、本当に必要だったものは当初の想定と結構違っていたという話でした。 LSPでの利用を重視してv2を作っているということでしたが、最近自分もLSP周りの整備を行ってtypeprofも触ってみましたが、もっと良い体験が得られるなら期待が高まります。

Multiverse Ruby

この話は個人的にかなり興味深いトークというか、Rubyのマニアックな挙動を利用して現実的に役に立ちそうな可能性に繋げるというのは、とても好きなジャンルの話ですね。 loadの第二引数に匿名モジュールを渡すことで、load先のクラス・モジュール定義が匿名モジュールの名前空間の中に定義されるという挙動を利用して、namespace分離を行えないかという内容の話でした。

実際問題として、他から参照を制限できるnamespaceやgem間での名前の衝突や同一gemの複数バージョン利用などを考えると、現状のRubyで対応できないけど、役に立ちそうなことはいくつかあるので、Rubyのこれからのnamespaceを考える上で一石を投じる発表だったと思います。

モリスさんがこんなブログを書いていたので、そちらも参考になると思います。 https://tagomoris.hatenablog.com/entry/2023/05/15/174652

Optimizing YJIT’s Performance, from Inception to Production

今回、gihyoさんのレポートとして私がこのキーノートを担当することになったので、詳しくはそちらで書くとして、どうでもいい小話が一つ。 Shopifyで頭文字Dが流行ってるんだろうか?w

Ruby Committers and The World

今回はShopifyさん仕切りで、アジェンダがしっかりしていたのと英語メインで進行していたのが今迄との違いです。 別に今回のが悪いという話ではないのですが、進行を無視してコミッタがプロレスを始めて、おもむろにnobuさんがパッチ袋引っ張り出してくる、みたいな展開が無かったのが、例年のThe Worldファンとしてはちょっと物足りなさを感じました。 まあ、どっちが良いという話ではないのですが。

Build Your Own SQLite3

picoruby上でSQLite3を動かすために、VFSレイヤーを自力で実装して必要な関数を全部マッピングしていくという発表でした。 この実装力は本当に凄い。そして、SQLite3が動くキーボードが完成していました。 hasumikinさんにキーボードを借りる時は注意した方がいいかもしれませんw 裏でSQLite3が動いているかもしれないw

Ruby JIT Hacking Guide

今回とても楽しみにしていたRJITに関する発表です。 RJITってrubyのiseq情報とcfpを受け取って、rubyアセンブラを書いて実装を置き換えることで高速化する、みたいな機構だと認識しているんですが、iseq情報を利用して悪い方向に書き換えて、見た目と違う挙動を実現できるのでは、と考えていました。 見込み自体は正しかったので、これを使って来年のRubyKaigiまでに何か作りたいと思ってはいるのですが、まだ実現可能性が何も見えていないので、本当にやれるかどうかは完全に不明です……。

Parsing RBS

soutaroさんによるキーノートで、error tolerant parserをどうやって作るかという話が順序立って説明されていて勉強になりました。 LSPのedit notificationを使って変更チャンクを認識して、そこで制御可能な形でparserの処理を打ち切ってエラー処理に持っていくというのは、とてもスマートだなと思いました。 steepのLSPも最近ある程度触っていて、rbsを書く機会もちょっとづつ作ろうとしているので、開発体験の向上に期待しています。

まとめ

今年は、自分の中でも多くのセッションを聞いたRubyKaigiになりました。 体調が比較的安定してたというか、睡眠破綻が余り起きなかったおかげだろうか。

Leaner Drink upについて

今回は、久しぶりに日本酒の選定をさせてもらいました。Leanerさんとは直接関わりがあった訳ではないのですが、コミュニティの友人の中の人から声をかけていただいて、それなら協力させてもらいますという感じでやらせてもらうことになりました。

まあ、要するにコミュニティ繋がりのただの酒好きのオッサンということです。

とは言え、それなりに長くRubyコミュニティで活動していたこともあってか、多くの友人や日本酒好きが参加してくれて、好評をいただくことができました。

いやー、趣味を曝け出してる感じはちょっと怖いところもあるし、全てちゃんと味を確認してから選べている訳ではないので、不安も少しありましたが、上手く終わって良かったかなと思います。

Leaner様からも感謝の言葉をいただきました。こちらこそスポンサー業の一部とはいえお手伝いできたことを嬉しく思います。

エモい話

今回、去年のRubyKaigiより久しぶりに会ったRubyistが遥かに多く、そのおかげで熱量の高い話も結構やれたかなと思っています。

特にTwitterでも話をしたんですが、kaneko.yさんから「Asakusa.rbでお世話になった頃から憧れのRubyistの一人でした、コミュニティで近くに居てくれたおかげでこれだけの成果が出せる様になったんです」という感じの事を言われて、本当にメチャクチャ嬉しかったんですよ。こんなに嬉しいことは無いんじゃないかと思うぐらい。ぶっちゃけ泣きそうになったし、これ書いてても涙出そうなのを堪えている。 自分から見たら、kanekoさんはとっくにRuby界のHeroの一人でこちらこそ憧れてるRubyistの一人だった訳で、そのことを伝え返すこともできて嬉しかった。 とは言え、そういう過去の自分に乗っかっているだけでは駄目で、何かしらの良いアウトプットを出していける自分でありたいなという思いも強くなりました。 コロナという非常に大きなコミュニティの分断があった後でそういうことがあったので、直接思いを伝える機会があるなら逃してはいけないなと本当に実感した訳ですね。

そういうことがあったので、その後の夜中にemori.houseの面々と飲んだ後、今回スタッフではなく一般参加していたぷぽさんに対して、今迄の感謝と我々が如何にRubyKaigiというイベントを楽しんでいるかということ、そしてその楽しみを今迄スタッフとして支えてくれていたぷぽさんが今年めちゃくちゃ満喫しているのを本当に嬉しく思っている、ということを語るのに繋がった訳ですね。これも気恥ずかしい話ではありますがw

その他のちょっとした話としては、自分のライブラリを使ってくれてるという人が何人か声をかけてくれたことも嬉しかったことの一つです。あんまり代表作と言えるほど大きなものが作れていないので、こういうちょっとしたことでも嬉しく思います。

コロナ過を経て、もしかしたらもう会わない人も一杯居るのかもしれないなと思ったし、今回のRubyKaigiで会えても次に会えるとは限らないということが、以前より遥かに現実的な問題になっていて、そのせいかちゃんと写真という記録を残したり、言える時に感謝の思いは伝えた方が良いなと思うことしきりなRubyKaigiでした。

ちょっと話は変わるのですが、Kaigi後に書かれたいくつかのブログの話を見たりKaigi中に自分もそういう話をしていたりということもあって、憧れの捉え方や自分が何者だと考えているかについて、人それぞれの色々な考え方があるのだなと実感しました。

今回のRubyKaigiでは、そういう自分の立ち位置の変遷や、周りの人達の変化の話や、考え方の違いというものに触れる機会がとても多かった様に思います。 CTOという立場からの変化、人生ステージの変化など、自分がRubyコミュニティに居場所を見つけて10年以上も経った訳で、自分にも周りにもそういった変化を感じている中で、久しぶりに会った人が一杯いたことで自然とそういう話が増えた気がします。

私は、昔から所謂「強い」エンジニアに憧れがあり、自分もそうなりたいと思っていたし、それが生きていく上で重要なポイントの一つでもありました。結果的にそれなりの適性があったので、ある程度のアウトプットは出せたと思うし、そういった友人も沢山できました。

一方で、今もずっと自分はそんな大した人間ではなく普通のプログラマだと思ってるのですが、Kaigi終了後にばったり遭遇したSTORESの藤村さんと話した時に「自分もそう思うけど、状況証拠的に何かしら逸脱したところが自分にあると認めざるを得ない」って感じのことを言っていて、その話に「めっちゃ分かるわー」と強く共感しました。普通それなりにエンジニアを抱えている会社のCTOなんかにはならないんですよねw

つまり、自分にはある程度は「強い」エンジニアとしてやっていく資質があったとは言えるのでは、と最近思える様になりました。ただ、そこに満足してしまうと自分の成長が止まってしまう可能性が高いので、より高い技術力に対する飢えはずっと持っておきたいところですが。

という感じで、自分はそういうコードを書いて「強く」なることが好きで何とかやれていると思うし、これまでの人生の中でRubyKaigiやRubyコミュニティに居る「ギャングスター」にストレートに憧れることが出来たのですが、RubyKaigiって世界でも稀に見る程にヤバイ人達が登壇しているスーパーなイベントでもある訳で、大体登壇している人達ってのはある種の変態ばっかりなんですよ。そう簡単に真似できるもんじゃない。(お前が言うなという話かもしれませんが)

なので、RubyKaigiで受けた刺激はあくまで刺激として受け取って、気にせず自分の人生のための活動をすること、自分なりのやり方でコミュニティに恩を返すのも大事なことなのだと思います。目立つヒーローばっかりが人間じゃない。

自分も、最近Rubyという言語と直接的に強く関わっていないこともあって、自分は最近ちゃんとやれてんのか?と思うこともたまに……という感じだったしw

そんなこんなで、この辺りのことを考えることに繋がったんですが、まあそれなりに経験も積んだおっさんになったので、自分としては、落ち着いて今回のRubyKaigiで受け取ったクソデカ感情というやつを次のRubyKaigiにぶつけられる様に活動していこうと思っています。

一言で言うなら、やる気は出てるぞ!ってことです。(頭が着いていかない可能性があるが……)

まずは、RJITで遊ぶぞ!

最後に

自分の観測範囲でも数名コロナ陽性反応と共に発熱している人がチラホラと出てきています。 幸い、自分は今のところ喉が痛いだけです。これは連日酒を飲んでは色々な人と話し続けてたので当然と言えるのですが、とりあえず時間を見つけて検査は受けに行こうと思っています。 皆様も体調にお気をつけください。

RubyでBigQueryのStorage Write APIを利用するまでの流れ

自分がググった限りではネット上に記事が皆無で無限の知識のAI様に聞いてもウソしか教えてくれなかったので、ここにまとめておく。 多分、fluent-plugin-bigqueryのメンテをやっている自分ぐらいにしか需要が無いのだろうと思う……。

とりあえず、1日かけて格闘した結果、とりあえず書き込みができるところまでは到達した。

必要なもの

rubygems.orgを見ると分かるのだが、BigQueryのライブラリが1000万件近くDLされているのに対して、storage APIを叩くためのgemの累計ダウンロード数がわずか3万件である。単純計算でRubyでBigQueryを触ろうとしている人の0.3%しか使っていない。それは解説も無いわという感じ。

実装例

require "google/cloud/bigquery"
require "google/cloud/bigquery/storage"
require "google/protobuf"
require "json"

project_id = "<your project id>"
dataset_id = "<your dataset id>"
table_name = "<your table name>"

bigquery = Google::Cloud::Bigquery.new(project_id: project_id)
dataset = bigquery.dataset(dataset_id)
table = dataset.table(table_name)

write_client = Google::Cloud::Bigquery::Storage.big_query_write

def convert_field_schema(parent_name, field, i, builder, struct_fields)
  method =
    case field.mode
    when "REQUIRED"
      :required
    when "NULLABLE"
      :optional
    when "REPEATED"
      :repeated
    else
      raise ArgumentError, "Unsupported mode: #{field.mode}"
    end

  case field.type.to_sym
  when :BOOLEAN
    builder.send(method, field.name, :bool, i)
  when :BYTES
    builder.send(method, field.name, :bytes, i)
  when :DATE
    builder.send(method, field.name, :int32, i)
  when :DATETIME
    builder.send(method, field.name, :int64, i)
  when :DOUBLE
    builder.send(method, field.name, :double, i)
  when :INTEGER
    builder.send(method, field.name, :int64, i)
  when :NUMERIC
    builder.send(method, field.name, :bytes, i)
  when :BIG_NUMERIC
    builder.send(method, field.name, :bytes, i)
  when :STRING
    builder.send(method, field.name, :string, i)
  when :JSON
    builder.send(method, field.name, :string, i)
  when :GEOGRAPHY
    builder.send(method, field.name, :string, i)
  when :TIME
    builder.send(method, field.name, :int64, i)
  when :TIMESTAMP
    builder.send(method, field.name, :int64, i)
  when :RECORD
    inner_type_name = parent_name + "." + camelize_name(field.name)
    builder.send(method, field.name, :message, i, "fluent.plugin.bigquery.table.#{inner_type_name}")
    struct_fields << field
  else
    raise ArgumentError, "Unsupported data type: #{field.type}"
  end
end

def camelize_name(name)
  name.split('_').map(&:capitalize).join
end

def build_protobuf_descriptor(name, fields)
  struct_fields = []
  type_name = camelize_name(name)
  msg_proto = nil
  file_proto = nil
  
  Google::Protobuf::DescriptorPool.generated_pool.build do
    add_file("fluent/plugin/bigquery/table/#{name}.proto", :syntax => :proto2) do
      msg_proto = build_message_descriptor(nil, type_name, fields, self, struct_fields)
      until struct_fields.empty?
        f = struct_fields.shift
        build_message_descriptor(type_name, camelize_name(f.name), f.fields, self, struct_fields)
      end
    end
  end

  {
    msgclass: Google::Protobuf::DescriptorPool.generated_pool.lookup("fluent.plugin.bigquery.table.#{type_name}").msgclass, 
    msg_proto: msg_proto,
  }
end

def build_message_descriptor(parent_name, name, fields, builder, struct_fields)
  type_name = [parent_name, name].compact.join(".")
  message_builder = nil
  builder.add_message "fluent.plugin.bigquery.table.#{type_name}" do
    message_builder = self
    fields.each.with_index(1) do |field, i|
      convert_field_schema(type_name, field, i, message_builder, struct_fields)
    end
  end
  message_builder.instance_variable_get("@msg_proto") # DescriptorProtoを取るために内部のインスタンス変数を直接参照している
end

# tableのスキーマ情報をAPI経由で取得し、Protocol Bufferのdescriptorに変換する
result = build_protobuf_descriptor(table_name, table.schema.fields)
msgclass = result[:msgclass]
msg_proto = result[:msg_proto]

# Nested TypeをBigQueryのAPI形式に合わせる
msg_proto.field[8].type_name = "Inner"

json = JSON.dump({id: "id", insight_id: 1, custom_event_id: 2, name: "hoge", properties: "{\"aa\": \"bb\"}", user_id: 3, tracked_at: Time.now.to_i * 1000000, idfv: "afsdaf", inner: {str_value: "str", int_value: 123}})
# 直接オブジェクトを生成しても良いが、今後の用途のためにJSONからメッセージオブジェクトを生成している
val = msgclass.decode_json(json)

# メッセージオブジェクトをProtocol Bufferのバイナリ形式にシリアライズする
serialized = msgclass.encode(val)

# Bigquery Storage APIのAppendRowsRequestの生成
data = [
  Google::Cloud::Bigquery::Storage::V1::AppendRowsRequest.new(
    write_stream: "projects/#{project_id}/datasets/#{dataset_id}/tables/#{table_name}/streams/_default",
    proto_rows: Google::Cloud::Bigquery::Storage::V1::AppendRowsRequest::ProtoData.new(
      rows: Google::Cloud::Bigquery::Storage::V1::ProtoRows.new(
        serialized_rows: [serialized]
      ),
      writer_schema: Google::Cloud::Bigquery::Storage::V1::ProtoSchema.new(
        proto_descriptor: msg_proto
      )
    )
  )
]

# リクエスト実行
output = write_client.append_rows(data)
output.each do |res|
  p res
end

とりあえず動くところまで持っていっただけのベタ書きのコードなので汚いが、ここまでいければ後は綺麗にするだけなので何とかなるだろう。

ざっくり解説していく。

RubyでStorage Write APIを使う上で非常に面倒な点が、自分でProtocol Buffer形式にデータをシリアライズしなければいけないことと、ProtocolBufferのDescriptorProto(スキーマ定義みたいなもの)を生成しなければならないことの二点である。

Protocol Bufferへのシリアライズ

Javaのライブラリなどでは、ライブラリ自体がテーブルからスキーマを取得し自動的にJSONをProtocol Bufferに変換してDescriptorProtoまで準備してくれるので、JSON形式のオブジェクトを投げるだけで良いのだが、Rubyでは全て自分でやる必要がある。 更にそこにいくつかハマりどころがあり、それを乗り越えなければならない。

上記のコードにおいてはbuild_protobuf_descriptorメソッドがその根幹になる。

基本的にはBigqueryのスキーマからフィールド定義を引っ張ってきて、それを適宜合う形のタイプのDescriptorに変換していく。 google-protobufにはDescriptorを生成するためのDSLが存在するので、それを利用することでRubyコードからDescriptorを定義できる。

しかし、これについてはドキュメントが全然無い。通常Protocol Bufferのスキーマはprotoファイルの書式で定義するもので各言語のコードでどう表現するかはprotocで自動生成したコードによって決まっている。通常利用することが余り無いのでちゃんとしたドキュメントが存在しない。

という訳で、protocで生成したコードを元にソースコードを確認し、使い方を調べる必要があった。

例えば、次の様なprotoファイルを元に生成したRubyコードは下記の形になる。

syntax = "proto2";

package test.pkg;

import "bar.proto";
import "google/protobuf/timestamp.proto";

message Foo {
  message Baz {
    optional int64 num = 1;
  }
  optional int64 id = 1;
  optional string name = 2;
  optional Bar bar = 3;
  repeated string values = 4;
  optional google.protobuf.Timestamp ts = 5;
  optional Baz baz = 6;
}
# Generated by the protocol buffer compiler.  DO NOT EDIT!
# source: foo.proto

require 'google/protobuf'

require 'bar_pb'
require 'google/protobuf/timestamp_pb'

Google::Protobuf::DescriptorPool.generated_pool.build do
  add_file("foo.proto", :syntax => :proto2) do
    add_message "test.pkg.Foo" do
      optional :id, :int64, 1
      optional :name, :string, 2
      optional :bar, :message, 3, "test.pkg.Bar"
      repeated :values, :string, 4
      optional :ts, :message, 5, "google.protobuf.Timestamp"
      optional :baz, :message, 6, "test.pkg.Foo.Baz"
    end
    add_message "test.pkg.Foo.Baz" do
      optional :num, :int64, 1
    end
  end
end

module Test
  module Pkg
    Foo = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("test.pkg.Foo").msgclass
    Foo::Baz = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("test.pkg.Foo.Baz").msgclass
  end
end

これによるとGoogle::Protobuf::DescriptorPool.generated_pool.buildを使ってDSLでDescriptorが定義できるらしい。実装はここにある。 https://github.com/protocolbuffers/protobuf/blob/main/ruby/lib/google/protobuf/descriptor_dsl.rb

BigQueryから取得したスキーマ情報を元に、これらのDSLメソッドを呼び出しているのがconvert_field_schemaになる。 少しややこしいのがRECORD型の扱いで、これに関してはnested typeとしてスキーマを参照する様にして、別のmessageタイプとしてadd_messageを呼び出す様にしている。

この生成したDescriptorからmsgclassを引っ張ってきて、オブジェクトを生成すればencodeメソッドでバイナリシリアライズができる。

ちなみに、BigQueryのどの型がProtocol Bufferのどの型に対応するかに関しても、ドキュメントが見つからなかった。そのため、この対応関係はJavaソースコードの関係がありそうな箇所を読んでパクってきた。

BigQueryへの書き込み

基本的にはWriteClient#append_rowsメソッドに配列に入っているAppendRowsRequestオブジェクトを渡せばいいのだが、ここにトラップが存在する。

このリクエストオブジェクトを作成するためには、Protocol BufferのDescriptorProtoオブジェクトが必要になる。Descriptorオブジェクトではない。 そして、RubyのライブラリではDescriptorからDescriptorProtoを得る手段が無い。Javaにはあるのに。

つまり、RubyでProtocolBufferにシリアライズするために必要なスキーマ情報のオブジェクトが、BigQueryのライブラリが要求しているデータと噛み合っていない。

DescriptorProtoを直接生成できなくは無いが、先に示したdescriptor_dsl.rbに定義されているBuilder DSLの実装を見るとかなりややこしいことをしている。正直、これを再実装したくはない。 なので、上記のソースコードではinstance_variable_getを使って、BuilderがDescriptorを生成した後にBuilderオブジェクト内に残されているDescriptorProtoのオブジェクトを無理矢理引っ張り出している。これはこれでかなりデンジャラスというか悪いことをしている気がするが、こうしない限りはDSLと同等の処理を自前で実装しなおすことになる。

ここまでしてDescriptorProtoを取得しても、まだこれだけでは書き込みは上手くいかない。

DescriptorProtoにnested typeが含まれている場合、DSL上ではpackageとnamespaceを含めたfull qualifiedな名前で型を参照する必要があるのだが、これをこのまま渡すとBigQueryのAPIが型を見つけられなくてエラーになる。 そのため、該当するnested typeからpackageとnamespaceを除外し、シンプルな型名に参照を変換しておく必要がある。 上記のコードでは、そのための変換コードを真面目に書いておらず、実験を成功させるために決め打ちで型名を調整している。

この挙動についても調べた限りでは、ドキュメントが見つからず、自分の場合はJavaのライブラリの実装を読んで何をしなければいけないのかを探り出した。

これで何とか書き込みに成功した。

まとめ

やってみたら使えない訳ではなかったので無いよりは全然マシなのだが、RubyでStorage Write APIを使うにはJavaに比べて実に手間がかかることが分かった。 余り需要が無い気がするが、もしRubyでBigQuery Storage Write APIを使いたい時に、この記事が参考になると良いなと思う。

とりあえず、これでfluent-plugin-bigqueryにStorage Write APIを使って書き込むモードが追加できそうだという取っ掛りを得た。Storage Write APIが使える様になればStream Insertより高いスループットで、しかもデータ量当たりの転送量を半額に抑えられるので、暇を見つけてやっていこうと思う。