SE(たぶん)の雑感記

一応SEやっている筆者の思ったことを書き連ねます。会計学もやってたので、両方を生かした記事を書きたいと考えています。 でもテーマが定まってない感がすごい。

データアクセスをリポジトリパターンに移行させた話

以前、下のような記事を書きました。

hiroronn.hatenablog.jp

DBアクセスの抽象化。
自動テストを作るうえでは、必須の作業です。

その作業を実際に行ったので、どうやってやったか残していきます。

ただ、ソースはかなりデフォルメしますし、諸事情により妥協の産物となっています。ご了承ください。

対象プロジェクト

とあるアプリのバックエンド(Web API)です。

以前の記事*1で触れたプロジェクトに、またヘルプで入ってきました。

事前状況

とある一日の活動状況を示すエンティティとして、以下の3つがあります。

名称 概要
活動実績 クライアントが送信した、活動結果
獲得値 活動実績から算出した、得られるポイント
累計値 活動実績、獲得値の一定期間累計

元々は、この集計等は決まった時間にバッチ処理で行っており、パフォーマンスは度外視していました。
そのため、古い日付から

各データ取得→更新

と行っていれば、特に問題は生じませんでした。

状況変化

上記処理について、 集計頻度を高くする ことになりました。
そうすると、処理速度の遅さがネックになりました。

元のソース例*2

  • 計算処理
//指定日の計算
public void execute(LocalDate target) {
    // DBから対象日活動実績取得
    Activity todayAct = Activity.findByDate(target);
    LocalDate yesterday = target.minusDays(1);
    // DBから昨日累計取得
    TotalActivity yesterdayTotal = TotalActivity.findByDate(yesterday);
    
    //計算
    GetActivity todayGet = calc(todayAct);
    TotalActivity todayTotal = aggregate(yesterdayTotal, todayAct, todayGet);

    //保存
    todayGet.save();
    todayTotal.save();
}
  • 呼び出し処理
public void calculate() {
    LocalDate today =  LocalDate.now();

    LocalDate targetDay = today.minusDays(6);
    while (!today.equals(targetDay)) {
        execute(targetDay);
        targetDay = targetDay.plusDay(1);
    }
}

サービスクラスみたいなものだと考えてください。

一週間前から、順次さかのぼって再集計を行う処理です。

上記LocalDate以外は全てエンティティです。
基本的には、エンティティをそのまま使っている仕組みとなっています。

遅延原因

エンティティのsaveを呼び出したタイミングで、DBに実際に書き込みが行われます。
処理単体で見れば安全*3なのですが、頻繁にDB書き込みが行われるため、処理すべき量が多い場合に、処理速度が遅延します。

今回は、パフォーマンス測定の結果、DBへの保存回数の多さがボトルネックであると判断できました。

解決策

保存の粒度は、Ebeanを使っていたため、ある程度制御*4できます。
そのため、問題解決のために、一括更新しようとしました。

しかし、

// DBから対象日活動実績取得
Activity todayAct = Activity.findByDate(target);
LocalDate yesterday = target.minusDays(1);
// DBから昨日累計取得
TotalActivity yesterdayTotal = TotalActivity.findByDate(yesterday);

処理開始時、DBから前日データを取得する処理。これも問題です。
一括更新すると、古い日付から順次更新するという仕様上、計算後のデータをDBに書かないといけなくなってしまっています。

よって、計算したデータは、メモリ上に保持しておく必要があります。

そこで利用できる手法が、リポジトリです。

リポジトリの実装

機能は二つ必要です。

  • データの取得
  • 一括保存

事前に言っておきますが、実装時間の都合上、以下の部分は妥協しました。

  • リポジトリインターフェイス
    自動テストにする場合は必須ですが、内部依存関係を完全に解決できなかった。

  • 戻り値をドメインオブジェクトにする
    本来、そうしたほうが良いのですが、これも、短時間で依存関係を解決するのが困難でした。

リファクタリングの鉄則ですが、全てを一気にやる必要はないと思います。

データの取得

まず、クラスを作ります。戻り値がエンティティなのは、妥協です。

public class ActivityRepository {
    public Activity findActivityByDate(LocalDate target) {

    }

    public TotalActivity findTotalByDate(LocalDate target) {
        
    }
}

データの取得ですが、最初はDBからの取得が必要です。

