TL;DR
一括入力系のcsvアップロード機能にはすでにあるFormクラスを使いまわしたかったので、csvバリデーション向けの共有メソッドを作成した
自己紹介
444株式会社エンジニアの橋本玄基です。 21/02にジョインし、バックエンド開発をメインに、ちょびっとインフラ運用/モニタリングを回しています。 前職ではアプリよりも低レイヤよりの設計実装やモニタリング運用改善等をしていました。
珍名のいい点でもあり悪い点でもありますが、検索すると出身地から前職まですべて出てくると思うので興味がある方は検索してみて下さい。
掲題の内容は日本語ではあまり見かけなかったので初めての技術記事で緊張ですが、書いてみます。
事の起こり
データの一括登録時にしばしば活躍するcsvファイルですが、様々なユースケース(問題を解くユーザ、学校利用、企業の求人掲載etc...)のあるTechFULでも利用されています。
これまではそれぞれ個別にバリデーション処理を記述していました。 一度実装するとあまり変更の無い箇所ではあるものの、Modelと紐付かないため変更漏れのリスクもあるし、意外と面倒だよなと感じていました。
一括登録にはもれなく1件だけ登録するための登録フォーム画面も大抵ついており、フォームがあるとDjangoでは入力チェックをしてくれるFormクラスがあります。 これを流用できれば呼び出すだけでどのcsvもバリデーションしてくれる夢のメソッドができるのではと思い、共通化に踏み切りました。
設計
行のバリデーションはFormクラスに移譲する。
今回のメインです。各列のバリデーション内容を渡すのではなく、既にあるであろうFormにバリデーションの一切を任せることで実装量や考えなければいけないことを減らしたいというのがモチベーションです。
Formクラスについて
FormクラスはDjangoのHttpRequest.POSTやGETを渡して利用します。 POSTやGETの内容を調べるとQueryDictというクラスのようです(https://docs.djangoproject.com/en/2.2/ref/request-response/#django.http.HttpRequest.GET)。
QueryDictはMultiValueDictを継承しておりMultiValueDictはdict(辞書型)を継承しています。細かくソースを読んでいませんが、ざっくりいうと同じkeyに対して複数valueを持てる辞書型です。HTMLのcheckboxで複数選べるものなどに対応するためという理解をしました。
細かいことはさておき、dict(辞書型)を渡せば動くということですので
- csvの各行でループを回す
- 各行をdictオブジェクトに
- Formクラスでバリデーション
の方針で行くことにしました。
ヘッダ周りの自由度は保証しておく
前項の通り、dictとして各行を扱おうとしていると、csvライブラリのDictReaderを使いたくなります。csvのヘッダをフィールド名にするルールを用意すれば読み込み処理で考えることが1つ減るなとも考えましたが 明らかにヘッダは日本語のほうが人間にやさしいですし、ヘッダなしの需要が0とは言い切れないと考え、ある程度柔軟に対応できることも考えました。
実装
そんなに特殊なことをしていない & 別にセキュリティリスクもないと思うのでベタ貼りしちゃいます。
ヘッダ周りの判定を行ったあと以下2パターンでcsvを辞書型に落とし込んでいきます。
import csv import io from django import forms from django.core.exceptions import ValidationError from django.core.files.uploadedfile import UploadedFile def csv_valid(csv_file: UploadedFile, form: forms.Form, alt_header: list[str]=None, has_header: bool=True) -> list[dict]: """ csvアップロード時のバリデーションメソッド Args: csv_file: UploadedFileクラス, csvファイルデータ form: forms.Formクラスの子クラス、行のバリデーションに利用 alt_header: csvヘッダ代替の文字列リスト、csvのヘッダが確定出ない場合はこのリストに従ってデータをバリデーションする。 has_headerがFalseのときは必須 has_header: bool、csvファイルにヘッダがある場合はTrue。デフォルト値True Raises: ValidationError: バリデーション失敗 Returns: list[dict]: バリデーション済csvデータの辞書リスト """ csv_data = io.StringIO(csv_file.read().decode('utf-8')) message = '' csv_dict_list = [] if not has_header and not alt_header: # バリデーション不可 message = 'csvの読み込みが行えませんでした。' raise ValidationError(message) if alt_header: # 代替ヘッダバリデーション reader = csv.reader(csv_data) if has_header: next(reader) # ヘッダスキップ for _row in reader: row = {k: v for (k,v) in zip(alt_header,_row)} row_num = reader.line_num row_form = form(row) if not row_form.is_valid(): message += '---'+str(row_num)+'行目---\n' message += row_form.errors.as_text()+'\n' else: csv_dict_list.append(row_form.data) if message: raise ValidationError(message) else: dict_reader = csv.DictReader(csv_data) for row in dict_reader: row_num = dict_reader.line_num row_form = form(row) if not row_form.is_valid(): message += '---'+str(row_num)+'行目---\n' message += row_form.errors.as_text()+'\n' else: csv_dict_list.append(row_form.data) if message: raise ValidationError(message) return csv_dict_list
反省点
ブログを書くにあたり読み直すと気になる点がいくつか、、、
エラーの判定微妙?
だめな行があった時点で抜ける処理だと、全てが解決するまでアップロードをリトライし続けるという事態が想定できるので全行確認するようにしていますが、 エラーのメッセージがあったらという条件は少し気持ち悪いなと反省です。皆さんだったら別でフラグ持ちますか?
Formのvalidatorsには使えない実装だった
メソッドにしていた理由としてFormのvalidatorに指定もできるようにしたいなと言う目論見があり例外もValidationErrorを使っていたのですが 少し調べると、validatorsはFieldの値以外の引数呼び出せないですね、、、
Class化して現在の引数部分をフィールドにもっておき、Formクラス内で初期化すればいけそうですので機会が来たら拡張を検討しています。
感想
軽く探してなかったのですが、ちゃんと探せばライブラリあるでしょ!とも思いましたが、個人的に大きかった今回のモチベーションは有名なテック系Podcast「Rebuild」のこの回でした(1:07:13~の話題)。 rebuild.fm
要約すると1部機能のためにライブラリを入れるのもいいが、余分なサイズになるし、依存先が増えることも考えると自分でメンテナンスできるように書いてしまうのも手段の一つという話。 開発しちゃって自分の範疇に収めておくのも手という発想、皆さんはどう考えますか?
業務に直結する実装をすることがほとんどで、一般的な処理を実装するってあまりない経験だと思うのでいい経験値になりました。
参考にした記事など
How to validate contents of a CSV file using Django forms - Stack Overflow