WEBrick 1.9.0 として RBS による型情報つきの gem をリリースできたので、そのメモです。

バージョン

動作確認に使ったバージョンはこのあたりでした。

  • ruby 3.3.5
  • rbs 3.6.1
  • steep 1.8.3
  • webrick 1.8.2 → 1.9.0

使い方

rbs_collection.yaml がなければ rbs collection init で作成するなどして用意しておいて、以下のように gems- name: webrick を追加して、 rbs collection installrbs collection update を実行すると、 rbs_collection.lock.yaml が更新されて steep などで型情報が使えるようになりました。

gems:
  - name: webrick

rbs_collection.lock.yaml には以下のように source.type: rubygems で追加されていました。

- name: webrick
  version: 1.9.0
  source:
    type: rubygems

ffi 1.17.0 は https://github.com/ffi/ffi/issues/1107 の問題があるので、 以下のように name: ffiignore: true を設定したものを gems: に追加しています。

gems:
  - name: webrick
  - name: ffi
    ignore: true # https://github.com/ffi/ffi/issues/1107

現状の型情報

typeprof で生成された https://github.com/ruby/webrick/pull/115 やソースコードを参考にしつつ、 rbs prototype rb で生成した *.rbs を更新する方法で、 https://github.com/ruby/webrick/pull/151 として追加しました。

2週間ぐらいかけて一通りざっと対応しただけなので、 まだ https://github.com/ruby/webrick/pull/155 のように間違っている部分や 考慮不足で使いにくい部分もあると思うので、改善案などあれば pull request や issue を 作成してもらえると良さそうです。

型付け中にひっかかった部分

singleton(ClassName)

例外クラスの指定のようにクラスオブジェクト自体が引数になるときは、 singleton(ClassName) のように singleton を使う、 というのが知らないと難しそうだった。

def self.register: (Numeric seconds, singleton(Exception) exception) -> Integer

const_set で定義されている定数

webrick/httpstatus const_set されている定数は以下で補ったので、 VSCode に Steep 拡張機能を入れているときに WEBrick::HTTPStatus::RC_OK: 200 などがホバーで確認できたり、補完がきいたりして便利です。

require 'webrick'

puts WEBrick::HTTPStatus::constants.grep(/\ARC_/).map{"#{_1}: #{WEBrick::HTTPStatus.const_get(_1)}"}

puts WEBrick::HTTPStatus::CodeToError.each_value.map{"class #{_1.name.split(/::/).last} < #{_1.superclass.name.split(/::/).last}\nend"}

method alias chain されているメソッド

Non-overloading method definition of `parse` in `::WEBrick::HTTPRequest` cannot be duplicated(RBS::DuplicatedMethodDefinition)

https.rbs に重複定義があったので、 | ... を追加して、

    alias orig_parse parse

    def parse: (?(TCPSocket | OpenSSL::SSL::SSLSocket)? socket) -> void
             | ...

のように定義して回避しました。

orig_parse に再定義前の型が保存されているわけではなく、 再定義した parse と同じ型になってしまうようなので、 厳密には alias ではなく def orig_parse: 元の型 にした方が良さそうでしたが、 直接使うメソッドではないと思って、そこはがんばらずに自動生成されたままにしました。

いろいろな body

webrick/httpresponsebody に悩んで、コメントの

    # Body may be:
    # * a String;
    # * an IO-like object that responds to +#read+ and +#readpartial+;
    # * a Proc-like object that responds to +#call+.

を参考にして、

    interface _CallableBody
      def call: (_Writer) -> void
    end

    attr_accessor body: String | _ReaderPartial | _CallableBody

にしました。

#read は呼ばれていなかったので、 _Reader & _ReaderPartial ではなく _ReaderPartial だけにしました。

書き込みは write のみだったので、 socket の型は _Writer にしました。

= つきメソッド

= つきのメソッドの返り値でちょっと悩んでしまいましたが、他の RBS ファイルを確認すると右辺の値の型をそのまま書くようだったので、そうしておきました。

singleton

set_redirectsingleton を使って Redirect 系の例外クラスならどれでも受け付けるように

    def set_redirect: (singleton(WEBrick::HTTPStatus::Redirect) status, URI::Generic | String url) -> bot

にしました。

read_body

