Salesforce Apexクラスのベストプラクティス:パフォーマンスと拡張性のための開発者ガイド

執筆者:Salesforce 開発者 (Salesforce Developer)


背景と応用シーン

Salesforceプラットフォームで働く私たち開発者にとって、Apexクラス (Apex Class) は最も強力なツールの一つです。Apexは、Salesforceのバックエンドで実行される、強く型付けされたオブジェクト指向プログラミング言語であり、ビジネスロジックの実行、データの操作、外部システムとの連携など、標準機能だけでは実現できない複雑な要件を実装するために不可欠です。

Apexクラスの主な応用シーンは多岐にわたります:

・トリガロジックの管理 (Trigger Logic Management): レコードの作成、更新、削除といったデータベースイベントに応じて、複雑なビジネスロジックを実行します。ベストプラクティスとして、トリガ本体にはロジックを記述せず、Apexクラスで実装したハンドラメソッドを呼び出すことが推奨されます。

・カスタムコントローラ (Custom Controllers): VisualforceページやAuraコンポーネント、Lightning Web Components (LWC) のサーバーサイドコントローラとして機能し、ユーザーインターフェースとデータのやり取りを制御します。

・バッチ処理 (Batch Apex): 何百万件もの大量のレコードを非同期で処理する必要がある場合、ガバナ制限を回避しながら効率的にデータを処理するために使用されます。

・REST/SOAP APIの提供: 外部システムがSalesforceのデータやロジックにアクセスするためのカスタムAPIエンドポイントを作成します。

・非同期処理 (Asynchronous Apex): FutureメソッドやQueueable Apexを利用して、時間のかかる処理をバックグラウンドで実行し、ユーザーエクスペリエンスを向上させます。

しかし、Apexの強力さゆえに、その設計と実装には細心の注意が必要です。特にSalesforceがマルチテナント環境であるという特性上、すべての開発者はガバナ制限 (Governor Limits) と呼ばれるリソースの消費制限を遵守しなければなりません。非効率なコードは、この制限に抵触しやすく、システムのパフォーマンス低下やエラーの原因となります。本記事では、私たちSalesforce開発者が堅牢でスケーラブルなApexクラスを記述するための、原理とベストプラクティスについて、具体的なコード例を交えながら解説します。

原理説明

効率的なApexクラスを記述するための核心は、「一括処理(Bulkification)」と「ガバナ制限の理解」にあります。これらはSalesforce開発における最も重要な概念と言っても過言ではありません。

ガバナ制限(Governor Limits)の理解

Salesforceは、単一のインフラストラクチャを複数の顧客(テナント)で共有するマルチテナントアーキテクチャを採用しています。これにより、低コストで安定したサービスを提供できる一方、ある一人のユーザーが悪意のある、あるいは非効率なコードを実行することで、共有リソースを独占し、他のユーザーのパフォーマンスに影響を与えるリスクがあります。

このリスクを回避するために、SalesforceはApexの実行トランザクションごとに厳格なリソース制限、すなわちガバナ制限を設けています。主なガバナ制限には以下のようなものがあります。

・SOQLクエリの発行回数: 1トランザクションあたり100回(同期処理)

・DMLステートメントの発行回数: 1トランザクションあたり150回

・合計CPU時間: 1トランザクションあたり10,000ミリ秒(同期処理)

・ヒープサイズ: 1トランザクションあたり6MB(同期処理)

これらの制限を超えると、コードは実行時エラー(System.LimitException)をスローし、トランザクション全体がロールバックされます。したがって、私たちのコードは、常にこれらの制限内で動作するように設計されなければなりません。

一括処理(Bulkification)の重要性

ガバナ制限を遵守するための最も重要なテクニックが一括処理 (Bulkification) です。一括処理とは、単一のレコードではなく、レコードのコレクション(List, Set, Map)を一度に処理するようにコードを設計することを指します。

例えば、データローダや一括更新機能によって一度に最大200件のレコードがトリガを起動させることがあります。もしトリガのロジックが1件のレコードしか想定していない場合、レコードごとにSOQLクエリやDMLステートメントを発行してしまい、あっという間にガバナ制限に達してしまいます。

一括処理を実装する基本原則は非常にシンプルです:「ループの中でSOQLクエリやDMLステートメントを実行しない」

