はじめに
Javaのポートフォリオ作成の過程で学んだフレームワークやライブラリ等を、サンプルコードをもとに一言で説明したり要点をまとめてみました。
本記事は自身の振り返りのために作成した備忘録なので、第三者向けの説明が不十分な箇所があるかと思います。ご了承ください。
Spring Tool Suite(STS)
Springベースのアプリケーション開発を行うために提供される開発ツール。
https://spring.io/tools
MVCアーキテクチャ
Webアプリケーションを設計・作成するときの考え方。
表1 MVCとその役割
名前 | 役割 | 対応するファイル |
---|---|---|
Model | アプリケーションで扱うデータを管理する | Entity,Repository,Service |
View | 画面の表示を扱う | Thymeleaf(HTML) |
Controller | あるアドレスにアクセスした時に実行される処理を制御する | Controller |
Entity
データベースに定義したテーブルのレコードを、オブジェクトとして利用できるようにしたクラス。
フィールドはprivateにして、publicなgetterとsetterメソッドを定義する。
Post.java
public class Post {
private String userName;
private String nickName;
private String content;
private int postCategory;
private LocalDateTime createAt;
//コンストラクタ、getter、setterは省略
}
ソースコードを右クリック→Sourceから、getterとsetterを自動生成できる。超便利。
Repository
データの永続化を担うインタフェース。
前述のEntityをもとに、データベースの値を取得・追加・更新・削除する。SQLを実行してデータベースを更新するイメージ。
基本的には、Entity一つに対し一つのRepositoryを作成する(例外有り)。
MyBatis
SQLとJavaのオブジェクトを紐づける、データベースアクセス用の外部フレームワーク。
Mapperインタフェースとマッピングファイル(xml)の2つを作成する。MapperインタフェースはRepositoryインタフェースの代わりとして使用できる。
PostMapper.java
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PostMapper {
void insertPost(Post post);
//引数が複数存在する場合は、@Param("~~~")で引数にユニークな名前を付与させる。
void deletePost(@Param("userName")String userName,
@Param("postId")long postId);
PostRecord findOnePostRecord(long postId);
}
PostMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sample.spring.model.mapper.PostMapper">
<insert id = "insertPost" parameterType="com.sample.spring.model.entity.Post">
INSERT INTO
post(username,nickname,content,postcategory,createat)
VALUES(
#{userName},
#{nickName},
#{content},
#{postCategory},
#{createAt}
)
</insert>
<delete id = "deletePost">
DELETE FROM post
WHERE
username = #{userName}
and postid = #{postId}
</delete>
<select id = "findOnePostRecord" resultMap="PostRecordResultMap">
SELECT
POST.postid,
POST.username,
POST.nickname,
S_LIST.statusname,
P_CATEGORY.postname,
POST.content,
CAST(POST.createat as CHAR) as createat
FROM
post as POST
INNER JOIN account_info as INFO
ON POST.nickname = INFO.nickname
INNER JOIN status_list as S_LIST
ON INFO.status = S_LIST.statusid
INNER JOIN post_category as P_CATEGORY
ON POST.postcategory = P_CATEGORY.postid
WHERE
POST.postid = #{postid}
</select>
<resultMap id="PostRecordResultMap" type="com.sample.spring.model.entity.PostRecord">
<result property="postId" column="postid"></result>
<result property="userName" column="username"></result>
<result property="nickName" column="nickname"></result>
<result property="status" column="statusname"></result>
<result property="postCategory" column="postname"></result>
<result property="content" column="content"></result>
<result property="createAt" column="createat"></result>
</resultMap>
</mapper>
以下、Mybatisを使う上での超ザックリな要点
・マッピングファイルはsrc/main/resources下に、インタフェースと同一パッケージに同一名で作成する。
・Java側で定義したメソッド名を、xml側でidとして記述し、メソッドに紐づけたいSQLを記述する。
・インタフェースで指定した引数をSQL内で使う場合は、バインド変数を利用する。
「#{変数名}」もしくは「#{@PARAM(~~~)で指定した名前}」と記述する。
・テーブルの結合など複雑なマッピングが必要な場合は、戻り値としてresultMap属性を別途定義する。propertyにエンティティ側のフィールド、columnにはSQLで取得した値の列名を設定する。
Service
ロジック・データ処理を担うクラス。
前述のRepositoryから取得したデータを後述のControllerに渡したり、Repositoryを呼び出してデータベースの更新を命令する。
PostService.java
@Service
public class PostService {
private final PostMapper postMapper;
public PostService(PostMapper postMapper) {
this.postMapper = postMapper;
}
@Transactional(readOnly = false)
public void insertPost(PostForm form) {
Post post = new Post(form.getUserName(),form.getNickName(),form.getContent(),
form.getPostCategory(),LocalDateTime.now());
postMapper.insertPost(post);
}
@Transactional(readOnly = false)
public void deletePost(String userName,long postId) {
postMapper.deletePost(userName, postId);
}
@Transactional(readOnly = true)
public PostRecord findOnePostRecord(long postId) {
return postMapper.findOnePostRecord(postId);
}
Controller
アプリ利用者からリクエストを受け取り、レスポンスを返すクラス。
Controllerのメソッドには「@RequestMapping("パス")」を付与し、パスに対するリクエストをメソッドにマッピングする。
メソッドの引数にはwebページで利用するテンプレートやデータを管理する「Modelクラス」もしくは「ModelAndViewクラス」を用意する必要がある。
Controllerクラスで記述する処理は大きく分けて2つ
① リクエストマッピング、受け取るリクエストデータを設定する。
② 入力チェックを行い問題なければ、必要に応じてServiceクラスを呼び出したり遷移先にデータを設定する。最後に必ず遷移先を指定する。
PostController.java
@Controller
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
@GetMapping("/index/content/{postId}")
String showPostDetail(@AuthenticationPrincipal AccountUserDetails details,
@PathVariable("postId")long postId,Model model) {
PostRecord record = postService.findOnePostRecord(postId);
if(record == null) {
return "error/404";
}
model.addAttribute("postRecord",record);
return "Post/PostDetail";
}
@PostMapping("/index/content/post/delete")
String deletePost(@AuthenticationPrincipal AccountUserDetails details,
@RequestParam(value="postId")long postId) {
postService.deletePost(details.getUsername(),postId);
return "redirect:/index/content";
}
@PostMapping("/index/content/post/insert")
String insertPost(@ModelAttribute("postForm") @Validated PostForm form,
BindingResult result,Model model) {
//入力チェック
if(result.hasErrors()) {
return "Post/PostCreate";
}
//Serviceクラス(ビジネスロジック)の呼び出し
postService.insertPost(form);
//遷移先にデータを連携
model.addAttribute("message","投稿しました。");
//遷移先の指定
return "redirect:/index/content";
}
}
Thymeleaf
プログラム内から画面の表示を操作できるテンプレートエンジン。
htmlのth属性に対し、値の紐づけを行う式を記述する。
表2 Thymeleafで使う式
名称 | 書き方 | 例 | 説明 |
---|---|---|---|
変数式 | ${...} | th:text="${message}" | Controllerから渡された変数を埋め込む。 文字列や数値、エンティティも受け取ることが出来る。 |
選択変数式 | *{...} | th:field="*{nickName}" | 特定のオブジェクトのプロパティを埋め込む。 th:objectと組み合わせて使う。 |
メッセージ式 | #{...} | th:text="#{index.title}" | プロジェクトで用意しておいた プロパティファイルの値を埋め込む。 |
リンク式 | @{...} | th:href="@{/index/content/{id}(id=${obj.postId})}" | 他の変数と合わせてリンクのアドレスを指定する。 |
振り返ってきたクラスをMVCアーキテクチャに当てはめると、(多分)こんな感じ
Dependency Injection(依存性の注入)って?
あるクラスに必要となる「部品(コンポーネント)」を設定する仕組み。部品(コンポーネント)とは他クラスのインスタンスのこと。
「他クラスをnewしてメソッドを呼び出せるようにしないと、このメソッドが使えないんだよな~」
「今はnewしてコーディングしてるけど、単体テストどうすりゃいいんだ?」
という悩みを解決してくれる。
DIコンテナ
部品(Bean)の入れ物。
Spring framewworkでは、ApplicationContextがDIコンテナの役割を担う。
Bean
DIコンテナで管理されるインスタンス。
特定のアノテーションをクラスに付与することで、アプリにBeanとして認識される。
コンポーネントスキャン
アプリ起動時に、Bean定義用のアノテーションが付与されたクラスをインスタンス化し、DIコンテナによって登録する仕組み。
Beanとして定義・管理してもらうには?
・Javaベース
@Configurationが付与されたクラスのメソッドに、@Beanアノテーションを付与する。
メソッドは、Bean登録したいクラスを戻り値として返すように定義する。
@Configuration
public class BeanConfig {
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public ExifRewriter exifRewriter() {
return new ExifRewriter();
}
・アノテーションベース
コンポーネントスキャン対象のアノテーションをクラスに付与することで、自動でDIコンテナで管理される。
表3 スキャン対象のアノテーションと使い分け
アノテーション | 付与するクラス |
---|---|
@Controller | クライアントからリクエストを受け取り、レスポンスを返すクラス |
@Service | 業務ロジックを扱うクラス |
@Repository | データ永続化に関わる処理を扱うクラス |
@Component | 上記に当てはまらないクラス(バリデーションとか) |
どんなメリットがあるの?
・Beanクラスを必要とするクラスで、Beanクラスをインスタンス化する必要が無くなる。
・テストが簡単になる(後述のMockitoを使用した単体テストが可能になる)。
Spring Security
セキュリティ機能を提供するフレームワーク。
認証と認可を基本機能として提供し、要件に合わせてデフォルト実装の動作を変更していく。
※今回はConfigファイルの設定に絞り、UserDetailsやUserDetailsServiceの実装サンプルは省略します。
WebSecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled=true) //メソッド認可の有効
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final SuccessHandler successHandler;
private final AccountUserDetailsService accountUserDetailsService;
private final PasswordEncoder passwordEncoder;
public WebSecurityConfig(SuccessHandler successHandler,
AccountUserDetailsService accountUserDetailsService,
PasswordEncoder passwordEncoder) {
this.successHandler = successHandler;
this.accountUserDetailsService = accountUserDetailsService;
this.passwordEncoder = passwordEncoder;
}
@Override //全体に対するセキュリティ設定を行う
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/css/**", "/webjars/**", "/js/**", "/images/**");
}
@Override //URLごとに異なるセキュリティ設定を行う
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login") //ログインフォームを表示させるパス
.loginProcessingUrl("/authenticate") //フォーム認証のaction属性の値
.usernameParameter("username") //ユーザ名を入力するinputタグのname属性の値
.passwordParameter("password") //パスワードを入れるinputタグのname属性の値
.successHandler(successHandler) //ログイン成功時にROLEによって遷移するページを制御
.failureUrl("/login?error") //ログイン失敗時のURL
.permitAll();
http.logout()
.logoutUrl("/logout") //ログアウト処理のURL
.logoutSuccessUrl("/login?logout") //ログアウト成功時に遷移するURL
.permitAll();
http.authorizeRequests()// アクセス権限の設定
.antMatchers("/").permitAll() //トップページは全ユーザがアクセス可
.antMatchers("/registration","/regist").permitAll() //登録処理は全ユーザがアクセス可
.antMatchers("/resetpassword","/updatePassword").permitAll() //パスワード再設定は全ユーザがアクセス可
.antMatchers("/admin/**").hasAuthority("ROLE_ADMIN") // /admin/**ページは、ROLE_ADMINを持つ認証ユーザーがアクセスできる
.antMatchers("/index/**").hasAuthority("ROLE_USER") // /index/**ページは、ROLE_USERを持つ認証ユーザーがアクセスできる
.anyRequest().authenticated() //上記以外のリクエストは認証を求める
.and()
.exceptionHandling().accessDeniedPage("/accessdenied"); //アクセス拒否された時に遷移するパス
}
@Override //認証方法の実装の設定を行う
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(accountUserDetailsService).passwordEncoder(passwordEncoder);
}
}
passay
パスワード検証ライブラリ。
設定したルール(アルファベットの大文字を含む等)に沿ってパスワードを生成したり、入力されたパスワードが特定の正規表現を満たしているかを検証することが出来る。
(恥ずかしながら、terasolunaのドキュメントで初めて存在を知りました。。)
Mockito
ユニットテストのために開発されたモックフレームワーク。
テスト対象クラスが依存している他クラスをモック化して、戻り値を自由に設定してテストが行える。
Serviceクラスの単体テスト
PostServiceTest.java
@RunWith(SpringRunner.class)
public class PostServiceTest {
private static LocalDateTime datetime = LocalDateTime.parse("2022-03-03T09:31:12");
private static MockedStatic<LocalDateTime> mock;
@Mock //テスト対象のクラス内で呼ばれるクラスのMockオブジェクト
PostMapper postMapper;
@InjectMocks //モックを注入する、テスト対象のクラス
PostService postService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
//staticなメソッドもモック化できる。CALLS_REAL_METHODSで、部分的なMock化が可能になる。
mock = Mockito.mockStatic(LocalDateTime.class,Mockito.CALLS_REAL_METHODS);
//メソッド参照で、staticなメソッドをモック化する。
mock.when(LocalDateTime::now).thenReturn(datetime);
}
@AfterEach //mockStaticのモック化の解除
void tearDown() throws Exception{
mock.close();
}
@Test
void insertPostで投稿が1件追加される() throws Exception{
PostForm form = new PostForm();
form.setUserName("miho");
form.setNickName("匿名");
form.setContent("こんにちわ");
form.setPostCategory(2);
//データベース更新系のメソッドの単体テストでは、実行されたら何も行わないよう設定
doNothing().when(postMapper).insertPost(any(Post.class));
postService.insertPost(form);
//モックオブジェクトのメソッドが1回呼ばれていることをチェック
verify(postMapper,times(1)).insertPost(any(Post.class));
}
@Test
void deletePostで投稿が1件追加される() throws Exception{
//データベース更新系のメソッドの単体テストでは、実行されたら何も行わないよう設定
doNothing().when(postMapper).deletePost("糸井", 3);
postService.deletePost("糸井", 3);
//モックオブジェクトのメソッドが1回呼ばれていることをチェック
verify(postMapper,times(1)).deletePost("糸井", 3);
}
@Test
void findOnePostRecordで投稿を一件取得する() throws Exception{
PostRecord record = new PostRecord();
record.setPostId("10");
record.setUserName("マクベイ");
record.setNickName("mack");
record.setContent("筋トレ第一");
record.setStatus("健康状態問題なし");
record.setPostCategory("その他");
record.setCreateAt("2022-03-03 19:32:44");
//モックオブジェクトのメソッドの戻り値を設定
when(postMapper.findOnePostRecord(10)).thenReturn(record);
PostRecord result = postService.findOnePostRecord(10);
// テスト対象メソッドの戻り値を検証
assertEquals("10",result.getPostId());
assertEquals("マクベイ",result.getUserName());
assertEquals("mack",result.getNickName());
assertEquals("筋トレ第一",result.getContent());
assertEquals("健康状態問題なし",result.getStatus());
assertEquals("その他",result.getPostCategory());
assertEquals("2022-03-03 19:32:44",result.getCreateAt());
//モックオブジェクトのメソッドが1回呼ばれていることをチェック
verify(postMapper,times(1)).findOnePostRecord(10);
}
@Test
void findOnePostRecordで投稿を取得できない場合はnullが返ってくる() throws Exception{
when(postMapper.findOnePostRecord(10)).thenReturn(null);
PostRecord result = postService.findOnePostRecord(10);
assertEquals(null,result);
verify(postMapper,times(1)).findOnePostRecord(10);
}
}
Spring Securityを考慮したControllerのテストでのつまずき
PostControllerTest.java
@AutoConfigureMockMvc
@AutoConfigureMybatis
@WebMvcTest(controllers = PostController.class,
includeFilters = @ComponentScan.Filter
(type = FilterType.ASSIGNABLE_TYPE,
value = {AccountUserDetailsService.class,BeanConfig.class,
SuccessHandler.class}))
public class PostControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private WebApplicationContext context;
@MockBean
PostService postService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(springSecurity()).build();
}
@Nested
class showPostDetail {
PostRecord record;
@BeforeEach
void setUp(){
record = new PostRecord("7","miho","匿名","ダイエット中","ダイエット",
"先月から体重1キロ落ち増した!","2022-03-02 11:12:50");
when(postService.findOnePostRecord(7)).thenReturn(record);
when(postService.findOnePostRecord(333)).thenReturn(null);
}
@Test
@WithMockCustomUser(userName="マクベイ",password="sun-fla-cis",role="ROLE_USER")
void showPostDetailで投稿詳細画面が表示される() throws Exception{
mockMvc.perform(get("/index/content/7"))
.andExpect(status().is2xxSuccessful())
.andExpect(model().attribute("postRecord",
hasProperty("nickName",is("匿名"))))
.andExpect(view().name("Post/PostDetail"));
verify(postService,times(1)).findOnePostRecord(7);
}
@Test
@WithMockCustomUser(userName="miho",password="ocean_nu",role="ROLE_USER")
void showPostDetailで投稿が見つからない場合は404ページを返す() throws Exception{
mockMvc.perform(get("/index/content/333"))
.andExpect(status().is2xxSuccessful())
.andExpect(model().hasNoErrors())
.andExpect(view().name("error/404"));
verify(postService,times(1)).findOnePostRecord(333);
}
}
@Test
@WithMockCustomUser(userName="miho",password="ocean_nu",role="ROLE_USER")
void insertPostでぼやき投稿が1件追加される() throws Exception{
PostForm form = new PostForm();
form.setUserName("miho");
form.setNickName("匿名");
form.setContent("よろしくお願いします");
form.setPostCategory(2);
doNothing().when(postService).insertPost(form);
mockMvc.perform(post("/index/content/post/insert")
.flashAttr("postForm", form)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.with(SecurityMockMvcRequestPostProcessors.csrf()))
.andExpect(status().is3xxRedirection())
.andExpect(model().hasNoErrors())
.andExpect(redirectedUrl("/index/content"));
verify(postService,times(1)).insertPost(form);
}
}
Spring Securityによる認証後にレスポンスを返すControllerのテストは、@WebMvcTestアノテーションを付与してテストします。そうすることでSpring Securityが有効になり、セキュリティも考慮したテストが可能になります。
しかし、@WebMvcTestだけでは@Serviceや@RepositoryといったBeanは適用されない仕様でした。それを知らずにテストを実行したところ、
Spring Securityが有効になる
↓
Spring SecurityのConfigファイルが有効になる
↓
Configファイルが他クラスに依存しているので、テスト時にエラーが発生する
という事態になってしまいました。アレコレ対策を考えて、アノテーションの引数にincludeFiltersを追加してConfigが依存しているBeanをDIコンテナに登録してエラーを解消しました。
(本当はこれをモック化してテストしたかったんですが上手い方法が見つかりませんでした。。)
参考文献