.NET MAUI BlazorでCSVアップロードしてテーブル表示する方法|CsvHelperとInputFileの落とし穴を解決

インド証券取引所(NSE India)からダウンロードした株価CSVを、.NET MAUI Blazor(Blazor Hybrid)アプリでアップロードしてHTMLテーブル表示したい。ところが「CsvHelperで読めない」「ファイル選択イベントが動かない」といった初見殺しの罠にハマりがちです。本記事では、原因の切り分けから、実務で安全に動く実装パターンまでをまとめて解説します。

目次

.NET MAUI BlazorでCSVアップロード→テーブル表示を作る全体像

.NET MAUI Blazorで「CSVを読み込んで表に出す」処理は、ざっくり次の流れです。構造を先に押さえておくと、どこで詰まっているかが一気に見えやすくなります。

工程担当目的つまずきやすい点
ファイル選択Blazor(UI)CSVファイルをユーザーに選ばせる<input type=”file”> と <InputFile> の混同
CSV文字列を取得Blazor(UI/コードビハインド)StreamからCSV全体を読み込むOpenReadStreamのサイズ制限、エンコーディング
CSVをパースサービス層(CsvHelper)行・列を型付きモデルに変換ヘッダー不一致、日付/数値のカルチャ差
画面へバインドBlazor(UI)IEnumerableをテーブルに描画大量データで重い、再描画のタイミング

今回のQ&Aのコアは、特に次の2点です。

  • CsvHelperは「CSVヘッダー名」と「C#プロパティ名」の一致を前提にする(一致しないと例外や欠損扱いになりやすい)
  • InputFileChangeEventArgsを受けたいなら、Blazorの<InputFile>を使う(プレーンな<input type=”file”>では別のイベント引数になる)

つまずきポイント1:CSVヘッダー名とStockDataプロパティ名の不一致

NSE Indiaの株価CSVは、見た目がシンプルな一方で、ヘッダーが「スペースを含む」など、C#のプロパティ名とそのまま一致しないケースが典型的です。

項目コード側が想定しがちなヘッダー/プロパティ実際のCSV例起きること
出来高SharesTradedShares Tradedヘッダー検証エラー、または該当列が見つからず欠損扱い
日付Date(DateTime)Date(例: 18-Dec-2025)カルチャや書式次第で変換失敗(特に月の英語表記)
数値decimal / int1,234.50 などカルチャ設定次第で桁区切りの扱いが変わる

CsvHelperはデフォルトで「CSVのヘッダー名」と「クラスのプロパティ名」を照合し、合わない場合に例外(ヘッダー検証エラー)を投げたり、列が不足していると判断してMissingField系のエラーになったりします。つまり、ヘッダー名の差異を“吸収する仕組み”が必要です。

ヘッダー不一致の解決アプローチは3つある

ヘッダー不一致を解決する方法は、大きく分けて3種類です。それぞれメリット・デメリットがあるので、用途に合わせて選びます。

アプローチ具体例メリットデメリットおすすめ場面
明示マッピング(属性/Map)[Name(“Shares Traded”)]意図が明確で壊れにくい。列名が多少変でも追跡しやすい列が増減するとマッピング修正が必要実務・運用前提のアプリ
ヘッダー正規化PrepareHeaderForMatchで空白除去「スペース有無」程度なら一括で吸収できる正規化ルールを雑にすると誤マッチの危険供給元CSVの表記ゆれが多い
検証を緩めて通すHeaderValidated=null / MissingFieldFound=nullとにかく例外を止めて読み込みを継続できる“読み込めていない”ことに気づきにくい(静かに欠損)まず動かして検証したい試作段階

結論としては、本命は「明示マッピング」か「ヘッダー正規化」です。検証緩和は便利ですが、運用で「いつの間にか列がズレていた」を引き起こしやすいので、採用するならログ出力などの“保険”を入れるのがおすすめです。

実装例:StockDataをCsvHelperで安全に読み込む(属性マッピング)

最もわかりやすく、後から見返しても事故りにくいのが、プロパティにヘッダー名を紐づける方法です。CSVが変更できない(外部サイトから取得)場合でも、C#側で吸収できます。

using CsvHelper.Configuration.Attributes;

public class StockData
{
    // NSEのCSVが dd-MMM-yyyy の形なら、ここで書式を指定しておくと安定します
    // 例: 18-Dec-2025
    [TypeConverterOption.Format("dd-MMM-yyyy")]
    public DateTime Date { get; set; }

    public decimal Open { get; set; }
    public decimal High { get; set; }
    public decimal Low { get; set; }
    public decimal Close { get; set; }

    // CSVヘッダーにスペースが含まれる場合は Name 属性で合わせる
    [Name("Shares Traded")]
    public int SharesTraded { get; set; }

