社内勉強会で議論したApex Selectorパターンのベストプラクティス
こんばんは、遠藤です。
co-meetingでは毎週水曜日に社内勉強会を実施していますが、今回はその一つ「プロダクトもくもく会」で話題にした内容を紹介します。
また、この記事はSalesforce Advent Calendar 2022の22日目の記事でもあります。
前回12月の第1週に取り上げたのは、Apex エンタープライズパターンについて解説する 2つのTrailheadモジュールのから「セレクタレイヤの原則について」について社内で議論しました。
タイトルで「ベストプラクティス」と謳っていますが2時間の勉強会で議論した内容なので結論がでているわけではありませんのであしからず。
とはいえなかなか良い議論でき、勉強会の中で整理したセレクタレイヤの要件の実装までできたので、そのサンプルコードも紹介したいと思います。
この記事を読み進めるに当たっては、上記の2つのモジュールにも目を通しておくと良さそうです。また、この記事の内容はApexでコードを記述する必要がある中規模以上のプロジェクトに関わる開発者が対象となるのではないかと思います。
Apexにおけるセレクタレイヤとは
「セレクタレイヤの原則について」で紹介されているセレクタレイヤについて簡単にまとめると、オブジェクトへのアクセスはサービスやドメインを記述するApexクラス直にSOQLを書くのではなく、セレクタレイヤーを設けて抽象化しようというものです。
それによって、以下の問題を避けることができると解説されています。
クエリの不整合: 同じようなクエリーがあちこちにコピーされる事によって、ロジックによって期待と異なる結果が生じてしまう
クエリデータの不整合: 返したレコードが意図せず再利用された場合などに、ランタイムエラー「System.SObjectException: SObject row was retrieved via SOQL without querying the requested field: X.Y」が発生する恐れがある。
セキュリティの不整合: オブジェクトセキュリティのチェックがロジックごとに異なり一貫性が保たれない
また、これらを解決するためにセレクターパターンの責務(関心事)として以下が挙げられています。
可視性、再利用性、メンテナンス性: クエリロジックをまとめメンテナンスを容易にする。
照会されるデータの予測可能性: セレクターが何をするかをメソッド名でわかるようにし、何を返すかも明確にする。
セキュリティ: ユーザーコンテキストに適用される共有および権限を執行するセキュリティチェックをオプトインまたはオプトアウトできる
プラットフォームの共鳴 (Platform sympathy): 一括処理化を促す。ヒープなどガバナ制限を考慮する。
確かに、これらが解決されるととてもハッピーになりそうです。
しかし、他の言語のフレームワークに組み込まれているActiveRecordやORマッパーと異なり、Apexの制限やSOQLの特殊性から、きれいに実装するのはなかなか難しい課題と思われます。
Selectorパターンの実装の難しさ
SOQLはクエリー言語とそれ自体にORマッピング機能が融合されているようなものなので、セレクタレイヤの内部実装を閉じ込めることが難しく、開放/閉鎖原則にしたがったクラスを設計することはなかなか難しい作業です。
以下詳しく見ていきます。
どの項目をSELECTするのか?
例えば、セレクタレイヤーのApexクラスAccountSelectorでgetAccountByIdメソッドを実装するとします。
このメソッドが戻り値としてsObjectレコードを返す場合、利用する側ではこのメソッドがどの項目を返すのかということを確認しながら実装することになります。
目的の項目がSELECTされていない場合、何も考えないで実装するとgetAccountByIdのSELECT句に項目を追加することになります。また、追加するには「getAccountByIdにPhone項目追加して良い?」みたいな会話がGithub上などで必要となるわけです。
Trailheadにも以下のように記載されていますが、バランスを維持するのは難しいです。バランスをコードでは表現できないので、やるとしてもApex外部にコーディングルールとして明文化するなどすることになりそうです。
多くの場合は、プロジェクトが進むにつれて、getAccountByIdがSELECTする項目は最大公約数的に増えて行きそうですし。実際に見てきたプロジェクトでも概ねそうなっていました。
つまり、関心事の一つ「照会されるデータの予測可能性」の実装はかなり難しそうな気がします。
勉強会で話し合った結論としては、セレクターレイヤーでオブジェクト単位の共通化は割り切って諦めてサービスやドメインレイヤーがわに記述するのが良いのではということになりました。
すべての項目をSELECTしてしまえばよいのでは?
SQLには無い、SOQLの便利機能としては、参照項目を辿ってSELECTすることができます。
ただし、これが逆に共通化には厄介です。
前節の問題を解決するためにセレクタレイヤではオブジェクトのすべての項目を返すように決めたとします。
getAccountByIdの場合は、以下のようなクエリーを実行するわけです。
SELECT FIELDS(ALL) FROM Account WHERE Id = :recordId
しかし、Owner.Nameが必要な場合には対応できません。
また、サブクエリーも同様で「Apex でのサービスレイヤの原則の適用」で紹介されている以下ようなサブクエリーを使用するSOQLが必要な場合は、確実に専用メソッドが生えそうですし、使い回される可能性は低そうです。
SELECT Amount, (SELECT UnitPrice FROM OpportunityLineItems)
FROM Opportunity WHERE Id IN :opportunityIds
項目レベルセキュリティのハンドリングは厄介
Apexの実装ではオブジェクト・項目レベルセキュリティの考慮が必須です。
機能によってはセキュリティのチェックが不要なケースもありますが、全く考慮しないわけにはいきません。
Spring '23ではUser ModeがGAとなる予定で項目レベルセキュリティのチェック自体は楽になります。
しかし、項目レベルセキュリティを考慮したgetAccountByIdを利用する場合にSELECT句に指定されていても返されるsObjectインスタンスには項目がないかもしれません。
VisualforceやRecordEditFormを使用する場合は、それが考慮されていますが、それ以外の処理ではFLSをチェックする必要がある場合は、サービス・ドメインレイヤーで考慮する必要があり、それをハンドリングするコードは煩雑になりがちです。
どちらかというとセレクタレイヤー自体の話というよりは、セレクタレイヤーとそれを呼び出す側はとても密になってしまう例なので本筋からは離れてしまう話だったかもしれません。
POJOを返すのはどうか?
Trailheadモジュールの「デザインの考慮事項」ではgetAccountByIdがsObjectじゃなくてPOJOを返すのもありだよと言っています。
「さまざまなリレーションから項目を選択する場合」は、値がセットされていない参照項目にアクセスされた場合はLazyローディングするように実装するのかもしれないですが、ガバナ制限があるので汎用性はなく、一括処理への対応もできないため注意が必要です。
Apexでも任意のオブジェクトに汎用的に対応しないとならないような限定的なケースでは、DAOパターンによってORマッピングを実装しないとならないケースがあるので、そういったケースには当てはまりそうです。
確かに集計クエリーが返すAggregateResultをわかりやすくラップするというのはありそうですが、集計クエリーを使い回す機会はほぼないためセレクタレイヤーで共通化するメリットは無さそうです。
社内で上がったセレクタレイヤーに求める要件
これまでApexでセレクタレイヤを実装する難しさを解説してきましたが、それを踏まえた要件(セレクタレイヤーに求める責務)を改めて洗い出してみました。
セキュリティチェック (CRUD/FLS) のチェックロジックの共通化
安全に動的クエリーを生成できる
Apexテストでオブジェクト取得メソッドにスタブを注入できる
SELECTする項目を外から指定可能など、柔軟なクエリービルダーとして利用できる
これらの要件を実現するクラスを実装してみたので、以下のリポジトリに公開しています。
https://github.com/co-meeting/apex-selector-pattern-sample
2時間の勉強会で実装したものなのでそのまま使えるコードではないのであくまで参考までです。
実装にあたって割り切った点は以下です。
ドメイン (オブジェクト) 単位のレイヤーを担当するクラスは実装しない
取得する項目と条件はロジックに強く結びつき共通化が難しいためあえてサービスやドメインクラス内で局所的に実装する
addSelectFieldなどはthisを返したいが、自身を返すメソッドのスタブが難しいため諦める
サンプル実装の解説
上記要件を元に、オブジェクトを限定しないSObjectSelectorクラスを実装しました。
SObjectSelectorのコードはGithubリポジトリを見ていただくとして、それを利用するLeadServiceクラスについて解説します。
以下のconvertLeadメソッドは、指定されたIdのレコードを取得して取引先と取引先責任者に変換します。
public Account converLead(Id leadId) {
Lead lead = getLead(leadId); // レコードの取得
Account acc = new Account(
Name = lead.Company,
BillingPostalCode = lead.PostalCode,
BillingCountry = lead.Country,
BillingState = lead.State,
BillingCity = lead.City,
BillingStreet = lead.Street,
Phone = lead.Phone
);
insert acc;
Contact con = new Contact(
AccountId = acc.Id,
LastName = lead.LastName,
FirstName = lead.FirstName,
Title = lead.Title,
Phone = lead.Phone
);
insert con;
return acc;
}
レコードの取得はプライベートメソッドのgetLeadを呼び出します。
ここでSObjectSelectorが登場します。SObjectSelectorはSObjectSelectorFactoryのnewSObjectSelectorメソッドで生成します。
SOQLは使い回さないという原則の元、getLeadは他のクラスから呼び出されないようにprivateとしています。ただ、実際に発行されるクエリーと結果が正しいことは確認をしたいので、@TestVisibleアノテーションを付与しています。
@TestVisible
private Lead getLead(Id leadId) {
SObjectSelector selector = this.selectorFactory.newSObjectSelector('Lead');
selector.addSelectField('Id');
selector.addSelectField('LastName');
...
selector.addSelectField('Street');
sObject[] records = selector.query();
if (records.isEmpty()) {
return null;
} else {
return (Lead) records[0];
}
}
次にApexテストです。Apexテストでは、メインロジックはオブジェクトに直にアクセスせずに実行できるようにSelectorのスタブを使用します。
スタブは、SObjectSelectorFactory用のSObjectSelectorFactoryStubProviderクラスとSObjectSelectorのためのSObjectSelectorStubProviderを用意しています。
LeadServiceTestのテストメソッドconverLeadは以下のような実装となっています。
static void converLead() {
Lead testLead = new Lead(
LastName = '山田',
FirstName = '太郎',
...
Street = '築地1丁目1−1'
);
SObjectSelectorFactoryStubProvider provider = new SObjectSelectorFactoryStubProvider();
provider.addQueryResult(new List<Lead>{ testLead });
Test.startTest();
LeadService service = new LeadService(
(SObjectSelectorFactory) provider.createStub()
);
Account acc = service.converLead(Id.valueOf('0066D000006uNQWQA2'));
Test.stopTest();
Assert.areEqual(acc.Name, testLead.Company);
...
Leadのテストデータはinsertせずにスタブに渡し、LeadService.getLeadのSObjectSelectorクラスのqueryメソッドが呼ばれるとそれを返します。
リポジトリには、Trailheadモジュールに沿ったOpportunityServiceクラスも含めました。
こちらのapplyDiscountsメソッドは、レコードのupdateをSObjectUnitOfWorkクラスを介して実行します。UnifOfWorkクラスを導入することによってDML操作もスタブ化することが可能になっています。
UnitOfWorkは今回の対象ではなかったので、詳細は別の機会に議論したいところです。
まとめ
ここまでまとめてきましたが、実際のところ実プロジェクトに導入するにはもう少し詰める必要がありそうです。
この記事を書きながら社内で、要件の一つ「Apexテストでオブジェクト取得メソッドにスタブを注入できる」はよほど大きなプロジェクトではない限りはToo Muchだよねえ。というような話もしていました。
そうするとシンプルなクエリービルダーがあれば十分なのかもしれません。今回紹介した内容は「Apexテストの実行時間が5分以上かかるようになってきた。」など、開発に支障がでてきそうになってから導入しても遅くないかもしれません。
どちらかというと、この記事の本題では無いですが、コードには登場するUnitOfWork(作業単位)の方は汎用的なクラスを実装しやすく実用性がありそうです。次はこの辺りも議論してみたいところです。
10月11月に議論したApexクラスの命名規則についても紹介する予定ですのでお楽しみに。