LoginSignup
4
3

More than 1 year has passed since last update.

ポートフォリオ作成の過程で学んだ技術を振り返る ~Spring Boot編~

Last updated at Posted at 2022-05-07

はじめに

Javaのポートフォリオ作成の過程で学んだフレームワークやライブラリ等を、サンプルコードをもとに一言で説明したり要点をまとめてみました。
本記事は自身の振り返りのために作成した備忘録なので、第三者向けの説明が不十分な箇所があるかと思います。ご了承ください。

Spring Tool Suite(STS)

Springベースのアプリケーション開発を行うために提供される開発ツール。
https://spring.io/tools

MVCアーキテクチャ

Webアプリケーションを設計・作成するときの考え方。

表1 MVCとその役割

名前 役割 対応するファイル
Model アプリケーションで扱うデータを管理する Entity,Repository,Service
View 画面の表示を扱う Thymeleaf(HTML)
Controller あるアドレスにアクセスした時に実行される処理を制御する Controller

図で表すとこんな感じ
qiita-spring2.png

Entity

データベースに定義したテーブルのレコードを、オブジェクトとして利用できるようにしたクラス。
フィールドはprivateにして、publicなgetterとsetterメソッドを定義する。

Post.java
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を自動生成できる。超便利。
Inkedqiita-spring_LI.jpg

Repository

データの永続化を担うインタフェース。
前述のEntityをもとに、データベースの値を取得・追加・更新・削除する。SQLを実行してデータベースを更新するイメージ。
基本的には、Entity一つに対し一つのRepositoryを作成する(例外有り)。

MyBatis

SQLとJavaのオブジェクトを紐づける、データベースアクセス用の外部フレームワーク。
Mapperインタフェースとマッピングファイル(xml)の2つを作成する。MapperインタフェースはRepositoryインタフェースの代わりとして使用できる。

PostMapper.java
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
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
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
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アーキテクチャに当てはめると、(多分)こんな感じ
qiita-spring3.png

Dependency Injection(依存性の注入)って?

あるクラスに必要となる「部品(コンポーネント)」を設定する仕組み。部品(コンポーネント)とは他クラスのインスタンスのこと。
「他クラスをnewしてメソッドを呼び出せるようにしないと、このメソッドが使えないんだよな~」
「今はnewしてコーディングしてるけど、単体テストどうすりゃいいんだ?」
という悩みを解決してくれる。

DIコンテナ

部品(Bean)の入れ物。
Spring framewworkでは、ApplicationContextがDIコンテナの役割を担う。

Bean

DIコンテナで管理されるインスタンス。
特定のアノテーションをクラスに付与することで、アプリにBeanとして認識される。

コンポーネントスキャン

アプリ起動時に、Bean定義用のアノテーションが付与されたクラスをインスタンス化し、DIコンテナによって登録する仕組み。

Beanとして定義・管理してもらうには?

・Javaベース
@Configurationが付与されたクラスのメソッドに、@Beanアノテーションを付与する。
メソッドは、Bean登録したいクラスを戻り値として返すように定義する。

BeanConfig.java
@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
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
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
PostController.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コンテナに登録してエラーを解消しました。
(本当はこれをモック化してテストしたかったんですが上手い方法が見つかりませんでした。。)

参考文献

4
3
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
4
3