追記:Herokuに簡単にデプロイできるようにしました。(2015/9/28)
https://github.com/mmizutani/play-gulp-standalone
https://play-gulp-standalone.herokuapp.com
sbt-webからGulpへ
Play Frameworkでは、スタティックアセットはapp/assetsフォルダからsbt-webプラグインでコンパイルされたものとpublic/フォルダにあるファイルがjarにまとめられてクライアントに送るかたちになっています。Play Frameworkを使い始めてしばらくの間はsbt-webのアセットコンパイルで充分で、bower/npmのパッケージを取りこめるようになったWebJarのエコシステムにも満足していたのですが、やれTypeScriptだやれReactだなどといってフロントエンドがどんどん複雑化してくると、sbt-webのプラグイン群(sbt-rjs, autoprefixerなど)の中には長期間更新のないものも散見される現状では心許なくなってきました。
ということで、今回、Playアプリのアセットコンパイルを人気のJavaScriptタスクランナーGulpにまるごと乗り換えられるようにしてみました。基本的には、sbtからGruntを呼び出しつつスタティックアセットへのアクセスをyeomanのジェネレータで作成したhtmlプロジェクトにリダイレクトする方法をとっているPlayプラグインplay-yeomanのやり方に倣いつつ、GruntをGulpに置き換え、かつプラグインもサブプロジェクトも不要な形に簡素化しました。プロジェクトの細かい微調整は必要ですが、新規に作成する必要があるScalaファイルは2つのみです。
ささっと使ってみたい方は[こちらのサンプルプロジェクト https://github.com/mmizutani/play-gulp-standalone をcloneして、普通のPlayプロジェクト同様にsbt run
, sbt ~run
, sbt test
, sbt testProd
, sbt ";stage;dist"
(Typesafe Activatorならactivator run
, activator ~run
, activator test
, activator testProd
, activator ";stage;dist"
) などをしてみてください。
設定
0. 前提
Scala, sbt(またはTypesafe Activator), Node.js (node & npm)をインストール済。
1. プロジェクト作成
まずgenerator-gulp-angularをインストールし、
$ npm install -g yo gulp bower
$ npm install -g generator-gulp-angular
テンプレートからプレーンなPlayプロジェクトを作成します。プロジェクトの名称はここではとりあえずplay-scalaとしておきます。
$ activator new play-gulp
Choose from these featured templates or enter a template name:
1) minimal-akka-java-seed
2) minimal-akka-scala-seed
3) minimal-java
4) minimal-scala
5) play-java
6) play-scala
(hit tab to see a list of all templates)
> 6
OK, application "play-gulp" is being created using the "play-scala" template.
そして、rm -rf public
で、プロジェクトルートのpublicフォルダとはおさらばします。代わりにプロジェクトルートにuiフォルダを作成して、この中にさきほどインストールしたyeomanのジェネレータの1つ、gulp-angularでプロジェクト雛形を作成します。(gulpのプロジェクト雛形であれば他のyeomanジェネレータでも以降の設定方法はほぼ同じなはずです。)
$ cd play-gulp
$ rm -rf public
$ mkdir ui && cd $_
$ yo gulp-angular
2. project/PlayGulp.scalaの作成
projectフォルダ内にPlayGulp.scalaファイルを新規作成し、Gulpのタスクをsbtのタスクに紐付けてsbtコンソールから呼び出せるようにするための設定をします。
import com.typesafe.sbt.packager.universal.UniversalPlugin.autoImport._
import com.typesafe.sbt.web.Import._
import play.sbt.PlayImport.PlayKeys._
import play.sbt.PlayRunHook
import play.twirl.sbt.Import.TwirlKeys
import sbt.Keys._
import sbt._
object PlayGulp {
lazy val gulpDirectory = SettingKey[File]("gulp-directory", "gulp directory")
lazy val gulpFile = SettingKey[String]("gulp-file", "gulpfile")
lazy val gulpExcludes = SettingKey[Seq[String]]("gulp-excludes")
lazy val gulp = InputKey[Unit]("gulp", "Task to run gulp")
lazy val gulpBuild = TaskKey[Int]("gulp-dist", "Task to run dist gulp")
lazy val gulpClean = TaskKey[Unit]("gulp-clean", "Task to run gulp clean")
lazy val gulpTest = TaskKey[Unit]("gulp-test", "Task to run gulp test")
val playGulpSettings: Seq[Setting[_]] = Seq(
// Specifies the location of the root directory of the Gulp project relative to the Play app root
gulpDirectory <<= (baseDirectory in Compile) { _ / "ui" },
gulpFile := "gulpfile.js",
gulp := {
val base = (gulpDirectory in Compile).value
val gulpfileName = (gulpFile in Compile).value
runGulp(base, gulpfileName, Def.spaceDelimited("<arg>").parsed.toList).exitValue()
},
gulpBuild := {
val base = (gulpDirectory in Compile).value
val gulpfileName = (gulpFile in Compile).value
val result = runGulp(base, gulpfileName, List("build")).exitValue()
if (result == 0) {
result
} else throw new Exception("gulp failed")
},
gulpClean := {
val base = (gulpDirectory in Compile).value
val gulpfileName = (gulpFile in Compile).value
val result = runGulp(base, gulpfileName, List("clean")).exitValue()
if (result != 0) throw new Exception("gulp failed")
},
gulpTest := {
val base = (gulpDirectory in Compile).value
val gulpfileName = (gulpFile in Compile).value
val result = runGulp(base, gulpfileName, List("test")).exitValue()
if (result != 0) throw new Exception("gulp failed")
},
// Executes `gulp build` before `sbt dist`
dist <<= dist dependsOn gulpBuild,
// Executes `gulp build` before `sbt stage`
stage <<= stage dependsOn gulpBuild,
// Executes `gulp clean` before `sbt clean`
clean <<= clean dependsOn gulpClean,
// Executes `gulp test` before `sbt test` (optional)
//(test in Test) <<= (test in Test) dependsOn gulpTest,
// Ensures that static assets in the ui/dist directory are packaged into
// target/scala-2.11/play-gulp_2.11-1.0.0-web-asset.jar/public when the play app is compiled
unmanagedResourceDirectories in Assets <+= (gulpDirectory in Compile)(_ / "dist"),
// Starts the gulp watch task before sbt run
playRunHooks <+= (gulpDirectory, gulpFile).map {
(base, fileName) => GulpWatch(base, fileName)
},
// Allows all the specified commands below to be run within sbt in addition to gulp
commands <++= gulpDirectory {
base =>
Seq(
"npm",
"bower",
"yo"
).map(cmd(_, base))
}
)
val withTemplates: Seq[Setting[_]] = Seq(
// Added ui/src/views/*.scala.html to the target of Scala view template compilation
sourceDirectories in TwirlKeys.compileTemplates in Compile <+= (gulpDirectory in Compile)(_ / "src"),
includeFilter in sources in TwirlKeys.compileTemplates := "*.scala.html",
gulpExcludes <<= gulpDirectory(gd => Seq(
gd + "/src/app/",
gd + "/src/assets/",
gd + "/src/bower_components/"
)),
excludeFilter in unmanagedSources <<=
(excludeFilter in unmanagedSources, gulpExcludes) {
(currentFilter: FileFilter, ge) =>
currentFilter || new FileFilter {
def accept(pathname: File): Boolean = {
(true /: ge.map(s => pathname.getAbsolutePath.startsWith(s)))(_ && _)
}
}
},
// Makes play autoreloader to compile Scala view templates and reload the browser
// upon changes in the view files ui/src/views/*.scala.html
// Adds ui/src/views directory's scala view template files to continuous hot reloading
watchSources <++= gulpDirectory map { path => ((path / "src/views") ** "*.scala.html").get}
)
private def runGulp(base: sbt.File, fileName: String, args: List[String] = List.empty): Process = {
if (System.getProperty("os.name").startsWith("Windows")) {
val process: ProcessBuilder = Process("cmd" :: "/c" :: "gulp" :: "--gulpfile=" + fileName :: args, base)
println(s"Will run: ${process.toString} in ${base.getPath}")
process.run()
} else {
val process: ProcessBuilder = Process("gulp" :: "--gulpfile=" + fileName :: args, base)
println(s"Will run: ${process.toString} in ${base.getPath}")
process.run()
}
}
import scala.language.postfixOps
private def cmd(name: String, base: File): Command = {
if (!base.exists()) {
base.mkdirs()
}
Command.args(name, "<" + name + "-command>") {
(state, args) =>
if (System.getProperty("os.name").startsWith("Windows")) {
Process("cmd" :: "/c" :: name :: args.toList, base) !<
} else {
Process(name :: args.toList, base) !<
}
state
}
}
object GulpWatch {
def apply(base: File, fileName: String): PlayRunHook = {
object GulpSubProcessHook extends PlayRunHook {
var process: Option[Process] = None
override def beforeStarted(): Unit = {
process = Some(runGulp(base, fileName, "watch" :: Nil))
}
override def afterStopped(): Unit = {
process.foreach(_.destroy())
process = None
}
}
GulpSubProcessHook
}
}
}
build.sbtでこの設定をプロジェクトに読み込みます。
import PlayGulp._
...
lazy val root = (project in file("."))
.enablePlugins(PlayScala)
.settings(playGulpSettings ++ withTemplates) //この行を追記
3. app/controllers/GulpAssets.scalaの作成
続いて、スタティックアセットへのアクセスをuiフォルダのgulp-angularプロジェクトにルーティングするためのGulpAssetsコントローラを作成します。
package controllers
import java.io.File
import javax.inject.Singleton
import play.api.Play.current
import play.api.{Logger, _}
import play.api.http.DefaultHttpErrorHandler
import play.api.libs.concurrent.Execution.Implicits.defaultContext
import play.api.mvc.{Action, AnyContent}
import scala.concurrent.Future
object GulpAssets extends controllers.Assets(DefaultHttpErrorHandler) {
private lazy val logger = Logger(getClass)
def index = Action.async { request =>
if (request.path.endsWith("/")) {
at("index.html").apply(request)
} else {
Future(Redirect(request.path + "/"))
}
}
// List of UI directories from which static assets are served in development mode
// (A directory in higher priority comes first.)
val devBasePaths: List[java.io.File] = List(
Play.application.getFile("ui/.tmp/serve"),
Play.application.getFile("ui/src"),
Play.application.getFile("ui")
)
def devAssetHandler(file: String): Action[AnyContent] = Action { request =>
// Generates a non-strict list of the full paths
val targetPaths = devBasePaths.view map {
new File(_, file)
}
// Generates responses returning the file in the dev and test modes only (not in the production mode)
val responses = targetPaths filter { file =>
file.exists()
} map { file =>
if (file.isFile) {
logger.info(s"Serving $file")
Ok.sendFile(file, inline = true).withHeaders(CACHE_CONTROL -> "no-store")
} else {
Forbidden(views.html.defaultpages.unauthorized())
}
}
// Returns the first valid path if valid or NotFound otherwise
responses.headOption getOrElse NotFound("404 - Page not found error\n" + request.path)
}
def prodAssetHandler(file: String): Action[AnyContent] = Assets.at("/public", file)
lazy val atHandler: String => Action[AnyContent] = if (Play.isProd) prodAssetHandler(_: String) else devAssetHandler(_: String)
def at(file: String): Action[AnyContent] = atHandler(file)
}
@Singleton
class GulpAssets extends controllers.Assets(DefaultHttpErrorHandler) {
def at(file: String) = GulpAssets.at(file: String)
}
4. conf/routesの変更
デフォルトで設定されているスタティックアセット用のルーティング(Assets.versioned)は削除し、かわりに先ほど作成したGulp用のスタティックアセットのルーティングの設定(GulpAssets.at)を一番下に加えます。
GET / controllers.Application.index
GET /oldhome controllers.Application.oldhome
GET /*file controllers.GulpAssets.at(file)
#GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
routeを修正したので、viewsのテンプレートファイルも合わせて修正します。
Before
@(title: String)(content: Html)
<!DOCTYPE html>
<html lang="en">
<head>
<title>@title</title>
<link rel="stylesheet" media="screen" href="@routes.Assets.versioned("stylesheets/main.css")">
<link rel="shortcut icon" type="image/png" href="@routes.Assets.versioned("images/favicon.png")">
<script src="@routes.Assets.versioned("javascripts/hello.js")" type="text/javascript"></script>
</head>
<body>
@content
</body>
</html>
After
@(title: String)(content: Html)
<!DOCTYPE html>
<html lang="en">
<head>
<title>@title</title>
<link rel="stylesheet" media="screen" href="@routes.GulpAssets.at("assets/styles/oldhome-style.css")">
<link rel="shortcut icon" type="image/png" href="@routes.GulpAssets.at("assets/favicon.png")">
<script type="text/javascript" src="@routes.GulpAssets.at("assets/scripts/oldhome-script.js")"></script>
</head>
<body>
@content
</body>
</html>
上記のroutes.GulpAssets.at("assets/~/~.xxx")の実パスはui/src/assets/~/~.xxxに相当するので、この場所に適当にoldhome-style.cssやoldhome-script.jsをつくっておきます。
$ touch ui/src/assets/styles/oldhome-style.css
$ touch ui/src/assets/scripts/oldhome-script.js
そしてApplication.indexはGulpAssets.indexに設定して、URLのルート/へのアクセスは、開発時はui/.tmp/serve/index.htmlにリダイレクトされるようにします。従来のindexページは適当に/oldhomeでアクセスできるようにしておきます。
class Application extends Controller {
def index = GulpAssets.index
def oldhome = Action {
Ok(views.html.index("Play Framework"))
}
}
5. gulp-angularプロジェクトの微修正
さすがにgulp-angularのプロジェクト構成そのままではPlayアプリと統合できないので、以下を修正します。
- ui/.bowerrc
Before
{
"directory": "bower_components"
}
After
{
"directory": "src/bower_components"
}
- bower_componentsはsrcフォルダの中へ移動
mv ui/bower_components ui/src/bower_components
- ui/gulp/conf.js
Before
exports.wiredep = {
exclude: [/bootstrap.js$/, /bootstrap-sass-official\/.*\.js/, /bootstrap\.css/],
directory: 'bower_components'
};
After
exports.wiredep = {
exclude: [/bootstrap.js$/, /bootstrap-sass-official\/.*\.js/, /bootstrap\.css/],
directory: 'src/bower_components'
};
- ui/gulp/build.js
Before
gulp.task('fonts', function () {
return gulp.src($.mainBowerFiles())
.pipe($.filter('**/*.{eot,svg,ttf,woff,woff2}'))
.pipe($.flatten())
.pipe(gulp.dest(path.join(conf.paths.dist, '/fonts/')));
});
...
gulp.task('build', ['html', 'fonts', 'other']);
After
gulp.task('fonts', function () {
return gulp.src(path.join(conf.paths.src, '/bower_components/**/*'))
.pipe($.filter('**/*.{eot,svg,ttf,woff,woff2}'))
.pipe($.flatten())
.pipe(gulp.dest(path.join(conf.paths.dist, '/fonts/')));
});
gulp.task('otherassets', function () {
return gulp.src(path.join(conf.paths.src, '/assets/**/*'))
.pipe(gulp.dest(path.join(conf.paths.dist, '/assets/'), {overwrite: false}));
});
...
gulp.task('build', ['html', 'fonts', 'other', 'otherassets']);
必須ではないですが、各スタティックファイルがどのディレクトリからクライアントに送信させているのかがコンソールにリアルタイムに表示されたほうが慣れるまではいいと思うので、logの出力レベルをERRORからINFOに緩めておきます。
...
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
...
これでプロジェクト設定は完了です。
使い方
sbt/activatorのコンソールに入ると、
$ sbt/activator
build, clean, serve, testなどのgulpのタスクをuiディレクトリをルートとして実行できるようになっています。gulpのデフォルトタスク(ここではgulp build)を実行すると、ui/buildディレクトリにng-annotate, uglify等がされたその他スタティックファイルと、JavaScript/CSSをinjectされたindex.htmlファイルができます。
[play-gulp] > gulp
Will run: [gulp, --gulpfile=gulpfile.js, build] in /home/user/dev//play-gulp/ui
[23:41:29] Using gulpfile ~/dev/play-gulp/ui/gulpfile.js
[23:41:29] Starting 'clean'...
[23:41:29] Finished 'clean' after 53 ms
[23:41:29] Starting 'default'...
[23:41:29] Starting 'scripts'...
[23:41:29] Starting 'styles'...
[23:41:29] Starting 'partials'...
[23:41:29] Starting 'fonts'...
[23:41:29] Starting 'other'...
[23:41:29] Starting 'otherassets'...
[23:41:29] Finished 'default' after 480 ms
[23:41:29] gulp-inject 2 files into index.scss.
[23:41:31] Finished 'styles' after 1.87 s
[23:41:31] all files 7.88 kB
[23:41:31] Finished 'scripts' after 2.04 s
[23:41:31] Starting 'inject'...
[23:41:31] Finished 'partials' after 1.73 s
[23:41:31] gulp-inject 1 files into index.html.
[23:41:31] Finished 'otherassets' after 1.64 s
[23:41:31] gulp-inject 10 files into index.html.
[23:41:31] Finished 'inject' after 121 ms
[23:41:31] Starting 'html'...
[23:41:31] gulp-inject 1 files into index.html.
[23:41:40] 'dist/' styles/app-c90c023e53.css 120.34 kB
[23:41:40] 'dist/' styles/vendor-1dddaadd0b.css 58.62 kB
[23:41:40] 'dist/' scripts/app-10800ada38.js 6.26 kB
[23:41:40] 'dist/' scripts/vendor-48a98d1612.js 438.69 kB
[23:41:40] 'dist/' index.html 636 B
[23:41:40] 'dist/' all files 624.56 kB
[23:41:40] Finished 'html' after 8.83 s
[23:41:40] Finished 'other' after 11 s
[23:41:40] Finished 'fonts' after 11 s
[23:41:40] Starting 'build'...
[23:41:40] Finished 'build' after 7.29 μs
runにより開発モードでアプリケーションを実行すると、PlayGulp.scala
の設定
...
playRunHooks <+= (gulpDirectory, gulpFile).map {
(base, fileName) => GulpWatch(base, fileName)
}
...
によりgulp watchタスクが呼ばれ、http://localhost:9000 を開くと、
[play-gulp] > run
Will run: [gulp, --gulpfile=gulpfile.js, watch] in /home/user/dev/play-gulp/ui
--- (Running the application, auto-reloading is enabled) ---
[info] p.a.l.c.ActorSystemProvider - Starting application default Akka system: application
[info] p.c.s.NettyServer - Listening for HTTP on /localhost:9000
(Server started, use Ctrl+D to stop and go back to the console...)
Yeomanのシンボルキャラクターのひげおじさんと昔の人気British sitcomのタイトルが飾る(なぜこのタイトルがここで出てくるのか理由は知りません...)トップ画面になります。
ターミナルを見てみると、GulpAssetsコントローラ経由でどこのパスからファイルがserveされているかわかります。
[23:52:50] Using gulpfile ~/dev/play-gulp/ui/gulpfile.js
[23:52:50] Starting 'scripts'...
[23:52:50] Starting 'styles'...
[23:52:50] gulp-inject 2 files into index.scss.
[23:52:51] all files 7.88 kB
[23:52:51] Finished 'scripts' after 530 ms
[23:52:52] Finished 'styles' after 1.43 s
[23:52:52] Starting 'inject'...
[23:52:52] gulp-inject 1 files into index.html.
[23:52:52] gulp-inject 10 files into index.html.
[23:52:52] Finished 'inject' after 61 ms
[23:52:52] Starting 'watch'...
[23:52:52] Finished 'watch' after 15 ms
...
[info] - play.api.Play - Application started (Dev)
[info] - controllers.GulpAssets - Serving /home/user/dev/play-gulp/ui/.tmp/serve/index.html
[info] - controllers.GulpAssets - Serving /home/user/dev/play-gulp/ui/src/bower_components/toastr/toastr.css
[info] - controllers.GulpAssets - Serving /home/user/dev/play-gulp/ui/src/bower_components/angular-animate/angular-animate.js
[info] - controllers.GulpAssets - Serving /home/user/dev/play-gulp/ui/.tmp/serve/app/index.css
[info] - controllers.GulpAssets - Serving /home/user/dev/play-gulp/ui/src/bower_components/animate.css/animate.css
[info] - controllers.GulpAssets - Serving /home/user/dev/play-gulp/ui/src/bower_components/angular/angular.js
[info] - controllers.GulpAssets - Serving /home/user/dev/play-gulp/ui/src/bower_components/jquery/dist/jquery.js
...
見えているトップページの実体は、デバッグ時にGulpが一時的に生成するui/.tmp/serve/index.html
です。
デバッグをやめて、sbt(またはactivator)のstage
タスクを実行すると、
[play-gulp] > stage
target/scala-2.11/play-gulp_2.11-1.0-web-assets.jar
がほかのjarファイルとともに生成されます。play-gulp_2.11-1.0-web-assets.jar
をzipファイルとして解凍すると、Gulpによりビルドされたスタティックファイルが入っているui/dist
フォルダの中身がそのままそっくりjar内のpublicフォルダにコピーされているのがわかります。
このjar/publicには本来はプロジェクトルートのpublicフォルダ内のスタティックファイルがコピーされますが、このプロジェクトでは、プロジェクトルート/publicフォルダを削除して、代わりに下記のプロジェクト設定
unmanagedResourceDirectories in Assets <+= (gulpDirectory in Compile)(_ / "dist")
を追加しているため、このようになります。
Gulpでビルドしたスタティックアセットは、最終的にはdist
タスクにより、デプロイ用ファイル一式target/universal/play-gulp-1.0.zip
の中のlibに格納されます。
リリースモードで実行し、ブラウザでディベロッパーツールを開くと、JavaScript/CSSがきちんとminify, concatenate, obfuscateされているのがわかります。
[play-gulp] > testProd
app/views/~.scala.htmlのテンプレートからスタティックアセットにアクセスしたい時は、JavaScript, CSS, PDF, 画像、音楽ファイルなど(たとえばphoto.png)をui/src/assets以下のディレクトリに置いておけば、GulpAssetsコントローラ経由で開発時もリリース時もアクセスできます。(routesで/をgulp-angularのトップページに設定しているので、この設定のままであればGulpAssetsを使わず"/assets/images/photo.png"でも可。)
<img src="@routes.GulpAssets.at("assets/images/photo.png")">
<img src="/assets/images/photo.png">
<img src="@routes.Assets.at("assets/images/photo.png")">
<img src="/assets/images/photo.png">
サーバサイドとフロントエンドの分離開発
このプロジェクト構成のいいところとして、Gulpをフルに使える点に加えて、サーバサイドとフロントエンドの分離開発がしやすいという点も挙げられます。
プロジェクトルートのuiフォルダ内は一般的なGulpプロジェクトなので、Play Frameworkプロジェクトには触らず、uiディレクトリ内のgulp-angularプロジェクトだけをgulpコマンドで開発していくことができます。
$ cd ui
$ gulp clean
$ gulp serve
$ gulp test
$ gulp build
ui以下をgit submoduleとして管理しておくと、デザイナーさんとの分業がはかどると思います。
その他
GitHubにあげたサンプルプロジェクトでは、おまけとして、AngularJSのhtml5modeを有効にしたり、twirlのScala htmlテンプレートファイルをui/src/views/~~/~.scala.html に置いてみたり、ApplicationコントローラにREST APIっぽいアクションを追加してみたりしています。開発モードで http://localhost:9000/routes からいろんなパスを叩いてみてください。
大してファイルを追加していませんし、Gulpのプロジェクト構成は人それぞれだと思いますので、改変しやすいようあえてプラグインにはしていません。ご自由にカスタマイズしていただいて結構です。