public class ActivityRepository {
    public Activity findActivityByDate(LocalDate target) {
        return Activity.findByDate(target);
    }

    public TotalActivity findTotalByDate(LocalDate target) {
        return TotalActivity.findByDate(target);
    }
}

しかし、これでは常にDB値を返してしまうため、一度取得したものはキャッシュしましょう。
キャッシュにはMapを使えば十分です。

public class ActivityRepository {
    private Map<LocalDate, Activity> activities = new HashMap<>();
    private Map<LocalDate, TotalActivity> totals = new HashMap<>();

    public Activity findActivityByDate(LocalDate target) {
        if (!activities.containsKey(target)) {
            activities.put(target, Activity.findByDate(target));
        }
        return activities.get(target);
    }

    public TotalActivity findTotalByDate(LocalDate target) {
        if (!totals.containsKey(target)) {
            totals.put(target, TotalActivity.findByDate(target));
        }
        return totals.get(target);
    }
}

取得に関しては、これで良いでしょう。計算後の値保持も、利用者が、リポジトリから取得したオブジェクトだけ使用している限り、インスタンスの同一性が保たれるので問題なし*5です。

保存

保存する前に、計算後の値をどうやって保持するかです。

ここでは、保存対象を指定するためのaddメソッドを追加しましょう。
add内では、リストに保存対象を格納します。

ついでに、保存処理も追加しましょう。

public class ActivityRepository {
    private List<Activity> addedAcitivities = new ArrayList<>();
    private List<TotalActivity> addedTotals = new ArrayList<>();

    public void add(Activity item) {
        addedAcitivities.add(item);
    }

    public void add(TotalActivity item) {
        addedTotals.add(item);
    }

    public void save() {
        Ebean.save(addedAcitivities);
        Ebean.save(addedTotals);
    }
}

利用側書き換え

計算処理

リポジトリを受け取り、それを介してデータを取得します。

//指定日の計算
public void execute(LocalDate target, ActivityRepository repository) {
    // 対象日活動実績取得
    Activity todayAct = repository.findActivityByDate(target);
    LocalDate yesterday = target.minusDays(1);
    // 昨日累計取得
    TotalActivity yesterdayTotal = repository.findTotalByDate(yesterday);
    
    // 計算
    GetActivity todayGet = calc(todayAct);
    TotalActivity todayTotal = aggregate(yesterdayTotal, todayAct, todayGet);

    // 保存
    repository.add(todayGet);
    repository.add(todayTotal);
}

なお、クラスを書いていませんが、リポジトリインスタンスを、コンストラクタの引数で受け取る*6ようにすると、なお良いです。

計算呼び出し

以下の役割を持ちます。

public void calculate() {
    LocalDate today =  LocalDate.now();

    LocalDate targetDay = today.minusDays(6);
    ActivityRepository repository = new ActivityRepository();
    while (!today.equals(targetDay)) {
        execute(targetDay, repository);
        targetDay = targetDay.plusDay(1);
    }

    repository.save();
}

これでだいたい動く

ええ、動きます。だいたい。

ただし、考慮していない部分が多いです。たとえば、

  • 自動テスト化するなら、リポジトリインターフェイスにする必要がある
  • エンティティをそのまま使っているので、使い方によってはDBアクセスができてしまう
  • オプティミスティック同時実行制御に頼っているので、不安定*7

例外処理等は、単純化したため考慮していません。

おわりに

こういうの書いて、わかった!となる人がいるのか、少々疑問はあります。
単純な形に移行して、DB依存を減らすだけなら、そこまで難しくないんだよ、という部分が分かってもらえたら幸いです。

実際のソースは、これよりかなり入り組んでいます。
処理速度遅延の解消が目下の目標だったため、一部のリポジトリ化という、妥協策を取っています。

リポジトリを使う理由はそれぞれだと思いますが、場合によってより良い方法を取れるようになりたいですね。


*1:http://hiroronn.hatenablog.jp/entry/20170916/1505547623

*2:本来は、ユーザー等の概念もあるが、省略している。処理は、日数と人数の分だけ行われる

*3:オプティミスティック同時実行制御は入っている

*4:Ebean.saveには、リスト内のエンティティを一括で保存するメソッドがある

*5:だから、エンティティのインスタンスを、リポジトリから作れないようにしたほうが良い

*6:依存性の注入を行う

*7:同時に計算を実行した場合、処理順によってはどちらかが失敗する