angularJS と jQuery に関する誤解を解く

最近 angularJS に対する期待の低下が著しくてつらい。


なんだかんだで SPA から jQuery に戻った話 - ボクココ
Angularの問題では
はてなブックマーク - mizchi のブックマーク - 2015年5月18日
みんな使いどころを間違ってるんや。1年半くらい使ってて不満もあるけど自分のよく使う範囲では angularJS 最強だと思う。

angularJS が向いてるのは Single Page Application ではない

angularJS が向いてるのは

フォームのような細かい部品を多用 & DOMツリーとデータスコープがほぼ一致していてユーザの入力をサーバに送ったりする webアプリ。管理画面、マイページ、業務アプリなど
Single Page Application ← 簡単に作れるけどページ間の連携が必要ないならサーバ側で分けてしまった方がよい。
× SEO対策が必要なページ。SEOが大事な webサービスのフロント側とか
× ゲームのようにDOMツリーとデータスコープがあんまり関係ないもの、変化するデータが多くてfps単位でのスピードが求められるもの

とくに 管理画面にありがちな、一覧(並び替え/絞り込み/表示形式変更)・編集・追加・削除 という一連の画面の作りやすさはすごい。下手するとほとんどjavascript書かずに実装できる*1

そして「javascriptアプリケーションのサイズ」はなるべく小さい単位にとどめておくことをお薦めする。例えば「管理画面のログインからシングルページアプリケーション」というのは避けた方がよくて「ログインはjavascriptなしのフォーム」その後メニューから「商品管理画面に遷移したらそこで一つのアプリ」のように普通のページ遷移+別のmainコントローラーという構成が、扱いよい。可能なら「一覧」「詳細編集」で別にするともっと取り回しが効く。
複数のページを管理する、ということは、状態を示す変数が大きく複雑になるということだ。状態が複雑になると、実装はさらに指数関数的に複雑になり、速度も遅くなるしテストも大変になる。できるなら小さくした方が影響範囲も狭くなって改修しやすい。メモリリークなどが起こっても影響が少なくてすむし、別ユーザの操作やバッチ処理などからおこる、サーバのデータとのコンフリクトの可能性も減る。
特にパフォーマンスが求められない限り、シングルページアプリケーションよりも、ページ遷移させてそこをリッチにした方が、いろいろ初期化されるので開発も楽で、ユーザ体験も自然なことが多い。
そして angularJS で作っておくと「「一覧」「詳細編集」で別にしてたけどいろいろ仕様追加があって一つにまとめたほうが便利」的な流れでも生き残れたりする。

(追記:5/20)「angularJS はシングルページアプリケーションに向いてない」という事ではなくて「シングルページアプリケーションっ ていろいろ難しいから、避けられるなら避けたほうが開発が楽」という話です(/追記)

angularJS と jQuery は同時に使ってよい

angularJS と jQuery はまさにライブラリとフレームワークの関係で、angularJS のDOM操作部分=ディレクティブの中で、jQuery を動かすようにすればよい。いろいろな解説で「jQuery使っちゃダメ」みたいな事が書いてあるが、「コントローラーからDOMをセレクタ指定でイベント仕込むようなやり方で jQuery は使っちゃダメ」というのが正しい。なぜなら「DOMの生成破棄のタイミングがコントローラーの実行タイミングと違うから」だ。
サービスではエレメント指定しないなら使っていいし、ディレクティブでは「そのディレクティブ内に影響範囲をとどめる」という注意を持っていれば積極的に使ってもよい(scope.$apply などとの連携方法の学習が必要になるが)。

jQuery プラグインにはいろいろあって、angularJS と組み合わせたときの相性・指針は

