Chapter 12

DBから読み込んでオブジェクト返すやつ

とっくり
とっくり
2022.06.14に更新

今回は、Webアプリを開発していたら必ず通る道であるところの、DBから値を読み込んで画面に表示する機能について考えます。

サンプルプログラム

話をできるだけシンプルにするためReactとかVueとかイマドキのフロントエンドではなく、レスポンスボディとしてHTMLを返す昔ながらのサンプルアプリをJava14 + SpringBootで作りました。

日曜始まりの一般的なカレンダーの上に、DBに保存されているスケジュールデータをはめ込んで表示します。「前の月」「次の月」のリンクで表示月を切り替えます。

機能としてはこれだけです。登録機能もなにもないので、デモ用データをランダムに発生させるギミックだけ仕込んであります。

ソースコードはこちらです。

https://github.com/tockri/not-scary-fp/tree/master/ex-dto-with-dbdata/initial
JDK14だけあればすぐ動かせます。
表示のための構造は、下図に示すように1日単位のDayCellDto、週単位のWeekRowDto、月単位のScheduleCalendarDtoの3つのDTOからなります。いずれも構造上の子をListで保持するだけで、とても小さくて良い感じです。

そして、この ScheduleCalendarDto を組み立てているのは ScheduleCalendarService#makeDto(int, int)です。
/**
 * DBからデータを取得してDTOを構築する
 */
public ScheduleCalendarDto makeDto(int year, int month) {
   // 月の1日
   var monthTop = LocalDate.of(year, month, 1);
   // 次月の1日
   var nextMonthTop = monthTop.plusMonths(1);
   // 月の1日を含む週の日曜日
   var calendarTop = switch (monthTop.getDayOfWeek()) {
       case SUNDAY -> monthTop;
       default -> monthTop.minusDays(monthTop.getDayOfWeek().getValue());
   };
   // DBからデータを取得する
   var schedules = scheduleRepository.findBetween(monthTop, nextMonthTop);

   var dto = new ScheduleCalendarDto();
   for (var sunday = calendarTop; sunday.isBefore(nextMonthTop); sunday = sunday.plusDays(7)) {
       var weekRow = new WeekRowDto();
       for (var i = 0; i < 7; i++) { // 日曜~土曜の7日間
           var date = sunday.plusDays(i);
           var dayCell = new DayCellDto(date, date.getMonthValue() == month);
           weekRow.addDay(dayCell);
           // スケジュールをセルに設定
           for (var schedule : schedules) {
               if (schedule.getDate().equals(date)) {
                   dayCell.addSchedule(schedule.getTitle());
               }
           }
       }
       dto.addWeek(weekRow);
   }
   return dto;
}

カレンダー作るの、意外と面倒ですよね……。

テストもちゃんと書いた。

さすがにこれだけ複雑なことをするコードはテスト要りますね。ScheduleCalendarServiceTestで上記makeDtoメソッドの正常系だけですが、テストしています。

作りとしての問題点

これだけであれば、機能が少なくて見通しもいいし、この状態で特に大きな問題はありません。ただこれはあくまでサンプルなので、ちょっと想像を膨らませてみます。

もっと本気で開発しているアプリケーションで、DBはH2ではなくMySQLなどで、数年かけて仕様がどんどん大きくなり、コードもテストもチームメンバーも増え、CI/CDも備えていくということを考えます。

そんな状況であれば問題になりそうな点が2つあります。

問題点1: ミュータブルなDTO

DayCellDtoWeekRowDtoScheduleCalendarDtoの3つはいずれもaddXXXメソッドを備えているミュータブルなクラスです。

そのため、仕様追加のときに、生成した後からちょっと中身をいじるとか、やろうと思えばHTMLテンプレートの中からDTOを変更するなどのアクロバティックなこともできてしまいますし、テストが保証できる安全性の幅が狭いです。詳しくは第9回を参照。

https://note.com/tockri/n/n0a86898d01af

問題点2: テストが遅い

ScheduleCalendarServiceTestは実行してみればわかりますが、Springの@Autowired を解決するのと H2 データベースを準備するためにダミーアプリケーションを数秒〜10数秒かけて実行しています。

