TimerTrigger/SpringBatch

CSVファイルの内容をDBに書き込む前にファイルの文字コードを判定してみた

バッチ処理でCSVファイルの内容をDBに書き込む際、ファイルの文字コードをチェックしたい場合があるが、その際に、文字コードを判定するライブラリを利用すると便利である。

今回は、juniversalchardetというMozillaによって提供されているライブラリを利用して、ファイルの文字コードを判定し、その文字コードを利用してファイルを読み込むよう修正してみたので、そのサンプルプログラムを共有する。

なお、juniversalchardetについては、以下のサイトを参照のこと。
https://qiita.com/aaaanwz/items/1554afadc44cb5cf3a80

今回のサンプルプログラムでは、上記サイトのサンプル1のプログラムを流用している。

前提条件

下記記事のサンプルプログラムを作成済であること。

TimerTriggerによって動作するAzure Function上でCSVファイルの内容をDBに書き込んでみたTimer Triggerによって、一定時間が来たタイミングでAzure Functionsが動作するアプリケーションを生成し、そのバッ...

作成したサンプルプログラムの修正

作成したサンプルプログラムの構成は以下の通り。
サンプルプログラムの構成
なお、上記の赤枠は、前提条件のプログラムから変更したプログラムである。

pom.xmlに追加した内容は以下の通りで、文字コードを判定するjuniversalchardetというライブラリを追加している。

<!-- 文字コードを判定するための設定 -->
<dependency>
    <groupId>com.googlecode.juniversalchardet</groupId>
    <artifactId>juniversalchardet</artifactId>
    <version>1.0.3</version>
</dependency>

また、文字列のユーティリティクラスは以下の通りで、文字コードを判定するgetCharsetNameというメソッドを追加している。

package com.example.util;

import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;

import org.apache.commons.lang3.StringUtils;
import org.mozilla.universalchardet.UniversalDetector;

public class DemoStringUtil {

    /**
     * 文字列前後のダブルクォーテーションを削除する.
     * @param str 変換前文字列
     * @return 変換後文字列
     */
    public static String trimDoubleQuot(String regStr) {
        if (StringUtils.isEmpty(regStr)) {
            return regStr;
        }
        char c = '"';
        if (regStr.charAt(0) == c && regStr.charAt(regStr.length() - 1) == c) {
            return regStr.substring(1, regStr.length() - 1);
        } else {
            return regStr;
        }
    }

