はじめに
SalesforceのTrailheadにある「セレクターレイヤー(セレクターパターン)」を否定して、リポジトリパターンを導入して、もっと便利になるようにEloquentみたいなものを作ったよという記事です
多分、Apexの中〜上級者向けです。
どのような感じか
普段の使い方
次のようにしてオブジェクトを取得できるようになります。
laravelに慣れた身からするとすごい扱いやすい。
// Eloquentっぽい感じでクエリを組み立てる
Query query = (new Query())
.source('Opportunity')
.pick('Id')
.condition(
(new Query())
.condition('Probability', '=', 0)
.orCondition('StageName', '=', OppStageName.CONTRACT_IN_PROGRESS)
.orCondition('Name', '=', targetOppName)
)
.join(
'AccountId',
'IN',
(new Query())
.source('Account')
.pick('Id')
.condition('Id', '=', accountId)
.condition('Phone', '=', '00000000000')
.condition('Fax', '=', '11111111111')
);
// クエリを使って商談を取得
Opportunity opp = (Opportunity) repository.first(query);
テスト容易性
Repositoryクラス
、MockRepositoryクラス
はともにRepositoryInterface
を実装しているので次のようにMockを使って簡単にDBとの依存を切ることができます。
あとはRepisotiryクラスをコンストラクタインジェクションすれば、テスト時に置き換えが楽になる!
// 実際に存在する商談のID
Id existOppId = existOpp.Id;
// mockの商談を用意
Opportunity mockOpp = new Opportunity();
mockOpp.Id = '006000000000000';
// repositoryにMockを使用する
RepositoryInterface repository = new MockRepository(mockOpp);
// 存在する商談のIDを指定して商談を取得すると・・・?
query = (new Query()).source('Opportunity').pick('Id').find(existOppId);
Opportunity opp = (Opportunity) repository.first(query);
// 存在する商談のIDではなく、モックの商談のIDが返ってくる!
Assert.areEqual(mockOpp.Id, opp.Id);
ちなみに、MockRepository
はinsert
時に自動的に3桁のprefixを自動でつけるようになっているのでとても便利
Opportunity mockOpp = new Opportunity();
repository = new MockRepository();
Opportunity insertedOpp = (Opportunity) repository.doInsert(mockOpp);
Assert.areEqual('006000000000000', insertedOpp.Id);
やろうとしたきっかけ
ApexでのSOQL発行について
ApexでSOQLを発行するには、次の2つのやり方が(個人的な)メジャーなやり方
1. SOQLを文字列で作って実行する
String soql = 'SELECT ID, Name FROM Opportunity WHERE ID = :oppId AND .......';
Opportunity opp = Database.query(soql);
2. 直接クエリ発行を書く
Opportunity opp = [SELECT ID, Name From Opportunity WHERE ID = :oppId AND .......];
どちらでも動くコードは書けるのですが、次のような問題があります。
- 文字列でSOQLを作る場合、自動フォーマットが効かないので長いクエリがとても見づらい
-
SELECT * FROM
が使えないので、オブジェクトをクローンしたい時とかに文字列でクエリを作るしかなくなる - どちらの方法とも共通で、テストを書こうとするとDBへの依存が必ず発生する
- 途中まで共通のクエリで、ある条件の時だけSOQLに条件を追加したい、というような時は
- 文字列を作る場合、文字列操作をしないといけなくなり操作しにくい
- 直接書く場合はそもそもできない。なので共通箇所の多いクエリを二つ書くことになり、いい方法とはいえない
- 文字列だと変数を指定するときにLSPの力を得られない。(頑張ってタイポしないように変数名を書くとか前時代すぎる)
これらを解消するために今回の仕組みを作りました。
セレクターレイヤー(セレクターパターン)について
今回のこの仕組みの前に、salesforceのTrailheadに紹介されているデザインパターンにも触れておきます。
https://trailhead.salesforce.com/ja/content/learn/modules/apex_patterns_dsl/apex_patterns_dsl_learn_selector_l_principles
紹介しておいてなんですが、やらない方が良いです。
セレクターパターンではクエリを実行する際に、同じクエリを発行するのであればセレクタークラスに集めて再利用しようという考え方です。一見まともに見えますが
- 似ているけどちょっと違う条件の時に、別のメソッドを作る必要がある。
- クラスの肥大化
- ちょっと違うクエリに対応するために、フラグ変数を入れる
- 可読性を下げる、悪魔の手法
- selectの違いによる再利用性が下がるのを避けるために、全フィールド指定をする可能性がとても高い
- メモリ使用量の悪化
-
SELECT * FROM
なんて書かないでしょ?それを行う動機が揃ってしまう
特にApexは非エンジニアがなりがちだと私は感じているので、上記の「やばい」方法が実装される可能性は極めて高いと考えられます。また、データの取得で切り出されしまうとオブジェクト指向っぽくないです。
そして極め付けはSalesforce主席エンジニアなる人のブログの「The Repository Pattern」でこのセレクターパターンについて
OK, yikes. That was a lot of code just to prove a point — namely that going this route (which builds to the Selector pattern, where all your queries are encapsulated by methods that can then be overridden) is unsustainable. You’ll need to mock every method that leads to a SOQL query; you’ll need many different methods to add different filtering criteria. The Selector pattern requires a different method for each query you require, and if you’d like to override your selector methods, you’re going to have your work cut out for you.
と言っています。要約すると
- セレクターパターンは全てのクエリがメソッド化・カプセル化されておりオーバーライド可能
- しかしモックする場合は全てのメソッドをモックする必要がある
- いろんな条件を追加すると、それだけメソッドが必要になる
- 以上から、セレクターパターンをオーバーライドするのは大変な作業で、持続不可能である!
というわけで、セレクターパターンは却下!!
どうりで聞き馴染みのないデザインパターンなわけだ
では、どうするか?
先ほどの「The Repository Pattern」がほぼほぼ答えにはなってきます。
ただし、これだと微妙にコードが古い箇所があったり、個人的趣向にあっていなかったのでLaravelのEloquentを参考に、似たような感じで組めるようにしました。
ただし、LaravelでいうところのModelクラスがないので次の方法を真似しました、
- Queryクラスで、クラスに定義されているメソッドを使いながらクエリを組み立てる
- RepositoryクラスにQueryクラスを渡すとそれが実行される
他にも使い方の想定をするときにこちらの5年間 Laravel を使って辿り着いた,全然頑張らない「なんちゃってクリーンアーキテクチャ」という落としどころや良いコード/悪いコードで学ぶ設計入門を参考にしました。
使い方の想定
public with sharing class OppUpdater {
private final Id oppId;
public OppUpdater(Id oppId) {
this.oppId = oppId;
}
public Opportunity execute(){
Opportunity opp = [SELECT ID, ....... FROM Opportunity WHERE ID = :this.oppId];
// 何かしらの更新処理
update opp;
return opp;
}
}
このような商談を取得して、加工して、更新するクラスがあったとします。
これを今回のEloquentもどきを使ったRepositoryパターンに変えると
public with sharing class OppUpdater {
private final Id oppId;
private final RepositoryInterface oppRepo;
public OppUpdater(Id oppId, RepositoryInterface oppRepo) {
this.oppId = oppId;
this.oppRepo = oppRepo;
}
public Opportunity execute(){
List<String> selectFields = new List<String>{'ID', .........};
Query query = (new Query())
.source('Opportunity')
.pick(selectFields)
.find(this.oppId)
Opportunity opp = (Opportunity) this.oppRepo.first(query);
// 何かしらの更新処理
opp = this.oppRepo.doUpdate(opp);
return opp;
}
}
このようになります。そして、テストを書く際には次のように書けばMockOppがOppUpdaterの中で使われるようになり、DBへの依存もなくなってロジックのテストに集中することができます。
@isTest(seeAllData=false)
public with sharing class OppUpdater_T {
public static testMethod void testUpdate() {
// モックする商談とリポジトリを用意
Opportunity mockOpp = new Opportunity();
MockRepository mockRepo = new MockRepository(mockOpp);
// OppUpdaterにコンストラクタインジェクション
Id dummyId = '006000000000000';
OppUpdater updater = new OppUpdater(dummyId, MockRepo);
Opportunity UpdatedOpp = updater.execute();
// OppUpdaterの中の更新処理の内容をAssertする
Assert.areEqual(.......);
Assert.areEqual(.......);
}
}
SELECT * をしたい
もしオブジェクトのクローンをするためとかdSELECT * FROM
をやりたくなったら
Query query = (new Query())
.source('Opportunity')
.pick(selectFields)
.find(this.oppId)
を
Query query = (new Query())
.source('Opportunity')
.pickAll()
.find(this.oppId)
に変えるだけです。とてもわかりやすく、シンプルですね!
特定条件でwhere句を追加したい
ifでwhere句を追加するのもお手のものです
Id oppId = opp.Id;
// 商談IDで紐づく見積もりを取得する条件を作成
Query query = (new Query())
.source('Quote')
.pickAll()
.condition('OpportunityId', '=', oppId);
// もし商談が契約中であれば、タイプが請求用の見積だけ取得する
if(opp.StageName == StageName.CONTRACT) {
query.condition('Type', '=', QuoteType.BILLING);
}
// 見積取得
List<Quote> quotes = (List<Quote>) this.quoteRepo.get(query);
SELECTやWHEREが多い場合は?
フォーマッタを使えば縦に並んでくれるので(横に並ぶよりは)見やすいと思います
Query query = (new Query())
.source('Opportunity')
.pick('hoge')
.pick('huga')
..
..
..
.condition('A', '=', 1)
.condition('B', '=', 2)
..
..
..
SELECTは配列で渡すこともできます
List<String> selectFields = new List<String>{'hoge', 'fuga', .......};
Query query = (new Query())
.source('Opportunity')
.pick(selectFields)
.condition('A', '=', 1)
.condition('B', '=', 2)
..
..
..
使うためには?
まだリポジトリ(上のRepositoryではなく、githubの方)を作ってないので後ほど作ります・・・💦
できたらgit submodule
とかで取り込んでください!