Spring BootからMyBatisを使ったDB接続(3回目 トランザクション管理)

2022/11/04に公開

前回の記事でCRUDを行うAPIを開発しました。
Spring BootからMyBatisを使ったDB接続(2回目 CRUDを行うAPI)

今回は更新系APIにトランザクション管理を組み込みます。
また、テストにおいて各ケース(テストメソッド)が終了した際に自動的にロールバックされるようにトランザクションを有効化します。
そうすることで各テストケースを同一条件で設計でき、テストケース間の依存関係を持たないようにすることで保守性を高めることを図ります。

トランザクション管理とは

様々なサイトで説明が掲載されていますが、ここでは擬似的なソースコードで説明したいと思います。
ちょっと強引な例として、商品(Item)データの更新を以下の流れで行うものとします。

  1. 更新対象のidの既存レコードを削除
  2. 更新するデータをチェック
  3. 更新後のデータとして同一idで新規登録

トランザクション管理がされていない場合、2の処理でエラーが発生して強制終了した場合に、1の処理が取り消されず(ロールバックされず)、処理終了後は更新対象のレコードが削除された状態となりデータの不整合が発生します。

そこで、

  • 1,2,3全ての処理が正常終了したらコミット
  • いずれかの処理でエラーが発生した場合はロールバック

とすることでデータが不整合な状態になることを回避します。
1,2,3のような処理の単位をトランザクションと言い、仕組みをトランザクション管理と言います。

擬似的なソースコードで見てみましょう。

@RestController
@RequestMapping("/items")
public class ItemController {
    @PutMapping
    public ItemResponse doPut(@PathVariable int id, @RequestBody ItemRequest itemRequest) {
        
        // 1.削除
        item.delete(id);

        // 2.チェック
        item.check();

        // 3.登録
        Item item = new Item();
        BeanUtils.copyProperties(itemRequest, item);
        item.setId(id);
        itemMapper.insert(item);

        // レスポンスを返却
        ItemResponse itemResponse = new ItemResponse();
        BeanUtils.copyProperties(item, itemResponse);
        return itemResponse;
    }
}

2.チェックitem.check()内でRuntimeException(非検査例外)がthrowされた場合、ここで処理が強制終了され、後続のinsertは実行されません。
そのときに1.削除をロールバックしてデータの不整合が発生しないようにトランザクション管理を行います。

テストメソッドのトランザクション有効化

上記のトランザクション管理とは目的が別となりますが、テストメソッドのトランザクション有効化についても実装します。

Spring Testの公式マニュアルの以下のサイトで説明がされていますが、テストメソッドのトランザクションを有効化するとテスト完了後に自動的にロールバックされます。
https://spring.pleiades.io/spring-framework/docs/current/reference/html/testing.html#testcontext-tx-enabling-transactions

どうして自動でロールバックさせるのか、今回も擬似的なソースコードを例に見ていきましょう。

@SpringBootTest
@AutoConfigureMockMvc
class SpringBootDemoApplicationTests {

    @Test
    void test1() {
        ...
    }

    @Test
    void test2() {
        ...
    }

上記のようにテストメソッドが二つあるとします。

デフォルトではテストメソッドの実行順序は保証されません。
もしトランザクションを有効化せずに、

  1. test1()で更新処理含むテスト
  2. test2()ではtest2()での更新結果を前提としたテスト

と開発した場合、新しくメソッドを追加したタイミング等で実行順序が変わりテストがエラーとなることがあります。

@Orderアノテーションを使って、順序を指定することも出来ますが、他のテストメソッドの結果に依存するテスト設計は見通しも悪くお勧めしません。

また、トランザクションを有効化した場合は、メソッド開始時点のDBの状態が同一なので、テストの設計もシンプルとなります。

以上の理由からテストメソッドはトランザクションを有効化することをお勧めします。

ソースコード

今回作成するソースコードの全量は以下を参照ください。

https://github.com/ryotsuka7/spring-boot-demo/tree/v.1.0.5

トランザクション管理の実装

説明が長くなりましたが、トランザクション管理を実装していきましょう。
実装自体は非常に簡単です。
今まで実装してきたコードに対して、トランザクション管理を行いたいメソッドに@Transactionalアノテーションを付与するだけです。

ControllerとTestクラスの全量を載せておきますが、アノテーションを追記しているだけです。

ItemController.java
package com.example.springbootdemo.controller;

import com.example.springbootdemo.dto.ItemRequest;
import com.example.springbootdemo.dto.ItemResponse;
import com.example.springbootdemo.entity.Item;
import com.example.springbootdemo.mapper.ItemMapper;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/items")
public class ItemController {

    @Autowired
    ItemMapper itemMapper;

