入社初日の仕事がOSSへのコミットだった

株式会社BizReachに今年の3月にバックエンドエンジニアとして入社した新米社員の渡部未来(@ababupdownba)です。

入社してすぐ、頑張って仕事を覚えなければ!と意気込んでいたところ、同じ事業部の先輩エンジニアである石橋さんが、「僕のデザートあげるよ」と笑いながら声を掛けてきました。頭の中が「????」でいっぱいになったことを今でも鮮明に覚えています。

「デザート」の内容を聞いてみると、プロダクトで使用しているplay2-handlebarsと言うライブラリのバグを修正する、というものでした。実は石橋さんと僕はScalaコミュニティを通じてビズリーチ入社前から交流がありました。「デザートをあげる」とは、Scalaに熱い思いを持つ僕にScalaに関するディープな問題解決をさせてやろう、と言う石橋さんの粋な計らいだったのです!

早速石橋さんにplay2-handlebarsの作者である別事業部の小林さんとコンタクトを取り、入社早々ライブラリのバグを修正するという戦いに身を投じたのでした・・・!

play2-handlebarsとは

play2-handlebarsは弊社で開発されたOSSのライブラリです。JavaScript製のテンプレートエンジンHandlebars.jsをJavaに移植したHandlebars.javaを、さらにWebフレームワーク Play Framework (Scala版、以下Play)に対応させました。
Playで使われるテンプレートエンジン Twirl ほど型安全ではありませんが、高速に動作します。さらに Handlebars.js と同一の文法で、コンパイルも必要としないため、フロントエンドエンジニアにとっても馴染みやすいものとなっています。

Handlebars.js

簡単にコード例を紹介します。以下のようにHandlebarsオブジェクトにテンプレート名とテンプレートで使う値を渡してあげるだけで使うことができます。

views/simple.hbs

Hello {{who}}!

Application.scala

package controllers

import play.api.mvc._
import jp.co.bizreach.play2handlebars.HBS

object Application extends Controller {

  def simple = Action {
    Ok(HBS("simple", "who" -> "World"))
  }
}

発生していたバグ

さて、どのようなバグが発生していたのでしょうか?

play2-handlebarsでテンプレートに複数の値を渡す時にはListMapを使用できます。ただScalaを使っている以上はcase classを使いたくなるのがScala使いの性です。play2-handlebarsはもちろん対応していました。

しかし、Scala2.11から2.12に切り替わったときにcase classで渡した値が「ズレ」たり、表示されなくなったりする、と言う問題が発生しました。

どういうことかというと、例えば以下のようなcase classが定義され、

case class Hoge(
  fuga: Fuga,
  doubt: String
) {
  lazy val applicationIdAsID: Long = 0L
}

case class Fuga(name: String)

次のようなインスタンスが生成されているとします。

Hoge(Fuga("Scala"), "doubt")

このとき次のような指定をした場合、どのような表示結果を期待するでしょうか。

{{hoge.doubt}}

当然”doubt”と表示されることを期待しますが、実際には何も表示されませんでした。
なんと次のような指定をしたときに、“doubt”が表示されたのです。

{{hoge.fuga}}

これは明らかに変数と値の対応が「ズレ」ています。
さらに深刻なことに、lazyフィールドの値{{hoge.applicationIdAsID}}どうやっても表示することが出来なかったのです。

原因

なぜ変数と値の対応がズレたのでしょうか?
結論を先に言うと、play2-handlebarsがcase classMapに変換しているところに原因がありました。具体的に説明していきましょう。

play2-handlebarsでは、Handlebars.javaをラップしているためcase classをそのまま使用することはできません。そのためMap[String, Any]へ変換する必要があり、その変換は以下のようなメソッドでおこなっていました。

private def productAsMap(product:Product): Map[String, Any] = {
    product.getClass.getDeclaredFields
      .map(_.getName)
      .zip(product.productIterator.toList).toMap
}

引数のProductは、テンプレートに渡すcase classです。この処理は、classからリフレクションを用いてフィールド名と値のリストを取り出しzipすることでMapを作り出しています。

調査した結果、Scala2.12では以下のように挙動が変わり、それが問題を引き起こしていることがわかりました。

  1. Class#getDeclaredFieldsのフィールドの取得順序が定義順ではなくなった
  2. Product#productIteratorメソッドを用いたlazyフィールドの値が取得できなくなった

1により前述の「ズレ」が発生し、2によりlazyフィールドの値{{hoge.applicationIdAsID}}の表示ができなくなったのです。

解決に向けて試したこと

先輩エンジニアの石橋さんや小林さんに意見を聞いた結果、問題を解決するために取れる選択肢は以下の3つになりました。

  • Scalaコンパイラに仕様変更のIssueを立て、フィールドの取得順序が保証されるようにする
  • Handlebars.java側を大改造する
  • play2-handlebarsのコードを改善する

