0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【PowerShell】Excelで開けない巨大CSV同士も結合する、プレビュー付きCSVマージツール

0
Posted at

はじめに

業務で「数十万〜数百万行のCSV同士をキーで突合(VLOOKUP / LEFT JOIN)したい」という場面はよくあります。

しかし、いざやろうとすると以下のような壁にぶつかります。

  • Excelで開けない、そもそもない(104万行の壁、開けても重くてフリーズ)
  • Pythonなどの環境がない(会社の制約でPythonやDBツールをインストールできない)
  • CUI/コマンド操作は同僚に頼みづらい(黒い画面を嫌がる非エンジニアにも使ってもらいたい)

そこで、 Windows標準のPowerShellのみで動作し、GUIで対話的に列を選んでプレビュー確認までできる「超高速CSV結合ツール」 を作成しました。

主な機能と特徴

  1. 完全GUI操作
    • ファイル選択、結合キー列の選択、出力対象列の選択をGUI(ダイアログや Out-GridView)で直感的に操作可能。
  2. 超高速・省メモリ設計
    • Import-Csv などの標準コマンドレットは使わず、.NETのクラス(StreamReader / StreamWriter / Dictionary)を直叩きすることで、100万行規模のデータでも数秒〜数十秒で処理。
  3. 文字コード自動判別
    • UTF-8(BOMあり/なし)とShift_JIS(CP932)を先頭バイトから自動判定。
  4. 結合結果のプレビュー機能
    • 保存前に DataGridView で結合結果の上位件数を確認可能。
  5. 列名の重複回避
    • 左右のCSVで同一の列名が存在する場合、自動で 列名_1 のようにリネーム。

使い方

スクリプトを実行すると、対話形式で以下のステップが進みます。

  1. 左側(メイン)ファイル選択:ベースとなるCSVファイルを選択
  2. 左側の結合キー選択:突合キーにする列を1つ選択
  3. 左側の出力列選択:出力したい列を複数選択(Ctrl+クリックで複数指定)
  4. 右側(マスタ)ファイル選択:結合したいCSVファイルを選択
  5. 右側の結合キー選択:突合キーにする列を1つ選択
  6. 右側の出力列選択:追加結合したい列を複数選択
  7. プレビュー表示:指定した件数の結合結果がテーブル形式で表示される
  8. 保存:「保存する」ボタンをクリックし、保存先とファイル名を指定して出力

スクリプト全文

拡張子 .ps1(例: CsvJoinTool.ps1)で保存して実行してください。


# Windows Forms(GUI画面)を使用するためのアセンブリ(部品)を読み込みます
Add-Type -AssemblyName System.Windows.Forms

# VisualBasicの機能(InputBoxなど)を使用するためのアセンブリを読み込みます
Add-Type -AssemblyName Microsoft.VisualBasic

# Windowsのモダンな視覚スタイルを有効にして、ボタンなどをきれいに表示させます
[System.Windows.Forms.Application]::EnableVisualStyles()

# .NET Core/5以降の環境でもShift_JISなどの文字コードを使えるようにプロバイダを登録します(エラー時は無視します)
try { [System.Text.Encoding]::RegisterProvider([System.Text.CodePagesEncodingProvider]::Instance) } catch { }

# 結合キーの前後から取り除く不要な文字(半角スペース、タブ、改行、ダブルクォート、BOMなど)の配列を定義します
$global:TrimChars = [char[]]@(' ', "`t", "`r", "`n", '"', [char]65279, [char]8203)

# ==============================================================================
# ヘルパー関数群(文字コード判定やファイル読み込みなどの補助処理)
# ==============================================================================