HTML/DOM/イベントに関係ないライブラリ jQuery.md5 とか → どこでも(コントローラー内でさえ)積極的に使ってよい。まあそれjQueryである必要ないよね…
外部サービスとの通信ライブラリ $.ajax とか  → サービスにしよう。そして受信が完了したら promise でコールバックして、 $rootScope.$apply() を呼ぶ
$(elm).hoge() すると elm にエフェクトがかかるもの(text-shadow とか)  → 簡単にディレクティブ化できるのでそうやって使いましょう。
$(elm).hoge() すると elm の内部のdomを変更してなんかする系(スクロールバーをおしゃれにとか) → まあだいたいディレクティブ化できるのでそうやって使いましょう。一部 dom を壊して angularJS が設定する dom 情報とを破壊するものがあるかもしれないのでそれだけ注意
$(elm).hoge() すると elm の内部にデータに沿ったパーツが表示される系(カレンダー表示とかグラフ系とか) → 簡単にディレクティブ化できるのでそうやって使いましょう。フォームの入力パーツなら ngModel とかと連携しないといけなくてその辺がちょっと複雑だけど少し頑張ればできる
テンプレートエンジン → ディレクティブ化できるけど完全にangularJS化した方がいいよね

という感じだ*2。他の jQuery 以外のライブラリでもだいたい同じ感じで連携できる。

jQuery を angularJS のディレクティブに閉じ込めるのは難しくない

angularJS の中でもディレクティブの仕様は複雑怪奇であるが、「jQueryを閉じ込める」という目的ならそんなに難しい使い方は必要ない。

例えば

<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.js"></script>

<button class="popup" data-text="hoge1">1</button>
<button class="popup" data-text="hoge2">2</button>
<div id="alert" style="display:hidden"></div>

<script>
$(function(){
  /**
   * popup クラスのボタンはポップアップ。クリックするとアラートdiv(#alert)を表示する。表示内容は ボタンの data-text の内容。
   */
  $("button.popup").click(function(){
    $("#alert").hide().text($(this).data("text")).fadeIn();
  });
});
</script>

というのがあるとする*3。これなら

<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/angularJS/1.2.16/angular.js"></script>

<div ng-app="app">
<button popup data-text="hoge1">1</button>
<button popup data-text="hoge2">2</button>
<div id="alert" style="display:hidden"></div>
</div>

<script>
angular.module('app')
/**
 * popup ディレクティブはポップアップ。クリックするとアラートdiv(#alert)を表示する。表示内容は ボタンの data-text の内容。
 */
.directive('popupDirective',function(){
  return {
    link:function(scope, elm, attrs){
      elm.click(function(){
        $("#alert").hide().text(elm.data("text")).fadeIn();
      });
    }
  };
});
</script>

と、とりあえず使えるのはすぐに作れる。 #alert が、あまりに気になる(部品のスクリプトに全体の特殊事情が書かれている!)からまあ

<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/angularJS/1.2.16/angular.js"></script>

<div ng-app="app">
<button popup popup-target="#alert" data-text="hoge1">1</button>
<button popup popup-target="#alert" data-text="hoge2">2</button>
<div id="alert" style="display:hidden"></div>
</div>

<script>
angular.module('app',[])
/**
 * popup ディレクティブはポップアップ。クリックするとアラートを表示する。
 * 表示内容は ボタンの data-text の内容。
 * popup-target 属性にアラートボックスのセレクタを指定する(例: popup-target="#alert" )。
 */
.directive('popup',function(){
  return {
    link:function(scope, elm, attrs){
      elm.click(function(){
        $(attrs.popupTarget).hide().text(elm.data("text")).fadeIn();
      });
    }
  };
});
</script>

こうするともうちょっと汎用的になる(元のjQueryのやつより汎用的になった!)。だいたいの jQuery プログラムは、こんな感じで自分で中途半端にラップして使うと、jQuery の影響範囲を封じ込めることができる。*4