    @GetMapping("/{id}")
    @ResponseStatus(HttpStatus.OK)
    public ItemResponse findById(@PathVariable int id) {
        // DBからidをキーにデータを取得
        Item item = itemMapper.findById(id);

        // Responseにデータをコピーしてreturn
        ItemResponse itemResponse = new ItemResponse();
        BeanUtils.copyProperties(item, itemResponse);
        return itemResponse;
    }

    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    public List<ItemResponse> getItems() {
        List<ItemResponse> itemResponseList = new ArrayList<>();

        List<Item> itemList = itemMapper.findAll();

        itemList.forEach(item -> {
            ItemResponse itemResponse = new ItemResponse();
            BeanUtils.copyProperties(item, itemResponse);
            itemResponseList.add(itemResponse);
        });

        return itemResponseList;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    @Transactional
    public ItemResponse doPost(@RequestBody ItemRequest itemRequest) {
        Item item = new Item();
        BeanUtils.copyProperties(itemRequest, item);

        int ret = itemMapper.insert(item);

        ItemResponse itemResponse = new ItemResponse();
        BeanUtils.copyProperties(item, itemResponse);

        return itemResponse;
    }

    @PutMapping("/{id}")
    @ResponseStatus(HttpStatus.OK)
    @Transactional
    public ItemResponse doPut(@PathVariable int id, @RequestBody ItemRequest itemRequest) {
        Item item = new Item();
        BeanUtils.copyProperties(itemRequest, item);
        item.setId(id);
        itemMapper.update(item);

        ItemResponse itemResponse = new ItemResponse();
        BeanUtils.copyProperties(item, itemResponse);

        return itemResponse;
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    @Transactional
    public void doDelete(@PathVariable int id) {
        boolean ret = itemMapper.delete(id);
    }
}
SpringBootDemoApplicationTests.java
package com.example.springbootdemo;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.example.springbootdemo.dto.ItemRequest;
import com.example.springbootdemo.dto.ItemResponse;
import com.example.springbootdemo.dto.Sample;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.transaction.annotation.Transactional;


@SpringBootTest
@AutoConfigureMockMvc
class SpringBootDemoApplicationTests {

    // APIを発行するためのMockオブジェクトを生成
    @Autowired
    private MockMvc mockMvc;

    @Test
    void testHello() throws Exception {
        // 検証するAPIパス
        final String API_PATH = "/hello";

        // JavaのObjectをJSONに変換するためのクラスを生成
        ObjectMapper objectMapper = new ObjectMapper();

        // 結果を検証するためのクラスを生成して、期待値をセット
        Sample sample = new Sample();
        sample.setId(100);
        sample.setName("taro");

        // 「/hello」パスのAPIを実行してレスポンスを検証
        this.mockMvc.perform(MockMvcRequestBuilders.get(API_PATH))
            .andDo(MockMvcResultHandlers.print())
            .andExpect(status().isOk())
            .andExpect(content().json(objectMapper.writeValueAsString(sample)));
    }

    @Test
    @Transactional
    void testCrud() throws Exception {
        // select(idは1)
        /**
         * GETでIDを指定して1件取得するテスト
         */
        // 検証するAPIパス
        final String API_PATH1 = "/items/1";

        // JavaのObjectをJSONに変換するためのクラスを生成
        ObjectMapper objectMapper = new ObjectMapper();

        // 期待値を設定
        ItemResponse itemResponse = new ItemResponse();
        itemResponse.setId(1);
        itemResponse.setItemName("大豆");

        // APIを実行してレスポンスを検証
        this.mockMvc.perform(MockMvcRequestBuilders.get(API_PATH1))
            .andDo(MockMvcResultHandlers.print())
            .andExpect(status().isOk())
            .andExpect(content().json(objectMapper.writeValueAsString(itemResponse)));

        /**
         * POSTによる登録処理のテスト
         * 2件登録されている前提なので、idは3となる
         */
        final String API_PATH2 = "/items";

        // リクエストボディのデータを設定
        ItemRequest itemRequest = new ItemRequest();
        itemRequest.setItemName("コーヒー豆");

        // 期待値を設定
        itemResponse = new ItemResponse();
        itemResponse.setId(3);
        itemResponse.setItemName("コーヒー豆");

        // APIを実行してレスポンスを検証
        this.mockMvc.perform(
                MockMvcRequestBuilders
                    .post(API_PATH2)
                    .content(objectMapper.writeValueAsString(itemRequest))
                    .contentType(MediaType.APPLICATION_JSON_VALUE)
            )
            .andDo(MockMvcResultHandlers.print())
            .andExpect(status().isCreated())
            .andExpect(content().json(objectMapper.writeValueAsString(itemResponse)));

        /**
         * GETでIDを指定せずに全件を取得するテスト
         */
        // 検証するAPIパス
        final String API_PATH3 = "/items";

        // APIを実行
        ResultActions resultActions = this.mockMvc.perform(MockMvcRequestBuilders.get(API_PATH3));
        // レスポンスを出力して、ステータスコードを検証
        resultActions
            .andDo(MockMvcResultHandlers.print())
            .andExpect(status().isOk());
        // JSONの件数が3件であることを検証
        // リクエストボディを文字列(JSON)をJavaオブジェクトに変換して、Listのサイズをassert
        String contentAsString = resultActions.andReturn().getResponse()
            .getContentAsString(StandardCharsets.UTF_8);
        List<ItemResponse> itemResponseList = objectMapper.readValue(contentAsString,
            new TypeReference<List<ItemResponse>>() {
            });
        assertThat(itemResponseList, hasSize(3));

        /**
         * PUTによる更新処理のテスト
         * id=1のデータの商品名を更新
         */
        final String API_PATH4 = "/items/1";

        // リクエストボディのデータを設定
        itemRequest = new ItemRequest();
        itemRequest.setItemName("茶豆");

        // 期待値を設定
        itemResponse = new ItemResponse();
        itemResponse.setId(1);
        itemResponse.setItemName("茶豆");

        // APIを実行してレスポンスを検証
        this.mockMvc.perform(
                MockMvcRequestBuilders
                    .put(API_PATH4)
                    .content(objectMapper.writeValueAsString(itemRequest))
                    .contentType(MediaType.APPLICATION_JSON_VALUE)
            )
            .andDo(MockMvcResultHandlers.print())
            .andExpect(status().isOk())
            .andExpect(content().json(objectMapper.writeValueAsString(itemResponse)));

        /**
         * PUTでDB上の値が更新されているか、GETでIDを指定して1件取得して確認
         */
        // 検証するAPIパス
        final String API_PATH5 = "/items/1";

        // 期待値を設定
        itemResponse = new ItemResponse();
        itemResponse.setId(1);
        itemResponse.setItemName("茶豆");

        // APIを実行してレスポンスを検証
        this.mockMvc.perform(MockMvcRequestBuilders.get(API_PATH1))
            .andDo(MockMvcResultHandlers.print())
            .andExpect(status().isOk())
            .andExpect(content().json(objectMapper.writeValueAsString(itemResponse)));

        /**
         * DELETEによる更新処理のテスト
         * id=2のデータを削除
         */
        // 検証するAPIパス
        final String API_PATH6 = "/items/2";

        // APIを実行してレスポンスを検証
        this.mockMvc.perform(MockMvcRequestBuilders.delete(API_PATH6))
            .andDo(MockMvcResultHandlers.print())
            .andExpect(status().isNoContent());

        /**
         * DELETEでDB上のレコードが削除されているか、GETで全件取得して件数で確認
         */
        // 検証するAPIパス
        final String API_PATH7 = "/items";

        // APIを実行
        resultActions = this.mockMvc.perform(MockMvcRequestBuilders.get(API_PATH7));
        // レスポンスを出力して、ステータスコードを検証
        resultActions
            .andDo(MockMvcResultHandlers.print())
            .andExpect(status().isOk());
        // JSONの件数が3件であることを検証
        // リクエストボディを文字列(JSON)をJavaオブジェクトに変換して、Listのサイズをassert
        contentAsString = resultActions.andReturn().getResponse()
            .getContentAsString(StandardCharsets.UTF_8);
        itemResponseList = objectMapper.readValue(contentAsString,
            new TypeReference<List<ItemResponse>>() {
            });
        assertThat(itemResponseList, hasSize(2));
    }
}

動作確認

APIのトランザクション管理の確認

トランザクション管理が正常に動作するか確認したところですが、今回の例は非常に単純なAPIなので、以下の通り、強制的にRunetimeExceptionをthrowして、その前に更新されたデータがロールバックされていることを確認しました。

削除処理後に強制終了させてみましょう。

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    @Transactional
    public void doDelete(@PathVariable int id) {
        boolean ret = itemMapper.delete(id);

        // 強制終了させる
        if (true) {
            throw new RuntimeException();
        }
    }

一時的にソースコードを改修して、アプリケーションを起動して、削除APIを呼び出すと異常終了します。
その後に一覧取得のAPIを呼び出して全件取得すると削除されていないことが確認できます。

@Transactionalをコメントアウトして、同様のことをすると、削除APIは異常終了するけどもレコードが削除されてしまっています。

テストメソッドのトランザクション有効化の確認

@Orderアノテーションを使って、メソッドの実行順序を固定して確認しました。
ソースコードのイメージとしては、以下の通りです。

@SpringBootTest
@AutoConfigureMockMvc
@TestMethodOrder(OrderAnnotation.class)
class SpringBootDemoApplicationTests {

    @Test
    @Transactional
	@Order(1)
    void testCrud() throws Exception {
        ...
    }

    @Test
    @Transactional
	@Order(2)
    void testGetAll() throws Exception {
        // ここで全件取得するAPIを呼び出す
    }

}
  • クラスに@TestMethodOrder(OrderAnnotation.class)アノテーション追加
  • メソッドに@Orderアノテーション追加
  • 全件取得するAPIを呼び出すテストメソッドを追加

トランザクションが正常に有効化されていたら、全件取得するAPIの結果として、初期登録のレコードが返されます。

まとめ

今回使用したAPIのソースコードはシンプルでトランザクション制御が必要ないものです。
ただ、まずは簡単なソースコードで動作確認して、アプリケーションの基盤を構築してから、実際のアプリケーションを開発していくことをお勧めします。

今回の記事は以上となります。

Discussion