# ファイルの文字コード(UTF-8かShift_JISか)を自動判別する関数です
function Get-FileEncoding([string]$Path) {
    # ファイルを読み込み専用モードで開きます
    $fs = [System.IO.FileStream]::new($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
    try {
        # 先頭の8192バイトを読み込むためのバッファ(入れ物)を作成します
        $buffer = [byte[]]::new(8192)
        # 実際にファイルからバッファへデータを読み込み、読み込めたバイト数を取得します
        $bytesRead = $fs.Read($buffer, 0, $buffer.Length)
    } finally {
        # 処理が終わったら、ファイルストリームを確実に閉じてメモリを解放します
        $fs.Dispose()
    }

    # 先頭の3バイトが「EF BB BF」であれば、UTF-8(BOM付き)と判定します
    if ($bytesRead -ge 3 -and $buffer[0] -eq 0xEF -and $buffer[1] -eq 0xBB -and $buffer[2] -eq 0xBF) {
        # UTF-8のエンコーディングオブジェクトを返します
        return [System.Text.Encoding]::UTF8
    }

    # BOMがない場合、不正な文字があればエラーを投げる設定でUTF-8エンコーディングオブジェクトを作成します
    $utf8 = [System.Text.UTF8Encoding]::new($false, $true)
    try {
        # 読み込んだバイト列をUTF-8として文字列に変換してみます
        [void]$utf8.GetString($buffer, 0, $bytesRead)
        # エラーが出なければUTF-8と判定して返します
        return [System.Text.Encoding]::UTF8
    } catch {
        # エラーが出た場合(UTF-8として不正なバイトがある場合)はShift_JISと判定して返します
        return [System.Text.Encoding]::GetEncoding("Shift_JIS")
    }
}

# CSVファイルの1行目(ヘッダー)を読み込み、カンマで分割して列名の配列として返す関数です
function Get-CsvHeaders([string]$Path, [System.Text.Encoding]$Enc) {
    # テキストファイルを指定した文字コードで開くためのStreamReaderを作成します
    $reader = [System.IO.StreamReader]::new($Path, $Enc, $true)
    try {
        # ファイルの1行目を読み込みます
        $firstLine = $reader.ReadLine()
        # 1行目が空でなければ、カンマで分割して配列として返します
        if ($firstLine) { return $firstLine.Split(',') }
        # 1行目が空の場合は空の配列を返します
        return @()
    } finally {
        # StreamReaderを確実に閉じます
        $reader.Dispose()
    }
}

# ファイル全体の行数を高速にカウントする関数です
function Get-RowCount([string]$Path, [System.Text.Encoding]$Enc) {
    try {
        # ファイルを1行ずつ遅延評価で読み込みます(一度に全て読み込まないのでメモリを節約できます)
        $lines = [System.IO.File]::ReadLines($Path, $Enc)
        # 読み込んだ行の総数をカウントします
        $count = [System.Linq.Enumerable]::Count($lines)
        # 行数が1行以上(ヘッダーがある)なら、ヘッダー分(1)を引いたデータ件数を返します
        if ($count -gt 0) { return $count - 1 }
        # 行がない場合は0を返します
        return 0
    } catch {
        # エラー発生時はプレビュー処理などを止めないよう、仮の大きな数字を返します
        return 999999
    }
}

# ファイル選択ダイアログを表示する関数です
function Show-OpenFileDialog([string]$Title, $ParentForm) {
    # ファイルを開くダイアログのオブジェクトを作成します
    $dialog = [System.Windows.Forms.OpenFileDialog]::new()
    # 選択できるファイルの種類をCSVファイル等に限定するフィルターを設定します
    $dialog.Filter = "CSVファイル (*.csv)|*.csv|すべてのファイル (*.*)|*.*"
    # ダイアログのタイトルバーに表示する文字列を設定します
    $dialog.Title = $Title
    # ダイアログを表示し、「開く(OK)」が押されたか判定します
    if ($dialog.ShowDialog($ParentForm) -eq [System.Windows.Forms.DialogResult]::OK) {
        # 選択されたファイルのフルパスを返します
        return $dialog.FileName
    }
    # キャンセルされた場合は何も返さない($null)ようにします
    return $null
}

# ==============================================================================
# データ処理関数
# ==============================================================================

# マスタデータ(右側)を読み込み、検索用の辞書(連想配列)を作成する関数です
function Build-MasterDictionary($Path, $Enc, $KeyIndex, [int[]]$OutIndices) {
    # 検索を高速に行うため、文字列をキーと値に持つ辞書を作成します(初期容量100万件、大文字小文字を区別しない)
    $dict = [System.Collections.Generic.Dictionary[string, string]]::new(1000000, [System.StringComparer]::OrdinalIgnoreCase)
    
    # 読み込みを高速化するため、大きめのバッファ(128KB)を持ったStreamReaderを作成します
    $reader = [System.IO.StreamReader]::new($Path, $Enc, $true, 131072)
    
    # 出力する列の数を変数に入れておきます(ループ内での計算を省くため)
    $outCount = $OutIndices.Length
    
    try {
        # 1行目(ヘッダー)を読み飛ばします
        [void]$reader.ReadLine()
        
        # 抽出した列のデータを一時的に格納する配列を用意します
        $rowBuffer = [string[]]::new($outCount)

        # ファイルの最後までループします
        while (-not $reader.EndOfStream) {
            # 1行テキストとして読み込みます
            $line = $reader.ReadLine()
            # 空行であれば次の行へ進みます
            if ([string]::IsNullOrWhiteSpace($line)) { continue }

            # カンマで分割して配列にします
            $fields = $line.Split(',')
            
            # 分割した配列の長さが、キーの列番号より大きい(データが存在する)場合のみ処理します
            if ($fields.Length -gt $KeyIndex) {
                # キーとなる列の値を取り出します
                $rawKey = $fields[$KeyIndex]
                # キーから不要な空白や見えない文字を削除します
                $key = if ([string]::IsNullOrEmpty($rawKey)) { "" } else { $rawKey.Trim($global:TrimChars) }

                # キーが空ではなく、まだ辞書に登録されていない場合のみ追加処理を行います
                if ($key.Length -gt 0 -and -not $dict.ContainsKey($key)) {
                    
                    # 抽出対象として選ばれた列の数だけループを回します
                    for ($i = 0; $i -lt $outCount; $i++) {
                        # 抽出したい列のインデックス番号を取得します
                        $idx = $OutIndices[$i]
                        # 実際の列数がインデックス番号より大きければ値を取得し、なければ空文字にします
                        $rowBuffer[$i] = if ($idx -lt $fields.Length) { $fields[$idx] } else { "" }
                    }
                    
                    # 配列のままではなく、カンマで結合した「1つの文字列」として辞書に保存します
                    $dict[$key] = [string]::Join(",", $rowBuffer)
                }
            }
        }
    } finally {
        # StreamReaderを確実に閉じます
        $reader.Dispose()
    }
    # 完成した辞書を返します
    return $dict
}

# 左側のデータを読み込みながら、右側のデータを結合してファイルに書き出す関数です
function Save-JoinedData($OutPath, $OutHeaders, $LeftPath, $LeftEnc, $LeftKeyIndex, [int[]]$LeftIndices, $RightDict, $RightCount) {
    # 出力用の文字コードを設定します(左側がUTF-8ならBOM付きUTF-8にします)
    $outEnc = if ($LeftEnc.WebName -match "(?i)^utf-8$") { [System.Text.UTF8Encoding]::new($true) } else { $LeftEnc }
    
    # 左側から出力する列数を変数に格納します
    $leftCount = $LeftIndices.Length

    # ファイルの書き込みを高速化するため、特大バッファ(1MB)を持ったStreamWriterを作成します
    $writer = [System.IO.StreamWriter]::new($OutPath, $false, $outEnc, 1048576)
    # バッファがいっぱいになるまで自動でディスクに書き込まないように設定します
    $writer.AutoFlush = $false
    
    # 左側のファイルを読み込むためのStreamReaderを作成します(バッファ128KB)
    $reader = [System.IO.StreamReader]::new($LeftPath, $LeftEnc, $true, 131072)
    
    # 右側の結合データが見つからなかった場合に埋めるための「空のカンマ文字列(例: ",,")」を作成します
    $emptyRightStr = if ($RightCount -gt 0) { [string]::new(',', $RightCount - 1) } else { "" }

    try {
        # 左側ファイルの1行目(ヘッダー)を読み飛ばします
        [void]$reader.ReadLine()
        
        # 出力用のヘッダーをカンマで結合し、ファイルに1行目として書き込みます
        $writer.WriteLine([string]::Join(",", $OutHeaders))
        
        # 進行状況を確認するための行数カウンターを初期化します
        $lineCount = 0
        # 文字列を効率よく連結するためのStringBuilderオブジェクトを作成します(毎回のメモリ確保を減らします)
        $sb = [System.Text.StringBuilder]::new(512)
        
        # 左側のファイルが最後まで達するまでループします
        while (-not $reader.EndOfStream) {
            # 1行読み込みます
            $line = $reader.ReadLine()
            # 空行ならスキップします
            if ([string]::IsNullOrWhiteSpace($line)) { continue }
            
            # カンマで分割して配列にします
            $fields = $line.Split(',')
            
            # 結合キーとなる値を取得します
            $rawKey = if ($fields.Length -gt $LeftKeyIndex) { $fields[$LeftKeyIndex] } else { "" }
            # 結合キーの不要な空白を取り除きます
            $key = if ($rawKey.Length -gt 0) { $rawKey.Trim($global:TrimChars) } else { "" }
            
            # 文字列連結用バッファ(StringBuilder)の中身を空にします
            [void]$sb.Clear()
            
            # 左側データの出力対象列を順番にバッファへ追加していきます
            for ($i = 0; $i -lt $leftCount; $i++) {
                # 抽出対象のインデックスを取得します
                $idx = $LeftIndices[$i]
                # インデックスがデータ範囲内なら値を取得し、範囲外なら空文字とします
                $val = if ($idx -lt $fields.Length) { $fields[$idx] } else { "" }
                
                # バッファに値を追加します
                [void]$sb.Append($val)
                # 最後の列でなければカンマを追加します
                if ($i -lt $leftCount - 1) { [void]$sb.Append(",") }
            }
            
            # 右側データを出力する設定がある場合の処理です
            if ($RightCount -gt 0) {
                # 左側データの終端にカンマを追加して、右側データを繋ぐ準備をします
                [void]$sb.Append(",")
                
                # 辞書から取得した値を入れるための変数を用意します
                $rightStr = $null
                # 辞書内にキーが存在するか確認し、存在すれば $rightStr に取得します
                if ($RightDict.TryGetValue($key, [ref]$rightStr)) {
                    # 事前に結合しておいた右側データの文字列をそのままバッファに追加します
                    [void]$sb.Append($rightStr)
                } else {
                    # キーが存在しない場合は、空のカンマ文字列を追加して列数を合わせます
                    [void]$sb.Append($emptyRightStr)
                }
            }
            
            # 組み立てた1行分の文字列をファイルに書き込みます
            $writer.WriteLine($sb.ToString())
            
            # 処理した行数を1つ増やします
            $lineCount++
            # アプリケーションがフリーズしないよう、50万行に1回だけ画面のイベントを処理させます(回数を減らして高速化)
            if ($lineCount % 500000 -eq 0) { 
                [System.Windows.Forms.Application]::DoEvents()
            }
        }
    } finally {
        # 読み込み用のリーダーを確実に閉じます
        $reader.Dispose()
        # 書き込み用のライターを確実に閉じます(ここでバッファに残ったデータが完全にファイルに書き出されます)
        $writer.Dispose()
    }
}

# ==============================================================================
# メインプロセス(画面の表示や処理の進行を管理する本体)
# ==============================================================================
function Start-CsvJoinProcess {
    # ダイアログを画面の最前面に表示するための、透明な親ウィンドウを作成します
    $parent = [System.Windows.Forms.Form]::new()
    # 常に最前面に表示する設定にします
    $parent.TopMost = $true
    # タスクバーには表示しないようにします
    $parent.ShowInTaskbar = $false
    # ウィンドウの状態を最小化にします
    $parent.WindowState = "Minimized"
    # ウィンドウを表示します
    $parent.Show()

    try {
        # --- 1. メインデータ(左側)の選択と設定 ---
        # 左側のファイルを選択するダイアログを表示します
        $leftPath = Show-OpenFileDialog "1/6: メインデータ(左側)を選択してください" $parent
        # ファイルが選択されなかったら処理を終了します
        if (-not $leftPath) { return }

        # 選択されたファイルの文字コードを判別します
        $leftEnc = Get-FileEncoding $leftPath
        # 解析中であることをコンソールに表示します
        Write-Host "左側データの列名を解析中... (文字コード: $($leftEnc.WebName))"

        # ファイルの1行目を読み込んでヘッダー配列を取得します
        $leftHeaders = Get-CsvHeaders $leftPath $leftEnc
        # ヘッダーが取得できなければエラーメッセージを出して終了します
        if (-not $leftHeaders) { [System.Windows.Forms.MessageBox]::Show("左側データの列名が取得できません。"); return }

        # ヘッダー一覧を画面に表示し、結合キーを1つ選択させます
        $leftKey = $leftHeaders | Out-GridView -Title "2/6: 左側の【結合キー】となる列を選択してください" -OutputMode Single
        # キーが選択されなかったら終了します
        if (-not $leftKey) { return }
        # 選択されたキーが、ヘッダー配列の何番目(インデックス)にあるかを取得します
        $leftKeyIndex = [array]::IndexOf($leftHeaders, $leftKey)

        # ヘッダー一覧を画面に表示し、出力したい列を複数選択させます
        [string[]]$selectedLeftHeaders = @($leftHeaders | Out-GridView -Title "3/6: 左側データから出力する列を【複数選択】(Ctrl+Click)" -OutputMode Multiple)
        # 1つも選択されなかったら終了します
        if ($selectedLeftHeaders.Count -eq 0) { [System.Windows.Forms.MessageBox]::Show("キャンセルされました。"); return }
        
        # 選択された列名から元のインデックス番号を調べ、整数配列として取得します
        [int[]]$leftOutIndices = $selectedLeftHeaders | ForEach-Object { [array]::IndexOf($leftHeaders, $_) }


        # --- 2. マスタデータ(右側)の選択と設定 ---
        # 右側のファイルを選択するダイアログを表示します
        $rightPath = Show-OpenFileDialog "4/6: マスタデータ(右側)を選択してください" $parent
        # ファイルが選択されなかったら終了します
        if (-not $rightPath) { return }

        # 右側ファイルの文字コードを判別します
        $rightEnc = Get-FileEncoding $rightPath
        # 解析中であることをコンソールに表示します
        Write-Host "右側データの列名を解析中... (文字コード: $($rightEnc.WebName))"

        # 右側ファイルの1行目を読み込んでヘッダー配列を取得します
        $allRightHeaders = Get-CsvHeaders $rightPath $rightEnc
        # ヘッダーが取得できなければ終了します
        if (-not $allRightHeaders) { [System.Windows.Forms.MessageBox]::Show("右側データの列名が取得できません。"); return }

        # 右側の結合キーを1つ選択させます
        $rightKey = $allRightHeaders | Out-GridView -Title "5/6: 右側の【結合キー】となる列を選択してください" -OutputMode Single
        # キーが選択されなかったら終了します
        if (-not $rightKey) { return }
        # 選択されたキーのインデックス番号を取得します
        $rightKeyIndex = [array]::IndexOf($allRightHeaders, $rightKey)

        # 右側から結合する列を格納する配列を用意します
        [string[]]$selectedRightHeaders = @()
        
        # 右側の列が存在する場合の処理です
        if ($allRightHeaders.Length -gt 0) {
            # 結合する列を複数選択させます(結合キーも含めてすべての列を選択可能)
            $selectedRightHeaders = @($allRightHeaders | Out-GridView -Title "6/6: 右側データから結合する列を【複数選択】(Ctrl+Click)" -OutputMode Multiple)
            # 1つも選択されなかったら終了します
            if ($selectedRightHeaders.Count -eq 0) { [System.Windows.Forms.MessageBox]::Show("キャンセルされました。"); return }
        }

        # 選択された右側列名から元のインデックス番号を調べ、整数配列として取得します
        [int[]]$rightOutIndices = $selectedRightHeaders | ForEach-Object { [array]::IndexOf($allRightHeaders, $_) }


        # --- 3. 行数カウントとプレビュー設定 ---
        # 行数をカウントする旨をコンソールに表示します
        Write-Host "左側データの行数をカウントしています..."
        # 左側ファイルのデータ行数を取得します
        $leftDataRowCount = Get-RowCount $leftPath $leftEnc
        # プレビュー表示する行数の初期値を50行に設定します
        $previewCount = 50

        # データ総数が50行を超える場合、プレビュー件数をユーザーに入力させます
        if ($leftDataRowCount -gt $previewCount) {
            # 入力ダイアログを表示します
            $input = [Microsoft.VisualBasic.Interaction]::InputBox("画面へプレビュー表示する『上位何行か』を入力してください。`r`n(例: 50)", "プレビュー件数の指定", "50")
            # キャンセルされたり、不正な値が入力された場合は終了します
            if ([string]::IsNullOrWhiteSpace($input) -or -not [int]::TryParse($input, [ref]$previewCount) -or $previewCount -le 0) { return }
        } else {
            # 50行以下の場合は、データ総数をそのままプレビュー件数とします
            $previewCount = $leftDataRowCount
        }


        # --- 4. 出力ヘッダーの構築 ---
        # 最終的に出力するヘッダー名のリストを作成します
        $outHeaders = [System.Collections.Generic.List[string]]::new()
        # 左側から選んだヘッダーをすべて追加します
        $outHeaders.AddRange($selectedLeftHeaders)
        
        # 右側から選んだヘッダーを追加する処理です(左側のヘッダー名と重複しないようにします)
        foreach ($h in $selectedRightHeaders) {
            # 新しいヘッダー名の候補を変数に入れます
            $newHeader = $h
            # 重複した場合につける連番です
            $suffix = 1
            # 既に同じ名前のヘッダーが存在する間、ループします
            while ($outHeaders.Contains($newHeader)) {
                # 名前_1、名前_2 のように連番をつけて重複を回避します
                $newHeader = "${h}_${suffix}"; $suffix++
            }
            # 重複しなくなったヘッダー名をリストに追加します
            $outHeaders.Add($newHeader)
        }


        # --- 5. データ処理 (メモリ読み込みとプレビュー作成) ---
        Write-Host "=========================================="
        Write-Host "マスタデータ(右側)をメモリに読み込んでいます..."
        # 先ほど定義した処理関数を呼び出し、右側データを辞書化します
        $rightDict = Build-MasterDictionary -Path $rightPath -Enc $rightEnc -KeyIndex $rightKeyIndex -OutIndices $rightOutIndices

        Write-Host "プレビュー用のデータを抽出中..."
        # プレビュー画面の表に表示するためのデータリストを作成します
        $previewData = [System.Collections.Generic.List[string[]]]::new()
        
        # 左側ファイルを読み込むリーダーを作成します
        $reader = [System.IO.StreamReader]::new($leftPath, $leftEnc)
        try {
            # ヘッダー行を読み飛ばします
            [void]$reader.ReadLine()

            # 処理した件数を数えるカウンターです
            $count = 0
            # ファイルの末尾に達しない、かつ、指定したプレビュー件数に達するまでループします
            while (-not $reader.EndOfStream -and $count -lt $previewCount) {
                # 1行読み込みます
                $line = $reader.ReadLine()
                # 空行ならスキップします
                if ([string]::IsNullOrWhiteSpace($line)) { continue }

                # カンマで分割します
                $fields = $line.Split(',')

                # 結合キーを取得し、空白を除去します(安全性を考慮した書き方に統一)
                $rawKey = if ($fields.Length -gt $leftKeyIndex) { $fields[$leftKeyIndex] } else { "" }
                # 取得した値から不要な空白を取り除きます
                $key = if ($rawKey.Length -gt 0) { $rawKey.Trim($global:TrimChars) } else { "" }

                # 左側のデータを抽出して配列化します(リファクタリング箇所:配列キャストとパイプラインによる短縮化)
                [string[]]$leftVals = $leftOutIndices | ForEach-Object { if ($_ -lt $fields.Length) { $fields[$_] } else { "" } }

                # 辞書から取得する右側の文字列を入れる変数です
                $rightValsStr = $null
                # 辞書にキーが存在するか確認します
                if ($rightDict.TryGetValue($key, [ref]$rightValsStr)) {
                    # 存在する場合は、文字列を再びカンマで分割して配列化し、左側の配列と結合します
                    $outRow = $leftVals + $rightValsStr.Split(',')
                } else {
                    # 存在しない場合は、右側の列数分だけ空の配列を作成し、左側の配列と結合します
                    $outRow = $leftVals + ([string[]]::new($rightOutIndices.Length))
                }
                
                # 結合した1行分の配列を、プレビューデータの親リストに追加します
                $previewData.Add($outRow)
                # 処理件数を1つ増やします
                $count++
            }
        } finally {
            # リーダーを確実に閉じます
            $reader.Dispose()
        }

        # --- 6. プレビュー画面の表示と保存処理 ---
        # プレビューを表示するためのウィンドウ(フォーム)を作成します
        $form = [System.Windows.Forms.Form]::new()
        # フォームのタイトルを設定します
        $form.Text = "プレビュー ($previewCount 行) - 出力文字コード: $($leftEnc.WebName)"
        # フォームのサイズを設定します
        $form.Size = [System.Drawing.Size]::new(900, 600)
        # 画面の中央に表示させます
        $form.StartPosition = "CenterScreen"
        # 常に最前面に表示させます
        $form.TopMost = $true 

        # 表計算ソフトのようなグリッドビュー(表)を作成します
        $grid = [System.Windows.Forms.DataGridView]::new()
        # ウィンドウの全体に広がるように配置します
        $grid.Dock = "Fill"
        # ユーザーが新しい行を手動で追加できないようにします
        $grid.AllowUserToAddRows = $false
        # 読み取り専用にします
        $grid.ReadOnly = $true
        # 行の左端にあるヘッダー(グレーの余白)を非表示にします
        $grid.RowHeadersVisible = $false
        # セルの文字数に合わせて列幅を自動調整します
        $grid.AutoSizeColumnsMode = "DisplayedCells"
        # 表の列数を設定します
        $grid.ColumnCount = $outHeaders.Count

        # 列名(ヘッダー)を設定するためのループです
        for ($i = 0; $i -lt $outHeaders.Count; $i++) {
            # 列の内部名を設定します
            $grid.Columns[$i].Name = $outHeaders[$i]
            # 画面上に表示する列名を設定します
            $grid.Columns[$i].HeaderText = $outHeaders[$i]
        }
        
        # プレビューデータを1行ずつグリッドに追加していきます
        foreach ($row in $previewData) { [void]$grid.Rows.Add([object[]]$row) }

        # 保存ボタンを配置するためのパネル(土台)を作成します
        $panel = [System.Windows.Forms.Panel]::new()
        # ウィンドウの下部に配置します
        $panel.Dock = "Bottom"
        # パネルの高さを50ピクセルにします
        $panel.Height = 50

        # 保存用のボタンを作成します
        $btnSave = [System.Windows.Forms.Button]::new()
        # ボタンの文字を設定します
        $btnSave.Text = "保存する"
        # ボタンのフォントをメイリオの太字・サイズ10に設定します
        $btnSave.Font = [System.Drawing.Font]::new("Meiryo", 10, [System.Drawing.FontStyle]::Bold)
        # ボタンのサイズを設定します
        $btnSave.Size = [System.Drawing.Size]::new(130, 35)
        # ボタンの配置場所(左から10、上から7ピクセル)を設定します
        $btnSave.Location = [System.Drawing.Point]::new(10, 7)

        # 保存ボタンがクリックされた時の動作(イベント)を定義します
        $btnSave.Add_Click({
            # ファイルを保存するダイアログを作成します
            $sfd = [System.Windows.Forms.SaveFileDialog]::new()
            # ダイアログのタイトルを設定します
            $sfd.Title = "保存先とファイル名を選択してください"
            # 保存できるファイルの種類をCSVに限定します
            $sfd.Filter = "CSVファイル (*.csv)|*.csv|すべてのファイル (*.*)|*.*"
            # 保存するファイル名の初期候補を設定します
            $sfd.FileName = "結合出力結果.csv"
            # ダイアログを開いたときの初期フォルダを、左側データと同じ場所になります
            $sfd.InitialDirectory = Split-Path $leftPath -Parent
            
            # 保存ダイアログで「保存(OK)」が押されたか判定します
            if ($sfd.ShowDialog($form) -ne [System.Windows.Forms.DialogResult]::OK) { return }

            # 処理中であることがわかるよう、タイトルバーの文字を変更します
            $form.Text = "保存中... 100万行のデータ処理には数十秒かかります。そのままお待ちください。"
            # 画面の表示を強制的に更新します
            $form.Refresh()
            # ボタンを無効化して、二度押しを防ぎます
            $btnSave.Enabled = $false

            try {
                # ファイルに結合結果を書き出す保存関数を呼び出します
                Save-JoinedData -OutPath $sfd.FileName -OutHeaders $outHeaders -LeftPath $leftPath `
                                -LeftEnc $leftEnc -LeftKeyIndex $leftKeyIndex `
                                -LeftIndices $leftOutIndices -RightDict $rightDict -RightCount $rightOutIndices.Length
                
                # 保存が完了したことをポップアップメッセージで知らせます
                [System.Windows.Forms.MessageBox]::Show("保存が完了しました!`r`n出力先: $($sfd.FileName)", "完了", 0, 64)
                # プレビューウィンドウを閉じます
                $form.Close()
            } catch {
                # エラーが発生した場合は、エラー内容をポップアップで表示します
                [System.Windows.Forms.MessageBox]::Show("保存中にエラーが発生しました。`r`n$_", "エラー", 0, 16)
            } finally {
                # エラーの有無にかかわらず、処理が終わったらボタンを再度有効にします
                $btnSave.Enabled = $true
            }
        })

        # パネルの中に保存ボタンを追加します
        $panel.Controls.Add($btnSave)
        # フォームの中にグリッド(表)を追加します
        $form.Controls.Add($grid)
        # フォームの中にパネル(ボタンの土台)を追加します
        $form.Controls.Add($panel)

        # プレビューウィンドウをユーザーが閉じるまで画面に表示し続けます
        [void]$form.ShowDialog()

    } finally {
        # 処理がすべて終わるか、途中でエラーになって終了する際の最終処理です
        if ($parent) { 
            # 透明な親ウィンドウを閉じます
            $parent.Close()
            # 親ウィンドウが使っていたメモリを解放します
            $parent.Dispose() 
        }
        # PowerShellのガベージコレクション(不要なメモリの掃除)を強制実行し、メモリを綺麗にします
        [System.GC]::Collect()
    }
}

# 構築したメインプロセス(関数)を呼び出して、スクリプトの実行を開始します
Start-CsvJoinProcess


高速・省メモリ化のための技術的工夫

なぜこのPowerShellスクリプトが100万行を超えるCSVを軽快に処理できるのか、要点を解説します。

1. Import-Csv やパイプラインの排除

PowerShell標準の Import-Csv は、各行を PSCustomObject に変換するため莫大なメモリを消費し、速度も低下します。
本スクリプトでは .NETSystem.IO.StreamReader を直接使用し、文字列のままストリーム処理しています。

2. メモリ消費を激減させる「辞書の値」の工夫

マスタデータ(右側)はキー検索のためにメモリ(Dictionary)に乗せる必要があります。
ここで各行を string[](配列)として保持するとオブジェクトのオーバーヘッドが大きくなるため、出力対象列をあらかじめカンマで連結した1つの string として保持しています。

# 配列のままではなく、カンマ区切り文字列にして辞書に詰める
$dict[$key] = [string]::Join(",", $rowBuffer)

これにより、オブジェクトヘッダーの消費を抑え、GC(ガベージコレクション)の負荷を大幅に削減しています。

3. バッファサイズの最適化と StringBuilder

  • I/Oバッファ:読み込み側は 128KB、書き込み側は 1MB の内部バッファを割り当ててディスクI/O回数を削減しています。
  • 文字列結合:1行の結合には + 演算子ではなく System.Text.StringBuilder を使い、メモリ割り当ての回数を最小限に抑えています。

4. 高速な文字コード自動判別

先頭8KBを読み取り、BOM(EF BB BF)の有無を判定。BOMがない場合も System.Text.UTF8Encoding($false, $true) を使って「UTF-8として妥当か」を高速チェックし、不正なバイトがあればShift_JIS(CP932)とみなす仕組みにしています。

実行時の注意点

  • PowerShell 5.1(Windows標準) / PowerShell 7 双方で動作します。
    • .NET Core環境向けに CodePagesEncodingProvider の登録コードも含めているため、PowerShell 7でもShift_JISが正常に動作します。
  • 値の中に改行を含むCSVには対応していません(1行=1レコード前提で ReadLine()Split(',') による高速処理を行っているため)。

まとめ

PowerShellは「遅い」「重い」と言われがちですが、内部で動作している .NET のプリミティブなクラスを適切に使うことで、C#やGoなどのコンパイル言語に迫る速度で巨大データを処理できます。

「非エンジニアにCSVの加工作業を任せたいけれど環境構築ができない」という場面などに、ぜひ活用してみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?