Spring BootからMyBatisを使ったDB接続(2回目 CRUDを行うAPI)

2022/11/03に公開

前回の記事で、PK(id)をURLパスで指定するGETリクエストを発行して、サーバーサイドでDBからレコードを1件取得して、結果をJSONとして返却する簡単なAPIを開発しました。
※前回の記事
Spring BootからMyBatisを使ったDB接続(1回目 簡単なGET API)

今回は、簡単なCRUDを行うAPIを開発します。
基本的には前回の記事で作成したクラスやMapperに対して追記する形で実装します。

API仕様

開発するAPIの仕様は以下の通りです。

処理 メソッド パス リクエストボディ レスポンスボディ レスポンスステータスコード 説明
詳細検索 GET /items/{id} null {id: integer, item_name: string} 200(OK) idを指定して1件取得
一覧検索 GET /items null [{id: integer, item_name: string}] 200(OK) 全件取得
登録 POST /items {item_name: string} {id: integer, item_name: string} 201(Created) id以外の項目をリクエストボディデータとして渡して、登録。idは自動採番。登録したデータをレスポンスボディデータとして返却。
更新 PUT /items/{id} {item_name: string} {id: integer, item_name: string} 200(OK) idをURLで指定して、id以外の項目をリクエストボディデータとして渡して、更新。更新したデータをレスポンスボディデータとして返却。
削除 DELETE /items/{id} null null 204(No Content) idをURLで指定して、削除。

ポイントは以下で、後はREST APIの定石な感じだと思います。

  • ここでは異常系は考慮せずに、正常系のみを考えます。
  • 更新処理で更新後のデータを返却するか迷いましたが、クライアントでデータが必要な場合にGETで取得する必要がないようにするため、更新後のデータを返却するようにしました。
  • 更新処理はPUT(通常の更新)とPATCH(データの部分的更新)に分けているフレームワーク等もありますが、個人的にPATCHの有用性がわかっていないので、PUTのみとします。

処理の流れ

処理の流れの全体像は以下の通りです。
点線のボックスはやりとりされるデータとなります。
データについては上記のAPI仕様の通り処理内容によって異なります。
基本的な形は前回の記事と同じです。

ソースコード

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

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

開発手順

実際に各処理をしていきます。

Mapperインタフェース

既存のMapperインタフェースにメソッド宣言を追加します。

ItemMapper.java
package com.example.springbootdemo.mapper;

import com.example.springbootdemo.entity.Item;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;

@Mapper
public interface ItemMapper {
    Item findById(int id);
    List<Item> findAll();
    int insert(@Param("item") Item item);
    int update(@Param("item") Item item);
    boolean delete(int id);
}
  • findByIdメソッド以外を追加しています。
  • insert/update/deleteの戻り値については例外処理を組み入れるときに考慮しますので、今回のものはとりあえずと考えてください。
  • insertについてはidに値を設定せずに実行します。idはMySQL側で自動採番するように後でDDL(CREATE TABLE)を修正します。

Mapper XMLファイル

既存のMapper XMLファイルにSQLの定義を追記します。

