LightningComponent開発におけるApexのエラーハンドリング方法

皆さん、師走の忙しい時期いかがお過ごしでしょうか。
間もなくSalesforce国内最大のイベントとなるSalesforce World Tour Tokyoが開催されますが、人によってはこちらの準備に追われている方も多いでしょう。
私は虎ノ門のDevZoneでちょこっとだけLTをやることになりましたが、基本的には参加側のスタンスなので(イベントに関しては)気楽なもんです。
基本的に虎ノ門側に引きこもってセッション出たり、トレイルしてみたり楽しむつもりです。
もし見かけた方がいらっしゃればぜひお声掛けください。

そんな話はさておき、さっそくLightningのサーバーサイド(Apex)処理のエラーハンドリングについて書きたいと思います。

Lightningからサーバーサイド処理を呼び出す方法

LightningはSPAに最適化されたフレームワークです。
クライアントサイドのController.js、Helper.jsを中心に処理を記述しますが、オブジェクトのレコードを取得・更新したり、何かしらサーバーサイドで処理をする必要がある場合はApexを使うことになります。
具体的にはLightningの$A.enqueueAction()を使ってサーバーサイドのApexControllerのメソッドを呼び出し、結果のコールバックを受け取ります。

以下の例はコンポーネントからController.js⇒Apexを呼び出すシンプルな処理です。

  1. コンポーネントでボタンが押される
  2. クライアントサイドのController.jsからサーバーサイドのApexControllerを呼ぶ
  3. ApexのUserInfoで実行者の名前を取得して返す
  4. クライアントサイドでコールバックを受け取り、画面にレスポンスを表示する

Component

<aura:component controller="SampleServerSideController">
    <aura:attribute name="message" type="String"/>
    <ui:button label="実行" press="{!c.validate}"/>
    <div>
        ResultMessage:<ui:outputText value="{!v.message}"/>
    </div>
</aura:component>

Controller.js

({
    validate : function(cmp, evt) {
        var action = cmp.get("c.getServerSideMessage");
        action.setCallback(this, function(a) {
            cmp.set("v.message", a.getReturnValue());
        });
        $A.enqueueAction(action);
    }
})

ApexController

public class SampleServerSideController {
    
    @AuraEnabled
    public static String getServerSideMessage(){
        return UserInfo.getLastName();
    }
}

実行結果

ResultMessageとしてサーバーで取得したユーザーの名前が表示されました。

ApexでExceptionが発生した場合どうなるか

では、ApexでExceptionが発生した時、クライアントサイドではどうなるでしょうか?
まずは何もエラーハンドリングしないでエラーだけ発生させてみます。

ApexController

public class SampleServerSideController {
    
    @AuraEnabled
    public static String getServerSideMessage(){
        String str = null;
        str.length(); // throw ぬるぽ
        return UserInfo.getLastName();
    }
}

画面には何も表示されませんし、ブラウザのコンソールログを見ても何も出ていません。Apexのデバッグログを見るとちゃんと(?)「System.NullPointerException: Attempt to de-reference a null object」が発生しています。

これがもし正式なアプリケーションである場合、ユーザはエラーが起こったことに気づくことが出来ず、本当は必要だった処理も行われないままになってしまいます。
これはよくないですね。

クライアントサイドでエラーをハンドリングする

Lightningではサーバーサイドのコールバックに処理結果のステータスとエラーがあった場合のメッセージを持っているので、今度はちゃんとそれを見てメッセージを画面に表示してみましょう。

Controller.js

({
    validate : function(cmp, evt) {
        var action = cmp.get("c.getServerSideMessage");
        action.setCallback(this, function(a) {
            if (a.getState() === "SUCCESS") {
                cmp.set("v.message", a.getReturnValue());
            } else if (a.getState() === "ERROR"){
                var errors = action.getError();
                if (errors) {
                    if (errors[0] && errors[0].message) {
                        cmp.set("v.message", errors[0].message);
                    }
                }
            }
        });
        $A.enqueueAction(action);
    }
})

今度は画面にエラーメッセージが表示されました。
ただ、ApexのExceptionとしてはぬるぽが発生しているはずですが、返ってきているメッセージは内部サーバエラーです。
理由は分かりませんが、どうもLightningではサーバーサイドのシステム系のExceptionは全て「内部サーバエラー」が返ってくるようです。
仕様でしょうか・・

ちなみにDMLExceptionではどうなるか試してみましょう。

ApexController

public class SampleServerSideController {
    
    @AuraEnabled
    public static String getServerSideMessage(){
        Account acc = new Account();
        insert acc; // throw DMLException(必須のNameを入れていない)
        return UserInfo.getLastName();
    }
}

またもや画面に何も表示されません。先ほどController.jsにエラーステータスを見てメッセージを入れるようにしたのになぜでしょうか?
この原因を探るべくコンソールログを使ってcallbackで返ってくるエラーオブジェクトの中身を見てみます。

先ほどのぬるぽの時はerrors[0].messageでエラーメッセージを取得していましたが、DMLExceptionの場合はエラーオブジェクトの構造が違うようです。
DMLなので複数レコードに対するエラーが返りますし、1レコード内でも複数項目に対してのエラーがあります。この辺がpageErrorsやfieldErrorsに入っているようです。

では、それらを考慮したController.jsにしてみます。

Controller.js

