とある自社サービスのパッケージングの話

前の記事から大幅に時間をあけてしまった@mazgiです。

弊社ではDSPサービスSphereの他に Sphere Paper という紙広告をつくるWebサービスを運営しています。
Webの管理画面から画像をアップロードしたりテキストを入力すると紙広告の原稿ができあがるというちょっと面白いWebサービスです。
また配布時には独自のエリアターゲティング技術を活用しています。

このSphere Paperは開発コードネームFSSというScala製のいくつかのアプリケーションで構成されさくらのクラウド上で運用されています。
今回はこのプロダクトのシステム構成とパッケージング+deployについてご紹介します。
あと本番サービスでGentoo Linuxを使っている日本の会社の一員としてGentooの話を書きます!

サーバーについて

サーバーは基本となる構成を作ってさくらのクラウドの機能で複製します。
複製して起動するとすでに下記のような構成で動く状態になっています。
(サーバー構成についてはこちらの記事にも少し書いていますのであわせてご覧ください。)

仕事で扱うサーバーは物理/クラウド両方ですが、やっぱりクラウドサーバーは環境の複製が楽ですね!
さくらのクラウド快適です♪

アプリケーションとシステム構成について

FSSのシステム構成はこのようになっています。

f:id:maverick-techblog:20190801141455p:plain

またFSSを構成するアプリケーションは下記の3つです。

WebView

いわゆる管理画面のView側です。
Single Page Applicationとして作られており、広告の入稿や管理・紙広告原稿のプレビュー等が行えます。
弊社のフロントエンド開発環境としては標準的なSlim, Sass, CoffeeScript, AngularJSで実装されています。
deployに関連する点としてビルドにMiddlemanを使用するためRubyが必要です。

WebAPI

いわゆる管理画面のWebAPI側です。
Scalaで開発されており、WebViewからのリクエストを受けデータのCRUDを行い、結果をJSONや画像で返します。
Play FrameworkSkinny-ORMを使用して実装されています。
パッケージング時にはsbtを使用します。実行には当然ながらJVMが必要です。

RenderingServer

入稿データから紙広告の原稿を生成します。
こちらもScalaで開発されており、WebAPIと連携してRGB→CMYK変換や組版処理(!)を行い結果を保存します。
WebAPIと同じくパッケージング時にsbtを使用します。
実行にはJVMはもちろんですがImageMagickも必要です。

使用ミドルウェア

ミドルウェア/ツール/実行環境として下記を使います。

  • nginx
    • WebViewおよびWebAPIのフロントで使います。
  • JDK
    • WebAPI, RenderingServerともに1.8を使います。
  • sbt
    • WebAPI, RenderingServerともに0.13.xを使います。
  • Ruby
    • WebViewのビルドに使います。
    • 以前は2.1以上を使っていました。
    • あるタイミングから2.2以上を使うようにしました。
  • ImageMagick
    • RenderingServerで使います。
    • インストール時のオプションでLittle CMSに対応するようにします。
  • MariaDB
    • 原稿データ以外の永続化に使います。
  • MongoDB
    • 原稿データの永続化に使います。
    • レプリケーションおよびシャーディングしています。

パッケージングとdeployについて

各種ミドルウェアや実行環境の構築は大抵のLinuxディストリビューションであれば、パッケージ管理システムが整備されているおかげでそれほど苦になりません。
パッケージ管理システムの流儀に法って yum installapt-get installemerge すればインストールできますし、 /etc の中に設定ファイルが配置され大抵は少し編集すれば動作します。
ディストリビューションのメンテナーの皆さまのおかげですね。

一方、自社アプリケーションのdeployはどうでしょうか?
deployを繰り返す中で「必要なJDK/JREのバージョンがインストールされてる?」「このPull Requestがマージされると必要なRubyのバージョンが2.1から2.2に上がるけど大丈夫?」「ImageMagickをインストールするときにLittle CMS対応忘れてた!」「今回から設定ファイル増えたんだけど」などなど...色々な問題が待ち受けています。

ディストリビューションで提供されるパッケージのように、作ったアプリケーションも
「インストール/アップデート時に依存関係を解決してくれればいいのに!」
そして 「デフォルト設定や雛形が自動的に配置されればいいのに!」
...そう思いませんか?
私はずっとそう思っていました。

そもそもディストリビューションで提供されるパッケージと自分たちが開発しているアプリケーションのインストール方法やアップデート方法が違うことに意義はあるのでしょうか?
そう、自分たちで開発しているアプリケーションも パッケージングしてしまえば良い ではないですか!

しかし1日にいくつものcommitが生み出される自社アプリケーションでCIが通ってmasterにブランチがマージされるたびにパッケージを作るには工夫が必要です。
FSSでは全サーバーがGentoo LinuxということもありPortageパッケージ管理システムを使ってアプリケーションパッケージを作成しています。

