Jetty と grunt で Java でもサクサク Web 開発

最近はじめて Web アプリ開発を行っています。諸般の理由によりおおよそ以下の構成になっています。

* サーバーサイド
  * Java (JAX-RS)
  * 開発環境は Jetty
  * プロダクションは WebLogic
  * ビルドは [Gradle](http://www.gradle.org/)

* クライアントサイド
  * CoffeeScript / SCSS / HTML5 / Handlebars
  * Backbone.js
  * ビルドは [grunt](http://gruntjs.com/) ([Yeoman](http://yeoman.io/) ベース)

僕は今まで Servlet は勿論 WebLogic なんてさっぱり触ったことが無かったのですが, WebLogic へのデプロイはとにかく面倒で時間がかかるみたいなので開発環境は Jetty にしています。基本的には Jetty を起動しておきながら grunt で watch して各種アセットのプリコンパイルを行いつつ、たまに必要に応じて Jetty を再起動させて動作確認するといった具合。こうすると grunt を使いながら高速に起動する Jetty で動作確認出来るのでとっても楽です。

当初は Gradle の ```jettyRun``` タスクを使っていたのですが, IDE を使って開発しているといちいち Gradle で起動した Jetty をリモートデバッグするよりもスタンドアローンの Java プログラムとして起動した方が楽なので埋め込みの Jetty サーバを利用するようにしてみました。なかなかサクサク開発出来るので結構オススメです（といっても普段から Java で Web 開発をしている人には普通の内容なのかもしれませんが……）。

ちなみに grunt は Yeoman で生成した Gruntfile をもろもろ修正しながら使っているのですが, Yeoman ではプリコンパイルしたアセットを ```.tmp``` に配置し, ```.tmp``` と ```app``` の両方のディレクトリをマウントしてサーバを起動することになっています（逆にプロダクション用にビルドした場合は ```dist``` 以下に全てのアセットが出力されます）。この場合, Jetty では ```WebAppContext#setBaseResource``` に ```.tmp``` と ```app``` を ```ResourceCollection``` として渡してやれば良いみたいです。僕はこんな感じにしています。Eclipse を使っている場合は環境に合わせて launch ファイルを作ってあげれば良いですね。

```java
/**
 * 埋め込みの Jetty サーバを利用してスタンドアローンで起動する Web アプリケーションです。
 * 
 * @author monzou
 */
public class JettyLauncher {

    private static final Logger LOGGER = LoggerFactory.getLogger(JettyLauncher.class);

    static {
        Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionHandler() {

            @Override
            public void uncaughtException(Thread t, Throwable e) {
                LOGGER.error("uncaught exception occurred.", e);
                exit(false);
            }
        });
    }

    /**
     * Web アプリケーションのエントリポイントです。
     * 
     * @param args 引数
     * @throws Exception サーバ起動中に発生した例外
     */
    public static void main(String[] args) throws Exception {

        if (args.length != 1) {
            throw new IllegalArgumentException("usage: you have to specify environment (`development` or `production`).");
        }

        try {
            JettyLauncher launcher = new JettyLauncher(args[0]);
            launcher.launch();
        } catch (Throwable t) {
            LOGGER.error("failed to start server.", e);
            exit(false);
        }

    }

    private static void exit(boolean normally) {
        System.exit(normally ? 0 : -1);
    }

    private final String environment;

    private JettyLauncher(String environment) {
        this.environment = environment;
    }

    private void launch() throws Exception {

        Config config = ConfigurationLoader.load(Config.class, String.format("jetty-%s.yml", environment));
        final WebApplication webapp = new WebApplication(config);

        Thread shutdownHook = new Thread("shutdown-hook") {

            @Override
            public void run() {
                try {
                    webapp.stop();
                } catch (Throwable t) {
                    LOGGER.error("unknown error occurred.", t);
                }
            }
        };

        Runtime.getRuntime().addShutdownHook(shutdownHook);
        webapp.boot();

    }

    private static class WebApplication {

        private final Config config;

        private Server server;

        WebApplication(Config config) {
            this.config = config;
        }

        void boot() throws Exception {
            server = new Server(config.port);
            server.setHandler(createContext());
            server.start();
            server.join();
        }

        private WebAppContext createContext() {

            WebAppContext context = new WebAppContext();
            context.setConfigurations(new Configuration[] { //
            new WebXmlConfiguration() //
                , new WebInfConfiguration() //
                , new PlusConfiguration() //
                , new MetaInfConfiguration() //
                , new FragmentConfiguration() //
                , new EnvConfiguration() //
            });
            context.setContextPath("/");
            context.setDescriptor(config.webapp.path("src/main/webapp/WEB-INF/web.xml"));
            context.setBaseResource(new ResourceCollection(config.www.resources()));
            context.setParentLoaderPriority(true);
            context.setInitParameter("org.eclipse.jetty.servlet.Default.cacheControl", "max-age=0, public"); // 静的ファイルをキャッシュしないようにする
            context.setInitParameter("org.eclipse.jetty.servlet.Default.useFileMappedBuffer", "false"); // Windows で静的ファイルがロックされてしまう問題に対応

            return context;

        }

        void stop() throws Exception {
            if (server != null) {
                try {
                    server.stop();
                } finally {
                    server = null;
                }
            }
        }

    }

    private static class Config {

        public int port;

        public WebApp webapp;

        @JsonProperty("static")
        public Static www;

        static class WebApp {

            public String dir;

            String path(String relative) {
                return String.format("%s/%s", dir, relative);
            }

        }

        static class Static implements Function<String, String> {

            public String dir;

            public List<String> resources = Lists.newArrayList();

            String[] resources() {
                return Collections2.transform(resources, Static.this).toArray(new String[resources.size()]);
            }

            /** {@inheritDoc} */
            @Override
            public String apply(String relative) {
                return String.format("%s/%s", dir, relative);
            }

        }

    }

}
```

詳細は [gist](https://gist.github.com/monzou/6062166) に上げてみました。ちなみに gist の方には書いていませんが, Gradle の ```jettyRun``` で ```.tmp``` と ```app``` を両方マウントしたい場合はこんな感じ。

```groovy
// build.gradle
import org.gradle.api.plugins.jetty.internal.JettyPluginWebAppContext

[jettyRun, jettyRunWar]*.contextPath = "/"
[jettyRun, jettyRunWar]*.httpPort = 8080
[jettyRun, jettyRunWar]*.scanIntervalSeconds = 1

def newResourceCollection(File... resources) {
    shell = new GroovyShell(JettyPluginWebAppContext.class.classLoader)
    shell.setProperty("resources", resources as String[])
    return shell.evaluate(file("resource_collection.groovy"))
}

// 開発環境では web プロジェクトのリソースを参照する
jettyRun.doFirst {
    jettyRun.webAppConfig = new JettyPluginWebAppContext()
    jettyRun.webAppConfig.baseResource = newResourceCollection(
        file("${webProjectDir}/app"),
        file("${webProjectDir}/.tmp")
    )
}
```
```groovy
// resource_collection.groovy
import org.mortbay.resource.ResourceCollection
new ResourceCollection(resources)
```

なおプロダクション用にビルドする際は, こんな感じで Gradle の war タスクに引っかけて grunt で build しています。

```groovy
// build.gradle
task buildStatic(type: Exec) {
    workingDir webProjectDir
    commandLine "grunt build --debug"
}

war {
    manifest.attributes = jar.manifest.attributes
    from "${webProjectDir}/dist"
}
war.dependsOn buildStatic
```

実は結局 Jenkins で別個にビルドしてから下流ジョブで war を作ることにしたんですけどまぁこんな感じで大丈夫なはずです。

Jetty と grunt があればサクサク開発出来るよ、ってお話でした。Servlet も Jetty も初めてなので何か間違っていたらツッコミ頂けるとありがたいです。（実は grunt も JS も殆ど初めてですが……）