はまりがちなのは

  • 独自のタイミングで scope の値を更新したけど表示が更新されない
    • scope.hoge=huge *5 みたいに更新した後に、 scope.$apply を呼ばないと更新されません
    • 間にあるディレクティブによって変なところで子スコープが作成されてて子スコープに値を追加しているかも → scope.hoge じゃなくてscope.hoge.huge のようにオブジェクトを介して更新するんや……(バッドノウハウ)
  • jQuery でやってたアニメーションを ng-animate でどうやったらいいか分からない
    • → ディレクティブ作って内部で jQuery を使う。もちろん ng-animate 使えれば使えばいいけど書き直すコストがかかるとかなら jQuery のままでもいい
  • 出し消ししたいエレメントを動的に生成しようとして躓く
    • jQuery の show() hide() とか ng-show ng-if でええんやで
  • jQuery 非依存」を謳う angularJS専用同機能モジュールを探したけど今までと書き方がだいぶ変わってつらい
    • → 概念/使い方が全然違って学習/書き換えコストが高いことが多いしネットで見つかるのは玉石混合でバギーだったりするし、最初の内は自分でディレクティブ作って jQuery プラグインを中途半端にラップして使ったほうが楽。目当ての jQuery プラグインがなくて angularJS モジュールを知ってるなら angularJS 専用モジュールの使い方を覚える、でいいけど、すでに使い慣れた jQuery プラグインがあるなら自分の使い方に合わせてラップする。その参考に既存のモジュールを探すと、ためになる(オレオレラッパーにいろいろ機能・汎用性を足していくと最終的に「なるほど!このモジュールすごい考えられてたんだ!」となることが多い)
  • scope.$on() scope.$emit などで独自イベントを発行したりするイベント駆動
    • → イベントがグローバル変数になりがち。イベントリスナ側は scope.$watch などで変数監視、 イベント発行側は scope の変数を変更して scope.$apply。 監視する変数名を attrs で指定できるようにしたり scope.$eval や $parse などで式を実行・代入できるようになると汎用性が上がる。非同期結果取得の場合は $q ( promise ) を使ったコールバック

既存の jQuery が idやセレクタ指定つかいまくりの場合は移行が厳しいが、むしろ、この状態を制限したい(idやセレクタグローバル変数的になりがちなので回避したい)がための「ディレクティブ化」であるのでそこは積極的に変えていこう。

初期コストが増えてもメリットがある場合にフレームワークを導入する

jQuery 関係をディレクティブ・スコープに切り出すメリットとしては「angujarJS の他の機能が使える」の他に

  • view の変更とデータの変更を切り離せる
    • テストが容易になる
    • 汎用性が上がる
    • メンテナンス性が上がる
  • view の変更の影響範囲を狭めやすい
    • 再利用性が上がる

代わりに

  • 使い回ししない部分でもディレクティブ化を求められる
  • 覚えないといけない規則が多くなる

世の中のweb開発にはこれらのコストを見ない方が現実にあっている場合も多くて、例えば広告・短期イベントページなど改修・長期メンテナンスが想定されなかったり、状態が少なく「全部グローバル変数でも何とかなる」程度の複雑さのページだ。そのばあいはjQuery の方が合っているだろう。
これは「モジュール化すべきか?」「フレームワークを導入するべきか?」「マイクロサービスに分割すべきか?」みたいな問題で、PHPでも単ファイルにべた書きしたり共通includeした方がよい規模がある。rubyなどはデコードやエラー処理、cgi設定など諸々している内に「ならrails/sinatraで」となりがちだが PHP なら 3ページくらいで他と切り分けられるなら素のPHPの方が楽で早い、みたいな話だ。

まとめ

  • angularJS が向いてるのは Single Page Application ではない
  • angularJS と jQuery は同時に使ってよい
  • jQuery を angularJS のディレクティブに閉じ込めるのは難しくない
  • 初期コストが増えてもメリットがある場合にフレームワークを導入する
  • angularJS 最強なので使いましょう。

*1:まあこれについて「それjavascriptなくてもできるよ」というのは同意で、それで十分ならjavascript使わない方がいいと思います

*2:俺調べ。もうちょっと練ったらわかりやすい指針が出せるのかも。誰か……

*3:この程度だとわざわざ作らなくても既存のディレクティブで実装できるが、まあ jQuery 使う例

*4:本当はもう一段階進めて これくらい まですると、さらに angularJS っぽくて、なるほど ng-click ng-show ってそうなってるのか、みたいなのが見えてくると思う

*5:あるいは scope.$eval や $parse(exp)(scope).assign(val) など

windos の msys git の bash で peco

peco は補完候補を gui で絞り込みできるコマンドラインツール。chocolatey でインストールできる。

choco install peco

