aws-sdk-ruby配下すべてのgemにRBSが含まれた状態でリリースされました

みなさまに、RBSに関する重要なニュースを発表できることを嬉しく思います。

私の目標の一つにはRBSを当たり前の世界にするというものがあります。

この目標に対して大きなインパクトを残せたことに大変興奮しています。*1

aws-sdk-ruby配下すべてのgemにRBSが含まれた状態でリリースされました

こちらは公式blogからのアナウンスです。

aws.amazon.com

aws-sdk-rubyrubygemsでの累計ダウンロードランキング2位に乗るほどの人気gemです。(aws-sdk-core)

aws-sdk-rubyは現状370以上のgemのあつまりです。 このすべてのgemにRBSが含まれた状態でリリースされました。 そうです。すべてです。

rbs v3.4.0以上でご利用いただけます。

steep + vscodeの例。etagがStringであることがわかる

え、なにがどうなるの?

具体的には配信されるgemのディレクトリーにsigというディレクトリーが生えて、中にRBSファイルが配置されるだけです。ライブラリーの挙動は何も変わりません。productionでも安心してご利用いただけます。

RBSをプロダクトに導入していない場合

何も変わりません。RBSを導入しましょう!

RBSをプロダクトに導入している場合

まずrbsをv3.4.0以上にあげましょう。それ以前だとgem_rbs_collectionのaws-sdk-coreが優先して読み込まれてしまうので、最悪RBS読み込みに失敗してしまいます。

まさかとは思いますが、万が一rbs collectionを使用していない場合は全く問題ないです。

たくさんのaws-sdk-ruby gemを使用している場合、RBSの読み込みが遅くなるかもしれません。 使わなそうなものはrbs_collection.yamlignore: true指定すると良いかもしれません。

なんでそんなに詳しいの?

私が実装を担当しました。何かおかしかったら直すので教えてください。

https://github.com/aws/aws-sdk-ruby/pull/2961

なにがおこったの?

2年くらい前

RBS利用当初から、ネットワーク等のI/Oを使用するプログラムと型は相性がいいとにらんでいました。 テストコードだと、外部通信だのモックだので複雑になったり、そもそもおろそかになりがちだったりするところですが、型ならコードを書いた瞬間からレスポンス形式の不一致が分かったりします。そんなわけでaws-sdk-rubyとは相性が良いだろうと考えていました。

そこで、私はgem_rbs_collectionというリポジトリーでaws-sdk-rubyに対するRBSを生成するという取り組みを行なっていました。

aws-sdk-rubyRubyコードのほとんどはJSONから自動生成されており、その仕組みに乗っかってRBSも出力してみようというアイデアです。

初期実装に2ヶ月くらいかかりました。※毎日の寝る前の1時間とかでやってる趣味活動です

これ自体はaws-sdk-rubyの中の人は知らず、私が勝手にやっている活動でした。

https://github.com/ruby/gem_rbs_collection/pull/118

2ヶ月くらい前

RubyConf2023 in San DiegoでRBSの作者である id:soutaro さんの元にaws-sdk-rubyのメンテナが「aws-sdk-rubyRBSを入れたいんだよね」と声をかけたそうです。

soutaroさんは私の活動を知っていたので、すでにやってる人がいるよとgem_rbs_collectionを紹介したそうです。

という話を私がsoutaroさんから聞き、「それなら、ようし ひとつ やってみるかな」とissueを立てました。

https://github.com/aws/aws-sdk-ruby/issues/2950

やるぞ宣言です。

2週間くらい前まで

既存の実装を乗せ替えるだけでしょ。と思っていたら既存の実装は先方の理想の基準に達しておらず、長い開発の日々が始まりました。※毎日の寝る前の1時間とかでやってる趣味活動です

2ヶ月の間コードを書く→レビューを受けるを繰り返しました。

95のコメントと57ファイルの変更、1,657行の追加と22行の削除が行われました。

