Spring Bootにおけるデータアクセス層の構築
本記事では、Spring Bootアプリケーションにおいて、高機能なデータソースであるDruid、ORMフレームワークのMyBatis、そして柔軟なページネーション機能を提供するPageHelperを統合する方法について解説します。
1. Druidデータソースの統合
Druidは、高性能な接続プールと監視機能を備えたデータソース実装です。Spring BootにDruidを組み込むことで、データベース接続の効率化と詳細なモニタリングが可能になります。
依存関係の追加
pom.xmlにDruid Spring Boot Starterと必要なMySQLドライバーを追加します。
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.8</version> <!-- 最新の安定版に更新してください -->
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.28</version> <!-- 使用するMySQLのバージョンに合わせてください -->
</dependency>
<!-- 必要に応じてAOP依存を追加 (Druid統計に利用) -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>
application.ymlの設定
application.ymlファイルに、Druidデータソースの接続情報と監視に関する設定を記述します。
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/sample_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Tokyo
username: root
password: password
druid:
# 接続プール設定
initial-size: 5
min-idle: 5
max-active: 20
max-wait: 60000
time-between-eviction-runs-millis: 60000
min-evictable-idle-time-millis: 30000
validation-query: SELECT 1 FROM DUAL
test-while-idle: true
test-on-borrow: false
test-on-return: false
pool-prepared-statements: true
max-pool-prepared-statement-per-connection-size: 20
# 監視フィルター設定
filter:
stat:
merge-sql: true
slow-sql-millis: 5000
# Web統計フィルター設定
web-stat-filter:
enabled: true
url-pattern: /*
exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*"
session-stat-enable: true
session-stat-max-count: 100
# 監視コンソール設定
stat-view-servlet:
enabled: true
url-pattern: /druid/*
reset-enable: true
login-username: admin
login-password: admin
allow: 127.0.0.1
# deny: 192.168.1.100
Druid監視機能の確認
簡単なRESTコントローラを作成し、DruidのURL監視機能が動作しているかを確認します。
package com.example.app.web.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/greetings")
public class GreetingController {
@GetMapping("/hello")
public String sayHello() {
return "Spring BootとDruidの連携テスト成功!";
}
}
アプリケーションを起動後、http://localhost:8080/api/greetings/hello(ポートは環境に合わせて)にアクセスします。その後、Druidの監視コンソール(例: http://localhost:8080/druid/index.html)にアクセスし、設定したユーザー名とパスワードでログインすると、URL監視が成功していることを確認できます。
2. MyBatisの統合とコード生成
MyBatisは、SQLをXMLまたはアノテーションで直接記述できる柔軟なORMフレームワークです。MyBatis Generatorを使用することで、データベーススキーマからエンティティ、Mapperインターフェース、XMLマッピングファイルを自動生成できます。
依存関係とMavenプラグインの設定
pom.xmlにMyBatis Spring Boot StarterとMyBatis Generatorプラグインを追加します。
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version> <!-- 最新の安定版に更新してください -->
</dependency>
<build>
<resources>
<!-- Mapper XMLファイルをビルドパスに含めるための設定 -->
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
<filtering>false</filtering>
</resource>
<resource>
<directory>src/main/resources</directory>
<includes>
<include>*.properties</include>
<include>*.xml</include>
<include>*.yml</include>
</includes>
<filtering>false</filtering>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-maven-plugin</artifactId>
<version>1.4.0</version> <!-- 最新の安定版に更新してください -->
<dependencies>
<!-- ジェネレータが使用するMySQL JDBCドライバ -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.28</version>
</dependency>
</dependencies>
<configuration>
<overwrite>true</overwrite>
<configurationFile>${basedir}/src/main/resources/generatorConfig.xml</configurationFile>
</configuration>
</plugin>
</plugins>
</build>
generatorConfig.xmlの設定
MyBatis Generatorの設定ファイルを作成し、エンティティ、Mapperインターフェース、SQLマッピングXMLファイルの生成ルールを定義します。ここでは、sample_dbデータベースのapp_itemsテーブルからItemエンティティを生成する例を示します。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration
PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
"http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
<generatorConfiguration>
<properties resource="jdbc.properties"/>
<!-- JDBCドライバJARへのパスを指定してください (例: Mavenリポジトリからダウンロードされるパス) -->
<!-- <classPathEntry location="/path/to/mysql-connector-java-8.0.28.jar"/> -->
<context id="SpringBootApp" targetRuntime="MyBatis3">
<commentGenerator>
<property name="suppressAllComments" value="true"/>
<property name="suppressDate" value="true"/>
</commentGenerator>
<jdbcConnection driverClass="${jdbc.driver}"
connectionURL="${jdbc.url}"
userId="${jdbc.username}"
password="${jdbc.password}"/>
<javaTypeResolver>
<property name="forceBigDecimals" value="false"/>
</javaTypeResolver>
<!-- エンティティクラスの生成設定 -->
<javaModelGenerator targetPackage="com.example.app.model.entity"
targetProject="src/main/java">
<property name="enableSubPackages" value="false"/>
<property name="constructorBased" value="true"/>
<property name="trimStrings" value="true"/>
</javaModelGenerator>
<!-- SQLマッピングXMLファイルの生成設定 -->
<sqlMapGenerator targetPackage="com.example.app.mapper"
targetProject="src/main/java">
<property name="enableSubPackages" value="false"/>
</sqlMapGenerator>
<!-- Mapperインターフェースの生成設定 -->
<javaClientGenerator targetPackage="com.example.app.mapper"
targetProject="src/main/java" type="XMLMAPPER">
<property name="enableSubPackages" value="false"/>
</javaClientGenerator>
<!-- 対象テーブルの設定 -->
<table tableName="app_items" domainObjectName="Item"
enableCountByExample="false" enableDeleteByExample="false"
enableSelectByExample="false" enableUpdateByExample="false">
<property name="useActualColumnNames" value="true" />
</table>
</context>
</generatorConfiguration>
jdbc.propertiesファイル
データベース接続情報を外部ファイルとして定義します。
jdbc.driver=com.mysql.cj.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/sample_db?useUnicode=true&characterEncoding=UTF-8&nullCatalogMeansCurrent=true&serverTimezone=Asia/Tokyo
jdbc.username=root
jdbc.password=password
コードの生成
Mavenコマンドを実行してコードを生成します。
mvn mybatis-generator:generate -e
これにより、com.example.app.model.entity.Item、com.example.app.mapper.ItemMapper、com.example.app.mapper.ItemMapper.xmlなどが生成されます。
Mapperインターフェースのスキャン
Spring Bootアプリケーションのメインクラスに@MapperScanアノテーションを追加し、Mapperインターフェースを認識させます。
package com.example.app;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.example.app.mapper")
public class SampleApplication {
public static void main(String[] args) {
SpringApplication.run(SampleApplication.class, args);
}
}
CRUD操作のテスト
生成されたMapperと連携するService層を作成し、CRUD操作をテストします。
// src/main/java/com/example/app/model/entity/Item.java (MyBatis Generatorで生成)
package com.example.app.model.entity;
public class Item {
private Integer id;
private String name;
// Constructor, getters, setters...
public Item(Integer id, String name) {
this.id = id;
this.name = name;
}
// ...
public Integer getId() { return id; }
public String getName() { return name; }
public void setId(Integer id) { this.id = id; }
public void setName(String name) { this.name = name; }
@Override
public String toString() {
return "Item{id=" + id + ", name='" + name + "'}";
}
}
// src/main/java/com/example/app/mapper/ItemMapper.java (MyBatis Generatorで生成)
package com.example.app.mapper;
import com.example.app.model.entity.Item;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface ItemMapper {
int deleteByPrimaryKey(Integer id);
int insert(Item record);
Item selectByPrimaryKey(Integer id);
List<Item> selectAll();
int updateByPrimaryKey(Item record);
}
// src/main/java/com/example/app/service/ItemService.java
package com.example.app.service;
import com.example.app.model.entity.Item;
import java.util.List;
import java.util.Optional;
public interface ItemService {
Optional<Item> findItemById(Integer itemId);
boolean deleteItemById(Integer itemId);
List<Item> findAllItems();
}
// src/main/java/com/example/app/service/impl/ItemServiceImpl.java
package com.example.app.service.impl;
import com.example.app.mapper.ItemMapper;
import com.example.app.model.entity.Item;
import com.example.app.service.ItemService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class ItemServiceImpl implements ItemService {
private final ItemMapper itemMapper;
public ItemServiceImpl(ItemMapper itemMapper) {
this.itemMapper = itemMapper;
}
@Override
public Optional<Item> findItemById(Integer itemId) {
return Optional.ofNullable(itemMapper.selectByPrimaryKey(itemId));
}
@Override
public boolean deleteItemById(Integer itemId) {
return itemMapper.deleteByPrimaryKey(itemId) > 0;
}
@Override
public List<Item> findAllItems() {
return itemMapper.selectAll();
}
}
// src/test/java/com/example/app/service/ItemServiceTest.java
package com.example.app.service;
import com.example.app.model.entity.Item;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@Transactional // テスト後のデータ変更をロールバック
public class ItemServiceTest {
@Autowired
private ItemService itemService;
@Test
void testItemRetrieval() {
// 事前にデータが存在することを確認または挿入
Item newItem = new Item(null, "テストアイテムA");
// itemMapper.insert(newItem); // テスト用に挿入ロジックが必要な場合
// 適切なIDで検索
Optional<Item> item = itemService.findItemById(1);
assertThat(item).isPresent();
System.out.println("取得したアイテム: " + item.get());
}
@Test
void testItemDeletion() {
// 事前にデータが存在することを確認または挿入
Item newItem = new Item(null, "削除対象アイテム");
// itemMapper.insert(newItem); // テスト用に挿入ロジックが必要な場合
// 適切なIDで削除
boolean deleted = itemService.deleteItemById(2);
assertThat(deleted).isTrue();
Optional<Item> itemAfterDeletion = itemService.findItemById(2);
assertThat(itemAfterDeletion).isNotPresent();
System.out.println("アイテムID 2 を削除しました。");
}
}
3. PageHelperによるページネーションの統合
PageHelperは、MyBatisのクエリ結果に簡単にページネーションを適用できるプラグインです。
依存関係の追加
pom.xmlにPageHelper Spring Boot Starterを追加します。
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.1</version> <!-- 最新の安定版に更新してください -->
</dependency>
application.ymlの設定
PageHelperの設定をapplication.ymlに追加します。
pagehelper:
helperDialect: mysql
reasonable: true # 不正なページ番号を自動修正
supportMethodsArguments: true # メソッドの引数からページング情報を取得
params: count=countSql # countSqlパラメータを使用
logging:
level:
com.example.app.mapper: debug # MyBatisのSQLログ表示
ページネーション情報クラスの作成
ページネーションの状態を保持するための汎用クラスを作成します。
package com.example.app.util.pagination;
import java.io.Serializable;
import java.util.Optional;
public class PaginationInfo implements Serializable {
private static final long serialVersionUID = 1L;
private int pageIndex = 1; // 現在のページ番号 (1から開始)
private int pageSize = 10; // 1ページあたりのレコード数
private long totalRecords = 0; // 総レコード数
private boolean enabled = true; // ページネーションを有効にするか
public PaginationInfo() {}
public PaginationInfo(int pageIndex, int pageSize) {
this.pageIndex = Math.max(1, pageIndex);
this.pageSize = Math.max(1, pageSize);
}
public int getPageIndex() { return pageIndex; }
public void setPageIndex(int pageIndex) { this.pageIndex = Math.max(1, pageIndex); }
public int getPageSize() { return pageSize; }
public void setPageSize(int pageSize) { this.pageSize = Math.max(1, pageSize); }
public long getTotalRecords() { return totalRecords; }
public void setTotalRecords(long totalRecords) { this.totalRecords = totalRecords; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
/**
* SQLのOFFSET値 (スキップするレコード数) を計算します。
* @return OFFSET値
*/
public int getOffset() {
return (this.pageIndex - 1) * this.pageSize;
}
/**
* 総ページ数を計算します。
* @return 総ページ数
*/
public long getTotalPages() {
if (pageSize == 0) return 0;
return (totalRecords + pageSize - 1) / pageSize;
}
/**
* 次のページ番号を返します。現在のページが最終ページの場合は最終ページ番号を返します。
* @return 次のページ番号
*/
public int getNextPageIndex() {
long totalPages = getTotalPages();
return (int) (pageIndex >= totalPages ? totalPages : pageIndex + 1);
}
/**
* 前のページ番号を返します。現在のページが最初のページの場合は1を返します。
* @return 前のページ番号
*/
public int getPreviousPageIndex() {
return Math.max(1, pageIndex - 1);
}
@Override
public String toString() {
return "PaginationInfo{" +
"pageIndex=" + pageIndex +
", pageSize=" + pageSize +
", totalRecords=" + totalRecords +
", enabled=" + enabled +
'}';
}
}
AOPによるページネーション処理の適用
アスペクト指向プログラミング(AOP)を利用して、特定のServiceメソッドが呼び出された際に自動的にPageHelperを適用します。
package com.example.app.aop;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.example.app.util.pagination.PaginationInfo;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@Aspect
public class PaginationAspect {
/**
* Service層のメソッドで、引数にPaginationInfoを含み、
* メソッド名が 'search' または 'find' で始まるものに適用
*/
@Around("execution(* com.example.app.service.*Service.search*(..)) || execution(* com.example.app.service.*Service.find*(..))")
public Object handlePagination(ProceedingJoinPoint joinPoint) throws Throwable {
PaginationInfo paginationInfo = null;
Object[] args = joinPoint.getArgs();
// メソッド引数からPaginationInfoオブジェクトを検索
for (Object arg : args) {
if (arg instanceof PaginationInfo) {
paginationInfo = (PaginationInfo) arg;
break;
}
}
if (paginationInfo != null && paginationInfo.isEnabled()) {
// PageHelperを有効化
PageHelper.startPage(paginationInfo.getPageIndex(), paginationInfo.getPageSize());
}
// 元のメソッドを実行
Object result = joinPoint.proceed(args);
if (paginationInfo != null && paginationInfo.isEnabled() && result instanceof List) {
// PageInfoで結果をラップし、PaginationInfoに総レコード数を設定
PageInfo<?> pageInfo = new PageInfo<>((List<?>) result);
paginationInfo.setTotalRecords(pageInfo.getTotal());
}
return result;
}
}
検索条件ユーティリティ
検索クエリでLIKE句を使用するためのシンプルなユーティリティクラス。
package com.example.app.util;
public class SearchUtil {
public static String wrapWithLikeOperators(String input) {
return Optional.ofNullable(input)
.map(s -> "%" + s.trim() + "%")
.orElse(null);
}
}
ページネーション機能のテスト
MyBatis Mapperに検索メソッドを追加し、Service層でページネーションを適用してテストします。
// ItemMapperに検索メソッドを追加
// src/main/java/com/example/app/mapper/ItemMapper.java
package com.example.app.mapper;
import com.example.app.model.entity.Item;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ItemMapper {
// ... 既存のメソッド ...
List<Item> findItemsByNameLike(@Param("itemName") String itemName);
}
<!-- ItemMapper.xml に対応するSQLを追加 -->
<!-- src/main/java/com/example/app/mapper/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.app.mapper.ItemMapper">
<resultMap id="BaseResultMap" type="com.example.app.model.entity.Item">
<id column="id" jdbcType="INTEGER" property="id" />
<result column="name" jdbcType="VARCHAR" property="name" />
</resultMap>
<!-- ... 既存のSQL ... -->
<select id="findItemsByNameLike" resultMap="BaseResultMap">
SELECT id, name
FROM app_items
<where>
<if test="itemName != null and itemName != ''">
AND name LIKE #{itemName}
</if>
</where>
ORDER BY id
</select>
</mapper>
// ItemServiceにページネーション検索メソッドを追加
// src/main/java/com/example/app/service/ItemService.java
package com.example.app.service;
import com.example.app.model.entity.Item;
import com.example.app.util.pagination.PaginationInfo;
import java.util.List;
import java.util.Optional;
public interface ItemService {
// ... 既存のメソッド ...
List<Item> searchItemsPaged(String itemName, PaginationInfo paginationInfo);
}
// ItemServiceImplにページネーション検索の実装を追加
// src/main/java/com/example/app/service/impl/ItemServiceImpl.java
package com.example.app.service.impl;
import com.example.app.mapper.ItemMapper;
import com.example.app.model.entity.Item;
import com.example.app.service.ItemService;
import com.example.app.util.SearchUtil;
import com.example.app.util.pagination.PaginationInfo;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class ItemServiceImpl implements ItemService {
private final ItemMapper itemMapper;
public ItemServiceImpl(ItemMapper itemMapper) {
this.itemMapper = itemMapper;
}
// ... 既存のメソッド ...
@Override
public List<Item> searchItemsPaged(String itemName, PaginationInfo paginationInfo) {
String likeName = SearchUtil.wrapWithLikeOperators(itemName);
return itemMapper.findItemsByNameLike(likeName);
}
}
// src/test/java/com/example/app/service/ItemServiceIntegrationTest.java
package com.example.app.service;
import com.example.app.model.entity.Item;
import com.example.app.util.pagination.PaginationInfo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@Transactional
public class ItemServiceIntegrationTest {
@Autowired
private ItemService itemService;
@BeforeEach
void setup() {
// テスト用のダミーデータを挿入 (MyBatis Mapperを直接使用)
// itemService.deleteItemById(999); // 必要に応じて既存データをクリーンアップ
// itemService.deleteItemById(998);
// itemService.insert(new Item(null, "サンプル商品A"));
// itemService.insert(new Item(null, "商品B"));
// itemService.insert(new Item(null, "テスト商品C"));
// itemService.insert(new Item(null, "サンプル品D"));
// itemService.insert(new Item(null, "別カテゴリ商品E"));
// ... 十分なデータがあればコメントアウト
}
@Test
void testPagedItemSearch() {
// ページネーション情報を設定
PaginationInfo pInfo = new PaginationInfo();
pInfo.setPageIndex(1); // 1ページ目
pInfo.setPageSize(3); // 3件/ページ
// 検索を実行
List<Item> resultPage1 = itemService.searchItemsPaged("商品", pInfo);
System.out.println("--- 1ページ目の結果 ---");
resultPage1.forEach(System.out::println);
System.out.println("ページ情報: " + pInfo);
assertThat(resultPage1).hasSize(3); // 3件取得されるか
assertThat(pInfo.getTotalRecords()).isGreaterThan(0); // 総レコード数が更新されるか
// 2ページ目を試す
pInfo.setPageIndex(2);
List<Item> resultPage2 = itemService.searchItemsPaged("商品", pInfo);
System.out.println("\n--- 2ページ目の結果 ---");
resultPage2.forEach(System.out::println);
System.out.println("ページ情報: " + pInfo);
//assertThat(resultPage2.size()).isLessThanOrEqualTo(3); // 2ページ目も取得されるか (データ数による)
}
}