LoginSignup
185
196

More than 5 years have passed since last update.

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

Last updated at Posted at 2015-10-18

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を定義して、全員で共有。

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

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

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

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:セッションタイムアウトした時どうすんの?

業務系システムにはなぜかセッションタイムアウトを大事にする。一応入れておくが実装は複雑。

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

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

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

CommonResultModel.java

    private boolean success = false;
    private List<String> messages = new ArrayList<>();
    private Object data;
+ getter / setter

すべてのAPIにて共通してこのモデルを返すようにしておけば、タイムアウトしたメッセージを返却することが出来ます(そして、モーダルに表示したりすると思われ)

さて、実装。
1つのインターセプターで記載する。コードで書くとこのような感じ。

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 {}
}

3:Spring Securityを使って1ユーザに対して複数権限をあてる方法

ログイン周りの認証をやってくれるSpring Securityを導入。今回のルールは次のようにした。

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

今回のユーザ周りのテーブルは次のようになる。
2015y10m18d_160505348.jpg

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

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エラー画面に飛ばせる。

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

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

zxy_view.html
<li sec:authorize="hasRole('SELECT_ZYX')"><a th:href="@{/sites/}">ZYX機能</a></li>

4:バリデーションってどうしてる?

クライアントバリデーションは独自実装した。Validator.checkRule("ドメインルール名", 値)を渡すとメッセージが返却されるような実装である。
messagesにメッセージを格納して、メッセージがあるときにはエラーモーダルを表示する。

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=正規表現)とかバリデーションが簡易に書けるようになった。

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 = "";

受け取るコントローラーで、メッセージを変換して画面に返すようにする。

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;
    }

5:共通ロギングを出しましょう

プロジェクトによっては「○○を開始しました。ユーザ名:hoge」「××を終了しました。パラメータ:bar=foo」のようなログを毎回アプリケーションで記載しろという方針があったりする。
どのユーザがどんな操作をしたかが一目瞭然なので、僕は好きだ。今回はフィルターを使って「どのユーザが何の操作をしたか?」をログ出力する際にフィルターを使って記述した。

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));
         }
     }
 }

ログ出力方針は前もって決めておく。アプリケーションを作る側と、それを管理する人(監視する人)とディレクトリ・ローテートのネゴは必須

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

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

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

./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を作らない方針になぜかなる。でも結局つくるハメになる。
ちゃんと工数に積んでおきましょう。簡単です。

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案件だったらこれしか選択肢がないのも事実・・・。

185
196
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
185
196