ストレージのテストフレームワークをScalaからRustに移植した感想

Rustのケーススタディである。

おれはdm-writeboost というソフトウェアの開発者なのであるが、 その昔、このモジュールのテストを書くためのフレームワークをScalaで書いた。

writeboost-test-suite はwriteboostと言ってるが、事実上device-mapper一般のテストフレームワークであり、 この中でwriteboostのテストを書いている。

writeboostのテストとは何なのか。 これは例えば、ブラックボックステストであれば簡単なものとしては 書いたものが読めるかとか、ファイルシステムをmountして適当に読み書きしたあとにunmountしてxfs_repairで ファイルシステムが壊れていないかを確かめるとかいうもののことだ。

もちろんこんな簡単なものばかりではない。 実際にはほとんどは、内部の動作を把握した上でのホワイトボックステストを書いている。 こういうIOをした場合にはキャッシュヒットしないはずだとか、IOのパス情報を内部で保存していて、 それを外から取得して確認するという感じのテストを書いている。

このフレームワークを使ってたくさんのバグを潰すことが出来た。 バグ報告をもらって、報告者と一緒に出来るだけシンプルなリプロデューサを作ってからバグフィックスに取り組んだこともある。

一例として、このフレームワークを使うと、こんな感じでテストを書くことが出来る。

  test("no read caching") {
    slowDevice(Sector.G(1)) { backing =>
      fastDevice(Sector.M(32)) { caching =>
        Writeboost.sweepCaches(caching)
        Writeboost.Table(backing, caching).create { s =>
          import PatternedSeqIO._
          val pat = Seq(Read(Sector.K(4)), Skip(Sector.K(4)))
          val pio = new PatternedSeqIO(pat)
          pio.maxIOAmount = Sector.M(16)
          // def run() = Shell(s"fio --name=test filename=${s.bdev.path} --io_limit=16M --rw=read:4K --bs=4K --direct=1")
          pio.run(s)
          val st1 = Writeboost.Status.parse(s.dm.status())
          pio.run(s)
          val st2 = Writeboost.Status.parse(s.dm.status())
          val key = Writeboost.StatKey(false, true, false, true)
          assert(st2.stat(key) === st1.stat(key))
        }
      }
    }
  }

ScalaではおなじみのLoan Patternを使い、確保したデバイスはスコープを抜けた時に解放される。 一番下のスコープでは、HDDとSSDを使い、writeboostデバイスを作って、 これに対して決まったパターンのディスクIOを与えて 最後にパス情報でアサートをして、リードヒットしていないことを確かめている。 当然、スコープを抜けるとwriteboostデバイスは解体される。

大変読みやすい。非常によく出来ている。 こういうものをちまちまシェルスクリプトで書くととてもメンテ不能なものになる。 Raftライブラリのlolでもやっていることだが、 専用のテストフレームワークを作ってしまうことは、結果として報われることが多いように思う。 ノーテストは悪ですらなく、もはやギャグだ。

大変うまく出来たものだがしかし不満があった。 最大の不満は言わずもがな、Rustで書かれていないことだ。 おれはもはや、Scalaを愛していない。それどころかゴミだとすら思っている。 こんなゴミをもう使いたくない。これが強いモチベーションとなり、おもむろにRustへの移植を始めた。

もちろん、Rustへ移植することによるメリットは見えていた。 その一つは、JVMが介在しないこと。テストを書く上で、JVMの存在が邪魔になっていることがあり、 この糞野郎のために書きたいテストが正しく書けなかった。 この問題には当時から気づいていた。

ではなぜ当時、Scalaで書いたのか。 それは、Rustがまだよちよち状態であったというのもあるし、 おれは当時、仕事でもScalaを書いていて、これが一番おれにとって書きやすい言語だったからだ。 というかぶっちゃけていうと、当時はそんなにRustが書けなかった。 2015年のことだ。 本もなかったし、Rustの開発例なんかServoくらいしか存在しなかった。

もし、Rustで書き始めていたら、テストプログラム自体に確信が持てず、 何をしているのかよくわからない事態になっていただろう。 だから当時の選択としては間違ってはいなかった。 しかし時は流れ、おれのRust力はかなり高まり、かなり確信を持ってRustへの移植を始めることが出来た。

その結果、出来たものがこれになる。 まだ基盤の部分しか出来ていないが、基盤が出来てしまえばテストを移植するのは時間の問題でしかない。 興味があればコードを読んでみるといい。1000行もない。

device-mapper-tests

ScalaからRustに移植するに当たって、当然だが、Loan Patternはdropで実現することになる。 これは直感的には、ネストをなくして一直線なコードを書けるようになるし良いことだろうと思ったが、 システムのリソースを操作するクラスを書くことは自明ではなかった。 例えば、dropされる順番を意識しなければいけない。 調べてみると、Rustの構造体のフィールドは、暗黙的には宣言順にdropされる。 明示的にはManuallyDrop というクラスで包むことで、独自のdrop内で明示的なコントロールをすることが出来る。 これを知らなかったので少しだけ嵌った。

他に設計で苦労した点としては、Rustでは静的ディスパッチがデフォルトなため、仮想デバイスの抽象型の設計が難しかった。 Scalaから直接書き写せば良いというものではなく、再設計する必要があった。 Rustには特殊化がまだないことも、設計に影響した。

しかし、Rustで開発をしたことがある人ならきっと経験があるだろうが、 こういう厳しい制約の中で設計するから設計は結局簡素でわかりやすいものになる。 今となっては、Scala版の設計は不必要にわかりにくく、ゴミだと思う。

その他、ライブラリとしては、コマンドの発行のために cmd_libを使った。 Rustはコマンドの発行のために標準ではCommandビルダーを使うがこれがめんどくさい。 cmd_libを使うと、Rustの中でコマンドをそのまま書くことが出来る。 このライブラリは問題なく動いたから、使っていいと思う。

基盤ソフトウェアだけでなく、 テストもコマンドもすべてRustで書こう。


このエントリーをはてなブックマークに追加

See also