Portageのパッケージはebuildという形式で作成しますがその実態はインストール/アップデートの手順を書いたBashスクリプトです。Portageのコマンドであるemergeでパッケージをインストール/アップデートすると、ebuildに従ってソースコードが取得されコンパイルされ配置されます。
もし例えるならrpmdebのようなバイナリパッケージが料理そのものだとしたら、ebuildはレシピ+材料の在り処といった感じでしょうか。すぐ食べることができるのはrpm, debですが、ebuildは調理の手間はかかるものの状況に合わせてアレルギーがある食材を抜いたり旬のものを追加したりできるというイメージです。

またebuildではGit(Hub)の特定のブランチや特定のcommitを参照しインストール/アップデートすることもできます。
パッケージの依存関係として"Little CMS対応のImageMagickがインストールされていること"という記述もできます。
理想的なパッケージングの仕組みに思えてきましたね!?

ebuildに興味を持っていただけたところで(?)弊社で作っているebuildをご紹介します。
もしPortageで自社アプリケーションをdeployされる際の参考になれば幸いです!!

なお抜粋ですので全行を載せているわけではありません。また各サーバーで自家製ebuildを取得するためにはパッケージリポジトリも必要です。
全行を載せたサンプルやパッケージリポジトリについては以前書いた記事およびこちらのパッケージリポジトリにありますのでぜひご参照ください。

前提

弊社で開発しているWebView, WebAPI, RenderingServer各アプリケーションのebuildをご紹介します。
3アプリケーションともにソースコードはすべてGitHub.comで管理していますので、ソースコードの取得元を定義するEGIT_REPO_URIは弊社のGitHubリポジトリのGit URLを指定しています。

またebuildは前述のとおり拡張されたBashスクリプトこちらのドキュメントに書かれているように決められたfunctionが決められた順番で呼び出されます。 function call order

WebViewのebuild

ではWebViewのebuildです。

DEPENDでインストール時(ビルド時)の依存関係を記述します。RubyBundlerNode.jsに依存していることを示し、さらにRubyは2.2以上が必要なことを、Node.jsはnpmが必要なことも記載しています。
このように細かい依存関係が指定できるのはebuildのメリットですね。
RDEPEND(Runtime Dependencies)で実行時の依存関係を記述できますが、このWebViewアプリケーションは特に依存しているパッケージがありませんので記載していません。

「パッケージインストール時にコンパイルが必須なGentoo(Portage)でビルド時と実行時の依存関係分ける必要あるの?」と思われるかもしれませんが、じつはPortageにはバイナリパッケージを扱う仕組みがあります。今回は触れませんがバイナリパッケージを活用するとパッケージ構成の柔軟性を保ったままインストール/アップデートにかかる時間とコンピュータリソースを大幅に節約できます。

webapp-configというWeb(View)アプリケーションのインストーラを継承してそちらの機能に頼っていますので他のebuildよりシンプルになっています。

EAPI=5
inherit git-2 versionator webapp
EGIT_REPO_URI="git@github.com:???/???.git"

DEPEND=">=dev-lang/ruby-2.2
virtual/rubygems
dev-ruby/bundler
net-libs/nodejs[npm]"
RDEPEND=""

src_compile() {
    cd "${S}/sources/${APP_PROJECT}/default" || die
    bundle install --path=vendor/bundle || die
}

src_install() {
    webapp_src_preinst

    insinto "${MY_HTDOCSDIR#${EPREFIX}}/../default"
    doins -r "${S}/sources/${APP_PROJECT}/build/default"

    webapp_src_install
}

WebAPIのebuild

つぎにWebAPIのebuildです。

DEPENDでインストール時(ビルド時)の依存関係を記述しているのはWebViewと一緒ですが、Scalaアプリケーションなので依存がJDK 1.8以上とsbt 0.13以上な点が異なります。
またsbtパッケージはオフィシャルにも提供されているのですが、合わない部分があったので現在は自前のパッケージを使っています。
RDEPENDJDKではなくJREにしているのですが、実際にはOracle JDKをインストールしてしまっています。
今気づいたのですがWebAPIもImageMagickに依存していますね。ただLittle CMS対応は必須ではありません。

pkg_setup()でアプリケーション用のシステムユーザーを作っています。もちろんAnsibleのような構成管理ツールでもユーザー作成はできるのですが、個人的にはアプリケーションで必要となるユーザーはアプリケーションパッケージ側で記述する方が見通しが良いと思っています。
src_compile()ではsbt stageを実行してScalaソースコードコンパイルしています。
src_install()で必要なディレクトリを作成しsbt stageで生成されたバイナリを配置しています。また起動スクリプトや設定ファイルの配置も行います。