IO? 型が渡せなくなるらしいという話があったので、 IO socketIO? socket に変更した方がいいかもしれないと思ったのですが、 返り値も String? になってしまうので、とりあえずそのままにしました。 実用上問題があれば、ユースケースと一緒に pull request を作ってほしいです。

最初は void block にしていたのですが void は返り値以外で書ける位置が制限されているらしいので top に変更しました。

    def read_body: (IO socket, body_chunk_block block) -> String
                 | (nil socket, top block) -> nil

Servlet の config や options の問題

AbstractServlet@configHTTPServerFileHandlerHash[Symbol, untyped] で困ったので、

    class AbstractServlet
      @server: HTTPServer

      interface _Config
        def []: (Symbol) -> untyped
      end

      @config: _Config

にしました。

@optionsAbstractServletArray[untyped]FileHandlerHash[Symbol, untyped] なので、 AbstractServlet の方は untyped にしました。

返り値の型

do_GET などが AbstractServlet-> botFileHandler-> void にしたのは型エラーにはならなかったので、継承で返り値がこのように変わるのは大丈夫のようです。

Enumerable の型

rbs prototype rb で自動生成されただけの cgi.rbs に型エラーがあると思って確認すると、こんな感じで include Enumerable[untyped] になっていないからでした。

% cat a.rb
class C
  include Enumerable

  def each
    yield nil
  end
end
% rbs prototype rb a.rb
class C
  include Enumerable

  def each: () { (untyped) -> untyped } -> untyped
end

rbs prototype rb は対応していなくて、 typeprof なら対応してそうでした。 rbs prototype には rbs prototype runtime もあるらしく、そちらなら対応しているらしいです。

位置引数でも &block でも受けとる場合の型

webrick/httpserver.rbs

    def mount_proc(dir, proc=nil, &block)
      proc ||= block
      raise HTTPServerError, "must pass a proc or block" unless proc
      mount(dir, HTTPServlet::ProcHandler.new(proc))
    end

に対応する型として、

    def mount_proc: (String dir, ?HTTPServlet::ProcHandler::_Callable proc) -> void
                  | (String dir, ?nil proc) { (HTTPRequest, HTTPResponse) -> void } -> void

としてみました。

型付け後からリリースまでにひっかかった部分

確認のしやすさの都合で、 bitclust の型付け作業をしている作業ディレクトリで webrick の rbs も作成していました。

そこから ruby/webrick にコピーして pull request を作成しました。

その前に手元で rake build して rake install:local して動作確認していました。

その結果、 manifest.yamlgemspec と同じトップではなく sig/manifest.yaml に置く必要があるとわかりました。

リリースされる gem に rbs ファイルを含めるには webrick.gemspecsig/**/*.rbs などを s.files に追加する必要がありました。

stringiomanifest.yaml に入れるとエラーになって困ったので調べてみると、 https://github.com/ruby/rbs/tree/master/core にあったので、 core のライブラリは不要なのかも、と思って書かなかったらエラーが消えました。 require が必要なのに stringiocore に入っているのは分類ミスのようなので、 そのうち移動するかもしれないようです。

直接のリリース権限はないので、他の標準添付から分離されたり、されつつある gem と同じように https://github.com/ruby/webrick/blob/master/.github/workflows/push_gem.yml が追加されて、タグの push でリリースができました。

最後の Create GitHub releaseGITHUB_TOKEN の secrets の設定ミスがあったらしく、 タグを消して push しなおしたら、gem のリリースの方でリリース済みバージョンということでそこまで進まなかったので、 今回だけ gh release create v1.9.0 --verify-tag --generate-notes は手元で実行しました。

そもそもの経緯

RBS による型付け作業を開始した bitclust が webrick にも依存していて、 webrick の使っている部分だけ型付けをしてエラーがでないようにしていました。

しばらくして bitclust の型付けである程度ノウハウがたまったので、 webrick の型がちゃんとついている方が良さそうと思ったので、 bitclust 自体の作業を中断して、 webrick の方を一気に対応しました。

最後に

webrick は production 環境では使うべきでない、というものなので、一般公開用のサーバーとして使うことはないと思いますが、 限定された環境でのサーバーやソースコードを読む対象としてはまだまだ使われることがあると思うので、 機会があれば webrick の rbs を有効利用してみてください。

Disqus Comments

Kazuhiro NISHIYAMA

Ruby のコミッターとかやってます。 フルスタックエンジニア(って何?)かもしれません。 About znzに主なアカウントをまとめました。

znz znz


Published