RBSの整合性を確認し、実際のライブラリーの挙動とRBSの表現で乖離がないか確認するテストも追加して、CIとしてタスクも追加しました。

メンテナがメンテナンスしやすいようにコードの分離関係も気を使いました。

もちろん全世界で使われる重要なライブラリーなので相当なプレッシャーでした。

ドキュメントひとつとっても、先方と議論を交わしました。

タイムゾーンが違いすぎるので、議論も1コメントしては次の日まで返事を待つというサイクルでした。

rbs本体側の問題もあったのでいくつか修正しました。※そのためrbs v3.4.0以上でないと利用できません。

おかげさまで満足できる完成度に至りました。

S3のget_objectのRBS(左)と公式ドキュメント(右)の比較

伝わりますか?オプションの順番までドキュメントと一緒インデントもあって読みやすい。狂気の完成度と言えるでしょう。(言えない)

ちょっと前

https://github.com/aws/aws-sdk-ruby/commit/5b831e8eaa27b453fe6c1ca2e38b69ec39c58326

かくして、4,276 changed files with 983,107 additions and 2,101 deletions.という豪快なcommitによって、全サービス370gemで同時リリースが行われました。

RBS界隈へ与えたインパクトは大きいと思っていて、RBSがgemに含まれるのが当たり前の世界に近づいたとさえ思っています。最近RBSを同梱しているgemが増えたと耳にします。そこに世界トップクラスのgem 370個にもRBSが入ったとなれば、さらにRBSをgemに同梱する流れは加速するんじゃないかと考えています。

RBSの工夫点

RBSで型をつける上でのTipsを記憶があるうちに残しておきます。 型チェッカーはsteepを想定しています。RubyMineでは大きな問題はないですが、ちょっと違う挙動らしいです。

RBS::Testを使用

RBSの正しさを検証するためにRBS::Testを使用しました。

RBS::Testが何なのかは以前に記事を書いたのでこちらも合わせてどうぞ。 RBSのテスト方法4パターン

RBS::Testを使ってaws-sdk-coreとaws-sdk-s3のrspec上でメソッドの引数と返り値を監視して、定義通りの挙動かチェックしています。 RBS::Testはあんまり使用例がないらしく、大きな使用例を世界に示せたと思います。

実装は短いrake taskになっているので参考にどうぞ。

https://github.com/aws/aws-sdk-ruby/blob/5b831e8eaa27b453fe6c1ca2e38b69ec39c58326/tasks/rbs.rake

rspecのタグを使って、後述するレアケースはスキップするようにしていますが、レスポンス構造の正しさなど基本的な部分は十分保証できています。

ほとんどのgemは自動生成された少量のテストケースしかないのですが、aws-sdk-s3は特別にテストケースが多くユーザーも多いので、これさえ通れば他のサービスもカバレッジ的にほぼカバーできているはずです。

引数の型

aws-sdk-rubyの特にClient系メソッドは以下のように書けます。※名前は私が適当につけたものです

client = Aws::S3::Client.new

# keyword argument style
client.get_object(bucket: 'bucket', key: 'key')

# record argument style
client.get_object({ bucket: 'bucket', key: 'key' })

# hash argument style
args = { bucket: 'bucket', key: 'key' }
client.get_object(args)

そのため、次のようなRBSを書くと、record argument styleとhash argument styleでエラーになります。

def get_object: (bucket: String, key: String) -> Response

かといって、こう書いてもhash argument styleでエラーになります。 Hashオブジェクトは変数宣言時にrecord型からHash型になるっぽい?のです。※将来的にSteepの挙動が変わるかもしれません。

def get_object: ({ bucket: String, key: String }) -> Response

そして次のように書くと全パターン通りますが、引数の型情報がなくなってしまいます。

def get_object: (Hash[Symbol, untyped]) -> Response

最終的には次のように、keyword argument styleとhash argument styleをまぜました。

def get_object: (bucket: String, key: String) -> Response
              | (Hash[Symbol, untyped]) -> Response

