以前、下のような記事を書きました。
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依存を減らすだけなら、そこまで難しくないんだよ、という部分が分かってもらえたら幸いです。
実際のソースは、これよりかなり入り組んでいます。
処理速度遅延の解消が目下の目標だったため、一部のリポジトリ化という、妥協策を取っています。
リポジトリを使う理由はそれぞれだと思いますが、場合によってより良い方法を取れるようになりたいですね。