    public decimal Turnover { get; set; }
}

ポイントは、「Shares Traded」⇔「SharesTraded」のズレを属性で吸収することです。さらに日付が英語月表記の場合、端末や環境によって変換がブレることがあるため、TypeConverterOption.Formatで書式を固定しておくと安定します。

実装例:CsvConfigurationで“実務向け”に堅牢化する

次に、CsvHelperの設定(CsvConfiguration)側で、よくある落とし穴を潰します。特に重要なのは「カルチャ」「ヘッダーの揺れ」「壊れた行の扱い」です。

using System.Globalization;
using CsvHelper;
using CsvHelper.Configuration;

public class CsvService
{
    public IReadOnlyList&lt;StockData&gt; LoadCsvFromString(string csvData)
    {
        // monthが英語表記(Decなど)なら、InvariantCulture/英語圏の指定が安定
        // ここではInvariantCultureを基本としつつ、必要なら CultureInfo("en-US") に変更
        var config = new CsvConfiguration(CultureInfo.InvariantCulture)
        {
            HasHeaderRecord = true,

            // ヘッダーの表記ゆれを吸収する(例: "Shares Traded" → "SharesTraded" に揃える)
            // これを入れると、Name属性を減らせるケースがあります
            PrepareHeaderForMatch = args =&gt;
                (args.Header ?? string.Empty).Replace(" ", string.Empty).Trim(),

            // まずは例外で落ちないようにしつつ、必要ならログへ回す設計にする
            // 試作段階で便利。ただし本番運用では「欠損に気づける」仕組みを必ず用意する
            HeaderValidated = null,
            MissingFieldFound = null,

            // 不正データの検知(必要に応じて null にして握りつぶすのではなくログへ)
            // BadDataFound = context =&gt; { /* ログ出力 */ },
        };

        using var reader = new StringReader(csvData);
        using var csv = new CsvReader(reader, config);

        var records = csv.GetRecords&lt;StockData&gt;().ToList();
        return records;
    }
}

PrepareHeaderForMatchはかなり強力です。今回のような「スペースの有無」程度の差なら、この1行で吸収できることが多く、CSV提供元のブレにも強くなります。

ただし、正規化ルールを強くしすぎると、別の列名まで同一視して誤マッチする可能性もあります。現実的には次のような方針が安全です。

  • まずは「空白除去」「前後Trim」程度の穏やかな正規化から始める
  • 列名が似通っているCSVの場合は、属性マッピング([Name])やClassMapに寄せる
  • HeaderValidated/MissingFieldFoundをnullにするなら、読み込み後に列の妥当性チェック(必須列が埋まっているか)を追加する

“とりあえず通す”設定の注意点(HeaderValidated/MissingFieldFound)

質問で提示されていたように、次の設定でヘッダー検証や不足フィールドを無視すれば、読み込みエラーは止まります。

var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
    HeaderValidated = null,
    MissingFieldFound = null,
};

これは確かに便利ですが、実務では次のリスクがある点を理解しておく必要があります。

  • 列名が変わっても気づきにくく、0や既定値で埋まったまま「成功」と見える
  • データ精度が求められる(株価など)場面では、サイレントな欠損が重大事故になりやすい

おすすめの落としどころは、「読み込み時の例外は抑えつつ、読み込み後に必須列が埋まっているか検証して、NGなら画面にエラー表示する」設計です。ユーザー体験と安全性のバランスが取りやすくなります。

つまずきポイント2:Blazorのファイル入力は<InputFile>が前提

もう一つの大きな詰まりポイントが、ファイル入力の扱いです。Blazorではファイルアップロード用に<InputFile>コンポーネントが用意されており、InputFileChangeEventArgsを受け取るにはこれを使う必要があります。

よくある誤りは、次のようにプレーンな<input type=”file”>に対して、BlazorのInputFileChangeEventArgsを受けるイベントを紐づけてしまうケースです。

&lt;!-- これは罠になりやすい例:InputFileChangeEventArgsは渡りません --&gt;
&lt;input type="file" style="display:none" @onchange="OnFileChange" /&gt;

@code {
    private Task OnFileChange(InputFileChangeEventArgs e)
    {
        // ここは呼ばれない/想定通りに動かない可能性が高い
        return Task.CompletedTask;
    }
}

正しくは、Blazorが用意する<InputFile>を使います。

<input type=”file”>と<InputFile>の違いを表で整理

比較項目<input type=”file”><InputFile>(推奨)
イベント引数ChangeEventArgs など(ブラウザ標準)InputFileChangeEventArgs(Blazor専用)
ファイル取得JSで取り回すことが多いe.File / e.GetMultipleFiles() で扱える
ストリームJS経由で複雑になりがちOpenReadStream() で直接読み込み可能
JSの必要性ボタン化やクリック誘導でJSに寄りがちlabelでラップすればJSなしでボタン風にできる

