Spring Boot + Thymeleaf + Tomcat + Gradleで業務系アプリ 躓いた所6点

JavaでWEBアプリケーションを作りたい！！という要望に今答えられるフレームワークはいくつかあるが、
その中でSpring Bootは大きな存在感があると思う。
短期間で業務系アプリを構築してほしいという要望が来たのでSpring Bootを使って開発した。
いくつか躓いた点をメモしていたので、それについて共有します。

※注意：これは実際使ってみて困った点を共有するために記述しています。
※Spring Bootを使ったことがないというかたは、こちらの方が参考になるかと思います。：http://qiita.com/opengl-8080/items/05d9490d6f0544e2351a
 
###システム全体像
・フレームワーク：Spring Boot
・実行環境：開発中は組込みTomcat。本番運用はTomcat7上で。
・ログイン認証：Spring Security
・プレゼンテーション層：Thymeleaf。見た目をきれいにするためにBootstrap + Flat UIを利用。
・言語：Java SE 8
・運用時はHTTPS通信を行う想定
・フロントはApache2.2

開発チームに Eclipse ユーザが多かったのでEclipse4.4を選択。Checkstyle + CodeFormatterを定義して、全員で共有。

## １：開発環境を定義するのって結構大変だよね・・・

Spring Bootを使うと環境構築はgradle + application.propertiesでほぼ完了する。とはいえ、0から作るには時間がかかるので出来るだけSpringBoot公式ページから引っ張ってきた方が良い。
ただし1行1行意味があるので適当には出来ない。

```lang:build.gradle

apply plugin: "java"
apply plugin: "spring-boot"
apply plugin: "war"
apply plugin: "eclipse"

description = "XXXXXXX"
war {
    baseName = "XXXXXXX"
}
configurations.all {
    resolutionStrategy {
        eachDependency {
            if (it.requested.group == "org.apache.tomcat.embed") {
                it.useVersion "7.0.59"
            }
        }
    }
}
buildscript {
    repositories {
        jcenter()
        mavenCentral()
    }
    dependencies {
        classpath "org.springframework.boot:spring-boot-gradle-plugin:1.2.3.RELEASE"
        classpath "org.springframework:springloaded:1.2.3.RELEASE"
    }
}
configurations {
    providedRuntime
}
dependencies {
    def springBootVersion="1.2.3.RELEASE"
    compile "net.sf.dozer:dozer:5.5.1"
    compile "org.springframework.boot:spring-boot-starter-data-jpa:${springBootVersion}"
    compile "org.springframework.boot:spring-boot-starter-thymeleaf:${springBootVersion}"
    compile "org.springframework.boot:spring-boot-starter-security:${springBootVersion}"
    compile "org.thymeleaf.extras:thymeleaf-extras-springsecurity3:2.1.2.RELEASE"
    compile "org.springframework.boot:spring-boot-starter-web:${springBootVersion}"
    providedRuntime "org.springframework.boot:spring-boot-starter-tomcat:${springBootVersion}" 
    testCompile "org.springframework:spring-test"
    testCompile "org.springframework.boot:spring-boot-starter-test:${springBootVersion}"

    //ここにjdbcを入れてね

}
```

特徴としては次の3点を盛り込んだ。
・ホットデプロイを有効にしておくと、実行中でも再読み込みしてくれる。これは入れておくべきである。
  >org.springframework:springloaded:1.2.3.RELEASE
  
・spring-boot-starter-tomcatは組み込みtomcat。開発者はTomcatをインストールせずとも、Eclipseの実行ボタンで、アプリケーションを立ち上げられる。
  ただし、Tomcatを運用環境で利用している場合には、組込みTomcatが邪魔なので以下のようにしている。
  >providedRuntime "org.springframework.boot:spring-boot-starter-tomcat:${springBootVersion}" 

・ConnectionPoolはTomcatに任せるようにしている。


続いて/src/main/resources/application.properties

```lang:application.properties
spring.datasource.url=jdbc:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
spring.datasource.username=XXXX
spring.datasource.password=XXXX
spring.datasource.driverClassName=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
#コネクションプール設定(default)
spring.datasource.max-active=100
spring.datasource.max-idle=8
spring.datasource.min-idle=8
spring.datasource.initial-size=10
#セッションタイムアウトを1800秒とする。
spring.datasource.session-timeout=1800
#コネクションを利用する際に検証を行う。DBが再起動していてもこの処理を挟むことでtomcatを再起動しなくても済む
spring.datasource.test-on-borrow=true
spring.datasource.validation-query=SELECT 1
#コミットされずに残ったコネクションは60秒後に破棄される。
spring.datasource.remove-abandoned=true
spring.datasource.remove-abandoned-timeout=60
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML5
spring.thymeleaf.encoding=UTF-8
spring.thymeleaf.content-type=text/html
spring.thymeleaf.cache=false
server.session-timeout=1800
server.context-path=/XXXXXXXX
spring.messages.cache-seconds=-1
error.whitelabel.enabled=false
security.require_ssl=true
```
server.contextは早めに定義しておいた方が楽だった。
DB周りは結構悩んだりした。DBを落として上げてのテストを繰り返して、最終的にこうした。


