SpringBootとDoma2を使用したサンプルアプリケーション

前提条件

  • Java SE 8がインストールされていること。
  • Mavenがインストールされていることかつ基本的なことが分かること。
  • STSがインストールされていること。

参考書籍

はじめてのSpring Boot

http://amzn.to/2jb63mU

Spring徹底入門

http://amzn.to/2j4pPya

目次

インストール方法

STS環境で、以下の手順でサンプルプロジェクトをインポートします。

1.左ペインで右クリック 「import」 - 「Git」 「Projects from Git」を選択 「Next」

_images/01.png

2.Clone URIを選択 「Next」

_images/02.png

3.URIに「https://github.com/ko-aoki/springboot-doma.git」を入力し 「Next」

_images/03.png

4.次画面の「Branch Selection」で「Next」

_images/04.png

5.「Directory」で環境に合わせてパスを選択し「Next」

_images/05.png

6.「import as general project」を選択し「Finish」

_images/06.png

プロジェクトをimportした後、mavenを使用してコンパイルします。

7.importしたプロジェクトを右クリックし「Configure」-「Convert to Maven Project」

_images/07.png

8.pom.xmlを右クリックし、「Run AS」-「Run Configurations」

_images/08.png

9.「Base director」にpom.xml配置フォルダ、「Goals」に「package」を指定し「Run」

_images/09.png

コンパイルされ、targetフォルダにjarファイルが生成されます。

注釈

pom.xml配置フォルダで以下コマンドを実行してもいいです。

$ mvn package

アプリケーションを起動します。

10.プロジェクトを右クリックし、「Run As」-「Spring Boot App」

_images/10.png

以下URLでログイン画面が表示されます。

http://localhost:8080/edu

_images/11.png

以下のアカウントでログインできます。

管理者ユーザ:01/test

一般ユーザ:02/test

概要

アプリケーションは以下の構成になります。

├── pom.xml ... Maven設定ファイル
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── example
    │   │           ├── App.java  ... アプリケーション実行ファイル
    │   │           ├── SecurityConfig.java  ... セキュリティ設定ファイル
    │   │           ├── dao  ... Doma2のDAOクラス格納パッケージ
    │   │           │   ├── MstEmployeeDao.java
    │   │           │   ├── MstNewsDao.java
    │   │           │   └── MstRoleDao.java
    │   │           ├── dto
    │   │           │   └── NewsDto.java
    │   │           ├── entity  ... Doma2のEntityクラス格納パッケージ
    │   │           │   ├── AuditEntity.java
    │   │           │   ├── MstEmployee.java
    │   │           │   ├── MstNews.java
    │   │           │   ├── MstRole.java
    │   │           │   ├── UserEntity.java
    │   │           │   └── listener
    │   │           │       └── AuditListener.java
    │   │           ├── security  ... Spring Securityの認証機能
    │   │           │   ├── LoginUserDetails.java
    │   │           │   ├── LoginUserDetailsService.java
    │   │           │   └── UserInfo.java
    │   │           ├── service  ... サービスクラス格納パッケージ
    │   │           │   ├── NewsService.java
    │   │           │   └── NewsServiceImpl.java
    │   │           └── web ... Spring MVC コントローラクラス格納パッケージ
    │   │               ├── LoginController.java
    │   │               ├── NewsController.java
    │   │               ├── TopController.java
    │   │               └── manager
    │   │                   ├── NewsForm.java
    │   │                   ├── NewsManagerEditController.java
    │   │                   ├── NewsManagerListController.java
    │   │                   └── NewsManagerRegisterController.java
    │   └── resources
    │       ├── META-INF
    │       │   └── com
    │       │       └── example
    │       │           └── dao ... Doma2のSQLファイル格納パッケージ
    │       │               ├── MstEmployeeDao
    │       │               │   ├── selectAll.sql
    │       │               │   ├── selectOne.sql
    │       │               │   └── selectUser.sql
    │       │               ├── MstNewsDao
    │       │               │   ├── selectAll.sql
    │       │               │   ├── selectNewsDtoByCond.sql
    │       │               │   └── selectOneNewsDto.sql
    │       │               └── MstRoleDao
    │       │                   └── selectAll.sql
    │       ├── ValidationMessages.properties ... バリデーションチェックのメッセージ設定ファイル
    │       ├── application.yml ... アプリケーション設定ファイル
    │       ├── data.sql ... 起動時に実行するDDL文
    │       ├── messages.properties ... バリデーションチェックメッセージのFormプロパティ置換設定ファイル
    │       ├── schema.sql ... 起動時に実行するDML文
    │       ├── static ... 実行時にはコンテキストルート直下になる静的ファイル格納フォルダ
    │       │   ├── css
    │       │   │   └── lib ... CSSライブラリ格納フォルダ
    │       │   │       ├── bootstrap-theme.min.css
    │       │   │       └── bootstrap.min.css
    │       │   ├── fonts ... フォント格納フォルダ(Bootstrap)
    │       │   │   ├── glyphicons-halflings-regular.eot
    │       │   │   ├── glyphicons-halflings-regular.svg
    │       │   │   ├── glyphicons-halflings-regular.ttf
    │       │   │   ├── glyphicons-halflings-regular.woff
    │       │   │   └── glyphicons-halflings-regular.woff2
    │       │   ├── js
    │       │   │   └── lib ... JavaScriptライブラリ格納フォルダ
    │       │   │       └── bootstrap.min.js
    │       │   └── movie
    │       │       ├── sample
    │       │       │   └── nc144421.mp4
    │       │       └── thumbnail
    │       │           ├── nc144421.jpg
    │       │           └── nc144555.jpg
    │       └── templates ... Thymeleafテンプレート格納フォルダ
    │           ├── learning
    │           │   └── learning.html
    │           ├── loginForm.html
    │           ├── manager
    │           │   ├── managerLayout.html
    │           │   └── news
    │           │       ├── edit
    │           │       │   ├── newsEditComplete.html
    │           │       │   ├── newsEditConfirm.html
    │           │       │   └── newsEditInput.html
    │           │       ├── list
    │           │       │   └── newsList.html
    │           │       └── register
    │           │           ├── newsRegisterComplete.html
    │           │           ├── newsRegisterConfirm.html
    │           │           └── newsRegisterInput.html
    │           ├── news
    │           │   └── news.html
    │           └── top
    │               └── top.html
    └── test ... テストコード格納フォルダ
        └── java
            └── com
                └── example
                    └── service
                        └── NewsServiceImplTest.java