({
    validate : function(cmp, evt) {
        var action = cmp.get("c.getServerSideMessage");
        action.setCallback(this, function(a) {
            if (a.getState() === "SUCCESS") {
                cmp.set("v.message", a.getReturnValue());
            } else if (a.getState() === "ERROR"){
                var errors = action.getError();
                if (errors) {
                    if (errors[0] && errors[0].message) {
                        // システムエラー対応
                        cmp.set("v.message", errors[0].message);
                    } else if (errors[0] && errors[0].pageErrors) {
                        // DMLエラー対応
                        // (このコードは手抜きで複数件、複数項目のエラーの場合は考慮外になってます)
                        cmp.set("v.message", errors[0].pageErrors[0].message);
                    }
    
                }
            }
        });
        $A.enqueueAction(action);
    }
})

今度はちゃんとDMLのエラーメッセージが表示されました。
そしてメッセージを見てみると、システム例外とは異なり「内部サーバエラー」とはなっていません。

ここまで検証して以下のことが分かりました。

  • サーバーサイドのシステム例外は「内部サーバエラー」が返ってくる
  • DMLの例外はちゃんとエラーメッセージが返ってくる
  • システム例外とDML例外でエラーオブジェクトの構造が異なる

では、どうするか

前置きが長くなりましたが、ここからが本題です!

クライアントサイドで詳細なエラーハンドリングをしようとしてみましたが「内部サーバエラー」など詳細なメッセージが返ってこないパターンがあります。DMLエラーはとれそうですが、そもそもどういったパターンが内部サーバエラーに該当して、あるいはどういったパターンはちゃんとメッセージが返ってくるのか仕様として不明です。
またエラーオブジェクトの構造についても、少なくともLightningの開発リファレンスには見当たりませんでした。
まだリリースされて間もないこともあるせいか「Lightningのエラーハンドリングはこうあるべきだ」というのも公式には見当たりません。

その辺を考えると、サーバーサイドのExceptionはきちんとハンドリングして、戻り値として返してあげる方法が良いのではないでしょうか。というか、サーバーサイドでtry-catchするってのは当たり前といえば当たり前ですね。

戻り値として返す方法としてはMapやオブジェクト、あるいはStringのJSONで返す方法などありますが、今回はレスポンス用の内部クラスを使ってみます。

次はその辺を踏まえたコードです。

ApexController

public class SampleServerSideController {
    
    @AuraEnabled
    public static ResponseDto getServerSideMessage(){
        try{
            // 何かしらの処理
            
        } catch(Exception e){
            // エラーをレスポンス用のオブジェクトに突っ込む
            ResponseDto res = new ResponseDto(false, 'システムエラーが発生しました:' + e.getMessage());
            return res;
        }
        
        // 正常終了としてレスポンス用のオブジェクトを作成し、戻り値をMapに入れる
        ResponseDto res = new ResponseDto(true, '');
        res.values.put('lastName', UserInfo.getLastName());
        return res;
    }
    
    public class ResponseDto {
		@AuraEnabled public Boolean isSuccess { get; set; }
		@AuraEnabled public String message { get; set; }
		@AuraEnabled public Map<Object, Object> values { get; set; }
        
		public ResponseDto(Boolean isSuccess, String msg){
			this.isSuccess = isSuccess;
			this.message = msg;
			this.values = new Map<Object, Object>();
		}
    }
}

Controller.js

({
    validate : function(cmp, evt) {
        var action = cmp.get("c.getServerSideMessage");
        action.setCallback(this, function(a) {
            // レスポンス用のオブジェクトを取得
            var response = a.getReturnValue();
            
            if (action.getState() == "SUCCESS" && response.isSuccess) {
                // 正常終了したパターン
                cmp.set("v.message", response.values.lastName);
                
            } else if (action.getState() == "ERROR" || !response.isSuccess) {
                var errors = a.getError();
                if (errors[0] && errors[0].message) {
                    // サーバーサイドでcatchできなかったパターン
                    cmp.set("v.message", errors[0].message);
                } else if ( !response.isSuccess ) {
                    // サーバーサイドでcatchしたパターン
                    cmp.set("v.message", response.message);
                }
            }
        });
        $A.enqueueAction(action);
    }
})

Apex側では戻り値用の内部クラスを作り、エラー時または成功時にオブジェクトを生成して必要な情報を入れて返します。内部クラスの各項目にLightning側からアクセスする場合は@AuraEnabledのキーワードを付ける必要がある点に注意してください。

Controller.js側ではcallbackで戻り値を受け取り、正常終了、異常終了を判断します。
この時考慮する必要があるのが、Apex側でエラーをキャッチできなかったパターンです。

これは単にApex側でハンドリングを忘れるというものだけではなく、キャッチできないエラーであるガバナ制限(またはアサーションエラー)を考えた場合です。残念ながらガバナ制限はキャッチできない上に、クライアントサイドでは「内部サーバエラー」になるので詳細なエラー情報は分かりません。今のところdebugログでも仕込むくらいしか思いつきません。

DMLのエラーハンドリングについては今回のコードでは考慮していませんが、insertやupdateなどのDMLステートメントではなく、Databaseメソッドのinsertやupdateを使い、SaveResultから詳細なエラーを取得してresponseに詰めると良いでしょう。

まとめ

Lightningでのサーバーサイドのエラーハンドリングは、Apex側でthrowされたものをController.jsで見るよりかは、きちんとサーバーサイドでハンドリングしてあげて、そして結果をレスポンスとして返してあげると良いでしょう。
適切なエラー処理をすることで、アプリケーションを利用するユーザを次のアクションに誘導してあげることができます。もちろん画面にどこまで詳細なエラーメッセージを出すかという判断は必要ですが、そこを含めてエラー設計をしていきましょう。

なお、この記事はSalesforce App Cloud Advent Calendar 2015の2日目になります。
12月1日~25日まで毎日Salesforce AppCloudに関する記事が投稿される予定なので、ぜひチェックしてみてください。