## ２：セッションタイムアウトした時どうすんの？
業務系システムにはなぜかセッションタイムアウトを大事にする。一応入れておくが実装は複雑。

タイムアウトには2つのパターンがある。

1. タイムアウトした後に、ページ遷移をしようとしたとき。
セッションタイムアウトが発生した場合、ログイン画面に遷移して「セッションタイムアウトが発生しました」というメッセージを表示する
ただし、ログイン画面・ログアウト画面はセッションタイムアウト対象外とした。

2. タイムアウトした後に、Ajax通信を行おうとしたとき。
Ajax専用の共通レスポンスモデルを作っておき、どの画面でもこのレスポンスモデルを返却するようにしている。

```lang:CommonResultModel.java

    private boolean success = false;
    private List<String> messages = new ArrayList<>();
    private Object data;
+ getter / setter
```
すべてのAPIにて共通してこのモデルを返すようにしておけば、タイムアウトしたメッセージを返却することが出来ます（そして、モーダルに表示したりすると思われ）

さて、実装。
1つのインターセプターで記載する。コードで書くとこのような感じ。
 
```lang:SesseionExpireInterceptor.java
/**
 * セッションタイムアウト時の挙動を定義する。
 */
public class SessionExpireInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
            Object handler) throws Exception {
        if (isTarget(request)) {
            // セッションタイムアウトのチェック
            if (isSessionTimeout(request)) { // セッションタイムアウトの場合
                // Ajax処理の場合は「セッションタイムアウトが発生しました。ログインしなおしてください。」というメッセージを返却する。
                if (isAjaxRequest(request)) {
                    String message =
                            "{\"success\":false,\"messages\":[\"セッションタイムアウトが発生しました。ログインしなおしてください。\"]}";
                    response.setContentType("text/html;charset=UTF-8");
                    // ステータスは200となるが、success = falseとする。
                    response.setStatus(HttpServletResponse.SC_OK);
                    response.getWriter().write(message);
                    return false;
                } else {
                    String requestUri = request.getRequestURI();
                    // ログイン・ログアウト・タイムアウトページのタイムアウトは行わない。
                    if (!((request.getContextPath() + AppConst.URL_LOGIN_PAGE + "/timeout")
                            .equals(requestUri) //
                            || (request.getContextPath() + AppConst.URL_LOGIN_PAGE)
                                    .equals(requestUri) //
                    || (request.getContextPath() + AppConst.URL_LOGOUT_COMPLETED_PAGE)
                            .equals(requestUri))) {
                        // 通常画面の場合は、ログイン画面を表示しセッションタイムアウトメッセージを伝える。
                        response.sendRedirect(request.getContextPath() + AppConst.URL_LOGIN_PAGE
                                + "/timeout");
                    }
                }
            }
        }
        return true;
    }
    /**
     * 対象のリクエストか判定
     * css, js, favicon等のリソースアクセスについては出力対象外
     *
     * @param request リクエスト
     * @return true : ロギング対象 / false : ロギング対象ではない
     */
    private boolean isTarget(ServletRequest request) {
        String uri = ((HttpServletRequest) request).getRequestURI();
        return (uri.indexOf("/static") < 0 && uri.indexOf("/favicon.ico") < 0);
    }
    /**
     * Ajaxリクエスト判定
     *
     * @param request リクエスト
     * @return true : Ajaxリクエスト / false : そうではない
     */
    private boolean isAjaxRequest(ServletRequest request) {
        return StringUtils.equals("XMLHttpRequest",
                ((HttpServletRequest) request).getHeader("X-Requested-With"));
    }
    /**
     * セッションタイムアウト判定
     *
     * @param request
     * @return
     */
    private boolean isSessionTimeout(HttpServletRequest request) {
        HttpSession falsecurrentSession = request.getSession(false);
        if (falsecurrentSession == null) {
            return true;
        }
        String requestSession = request.getRequestedSessionId();
        boolean isValid = request.isRequestedSessionIdValid();
        return falsecurrentSession == null || requestSession == null || !isValid
                || !requestSession.equals(falsecurrentSession.getId());
    }
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response,
            Object handler, ModelAndView modelAndView) throws Exception {}
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
            Object handler, Exception ex) throws Exception {}
}
```

