業務ロジックを実行するServiceの作成-SpringBoot入門⑧

SpringBoot(Todo):サムネイル画像8 Todoアプリ(SpringBoot)

前回は Repository のテストコードを作成しました。

今回は業務ロジックを処理する Service を作成します。

SpringBootでTodo開発:システム構成図1
システム構成図

Service(サービス)とは?

業務ロジック専門のファイルです。

Repository から受け取った DB データを元に何らかの処理を実行します。

主に Controller から呼び出され、処理結果を Controller に返却する役割であることが殆どです。

Service 作成

作成するリポジトリ(Repository)ファイルは全部で、

  1. AuthorityService
  2. CategoryService
  3. TodoCategoryService
  4. TodoService
  5. UserService

の5つです。

手順1:フォルダ作成

todoapp フォルダ内に service フォルダを作成します。

さらに、作成した service フォルダ内に下記フォルダを作成してください。

  1. authority
  2. category
  3. todo
  4. todocategory
  5. user
SpringBootでToDo開発8:Service フォルダの作成
service フォルダ内に5つ作成

また、todoapp フォルダ内に exception フォルダも作成します。

こちらには Service で発生する例外を定義します。

SpringBootでToDo開発8:Service フォルダの作成2
exception フォルダを作成

手順2:ファイル作成

手順1で作成した5つの service フォルダ内に、

  1. ~Service.java
  2. ~ServiceImpl.java

という2つのファイルを作成します。

ファイル数が多いので GitHub での確認をお願いします。

SpringBootTodoServer/src/main/java/com/ozack/todoapp/service at main · o-zack-0390/SpringBootTodoServer
Todo データを処理するSpringプロジェクト. Contribute to o-zack-0390/SpringBootTodoServer development by creating an...

各Serviceの内容

基本的には下記2つを見ながら各 Service ファイルの中身を書いていきます。

  1. 第6回で作成した Repository
  2. 第2回で作成した以下の ER 図
