1
1

VFページ経由でビューステートエラーを回避して大容量CSVをSalesforceに登録する方法

Last updated at Posted at 2024-07-05

普通にAPIで叩けばいいんじゃないのって話なんですが、APIコール数の問題だったり、そもそもお金がなかったり 、要件として度々話題に上がるので、自分のデベロッパー環境で試行錯誤したことをここにまとめます。

そもそもVisualForceページ経由だとなにが問題になるのか

よく起きるエラーはこの辺:

  • ビューステートエラー(Apexクラスに値を渡すときに落ちる)
  • ヒープサイズエラー or CPUタイムアウトエラー(取得したデータの処理中に落ちる)
  • <apex:form>のファイルインプット上限が5MB
    ※LWCならlightning-file-uploadで回避できる
     しかし、それで実装出来ればこの記事を2024年に書いていないのである
  • 文字コード変換問題(SJIS→UTF8やUTF8→SJISなど)

動いているサンプルコード

ApexクラスだけではどうしようもなかったのでJavascriptで書きました。
とりあえず120MB程度のSJISのCSVファイルで問題なく動いているコードを置いておきます。
みんな大好きKenAll.csvでチェックしてます。

VisualForceページ

使ったNPM=>jQuery,encord.js,PapaParse

test.page
<apex:page docType="html-5.0" id="page_id" controller="csvInputCon">
    <apex:includeScript value="{!URLFOR($Resource.jQuery)}" />
    <apex:includeScript value="{!URLFOR($Resource.encord)}" />
    <apex:includeScript value="{!URLFOR($Resource.PapaParse)}" />
    <script>
        const MAX_STRINGS = 1000000;

        function parseCSV(file) {
            return new Promise((resolve, reject) => {
                var parsedData = [];
                Papa.parse(file, {
                    header: false, // ヘッダー行がある場合はtrue
                    encoding: 'Shift-JIS',
                    complete: function (results) {
                        parsedData = results.data;
                        var row = parsedData[0];
                        var confirmed = confirm('インポートを開始しますか?');
                        if (confirmed) {
                            resolve(parsedData);
                        }else{
                            return;
                        }
                    }
                });
            })
        }

        // toStringでUNICODE(UTF8)に変換
        const convert2String = function (file) {
            const encoding = Encoding.detect(file);
            var unicodeString = Encoding.convert(file, {
                to: 'UNICODE',
                from: encoding
            });
            var utf8String = unicodeString.toString();
            return utf8String;
        }

        // APEX実行
        const REMOTEACTION_OPTIONS = { buffer: false, escape: true, timeout: 120000 };
        function postApex(data) {
            return new Promise((resolve, reject) => {
Visualforce.remoting.Manager.invokeAction('{!$RemoteAction.csvInputCon.execBatch}',
                    data,
                    (result, event) => {
                        if (event.status){
                            resolve(result);
                        }else {
                            alert("CSV取り込みが失敗しました");
                            reject(event);
                            return;
                        }
                    }, this.REMOTEACTION_OPTIONS);
            });
        }

        // APEXコントローラーへ値を渡す処理
        async function sendCSVData2Controller(_file) {
            const noOfChunks = _file.length;
            const promises = [];
            var convertedData = '';
            for (var i = 0; i < noOfChunks; i++) {
                if (_file[i].length > 1) {
                    convertedData += await convert2String(_file[i]) + '\n';
                    if (convertedData.length >= MAX_STRINGS) {
                        var orderPromise = await postApex(convertedData);
                        convertedData = '';
                        promises.push(orderPromise);
                    }
                }
            }
            if (convertedData .length >= 0) {
                var lastorderPromise = await postApex(convertedData);
                promises.push(lastorderPromise );
            }
            const result = Promise.all(promises);
            return (console.log("JS処理終了"), alert("CSV取り込みが完了しました"));
        }

        // async処理全体概要
        async function main(file) {
            console.log("JS処理開始");
            var parsedCSV = await parseCSV(file);
            const result = sendCSVData2Controller(parsedCSV);
            console.log("APEX処理開始");
        }
        main();

        // 取得データチェック処理
        function checkFile() {
            const fileinput = document.getElementById('file_input');
            const file = fileinput.files[0];
            if (typeof file === "undefined") {
                alert("CSVファイルが選択されていません。");
                return;
            } else {
                main(file);
            }
        }
    </script>
    <input type="file" id="file_input" name="attFile" accept=".csv" />
    <br />
    <button onclick="checkFile(); return false;" value="CSV読み込み">Upload File</button>
</apex:page>
  • 一時ファイルをContentVersionあたりにテンポラリーで置いて後で結合という挙動もMAX5MBまでなら可能
  • 読み込み中にページを更新すると当たり前だけど処理が落ちるのでLoadingOverlay.js辺りでグルグル表示させると良さそう
  • 実際は静的リソースなりに格納して使うと可読性高いかな?
  • InputはJavascriptのフォームで用意してます
    <input type="file" id="file_input" name="attFile" accept=".csv" />

Apexクラス周り

VFのコントローラー

csvInputCon.cls
public  with sharing class csvInputCon{
    
    public csvInputCon(){
    }
    
    // CSV読み込み
    @RemoteAction
    public static void execBatch(String dataStr){
        CSVBatch batch = new CSVBatch(dataStr);
        Database.executeBatch(batch, 100);
    }
}
  • JavascriptRemotingで渡せる最大文字数は1,000,000文字まで。オーバーすると「input too long」エラーになります

バッチクラス

CSVBatch.cls
global with sharing class CSVBatch implements Database.Batchable<String>, Database.Stateful{
    private String csvFile;
    private static final String CRLF = '\r\n';

     public CSVBatch (String dataStr) {
        this.csvFile = dataStr;
     }
    
    global Iterable<String> start(Database.batchableContext context) { 
        return new CSVIterator(csvFile, CRLF);
    }

    global void execute(Database.BatchableContext batchableContext, List<String> scope){
        // execでバッチで行いたい処理を実行
    }

    global void finish(Database.BatchableContext BC){
        // finishで行いたい処理を実行
    }
}
CSVIterator.cls
global with sharing class CSVIterator implements Iterator<String>, Iterable<String> {
    
    Private String csvData;
    private String rowDelimiter;
    
    public CSVIterator(String dataStr, String rowDelimiter) {
        this.rowDelimiter = rowDelimiter;
        this.csvData= dataStr;
    }
    
    global Boolean hasNext() {
        return csvData.length() > 1 ? true : false;
    }
    
    global String next() {
        String row = this.csvData.subString(0, this.csvData.indexOf(this.rowDelimiter));
        this.csvData = this.csvData.subString(
            this.csvData.indexOf(this.rowDelimiter) + this.rowDelimiter.length(),
            this.csvData.length()
        );
        return row;
    }
    
    public Iterator<String> Iterator() {
        return this;
    }
}
  • Iteratorクラスは、大容量CSV処理の場合は必須っぽい(Splitだと落ちます)
  • もしCSVの値をごにょごにょしたい場合はParseクラスも別で持たないと、「regex too complicated」で落ちるので注意!

参考引用したブログ

一部丸パク引用させていただきました。いつも大変お世話になっております。
ちなみに、ダブルクオーテーション対応はこちらのコードでばっちりできてます。PapaParse様様です。感謝感謝!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1