## ３：Spring Securityを使って1ユーザに対して複数権限をあてる方法
ログイン周りの認証をやってくれるSpring Securityを導入。今回のルールは次のようにした。

・1人のユーザは1つのロールを持つようにする。
・1つのロールは複数の権限を持つ。
・ロールの例：「管理者」「オペレータ」「一般」
・権限の例：機能A_参照権 / 機能A_更新権 / 機能B_参照権 / 機能B_更新権
・ユーザはログイン後、権限を持っていないとそのページを開こうとするボタンが表示されない。URLを直接入力しても権限エラーとなるようにする。

今回のユーザ周りのテーブルは次のようになる。
![2015y10m18d_160505348.jpg](https://qiita-image-store.s3.amazonaws.com/0/40127/94fa23ed-1894-5579-94c3-f90efd675741.jpeg)


コードは複数のクラスに跨るのだが、一部抜粋する。

```lang:UserLogic.java
 /**
 * 認証処理を実施するクラス
 */
@Service
public class UserDetailLogic implements UserDetailsService {
    @Autowired
    UserLogic userLogic;
    private Logger logger = LoggerFactory.getLogger(UserDetailLogic.class);
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // ユーザ情報取得
        MUser mUser = userLogic.findUserByUserId(username);
        if (mUser == null) {
            String errorMessage = **********************************************
            logger.info(errorMessage);
            throw new UsernameNotFoundException(errorMessage);
        }
        // 権限をセットする。
        List<SimpleGrantedAuthority> authorityList = new ArrayList<SimpleGrantedAuthority>();
        // 権限取得
        List<MRRoleAuthority> auths = userLogic.getAuthorities(mUser.getRoleId());
        for (MRRoleAuthority auth : auths) {
            authorityList.add(new SimpleGrantedAuthority(auth.getId().getAuthorityName()));
        }
        return new UserDetail(mUser, authorityList);
    }
}
```
 
コントローラーには次のように書いておくと、権限が無いユーザが来たら403エラー画面に飛ばせる。

```lang:FunctionZyxController
@PreAuthorize("hasRole('SELECT_ZYX')") // ZYXの参照権が必要
@RequestMapping(value = "/")
public String index(Model model) {
  ....
}
```

ページには次のように書くと、権限があるときにだけ表示されるボタンが作れる。

```lang:zxy_view.html
<li sec:authorize="hasRole('SELECT_ZYX')"><a th:href="@{/sites/}">ZYX機能</a></li>
```
 
## ４：バリデーションってどうしてる？
クライアントバリデーションは独自実装した。Validator.checkRule("ドメインルール名", 値)を渡すとメッセージが返却されるような実装である。
messagesにメッセージを格納して、メッセージがあるときにはエラーモーダルを表示する。

```lang:zxy.js
messages.push({
    message: Validator.checkRule("NAME_255", $("#zyxName").val()),
    arg: "名称"
});
messages.push({
    message: Validator.checkRule("ID_8", $("#zyxId").val()),
    arg: "ID"
});
messages.push({
    message: Validator.checkRule("URL", $("#zyxUrl").val()),
    arg: "URL"
});
```

サーバーサイドバリデーションは@BindingResultを用いるようにした。パラメータとして渡すモデルのフィールドに@Length(max = 10)とか @Pattern(regrxp=正規表現)とかバリデーションが簡易に書けるようになった。

```lang:ZyxModel.java
@Pattern(regexp = ValidationConst.PATTERN_URL_POLICY,
        message = ValidationConst.ERROR_MESSAGE_MATCH)
@Length(max = ValidationConst.VALIDATION_RULE_URL,
        message = ValidationConst.ERROR_MESSAGE_MAXSIZE)
private String url = "";
```
受け取るコントローラーで、メッセージを変換して画面に返すようにする。

```lang:ZyxController.java
@PreAuthorize("hasRole('UPDATE_ZYX')")
@RequestMapping(value = "/entry", method = RequestMethod.POST)
@ResponseBody
public CommonResultModel executeEntry(@RequestBody @Valid ZyxModel zxyInfoForm,
        BindingResult bindingResult) {
    CommonResultModel result = new CommonResultModel();
    // parameter check result has error -> return error messages
    if (bindingResult.hasErrors()) {
        for (FieldError fe : bindingResult.getFieldErrors()) {
            result.addMessage(fe.getDefaultMessage(), fe.getField(), fe.getRejectedValue());
        }
        logger.warn(result.getMessages().toString()); // server side validation -> warn
        return result;
    }
```
 
## ５：共通ロギングを出しましょう
プロジェクトによっては「○○を開始しました。ユーザ名：hoge」「××を終了しました。パラメータ：bar=foo」のようなログを毎回アプリケーションで記載しろという方針があったりする。
どのユーザがどんな操作をしたかが一目瞭然なので、僕は好きだ。今回はフィルターを使って「どのユーザが何の操作をしたか？」をログ出力する際にフィルターを使って記述した。

```lang:LoggingFilter.java
@Override
 public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
         throws IOException, ServletException {
     // HTTPリクエストの場合、処理開始ログを出力
     if (request instanceof HttpServletRequest) {
         String uri = ((HttpServletRequest) request).getRequestURI();
         String method = ((HttpServletRequest) request).getMethod();
         // 開始ロギング対象のアクセスの場合はログ出力
         if (isStartLoggingTarget(request)) {
             String messageCode = getProcessStartMessageCode(request);
             String userId = getUserIdFromSession(request);
             logger.info(MessageUtil.make(messageCode, userId, method, uri));
         }
     }
     // 実処理実施
     chain.doFilter(request, response);
     // HTTPリクエストの場合、処理終了ログを出力
     if (request instanceof HttpServletRequest) {
         String uri = ((HttpServletRequest) request).getRequestURI();
         String method = ((HttpServletRequest) request).getMethod();
         // 終了ロギング対象のアクセスの場合はログ出力
         if (isEndLoggingTarget(request)) {
             String messageCode = MessageUtil.MC_COMMON_API_END;
             String userId = getUserIdFromSession(request);
             logger.info(MessageUtil.make(messageCode, userId, method, uri));
         }
     }
 }
```
 
**ログ出力方針は前もって決めておく。アプリケーションを作る側と、それを管理する人（監視する人）とディレクトリ・ローテートのネゴは必須**
 


## ６：SpringSecurityを使ってログイン画面を作成。HTTP通信だと問題ないけど、HTTPS通信化だとログイン後のURLがおかしくなる。

具体的にはこうだ。
https://example.com:80/app1/logined
httpsなのに80ポートに向かおうとしている。

SpringSecurityに任せると、ログイン後のURLには"リダイレクト"を行うようになっている。
Tomcatのserver.xmlで、proxyPortを443で定義しておくこととする。

```lang:./tomcat/conf/server.xml
<!-- Define an AJP 1.3 Connector on port 8009 -->
<Connector port="8009" protocol="AJP/1.3" redirectPort="8443" proxyPort="443" scheme="https" secure="true"/>
```

## 番外編
工数削減！工数削減！と言われ、少し目を離したスキに403 / 404 / 500を作らない方針になぜかなる。でも結局つくるハメになる。
ちゃんと工数に積んでおきましょう。簡単です。

```lang:Application.java
/**
 * ServletContainerCustomizer
 *
 * @return
 */
@Bean
public EmbeddedServletContainerCustomizer containerCustomizer() {
    return new ServletContainerCustomizer();
}
private static class ServletContainerCustomizer implements EmbeddedServletContainerCustomizer {
    @Override
    public void customize(ConfigurableEmbeddedServletContainer factory) {
        factory.addErrorPages(new ErrorPage(HttpStatus.FORBIDDEN, "/403"));
        factory.addErrorPages(new ErrorPage(HttpStatus.NOT_FOUND, "/404"));
        factory.addErrorPages(new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/500"));
    }
}
/**
 * 403エラー画面表示
 *
 * @return
 */
@RequestMapping("/403")
public String forbiddenError() {
    return "error/403";
}
/**
 * 404エラー画面表示
 *
 * @return
 */
@RequestMapping("/404")
public String notFoundError() {
    return "error/404";
}
/**
 * 500エラー画面表示
 *
 * @return
 */
@RequestMapping("/500")
public String internalServerError() {
    return "error/500";
}
```
{プロジェクトルート}/src/main/resources/template/error/500.html等を用意しておけばエラー時に表示してくれます。



##SpringBootを使ってみて・・・
良かった所：Tomcatインストールしなくても動くことが楽。環境構築も gradle + application.propertiesでほぼ出来る。

悪かった所：ドキュメント量が少ない。躓くポイントは多岐にわたる。躓いた所が共有されていない。テストコードの起動が遅い。テストに慣れるまで時間がかかった。Spring 3までの紹介ページは基本的に無視する。
XMLで記述する方法が紹介されていて、Javaで書き直す方法が分からなかったりする。
Spring関連は機能が充実しすぎていて、どこから手を付けたらいいのかが分からなくて辛い。

Spring自体が多機能で複雑化してきたから、SpringBootというコンポーネントを組み合わせる機能が出てきたのだが、Django, Railsといったフルスタックフレームワークと比べるとゴテゴテしてて扱いづらい。でもまぁ、Java案件だったらこれしか選択肢がないのも事実・・・。