画面遷移は以下になります。

_images/springboot-doma.png

注釈

この画面遷移図はdot言語で記述、Graphvizで作成しています。

参考:http://d.hatena.ne.jp/jun-yoshida/20070512/1179069363

digraph G {
    node [shape = record, height = .1, fontsize=10];
    edge [fontsize=8];

    /* 画面情報の定義 */
    PageTop      [label = "{<id>P0000 |<title>トップページ |<folder>top|<file>top.html}"];
    PageNews     [label = "{<id>P0001 |<title>お知らせ画面 |<folder>news |<file>news.html}"];
    PageLearning [label = "{<id>P0002 |<title>ラーニング画面|<folder>learning |<file>learning.html}"];

    PageManagerNewsList             [label = "{<id>M0101 |<title>お知らせマスタ管理\n一覧 |<folder>manager/news/list|<file>newsList.html}"];
    PageManagerNewsEditInput        [label = "{<id>M0111 |<title>お知らせマスタ管理\n編集 入力 |<folder>manager/news/edit|<file>newsEditInput.html}"];
    PageManagerNewsEditConfirm      [label = "{<id>M0112 |<title>お知らせマスタ管理\n編集 確認 |<folder>manager/news/edit|<file>newsEditConfirm.html}"];
    PageManagerNewsEditComplete     [label = "{<id>M0113 |<title>お知らせマスタ管理\n編集 完了 |<folder>manager/news/edit|<file>newsEditComplete.html}"];
    PageManagerNewsRegisterInput    [label = "{<id>M0121 |<title>お知らせマスタ管理\n登録 入力 |<folder>manager/news/register|<file>newsRegisterInput.html}"];
    PageManagerNewsRegisterConfirm  [label = "{<id>M0122 |<title>お知らせマスタ管理\n登録 確認 |<folder>manager/news/register|<file>newsRegisterConfirm.html}"];
    PageManagerNewsRegisterComplete [label = "{<id>M0123 |<title>お知らせマスタ管理\n登録 完了 |<folder>manager/news/register|<file>newsRegisterComplete.html}"];


    /* 画面遷移定義 */
    PageTop                         -> PageNews                ;
    PageNews                        -> PageTop                 ;
    PageTop                         -> PageLearning            ;
    PageLearning                    -> PageTop                 ;
    PageTop                         -> PageManagerNewsList     ;
    PageManagerNewsList             -> PageTop                 ;

    PageManagerNewsList             -> PageManagerNewsList     ;
    PageManagerNewsList             -> PageManagerNewsEditInput     ;
    PageManagerNewsEditInput        -> PageManagerNewsEditConfirm   [label="POST"];
    PageManagerNewsEditConfirm      -> PageManagerNewsEditInput     ;
    PageManagerNewsEditConfirm      -> PageManagerNewsEditComplete  [label="PRG"];
    PageManagerNewsEditComplete     -> PageManagerNewsList          ;

    PageManagerNewsList             -> PageManagerNewsRegisterInput     ;
    PageManagerNewsRegisterInput    -> PageManagerNewsRegisterConfirm   [label="POST"];
    PageManagerNewsRegisterConfirm  -> PageManagerNewsRegisterInput     ;
    PageManagerNewsRegisterConfirm  -> PageManagerNewsRegisterComplete  [label="PRG"];
    PageManagerNewsRegisterComplete -> PageManagerNewsList          ;

}

Spring MVC 基本

以下URLで、ログインするとトップ画面に遷移します。

http://localhost:8080/edu

(ユーザ/パスワード 01/test)

ログイン画面はSpring Securityの機能なので、ここではトップ画面がどのように表示されるのかを確認します。

_images/011.png

