CSVアップロードのバリデーションについて考える

に公開

CSVアップロード機能とは

業務システムにデータを大量に反映したいときに1件ずつ画面からポチポチするのは面倒なのでCSVで一括アップロードしたいという要望が出てきます。

システム間連携のCSVアップロードであれば送られてくるCSVデータが正しいフォーマットであることを連携元システムが担保するべきなので、取込時に1つでもエラーになり得る値が含まれていれば異常終了して連携元システムに再アップロードを要求すれば済みます。
しかし、ユーザーが直接編集するCSVファイルのアップロードではどこにエラーがあるか分からず、アップロードしたユーザー自身もファイルのバリデーション結果を受け取って再度修正してアップロードしたいというユーザー体験を考慮しなければいけません。

CSVファイルのバリデーションの具体例

新商品の地域別展開を行うために、商品マスタと地域別商品展開データを同時にアップロードするシステムを具体例として考えます。

商品マスタ.csv

カラム名 データ型 必須 最大長 形式・制約 説明
商品コード 文字列 10 PRD + 3桁数字 商品を一意に識別するコード
商品名 文字列 50 - 商品の名称
発売予定日 日付 10 YYYY-MM-DD 全国での発売予定日
最小価格 数値 7 1-9999999 地域ごとに設定可能な最小価格(円)
最大価格 数値 7 1-9999999 地域ごとに設定可能な最大価格(円)
合計出荷数量 数値 8 1-99999999 全地域への合計出荷予定数量

サンプルデータ

商品コード,商品名,発売予定日,標準価格,合計出荷数量
PRD001,北海道限定ポテトチップス,2024-04-01,180,10000
PRD002,九州甘口醤油ラーメン,2024-04-15,200,15000
PRD003,関西風お好み焼きソース,2024-05-01,280,12000
...

地域別商品展開.csv

カラム名 データ型 必須 最大長 形式・制約 説明
商品コード 文字列 10 PRD + 3桁数字 商品マスタの商品コードと対応
地域コード 文字列 10 英大文字 販売地域コード
販売価格 数値 7 1-9999999 地域別販売価格(円)
販売数量 数値 8 1-99999999 地域別販売予定数量
販売開始日 日付 10 YYYY-MM-DD 地域での販売開始日

サンプルデータ

商品コード,地域コード,販売価格,販売数量,販売開始日
PRD001,HOKKAIDO,180,5000,2024-04-01
PRD001,AOMORI,190,3000,2024-04-15
PRD001,IWATE,190,2000,2024-04-20
...
PRD003,TOKYO,290,4000,2024-05-15
PRD003,CHIBA,285,2000,2024-05-20
...

この2つのファイルはZIP化する等して同時にアップロードしますが、アップロードを成功させるには様々なバリデーションが必要になります。

カラムのバリデーション

カラムごとに制約があるのでそれらが正しいかチェックしなければいけません。

商品マスタ

  • 商品コード: PRD + 3桁数字の形式チェック、必須入力
  • 商品名: 50文字以下、必須入力
  • 発売予定日: 有効な日付形式、現在日以降、必須入力
  • 最小価格: 1円以上999万円以下の数値、必須入力
  • 最大価格: 1円以上999万円以下の数値、必須入力
  • 合計出荷数量: 1個以上9999万個以下の数値、必須入力

地域別商品展開

  • 商品コード: PRD + 3桁数字の形式チェック、必須入力
  • 地域コード: 文字列、必須入力
  • 販売価格: 1円以上999万円以下の数値、必須入力
  • 販売数量: 1個以上9999万個以下の数値、必須入力
  • 販売開始日: 有効な日付形式、現在日以降、必須入力

さらに
地域別商品展開.csvの地域コードについてはDBに存在するコード値であるかを確認する必要があります。DBに対する存在チェックはCSVの行数が増えるほどに1件ずつクエリ実行すればDBとアプリケーション間の物理的な距離によって処理が遅くなっていくので一括で問い合わせする実装が必要になります。

カラム間のバリデーション

商品マスタ.csvの最小価格は最大価格以下でなければいけない、という相関チェックも行わなければいけません。
このバリデーションをするためには各カラムのバリデーションが成功している必要があります。

特に厄介なのは全カラムのバリデーションが成功していなくても、相関チェックの対象カラムのバリデーションが成功していればバリデーションを進める、というケース。
ユーザー的にはなるべく一回のアップロードでバリデーションエラーになる内容を全て知りたいが、実装コストが一気に上がってしまいます。

行同士のバリデーション

商品マスタ.csvでは同一の商品コードが存在しないように重複チェックをしなければいけません。
地域別商品展開.csvでは商品コード、地域コードの組み合わせで重複チェックをしなければいけません。

CSVのレコード数が大量にあるときには全データをメモリに乗せれるかといった観点で負荷テストも行う必要が出てきます。データ量が多いときにOOMが発生するのはありがちです。

ファイル間のバリデーション