EAPI=5
inherit user git-2 versionator
EGIT_REPO_URI="git@github.com:???/???.git"

DEPEND=">=dev-java/sbt-bin-0.13
>=virtual/jdk-1.8"
RDEPEND=">=virtual/jre-1.8
media-gfx/imagemagick[jpeg,png]"

APP_DIR="/var/lib/${PN}"

pkg_setup() {
    enewgroup fss || die
    enewuser fss -1 -1 "${APP_DIR}" fss || die
}

src_compile() {
    rev=$(git rev-parse HEAD) || die
    cd "sources/${APP_PROJECT}" || die
    sbt clean stage || die
}

src_install() {
    keepdir "/var/log/${PN}" "/etc/${PN}" || die
    keepdir "${APP_DIR}/versions/${PN}.${PV}.${rev}" || die

    local stage="sources/${APP_PROJECT}/target/universal/stage"
    insinto "${APP_DIR}/versions/${PN}.${PV}.${rev}"
    doins -r "${stage}/bin" || die
    doins -r "${stage}/lib" || die

    newinitd "${FILESDIR}/${PN}.init2" "${PN}" || die
    newconfd "${FILESDIR}/${PN}.confd" "${PN}" || die
    cp "${FILESDIR}/application.conf.example" "${ED}/etc/${PN}/" || die "Cannot copy example application.conf"
    sed -e 's!<PLACEHOLDER_LOG_DIR>!/var/log/'${PN}'!' "${FILESDIR}/logger.xml" > "${ED}/etc/${PN}/logger.xml" || die

    if use symlink; then
        dosym "${APP_DIR}/versions/${PN}.${PV}.${rev}" "${APP_DIR}/versions/current" || ewarn 'Exist symlink!'
    fi

    fowners -R fss:fss "${APP_DIR}" "/var/log/${PN}" || die
    fperms 0755 "${APP_DIR}/versions/${PN}.${PV}.${rev}/bin/${DIST_NAME}" || die
}

RenderingServerのebuild

最後にRenderingServerのebuildです。

DEPENDはWebAPIと同じです。
RDEPENDでは組版のためにImageMagickGhostscriptへの依存関係を追加しています。こちらのImageMagickはWebAPIと違いLittle CMS対応が必要です。

pkg_setup()ではWebAPIと同じくアプリケーション用のシステムユーザーを作っています。
src_compile()でsbtによるコンパイルを行うところはWebAPIと同じですが、こちらのアプリケーションはsbt assemblyを実行します。
src_install()もWebAPIと同じく必要なディレクトリを作成しsbt assemblyで生成されたjarを配置しています。またこちらも起動スクリプトや設定ファイルの配置も行います。

EAPI=5
inherit user git-2 versionator
EGIT_REPO_URI="git@github.com:???/???.git"

DEPEND=">=dev-java/sbt-bin-0.13
>=virtual/jdk-1.8"
RDEPEND=">=virtual/jre-1.8
media-gfx/imagemagick[jpeg,truetype,postscript,png,lcms]
>=app-text/ghostscript-gpl-9.15"

APP_DIR="/var/lib/${PN}"

pkg_setup() {
    enewgroup fss || die
    enewuser fss -1 -1 "${APP_DIR}" fss || die
}

src_compile() {
    rev=$(git rev-parse HEAD) || die
    cd "sources/${APP_PROJECT}" && sbt clean assembly || die
}

src_install() {
    keepdir "/var/log/${PN}" "/etc/${PN}" || die
    keepdir "${APP_DIR}/versions/${PN}.${PV}.${rev}" || die

    local target="sources/${APP_PROJECT}/target/scala-2.11"
    insinto "${APP_DIR}/versions/${PN}.${PV}.${rev}"
    doins "${target}/${DIST_NAME}-assembly-${DIST_VERSION}.jar" || die

    newinitd "${FILESDIR}/${PN}.init2" "${PN}" || die
    newconfd "${FILESDIR}/${PN}.confd" "${PN}" || die

    if use symlink; then
        dosym "${APP_DIR}/versions/${PN}.${PV}.${rev}" "${APP_DIR}/versions/current" || ewarn 'Exist symlink!'
    fi

    fowners -R fss:fss "${APP_DIR}" "/var/log/${PN}" || die
}

おわりに

以上、弊社サービスのパッケージングとdeployのご紹介でした!
ところで弊社では一緒にebuildを書いてくださるエンジニアさんを募集しています!!
ご興味ありましたらぜひTwitter, 勉強会等でお声がけください!!!

リスト操作関数早見表(Underscore, Scala, Haskell)

ぽんこつ@ponkotuyです。勝手に会社のプロダクトをリファクタしたり高速化したりするだけの簡単なお仕事をしています。Scalaも楽しいけど、SQLのチューニングはもっと楽しいです。