対象URL(http://localhost:8080/edu)にアクセスすると、リクエストがマッピングされ、以下のコントローラクラスが処理を実行します。

TopController.java

@Controller // コントローラクラスのアノテーション
@RequestMapping("/")   // URLとのマッピング
public class TopController {
    /**
     * トップ画面.
     * @return テンプレートパス
     */
    @RequestMapping(method = RequestMethod.GET)
    public String top(Model model, @AuthenticationPrincipal LoginUserDetails userDetails) {

        return "top/top";   // 対応するテンプレートのパス
    }
}

topメソッドの戻り値から以下のテンプレートが表示されます。

└── resources
    └── templates ... Thymeleafテンプレート格納フォルダ
        └── top
            └── top.html

テンプレートはthymeleaf形式のhtmlです。 JSPとは異なり、ファイル単体で表示可能です。

HTMLとの値受け渡しは(ここでは)Formクラスを使用します。

NewsListCondForm.java

/**
 * お知らせ一覧画面条件フォームクラス.
 */
public class NewsListCondForm {

    /** ページ */
    private Integer page;
    /** 表題 */
    private String subject;
    /** 権限 */
    private String roleId;
    /** 権限名 */
    private String roleNm;
    /** URL */
    private String url;

    // getter, setter
 }

コントローラのメソッドの引数にFormクラスを指定します。

NewsManagerListController.java

/**
 * お知らせ管理リスト画面のコントローラクラス.
 */
@Controller
@RequestMapping("manager/news/list")
@SessionAttributes({"roleIdMap"})
public class NewsManagerListController {

    /** ロガー */
    private static final Logger logger = LoggerFactory.getLogger(NewsManagerListController.class);

    /** お知らせ機能のサービスクラス */
    @Autowired
    NewsService service;

    /**
     * 権限のコンボボックスを初期化します.
     * @return
     */
    @ModelAttribute("roleIdMap")
    public Map<String, String> setupRoleIdMap() {
        return service.retrieveRoleIdMap();
    }

    /**
     * 「重要なお知らせ」リスト画面を表示します.
     * @param form : お知らせForm
     * @param model : モデル
     * @return
     */
    @GetMapping
    public String display(NewsListCondForm form,
                        Model model) {

        int page = 0;
        if (form.getPage() == null) {
            page = 0;
            form.setPage(1);
        } else {
            page = form.getPage() - 1;
        }
        Page<NewsDto> newsList = service.findNewsPage(form.getSubject(), form.getRoleId(), form.getUrl(), page);
        if (newsList.getTotalElements() > 0) {
            model.addAttribute("newsList", newsList);
        }

        return "/manager/news/list/newsList";
    }
}

引数のModelは連携するデータを表すクラスです。 addAttribute メソッドでhtmlに引き渡すデータを設定します。 ModelAttribute はリクエストを受けるメソッドの実行前に自動で実行されるメソッドで、addAttribute と同様の処理になります。 クラスにアノテーションされている@SessionAttributes で指定した値の属性はセッションに格納され、セッションに存在する場合は、再実行されません。

FormクラスとHTMLの関連は以下のように行います。

newsList.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security4"
      xmlns:layout="http://www.ultraq.net.nz/web/thymeleaf/layout"
      layout:decorator="manager/managerLayout">
<head>
    <!-- 略 -->
</head>
<body>
<!-- メインコンテンツ -->
<div class="container">
    <div class="row">
        <div layout:fragment="content" class="col col-sm-12">
            <div class="panel panel-default">
                <div class="panel-heading"><h3 class="panel-title">「重要なお知らせ」管理画面</h3></div>
                <div class="panel-body">
                    <div id="div-cond">
                        <form id="news-input-form" th:action="@{/manager/news/list}"
                              th:object="${newsListCondForm}" method="get">
                            <input id="page" th:field="*{page}" type="hidden" />
                            <label for="subject">お知らせ表題</label>
                            <input class="form-control" id="subject" type="text" th:field="*{subject}"/>
                            <label for="role-id">権限</label>
                            <select class="form-control" id="role-id" name="roleId">
                                <option value="">---</option>
                                <option th:each="item : ${roleIdMap}" th:value="${item.key}" th:text="${item.value}"
                                        th:selected="${item.key} == *{roleId}">pulldown
                                </option>
                            </select>
                            <label for="url">お知らせURL</label>
                            <input class="form-control" id="url" type="text" th:field="*{url}"/>
                            <br/>
                            <div class="row">
                                <div class="form-group col col-sm-10">
                                    <button type="button" th:onclick="'pageJump(1);'" class="btn-primary">検索</button>
                                </div>
                                <div class="col col-sm-2">
                                    <a th:href="@{'/manager/news/register?input'}">新規作成</a>
                                </div>
                            </div>
                            <!-- 略 -->
                        </form>
                    </div>
                    <!-- 略 -->
                </div>
            </div>
        </div>
    </div>
</div>
</body>
</html>

<form id="news-input-form" th:action="@{/manager/news/list}" th:object="${newsListCondForm}" method="get"> 上記のように、通常のHTMLにth属性を設定しています。Thymeleafがth:action属性を解釈し、通常のHTML属性に変更します。 th:object属性はコントローラで指定したFormクラスです。 Formタグ内のth:field属性でFormクラスのプロパティと対応させます。

Doma2 基本

└── src
    └── main
        ├── java
        │   └── com
        │       └── example
        │           ├── dao  ... Doma2のDAOクラス格納パッケージ
        │           │   └── MstRoleDao.java
        │           ├── dto
        │           │   └── NewsDto.java
        │           └── entity  ... Doma2のEntityクラス格納パッケージ
        │               └── MstRole.java
        └── resources
            └── META-INF
                └── com
                    └── example
                        └── dao ... Doma2のSQLファイル格納パッケージ
                            └── MstRoleDao
                                └── selectAll.sql

エンティティクラス

テーブルを表現する(だけではないですが)クラスです。

以下のテーブル「権限マスタ」を例にとります。

CREATE TABLE mst_role
(
    role_id varchar(20) NOT NULL COMMENT '権限ID',
    role_name varchar(100) COMMENT '権限名',
    version int COMMENT 'バージョン',
    insert_user varchar(20) COMMENT '登録ユーザ',
    insert_date datetime COMMENT 'insert_date',
    update_user varchar(20) COMMENT '更新ユーザ',
    update_date datetime COMMENT 'update_date',
    PRIMARY KEY (role_id)
) COMMENT = '権限マスタ';

「権限マスタ」に対応するエンティティクラスは以下のようになります。

MstRole.java

/**
 * 権限マスタエンティティ.
 */
@Entity(\ = NamingType.SNAKE_LOWER_CASE)
@Table(name = "mst_role")
public class MstRole extends AuditEntity {

    @Id
    private String roleId;

    private String roleName;

    // getter, setter
}

@Entity でエンティティを定義します。

naming 要素でテーブル-クラスのネーミング規約を指定します。

Id で主キーを指定します。

クラスMstRoleにない項目がテーブル「mst_role」には存在します。

これはMstRoleが継承しているAuditEntityが共通の監査項目として定義しています。

AuditEntity.java

/**
 * 監査項目(共通カラム)部分のエンティティ.
 */
@Entity
public class AuditEntity {

    @Version
    private int version;
    private String insertUser;
    private LocalDateTime insertDate;
    private String updateUser;
    private LocalDateTime updateDate;

    // getter, setter
}

Daoインターフェース

Daoはデータベースアクセスのためのインタフェースです。

MstRoleDao.java

/**
 * 権限マスタのDaoインターフェース.
 */
@ConfigAutowireable
@Dao
public interface MstRoleDao {

    @Select
    List<MstRole> selectAll();

    @Insert
    @Transactional
    int insert(MstRole ent);

}

@Dao でDaoを定義します。

DomaのDaoはインターフェースであり、 @Dao を定義したインターフェースの実装クラスはaptにより、コンパイル時に自動生成されます。

@ConfigAutowireable はDomaの@AnnotateWith を使って、 生成されるDao実装クラスに@Repository@Autowired を付与するためのアノテーションです。

参考:https://blog.ik.am/entries/371

自動生成されるDao実装クラス

└── target
     └── generated-sources
         └── annotations
           └── com
               └── example
                    └── dao
                        └── MstRoleDaoImpl.java

MstRoleDaoImpl.java

@org.springframework.stereotype.Repository()
@javax.annotation.Generated(value = { "Doma", "2.12.0" }, date = "2017-01-06T22:56:19.786+0900")
public class MstRoleDaoImpl extends org.seasar.doma.internal.jdbc.dao.AbstractDao implements com.example.dao.MstRoleDao {

    static {
        org.seasar.doma.internal.Artifact.validateVersion("2.12.0");
    }

    private static final java.lang.reflect.Method __method0 = org.seasar.doma.internal.jdbc.dao.AbstractDao.getDeclaredMethod(com.example.dao.MstRoleDao.class, "selectAll");

    private static final java.lang.reflect.Method __method1 = org.seasar.doma.internal.jdbc.dao.AbstractDao.getDeclaredMethod(com.example.dao.MstRoleDao.class, "insert", com.example.entity.MstRole.class);

    /**
     * @param config the config
     */
    @org.springframework.beans.factory.annotation.Autowired()
    public MstRoleDaoImpl(org.seasar.doma.jdbc.Config config) {
        super(config);
    }

    @Override
    public java.util.List<com.example.enti.MstRole> selectAll() {
    // 後略

再掲

MstRoleDao.java

/**
 * 権限マスタのDaoインターフェース.
 */
@ConfigAutowireable
@Dao
public interface MstRoleDao {

    @Select
    List<MstRole> selectAll();

    @Insert
    @Transactional
    int insert(MstRole ent);

}

@Insert をDaoのメソッドにアノテーションして、挿入処理となります。

同様に、@Select をDaoのメソッドにアノテーションして、検索処理となります。

検索内容はメソッドと同名のSQLファイルに定義します。

└── src
        └── resources
            └── META-INF
                └── com
                    └── example
                        └── dao ... Doma2のSQLファイル格納パッケージ
                            └── MstRoleDao
                                └── selectAll.sql

selectAll.sql

select
  role_id,
  role_name,
  version,
  insert_date,
  update_date
FROM
  mst_role

SQL文がEntityと合致している必要があります。

Entityはテーブルと同一である必要はなく、結合処理の結果にも使用します。

NewsDto.java

/**
 * お知らせ情報Dtoクラス.
 */
@Entity(naming = NamingType.SNAKE_LOWER_CASE)
public class NewsDto {

    /** id */
    private Long id;
    /** 表題 */
    private String subject;
    /** 権限 */
    private String roleId;
    /** 権限名 */
    private String roleNm;
    /** URL */
    private String url;
    /** バージョン */
    private int version;

    // getter,setter

MstNewsDao.java

/**
 * お知らせマスタのDaoインターフェース.
 */
@ConfigAutowireable
@Dao
public interface MstNewsDao {

    @Select
    NewsDto selectOneNewsDto(Long id);

    @Select
    List<NewsDto> selectNewsDtoByCond(String subject, String roleId, String url, SelectOptions selectOptions);

    // 後略

selectOneNewsDto.sql

select
  n.mst_news_id id,
  n.role_id role_id,
  r.role_name role_nm,
  n.subject subject,
  n.url url,
  n.version version
FROM
  mst_news n
INNER JOIN
  mst_role r
ON
  n.role_id = r.role_id
WHERE
  n.mst_news_id = /* id */1

SQLファイルのパラメータとDaoメソッドのパラメータをマッピングさせて 動的条件を表現できます。

selectNewsDtoByCond.sql

select
  n.mst_news_id id,
  n.role_id role_id,
  r.role_name role_nm,
  n.subject subject,
  n.url url,
  n.version version
FROM
  mst_news n
INNER JOIN
  mst_role r
ON
  n.role_id = r.role_id
WHERE
/*%if @isNotEmpty(url) */
    n.url LIKE /* @prefix(url) */'http'
/*%end*/
/*%if @isNotEmpty(subject) */
AND
    n.subject LIKE /* @prefix(subject) */'今日'
/*%end*/
/*%if @isNotEmpty(roleId) */
AND
    n.role_id = /* roleId */'01'
/*%end*/
ORDER BY
  n.mst_news_id

検索条件そのものを動的に設定できます。

Spring Security

以下URLにアクセスすると、ログイン画面にリダイレクトされます。

http://localhost:8080/edu

http://localhost:8080/edu/loginForm

_images/012.png

以下のユーザ/パスワードでログイン可能です。

管理者:01/test

一般:02/test

設定

セキュリティの設定は以下のクラスになります。

SecurityConfig.java

/**
 * Spring Security設定.
 */
@EnableWebSecurity  // Spring Securityが提供するConfigurationクラスのインポート
// WebSecurityConfigurerAdapterでデフォルトのBean定義を適用
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/css/**").permitAll()
                .antMatchers("/fonts/**").permitAll()
                .antMatchers("/js/**").permitAll()
                .antMatchers("/loginForm").permitAll()
                // /manager配下はADMIN権限のみ(自動でROLE_が付加されROLE_ADMIN)
                .antMatchers("/manager/**").hasRole("ADMIN")
                // 認証していないリクエストは不許可
                .anyRequest().authenticated();
        // フォーム認証
        http.formLogin()
                // 認証パス
                .loginProcessingUrl("/login")
                // ログインフォーム表示用のパス設定
                .loginPage("/loginForm")
                // 認証失敗時のパス
                .failureUrl("/loginForm?error")
                // 認証成功時のパス
                .defaultSuccessUrl("/", true)
                // ユーザ名のリクエストパラメータ
                .usernameParameter("id")
                // パスワードのリクエストパラメータ
                .passwordParameter("password");
        http.logout()
                // ログアウトURL
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout**"))
                // ログアウト後の遷移先
                .logoutSuccessUrl("/loginForm");
        http.headers()
                .cacheControl().disable();
    }

    /**
     * パスワードの暗号化方式
     */
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

管理者:01/test

で管理用のURLにアクセスしてみます。

http://localhost:8080/edu/manager/news/list

マスタメンテ画面にアクセスできます。

_images/021.png

以下のユーザでアクセスすると403エラー画面が表示されます。

一般:02/test

_images/031.png

ログインには、対応するコントローラとテンプレートを用意します。

LoginController.java

/**
 * ログイン画面のコントローラ.
 */
@Controller
public class LoginController {

    @RequestMapping("loginForm")
    String loginForm() {
        return "loginForm";
    }
}

LoginForm.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>基幹システムログイン</title>
    <link rel="stylesheet" type="text/css" href="../../static/css/lib/bootstrap.min.css" th:href="@{/css/lib/bootstrap.min.css}"/>
    <link rel="stylesheet" type="text/css" href="../../static/css/lib/bootstrap-theme.min.css" th:href="@{/css/lib/bootstrap-theme.min.css}"/>
</head>

<body>

<div class="container">
    <h2>ログイン</h2>
    <form class="form-signin" method="post" th:action="@{/login}">
        <div th:if="${param.error}" class="alert alert-danger">
            IDまたはパスワードが正しくありません。
        </div>
        <input type="text" class="form-control" name="id" placeholder="ID" required="required"
               autofocus="autofocus"/>
        <br/>
        <input type="password" class="form-control" name="password" placeholder="Password" required="required"/>
        <br/>
        <button class="btn btn-lg btn-primary" type="submit">ログイン</button>
    </form>
</div>
</body>
</html>

パスによるアクセス可否、ログインは上記の設定クラスで可能です。

DBとの連携

ユーザ/パスワードはDB管理しています。

schema.sql

-- 従業員マスタ
CREATE TABLE mst_employee
(
    employee_id varchar(10) NOT NULL COMMENT '従業員番号',
    employee_last_name varchar(50) COMMENT '姓',
    employee_first_name varchar(50) COMMENT '名',
    role_id varchar(20) NOT NULL COMMENT '権限ID',
    version int COMMENT 'バージョン',
    insert_user varchar(20) COMMENT '登録ユーザ',
    insert_date datetime COMMENT 'insert_date',
    update_user varchar(20) COMMENT '更新ユーザ',
    update_date datetime COMMENT 'update_date',
    PRIMARY KEY (employee_id)
) COMMENT = '従業員マスタ';

-- パスワードマスタ
CREATE TABLE mst_password
(
    mst_password_id int NOT NULL COMMENT 'パスワードマスタID',
    employee_id varchar(10) NOT NULL COMMENT '従業員番号',
    password varchar(256) COMMENT 'パスワード',
    generation varchar(2) COMMENT 'パスワード世代',
    version int COMMENT 'バージョン',
    insert_user varchar(20) COMMENT '登録ユーザ',
    insert_date datetime COMMENT 'insert_date',
    update_user varchar(20) COMMENT '更新ユーザ',
    update_date datetime COMMENT 'update_date',
    PRIMARY KEY (mst_password_id),
    UNIQUE (employee_id, generation)
) COMMENT = 'パスワードマスタ';

-- 権限マスタ
CREATE TABLE mst_role
(
    role_id varchar(20) NOT NULL COMMENT '権限ID',
    role_name varchar(100) COMMENT '権限名',
    version int COMMENT 'バージョン',
    insert_user varchar(20) COMMENT '登録ユーザ',
    insert_date datetime COMMENT 'insert_date',
    update_user varchar(20) COMMENT '更新ユーザ',
    update_date datetime COMMENT 'update_date',
    PRIMARY KEY (role_id)
) COMMENT = '権限マスタ';

data.sql

insert into mst_employee (employee_id, employee_last_name, employee_first_name, role_id) values('01', '管理', '太郎', 'ROLE_ADMIN');
insert into mst_employee (employee_id, employee_last_name, employee_first_name, role_id) values('02', '一般', '二郎', 'ROLE_USER');
insert into mst_role (role_id, role_name) values('ROLE_ADMIN', '管理者');
insert into mst_role (role_id, role_name) values('ROLE_USER', '一般');
insert into mst_password (mst_password_id, employee_id, password) values(1, '01', '$2a$10$1gJJgBlL75OIjkSgkYPXI.mV7ihEPjxIiCkXKBEc7/r9xUIjZyc9i');
insert into mst_password (mst_password_id, employee_id, password) values(2, '02', '$2a$10$1gJJgBlL75OIjkSgkYPXI.mV7ihEPjxIiCkXKBEc7/r9xUIjZyc9i');

DB管理したユーザ情報を認証に使用する手順は以下です。

1.org.springframework.security.core.userdetails.UserDetailsService を継承したサービスクラスを作成する。

LoginUserDetailsService.java

/**
 * Spring Securityで使用するログイン時に取得するユーザ情報サービスクラス.
 */
@Service
public class LoginUserDetailsService implements UserDetailsService {
    @Autowired
    MstEmployeeDao dao;

    @Override
    public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
        UserEntity user = dao.selectUser(id);
        if (user == null) {
            throw new UsernameNotFoundException("ユーザID:" + id + "は存在しません");
        }

        UserInfo userInfo = new UserInfo();

        userInfo.setId(user.getEmployeeId());
        userInfo.setEmployeeFirstName(user.getEmployeeFirstName());
        userInfo.setEmployeeLastName(user.getEmployeeLastName());
        userInfo.setRoleId(user.getRoleId());
        userInfo.setPassword(user.getPassword());

        if (userInfo == null) {
            throw new UsernameNotFoundException("The requested user is not found.");
        }
        return new LoginUserDetails(userInfo);
    }
}

loadUserByUserName メソッドでDaoによりDBアクセスしています。 パスワードとはここでは確認せず、ユーザIDのみに合致するデータを取得しています。

SELECT
    e.employee_id employee_id,
    e.employee_last_name employee_last_name,
    e.employee_first_name employee_first_name,
    e.role_id role_id,
    p.password password
FROM
  mst_employee e
INNER JOIN
  mst_password p
ON
  e.employee_id = p.employee_id
INNER JOIN
  mst_role r
ON
  e.role_id = r.role_id
WHERE
  e.employee_id = /* id */'01'
;

2.org.springframework.security.core.userdetails.User を継承したユーザ情報クラスを作成する。

LoginUserDetails.java

/**
 * Spring Securityで使用するログイン時に取得するユーザ情報.
 */
public class LoginUserDetails extends User {

    /** ログイン情報 */
    private UserInfo userInfo;

    public LoginUserDetails(UserInfo userInfo) {
        super(userInfo.getId(), userInfo.getPassword(), AuthorityUtils.createAuthorityList(userInfo.getRoleId()));
        this.userInfo = userInfo;
    }

    public UserInfo getUserInfo() {
        return this.userInfo;
    }
}

ユーザ情報のID、パスワード、権限をコンストラクタに設定しています。 上記により、認証および認可が有効になります。

ユーザ情報取得

ログイン時に取得したユーザ情報は、以下で使用可能です。

コントローラクラス

/**
 * お知らせ画面のコントローラ.
 */
@Controller
@RequestMapping("news")
public class NewsController {

    /** ロガー */
    private static final Logger logger = LoggerFactory.getLogger(NewsController.class);

    /**
     *
     * @return
     */
    @RequestMapping(method = RequestMethod.GET)
    public String init(Model model, @AuthenticationPrincipal LoginUserDetails userDetails) {

        model.addAttribute("loginInfo", userDetails.getUserInfo());
        return "news/news";
    }

}

メソッドの引数で取得可能です。

他クラス

private UserInfo getUserInfo() {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth == null) {
        return null;
    }
    LoginUserDetails userDetails = (LoginUserDetails) auth.getPrincipal();
    if (userDetails == null) {
        return null;
    }
    return userDetails.getUserInfo();
}

ユーザ情報はThreadLocalで保持され、SecurityContextHolder経由でアクセス可能です。

Thymeleaf

<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security4">

htmlタグにxmlns:sec属性を指定する必要があります。

<a href="#" class="dropdown-toggle" data-toggle="dropdown" >
    <span class="glyphicon glyphicon-user" sec:authentication="principal.userInfo.employeeName"></span> さん
    <b class="caret"></b>
</a>

ユーザ情報が取得できます。

<li sec:authorize="hasRole('ADMIN')"><a th:href="@{/manager/news/list}" href="#learning"><span class="glyphicon glyphicon-pencil"></span> 重要なお知らせ管理画面</a>

権限による表示切り替えが可能です。(DBではROLE_ADMINですが、ROLE_は無視して記述します)

参考

Spring Securityに限りませんがTERASOLUNAのガイドが詳細なので、参照してください。

http://terasolunaorg.github.io/guideline/5.1.0.RELEASE/ja/Security/Authentication.html

ユニットテスト

ユニットテストの手順について記述します。 テスト系のリソースは以下のファイル構成になります。

├── main
│      └── resources
│       ├── schema-dev.sql // 開発時使用のDML文。ユニットテストでも使用する。

└── test
    ├── java
    │   ├── com
    │   │   └── example
    │   │       ├── dao
    │   │       │   ├── MstEmployeeDaoTest.java // Daoのテストクラス
    │   │       │   └── MstNewsDaoTest.java
    │   │       ├── dto
    │   │       ├── helper
    │   │       │   └── AuthenticationTestHelper.java // 認証系テスト用のヘルパークラス
    │   │       ├── service
    │   │       │   └── NewsServiceImplTest.java // サービスのテストクラス
    │   │       └── web
    │   │           └── manager
    │   │               ├── NewsManagerListControllerTest.java // コントローラのテストクラス
    │   │               └── NewsManagerRegisterControllerTest.java
    │   └── password
    │       └── PasswordGenerator.java // パスワードをハッシュ化するクラス
    └── resources
        └── com
            └── example
                └── dao
                    ├── data_employee.sql // Daoテストに使用するDDL文
                    └── data_news.sql

Daoクラスのテスト

Daoテストに使用するDDLを記述します。 実行対象のクラス/メソッドに@SQL アノテーションで指定します。

data_employee.sql

delete from mst_employee;
delete from mst_password;
delete from mst_role;
insert into mst_employee (employee_id, employee_last_name, employee_first_name, role_id) values('01', '管理', '太郎', 'ROLE_ADMIN');
insert into mst_employee (employee_id, employee_last_name, employee_first_name, role_id) values('02', '一般', '二郎', 'ROLE_USER');
insert into mst_employee (employee_id, employee_last_name, employee_first_name, role_id) values('03', '一般', '三郎', 'ROLE_USER');
insert into mst_password (mst_password_id, employee_id, password, generation) values(1, '01', '$2a$10$i7ZAPWh9xNT98pkJ4B6TyuPkPdehn6oZiwOOjm9/GXc3ZNlbUdQLq', '01');
insert into mst_password (mst_password_id, employee_id, password, generation) values(2, '02', '$2a$10$1gJJgBlL75OIjkSgkYPXI.mV7ihEPjxIiCkXKBEc7/r9xUIjZyc9i', '01');
insert into mst_password (mst_password_id, employee_id, password, generation) values(3, '01', '$2a$10$1gJJgBlL75OIjkSgkYPXI.mV7ihEPjxIiCkXKBEc7/r9xUIjZyc9i', '02');
insert into mst_role (role_id, role_name) values('ROLE_ADMIN', '管理者');
insert into mst_role (role_id, role_name) values('ROLE_USER', '一般');
insert into mst_role (role_id, role_name) values('ROLE_ACTUATOR', '運用管理');

Doma2のDAOをテストするサンプルです。 @SQL アノテーションで指定されたSQLを実行してテスト対象のデータを作成し、単体テストを行います。

MstEmployeeDaoTest.java

@RunWith(SpringRunner.class) // テストランナーでSpringRunner.classを指定してSpring Test Contextを使用
@JdbcTest // DB部分のコンポーネントだけロードする ※1
@Import(DomaAutoConfiguration.class) // Doma2のconfigurationをimport
@ComponentScan // テスト対象の実装Beanをscan
@Sql(scripts = "../../../schema-dev.sql") // DMLを実行する。ここではテーブルをDrop&Createしている
@Sql(scripts = "data_employee.sql") // テスト対象のデータをInsertしている
public class MstEmployeeDaoTest {

    @Autowired
    MstEmployeeDao dao;

    @Test
    public void selectAll() {

        List<MstEmployee> mstEmployees = dao.selectAll();
        assertThat(mstEmployees.size(), is(3));
    }

    @Test
    public void selectOne() {

        MstEmployee actual = dao.selectOne("01");
        assertThat(actual.getEmployeeLastName(), is("管理"));
        assertThat(actual.getEmployeeFirstName(), is("太郎"));
        assertThat(actual.getRoleId(), is("ROLE_ADMIN"));

        actual = dao.selectOne("100");
        assertNull(actual);
    }

    @Test
    public void selectUser(){

        UserEntity actual = dao.selectUser("01");
        assertThat(actual.getEmployeeLastName(), is("管理"));
        assertThat(actual.getEmployeeFirstName(), is("太郎"));
        assertThat(actual.getRoleId(), is("ROLE_ADMIN"));
        assertThat(actual.getPassword(), is("$2a$10$1gJJgBlL75OIjkSgkYPXI.mV7ihEPjxIiCkXKBEc7/r9xUIjZyc9i"));

        actual = dao.selectUser("100");
        assertNull(actual);
    }
}

※1 https://docs.spring.io/spring-boot/docs/current/reference/html/test-auto-configuration.html

Doma2自動生成のメソッドですが、insert,update,deleteもテストしておきたいです。

MstNewsDaoTest.java

@RunWith(SpringRunner.class)
@JdbcTest
@Import(DomaAutoConfiguration.class)
@ComponentScan
@Sql(scripts = "../../../schema-dev.sql")
@Sql(scripts = "data_news.sql")
public class MstNewsDaoTest {

    @Autowired
    MstNewsDao dao;

// 中略

    @Test
    public void insert() {
        MstNews news = new MstNews();

        news.setSubject("単体テスト");
        news.setUrl("http://test.url");
        news.setRoleId("ROLE_ADMIN");

        dao.insert(news);

        List<NewsDto> newsDtoList = dao.selectNewsDtoByCond("単体テスト", null, null, getDefaultSelectOptions());

        NewsDto dto = newsDtoList.get(0);
        assertThat(dto.getSubject(), is("単体テスト"));
        assertThat(dto.getRoleId(),is("ROLE_ADMIN"));
        assertThat(dto.getRoleNm(), is("管理者"));
        assertThat(dto.getUrl(), is("http://test.url"));
    }

    @Test
    public  void update() {

        List<MstNews> mstNewsList = dao.selectAll();
        MstNews news = mstNewsList.get(0);

        news.setSubject("更新テスト");
        news.setUrl("http://test.update.url");
        news.setRoleId("ROLE_ADMIN");
        int ver = news.getVersion();

        dao.update(news);

        List<NewsDto> newsDtoList = dao.selectNewsDtoByCond("更新テスト", null, null, getDefaultSelectOptions());

        NewsDto dto = newsDtoList.get(0);
        assertThat(dto.getSubject(), is("更新テスト"));
        assertThat(dto.getRoleId(),is("ROLE_ADMIN"));
        assertThat(dto.getRoleNm(), is("管理者"));
        assertThat(dto.getUrl(), is("http://test.update.url"));
        assertThat(dto.getVersion(), is(ver + 1));
    }

    @Test
    public void delete() {

        List<MstNews> mstNewsList = dao.selectAll();
        int size = mstNewsList.size();
        MstNews mstNews = mstNewsList.get(0);

        dao.delete(mstNews);

        mstNewsList = dao.selectAll();

        assertThat(mstNewsList.size(), is(size - 1));

    }

   private SelectOptions getDefaultSelectOptions() {
        // 最初のページ
        int pageNo = 0;
        // ページあたり件数
        int sizePerPage = 5;
        // offset指定、最大100件、カウントあり
        int offset = pageNo * sizePerPage;
        return SelectOptions.get().offset(offset).limit(100).count();
    }

// 後略

}

コントローラクラスのテスト

コントローラのテストを実行します。 ここでは、以下をテスト対象とします。

  1. リクエストマッピング
  2. 認証
  3. バリデーションのメッセージ

NewsManagerListControllerTest.java

@RunWith(SpringRunner.class)
@WebMvcTest(value = NewsManagerListController.class) // テスト対象のコントローラを指定
@Import(SecurityConfig.class) // 認証の設定を追加でロード
public class NewsManagerListControllerTest {

    // 擬似的なリクエストをDispatcher Servletにリクエストするモック
    @Autowired
    private MockMvc mvc;

    @MockBean
    NewsService mockService;

    @Before
    public void setup() {

        // セキュリティ設定
        AuthenticationTestHelper.管理者権限の設定();
        // modelAttribute
        Mockito.when(mockService.retrieveRoleIdMap()).thenReturn(new HashMap<String, String>());
    }

    @Test
    public void 重要なお知らせリスト画面_リクエストマッピング() throws Exception {

        this.mvc.perform(
                MockMvcRequestBuilders.get("/manager/news/list")
                        .with(csrf())
        ).andExpect(status().isOk())
        .andExpect(view().name("/manager/news/list/newsList"));
    }

    @Test
    public void 重要なお知らせリスト画面_一般ユーザでエラーになる() throws Exception {

        // セキュリティ設定
        AuthenticationTestHelper.一般権限の設定();
        this.mvc.perform(
                MockMvcRequestBuilders.get("/manager/news/list")
                        .with(csrf())
        ).andExpect(status().is4xxClientError());
    }
}

重要なお知らせリスト画面_リクエストマッピング メソッドでは「/manager/news/list」に対するリクエストに対し、 HTTPステータスの200が返却され、ビュー「/manager/news/list/newsList」が返却されることをテストしています。 Spring Securityが設定されているため、CSRFトークンが設定される必要があるのでwith(csrf()) でCSRFトークンが設定されています。 setup メソッドではAuthenticationTestHelper クラスで認証設定をしています。

AuthenticationTestHelper.java

public class AuthenticationTestHelper {

    public static void 管理者権限の設定() {

        // 認証状態にする
        UserInfo userInfo = new UserInfo();

        userInfo.setId("01");
        userInfo.setEmployeeFirstName("テスト");
        userInfo.setEmployeeLastName("太郎");
        userInfo.setRoleId("ROLE_ADMIN");
        userInfo.setPassword("pwd");

        LoginUserDetails details = new LoginUserDetails(userInfo);

        Authentication authentication =
                new TestingAuthenticationToken(details, null, "ROLE_ADMIN");

        setAuthentication(authentication);

    }

    public static void 一般権限の設定() {

        // 認証状態にする
        UserInfo userInfo = new UserInfo();

        userInfo.setId("01");
        userInfo.setEmployeeFirstName("テスト");
        userInfo.setEmployeeLastName("太郎");
        userInfo.setRoleId("ROLE_USER");
        userInfo.setPassword("pwd");

        LoginUserDetails details = new LoginUserDetails(userInfo);

        Authentication authentication =
                new TestingAuthenticationToken(details, null, "ROLE_USER");

        setAuthentication(authentication);

    }

    private static void setAuthentication(Authentication authentication) {
        SecurityContext securityContext;
        securityContext = SecurityContextHolder.createEmptyContext();
        securityContext.setAuthentication(authentication);
        SecurityContextHolder.setContext(securityContext);
    }
}

AuthenticationTestHelperクラスではSecurityContextHolderにSecurityContextを設定し、Spring Securityの認証状態を設定します。

NewsManagerRegisterControllerTest.java

@RunWith(SpringRunner.class)
@WebMvcTest(value = NewsManagerRegisterController.class)
@Import(SecurityConfig.class)
public class NewsManagerRegisterControllerTest {

    @Autowired
    private MockMvc mvc;

    @MockBean
    NewsService mockService;

    @Before
    public void setup() {

        AuthenticationTestHelper.管理者権限の設定();
        // modelAttribute
        Mockito.when(mockService.retrieveRoleIdMap()).thenReturn(new HashMap<String, String>());
    }

    @Test
    public void お知らせ登録確認画面_バリデーションチェック() throws Exception {

        MvcResult result = this.mvc.perform(
                MockMvcRequestBuilders.post("/manager/news/register")
                        .param("confirm", "")
                        .param("url", "hoge://a.b")
                        .param("subject", "")
                        .with(csrf())
        )
                .andExpect(status().isOk())
                .andExpect(model().hasErrors())
                .andExpect(view().name("/manager/news/register/newsRegisterInput"))
                .andReturn();

        // エラーメッセージの確認
        String content = result.getResponse().getContentAsString();

        assertThat(content, is(containsString("お知らせURLの形式が正しくありません。")));
        assertThat(content, is(containsString("お知らせ表題が入力されていません。")));
        assertThat(content, is(containsString("権限IDが入力されていません。")));

    }

}

NewsForm.java

/**
 * お知らせ画面フォームクラス.
 */
public class NewsForm {

    /** id */
    private Long id;
    /** ページ */
    private Integer page;
    /** 表題 */
    @NotBlank
    private String subject;
    /** 権限 */
    @NotBlank
    private String roleId;
    /** 権限名 */
    private String roleNm;
    /** URL */
    @NotBlank
    @URL(message = "お知らせURLの形式が正しくありません。")
    private String url;
    /** バージョン */
    private int version;

    // getter.setter
}

ValidationMessages.properties

org.hibernate.validator.constraints.NotBlank.message = {0}が入力されていません。

message.properties

newsForm.subject = お知らせ表題
newsForm.url = お知らせURL
newsForm.roleId = 権限ID

バリデーションチェックの結果、出力されるメッセージをレスポンスの文字列で確認しています。 エラーメッセージはValidationMessages.properties ,messages.properties ,Formクラスが関連して出力されるので、ユニットテストで確認しておいた方が無難です。