ファイルを同時にアップロードするケースでは、ファイル同士にチェックしなければいけない関係性もありさらに複雑になります。

  1. 商品マスタ.csvの発売予定日に対して地域別商品展開.csvの販売開始日は後でなければいけない。
  2. 商品マスタ.csvの合計出荷数量に対して地域別商品展開.csvの同一商品コードの販売数量を合計した値が一致しなければいけない。
  3. 商品マスタ.csvの最小価格〜最大価格の範囲内に地域別商品展開.csvの販売価格が収まっていなければいけない。

こういった処理では全てのファイルの中身をメモリに展開してバリデーションすることになり、さらにOOMのリスクも上がるのでメモリサイズの調整が必要になることもあります。

バリデーションの種類まとめ

バリデーションの種類をまとめると

  • カラムの単項目チェック
  • カラムの相関チェック
  • 行同士の相関チェック
  • ファイル間の相関チェック
    となり、仕様によっては上記の各チェックの中でDBアクセスによる検証も必要となります。

よくある流れとしてはこれらのチェック結果に対してエラーが発生したときはエラー内容をCSVで返すケースで以下のようなCSVを定義して返すことになります。

エラー内容.csv

カラム名 データ型 必須 説明
ファイル名 文字列 エラーの主原因となるファイル名
行番号 数値 エラーを発生させた元ファイルの行番号
カラム名 文字列 エラーの主原因となるカラム名
エラー内容 文字列 エラーの内容

バリデーションと実装の複雑さのトレードオフ

ユーザーからすればアップロードしたときに全てのエラー内容を返してもらえた方が、一度に全てのエラーを解消できますが、全てまとめて返すと実装が複雑になるのでトレードオフの関係です。

例外を投げる

例えばユーザー体験の悪い実装は以下のように例外を投げるだけのもので、これだと処理は終了してしまうのでエラー内容は1件しか受け取れません。最初に挙げたシステム間連携だけを考えるならこれでも別に成り立つかもしれないですが、ユーザーからすると1件ずつしかエラーが返ってこないようなシステムは使い勝手が悪すぎるでしょう。

data class 商品コード(val value: String) {
    companion object {
        fun create(rawCode: String): 商品コード {
            if (!rawCode.matches(Regex("PRD\\d{3}"))) {
                throw IllegalArgumentException("商品コードはPRD + 3桁数字の形式で入力してください")
            }
            return 商品コード(rawCode)
        }        
    }
}
// アプリケーション側の処理
val products = rawProductCsv.map {
  商品(
    商品コード.create(it.商品コード), // ここで例外が出ると1つのエラーで異常終了
    ...
  )
}
// 例外を投げたエラーだけをユーザーに返す

エラーを1件ずつチェックしてListに全部入れていく

実装は複雑になりますがユーザー体験を上げるためには可能な限り全てのエラーをチェックした上でまとめて返す必要があります。手続き型の処理で書くと以下のようになります。

// アプリケーション側の処理
val errorCsvs = mutableListOf<ErrorCsv>()
val validProducts = mutableListOf<商品>()

rawProductCsv.forEach {
  val productCode = try {
    商品コード.create(it.productCode)
  } catch (e: Exception) {
    errors.add(ErrorCsv(..., e.message))
    null
  }
  ...

  if (productCode != null && ...) {
    validProducts.add(商品(productCode, ...))
  }
  
}

(各項目を型定義しない場合はtry catchせずにif文で直接アプリケーション層に実装することになります。)

Result型でエラーをまとめる

ライブラリなどでResult型を使うとこういった実装は比較的楽になります。
手続き型が残るところはありますが宣言的に書けるコードが多く個人的には見やすいと感じています。
(参考例としてkotlin-resultを使っています)

data class 商品コード(val value: String) {
    companion object {
        fun create(rawCode: String): Result<商品コード, BusinessError> {
            if (!rawCode.matches(Regex("PRD\\d{3}"))) {
                return Err(BusinessError("商品コードはPRD + 3桁数字の形式で入力してください"))
            }
            return Ok(商品コード(rawCode))
        }
    }
}
// アプリケーション側の処理
val productResults: List<Result<商品, List<BusinessError>>> = rawProductCsvs.map {
  ...
  zipOrAccumulate(
    { 商品コード.create(it.productCode) },
    { 商品名.create(it.productName) },
    ...,
    { productCode, productName, ... -> 商品(productCode, productName, ...) }
  )
}

// DBチェックが必要な商品コードは一括取得する
val existingProductCodes = productResults.filter { it.isOk }
  .map { it.get().商品コード }
  .distinct()
  .let { productCodes -> productRepository.findListBy(productCodes) }
  .map {it.商品コード }

// 全てのカラムのチェックが成功している商品に対して商品コードのDBチェックを行う。
val validProductResults = productResults.map { result ->
    result.andThen { product ->
        if (existingProductCodes.contains(product.商品コード)) {
            Err(listOf(BusinessError(...)))
        } else {
            Ok(product)
        }
    }
}

まとめ

CSVアップロード処理でユーザー体験と実装の複雑さのトレードオフ、どういう観点を気にしなければいけないかを解説しました。
実装例をいくつか示しましたが、細かくは他にも色々な実装ができると思うので、要件や実装のしやすさ等を考慮して選択できると良いと思います。

Discussion