にて DB データをエクスポートする機能を紹介いたしました。
今回は、対となるインポート機能を開発いたします♪
インポート機能のポイント
こちらのシステムでインポート機能を考えます。ポイントは次の 2 点です。
- CSV の 1 行に Parent とその Child を記入する。
- インポートできる子ども (Child) は 2 人まで。
Parent 1-n Child という関係を持っております。
ですのでシステム上は、Parent は必ず 1 つですけれども、それに紐づく Child はいくつあっても問題ありません。
ですけれども、インポート機能におきましては子どもの数の最大数を制限しませんと CSV ファイルの仕様を固めることが難しくなります。
したがいまして、今回は Child の数を最大 2 つとし、それ以上登録したい場合はウェブブラウザから追加する、という運用方法を想定いたしました。
CSV インポート開発の流れ
に沿ってサンプルプロジェクトを作成したところからスタートです。
ソリューションおよびプロジェクト名は Sample3 といたしました。
- コントローラー CsvController.cs をスキャフォールディング作成
- アップロードファイルに対応するモデル CsvFile.cs を作成
- アップロードするファイルの種類を検証する属性クラス UploadFileAttribute の作成
- ビュー Import.cshtml の作成
- インポート処理を司るサービスクラス CsvImportService の作成
- テスト CSV ファイル作成と、インポートの確認
1. コントローラー CsvController.cs をスキャフォールディング作成
いきなり完成を目指すのではなく、まずはアップロードするファイルをコントローラーで扱うことを確認できるようにすることを第一に意識して作っていきすの!
- Controllers > 右クリック > 追加 > コントローラー
- MVC 5 コントローラー – 空
- コントローラー名「CsvController」、追加
- 最低限のコードを書く。
- Index アクションは、Import アクションへリダイレクト
- CSV ファイルは POST で受け取る。
public class CsvController : Controller { // GET: Csv public ActionResult Index() { return RedirectToAction("Import"); } // GET: Parents/Import public ActionResult Import() { return View(); } // POST: Parents/Import [HttpPost] [ValidateAntiForgeryToken] public ActionResult Import(CsvFile file) { return View(); } }
そうしますと、POST 時に受け取るモデル CsvFile が無い、とエラーが表示されますので、次に CsvFile.cs を作成いたします。
2. アップロードファイルに対応するモデル CsvFile.cs を作成
- CsvController.cs で、エラーの発生している CsvFile 上で Ctrl + . で修正候補を表示。
- 新しい型の生成…
- 型の詳細のアクセスは public、場所のファイル名は新しいファイルの作成を選び、「\Models\CsvFile.cs」と入力して、OK
- 次のコードを書く。
public class CsvFile { [DisplayName("Parent Child の CSV ファイル")] [UploadFile("csv")] public HttpPostedFileBase UploadFile { get; set; } }
この CsvFile モデルを、ビューでも使ってあげて、ビューからコントローラーへと CSV ファイルを渡す役割を果たしていただくことになりますわ。
さて、UploadFile に付与した属性、UploadFile はオリジナルですの!ですのでエラーとなってしまっております><。今度はこのエラーを解消すると同時に、オリジナルの検証属性を追加いたしましょう♪
3. アップロードするファイルの種類を検証する属性クラス UploadFileAttribute の作成
上記が参考ページです。こちらのページよりも簡略化して作っております。拡張子のみのチェックと貧相な機能ですけれども、練習にもなりますし、よいと思いますの♪
- CsvFile.cs で、エラーの発生している UploadFile 上で Ctrl + . で修正候補を表示。
- 新しい型の生成…
- 型の詳細のアクセスは public、場所のファイル名は新しいファイルの作成を選び、「\Extensions\UploadFileAttribute.cs」と入力して、OK
- 次のコードを書く。
/// <summary> /// アップロードされたファイルの種類の検証属性 /// </summary> public class UploadFileAttribute : ValidationAttribute { /// <summary> /// 許可する拡張子 /// </summary> private string _extentions; /// <summary> /// コンストラクタ /// </summary> /// <param name="extentions">許可する拡張子。複数指定する場合はカンマで区切ること</param> public UploadFileAttribute(string extentions) { // デフォルトのエラーメッセージを設定する ErrorMessage = "{0}は{1}以外のファイルはアップロードできません。"; this._extentions = extentions; } /// <summary> /// エラーメッセージを返却します。 /// </summary> /// <param name="name">DisplayName の値</param> /// <returns>エラーメッセージ</returns> public override string FormatErrorMessage(string name) { return String.Format(CultureInfo.CurrentCulture, ErrorMessage, name, _extentions); } /// <summary> /// ファイルの種類とサイズを検証します。 /// </summary> /// <param name="value">ファイル</param> /// <returns>検証 OK の場合は true、検証 NG の場合は false</returns> public override bool IsValid(object value) { // 値が null の時、ファイルが指定されていない時はエラー if (value == null) { return false; } // 値がアップロードされたファイルか確認 var postedFile = value as HttpPostedFileBase; if (postedFile == null) { return true; } // 拡張子を検証 var extention = Path.GetExtension(postedFile.FileName).Replace(".", ""); if (!string.IsNullOrEmpty(this._extentions) && !this._extentions.Split(',').Any(p => p == extention)) { // 許可されていない拡張子の場合はエラー return false; } // 検証が成功 return true; } }
これで度重なるエラー表示からは卒業!ですの♪次にビューを作成しまして、実際にファイルのアップロード、オリジナルの検証属性の動きを確かめますの♪
4. ビュー Import.cshtml の作成
- UploadFileAttribute.cs の Import アクション部分にカーソルを移動し、右クリック > ビューを追加
- ビュー名: Import、テンプレート: Empty (モデルなし)、追加
- 次のコードを書く
@model Sample3.Models.CsvFile @{ ViewBag.Title = "インポート"; } <h2>インポート</h2> @using (Html.BeginForm("Import", "Csv", FormMethod.Post, new { enctype = "multipart/form-data" })) { @Html.AntiForgeryToken() <div class="form-horizontal"> <div class="form-group"> @Html.LabelFor(model => model.UploadFile, htmlAttributes: new { @for = "InputFile" }) @Html.TextBox("UploadFile", "", new { id = "InputFile", type = "file" }) @Html.ValidationMessageFor(model => model.UploadFile, "", new { @class = "text-danger" }) </div> <input type="submit" value="インポート" class="btn btn-primary" /> </div> }
完成しましたらビルドし、http://localhost:49840/Csv/Import などにアクセスします。
- 拡張子が .csv の CSV ファイル
- 拡張子が .csv ではないファイル
- ファイル指定なし
以上のパターンで「インポート」を試してみて、CSV ファイルではエラーが表示されず、それ以外のパターンでエラーメッセージが表示されれば成功ですの♪
これまで、ファイルをクライアントからサーバへと送ることを意識してコードを書いてまいりました。そして、それは達成できましたの!
いよいよ、核となる部分、サーバ側での処理、データを分割して、検証し、DB 保存用のオブジェクトに詰めて保存する処理を書いてまいりますわ♪
5. インポート処理を司るサービスクラス CsvImportService.cs の作成
5-1. CsvController の POST の Import アクション
まずは CsvController の POST の Import アクションに CSV データを処理するコードを書きました。
public class CsvController : Controller { private Sample3Context db = new Sample3Context(); // GET: Csv public ActionResult Index() { return RedirectToAction("Import"); } // GET: Parents/Import public ActionResult Import() { return View(); } // POST: Parents/Import [HttpPost] [ValidateAntiForgeryToken] public ActionResult Import(CsvFile file) { // ファイルチェック if (!ModelState.IsValid) { return View(); } // ファイル読み込み CsvImportService csvImportService = new CsvImportService(file.UploadFile.InputStream); // バリデーション if (!csvImportService.IsValid) { ViewBag.ErrorMessageList = csvImportService.ErrorMessageList; return View(); } // モデル取得 List<Parent> parentList = csvImportService.ParentList; // DB 登録 parentList.ForEach(p => db.Parents.Add(p)); db.SaveChanges(); ViewBag.SuccessMessage = "インポートに成功しました。"; return View(); } }
5-2. Import ビュー
Import.cshtml に、CSV ファイルに見つかったエラーを表示する部分を追加いたします。また、インポート成功した時のメッセージも表示するようにいたします。
- CsvFile モデルがあるが、このモデルのエラーメッセージには追加しなかった。このモデルはファイルそのものを扱うモデルで、ファイルの内容は扱わないため。
と、いうよりも、ファイルの中身まで制御するような取り扱い方がわからない。。。 - ファイルの中身に関するエラーメッセージは、ViewBag に List を代入してビューで取り出すようにした。
- foreach でエラーメッセージの List を回すが、ViewBag に設定した変数が null だと例外が発生する。そのため、初回表示のときなどのために null チェックを行っている。
この null チェックは、あまり上手な書き方でないように思う。
@model Sample3.Models.CsvFile @{ ViewBag.Title = "インポート"; } <h2>インポート</h2> @using (Html.BeginForm("Import", "Csv", FormMethod.Post, new { enctype = "multipart/form-data" })) { @Html.AntiForgeryToken() <div class="form-horizontal"> <div class="form-group"> @Html.LabelFor(model => model.UploadFile, htmlAttributes: new { @for = "InputFile", @class = "control-label col-md-2" }) <div class="col-md-10"> @Html.TextBox("UploadFile", "", new { id = "InputFile", type = "file" }) @Html.ValidationMessageFor(model => model.UploadFile, "", new { @class = "text-danger" }) </div> </div> <div class="form-group"> <div class="col-md-offset-2 col-md-10"> <input type="submit" value="インポート" class="btn btn-primary" /> </div> </div> </div> <p class="h3 text-success">@ViewBag.SuccessMessage</p> } @{ @* TODO null チェクしないでスマートに済ませる方法がある気がする *@ if (ViewBag.ErrorMessageList != null) { foreach (String error in (List<String>)ViewBag.ErrorMessageList) { <ul> <li class="text-danger">@error</li> </ul> } } }
5-3. CSV ファイルデータの取り出し、検証、DB 格納用データを作る CsvImportService
- コンストラクタで渡す以外の方法で、クラスに値を渡さない。完全コンストラクタとした。
- コントローラーからなどこのクラスを使用するときは、次の 2 ステップの使い方のみ。
- CsvImportService に引数を渡してインスタンス化する。
- 検証 OK か否かのフラグ・エラーメッセージのリスト・DB 格納用モデルインスタンスのリストを取り出す。
- インポートするたびに毎回 CsvImportService クラスをインスタンス化しているが、そのたびに DB アクセスが発生している。これがベターなロジックなのか、自信なし。
- VisualBasic への参照を追加して、TextFieldParser を使用して CSV のパースを行った。
- 読み取った CSV データは、LINQ が使用できるように string[] の IEnumerable とした。
- CSV データは string[] の IEnumerable としたが、2 回目以降の LINQ 結果が空となり、うまくデータを取得できない><。結局、foreach で回して処理した。
- CSV の値をすべてチェックし、エラーの発生した要素をすべてピックアップしてエラーメッセージを蓄積するように設計した。最初にエラーに遭遇した時点で処理を中断しない。これは、CSV 修正後に次のエラーが表示される事態を避け、1回の修正ですべてのエラーを直せるようにしたかったため。
- CSV の要素を 1 つずつ要素の値のタイプに合わせて検証し、DB モデルのプロパティに格納している。検証の方法、モデルインスタンスの生成のロジックはまだまだ改善の余地があると思う。
- CheckLineAndMakeParent メソッドの columnNumber++ は、これを渡したメソッドが終わってからインクリメントされる。メソッドにはインクリメント前の値が渡ってくる。これは、i++ とインクリメントすることで要素の番号を意識しないで済むようにしたかったため。
- CSV の値の検証にあたり、最初に GetStringOrNull メソッドを必ず通している。これは、配列の要素が無く例外が発生するケースに備えている。
public class CsvImportService { private Sample3Context db = null; /// <summary> /// 性別マスタデータ /// </summary> private List<Sex> sexList = null; /// <summary> /// CSV ファイルの最低行数 /// </summary> private const int MIN_CSV_ROW_COUNT = 2; /// <summary> /// CSV 1 行に含める Child の最大数 /// </summary> private const int MAX_CHILD_COUNT = 2; /// <summary> /// ヘッダー定義 /// </summary> private string[] headers = { "親_名前", "親_性別", "親_メールアドレス", "子1_名前", "子1_性別", "子1_生年月日", "子2_名前", "子2_性別", "子2_生年月日" }; /// <summary> /// CSV 1行における Parent 1 つを構成するカラム数 /// </summary> private const int ONE_PARENT_COLUMN_COUNT = 3; /// <summary> /// CSV 1行における Child 1 つを構成するカラム数 /// </summary> private const int ONE_CHILD_COLUMN_COUNT = 3; /// <summary> /// 読みこんだ CSV のバリデーションが OK であることを示すフラグ /// </summary> public bool IsValid = false; /// <summary> /// エラーメッセージリスト /// </summary> public List<string> ErrorMessageList = new List<string>(); /// <summary> /// Parent モデルのリスト。IsValid プロパティが false の場合は内容の有効性が保証されない。 /// </summary> public List<Parent> ParentList = new List<Parent>(); /// <summary> /// コンストラクタ /// </summary> /// <param name="inputStream">CSV ファイルの Stream</param> public CsvImportService(Stream inputStream) { this.db = new Sample3Context(); this.sexList = this.db.Sexes.ToList(); IEnumerable<string[]> csvLines = ReadCsv(inputStream); // TODO 2回目の LINQ 取得がうまくいかないので for する。 //var header = csvLines.FirstOrDefault(); //var lineList = csvLines.Skip(0).ToList(); int i = 0; foreach (var csvLine in csvLines) { // 1回目のみヘッダーチェック i++; if (i == 1) { ValidateHeader(csvLine); continue; } // 2回目以降はチェックおよびオブジェクト生成 ValidateLineAndMakeParent(i, csvLine); } // 2回目以降はチェックおよびオブジェクト生成 if (i < MIN_CSV_ROW_COUNT) { ErrorMessageList.Add("読み込むデータがありません。"); } IsValid = (ErrorMessageList.Count == 0); } /// <summary> /// CSV の Stream を読み込み、string 配列の列挙子を返却します。 /// </summary> /// <param name="stream">CSV ファイルの Stream</param> /// <returns>各セルを string 配列の要素とし、1 行を 1 つの要素とした列挙子</returns> private IEnumerable<string[]> ReadCsv(Stream stream) { using (TextFieldParser parser = new TextFieldParser(stream, Encoding.GetEncoding("Shift_JIS"))) { parser.TextFieldType = FieldType.Delimited; parser.SetDelimiters(new[] { "," }); parser.HasFieldsEnclosedInQuotes = true; while (!parser.EndOfData) { string[] fields = parser.ReadFields(); yield return fields; } } } /// <summary> /// CSV のヘッダーを検証し、NG の項目はエラーメッセージに追加します。 /// </summary> /// <param name="csvHeader">CSV ヘッダー項目の配列</param> private void ValidateHeader(string[] csvHeader) { // 要素数チェック if (this.headers.Length != csvHeader.Length) { IsValid = false; ErrorMessageList.Add("ヘッダーの要素数が一致しません。"); } // 値チェック int max = csvHeader.Length; for (int i = 0; i < max; i++) { if (headers[i] != csvHeader[i]) { IsValid = false; ErrorMessageList.Add("ヘッダー[ " + headers[i] + " ]の値が一致しません。"); } } } /// <summary> /// 1行分 の CSV を検証し、Parent および Child インスタンスを生成して ParentList プロパティに追加します。 /// 検証 NG の場合はエラーメッセージをプロパティに追加します。 /// 検証 NG の場合でもインスタンスを生成しますが、内容は保証されません。 /// </summary> /// <param name="rowNumber">行番号</param> /// <param name="rowContent">1 行分の CSV 要素配列</param> private void ValidateLineAndMakeParent(int rowNumber, string[] rowContent) { int columnNumber = 0; var p = new Parent(); // 親_名前 p.Name = ValidateAndGetRequiredString(rowNumber, columnNumber++, rowContent); // 親_性別 p.SexId = ValidateAndGetRequiredSexId(rowNumber, columnNumber++, rowContent); // 親_メールアドレス p.Email = ValidateAndGetRequiredString(rowNumber, columnNumber++, rowContent); p.Children = new List<Child>(); var childCount = GetChildCountInRow(rowContent.Length); for (var i = 0; i < childCount; i++) { if (i == MAX_CHILD_COUNT) { break; } var c = new Child(); // 子n_名前 c.Name = ValidateAndGetRequiredString(rowNumber, columnNumber++, rowContent); // 子n_性別 c.SexId = ValidateAndGetRequiredSexId(rowNumber, columnNumber++, rowContent); // 子n_生年月日 c.Birthday = ValidateAndGetRequiredDateTime(rowNumber, columnNumber++, rowContent); p.Children.Add(c); } ParentList.Add(p); } /// <summary> /// 列番号に対応した要素の値を検証し、OK の場合は値をそのまま返却します。 /// 検証 NG の場合は行数を含めてエラーメッセージをプロパティに追加し、null を返却します。 /// </summary> /// <param name="rowNumber">行数</param> /// <param name="columnNumber">列番号</param> /// <param name="rowContent">1 行分の CSV 要素配列</param> /// <returns>列番号に対応した要素の値。検証 NG の場合は null</returns> private string ValidateAndGetRequiredString(int rowNumber, int columnNumber, string[] rowContent) { var value = GetStringOrNull(rowContent, columnNumber); if (string.IsNullOrEmpty(value)) { ErrorMessageList.Add(rowNumber + "行目 : [ " + headers[columnNumber] + " ] は必須です。"); return ""; } return value; } /// <summary> /// インデックスに対応した配列の要素を返却します。 /// インデックスが配列の要素数の範囲外の場合は null を返却します。 /// </summary> /// <param name="array">string の配列</param> /// <param name="index">配列のインデックス</param> /// <returns>インデックスに対応した配列の要素。無い場合は null</returns> private string GetStringOrNull(string[] array, int index) { if (array.Length <= index) { return null; } return array[index]; } /// <summary> /// 列番号に対応した要素の値を検証し、OK の場合は要素の値に対応する SexId を返却します。 /// </summary> /// <param name="rowNumber">行数</param> /// <param name="columnNumber">列番号</param> /// <param name="rowContent">1 行分の CSV 要素配列</param> /// <returns>SexId。検証 NG の場合は 0</returns> private int ValidateAndGetRequiredSexId(int rowNumber, int columnNumber, string[] rowContent) { var value = GetStringOrNull(rowContent, columnNumber); if (string.IsNullOrEmpty(value)) { ErrorMessageList.Add(rowNumber + "行目 : [ " + headers[columnNumber] + " ] は必須です。"); return 0; } if (!sexList.Any(s => s.Name == value)) { ErrorMessageList.Add(rowNumber + "行目 : [ " + headers[columnNumber] + " ] の入力値は誤っています。"); return 0; } return sexList.Single(s => s.Name == value).Id; } /// <summary> /// CSV 1 行に含まれる子の数を算出します。 /// 1 行の要素数が少ない場合、この数はマイナスや 0 となる場合があります。 /// </summary> /// <param name="rowColumnCount">CSV 1 行の要素数</param> /// <returns>CSV 1 行に含まれる子の数。</returns> private int GetChildCountInRow(int rowColumnCount) { return (rowColumnCount - ONE_PARENT_COLUMN_COUNT) / ONE_CHILD_COLUMN_COUNT; } /// <summary> /// 列番号に対応した要素の値を検証し、OK の場合は要素の値に対応する DateTime を返却します。 /// </summary> /// <param name="rowNumber">行数</param> /// <param name="columnNumber">列番号</param> /// <param name="rowContent">1 行分の CSV 要素配列</param> /// <returns>DateTime に変換した年月日。検証 NG の場合は DateTime.MinValue</returns> private DateTime ValidateAndGetRequiredDateTime(int rowNumber, int columnNumber, string[] rowContent) { var value = GetStringOrNull(rowContent, columnNumber); if (string.IsNullOrEmpty(value)) { ErrorMessageList.Add(rowNumber + "行目 : [ " + headers[columnNumber] + " ] は必須です。"); return DateTime.MinValue; } DateTime result; if (!DateTime.TryParse(value, out result)) { ErrorMessageList.Add(rowNumber + "行目 : [ " + headers[columnNumber] + " ] の入力値は誤っています。"); } return result; } }
これで完成です!
CSV ファイルを実際に読み込ませて動きを確認いたしましょう♪
6. テスト CSV ファイル作成と、インポートの確認
http://localhost:49840/Csv/Import などにアクセスしまして、早速 CSV ファイルをインポートしてみます。
CSV ファイルは、例えば次のような内容となります。これは、【ASP.NET MVC5】CSV エクスポートのサンプルプロジェクト作成チュートリアル | oki2a24 でエクスポートした CSV ファイルですの♪
"親_名前","親_性別","親_メールアドレス","子1_名前","子1_性別","子1_生年月日","子2_名前","子2_性別","子2_生年月日" "井上郁夫","男","inoue.ikuo@example.com","井上恵美子","女","2015/01/16" "宇佐美景子","女","usami.keiko@example.com","宇佐美涼介","男","2010/02/01","宇佐美信介","男","2013/11/08" "青木篤志","男","aoki.atsushi@example.com"
インポートが成功しましたら CSV の内容が正確に反映されているか、Parents や Childeren のページで、または直接データベースを見て確認いたしました。
続いて、失敗した時の様子です。適当に誤ったファイルを用意して、アップロードしてみますと、次のようにエラーとなりました♪
おわりに
エクスポート機能よりもずっと複雑で、難易度が高かったですの><。
でも、なんとか作り上げ、なんとかクラスも整理できましたのでホッとしております♪
といいましても、クラスの切り分け方など、設計に関しては自信はありませんけれども><。
設計に関しては、次のページを参考にいたしました。完全コンストラクタとして CsvImportService クラスを作成いたしました。これは、次のページを覚えていたからですの♪
他には、コントローラー内で、ModelState.IsValid を判定してから OK、NG の処理を分けて書いていることを参考にして、CsvImportService も同じように使えるように意識しております。
プログラミングは難しくて、楽しいですの♪
以上です。