実装例:<InputFile>でCSVを読み込み、HTMLテーブルに表示する

ここからは、MAUI Blazorで「CSVアップロード→テーブル表示」までを最短で動かす構成例です。ポイントは、JSを使わずにUIを完結させ、読み込んだデータをIEnumerableとして保持してテーブル描画することです。

@page "/stock-data"
@using Microsoft.AspNetCore.Components.Forms
@inject CsvService CsvService

&lt;h1&gt;.NET MAUI Blazorで株価CSVをテーブル表示&lt;/h1&gt;

&lt;p&gt;
  NSE IndiaなどからダウンロードしたCSVを選択すると、下にプレビュー表示します。
&lt;/p&gt;

&lt;label class="btn btn-primary"&gt;
  &lt;InputFile style="display:none" OnChange="OnFileChange" /&gt;
  CSVファイルを選択
&lt;/label&gt;

@if (!string.IsNullOrEmpty(errorMessage))
{
    &lt;div class="alert alert-danger" role="alert"&gt;
        @errorMessage
    &lt;/div&gt;
}

@if (stockData is null)
{
    &lt;p&gt;まだファイルが選択されていません。&lt;/p&gt;
}
else if (!stockData.Any())
{
    &lt;p&gt;読み込めるデータがありませんでした。&lt;/p&gt;
}
else
{
    &lt;p&gt;
      読み込み件数:@stockData.Count()
    &lt;/p&gt;

    &lt;div style="overflow:auto; max-height: 60vh;"&gt;
      &lt;table class="table table-striped table-sm"&gt;
        &lt;thead&gt;
          &lt;tr&gt;
            &lt;th&gt;Date&lt;/th&gt;
            &lt;th&gt;Open&lt;/th&gt;
            &lt;th&gt;High&lt;/th&gt;
            &lt;th&gt;Low&lt;/th&gt;
            &lt;th&gt;Close&lt;/th&gt;
            &lt;th&gt;Shares Traded&lt;/th&gt;
            &lt;th&gt;Turnover&lt;/th&gt;
          &lt;/tr&gt;
        &lt;/thead&gt;
        &lt;tbody&gt;
          @foreach (var row in stockData)
          {
            &lt;tr&gt;
              &lt;td&gt;@row.Date.ToString("yyyy-MM-dd")&lt;/td&gt;
              &lt;td&gt;@row.Open&lt;/td&gt;
              &lt;td&gt;@row.High&lt;/td&gt;
              &lt;td&gt;@row.Low&lt;/td&gt;
              &lt;td&gt;@row.Close&lt;/td&gt;
              &lt;td&gt;@row.SharesTraded&lt;/td&gt;
              &lt;td&gt;@row.Turnover&lt;/td&gt;
            &lt;/tr&gt;
          }
        &lt;/tbody&gt;
      &lt;/table&gt;
    &lt;/div&gt;
}

@code {
    private List&lt;StockData&gt;? stockData;
    private string? errorMessage;

    private async Task OnFileChange(InputFileChangeEventArgs e)
    {
        errorMessage = null;
        stockData = null;

        var file = e.File;
        if (file is null)
        {
            errorMessage = "ファイルが選択されていません。";
            return;
        }

        try
        {
            // サイズが大きいCSVを想定するなら上限を明示(例:10MB)
            // ここを指定しないと、環境によっては小さなデフォルト上限で例外になることがあります
            using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);

            // BOM付きUTF-8なども自動判定しやすいように detectEncodingFromByteOrderMarks: true
            using var reader = new StreamReader(stream, encoding: System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

            var csvText = await reader.ReadToEndAsync();

            // CPU負荷が高い/行数が多いなら Task.Run でUIスレッドから切り離すのも有効
            var result = await Task.Run(() =&gt; CsvService.LoadCsvFromString(csvText));

            stockData = result.ToList();
        }
        catch (Exception ex)
        {
            errorMessage = $"読み込みに失敗しました:{ex.Message}";
        }
    }
}

この構成のメリットは次の通りです。

  • <InputFile OnChange=”OnFileChange”>により、BlazorがInputFileChangeEventArgsを適切に渡してくれる
  • <label>でラップすることで、ボタン風UIをJSなしで実現できる
  • 読み込み後は単純に stockDataをforeachで描画するだけなので、デバッグが容易

重いCSVでアプリが固まるのを防ぐ実務的な工夫

株価データは期間を伸ばすとすぐに行数が増えます。Blazor Hybridでも、読み込みや描画で固まりやすいポイントがあるため、先回りして対策しておくと安心です。

ファイルサイズ上限(OpenReadStream)を意識する