インストールした後に git 用 bash から実行するとなんかエラーが出て動かないが、cmd 経由だと使えるっぽいので ~/.bashrc に

alias peco='cmd /C,peco'

としておくと、普通に peco 使える。

あとはこのへんの関数定義すると便利っぽい
http://qiita.com/sona-tar/items/fe401c597e8e51d4e243

[bash]githubの特定のプルリクエストをfetchできるようにしたい

# git で github の特定のプルリクエストをfetchできるようにする
function git-add-pull-request(){
  local PR=$1
  local REMOTE=$2
  REMOTE=${REMOTE:-origin}
  if [ -z "$PR" ]; then
    echo git-add-pull-request [remote] pull-request-id
    return
  fi
  # remote が数字なら多分remoteとプルリクエストIDが逆
  if expr "$REMOTE" : '[0-9]*' > /dev/null ; then
    PR=$2
    REMOTE=$1
  fi
  git config --add remote.${REMOTE}.fetch +refs/pull/${PR}/*:refs/remotes/${REMOTE}/pr/${PR}/*
}

消すときは .git/config を手で編集

jgit-chef で s3 に置いたリポジトリからデプロイする


この投稿は Chef Advent Calendar 2014 の 11日目の記事です。

社内に git サーバがあったりしてデプロイするときに困っていたりする全国の皆様こんにちは。

aws を使っているなら jgit を使えば s3 にリポジトリを置いて更新したりできます。

で、chef でリポジトリをチェックアウトする際に使える jgit-chef を作成しました。

https://github.com/team-lab/jgit-chef

基本は chef の git リソース と同じです。

コミットフックか jenkins に

source ~/.aws_env.sh # 環境変数に s3 の書き込み権限を得る
if [ $(git config --get remote.s3.url|wc -l) == 0 ];then
  git remote add s3 amazon-s3://ENV@your-s3-bucket/moromoro.git/
fi

/usr/bin/jgit push s3 "refs/heads/*:refs/heads/*"

のようにjobを設定すると s3 にリポジトリを作成、push してくれるので、 Berkshelf とかで jgit-chef を使えるようにして、

cookbook 'jgit', :git => "https://github.com/team-lab/jgit-chef.git"

metadata.rb のdepends に追加し

depends "jgit"

次のようなレシピを作ると

package "git" # ami-linuxは最初はgitがはいってなくてchefのgitリソースがこける・・
package "java-1.7.0-openjdk" # jgit-chef::install はjavaをインストールしないので自分で入れる
include_recipe "jgit::install" # github から /usr/bin/jgit にインストール

jgit "/opt/moromoro" do
  repository "amazon-s3://IAM@your-s3-bucket/moromoro.git" # こちらの認証はIAM-ROLEでインスタンからs3のリポジトリを参照できるようにしておく
  action :sync
  revision "master"
end

/opt/moromoro に社内リポジトリのmasterをチェックアウトしてくれます。

ご利用ください。

jgit で s3 にプライベートリポジトリ(IAM編)


この投稿は Git Advent Calendar 2014の 10日目の記事です。
昨日は kyanro@github さんの githubとgoogleを利用して世界征服の意図を調べる でした。
明日は @a-suenami さんです。
社内に git サーバがあったりしてデプロイするときに git pull できずに rsync を使っていたりする全国の皆様こんにちは。rsyncだとデプロイ先で緊急修正したときや試行錯誤したあとにgitリポジトリに書き戻すのが大変だったりしませんか? EC2 でリポジトリサーバたてられればいいけど EC2 のインスタンスはそれだけに使うにはちょっと高い。せめて s3 にリポジトリがおけたら……

できます。 git ではなく jgit です。git を java で実装した jgit というプロジェクトがあります*1
その中に jgit.sh という linux のシェルをくっつけて単体で実行できるようにしたファイルが有り、こいつを wget して chmod すれば*2 ./jgit.sh とコマンドラインから叩くだけで jgit を動かすことができます。

で、jgit.sh の何が嬉しいかというと AWS の S3 にリポジトリが置けることです。AWS CodeCommit を待たなくてもS3の容量価格だけでプライベートリポジトリをもてる!

詳しくは
http://www.fancybeans.com/blog/2012/08/24/how-to-use-s3-as-a-private-git-repository/
http://d.hatena.ne.jp/winebarrel/20120425/p1

jgit.sh の実装はいろいろと中途半端なのですが*3、データベースファイルは普通のgitと同じなので、 clone push fetch の時だけjgit使うようにするのもありです。

が、素の jgit だと AWS の Instance profile credential いわゆる IAM-ROLE が使えないので、使えるようにしました。副作用で STS の token も使えるようになっています。

https://github.com/team-lab/jgit/wiki

s3 の権限を ROLE で割り当てた EC2 から

wget https://github.com/team-lab/jgit/releases/download/v3.6.0.201411121045-token4-env4/jgit -O jgit
chmod oug+x jgit
git remote add s3 amazon-s3://IAM@backet-name/repo/dir.git
./jgit push s3

のようにすると s3 の backet-name バケットに push できます(jgitだと認証情報ファイル名を指定する URL のユーザ名部分に、キーワード 'IAM' を指定してください)。

ご利用ください。

とはいえ S3 はファイルロックができないので競合するときっと大変なことになると思います。s3 に push するのは管理サーバや社内 jenkins からだけにして ec2 では fetch のみがよいかと思います。EC2 側には書き込み権限をおかずに、インスタンス上で直接編集したらそれを直接社内 git サーバに push したい場合、ポートフォワードを設定しておくとできます。

# 社内http-gitサーバ git-server.syanai.local の 80 番をec2 (syagai.ec2.example.com)に持っていく 
ssh -R 10080:git-server.syanai.local:80 ec2-user@syagai.ec2.example.com
cd /opt/dir.git
# push の際のリモートを社内(=ポートフォワードのポート)に向ける
git remote set-url --push origin http://localhost:10080/git/dir.git
git commit
git push 

じゃあはじめからポートフォワードで git fetch すればよいのかというとそうでもなくてオートスケールなどしたときに困ることになるので s3 においておくのがいいんじゃないでしょうか。

OpsWorks など他のgit連携サービスから s3 においたリポジトリにアクセスさせるには、s3 の該当バケットを http website として公開して、 http プロトコルのgitリポジトリとして指定してください。

明日のこのブログでは chef から s3 のリポジトリを fetch するレシピを紹介します*4

*1:それのeclipseプラグインが EGIT

*2:あとjavaがインストールされていれば

*3:たとえば pull ができないので fetch merge する必要がある

*4:Git Advent Calendar ではなく Chef Advent Calendar になります……

play アプリケーションのクラスパス指定を短くする


この記事は Play framework Advent Calendar 2014 の9日目です。
昨日は @nazoking さんの play2.3 の sbt-web を使わず node で代替システムを作るための資料 でした。
明日は @xuwei_k さんの 2014年に自分がしたpull req です。

play でアプリを立ち上げて ps axwwww とかでプロセス見たときに恐ろしいことになっているのにお気づきでしょうか・・・

/usr/bin/java -classpath /opt/myapp/lib/org.scala-lang.scala-library-2.10.0.jar:/opt/myapp/lib/play.play_2.10-2.1.3.jar:/opt/myapp/lib/play.sbt-link-2.1.3.jar:/opt/myapp/lib/org.javassist.javassist-3.16.1-GA.jar:/opt/myapp/lib/play.play-exceptions-2.1.3.jar:/opt/myapp/lib/play.templates_2.10-2.1.3.jar:/opt/myapp/lib/com.github.scala-incubator.io.scala-io-file_2.10-0.4.2.jar:/opt/myapp/lib/com.github.scala-incubator.io.scala-io-core_2.10-0.4.2.jar:/opt/myapp/lib/com.jsuereth.scala-arm_2.10-1.3.jar:/opt/myapp/lib/play.play-iteratees_2.10-2.1.3.jar:/opt/myapp/lib/org.scala-stm.scala-stm_2.10.0-0.6.jar:/opt/myapp/lib/com.typesafe.config-1.0.0.jar:/opt/myapp/lib/io.netty.netty-3.6.3.Final.jar:/opt/myapp/lib/com.typesafe.netty.netty-http-pipelining-1.1.1.jar:/opt/myapp/lib/org.slf4j.jul-to-slf4j-1.6.6.jar:/opt/myapp/lib/org.slf4j.jcl-over-slf4j-1.6.6.jar:/opt/myapp/lib/ch.qos.logback.logback-core-1.0.7.jar:/opt/myapp/lib/ch.qos.logback.logback-classic-1.0.7.jar:/opt/myapp/lib/com.typesafe.akka.akka-actor_2.10-2.1.0.jar:/opt/myapp/lib/com.typesafe.akka.akka-slf4j_2.10-2.1.0.jar:/opt/myapp/lib/org.slf4j.slf4j-api-1.7.2.jar:/opt/myapp/lib/joda-time.joda-time-2.1.jar:/opt/myapp/lib/org.joda.joda-convert-1.2.jar:/opt/myapp/lib/org.apache.commons.commons-lang3-3.1.jar:/opt/myapp/lib/com.ning.async-http-client-1.7.6.jar:/opt/myapp/lib/oauth.signpost.signpost-core-1.2.1.2.jar:/opt/myapp/lib/oauth.signpost.signpost-commonshttp4-1.2.1.2.jar:/opt/myapp/lib/commons-logging.commons-logging-1.1.1.jar:/opt/myapp/lib/org.codehaus.jackson.jackson-core-asl-1.9.10.jar:/opt/myapp/lib/org.codehaus.jackson.jackson-mapper-asl-1.9.10.jar:/opt/myapp/lib/net.sf.ehcache.ehcache-core-2.6.0.jar:/opt/myapp/lib/javax.transaction.jta-1.1.jar:/opt/myapp/lib/org.scala-lang.scala-reflect-2.10.0.jar:/opt/myapp/lib/play.play-jdbc_2.10-2.1.3.jar:/opt/myapp/lib/play.play-java_2.10-2.1.3.jar:/opt/myapp/lib/org.yaml.snakeyaml-1.10.jar:/opt/myapp/lib/org.hibernate.hibernate-validator-4.3.0.Final.jar:/opt/myapp/lib/javax.validation.validation-api-1.0.0.GA.jar:/opt/myapp/lib/org.jboss.logging.jboss-logging-3.1.0.CR2.jar:/opt/myapp/lib/org.springframework.spring-context-3.1.2.RELEASE.jar:/opt/myapp/lib/org.springframework.spring-core-3.1.2.RELEASE.jar:/opt/myapp/lib/org.springframework.spring-beans-3.1.2.RELEASE.jar:/opt/myapp/lib/org.reflections.reflections-0.9.8.jar:/opt/myapp/lib/com.google.guava.guava-13.0.1.jar:/opt/myapp/lib/com.google.code.findbugs.jsr305-2.0.1.jar:/opt/myapp/lib/javax.servlet.javax.servlet-api-3.0.1.jar:/opt/myapp/lib/com.jolbox.bonecp-0.7.1.RELEASE.jar:/opt/myapp/lib/com.h2database.h2-1.3.168.jar:/opt/myapp/lib/tyrex.tyrex-1.0.1.jar:/opt/myapp/lib/play.anorm_2.10-2.1.3.jar:/opt/myapp/lib/mysql.mysql-connector-java-5.1.18.jar:/opt/myapp/lib/org.scalaz.scalaz-core_2.10-6.0.4.jar:/opt/myapp/lib/eu.medsea.mimeutil.mime-util-2.1.3.jar:/opt/myapp/lib/log4j.log4j-1.2.14.jar:/opt/myapp/lib/com.amazonaws.aws-java-sdk-1.5.6.jar:/opt/myapp/lib/org.apache.httpcomponents.httpclient-4.2.jar:/opt/myapp/lib/org.apache.httpcomponents.httpcore-4.2.jar:/opt/myapp/lib/commons-codec.commons-codec-1.6.jar:/opt/myapp/lib/javax.mail.mail-1.4.5.jar:/opt/myapp/lib/javax.activation.activation-1.1.jar:/opt/myapp/lib/org.codehaus.janino.janino-2.6.1.jar:/opt/myapp/lib/org.codehaus.janino.commons-compiler-2.6.1.jar:/opt/myapp/lib/dnsjava.dnsjava-2.1.1.jar:/opt/myapp/lib/junit.junit-3.8.2.jar:/opt/myapp/lib/net.sf.opencsv.opencsv-2.0.jar:/opt/myapp/lib/com.ibm.icu.icu4j-50.1.jar:/opt/myapp/lib/jivesoftware.smack-3.1.0.jar:/opt/myapp/lib/jivesoftware.smackx-3.1.0.jar:/opt/myapp/lib/csvutil_2.10-1.0.jar:/opt/myapp/lib/scalacsv_2.10-0.1-SNAPSHOT.jar:/opt/myapp/lib/seqform_2.10-0.1-SNAPSHOT.jar:/opt/myapp/lib/dbcolumns_2.10-0.1-SNAPSHOT.jar:/opt/myapp/lib/seqformdbcolumns_2.10-0.1-SNAPSHOT.jar:/opt/myapp/lib/playlib_2.10-0.1-SNAPSHOT.jar:/opt/myapp/lib/playtestlib_2.10-0.1-SNAPSHOT.jar:/opt/myapp/lib/myapp_2.10-1.0-SNAPSHOT-98.jar play.core.server.NettyServer

みたいな感じですね!

そこで build.sbt に

    scriptClasspath := {
      val originalClasspath = scriptClasspath.value
      val manifest = new java.util.jar.Manifest()
      manifest.getMainAttributes().putValue("Class-Path", originalClasspath.mkString(" "))
      val classpathJar = (target in Universal).value / "lib" / "classpath.jar"
      IO.jar(Seq.empty, classpathJar, manifest)
      Seq(classpathJar.getName)
    },
    mappings in Universal += (((target in Universal).value / "lib" / "classpath.jar") -> "lib/classpath.jar"),

のようなことを書くと、

/usr/bin/java -classpath /opt/myapp/lib/classpath.jar play.core.server.NettyServer

のようにすっきりします!

windows で activator start とかして環境変数が長すぎて起動できない場合などにも効果的です。

ご利用ください。

play2.3 の sbt-web を使わず node で代替システムを作るための資料


この記事は Play framework Advent Calendar 2014 の8日目です。
昨日は @gakuzzzz さんの ActionFunction の紹介 でした。
明日は @nazoking さんの play アプリケーションのクラスパス指定を短くする です。

さて、 play2.3 から asset のコンパイルなどを sbt-web という仕組みが受け持つようになりました。

https://www.playframework.com/documentation/ja/2.3.x/Migration23
https://www.playframework.com/documentation/ja/2.3.x/Assets

build.sbt に

pipelineStages := Seq(rjs, digest, gzip)

のように記述してコンパイルすると /assets/javascripts/main.js から

41baf331c9f3eeec351f8cdaa65f99f3-main.js.map
b4a1bbd887d00d800a27468e005e61b1-main.js
b4a1bbd887d00d800a27468e005e61b1-main.js.gz
main.js
main.js.gz
main.js.map
main.js.map.md5
main.js.md5

が生成され、 project-1.0-SNAPSHOT-assets.jar のようなjarに入れられます。

ですが、いろいろ検討した結果 sbt/sbt-web/webjars というエコシステムが未成熟な気がしたので nodejs/gulp/bower で同じような環境を構築しました*1。その中で発見した、上記の生成されたファイルの驚くべき役割について。

play2.3より Assets.versioned という仕組みが導入されました。

route に

GET    /assets/*file    controllers.Assets.versioned(path="/public", file: Asset)

と設定して、 view に

<script src="@routes.Assets.versioned("assets/javascripts/main.js")"></script>

と書くと、

<script src="/assets/javascripts/b4a1bbd887d00d800a27468e005e61b1-main.js"></script>

と展開されます*2。ご想像通り、javascripts/b4a1bbd887d00d800a27468e005e61b1-main.js へのアクセスは積極的にキャッシュされます*3。この仕組みをになっているのが、上記の諸々のファイルと、 AssetBuilder クラスです。
https://github.com/playframework/playframework/blob/master/framework/src/play/src/main/scala/play/api/controllers/Assets.scala

AssetBuilder は次のように展開し、受け取ります。

  • 表示時の展開: main.js のパスがほしいだと? → main.js.md5 が存在して中身が b4a1bbd887d00d800a27468e005e61b1 だな → b4a1bbd887d00d800a27468e005e61b1-main.js と表示してやれ
  • 受信時の展開: b4a1bbd887d00d800a27468e005e61b1-main.js が欲しいだと? → main.js.md5 が存在して、中身が 入力ファイル名と一致するな → Accepts ヘッダに gzip あるな → main.js.gz が存在するな → main.js.gz を主力

ついでに map ファイルの話もしておきますがこれはとソースファイルマップファイルで、 main.js の最後に場所が書かれており、その中身は main.js で、ソースパスはそのままブラウザに渡り、ブラウザ「main.js のソース見たいから main.js.map ください」play:「main.js.map が欲しいだと? main.js.map が存在するな → Accepts ヘッダに gzip あるな → main.js.map.gz がないな → main.js.map どうぞ」のようなやりとりがされます。

お気づきでしょうか?上記の生成されたファイルの一覧に不具合があることに。。。

41baf331c9f3eeec351f8cdaa65f99f3-main.js.map ← どこからも参照されない
b4a1bbd887d00d800a27468e005e61b1-main.js  ← どこからも参照されない
b4a1bbd887d00d800a27468e005e61b1-main.js.gz  ← どこからも参照されない
main.js  ← 存在チェックのみ
main.js.gz  ← レスポンスの出力内容
main.js.map  ← 存在チェックのみ
main.js.map.md5  ← どこからも参照されない
main.js.md5  ← レスポンスの出力内容
<del>main.js.md5.gz</del>  ← 使われるはずなのに生成されてない

なぜこうなっているのかは分かりませんが、 gulp などで代替システムを組む場合、次のように生成すればよいことが分かります。

main.js
+ 結合やminifyとソースファイルマップの生成( browserify とか ) = main.js (同名でminifyする場合), main.js.map
+ md5ファイルの生成 = main.js.md5 , main.js.map.md5
+ gzファイルの生成( gulp-gzip とか) = main.js.gz , main.js.map.gz

最終的な生成物は次のようになります。

main.js  ← 結合minifyされたファイル
main.js.gz  ← gzip圧縮されたファイル
main.js.md5  ← 結合minifyされたファイルの md5
main.js.map  ← ソースマップ(含む 結合minify前の元ファイル)
main.js.map.md5  ← ソースマップの md5
main.js.md5.gz  ← ソースマップの gzip 圧縮されたもの

*.md5 を生成するプログラムだけ見つからなかったので*4、自作しました。.md5 の中身は md5のhex値です*5

/**
  ファイルが来たら ファイル名.md5 に変換する(本体は事前に gulp.dest しておく)
 */
var through2 = require('through2');
var gutil = require('gulp-util');
var crypto = require('crypto');
module.exports = function(){
  'use strict';
  return through2.obj(function(file, enc, cb) {
    var stream = crypto.createHash("md5");
    file.pipe(stream);
    var hash = stream.read().toString('hex');
    var hashfile = new gutil.File({
      cwd: file.cwd,
      base: file.base,
      path: file.path+'.md5',
      contents: new Buffer(hash)
    });
    this.push(hashfile);
    cb();
  });
};

ご利用ください。

ちなみに 不要っぽいファイルの「b4a1bbd887d00d800a27468e005e61b1-main.js」も、「大量のリクエストを裁くために project-1.0-SNAPSHOT-assets.jar を CDN とかに展開して play ではそのリクエストを処理しないようにする」という目的であれば必要になるかもしれません。

*1:javascriptの事はjavascript界の皆様に任せる感じで……

*2:開発モードでは展開されません

*3:しかしexpireヘッダは設定されない……

*4:b4a1bbd887d00d800a27468e005e61b1-main.js にリネームするものはいくつかある

*5:本当の md5値である必要はなくコンテンツの一意性を示す適当な英数値であれば良さそうだけど、面倒なので本物のmd5使う