2_userPK`user_id` bigint unsigned NOT NULL AUTO_INCREMENTFK`authority_id` int unsigned NOT NULL `name` varchar(16) NOT NULLUK`email` varchar(32) NOT NULL`password` varchar(32) NOT NULL`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP3_todoPK`todo_id` bigint unsigned NOT NULL AUTO_INCREMENTFK`user_id` bigint unsigned NOT NULLUK`title` varchar(32) NOT NULL`is_check` tinyint(1) NOT NULL`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP1_authorityPK`authority_id` int unsigned NOT NULL AUTO_INCREMENTUK`name` varchar(16) NOT NULL`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP1_cateoryPK`category_id` int unsigned NOT NULL AUTO_INCREMENTUK`name` varchar(16) NOT NULL`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP4_todo_categoryPK`todo_category_id` bigint unsigned NOT NULL AUTO_INCREMENTFK`todo_id` bigint unsigned NOT NULLFK`category_id` int unsigned NOT NULL`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

AuthorityService

ミント色は今回作成する Service(Interface) です。

青色は今回作成する Service(Class) です。

紫色は第6回で作成した Repository です。

package com.ozack.todoapp.service.authority;

import java.util.List;

import com.ozack.todoapp.exception.TodoAppException;
import com.ozack.todoapp.repository.entity.Authority;

/* 権限に関する処理を行うサービス */
public interface AuthorityService {

    /* 全ての権限一覧を取得するメソッド */
    public List<Authority> selectAllAuthorities();

    /* データを登録するメソッド */
    public Authority insertAuthority(Authority authority) throws TodoAppException;

    /* データを更新するメソッド */
    public Authority updateAuthority(Authority authority) throws TodoAppException;

    /* データを削除するメソッド */
    public void deleteAuthority(Long authorityId) throws TodoAppException;

}
package com.ozack.todoapp.service.authority;

import java.util.List;

import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.ozack.todoapp.exception.DeleteException;
import com.ozack.todoapp.exception.InsertException;
import com.ozack.todoapp.exception.TodoAppException;
import com.ozack.todoapp.exception.UpdateException;
import com.ozack.todoapp.repository.AuthorityRepository;
import com.ozack.todoapp.repository.entity.Authority;

/* 権限に関する処理を行うサービス */
@Service
public class AuthorityServiceImpl implements AuthorityService {

    private final String loadErrorMessage = "authority テーブルのデータ取得に失敗しました。";
    private final String insertErrorMessageByDataAccess = "authority データ登録時にデータベース関連のエラーが発生しました。";
    private final String updateErrorMessageByDataAccess = "authority データ更新時にデータベース関連のエラーが発生しました。";
    private final String deleteErrorMessageByLoad = "authority データを削除できませんでした。";

    private final AuthorityRepository authorityRepository;

    public AuthorityServiceImpl(AuthorityRepository authorityRepository) {
        this.authorityRepository = authorityRepository;
    }

    /* 全ての権限一覧を取得するメソッド */
    public List<Authority> selectAllAuthorities() {
        return authorityRepository.findAll();
    }

    /* データを登録するメソッド */
    @Transactional(rollbackFor = TodoAppException.class)
    public Authority insertAuthority(Authority authority) throws TodoAppException {
        try {
            authorityRepository.save(authority);
            Authority res = authorityRepository.findById(authority.getId()).orElse(null);
            if (res == null) throw new InsertException(loadErrorMessage);
            return res;
        } catch (DataAccessException e) {
            throw new InsertException(insertErrorMessageByDataAccess, e);
        }
    }

    /* データを更新するメソッド */
    @Transactional(rollbackFor = TodoAppException.class)
    public Authority updateAuthority(Authority authority) throws TodoAppException {
        try {
            authorityRepository.save(authority);
            Authority res = authorityRepository.findById(authority.getId()).orElse(null);
            if (res == null) throw new UpdateException(loadErrorMessage);
            return res;
        } catch (DataAccessException e) {
            throw new UpdateException(updateErrorMessageByDataAccess, e);
        }
    }

    /* データを削除するメソッド */
    @Transactional(rollbackFor = TodoAppException.class)
    public void deleteAuthority(Long authorityId) throws TodoAppException {
        authorityRepository.deleteById(authorityId);
        Authority res = authorityRepository.findById(authorityId).orElse(null);
        if (res != null) throw new DeleteException(deleteErrorMessageByLoad);
    }

}
package com.ozack.todoapp.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.ozack.todoapp.repository.entity.Authority;

/* 権限情報を格納している authority テーブルと対応 */
@Repository
public interface AuthorityRepository extends JpaRepository<Authority, Long> {
}

インタフェースについて

今回は、わざわざ Service ファイルを2種類作成しており、ミント色の ~Service.java というファイルは全てインタフェース定義しています。

インタフェース定義する利点として、以下のような構成の場合にメリットを得られます。

SpringBootでToDo開発8:インタフェースの説明
インタフェースの例

この構成の場合、本場環境では本番用の Service を呼び出し、テスト環境では Mock 等を実装したテスト用の Service を呼び出すことが可能です。

上記構成を適用する場合はプロファイルも設定しておく必要があります。

今回は後述の理由で使わないため、設定しません。

覚えるのに役立った参考サイトだけ掲載します。

次に、AuthorityService を例にして具体例を提示します。

まず、以下の本番環境用とテスト環境用の Impl が存在すると仮定します。

package com.ozack.todoapp.service.authority;

import org.springframework.stereotype.Service;

/* 本番環境用のサービス */
@Service
@Profile("production") // 本番環境のプロファイルを指定
public class ProductionAuthorityServiceImpl implements AuthorityService {
    /* 内容は何でもいいため省略 */
}
package com.ozack.todoapp.service.authority;

import org.springframework.stereotype.Service;

/* テスト環境用のサービス */
@Service
@Profile("test") // テスト環境のプロファイルを指定
public class TestAuthorityServiceImpl implements AuthorityService {
    /* 内容は何でもいいため省略 */
}

そして、AuthorityService インタフェースを、

  1. プロファイル production を有効化
  2. プロファイル test を有効化

という2パターンに場合分けして、それぞれ Controller から呼び出します。

/* 1. プロファイル production を有効化して本番環境で呼び出す場合 */
@Controller
public class AuthorityController {
    /* ProductionAuthorityServiceImpl が呼び出される */
    public AuthorityController(AuthorityService authorityService) {
        this.authorityService = authorityService;
    }
}
/* 2. プロファイル test を有効化してテスト環境で呼び出す場合 */
@Controller
public class AuthorityController {
    /* TestAuthorityServiceImpl が呼び出される */
    public AuthorityController(AuthorityService authorityService) {
        this.authorityService = authorityService;
    }
}

プロファイルを変更するだけで異なる Service(Impl) が呼び出されるようになります。

つまり、呼び出し元の処理(例だとAuthorityController)を逐一変更せずとも、呼び出す Service を状況に応じて柔軟に変更することが可能です。

ですが、今回の開発では本番環境とテスト環境で、呼び出す Service を変更することはないです。

残念ながら、そこまでするほど複雑な構成ではないからです()

そのため、インタフェース定義は正直なくても問題ないのですが、当 Todo サービスの機能を拡張するケースを想定して作成しておくことにしました。

機能追加に伴いコードが複雑になってくると、単体テストを用いて部分的に値を検証することが増えます。

その場合、インタフェース定義しておくとテストしやすくなるため、かなり重宝します。

Impl の処理解説

インタフェースは解説したので、次に青色の Impl を説明します。

package com.ozack.todoapp.service.authority;

import java.util.List;

import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.ozack.todoapp.exception.DeleteException;
import com.ozack.todoapp.exception.InsertException;
import com.ozack.todoapp.exception.TodoAppException;
import com.ozack.todoapp.exception.UpdateException;
import com.ozack.todoapp.repository.AuthorityRepository;
import com.ozack.todoapp.repository.entity.Authority;

/* 権限に関する処理を行うサービス */
@Service
public class AuthorityServiceImpl implements AuthorityService {

    private final String loadErrorMessage = "authority テーブルのデータ取得に失敗しました。";
    private final String insertErrorMessageByDataAccess = "authority データ登録時にデータベース関連のエラーが発生しました。";
    private final String updateErrorMessageByDataAccess = "authority データ更新時にデータベース関連のエラーが発生しました。";
    private final String deleteErrorMessageByLoad = "authority データを削除できませんでした。";

    private final AuthorityRepository authorityRepository;

    public AuthorityServiceImpl(AuthorityRepository authorityRepository) {
        this.authorityRepository = authorityRepository;
    }

    /* 全ての権限一覧を取得するメソッド */
    public List<Authority> selectAllAuthorities() {
        return authorityRepository.findAll();
    }

    /* データを登録するメソッド */
    @Transactional(rollbackFor = TodoAppException.class)
    public Authority insertAuthority(Authority authority) throws TodoAppException {
        try {
            authorityRepository.save(authority);
            Authority res = authorityRepository.findById(authority.getId()).orElse(null);
            if (res == null) throw new InsertException(loadErrorMessage);
            return res;
        } catch (DataAccessException e) {
            throw new InsertException(insertErrorMessageByDataAccess, e);
        }
    }

    /* データを更新するメソッド */
    @Transactional(rollbackFor = TodoAppException.class)
    public Authority updateAuthority(Authority authority) throws TodoAppException {
        try {
            authorityRepository.save(authority);
            Authority res = authorityRepository.findById(authority.getId()).orElse(null);
            if (res == null) throw new UpdateException(loadErrorMessage);
            return res;
        } catch (DataAccessException e) {
            throw new UpdateException(updateErrorMessageByDataAccess, e);
        }
    }

    /* データを削除するメソッド */
    @Transactional(rollbackFor = TodoAppException.class)
    public void deleteAuthority(Long authorityId) throws TodoAppException {
        authorityRepository.deleteById(authorityId);
        Authority res = authorityRepository.findById(authorityId).orElse(null);
        if (res != null) throw new DeleteException(deleteErrorMessageByLoad);
    }

}

全体的に Repository でエラーが発生した際の例外処理を追加しているだけです。

それ以外は Repository を呼び出して CRUD 結果を取得し、Controller に返却する処理を実装しているだけです。

@Transactional(rollbackFor = TodoAppException.class)は、CUD 処理するメソッドに付与するアノテーションです。

データベースが SQL の実行に失敗した場合、データベースの状態を元に戻す役割を果たします。

一方、R(参照)の場合はデータベースの中身を閲覧するだけで、操作はしません。

そのため、中身がおかしくなるケース自体が発生しないため、アノテーションは不要です。

例外処理について

作成方法を解説します。

exception フォルダ内に下記4つのファイルを作成してください。

  1. TodoAppException.java
  2. InsertException.java
  3. UpdateException.java
  4. DeleteException.java
SpringBootでToDo開発8:例外定義ファイルの作成
例外定義ファイルの作成

内容は⬇️のとおりです。

package com.ozack.todoapp.exception;

/* アプリケーション固有のエラーに対する例外処理を集約するクラス */
public class TodoAppException extends Exception {
    
    public TodoAppException(String message) {
        super(message);
    }

    public TodoAppException(String message, Throwable course) {
        super(message, course);
    }

}
package com.ozack.todoapp.exception;

/* データの登録に失敗した際に発生する例外 */
public class InsertException extends TodoAppException {
    
    public InsertException(String message) {
        super(message);
    }

    public InsertException(String message, Throwable course) {
        super(message, course);
    }

}
package com.ozack.todoapp.exception;

/* データの更新に失敗した際に発生する例外 */
public class UpdateException extends TodoAppException {
    
    public UpdateException(String message) {
        super(message);
    }

    public UpdateException(String message, Throwable course) {
        super(message, course);
    }

}
package com.ozack.todoapp.exception;

/* データの削除に失敗した際に発生する例外 */
public class DeleteException extends TodoAppException {
    
    public DeleteException(String message) {
        super(message);
    }

    public DeleteException(String message, Throwable course) {
        super(message, course);
    }

}

それぞれ、

  1. Todo アプリ上でのエラー
  2. データ登録時のエラー
  3. データ更新時のエラー
  4. データ削除時のエラー

に関する例外を定義しました。

2,3,4 は1(TodoAppException)を継承しており、Todo アプリ上で発生した例外は全て TodoAppException に含まれるようにしています。

この構成にした方がアプリケーション上のエラーであるか判別しやすくなります。

後は Service 側で例外を使用するだけです。

例えば、登録処理では下記のように使います。

/* データを登録するメソッド */
@Transactional(rollbackFor = TodoAppException.class)
public Authority insertAuthority(Authority authority) throws TodoAppException {
    try { // データベース操作(登録処理)
        authorityRepository.save(authority);
        Authority res = authorityRepository.findById(authority.getId()).orElse(null);
        if (res == null) throw new InsertException(loadErrorMessage);
        return res;
    } catch (DataAccessException e) { // データベース接続エラー
        throw new InsertException(insertErrorMessageByDataAccess, e);
    }
}

想定される例外は他にも沢山あるのですが、全ての例外に対して例外処理を追記するとコードが物凄く分かりづらくなるため、今回はこれくらいに留めておきます。

UserService

同じく CRUD + 例外処理を実装しているだけです。

package com.ozack.todoapp.service.user;

import com.ozack.todoapp.dto.response.ResponseUserDto;
import com.ozack.todoapp.exception.TodoAppException;
import com.ozack.todoapp.repository.entity.User;

/* ログインユーザーの情報を取得するサービス */
public interface UserService {

    /* ログインユーザーの情報を取得するメソッド */
    public ResponseUserDto selectUsersByEmailWithAuthorityUser(String email);

    /* データを登録するメソッド */
    public ResponseUserDto insertUser(User user) throws TodoAppException;

    /* データを更新するメソッド */
    public ResponseUserDto updateUser(User user) throws TodoAppException;

    /* データを削除するメソッド */
    public void deleteUser(Long userId) throws TodoAppException;

}
package com.ozack.todoapp.service.user;

import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.ozack.todoapp.dto.response.ResponseUserDto;
import com.ozack.todoapp.exception.DeleteException;
import com.ozack.todoapp.exception.InsertException;
import com.ozack.todoapp.exception.TodoAppException;
import com.ozack.todoapp.exception.UpdateException;
import com.ozack.todoapp.repository.UserRepository;
import com.ozack.todoapp.repository.entity.User;

/* ログインユーザーに関連する情報を処理するサービス */
@Service
public class UserServiceImpl implements UserService {

    private final String loadErrorMessage = "user テーブルのデータ取得に失敗しました。";
    private final String insertErrorMessageByDataAccess = "user データ登録時にデータベース関連のエラーが発生しました。";
    private final String updateErrorMessageByDataAccess = "user データ更新時にデータベース関連のエラーが発生しました。";
    private final String deleteErrorMessageByLoad = "user データを削除できませんでした。";

    private final UserRepository userRepository;

    public UserServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    /* ログインユーザーの情報を取得するメソッド */
    @Override
    public ResponseUserDto selectUsersByEmailWithAuthorityUser(String email) {
        return userRepository.findByEmailWithAuthority(email).orElse(null);
    }

    /* データを登録するメソッド */
    @Override
    @Transactional(rollbackFor = TodoAppException.class)
    public ResponseUserDto insertUser(User user) throws TodoAppException {
        try {
            userRepository.save(user);
            ResponseUserDto res = userRepository.findByEmailWithAuthority(user.getEmail()).orElse(null);
            if (res == null) throw new InsertException(loadErrorMessage);
            return res;
        } catch (DataAccessException e) {
            throw new InsertException(insertErrorMessageByDataAccess, e);
        }
    }

    /* データを更新するメソッド */
    @Override
    @Transactional(rollbackFor = TodoAppException.class)
    public ResponseUserDto updateUser(User user) throws TodoAppException {
        try {
            userRepository.save(user);
            ResponseUserDto res = userRepository.findByEmailWithAuthority(user.getEmail()).orElse(null);
            if (res == null) throw new UpdateException(loadErrorMessage);
            return res;
        } catch (DataAccessException e) {
            throw new UpdateException(updateErrorMessageByDataAccess, e);
        }
    }

    /* データを削除するメソッド */
    @Override
    @Transactional(rollbackFor = TodoAppException.class)
    public void deleteUser(Long userId) throws TodoAppException {
        userRepository.deleteById(userId);
        User res = userRepository.findById(userId).orElse(null);
        if (res != null) throw new DeleteException(deleteErrorMessageByLoad);
    }

}
package com.ozack.todoapp.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import com.ozack.todoapp.dto.response.ResponseUserDto;
import com.ozack.todoapp.repository.entity.User;

/* ユーザー情報を格納している user テーブルと対応 */
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    /* メールアドレスを検索キーとしてユーザー情報を取得 */
    @Query("""
        SELECT new com.ozack.todoapp.dto.response.ResponseUserDto(
            u.id,
            u.name,
            u.email,
            new com.ozack.todoapp.repository.entity.Authority(
                a.id,
                a.name
            )
        )
        FROM User u
        LEFT JOIN u.authority a
        WHERE u.email = :email
            """)
    Optional<ResponseUserDto> findByEmailWithAuthority(@Param("email") String email);

}

強いて言えばパスワードをフロント側に送信したくないので、Dto でそぎ落としています。

ここら辺はエンティティ定義で既に説明しているため省きます。

CategoryService

解説ポイントがありません()

CRUD + 例外処理を実装しているだけです。

package com.ozack.todoapp.service.category;

import java.util.List;

import com.ozack.todoapp.exception.TodoAppException;
import com.ozack.todoapp.repository.entity.Category;

/* カテゴリーに関する処理を行うサービス */
public interface CategoryService {

    /* 全てのカテゴリー一覧を取得するメソッド */
    public List<Category> selectAllCategories();

    /* データを登録するメソッド */
    public Category insertCategory(Category category) throws TodoAppException;

    /* データを更新するメソッド */
    public Category updateCategory(Category category) throws TodoAppException;

    /* データを削除するメソッド */
    public void deleteCategory(Long categoryId) throws TodoAppException;

}
package com.ozack.todoapp.service.category;

import java.util.List;

import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.ozack.todoapp.exception.DeleteException;
import com.ozack.todoapp.exception.InsertException;
import com.ozack.todoapp.exception.TodoAppException;
import com.ozack.todoapp.exception.UpdateException;
import com.ozack.todoapp.repository.CategoryRepository;
import com.ozack.todoapp.repository.entity.Category;

/* カテゴリーに関する処理を行うサービス */
@Service
public class CategoryServiceImpl implements CategoryService {

    private final String loadErrorMessage = "category テーブルのデータ取得に失敗しました。";
    private final String insertErrorMessageByDataAccess = "category データ登録時にデータベース関連のエラーが発生しました。";
    private final String updateErrorMessageByDataAccess = "category データ更新時にデータベース関連のエラーが発生しました。";
    private final String deleteErrorMessageByLoad = "category データを削除できませんでした。";

    private final CategoryRepository categoryRepository;

    public CategoryServiceImpl(CategoryRepository categoryRepository) {
        this.categoryRepository = categoryRepository;
    }

    /* 全てのカテゴリー一覧を取得するメソッド */
    @Override
    public List<Category> selectAllCategories() {
        return categoryRepository.findAll();
    }

    /* データを登録するメソッド */
    @Override
    @Transactional(rollbackFor = TodoAppException.class)
    public Category insertCategory(Category category) throws TodoAppException {
        try {
            categoryRepository.save(category);
            Category res = categoryRepository.findById(category.getId()).orElse(null);
            if (res == null) throw new InsertException(loadErrorMessage);
            return res;
        } catch (DataAccessException e) {
            throw new InsertException(insertErrorMessageByDataAccess, e);
        }
    }

    /* データを更新するメソッド */
    @Override
    @Transactional(rollbackFor = TodoAppException.class)
    public Category updateCategory(Category category) throws TodoAppException {
        try {
            categoryRepository.save(category);
            Category res = categoryRepository.findById(category.getId()).orElse(null);
            if (res == null) throw new UpdateException(loadErrorMessage);
            return res;
        } catch (DataAccessException e) {
            throw new UpdateException(updateErrorMessageByDataAccess, e);
        }
    }

    /* データを削除するメソッド */
    @Override
    @Transactional(rollbackFor = TodoAppException.class)
    public void deleteCategory(Long categoryId) throws TodoAppException {
        categoryRepository.deleteById(categoryId);
        Category res = categoryRepository.findById(categoryId).orElse(null);
        if (res != null) throw new DeleteException(deleteErrorMessageByLoad);
    }

}
package com.ozack.todoapp.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.ozack.todoapp.repository.entity.Category;

/* カテゴリー情報を格納している category テーブルと対応 */
@Repository
public interface CategoryRepository extends JpaRepository<Category, Long> {
}

TodoService

CRUD + 例外処理に加えて、Dto 変換するためのメソッドを作成しています。

package com.ozack.todoapp.service.todo;

import java.util.List;

import com.ozack.todoapp.dto.response.ResponseTodoDto;
import com.ozack.todoapp.exception.TodoAppException;
import com.ozack.todoapp.repository.entity.Todo;

/* Todo に関する処理を行うサービス */
public interface TodoService {

    /* 任意ユーザーの Todo 一覧を取得するメソッド */
    public List<ResponseTodoDto> selectAllTodosByUserIdWithCategories(Long userId);

    /* データを登録するメソッド */
    public ResponseTodoDto insertTodo(Todo todo) throws TodoAppException;

    /* データを更新するメソッド */
    public ResponseTodoDto updateTodo(Todo todo) throws TodoAppException;

    /* データを削除するメソッド */
    public void deleteTodo(Long todoId) throws TodoAppException;

}
package com.ozack.todoapp.service.todo;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.ozack.todoapp.dto.response.ResponseTodoCategoryDto;
import com.ozack.todoapp.dto.response.ResponseTodoDto;
import com.ozack.todoapp.exception.DeleteException;
import com.ozack.todoapp.exception.InsertException;
import com.ozack.todoapp.exception.TodoAppException;
import com.ozack.todoapp.exception.UpdateException;
import com.ozack.todoapp.repository.TodoRepository;
import com.ozack.todoapp.repository.entity.Category;
import com.ozack.todoapp.repository.entity.Todo;

/* カテゴリーに関する処理を行うサービス */
@Service
public class TodoServiceImpl implements TodoService {

    private final String loadErrorMessage = "todo テーブルのデータ取得に失敗しました。";
    private final String insertErrorMessageByDataAccess = "todo データ登録時にデータベース関連のエラーが発生しました。";
    private final String updateErrorMessageByDataAccess = "todo データ更新時にデータベース関連のエラーが発生しました。";
    private final String deleteErrorMessageByLoad = "todo データを削除できませんでした。";

    private final TodoRepository todoRepository;

    public TodoServiceImpl(TodoRepository todoRepository) {
        this.todoRepository = todoRepository;
    }

    /* 全てのカテゴリー一覧を取得するメソッド */
    public List<ResponseTodoDto> selectAllTodosByUserIdWithCategories(Long userId) {
        List<Todo> res = todoRepository.findAllByUserIdWithCategories(userId);
        return convertResponseTodoDto(res);
    }

    /* データを登録するメソッド */
    @Transactional(rollbackFor = TodoAppException.class)
    public ResponseTodoDto insertTodo(Todo todo) throws TodoAppException {
        try {
            Todo resTodo = todoRepository.save(todo);
            if (resTodo == null) throw new InsertException(loadErrorMessage);
            // Dto 変換
            ResponseTodoDto res = new ResponseTodoDto(
                resTodo.getId(),
                resTodo.getTitle(),
                resTodo.getIsCheck(),
                null // フロント側で結合するため null で返却
            );
            return res;
        } catch (DataAccessException e) {
            throw new InsertException(insertErrorMessageByDataAccess, e);
        }
    }

    /* データを更新するメソッド */
    @Transactional(rollbackFor = TodoAppException.class)
    public ResponseTodoDto updateTodo(Todo todo) throws TodoAppException {
        try {
            Todo resTodo = todoRepository.save(todo);
            if (resTodo == null) throw new UpdateException(loadErrorMessage);
            // Dto 変換
            ResponseTodoDto res = new ResponseTodoDto(
                resTodo.getId(),
                resTodo.getTitle(),
                resTodo.getIsCheck(),
                null // フロント側で結合するため null で返却
            );
            return res;
        } catch (DataAccessException e) {
            throw new UpdateException(updateErrorMessageByDataAccess, e);
        }
    }

    /* データを削除するメソッド */
    @Transactional(rollbackFor = TodoAppException.class)
    public void deleteTodo(Long todoId) throws TodoAppException {
        todoRepository.deleteById(todoId);
        Todo res = todoRepository.findById(todoId).orElse(null);
        if (res != null) throw new DeleteException(deleteErrorMessageByLoad);
    }

    /* リスト型の Todo エンティティをリスト型の Dto に変換するメソッド */
    public List<ResponseTodoDto> convertResponseTodoDto(List<Todo> todos) {
        return todos.stream()
                    .map(todo -> new ResponseTodoDto(
                            todo.getId(),
                            todo.getTitle(),
                            todo.getIsCheck(),
                            todo.getTodoCategories() == null ? new ArrayList<>() : // カテゴリーが設定されてない場合は空リストを設定
                            todo.getTodoCategories()
                                .stream()
                                .sorted(Comparator.comparing(todoCategory -> todoCategory.getCategory().getName())) // 名前順にソート
                                .map(todoCategory -> new ResponseTodoCategoryDto( // カテゴリー型に変換
                                    todoCategory.getId(),
                                    new Category(
                                        todoCategory.getCategoryId(),
                                        todoCategory.getCategory().getName()
                                    )
                                ))
                                .collect(Collectors.toList())
                    ))
                    .collect(Collectors.toList());
    }

}
package com.ozack.todoapp.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import com.ozack.todoapp.repository.entity.Todo;

/* Todo を格納している todo テーブルと対応 */
@Repository
public interface TodoRepository extends JpaRepository<Todo, Long> {

    /* userId を検索キーとしてデータを取得 */
    @Query("""
        SELECT t
        FROM Todo t
        LEFT JOIN FETCH t.todoCategories tc
        LEFT JOIN FETCH tc.category c
        WHERE t.userId = :userId
        ORDER BY t.id DESC
    """)
    List<Todo> findAllByUserIdWithCategories(@Param("userId") Long userId);

}

データ取得メソッドでは値を Dto 型で返却したいので、Service 側でconvertResponseTodoDto メソッドを作成してエンティティ型から Dto 型に変換しています。

本来なら Repository で Dto 変換を済ませたかったのですが、TodoRepository 内での Dto 変換が不可能だったため Service 側で変換しました。

仕様上 Dto 変換できない理由は前回で解説しています。

なお、登録・更新メソッドも Dto 型で返却していますが、todocategories メソッドは null にしています。

構成上フロント側に TodoDto と TodoCategoryDto を分割して返却し、フロント側で合体させる方がよさそうだったので、Service ではエンティティ型で返却しています。

厳密には、データ登録時の返却型となる ResponseTodoDto のフィールドに TodoCategory エンティティの id 値を設定する必要があるのですが、この値は TodoCategoryServiceImpl から id 値を登録した後にしか設定できません。

そうなると、TodoServiceImpl の実行後に、TodoCategoryServiceImpl を実行する必要があり、TodoServiceImpl 内で TodoCategoryServiceImpl も呼び出して実行するという分かりにくい構成になってしまいます。

一方、Controller であれば、

  1. TodoServiceImpl
  2. TodoCategoryServiceImpl

と順番に呼び出せば済むため、Controller で実装できるかもと考えましたが、@Transactional を付与した Service をまとめて複数実行することになるので、排他制御がかかりエラーになります。

対策方法はあるのですが、かなり複雑になるので、フロント側に TodoDto と TodoCategoryDto を分割して返却し、フロント側で合体させる方がよさそうです。

TodoCategoryService

R(参照)に関しては TodoService で TodoCategory データをまとめて取得するので、こちらでは実装していません。

また、CUD のメソッドはリスト型にしています。

package com.ozack.todoapp.service.todocategory;

import java.util.List;

import com.ozack.todoapp.dto.response.ResponseTodoCategoryDto;
import com.ozack.todoapp.exception.TodoAppException;
import com.ozack.todoapp.repository.entity.TodoCategory;

/* Todo と Category のマッピング情報を処理するサービス */
public interface TodoCategoryService {

    /* データを登録するメソッド */
    public List<ResponseTodoCategoryDto> insertTodoCategories(List<TodoCategory> todoCategories) throws TodoAppException;

    /* データを更新するメソッド */
    public List<ResponseTodoCategoryDto> updateTodoCategories(List<TodoCategory> todoCategories) throws TodoAppException;

    /* データを削除するメソッド */
    public void deleteTodoCategories(List<Long> todoCategoryIds) throws TodoAppException;

}
package com.ozack.todoapp.service.todocategory;

import java.util.List;

import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.ozack.todoapp.dto.response.ResponseTodoCategoryDto;
import com.ozack.todoapp.exception.DeleteException;
import com.ozack.todoapp.exception.InsertException;
import com.ozack.todoapp.exception.TodoAppException;
import com.ozack.todoapp.exception.UpdateException;
import com.ozack.todoapp.repository.TodoCategoryRepository;
import com.ozack.todoapp.repository.entity.Category;
import com.ozack.todoapp.repository.entity.TodoCategory;

/* Todo と Category のマッピング情報を処理するサービス */
@Service
public class TodoCategoryServiceImpl implements TodoCategoryService {

    private final String loadErrorMessage = "todo_category テーブルのデータ取得に失敗しました。";
    private final String insertErrorMessageByDataAccess = "todo_category データ登録時にデータベース関連のエラーが発生しました。";
    private final String updateErrorMessageByDataAccess = "todo_category データ更新時にデータベース関連のエラーが発生しました。";
    private final String deleteErrorMessageByLoad = "todo_category データを削除できませんでした。";

    private final TodoCategoryRepository todoCategoryRepository;

    public TodoCategoryServiceImpl(TodoCategoryRepository todoCategoryRepository) {
        this.todoCategoryRepository = todoCategoryRepository;
    }

    /* データを登録するメソッド */
    @Override
    @Transactional(rollbackFor = TodoAppException.class)
    public List<ResponseTodoCategoryDto> insertTodoCategories(List<TodoCategory> todoCategories) throws TodoAppException {
        try {
            List<TodoCategory> resTodoCategories = todoCategoryRepository.saveAll(todoCategories);
            List<ResponseTodoCategoryDto> res = convertResponseTodoCategoryDto(resTodoCategories);
            if (res == null) throw new InsertException(loadErrorMessage);
            return res;
        } catch (DataAccessException e) {
            throw new InsertException(insertErrorMessageByDataAccess, e);
        }
    }

    /* データを更新するメソッド */
    @Override
    @Transactional(rollbackFor = TodoAppException.class)
    public List<ResponseTodoCategoryDto> updateTodoCategories(List<TodoCategory> todoCategories) throws TodoAppException {
        try {
            List<TodoCategory> resTodoCategories = todoCategoryRepository.saveAll(todoCategories);
            List<ResponseTodoCategoryDto> res = convertResponseTodoCategoryDto(resTodoCategories);
            if (res == null) throw new UpdateException(loadErrorMessage);
            return res;
        } catch (DataAccessException e) {
            throw new UpdateException(updateErrorMessageByDataAccess, e);
        }
    }

    /* データを削除するメソッド */
    @Override
    @Transactional(rollbackFor = TodoAppException.class)
    public void deleteTodoCategories(List<Long> todoCategoryIds) throws TodoAppException {
        todoCategoryRepository.deleteAllById(todoCategoryIds);
        List<TodoCategory> res = todoCategoryRepository.findAllById(todoCategoryIds);
        if (!res.isEmpty()) throw new DeleteException(deleteErrorMessageByLoad);
    }

    /* Dto 変換メソッド */
    public List<ResponseTodoCategoryDto> convertResponseTodoCategoryDto(List<TodoCategory> todoCategories) {
        return todoCategories
            .stream()
            .map(todoCategory ->
                new ResponseTodoCategoryDto(
                    todoCategory.getId(),
                    new Category(todoCategory.getCategoryId(), null)
                )
            )
            .toList();
    }

}
package com.ozack.todoapp.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.ozack.todoapp.repository.entity.TodoCategory;

/* Todo と Category のマッピング情報を格納している todo_category テーブルと対応 */
@Repository
public interface TodoCategoryRepository extends JpaRepository<TodoCategory, Long> {
}

リスト型にしている理由は、フロント側の Todo 登録画面にて、カテゴリーを複数個まとめて登録・更新・削除できるようにしているからです。

SpringBootでToDo開発8:カテゴリー選択時の画面
カテゴリー選択時の画面

この仕様に対応させるために、バックエンド側のこちらでも CUD はまとめて処理します。

テストコード作成

一応テストコードを GitHub に載せておきます。

どれも Repository と Service を繋いで行うインテグレーションテストです。

SpringBootTodoServer/src/test/java/com/ozack/todoapp/service at main · o-zack-0390/SpringBootTodoServer
Todo データを処理するSpringプロジェクト. Contribute to o-zack-0390/SpringBootTodoServer development by creating an...

コード内容に関してですが、テスト対象が Service であること以外は前回と同じです。

そのため解説は省略します。

まとめ

今回は業務ロジックを処理する Service を作成しました。

main/…/service

  1. フォルダ内の全ファイル

main/…/exception

  1. フォルダ内の全ファイル

プロジェクト構成は GitHub にプッシュしています。

GitHub - o-zack-0390/SpringBootTodoServer: Todo データを処理するSpringプロジェクト
Todo データを処理するSpringプロジェクト. Contribute to o-zack-0390/SpringBootTodoServer development by creating an...

これくらいのコンパクトなアプリケーションだと、例外処理を追加する以外は正直 Repository と同じことをしているだけになってしまいますが、今回のようなデータ取得の用途で一番使われるので、メタ的には覚えといた方がいいです()

次回はフロント側との API 通信を行う Controller を作成していきます。

タイトルとURLをコピーしました