InputFileのOpenReadStreamは、環境によって既定の最大許容サイズが小さく設定されていることがあります。特に「NSEのCSVを数年分」などになると、簡単に上限を超えます。

  • 少なくとも数MB〜数十MBを想定するなら、maxAllowedSizeを明示する
  • ただし無制限にするとメモリ使用量が跳ねるため、アプリの用途に合わせて現実的な上限を決める

読み込みとパースを分離し、UI更新を最小化する

CSVのパースはCPUを使う処理です。MAUI BlazorのUI操作感を守るには、次の分離が効きます。

  • UIイベント内では、まず「読み込み中」状態を立てる(スピナー表示など)
  • パースはサービス層に寄せる(CsvServiceなど)
  • 行数が多い場合はTask.Runでバックグラウンド処理に寄せる(端末負荷に応じて調整)

テーブル描画は「全部一気に表示」しない設計も検討

CSVが数千〜数万行になると、HTMLテーブルに全行を一度に描画するだけで体感が重くなります。プレビュー用途なら、次のような設計が実用的です。

  • 最初は先頭100行だけ表示し、「もっと見る」ボタンで追加表示
  • 日付範囲フィルタ(開始日・終了日)をつけて表示範囲を絞る
  • ソート/検索はまずUI側で簡易対応し、必要なら後から最適化

よくあるエラーと原因・対処の早見表

現場で遭遇しがちな症状を、原因と対処にまとめます。詰まったときは、この表に戻ると復帰が早いです。

症状ありがちな原因対処おすすめの優先度
ヘッダー検証エラーが出るCSVヘッダーとプロパティ名が一致しない[Name]で明示、またはPrepareHeaderForMatchで正規化最優先
MissingField系エラーが出る列不足、または列名誤認識ヘッダー確認、MissingFieldFoundの扱い見直し、必須列チェック最優先
DateTime変換に失敗するdd-MMM-yyyyのような英語月表記が環境依存TypeConverterOption.Formatで書式指定、CultureInfoを見直す高
数字の変換に失敗する桁区切りや小数点のカルチャ差CultureInfo.InvariantCultureを使う、入力データの形式を確認高
ファイル選択しても何も起きない<input type=”file”>でInputFileChangeEventArgsを受けようとしている<InputFile OnChange=”…”>を使用最優先
大きいCSVで例外/固まるOpenReadStream上限、描画が重いmaxAllowedSize指定、表示件数制限、Task.Run、フィルタ導入中〜高

Split(‘,’)で自前パースする前に知っておきたい落とし穴

質問者の最終実装では、Split(‘\n’) / Split(‘,’)でCSVを自前パースし、日付のTryParseExactなどで整形していました。小さなサンプルなら動きますが、CSVは見た目以上に「例外ケース」が多い形式です。

例えば、次のようなCSVはSplit(‘,’)では正しく分解できません。

  • 値がダブルクォートで囲まれ、内部にカンマを含む(例:“1,234”)
  • 説明列などに改行が入る(クォートで囲まれていてもSplit(‘\n’)で壊れる)
  • 空欄や末尾のカンマがあり、列数が行によって変わる

株価CSVは比較的シンプルとはいえ、提供元が変わる、列が増える、桁区切り表記が混ざる、などが起きると一気に破綻します。長く運用するならCsvHelperに寄せるほうが結果的に保守コストが下がることが多いです。

次のステップ:予測やグラフ描画へ拡張する場合の考え方

今回の本題は「CSVアップロード→テーブル表示」ですが、ここまで安定して動くようになると、次の拡張がやりやすくなります。

  • ML.NET(例:ForecastBySsa)で将来値を予測し、テーブルに予測列を追加する
  • Chart.jsをJSInteropで呼び出し、High/Closeの推移をラインチャート表示する
  • 日付範囲フィルタや銘柄切り替えUIを追加して、分析ツールとして使いやすくする

拡張のコツは、「入出力(CSV読み込み)」「データ加工(予測や集計)」「表示(テーブル/チャート)」を分離しておくことです。CSV読み込みが安定すれば、予測や可視化は後からいくらでも積み上げられます。

まとめ:押さえるべきは「ヘッダー整合」と「InputFile」の2点

.NET MAUI Blazorで株価CSVをアップロードし、テーブル表示まで到達するための最短ルートはシンプルです。

  • CsvHelperはヘッダー名とプロパティ名の一致が重要。合わないなら[Name]属性やPrepareHeaderForMatchで吸収する
  • ファイル入力は<InputFile>を使う。InputFileChangeEventArgsは<input type=”file”>では受け取れない

この2点を押さえれば、「CSVアップロード→テーブル表示」の問題は再現性高く解決できます。あとはアプリの目的に合わせて、表示件数制限やエラーメッセージの改善、分析機能の追加へ進めていくのがおすすめです。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次