itemMapper.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.example.springbootdemo.mapper.ItemMapper">
    <select id="findById" resultType="com.example.springbootdemo.entity.Item">
        SELECT id , item_name FROM item WHERE id  = #{id}
    </select>
    <select id="findAll" resultType="com.example.springbootdemo.entity.Item">
        SELECT id , item_name FROM item ORDER BY id
    </select>
    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO item (item_name) VALUES (#{item.itemName})
    </insert>
    <update id="update">
        UPDATE item
        SET item_name = #{item.itemName}
        WHERE
            id = #{item.id}
    </update>
    <delete id="delete">
        DELETE FROM item WHERE id = #{id}
    </delete>
</mapper>
  • 上述したように登録処理(insert)はidを指定せずに、MySQL側で自動採番する前提のSQLとしています。
  • findAllは複数レコード返却されますが、resultTypeは1レコードを格納する型(今回であればItem)を指定します。メソッドを呼び出したときにMyBatisがList<Item>型として返却してくれます。

DDL(CREATE TABLE)を修正してid自動採番

itemテーブルのPK項目idを自動採番項目に変更します。
また、insert文をschema.sqlに記載していましたが、DMLはdata.sqlに切り出しました。
初期化用スクリプトの詳細については以下の公式ドキュメントを参照ください。
https://spring.pleiades.io/spring-boot/docs/current/reference/html/howto.html#howto.data-initialization.using-basic-sql-scripts

schema.sql
DROP TABLE IF EXISTS item;

CREATE TABLE item (
  id INTEGER NOT NULL AUTO_INCREMENT,
  item_name VARCHAR(20),
  PRIMARY KEY(id)
);
data.sql
INSERT INTO item (item_name) VALUES ('大豆');
INSERT INTO item (item_name) VALUES ('小豆');

HTTPリクエストクラス

登録、更新処理でクライアントからのデータを受け取る入れ物となるリクエストクラスを定義します。
PKであるidを除いた項目からなるBeanクラスとします。
登録処理ではidは自動採番なのでクライアントから渡す必要はありません。
更新処理ではidはURLパスで指定します。

ItemRequest.java
package com.example.springbootdemo.dto;

import lombok.Data;

@Data
public class ItemRequest {
    private String itemName;
}

コントローラーにAPIを追加

前回の記事で作成したItemControllerに各APIに対応するメソッドを実装します。

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 org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;

@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)
    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)
    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)
    public void doDelete(@PathVariable int id){
        boolean ret = itemMapper.delete(id);
    }
}
  • 返却するHTTPステータスコードは各メソッドに@ResponseStatusアノテーションで指定します。指定しない場合のデフォルトは200(OK)ですが、ソースコードを統一して可読性を高めるために、200(OK)を返却する場合も明示的に指定しました。
  • 登録処理についてはMapperのinsertをEntityを渡して呼び出すと、採番したidが格納されます。

以上でCRUDするAPIの開発は完了となります。

動作確認

開発したAPIが正常に動作することを確認します。

クライアントツールの使用

前回までの記事で作成したAPIは単純なGETリクエストでしたので、Webブラウザで確認していましたが、今回、様々なメソッド(GET/POST/PUT/DELETE)を使用していますので、クライアントツールで確認します。
ツールは色々なものがありますが、普段使用されているツールをお使いください。
私はPostmanのMacアプリケーションを使用しました。

アプリケーションを起動して、以下の通りAPIを発行していきます。

詳細検索

設定
メソッド GET
URL localhost:8080/items/1
BODY null

結果は以下の通りです。
ステータスコードが200 OKで、レスポンスのBodyが想定通りのデータであることを確認します。

一覧検索

設定
メソッド GET
URL localhost:8080/items
BODY null

結果は以下の通りです。
こちらもステータスコードとBodyが想定通りであることを確認します。

登録処理

設定
メソッド POST
URL localhost:8080/items
BODY { "itemName": "post from postman."}

結果は以下の通りです。
こちらもステータスコードとBodyが想定通りであることを確認します。

更新処理

設定
メソッド PUT
URL localhost:8080/items/2
BODY { "itemName": "update from postman."}

結果は以下の通りです。
こちらもステータスコードとBodyが想定通りであることを確認します。

削除処理

設定
メソッド DELETE
URL localhost:8080/items/2
BODY null

結果は以下の通りです。
こちらもステータスコードとBodyが想定通りであることを確認します。

登録/更新/削除の確認

最後に一覧検索をして、登録/更新/削除が正常に反映されていることを確認します。
結果は以下の通りです。

テストケースの作成

簡単なプログラムですが、修正した後に正常に動作することを確認できるようにテストコードも作成しておきます。
今回はまだトランザクション処理を組み込んでいない関係で、1テストメソッド内で全APIを発行して結果を検証しています。
Spring Bootのテストでは@Orderアノテーションを使い、実行されるテストの順序を固定することが出来ますが、実行順序が変更されると結果が変わるテストは望ましくないので、今回は一時的なテストコードですが、1メソッドで一通りの確認をしています。
(最終的にはきちんとしたテストコードを作成します)

SpringBootDemoApplicationTests.java
package com.example.springbootdemo;

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 org.junit.jupiter.api.*;
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 java.nio.charset.StandardCharsets;
import java.util.List;

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.assertj.core.api.Assertions.*;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.MatcherAssert.*;


@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
	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));
	}
}

今までのテストコードと異なる点としましては、一覧検索の結果の件数を検証するために

  • レスポンスからBodyをテキストデータ(JSON)として取得
  • ObjectMapperを使って、JSONをJavaオブジェクト(List<Item>)に変換
    として、Listの件数を確認しています。

今回の記事は以上となります。
次回はトランザクション管理を実装する予定でます。

Discussion