Scalaコンパイラに仕様変更のIssueを立て、フィールドの取得順序が保証されるようにする

ScalaコンパイラにIssueを立てたところ、次のことが確認できました。

  • フィールドの取得順序が保証されていないのは仕様であること
  • 現状のJavaリフレクションを利用したやり方はアンチパターンで、Shapelessなどのジェネリックプログラミングを利用した手法でやるべきであること

GitHub Conversation

フィールドの取得順序が保証されていないのは仕様であることが確認できたので、既存のコードの改修を進めるべきだと判断しました。

Handlebars.java側を大改造する

既存のコードの大本であるHandlebars.javaにShapelessやScalaマクロを適用させる手段を考え付きましたが、あまりにも変更箇所が膨大なため、このアプローチも断念することにしました。

play2-handlebarsのコードを改善する

残る選択肢は「play2-handlebarsのコードを改善する」です。
もともとフィールドの名前と値を別々に取得し結合していたアプローチから、フィールドごとに名前と値を取得するアプローチを試みました。

private def productAsMap(product: Product): Map[String, Any] =
  product.getClass.getDeclaredFields.map(field => {
    field.setAccessible(true)
    (field.getName -> field.get(product))
  }).filter { case (name, value) => name.startsWith("$") == false }.toMap

このアプローチにより変数と値がズレることはなくなったのですが、肝心のlazyフィールドが結果に存在しないことに気付きました。
どうやらJavaから見た場合には、lazyはフィールドとして定義されてはいないようでした。

窮地に立たされたが、、、

窮地に立たされた私でしたが、もう一つのScala版HandlebarsであるHandlebars.scalaを参考にしてみては?というアドバイスを受けました。

ソースを解析していった結果、以下のようなアプローチで無事lazyフィールドの値を取得することに成功しました。

private def productAsMap(product: Product): Map[String, Any] = {
  val exclude = Seq(
    "canEqual",
    "unapply",
    "hashCode",
    "productElement",
    "productIterator",
    "productArity",
    "productPrefix",
    "wait",
    "toString",
    "getClass",
    "notify",
    "notifyAll"
  )

  product.getClass.getMethods().filterNot { m => exclude.contains(m.getName) }
    .filterNot { m => m.getName.indexOf("$") >= 0 }
    .filterNot { m => m.getReturnType.toString == "void" }
    .filterNot { m => (m.getModifiers & Modifier.PUBLIC) == 0 }
    .filter { m => m.getGenericParameterTypes.length == 0 }
    .map(m => (m.getName -> m.invoke(product))).toMap
}

これはクラスからメソッドを全て抽出し、不要なメソッドを削り落としてinvokeし値を取得するというかなり力技であった上に、自分で導き出した手法ではないのが残念でしたが、無事問題を解決に導くことができ満足することができました。

学んだこと

今回は偶然知り合いだった石橋さんと職場が一緒になったため振られた仕事でしたが、知らない仕事に臆さず積極的にチャンスを拾いに行くことが大きな経験に繋がるということを強く実感しました。新人だから荷が重いなとならずに快く仕事を振っていただいた石橋さんに感謝です。

また、一人で抱え込まずに有識者に積極的に尋ねることも重要だと思いました。特に社内の人は質問をして嫌な顔をする人は一人も居ません。また専門性の高い人が多く、成長するにはもってこいの場だと改めて実感しています。

文中にでてるひとより

ある日、突然知らない人からSlackで声がかかったんです。誰だ一体と思いつつ、石橋さんと少し前に会話していた課題を引き継いでくれたようなので、渡りに船ということでお願いしたのですが、あっという間に対応してくれました。Scala言語自体のGitHubにissueを投げる 暴挙 行動力も頼もしかったです (笑)。 play2-handlebars は社内では実績があるのですが、一方、安定していてたまにしかメンテしないので、責任の所在も曖昧になりがち。こういう主体的な動きは本当に助かります! 〜 小林(@scova0731)

期待のスーパールーキーとして入社を心待ちにしていました。 入社のタイミングで、チームはプロジェクト分割の作業をしており、開発環境と検証環境も大規模な改善中であったため、既存の受け入れ作業用ドキュメントもそのままでは使えない状態でした。そこで環境に依存せず作業できて簡単すぎない適度な難易度のタスクとしてお願いすることにしました。デザートとは暫定対応済みでリリース後に落ち着いてやろうと思って楽しみにとっておいたIssueの意図です。 原因は特定できていたものの修正は想定より難しい対応が必要になりましたが、超速で完了させてくれて助かりました! ブログの執筆も快く引き受けてくれて感謝です。 〜 石橋(@cactaceae)