    /**
     * DateTimeFormatterを利用して日付チェックを行う.
     * @param dateStr    チェック対象文字列
     * @param dateFormat 日付フォーマット
     * @return 日付チェック結果
     */
    public static boolean isCorrectDate(String dateStr, String dateFormat) {
        if (StringUtils.isEmpty(dateStr) || StringUtils.isEmpty(dateFormat)) {
            return false;
        }
        // 日付と時刻を厳密に解決するスタイルで、DateTimeFormatterオブジェクトを作成
        DateTimeFormatter df = DateTimeFormatter.ofPattern(dateFormat)
           .withResolverStyle(ResolverStyle.STRICT);
        try {
            // チェック対象文字列をLocalDate型の日付に変換できれば、チェックOKとする
            LocalDate.parse(dateStr, df);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 数値文字列が1桁の場合、頭に0を付けて返す.
     * @param intNum 数値文字列
     * @return 変換後数値文字列
     */
    public static String addZero(String intNum) {
        if (StringUtils.isEmpty(intNum)) {
            return intNum;
        }
        if (intNum.length() == 1) {
            return "0" + intNum;
        }
        return intNum;
    }

    /**
     * 引数のInputStreamから判定した文字コードを返す.
     * @param is InputStreamオブジェクト
     * @return 判定した文字コード
     * @throws IOException
     */
    public static String getCharsetName(InputStream is) throws IOException {
        // 4kBのメモリバッファを確保する
        byte[] buf = new byte[4096];
        UniversalDetector detector = new UniversalDetector(null);

        // 文字コードの推測結果が得られるまでInputStreamを読み進める
        int nread;
        while ((nread = is.read(buf)) > 0 && !detector.isDone()) {
            detector.handleData(buf, 0, nread);
        }

        // 推測結果を取得する
        detector.dataEnd();
        final String detectedCharset = detector.getDetectedCharset();

        detector.reset();

        // 文字コードを取得できなかった場合、環境のデフォルトを使用する
        if (detectedCharset != null) {
            return detectedCharset;
        }
        return System.getProperty("file.encoding");
    }
}



さらに、DemoBatchService.javaの内容は以下の通りで、CSVファイルを読み込む際の文字コードを判定し、その文字コードを利用してファイルを読み込むよう修正している。その際、文字コードがShift_JISだった場合は、MS932として読み込むようにしている。

package com.example.service;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URISyntaxException;
import java.security.InvalidKeyException;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.mybatis.UserDataMapper;
import com.example.mybatis.model.UserData;
import com.example.util.DemoStringUtil;
import com.microsoft.azure.storage.CloudStorageAccount;
import com.microsoft.azure.storage.StorageException;
import com.microsoft.azure.storage.blob.CloudBlobClient;
import com.microsoft.azure.storage.blob.CloudBlobContainer;
import com.microsoft.azure.storage.blob.CloudBlockBlob;

@Service
public class DemoBatchService {

    /* Spring Bootでログ出力するためのLogbackのクラスを生成 */
    private static final Logger LOGGER 
         = LoggerFactory.getLogger(DemoBatchService.class);

    /** Azure Storageのアカウント名 */
    @Value("${azure.storage.accountName}")
    private String storageAccountName;

    /** Azure Storageへのアクセスキー */
    @Value("${azure.storage.accessKey}")
    private String storageAccessKey;

    /** Azure StorageのBlobコンテナー名 */
    @Value("${azure.storage.containerName}")
    private String storageContainerName;

    /** USER_DATAテーブルにアクセスするマッパー */
    @Autowired
    private UserDataMapper userDataMapper;

    /**
     * BlobStorageからファイル(user_data.csv)を読み込み、USER_DATAテーブルに書き込む
     */
    @Transactional
    public void readUserData() {

        // ファイルからデータを読み込む際の文字コード
        String characterCode = null;

        // BlobStorageからファイル(user_data.csv)を読み込む
        try (InputStream is = getBlobCsvData()) {
            // 読み込んだデータの文字コードを判定する
            String csName = DemoStringUtil.getCharsetName(is);
            LOGGER.info("判定された文字コード: " + csName);

            // 判定された文字コードがShift_JISの場合は、MS932としてデータを読み込む
            characterCode = "SHIFT_JIS".equals(csName) ? "MS932" : csName;
            LOGGER.info("ファイルからデータを読み込む際の文字コード: " + characterCode);

        } catch (Exception ex) {
            LOGGER.error(ex.getMessage());
            throw new RuntimeException(ex);
        }

        // BlobStorageからファイル(user_data.csv)を1行ずつ読み込む
        try (BufferedReader br = new BufferedReader(
                 new InputStreamReader(getBlobCsvData(), characterCode))) {
            String lineStr = null;
            int lineCnt = 0;

            // 1行目(タイトル行)は読み飛ばし、2行目以降はチェックの上、USER_DATAテーブルに書き込む
            // チェックエラー時はエラーログを出力の上、DB更新は行わず先へ進む
            while ((lineStr = br.readLine()) != null) {
                // 1行目(タイトル行)は読み飛ばす
                lineCnt++;
                if (lineCnt == 1) {
                    continue;
                }

                // 引数のCSVファイル1行分の文字列を受け取り、エラーがあればNULLを、
                // エラーがなければUserDataオブジェクトに変換し返す
                UserData userData = checkData(lineStr, lineCnt);

                // 読み込んだファイルをUSER_DATAテーブルに書き込む
                if (userData != null) {
                    userDataMapper.upsert(userData);
                }
            }
        } catch (Exception ex) {
            LOGGER.error(ex.getMessage());
            throw new RuntimeException(ex);
        }
    }

    /**
     * Blobストレージからファイルデータ(user_data.csv)を取得する.
     * @return ファイルデータ(user_data.csv)の入力ストリーム
     * @throws URISyntaxException
     * @throws InvalidKeyException
     * @throws StorageException
     */
    private InputStream getBlobCsvData() 
        throws URISyntaxException, InvalidKeyException, StorageException {
        // Blobストレージへの接続文字列
        String storageConnectionString = "DefaultEndpointsProtocol=https;" 
                + "AccountName=" + storageAccountName + ";"
                + "AccountKey=" + storageAccessKey + ";";

        // ストレージアカウントオブジェクトを取得
        CloudStorageAccount storageAccount 
            = CloudStorageAccount.parse(storageConnectionString);

        // Blobクライアントオブジェクトを取得
        CloudBlobClient blobClient = storageAccount.createCloudBlobClient();

        // Blob内のコンテナーを取得
        CloudBlobContainer container 
            = blobClient.getContainerReference(storageContainerName);

        // BlobStorageからファイル(user_data.csv)を読み込む
        CloudBlockBlob blob = container.getBlockBlobReference("user_data.csv");
        return blob.openInputStream();
    }

    /**
     * 引数のCSVファイル1行分の文字列を受け取り、エラーがあればNULLを、
     * エラーがなければUserDataオブジェクトに変換し返す.
     * @param lineStr CSVファイル1行分の文字列
     * @param lineCnt 行数
     * @return 変換後のUserData
     */
    private UserData checkData(String lineStr, int lineCnt) {
        // 引数のCSVファイル1行分の文字列をカンマで分割
        String[] strArray = lineStr.split(",");

        // 桁数不正の場合はエラー
        if (strArray == null || strArray.length != 7) {
            LOGGER.info(lineCnt + "行目: 桁数が不正です。");
            return null;
        }

        // 文字列前後のダブルクォーテーションを削除する
        for (int i = 0; i < strArray.length; i++) {
            strArray[i] = DemoStringUtil.trimDoubleQuot(strArray[i]);
        }

        // 1列目が空またはNULLの場合はエラー
        if (StringUtils.isEmpty(strArray[0])) {
            LOGGER.info(lineCnt + "行目: 1列目が空またはNULLです。");
            return null;
        }
        // 1列目が数値以外の場合はエラー
        if (!StringUtils.isNumeric(strArray[0])) {
            LOGGER.info(lineCnt + "行目: 1列目が数値以外です。");
            return null;
        }
        // 1列目の桁数が不正な場合はエラー
        if (strArray[0].length() > 6) {
            LOGGER.info(lineCnt + "行目: 1列目の桁数が不正です。");
            return null;
        }

        // 2列目が空またはNULLの場合はエラー
        if (StringUtils.isEmpty(strArray[1])) {
            LOGGER.info(lineCnt + "行目: 2列目が空またはNULLです。");
            return null;
        }
        // 2列目の桁数が不正な場合はエラー
        if (strArray[1].length() > 40) {
            LOGGER.info(lineCnt + "行目: 2列目の桁数が不正です。");
            return null;
        }

        // 3列目が空またはNULLの場合はエラー
        if (StringUtils.isEmpty(strArray[2])) {
            LOGGER.info(lineCnt + "行目: 3列目が空またはNULLです。");
            return null;
        }
        // 3列目が数値以外の場合はエラー
        if (!StringUtils.isNumeric(strArray[2])) {
            LOGGER.info(lineCnt + "行目: 3列目が数値以外です。");
            return null;
        }
        // 3列目の桁数が不正な場合はエラー
        if (strArray[2].length() > 4) {
            LOGGER.info(lineCnt + "行目: 3列目の桁数が不正です。");
            return null;
        }

        // 4列目が空またはNULLの場合はエラー
        if (StringUtils.isEmpty(strArray[3])) {
            LOGGER.info(lineCnt + "行目: 4列目が空またはNULLです。");
            return null;
        }
        // 4列目が数値以外の場合はエラー
        if (!StringUtils.isNumeric(strArray[3])) {
            LOGGER.info(lineCnt + "行目: 4列目が数値以外です。");
            return null;
        }
        // 4列目の桁数が不正な場合はエラー
        if (strArray[3].length() > 2) {
            LOGGER.info(lineCnt + "行目: 4列目の桁数が不正です。");
            return null;
        }

        // 5列目が空またはNULLの場合はエラー
        if (StringUtils.isEmpty(strArray[4])) {
            LOGGER.info(lineCnt + "行目: 5列目が空またはNULLです。");
            return null;
        }
        // 5列目が数値以外の場合はエラー
        if (!StringUtils.isNumeric(strArray[4])) {
            LOGGER.info(lineCnt + "行目: 5列目が数値以外です。");
            return null;
        }
        // 5列目の桁数が不正な場合はエラー
        if (strArray[4].length() > 2) {
            LOGGER.info(lineCnt + "行目: 5列目の桁数が不正です。");
            return null;
        }

        // 3列目・4列目・5列目から生成される日付が不正であればエラー
        String birthDay = strArray[2] + DemoStringUtil.addZero(strArray[3]) 
                            + DemoStringUtil.addZero(strArray[4]);
        if (!DemoStringUtil.isCorrectDate(birthDay, "uuuuMMdd")) {
            LOGGER.info(lineCnt + "行目: 3~5列目の日付が不正です。");
            return null;
        }

        // 6列目が1,2以外の場合はエラー
        if (!("1".equals(strArray[5])) && !("2".equals(strArray[5]))) {
            LOGGER.info(lineCnt + "行目: 6列目の性別が不正です。");
            return null;
        }

        // 7列目の桁数が不正な場合はエラー
        if (!StringUtils.isEmpty(strArray[6]) && strArray[6].length() > 1024) {
            LOGGER.info(lineCnt + "行目: 7列目の桁数が不正です。");
            return null;
        }

        // エラーがなければUserDataオブジェクトに変換し返す
        UserData userData = new UserData();
        userData.setId(Integer.parseInt(strArray[0]));
        userData.setName(strArray[1]);
        userData.setBirth_year(Integer.parseInt(strArray[2]));
        userData.setBirth_month(Integer.parseInt(strArray[3]));
        userData.setBirth_day(Integer.parseInt(strArray[4]));
        userData.setSex(strArray[5]);
        userData.setMemo(strArray[6]);
        return userData;
    }
}

その他のソースコード内容は、以下のサイトを参照のこと。
https://github.com/purin-it/azure/tree/master/timer-trigger-batch-character-code/demoAzureFunc



サンプルプログラムの実行結果

サンプルプログラムの実行結果は、以下の通り。

1) 取り込むCSVファイルの文字コードがShift_JISの場合の、CSVファイル・ログ・取り込み後のDBの内容は以下の通りで、ファイルを読み込む際の文字コードをMS932として、DBに書き込まれることが確認できる。
取り込みCSVファイル_SJIS

サンプルプログラムの実行結果_ログ_SJIS サンプルプログラムの実行結果_DB_SJIS

2) 取り込むCSVファイルの文字コードがUTF-8の場合の、CSVファイル・ログ・取り込み後のDBの内容は以下の通りで、ファイルを読み込む際の文字コードをUTF-8として、DBに書き込まれることが確認できる。
取り込みCSVファイル_UTF8

サンプルプログラムの実行結果_ログ_UTF8 サンプルプログラムの実行結果_DB_UTF8

3) 取り込むCSVファイルの文字コードがEUC_JPの場合の、CSVファイル・ログ・取り込み後のDBの内容は以下の通りで、ファイルを読み込む際の文字コードをEUC_JPとして、DBに書き込まれることが確認できる。
取り込みCSVファイル_EUCJP

サンプルプログラムの実行結果_ログ_EUCJP サンプルプログラムの実行結果_DB_EUCJP

要点まとめ

  • ファイルの文字コードを判定するライブラリの1つとして、juniversalchardetというMozillaによって提供されているライブラリが利用できる。