テストクラスが数百、数千と増えていったとき、テストの遅さ=CIの遅さ=デプロイの遅さとなります。テストが速いと機能追加もバグ修正も全部速くなっていろいろ助かります。

また、テストの最初の部分

ScheduleCalendarServiceTest.java
// repositoryにデータを生成
List.of(
   new Schedule(LocalDate.of(2020, 6, 5), "6月5日-1"),
   new Schedule(LocalDate.of(2020, 6, 30), "6月30日"),
   new Schedule(LocalDate.of(2020, 7, 1), "7月1日"),
   new Schedule(LocalDate.of(2020, 6, 5), "6月5日-2")
).forEach(scheduleRepository::save);

でRDBにテスト用データを登録していますが、この手のテストは並列実行できません。テスト時間を劇的に短縮するのはかなり難しいでしょう。

テストが速い、といえば純粋関数ですね。

https://note.com/tockri/n/nff1642fcfe4e
ScheduleCalendarDto を生成する複雑なロジック、テストしやすい純粋関数にできないでしょうか。

DTOをイミュータブルにしてみる

まず簡単な方から取り掛かっていきます。3つのDTOをイミュータブルにしましょう。

DayCellDtoの元のコードはこうでした。

DayCellDto.java
/**
* 日を表すセル
*/
public class DayCellDto {
   private final ArrayList<String> schedules = new ArrayList<>();
   public final LocalDate date;
   public final boolean inRange;

   public DayCellDto(LocalDate date, boolean inRange) {
       this.date = date;
       this.inRange = inRange;
   }

   /**
    * スケジュールを追加
    */
   public void addSchedule(String title) {(略)}

   /**
    * スケジュールのリスト
    */
   public List<String> getSchedules() {(略)}

   /**
    * 日付
    */
   public int getDayOfMonth() {(略)}

   /**
    * <td>タグに設定するclass属性
    */
   public String getCssClass() {(略)}
}

これがこうなります。リファクタリング後のDayCellDto

DayCellDto.java
/**
* 日を表すセル
* イミュータブル
*/
public class DayCellDto {
   public final List<String> schedules;
   public final LocalDate date;
   public final boolean inRange;

   public DayCellDto(LocalDate date, boolean inRange, List<String> schedules) {
       this.date = date;
       this.inRange = inRange;
       this.schedules = Collections.unmodifiableList(schedules);
   }

   /**
    * 日付
    */
   public int getDayOfMonth() {(略)}

   /**
    * <td>タグに設定するclass属性
    */
   public String getCssClass() {(略)}
}

schedulespublic finalにしたらgetterも要らなくね?ということでとっちゃいました。この辺は好みというか、適宜やっていただければ。

Javaにはイミュータブルを保証してくれるList型がないので、仕方なくコンストラクタ中で

this.schedules = Collections.unmodifiableList(schedules);

として、改変したら実行時例外が出るように保証つけてます。

同様に、 WeekRowDtoはこうなり

WeekRowDto.java
/**
* 週を表す行
* イミュータブル
*/
public class WeekRowDto {
   public final List<DayCellDto> days;

   public WeekRowDto(List<DayCellDto> days) {
       this.days = Collections.unmodifiableList(days);
   }
}

ScheduleCalendarDtoはこうなりました 。短いですね。

ScheduleCalendarDto.java
/**
* 一か月分の日曜始まりカレンダーにスケジュールを表示するためのDTO
* イミュータブル
*/
public class ScheduleCalendarDto {
   public final List<WeekRowDto> weeks;

   public ScheduleCalendarDto(List<WeekRowDto> weeks) {
       this.weeks = Collections.unmodifiableList(weeks);
   }
}

複雑なDTO構築ロジックを純粋関数に

DTO構築している ScheduleCalendarService#makeDto(int, int) をよく見てみれば、引数以外からデータを取得している、いわゆる副作用は1箇所だけです。

schedulesのデータをメソッド内でDBから取得するのではなく、メソッド外から引数で受け取るようにすれば、makeDto全体を純粋関数にできます。

さらにそうすれば、クラスでscheduleRepositoryの参照を持たなくてよくなり、関数自体を static 、つまり外から見て状態を持たないことが判りやすいシグネチャにすることができます。