最終的にはhash argument styleで受けるのでエラーは発生しません。しかしながらkeyword argument styleがコードを書いているときにサジェストされるのでコーディング時のサポートになります。 しかもRubyMineではkeyword argument styleの方が採用されて引数の型チェックができるっぽいです。すごい。本当かどうか確かめていません。

返り値の型

Delegatorの表現

RBSでは、同じclassなのに使えるメソッドが変わるDelegator class系の表現は難しいです。 aws-sdkの各Clientレスポンスデータは#dataで取得でき、かつ#dataを省略すると#datadelegateされます。

resp = Aws::S3::Client.new.get_object(...)
resp.class #=> Seahorse::Client::Response
resp.data #=> Types::GetObjectOutput (レスポンスデータ)
resp.data.etag #=> String
resp.etag #=> String (dataへdelegateされている)
resp.successful? #=> bool (各レスポンス共通で使えるメソッドもある)

こういうclassのRBSはどう書けばいいのでしょうか?そのままSeahorse::Client::Responseを返しても、Seahorse::Client::Response#etagメソッドを持っていません。class GetObjectOutput < Seahorse::Client::ResponseのようにRBSを定義しても、現実とclassが異なるのでRBS::Testは落ちてしまいます。

今回はinterfaceを使って解決してみました。 イメージとしてはこうです。

interface _ResponseSuccess[DATA]
  def data: () -> DATA # レスポンスデータ
  def successful?: () -> bool # 共通メソッド
end
interface _GetObjectResponseSuccess
  include ::Seahorse::Client::_ResponseSuccess[Types::GetObjectOutput] #=> 共通メソッドをとりこみつつ、dataでレスポンスデータを取得できるように
  def etag: () -> ::String #=> delegateするメソッドはinterfaceのメソッドとして生成しちゃう
end
def get_object: (...) -> _GetObjectResponseSuccess

RBS::Testでは、interfaceならメソッド名が存在しているかと、メソッドの引数の個数ぐらいしかみません。 かつsteepなどの型チェッカーでも正しい型が扱えます。

SuccessとErrorでレスポンスの型をわけるという割り切り

Clientのレスポンスでは、問題があると普通は例外をraiseするのですが、raiseしないオプションを使用することもできます。こうなると#etagなどは呼び出せないので使えるメソッドが変わってしまいます。よってRBSとしては正しくなくなってしまいます。

この問題へは、raiseしない場合はレアケースと割り切って、レスポンスは全て成功する前提で型をつけています。 もしレアケースを使いたい場合は、型アノテーションを使ってエラーパターンを指定してあげてください。

client = Client.new(raise_response_errors: false)
# @type var response: ::Seahorse::Client::_ResponseError
response = client.operation()
response.error.message

レアケースなのでちょっと難しい対応にはなりますが、多くの場合でメリットを享受できるので実践的な割り切りといえます。

Resourceの型

Resource系は型付けと相性が良いようで、画像のように複雑なメソッドチェインしても型情報を失いません。

aws-sdk-rubyの人たち

GitHub上では3名で管理しているっぽくて、少人数で大きなプロジェクトを管理するという責任あるお仕事をされていました。 しかしながら、そもそもRBSを導入したいと思っていたことや、PRでも好意的にサポートしていただいてレビュー体験は非常に良いものでした。みんなプログラミングが好きなんだろうな。 最初は1サービスに導入して様子を見るのかな?と思っていたら、いきなり全サービスに導入する話になっていて、豪快さも持っています。さすが世界トップクラスのライブラリーのメンテナという感じでした。感謝。

さいごに

aws-sdk-rubyの型は、利用頻度が高そうなものに絞って型をつけているので、結構主観が入っています。 例えばAsyncClientはまだ対応していませんし、coreは全て手書きなので、まだまだ型が足りないでしょう。 つまりはコントリビュートチャンスです。みんなでこのビッグウェーブに乗っていきましょう!正直一人は心細い!

*1:言ってみたかっただけですすみません