テストステ論

高テス協会会長が, テストステロンに関する情報をお届けします.

(akashic report) akashic-storageの紹介

分散ファイルシステム上で動作するS3互換アダプタが作りたいと思っています. プライベート開発はライフワークなので, dm-writeboostに続く中規模以上のソフトウェアを作りたいと思っていました. Scalaで良いコードが書けることをアピール出来れば, キャリアの選択肢も広がります. dm-writeboostの開発はすごく大変でしたが, 評価されることも多いため, それに見合うだけのリターンがありました.

フレームワークとしてはfinchを使いたいと思っています. finchは, Twitterのfinagle-httpの上に, 関数型のテイストでAPIサーバを実装出来るフレームワークであり, 同様にTwitterの人が作っています. 現在のバージョンは0.9.3であり, まだ時々ドラスティックにAPIが変わったりするのと, 型推論が激しく使われているためコンパイルが通らなかった時に原因が全く分からないということもあるのですが, 私は, 将来性のあるフレームワークだと思って採用しました. ソフトウェアの将来性を決める有力は指標は, 開発者が優秀かどうかです. finchの開発者は, さすがTwitterで基盤ソフトウェアをやるだけはあって, 素晴らしいです.

finagle/finch · GitHub

依存ライブラリを見れば, やばいということが分かるでしょうか?

  libraryDependencies ++= Seq(
    "com.chuusai" %% "shapeless" % shapelessVersion,
    "org.spire-math" %% "cats-core" % catsVersion,
    "com.twitter" %% "finagle-http" % finagleVersion,
    "org.scala-lang" % "scala-reflect" % scalaVersion.value,

ドキュメントはほぼなく, 使うためにはコードを読む必要があります. 私は, Gitterで質問をするのとコードを読むのを繰り返しました. コードは全部理解したわけではないですが, 全部入念に読みはしました. finchに不足している機能のPRも行いましたが, これは採用されませんでした. (https://github.com/finagle/finch/pull/441) Endpointという型に対してモナドを実装するということなのですが, 異常に難しいので断念しました. これから紹介するakashic-storageで, モナドが必要ないと最終的に判断したからというのもあります.

S3をfinchの上に実装する上でどういう設計がベストか?など何度か作り直しながら検討して, 今の形に落ち着きました. フレームワークを利用する時は, フレームワークの意図に逆らわず自然な実装をするのが良いです. 安定した意図に依存する方が, 結果としてソフトウェアは安定し, 性能も高くなるからです.

正直にいうと, かなり自信があります. S3は, 多くのAPIを実装しなければいけないため, 基礎設計が大変重要となります. 設計が悪いと, バグりやすくなります. フレームワークの価値は, その上にどういう著名なアプリケーションが実装されたかにより大きく評価されますが, たぶん, akashic-storageはfinchに貢献出来ると思います.

akiradeveloper/akashic-storage · GitHub

Akashic Recordsというのは, この世のすべての情報を記録してある仮想的なストレージのことです. 私はこのロマンチックなアイデアが好きです. このソフトウェアにも, どういう使い方をしても壊れない堅牢なものにしたいという思いを込めて, このような名前をつけました.

今はまだ, バケットを作ってそれをリストすることしか出来ません. 少ないプライベートな時間にしか実装出来ませんから, 素早く進捗することは難しいと思いますが, 地道に1APIずつ作っていきます(今はPutObjectを実装中)

少しだけアーキテクチャの説明をします.

$ tree
.
├── Server.scala
├── ServerConfig.scala
├── admin
│   ├── Error.scala
│   ├── GetUser.scala
│   ├── MakeUser.scala
│   ├── TestUsers.scala
│   ├── UpdateUser.scala
│   ├── User.scala
│   └── UserTable.scala
├── app
│   └── package.scala
├── auth
├── files.scala
├── patch
│   ├── Bucket.scala
│   ├── Commit.scala
│   ├── Data.scala
│   ├── Key.scala
│   ├── Patch.scala
│   ├── PatchLog.scala
│   ├── Tree.scala
│   ├── Upload.scala
│   ├── Uploads.scala
│   └── Version.scala
├── service
│   ├── Acl.scala
│   ├── CallerId.scala
│   ├── Error.scala
│   ├── GetServiceSupport.scala
│   ├── Meta.scala
│   ├── PutBucketSupport.scala
│   ├── PutObjectSupport.scala
│   ├── RequestId.scala
│   ├── Task.scala
│   ├── Versioning.scala
│   ├── dates.scala
│   └── package.scala
└── strings.scala

patchパッケージは, 複数ノードから同時書き込みが起こった時の対処として, 上書きが発生しないようにファイルシステムに追記していき, かつ, 書き込みが途中で失敗した場合にその中途半端な書き込みを破棄する仕組みを実装するものです.

serverパッケージは, その書き込みに何を乗せるか?やAPI実装など, S3に関連したものです. Taskというのは, 途中で処理が失敗した場合にリトライする仕組みです. 各APIは, ファイルストレージに対する全副作用をCommitによって包まなければならないため, どこかでこけた場合はそれは破棄され, リトライします. リトライは, 原理的にいつか必ず成功します(あるいはファイルシステムが何も応答しないため一切副作用が発生しないまま繰り返し続けるか)

adminは, ユーザ管理に関するものです. HTTPによってユーザを作成したり変更することが出来ます.

trait PutBucketSupport {
  self: Server =>
  object PutBucket {
    val matcher = put(string ? RequestId.reader ? CallerId.reader).as[t]
    val endpoint = matcher { a: t => a.run }

    case class t(bucketName: String, requestId: String, callerId: String) extends Task[Output[Unit]] with Reportable {
      def resource = bucketName
      def runOnce = {
        val created = Commit.Once(tree.bucketPath(bucketName)) { patch =>
          val bucketPatch: Bucket = Bucket(patch.root)
          bucketPatch.init
          Commit.Retry(bucketPatch.acl) { patch =>
            val dataPatch = patch.asData
            dataPatch.writeBytes(Acl.t(callerId, Seq(
              Acl.Grant(
                Acl.ById(callerId),
                Acl.FullControl()
              )
            )).toBytes)
          }.run
          Commit.Retry(bucketPatch.versioning) { patch =>
            val dataPatch = patch.asData
            dataPatch.writeBytes(Versioning.t(Versioning.UNVERSIONED).toBytes)
          }.run
        }.run
        if (!created) failWith(Error.BucketAlreadyExists())
        Ok()
          .withHeader(("x-amz-request-id", requestId))
      }
    }
  }
}

例えば, PutBucketはこのような形をしています. matcherというのは, パスマッチングとリクエストからのデータ読み出しを行うものです(Reader Monadです). PutBucketSupportというtraitはServerに対するmix-inとして作られています. この設計は妥当だと思います. Serverクラスにべたっと全APIを実装するというコードのうちAPI実装を分離したと考えればよいからです. その中にさらにobject PubBucketを作り, 実際の処理実行体であるtクラスを実装するという設計パターンです(tは, OCamlではよく使うのですが, Scalaではあまり使われないです. 私はScalaでもこのイディオムを好んでいます)

結果として, Serverクラスではこのように書くことが出来ます. エレガントでしょう!

  val api =
    adminService :+:
    GetService.endpoint :+:
    PutBucket.endpoint :+:
    PutObject.endpoint

Reportableというのは, S3ではエラーが起こった時にXMLをユーザに返すのですが, エラー処理をするハンドラに返すための情報を持っていることを保証させるtraitです. もしS3関連のエラーが起こった場合は, Reportable#failWithによって例外を飛ばします. これはServerにおいて, Endpoint#handleで捕捉されます.

明確な日程はないですが, 2末あたりには第一弾のリリースを出来ればと思います. 協力してくれる人は歓迎します.