代わりに、以下の手順で実装します。

1. ループを開始する前に、必要なIDやデータをSetやListに収集します。 2. ループの外で、収集したデータを使って一度だけSOQLクエリを実行し、関連データを取得します。取得したデータはMapに格納すると、後の処理で高速にアクセスできます。 3. ループ内で、Mapから関連データを参照しながらロジックを実行し、更新が必要なレコードを新しいListに格納します。 4. ループの終了後、更新用Listに溜まったレコードを一度のDMLステートメントでデータベースに書き込みます。

この設計パターンに従うことで、処理するレコード数に関わらず、SOQLやDMLの実行回数を最小限に抑えることができ、スケーラブルなアプリケーションを構築することが可能になります。

示例代码

ここでは、取引先(Account)が更新されたときに、関連するすべての商談(Opportunity)の説明(Description)を更新するというシナリオを考えます。まず、一括処理を考慮していない悪い例を見てみましょう。

悪い例:ループ内でのSOQLとDML

このコードは、1件の取引先が更新された場合は動作しますが、複数の取引先が同時に更新されると、すぐにガバナ制限に抵触します。

// このコードはアンチパターンです。絶対に使用しないでください。
trigger AccountTrigger on Account (after update) {
    for (Account acc : Trigger.new) {
        // ループ内でSOQLクエリを実行しています。
        // 100件の取引先が更新されると、100回のSOQLが実行され、ガバナ制限を超えます。
        List<Opportunity> opps = [SELECT Id, Description FROM Opportunity WHERE AccountId = :acc.Id];
        
        for (Opportunity opp : opps) {
            opp.Description = 'Account was updated. New description: ' + acc.Description;
        }
        
        // ループ内でDMLステートメントを実行しています。
        // 同様に、ガバナ制限に抵触する原因となります。
        update opps;
    }
}

良い例:一括処理(Bulkification)されたコード

次に、同じ要件をベストプラクティスに従って実装した例です。トリガからハンドラクラスを呼び出す設計パターンを採用しています。

トリガ (AccountTrigger.trigger)

トリガはロジックを持たず、ハンドラクラスのメソッドを呼び出すだけです。

trigger AccountTrigger on Account (after update) {
    AccountTriggerHandler.handleAfterUpdate(Trigger.new, Trigger.oldMap);
}

ハンドラクラス (AccountTriggerHandler.cls)

実際のロジックはこのクラスに実装されます。このコードはSalesforce公式ドキュメントの「Trigger and Bulk Request Best Practices」の例を参考にしています。

public class AccountTriggerHandler {
    public static void handleAfterUpdate(List<Account> newAccounts, Map<Id, Account> oldAccountMap) {
        // 更新対象の商談を格納するリストを初期化
        List<Opportunity> opportunitiesToUpdate = new List<Opportunity>();

        // 1. 関連する商談を一度に取得するための取引先IDを収集
        //    Descriptionが変更された取引先のみを対象とする
        Set<Id> accountIds = new Set<Id>();
        for(Account acc : newAccounts) {
            // 新旧のDescriptionを比較し、変更があった場合のみIDをSetに追加
            if (acc.Description != oldAccountMap.get(acc.Id).Description) {
                accountIds.add(acc.Id);
            }
        }

        // 収集したIDが空でなければ、クエリを実行
        if (!accountIds.isEmpty()) {
            // 2. ループの外で一度だけSOQLクエリを実行
            //    関連するすべての商談を一度に取得
            Map<Id, Account> accountsWithOpps = new Map<Id, Account>([
                SELECT Id, Name, Description, (SELECT Id, Description FROM Opportunities)
                FROM Account
                WHERE Id IN :accountIds
            ]);

            // 3. ループ内でロジックを実行し、更新用リストにレコードを追加
            for (Id accId : accountsWithOpps.keySet()) {
                Account updatedAccount = accountsWithOpps.get(accId);
                // 取得した取引先に紐づく商談をループ処理
                for (Opportunity opp : updatedAccount.Opportunities) {
                    // 商談の説明を更新
                    opp.Description = 'Account was updated. New description: ' + updatedAccount.Description;
                    // 更新対象の商談をリストに追加
                    opportunitiesToUpdate.add(opp);
                }
            }
        }

        // 4. ループの終了後、更新用リストが空でなければ一度だけDMLステートメントを実行
        if (!opportunitiesToUpdate.isEmpty()) {
            update opportunitiesToUpdate;
        }
    }
}