ScheduleServiceは単に渡されたオブジェクトを別のオブジェクトに変換するだけのメソッドを一つだけ持つクラスになるので、もう "Service" なんていう「なんか便利にいろいろできるクラス」と取られてファットになりやすい名前じゃなくしたほうがよさそうです。

DIも必要ないのでSpringの@Serviceアノテーションもなくして、実行時に環境によってなにか変化するかもしれない不安感から解放されます。

ということでクラス名も変更して、誰がどう見ても「ScheduleCalendarDtoの構築しかしないクラス」になったのがこちら、ScheduleCalendarDtoBuilderです。

ScheduleCalendarDtoBuilder.java
public class ScheduleCalendarDtoBuilder {
   /**
    * 画面表示用DTOを構築する
    */
   public static ScheduleCalendarDto build(int year, int month, List<Schedule> schedules) {
       // 月の1日
       var monthTop = LocalDate.of(year, month, 1);
       // 次月の1日
       var nextMonthTop = monthTop.plusMonths(1);
       // 月の1日を含む週の日曜日
       final var calendarTop = switch (monthTop.getDayOfWeek()) {
           case SUNDAY -> monthTop;
           default -> monthTop.minusDays(monthTop.getDayOfWeek().getValue());
       };
       var weeks = new ArrayList<WeekRowDto>();
       for (var sunday = calendarTop; sunday.isBefore(nextMonthTop); sunday = sunday.plusDays(7)) {
           var days = new ArrayList<DayCellDto>();
           for (var i = 0; i < 7; i++) { // 日曜~土曜の7日間
               final var date = sunday.plusDays(i);
               var schedulesInDay = schedules.stream()
                       .filter(s -> s.getDate().equals(date))
                       .map(s -> s.getTitle())
                       .collect(Collectors.toList());
               days.add(new DayCellDto(calendarTop, calendarTop.getMonthValue() == month, schedulesInDay));
           }
           weeks.add(new WeekRowDto(days));
       }
       return new ScheduleCalendarDto(weeks);
   }
}

自分で書いておいてなんですけど、リファクタリング前と後で、コメントの変化が面白いです。

前:DBからデータを取得して画面表示用DTOを構築する
後:画面表示用DTOを構築する

確かに、リファクタリング前は2つのことをしてたのが、リファクタリング後は1つのことしかしなくなってますね。

テストが大幅に改善

テストにもリファクタリングを加えて、ScheduleCalendarDtoBuilderTestになりました。

リファクタリング前のScheduleCalendarServiceTestと比較して行数はほとんど変わってませんが、対象メソッドを呼ぶ前のデータ準備部分

ScheduleCalendarServiceTest.java
// repositoryにデータを生成
List.of(
   new Schedule(LocalDate.of(2020, 6, 5), "6月5日-1"),
   new Schedule(LocalDate.of(2020, 6, 30), "6月30日"),
   new Schedule(LocalDate.of(2020, 7, 1), "7月1日"),
   new Schedule(LocalDate.of(2020, 6, 5), "6月5日-2")
).forEach(scheduleRepository::save);

が、

ScheduleCalendarDtoBuilderTest.java
// 引数に渡すリストを生成
var schedules = List.of(
   new Schedule(LocalDate.of(2020, 6, 5), "6月5日-1"),
   new Schedule(LocalDate.of(2020, 6, 30), "6月30日"),
   new Schedule(LocalDate.of(2020, 7, 1), "7月1日"),
   new Schedule(LocalDate.of(2020, 6, 5), "6月5日-2")
);

になりました。

ここだけ比較すると、DBへのINSERTと単なるList生成なので、実行時間は数百倍違ってきます。こういう差が数百、数千と積もっていけば、テスト全体にかかる時間はものすごく大きな差になります。

少しの違いに見えるけどわりと大きい

完成したプロジェクトはこちらです。

https://github.com/tockri/not-scary-fp/tree/master/ex-dto-with-dbdata/complete
あらためて、このリファクタリングで起こった変化をまとめます。

「コントローラーでなんでもやるようにしよう」というファットコントローラーを推奨しているわけではありません。IO(副作用)を伴う処理を最初と最後にまとめて、やりたい事の本質的で複雑なロジックを純粋関数にしましょう、ということです。

ほかにもパッケージ間の結合度が下がったりしていますが、その辺は割愛します。今回はここまで!