という自己紹介をガン無視してリスト操作関数とUnderscore.jsの話をします。

続きを読む

フロントエンド開発環境の紹介

フロントエンド担当の shuji-koike です。

マーベリックに2015年1月にjoinして最初に任されたお仕事は、DSP広告システム Sphereの管理画面のリニューアルでした。

今回の記事では、管理画面のリニューアルにあたって採用したスタック(技術セット)について紹介させていただきます。

Slim

http://slim-lang.com/

htmlのテンプレートエンジンとして、haml,slim,jadeなどがありますが、個人的に使い慣れているという理由でslimを採用しました。 インデントレベルだけでhtmlの構造を記述できるので非常に生産性が高く、可読性も良好です。

CoffeeScript

http://coffeescript.org/

altJSの定番ということで迷わず採用。

Bootstrap

http://getbootstrap.com/

htmlマークアップやデザイン面のフレームワークとしてBootstrapを採用しました。

Bootstrapフレームワークに乗っかることで開発・改修工数を低く抑えつつ、 先々デザインを改善することになった場合は、Bootstrapのスキンを置き換えることで比較的簡単に改修できるという想定です。

AngularJS

https://angularjs.org/

Reactなど仮想DOM系のフレームワークも検討しましたが、まだ開発手法やノウハウの蓄積が十分でないと判断し、十分に枯れてきているAngularJSを採用しました。 AngularJSはとっつきにくい面もありますが、中〜大規模なWebアプリのフレームワークとしては良い選択肢だと思います。

Middleman

https://middlemanapp.com/jp/

フロントエンドのビルドツールとしては、gruntやgulpが一般的ですが、弊社ではmiddlemanをビルドツールとして採用しました。 middlemanRuby on Railsのエコシステムから派生した静的サイトジェネレータです。

本来MiddlemanはコーポレートサイトやブログのようなWebサイトを構築するためのツールですが、 Ruby on Railsアセットパイプラインのような機能 (sprockets)を利用するために使っています。

slim,coffee,sassのソースコードをhtml,js,cssに変換・難読化(Uglify)して、さらにgzip圧縮するところまで、middleman buildコマンド一発で実行できます。 また、開発時はmiddleman serverコマンドを使って、コードの修正をプレビューしながら開発できます。


今回は技術的に踏み込んだことは書けませんでしたが、今後の記事では開発手法やノウハウについて共有していきたいと思います。

Scala関西 Summit 2015 に行ってきた

