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

普通に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
```apex: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のコントローラー
```apex: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」エラーになります

#### バッチクラス
```apex: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で行いたい処理を実行
    }
}
```

```apex: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様様です。感謝感謝！

https://qiita.com/hrk623/items/55cb885f40f402983a8f

https://note.com/furucrm/n/n31f932503669

https://dev.classmethod.jp/articles/node-js-divide-files

https://dackdive.hateblo.jp/entry/2015/03/01/175751