【.NET】固定長ファイル(複数行一組対応)の読み込み

# はじめに
これは[Visual Basic Advent Calendar 2017](https://qiita.com/advent-calendar/2017/vb)の21日の記事となります。

1年前に固定長ファイルを使用するアプリケーションを作りました。
その固定長ファイルは2行で1組という過去に見たことがないタイプでした。

# 定義ファイル
過去にADOで固定長テキストを読み込む際にスキーマファイル(schema.ini)を使用したことがあります。
今回は独自に専用のスキーマファイル(schema.ini)もどき作成しました。
※schema.ini をシェーマ.iniって最近まで読んでました(^-^;

サンプルデータを考えるのが面倒だったので、次のサイトのデータを2行1組に分けて利用しました。
[固定長形式テキストデータの読み込み - Excelでお仕事!](http://www.asahi-net.or.jp/~ef2o-inue/vba_o/sub05_110_035.html)

|No|項目名|タイプ|桁数|
|:--|:--|:--|:--|
|1|コード|文字|5|
|2|メーカー|文字|10|
|3|品名|文字|15|

|No|項目名|タイプ|桁数|
|:--|:--|:--|:--|
|1|コード|文字|5|
|2|数量|数値|4|
|3|単価|数値|6|
|4|金額|数値|8|

```ini:Sample.ini
[CONFIG]
ColNameHeader=False
Format=FixedLength
FixedFormat=RaggedEdge

[LINE1]
Col1=CODE       Char Width 5    ;コード
Col2=MAKER      Char Width 10   ;メーカー
Col3=HINNAME    Char Width 15   ;品名

[LINE2]
Col1=CODE       Char Width 5    ;コード
Col2=SURYO      Integer Width 4 ;数量
Col3=TANKA      Integer Width 6 ;単価
Col4=KINGAKU    Integer Width 8 ;金額
```

本物のスキーマファイル(schema.ini)はセクションで複数ファイルを定義できるのですが、独自形式では固定長単位に(定義名).iniとしてセクションは行単位の定義としています。その為、複数の固定長定義があれば、その分定義ファイルが必要となります。

# 固定長データ
1行目が30桁、2行目は23桁の固定長データのファイルです。

```bash:Sample.txt
101  あああ    そうめん       
101  000100010000000100
102  いい      ひやむぎ       
102  000500020000001000
103  ううううう北海道味噌ラー 
103  001000045000004500
104  え        焼きｿﾊﾞ        
104  002000035000007000
105  おおお    讃岐うどん     
105  010000010000010000
```

# 出力結果
DataSet型で2つのテーブル(セクションがテーブル名)が格納されます。

```vb:sample.vb
Dim ts As TextFileStream = New TextFileStream

Dim dsTable As DataSet

Try
    ' スキーマ定義ファイルセット
    ts.SchemaPath = "Sample.ini"
    dsTable = ts.ImportData("Sample.txt", vbCrLf, True)

    MessageBox.Show("読み込み完了")

Catch ex As Exception
    MessageBox.Show(ex.Message)
End Try
```

## 1行目のDataTable型の格納内容

![Text_Line1.png](https://qiita-image-store.s3.amazonaws.com/0/14053/3eb2953a-d9d0-7938-2245-d28b6e0f448d.png)

## 2行目のDataTable型の格納内容

![Text_Line2.png](https://qiita-image-store.s3.amazonaws.com/0/14053/9501fe3a-ab28-720c-8fb6-6148cd89581a.png)


# ソースコード
TextFileStreamクラスを作成しています。
この中で、「Utility.ReadIni」は無いですが、次サイトのReadIniを参考にしています。
[INI形式のファイルを読む - smdn](http://smdn.jp/programming/netfx/tips/read_ini/)

```vb:TextFileStream
Imports System.Text
Imports System.IO
Imports System.Text.RegularExpressions

Public Class TextFileStream

    Public Property Section As Dictionary(Of String, String)()
    Public Property SchemaPath As String

    Public Const ERROR_INIFILE_NOTFOUND = "Ini File Not Found"         ' 定義ファイルが見つかりません
    Public Const ERROR_NEWLINE_MISMATCH = "Mismatch In New line code"  ' 改行コードが不一致
    Public Const ERROR_LENGTH_MISMATCH = "Mismatch In length"          ' 桁数が不一致

    ' 定義データオブジェクト
    Private _dsSchema As DataSet

    ''' <summary>
    ''' ファイルオープン
    ''' </summary>
    ''' <param name="path">テキストファイル</param>
    ''' <param name="lineSeparator">改行コード</param>
    ''' <param name="isLengthCheck">桁数チェック</param>
    ''' <returns>True : 成功 / False : 失敗</returns>
    Public Function ImportData(path As String, ByVal lineSeparator As String, Optional ByVal isLengthCheck As Boolean = False) As DataSet

        'ファイルを一時的に読み込むバイト型配列を作成する
        Dim bs(1) As Byte
        Dim bytesRead As Integer = 0
        Dim nBytes As Integer = 10
        Dim drRow As DataRow
        Dim dtTable As DataTable = Nothing
        Dim dsTable As DataSet = Nothing
        Dim lengthCheck As Dictionary(Of String, Integer) = New Dictionary(Of String, Integer)

        Try
            ' スキーマ定義用データセット生成
            If File.Exists(SchemaPath) = False Then
                ' 定義ファイルが見つかりません
                Throw New Exception(ERROR_INIFILE_NOTFOUND + "," + SchemaPath)
            Else
                _dsSchema = CreateSchemaDataSet(Utility.ReadIni(SchemaPath))
            End If

            ' 桁数を集計する
            For Each schema As DataTable In _dsSchema.Tables
                lengthCheck.Add(schema.TableName, 0)
                For Each row As DataRow In schema.Rows
                    lengthCheck(schema.TableName) += row("LENGTH")
                Next
            Next

            ' スキーマ定義に従ってテーブル生成
            dsTable = CreateTable(_dsSchema)

            Using fs As New FileStream(path, FileMode.Open, FileAccess.Read)
                Using sr As StreamReader = New StreamReader(fs)
                    While True
                        For Each schema As DataTable In _dsSchema.Tables
                            dtTable = dsTable.Tables(schema.TableName)
                            drRow = dtTable.NewRow()
                            Dim col As Integer = 0
                            Dim line As String = ""
                            For Each row As DataRow In schema.Rows
                                If row("LENGTH").ToString().IndexOf(",") > 0 Then
                                    ' 小数点の場合、データには小数点は含まない  123.4567→7,4(精度,位取り)と指定
                                    ' 先頭に符号がある場合、精度に符号を含めた桁を指定 +1234567→+123.4567→8,4
                                    ' 読む桁数は精度の値で指定すれば良い
                                    nBytes = Math.Truncate(Decimal.Parse(row("LENGTH").ToString.Replace(",", ".")))
                                Else
                                    nBytes = Integer.Parse(row("LENGTH").ToString())
                                End If
                                If nBytes = 0 Then Exit For

                                ReDim bs(nBytes - 1)
                                bytesRead = fs.Read(bs, 0, nBytes)
                                ' 先頭列が改行コードなら終了する
                                If col = 0 AndAlso bytesRead = 0 Then Exit While
                                If bytesRead = 0 Then Exit For

                                Dim str As String = Encoding.GetEncoding(932).GetString(bs)
                                drRow(row("NAME")) = str
                                line += str

                                col += 1
                            Next
                            dtTable.Rows.Add(drRow)

                            '改行コード分読み飛ばす
                            nBytes = lineSeparator.Length
                            ReDim bs(nBytes - 1)
                            bytesRead = fs.Read(bs, 0, nBytes)
                            If bytesRead = 0 Then Exit For
                            Dim newline As String = Encoding.GetEncoding(932).GetString(bs)
                            If newline <> lineSeparator Then
                                If newline = vbCr OrElse newline = vbCrLf OrElse newline = vbLf Then
                                    ' 改行コード不一致
                                    Throw New Exception(ERROR_NEWLINE_MISMATCH + "," + path)
                                Else
                                    ' 桁数不一致
                                    If isLengthCheck Then
                                        Dim len As Integer = lengthCheck(schema.TableName)
                                        Throw New Exception(ERROR_LENGTH_MISMATCH + "(" + len.ToString() + ")," + path)
                                    End If
                                End If
                            End If
                        Next
                        If bytesRead = 0 Then Exit While
                    End While
                End Using
            End Using

        Catch ex As Exception
            Throw
        End Try

        Return dsTable

    End Function

    ''' <summary>
    ''' スキーマ定義用データセット生成
    ''' </summary>
    ''' <param name="Section"></param>
    ''' <returns></returns>
    Private Function CreateSchemaDataSet(ByVal Section As Dictionary(Of String, Dictionary(Of String, String))) As DataSet

        Dim dsSchema As DataSet = New DataSet("Schemas")
        Dim dtSchema As DataTable = Nothing
        Dim drRow As DataRow
        Dim index As Integer = 0

        ' 定義解析用正規表現
        Dim r As New Regex("Col(\d+)=("""".*?""""|.*?)[\t ]+(.*?)[\t ]Width[\t ](\d+)", RegexOptions.IgnoreCase)

        For Each sec As KeyValuePair(Of String, Dictionary(Of String, String)) In Section
            index = 0
            For Each pair As KeyValuePair(Of String, String) In sec.Value
                Dim mc As MatchCollection = r.Matches(String.Format("{0}={1}", pair.Key, pair.Value))
                If mc.Count > 0 Then
                    For Each m As Match In mc
                        If index = 0 Then
                            '列指定先頭ならテーブル作成
                            dtSchema = dsSchema.Tables.Add(sec.Key)
                            dtSchema.Columns.Add("INDEX", Type.GetType("System.Int32"))
                            dtSchema.Columns.Add("NAME", Type.GetType("System.String"))
                            dtSchema.Columns.Add("TYPE", Type.GetType("System.String"))
                            dtSchema.Columns.Add("LENGTH", Type.GetType("System.String"))
                        End If

                        drRow = dtSchema.NewRow()
                        drRow("INDEX") = Integer.Parse(m.Groups.Item(1).ToString())     '列番
                        drRow("NAME") = m.Groups.Item(2).ToString()                     '名前
                        drRow("TYPE") = m.Groups.Item(3).ToString()                     '型
                        drRow("LENGTH") = m.Groups.Item(4).ToString()                   '長さ
                        dtSchema.Rows.Add(drRow)
                        index += 1
                    Next
                End If
            Next
        Next

        Return dsSchema

    End Function

    ''' <summary>
    ''' スキーマ定義に従ってテーブル生成
    ''' </summary>
    ''' <param name="schema"></param>
    ''' <remarks>引数のschemaからテーブルを作成する</remarks>
    ''' <returns></returns>
    Private Function CreateTable(ByVal schema As DataSet) As DataSet

        Dim dsTable As DataSet = New DataSet
        Dim dtTable As DataTable = Nothing

        For Each dt As DataTable In schema.Tables
            dtTable = dsTable.Tables.Add(dt.TableName)
            For Each row As DataRow In dt.Rows
                Dim tp As String = "System.String"

                Select Case UCase(row("TYPE"))
                    Case "BYTE"
                        tp = "System.Byte"
                    Case "CHAR", "TEXT"
                        tp = "System.String"
                    Case "SHORT"
                        tp = "System.Int16"
                    Case "INTEGER"
                        tp = "System.Int32"
                    Case "LONG"
                        tp = "System.Int64"
                    Case "DOUBLE"
                        tp = "System.Double"
                    Case "DECIMAL"
                        tp = "System.Decimal"
                    Case "FLOAT"
                        tp = "System.Single"
                    Case "DATE", "DATETIME"
                        tp = "System.Date"
                    Case "CURRENCY"
                        tp = "System.Currency"
                    Case "BIT", "Boolean"
                        tp = "System.Boolean"
                End Select

                dtTable.Columns.Add(row("NAME"), Type.GetType(tp))
            Next
        Next

        Return dsTable

    End Function

End Class
```

# ライセンスっぽいこと
コード改変や配布は自由です。
このツールによる義務／責任を何ら負いません。

# 最後に
え、C#版が欲しいって、これはVisual Basic Advent Calendarなんですよ。
DataSet型やDictionary型を使用しているので、Web系のConvert Code VB to C#は応答がなかったり変換できなかったします。
そのうち、GitHubに公開します。

# 参照
* [テキスト ファイルのフィールドを定義する方法 schema.ini](http://blog.esrij.com/2010/06/18/schemaini-301f/)