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行意味があるので適当には出来ない。
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
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つのパターンがある。
-
タイムアウトした後に、ページ遷移をしようとしたとき。
セッションタイムアウトが発生した場合、ログイン画面に遷移して「セッションタイムアウトが発生しました」というメッセージを表示する
ただし、ログイン画面・ログアウト画面はセッションタイムアウト対象外とした。 -
タイムアウトした後に、Ajax通信を行おうとしたとき。
Ajax専用の共通レスポンスモデルを作っておき、どの画面でもこのレスポンスモデルを返却するようにしている。
private boolean success = false;
private List<String> messages = new ArrayList<>();
private Object data;
+ getter / setter
すべてのAPIにて共通してこのモデルを返すようにしておけば、タイムアウトしたメッセージを返却することが出来ます(そして、モーダルに表示したりすると思われ)
さて、実装。
1つのインターセプターで記載する。コードで書くとこのような感じ。
/**
* セッションタイムアウト時の挙動を定義する。
*/
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を直接入力しても権限エラーとなるようにする。
コードは複数のクラスに跨るのだが、一部抜粋する。
/**
* 認証処理を実施するクラス
*/
@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エラー画面に飛ばせる。
@PreAuthorize("hasRole('SELECT_ZYX')") // ZYXの参照権が必要
@RequestMapping(value = "/")
public String index(Model model) {
....
}
ページには次のように書くと、権限があるときにだけ表示されるボタンが作れる。
<li sec:authorize="hasRole('SELECT_ZYX')"><a th:href="@{/sites/}">ZYX機能</a></li>
4:バリデーションってどうしてる?
クライアントバリデーションは独自実装した。Validator.checkRule("ドメインルール名", 値)を渡すとメッセージが返却されるような実装である。
messagesにメッセージを格納して、メッセージがあるときにはエラーモーダルを表示する。
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=正規表現)とかバリデーションが簡易に書けるようになった。
@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 = "";
受け取るコントローラーで、メッセージを変換して画面に返すようにする。
@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」のようなログを毎回アプリケーションで記載しろという方針があったりする。
どのユーザがどんな操作をしたかが一目瞭然なので、僕は好きだ。今回はフィルターを使って「どのユーザが何の操作をしたか?」をログ出力する際にフィルターを使って記述した。
@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で定義しておくこととする。
<!-- 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を作らない方針になぜかなる。でも結局つくるハメになる。
ちゃんと工数に積んでおきましょう。簡単です。
/**
* 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案件だったらこれしか選択肢がないのも事実・・・。