2015年8月1日、関西最大級のScalaカンファレンス Scala関西 Summit 2015 が開催されました。
今回はその様子をお伝えしたいと思います。
(Twitterハッシュタグ #scala_ks でも当日の雰囲気を掴めるかと思います。)

f:id:maverick-techblog:20190801123942j:plain

場所は国の重要文化財に指定されている大阪市中央公会堂
関西だけでなく、関東・九州などから参加者・スピーカーが集い、3トラックで発表が行われました。

f:id:maverick-techblog:20190801123955j:plain

すでにプロダクション環境でガッツリScalaを使用されている現場からの知見の共有もありましたが、
PerlPHPなどで書かれた既存のプロダクトをScalaで書き直す取り組み、
Java中心の開発現場にどのようにしてScalaを普及・浸透させようとしているかという話も
興味深く聞かせていただきました。
特にリモート環境でのペアプログラミングにより社内でのScalaの技術向上を図るといった話が
印象に残っています。

弊社では元々Scalaを採用してプロダクトの開発を行ってきていますが、
最近はScalaで書かれたレガシー化しつつある部分を再度Scalaで書き直す取り組みを計画しており
状況は異なりますが参考になるところもありました。

Scalaの国内イベントといえば ScalaMatsuri が恒例となりつつあるようですが、
今回のように各地でイベントが開催され、盛り上がりを見せればいいな、などと思いました。

f:id:maverick-techblog:20190801123959j:plain

余談ではありますが、今回個人スポンサーという形で参加させていただきました。
微力ながらイベント運営にお力添えできていれば幸いです。
運営・スピーカー・スポンサー・参加者の皆様、ありがとうございました。

さくらのvpsにansibleでowncloud構築

こんにちは 第2回はインフラ担当のあすてるが担当します。

今回はansibleでowncloudサーバを構築です。
その前にansibleとowndloudについて少し紹介します。

ansibleとは

今回は構成管理ツールと呼ばれるansibleを使って構築したいと思います。
pythonで書かかれたエージェントがいらないツールとなっています。
簡単にいうと構築する際に書くシェルスクリプトのようなものです。

ansibleモジュール

標準モジュールといわれるものがあり公式が提供しています。
ファイルをコピーしたりyumコマンドやaptコマンドを使用する際に使います。

vimyumインストールする際には

- yum: name=vim

と書くだけでインストールすることができます。
モジュールを使用することにより冪等性が担保されます。
もう一度動作した場合は既にインストールされているのでスキップされます。

複数台に同時に実行可能

ansibleはサーバを指定して実行することが可能です。
またそれは1台だけではなく数十台同時に実行することができます。
今回のサンプルコードにはhostという名前でファイルを作成し、一台のサーバのみしか書いていませんが

[api_server]
192.168.1.[1:20]

と書くことにより平行して複数サーバを構築することができます。

気を付けること

一番気を付けることは冪等性です。
標準モジュールにはshellやcommandといったものがあるので全てshellモジュールで書くことも可能です。
しかしそれでは冪等性が失われる場合があるので注意しましょう。
echo hogehoge >> /etc/piyo/foo.conf などを実行した場合どうなるでしょうか?

初期構築時は問題ないかもしれませんが2回実行した場合、foo.confにはhogehogeが2つ書かれてしまいます。
標準モジュールはたくさんありますがよく使うものは限定されていきますので出来るだけモジュールを使用しましょう。

ansible-playbookを実行した際
okかchangedという2種類のどちらかが基本的に表示されます。

okの場合、既にインストールされていたり処理が実行済みの場合です。
逆にchangedは変更があった際に実行されます。
ansibleのコツは2回目に流した際にchangedをなるべく出さないことですね。


owncloudとは

オンラインストレージサービスを自分のサーバで構築できるオープンソースソフトです。
自前dropboxとよくいわれているみたいです。
webサーバ上でphpで動作します。


サンプルコード

今回はowncloudということで
nginx + php-fpm + mysql という構成で構築します。

初期設定として鍵認証でsshできるようにしてsudo権限がある、
もしくはrootになってローカルにansibleを流す様にすると簡単に実行できると思います。
OSはcentos6を想定しています。

まだ改善の余地があるかと思いますがサンプルコードはこちらです。

ansible-playbookを実行し全部が流し終わると http://hostip/owncloud/ にアクセスしてowncloud初期設定ができると思います。

以上、あすてるでした。
次回も乞うご期待!

ScalaでMongoDBモデル操作をラクにする

ども。プロダクトグループ所属エンジニアの、あおいの(@AoiroAoino)です。
先月25歳になりましたが、見た目年齢は30代らしいです。よろしくお願いしますmm

弊社技術ブログ一発目の記事担当になってしまったので頑張ります!
さてさて、記念すべき一発目はMongoDBとScalaの組み合わせについて書こうかなと思います。
テーマは「モデルを定義/使用する人がラクできるようにする」です。

前置き

とりあえず何も無いのもアレなので、「イベントホスティングサービスを開発する」という妄想で話を進めていきます。
モデル部分のデータ構造はざっとこんな感じで。

// Event.scala
case class Event (
  _id:       ObjectId,
  title:     String,
  start:     String,
  end:       String,
  place:     String,
  owners:    List[User],
  attendees: List[User]
)
// User.scala
case class User (
  _id:   ObjectId,
  name:  String,
  phone: String
)

さぁ、定義したモデル使ってもりもり開発していきましょう!

Casbahを使う

Scalaで使用できるMongoDBクライアントはいくつかあるようですが、今回はMongoDB公式の「Casbah」を使用します。

内部的にはJavaで実装されたmongo-java-driverをラップし、Scalaっぽく書けるようにしたものです。
検索結果がOptionに包まれて返ってきたり、検索クエリ構築のための内部DSLが用意されていたりするので、
Java版を直接使用するよりもScalaっぽく書けるようになっています。

色々方法はあると思いますが、今回はモデルとDBObjectの相互変換をラクにするため、
toModeltoDBObjectメソッドを用意していきます。

// ModelMapper.scala
trait ModelMapper[A] {

  // Model -> DBObject
  def toDBObject(model: A): DBObject

  // DBObject -> Option[Model]
  def toModel(obj: DBObject): Option[A]
}
// User.scala
object User extends ModelMapper[User] {

  def toDBObject(user: User): DBObject = {
    DBObject(
      "_id"   -> user._id,
      "name"  -> user.name,
      "phone" -> user.phone
    )
  }

  def toModel(obj: DBObject): Option[User] = {
    for {
      oid   <- obj.getAs[ObjectId]("_id")
      name  <- obj.getAs[String]("name")
      phone <- obj.getAs[String]("phone")
    } yield User(oid, name, phone)
  }
}

ここで、DBObjectからModelへの変換は失敗する可能性があるので、結果がOptionで包まれているのがポイントです。
後々重要になるので、頭の片隅に置いておいてくださいね。

さて、これで準備は完了です!
以下のようにして使用することができます。

// 使用例
scala> val user = User(new ObjectId, "Aoino", "xxx-xxx-xxx")

scala> val obj = User.toDBObject(user)
// => com.mongodb.DBObject = { "_id" : { "$oid" : "559e6298ce242b655aa45de1"} , "name" : "Aoino" , "phone" : "xxx-xxx-xxx"}

// 変換成功例
scala> val model = User.toModel(obj)
// => Option[com.github.aoiroaoino.model.User] = Some(User(559e6298ce242b655aa45de1,Aoino,xxx-xxx-xxx))

// 変換成功したmodelからnameの取得
scala> model.map(_.name)
// => Option[String] = Some(Aoino)

// DBObjectが想定してる構造で無かった場合
scala> User.toModel(DBObject.empty)
// => Option[com.github.aoiroaoino.model.User] = None

良さそうですね!
これでも十分に開発を進めることができるかと思います。

さて、ここからがいよいよ本番です!!

マクロを使ってモデル定義時にラクをする

さて、このままだと新たなモデルが増えるたびにtoDBObject/toModelの定義をしなければいけません。
私は貧弱Vimmerなので、手元のVimにはIDEほどの入力補完もなければ長年蓄積されたスニペット資産もないのでとても面倒です。 なので、面倒なお仕事はコンパイラに押し付けてしまおう!ってことでマクロを使ってみることにします。1

まず最初にマクロを呼び出す側のコードを書いてしまいます。
ModelMappercase classから得られる情報で自動的に構築するので、とりあえずAutoModelMapperって名前にしました。

// AutoModelMapper.scala
object AutoModelMapper {
  def apply[A]: ModelMapper[A] = macro AutoModelMapperImpl.autoModelMapper_impl[A]
}

次に、呼び出されるマクロを実装します。

// AutoModelMapper.scala
private object AutoModelMapperImpl {

  def autoModelMapper_impl[A: c.WeakTypeTag](c: Context): c.Expr[ModelMapper[A]] = {
    import c.universe._

    val tpe = weakTypeOf[A]

    val constructor = tpe.decls.collectFirst {
      case m: MethodSymbol if m.isPrimaryConstructor => m
    }.getOrElse(c.abort(c.enclosingPosition, s"Cannot find constructor in $tpe"))

    val fields = constructor.paramLists.head match {
      case Nil => c.abort(c.enclosingPosition, s"Constructor argument is empty in $tpe")
      case x   => x
    }

    val toDBObjectParams: List[Tree] = fields.map { field =>
      val name    = field.name.toTermName
      val decoded = name.decodedName.toString

      q"$decoded -> model.$name"
    }

    val toModelParams: List[Tree] = fields.map { field =>
      val name       = field.name.toTermName
      val decoded: String    = name.decodedName.toString
      val returnType = tpe.decl(name).typeSignature

      q"""obj.getAs[$returnType]($decoded)"""
    }

    val n = if (toModelParams.length <= 1) "" else toModelParams.length.toString
    val applyN = TermName("apply" + n)
    val companion = tpe.typeSymbol.companion

    // Watch out for apply12 problem
    c.Expr[ModelMapper[A]](q"""
      import scalaz._, Scalaz._
      import com.mongodb.casbah.Imports._
      import com.github.aoiroaoino.mongodb.ModelMapper

      new ModelMapper[$tpe] {

        def toDBObject(model: $tpe): DBObject =
          DBObject(..$toDBObjectParams)

        def toModel(obj: DBObject): Option[$tpe] =
          Apply[Option].$applyN(..$toModelParams)($companion.apply _)
      }
    """)
  }
}

コードはちょっと長いですが、やってる事は単純です。ざっくりと見ていきましょう。
まずは型パラメータAの情報を取得し、そこからapplyメソッド、およびその引数名一覧を取得します。

// 事前準備
val tpe = weakTypeOf[A]

val constructor = tpe.decls.collectFirst {
  case m: MethodSymbol if m.isPrimaryConstructor => m
}.getOrElse(c.abort(c.enclosingPosition, s"Cannot find constructor in $tpe"))

val fields = constructor.paramLists.head match {
  case Nil => c.abort(c.enclosingPosition, s"Constructor argument is empty in $tpe")
  case x   => x
}

fieldsよりDBObject.applyに渡すためのタプルを作ります。

// toDBObject作るための準備
val toDBObjectParams: List[Tree] = fields.map { field =>
  val name    = field.name.toTermName
  val decoded = name.decodedName.toString

  q"$decoded -> model.$name"
}

fieldsよりscalaz.Applyに渡すための式(obj.getAs[Type]("field_name")getAsはCasbah提供のメソッド)を作ります。2

// toModel作るための準備
val toModelParams: List[Tree] = fields.map { field =>
  val name       = field.name.toTermName
  val decoded    = name.decodedName.toString
  val returnType = tpe.decl(name).typeSignature

  q"""obj.getAs[$returnType]($decoded)"""
}

で、最後にModelMapper[User]インスタンスを返します。

// 仕上げ
c.Expr[ModelMapper[A]](q"""
  import scalaz._, Scalaz._
  import com.mongodb.casbah.Imports._
  import com.github.aoiroaoino.mongodb.ModelMapper

  new ModelMapper[$tpe] {

    def toDBObject(model: $tpe): DBObject =
      DBObject(..$toDBObjectParams)

    def toModel(obj: DBObject): Option[$tpe] =
      Apply[Option].$applyN(..$toModelParams)($companion.apply _)
  }
""")

これで一通りマクロの実装は完了です!
先ほどのobject Userの中身を以下のように書き換えます。

// User.scala
object User {

  // toDBObject/toModelが実装されたModelMapper[User]が返ってくる
  val m = AutoModelMapper[User]

  def toDBObject(user: User) = m.toDBObject(user)

  def toModel(obj: DBObject) = m.toModel(obj)
}

置き換えた上で、再度試してみると手作業で実装したtoDBObject/toModelと同様に使えるかと思います。
これで非常に少ないコードでモデルを定義する際にラクできるようになりました!3

Monocleでモデル使用時にラクをする

さて、突然のMonocleです。
そもそもMonocleとはなんだ?って方が多いかと思うので、簡単に説明すると,「Haskellのlensパッケージを元にJulien Truffaut氏が開発したScalaOptics(Lens, Prismなど)な概念が使えるようになるライブラリ」です。
んじゃそもそもLens, Prismとは何かという話になりますが、ここでは簡単な使い方のみご紹介します。4

Lensは誤解を恐れずに言うと、composableな抽象化されたgetter/setterです。
まずはEventに対してLensPrismを定義してしょう。Monocleでは以下のようにして定義します。

// EventOptics.scala
trait EventLens {

  // GenLensマクロを使用
  private def eventGenLens = GenLens[Event]

  def _eventOid  = eventGenLens(_._id)
  def _title     = eventGenLens(_.title)
  def _startDate = eventGenLens(_.start)
  def _endDate   = eventGenLens(_.end)
  def _place     = eventGenLens(_.place)
  def _owners    = eventGenLens(_.owners)
  def _attendees = eventGenLens(_.attendees)
}

今回は省力化のためにMonocleのGenLensマクロを使用しましたが、マクロを使用せずに定義すると以下のようになります。
こっちの方がcomposableLensって入れ物にgetter/setterを入れてる感(?)が伝わってきますね。

// 手動でLensを定義
// getterとsetterを引数に渡して、titleに対するLensを定義する
def _title = Lens[Event, String](_.title)(t => e => e.copy(title = t))

Prismは一言で表現するのは難しいのですが、とりあえずcomposableで、変換(getOption: S => Option[A]) とその逆変換(reverseGet: A => S)の関数を持つ概念ってイメージです。
このS => AA => Option[S]がまさにtoModeltoDBObjectの関係にあたるのが見てとれるかと思います。

// EventOptics.scala
trait EventPrism {

  def _event = Prism[DBObject, Event] {
    case obj: DBObject => Event.toModel(obj)
    case _             => None
  }(Event.toDBObject _)
}

さーて、ここまでだいぶ長い道のりでしたが、LensPrismを定義することで魔法のような操作が可能になります。

// サンプルデータ
// サンプルデータを適当に用意
val owner1 = User(new ObjectId, "Owner1", "xxx")
val owner2 = User(new ObjectId, "Owner2", "yyy")

val event = Event(new ObjectId, "title", "start", "end", "place", List(owner1, owner2), List())

// MongoDBでfindOneした結果を想定
val findOneResult: Option[Event] = Some(event)

上記サンプルデータを使ってデータを操作してみましょう。5

// 使用例
// Eventの開催場所を取得
scala> findOneResult applyPrism some[Event, Event] composeLens _place getOption
res31: Option[String] = Some(place)

// Eventのタイトルを変更
scala> findOneResult applyPrism some[Event, Event] composeLens _title set("new title")
res32: Option[com.github.aoiroaoino.model.Event] = Some(Event(559f39e3d4c646b1d30589bd,new title,start,end,place,List(User(559f39e3d4c646b1d30589bb,Owner1,xxx), User(559f39e3d4c646b1d30589bc,Owner2,yyy)),List()))
 
// Event主催者の名前一覧を取得
scala> findOneResult applyPrism some[Event, Event] composeLens _owners composeTraversal each composeLens _name getAll
res33: List[String] = List(Owner1, Owner2)

すごーく冗長に感じてしまった方も多いかと思いますが、ご安心ください。
MonocleではapplyXXX, composeXXXに対してエイリアスも用意されているので、以下のように書くことも可能です。

// エイリアスメソッドを使う
// Eventの開催場所を取得
findOneResult &<-? some[Event, Event] ^|-> _place getOption

// Eventのタイトルを変更
findOneResult &<-? some[Event, Event] ^|-> _title set("new title")
 
// Eventオーナーの名前一覧を取得
findOneResult &<-? some[Event, Event] ^|-> _owners ^|->> each ^|-> _name getAll

いかがでしょうか?Lens/Prismの力によって、より柔軟にtoModel/toDBObjectを操作できるようになりましたね。
アドホックLensPrismを合成できるので、findした結果 -> Prism -> Lensへのシームレスな連携が可能となりました!

さて、これがどういった時に美味しいのかというのをもう一例みてみましょう。
例えば、開発時に「Eventの全参加者の名前を大文字に変換したデータが欲しい」ってメソッドが欲しくなったとします。

このメソッドをLensを使わずに書くと

// Lens使わない版
def Lens使わずに全参加者の名前を大文字に(e: Event) = {
  event.copy(attendees = event.attendees.map { attendee =>
    val n = attendee.name.toUpperCase
    attendee.copy(name = n)
  })
}

割と素直で簡潔に書いたつもりですが、copymapが絡まってちょっと直感的でない印象ですね。
ですが、同様のメソッドをLensを使って書くと

// Lensを使う版
def Lens使って全参加者の名前を大文字に(e: Event) = {
  event &|-> _attendees ^|->> each ^|-> _name modify(_.toUpperCase)
}

と書けます。すごいですね、実質一行です!
一応動作確認もしてみましょう。

// 動作確認
scala> :paste
// Entering paste mode (ctrl-D to finish)

val owner = User(new ObjectId, "Owner", "xxx")
val attendee1 = User(new ObjectId, "attendee1", "yyy")
val attendee2 = User(new ObjectId, "attendee2", "zzz")

val event = Event(new ObjectId, "title", "start", "end", "place", List(owner), List(attendee1, attendee2))

// Exiting paste mode, now interpreting.

scala> Lens使わずに全参加者の名前を大文字に(event) == Lens使って全参加者の名前を大文字に(event)
res16: Boolean = true

ちゃんと同じ結果になってますね!!

といった感じでcopyからのmapしてcopyなんて面倒なコードも書かなくて済むようになりました。
これでモデルを弄る側もハッピーです!!

まとめ

いかがでしたでしょうか?
マクロとMonocleを使ってモデルの定義と使用がラクになりましたね!

実際はもっと複雑でこんな簡単にはいかないもんで、悩まなきゃいけないことは山ほどあるかと思います。
toDBObject/toModelに引数で渡すのダサいからメソッドとして呼ぶような形式にしたくない?とか、
case classのfield名をcamelCaseからsnake_caseに変換するようにしたくない?とか、
それはそれで色々考え出すととても面白いのですが、記事の長さと時間の都合上この辺にしたいと思います。

今回の記事を書きつつ、柔軟な構文と豊富な機能によって色々な選択肢があるところがまたScalaの楽しいところだなーと改めて思いました。 最終的なコードはこちらに公開しておきましたので参考になれば幸いです。

以上、あおいの(@AoiroAoino)がお送り致しました。次回の記事も乞うご期待!!


  1. あまりいい理由では無いですね!

  2. 本当は温かみのある手作業時と同様にfor式で組み立てたかったのですが、上手く実装できなかったのでScalazのApplyを使用しました…

  3. 残念ながら、今回作成したマクロはあまり汎用的ではありません。case classのfield名とDBObjectのfield名が一致している必要がある等の制限があり、課題は山積みです:-(

  4. 気になる方は手前味噌で申し訳ないですが、こちらの記事をご覧ください。

  5. REPLで試す際にはScala型推論の都合上、findOneResultsomeで明示的に型を指定する必要がありますので注意…

bootup

はじめまして。
マーベリック広報担当です。

本日よりマーベリックの技術ブログである"TECH LAB"を開始致します。

マーベリックはDSPと呼ばれる Web広告配信プラットフォームを開発しています。 開発を行う中で、日々多くの技術的課題への挑戦が行われ、そうした内容を今日からこの "TECH LAB" で、随時発信していきたいと思っています。

  • サーバーインフラ構築
  • Scalaでのアプリケーション開発
  • AngularJSを用いたフロントエンドアプリケーション開発
  • 機械学習のチューニング

などなど技術的に面白いネタが詰まったものにしたいと思っておりますので、是非、チェックしてくださいね。