この良い例では、処理する取引先の件数に関わらず、SOQLクエリとDMLステートメントはそれぞれ最大1回しか実行されません。これにより、ガバナ制限を遥かに下回るリソース消費で、大量のデータを安全に処理できます。

注意事項

権限とセキュリティ (Permissions and Security)

Apexクラスは、デフォルトではシステムコンテキストで実行され、実行ユーザーの項目レベルセキュリティやオブジェクト権限を無視します。これは意図しないデータアクセスを引き起こす可能性があるため、セキュリティを考慮したコーディングが不可欠です。

クラス定義時に共有キーワード(sharing keyword)を指定することで、実行コンテキストを制御できます。

・`with sharing`: 実行ユーザーの共有ルールが適用されます。ユーザーがアクセス権を持つレコードのみが操作対象となります。原則として、ユーザーの操作に応じて直接実行されるクラスにはこれを指定すべきです。

・`without sharing`: 共有ルールを無視し、すべてのレコードにアクセスできます。レコードの所有者に関わらず集計処理などを行う場合に有用ですが、権限昇格に繋がる可能性があるため、慎重に使用する必要があります。

・`inherited sharing`: 呼び出し元のクラスの共有設定を継承します。コンポーネントのコントローラなど、再利用性を高めたい場合に適しています。

API制限 (API Limits)

ガバナ制限はApexトランザクションの文脈で語られることが多いですが、Salesforceプラットフォーム全体で共有されるリソース制限の一部です。特に外部システムとの連携を実装する際は、コールアウトの回数や時間、24時間あたりのAPIコール数なども考慮に入れる必要があります。

エラー処理 (Error Handling)

堅牢なアプリケーションを構築するためには、適切なエラーハンドリングが不可欠です。`try-catch`ブロックを使用して、予期せぬ例外(`LimitException`, `DmlException`など)を捕捉し、適切に処理(ログ記録、ユーザーへの通知など)することが重要です。

特にDML操作では、`Database`クラスのメソッド(例: `Database.update(records, allOrNone)`)を使用することで、一部のレコードでエラーが発生した場合の動作を制御できます。`allOrNone`パラメータを`false`に設定すると、成功したレコードのみがコミットされ、エラーが発生したレコードの情報を`Database.SaveResult`オブジェクトから取得できます。これにより、より柔軟なエラー処理が可能になります。

まとめとベストプラクティス

本記事では、Salesforce開発者として、スケーラブルでパフォーマンスの高いApexクラスを記述するための基本原則とベストプラクティスを解説しました。最後に、重要なポイントを再確認しましょう。

1. 常にコードを一括処理(Bulkify)する: トリガやクラスが一度に複数のレコードを処理できることを前提に設計します。

2. ループ内でのSOQL/DMLは絶対に避ける: これがガバナ制限違反の最も一般的な原因です。

3. 効率的なSOQLクエリを作成する: `WHERE`句でインデックス付き項目を使用してセレクティブなクエリを書き、不要なデータを取得しないようにします。

4. Mapを活用してデータアクセスを最適化する: 関連レコードをIDをキーとするMapに格納することで、ループ内でのネストされたクエリや非効率な検索を回避できます。

5. ガバナ制限を深く理解する: 自身のコードがどのリソースをどれだけ消費するかを常に意識します。デバッグログの`Limits`情報を活用して、パフォーマンスを分析しましょう。

6. 共有モデルを意識したセキュリティを実装する: `with sharing`や`inherited sharing`を適切に使用し、最小権限の原則に従います。

7. 包括的なテストクラスを作成する: 単体テストは、コードカバレッジ75%を満たすためだけのものではありません。一括処理シナリオ、境界値、ポジティブ/ネガティブケースを網羅する、品質保証のための重要なプロセスです。

これらの原則を日々の開発業務に適用することで、私たち開発者は、顧客のビジネス要件を満たすだけでなく、長期間にわたって安定して動作する、高品質なSalesforceアプリケーションを構築することができるのです。

コメント