C#
ini
VisualBasic

【.NET】UTF-8対応Iniファイルの読み込みと書き込み

はじめに

これは、Visual Basic Advent Calendar 2017の24日目の記事となります。

設定ファイルをxml形式で作成したところ、ユーザーからIni形式で慣れているので変更して欲しいという要望がありました。
既に本体プログラムはReadXmlでDataSet型にして設定を読み込むむようになっていたため、本体プログラムに出来るだけ影響しないようにIni形式をDataSet型に変換するクラスを作成しました。

説明

下記のようなiniファイルがあった場合、Dictionary型およびDataSet型にして取得できます。
特徴として、セクション名が同じで末尾数字が違った場合、同一グループとして扱います。また、キー名も同様で末尾数字が同じなら同一グループになります。
iniファイルのパースはAPIを使わず独自に行っているため、UTF-8に対応しています。
また、読み込みだけでなく書き込みも出来ます。

※内部ではini形式をXML形式に変換してDataSetのReadXmlメソッドを読んでいます。

Sample.ini
; データベース
[DATABASE]
Host = 192.168.0.1
User = username
Pass = password

; 文字コード
[ENCODING]
enc = SJIS

; フォント情報
[FONT1]
FontType = Meiryo UI
FontSize = 36
FontColor = white
[FONT2]
FontType = MS ゴシック
FontSize = 24
FontColor = red
[FONT3]
FontType = Century Gothic
FontSize = 12
FontColor = green

; データ
[DATA1]
Code1 = 100
Str1 = Hoge
Code2 = 101
Str2 = Fuga
[DATA2]
Code1 = 200
Str1 = Hoge2
Code2 = 201
Str2 = Fuga2

読み込み

Sample.vb
Dim path As String = "Sample.ini"
Dim iniManager As IniManager = New IniManager()

' 文字コードをUTF-8とする。
iniManager.Encording = Encoding.UTF8
' Iniファイルの読み込み。FalseはDictionary型のみ、TrueならDataSet型にも変換する。
iniManager.ReadIni(path, True)

' Dictionary型による取得
Dim host As String = iniManager.GetString("DATABASE", "Host")

' DataSet型による取得
Dim ds As DataSet = iniManager.SectionDataSet
Dim dtFont As DataTable = ds.Tables("FONT")
Dim dtData As DataTable = ds.Tables("DATA")

セクション名(DATABASE)のDictionary型の内容
IniDic.png

セクション名(DATABASE)のDataTable型の内容
IniDataSet1.png

セクション名(ENCODING)のDataTable型の内容
IniDataSet2.png

セクション名(FONT1,FONT2,FONT3)のDataTable型の内容
同一セクション名(FONT)でまとめています。Grpの列が自動で追加され末尾数字がセットされます。末尾数字が無い場合、0 になります。
IniDataSet3.png

セクション名(DATA1,DATA2)のDataTable型の内容
同一セクション名(DATA)と同一キー名(Code,Str)でまとめています。GrpとIdxの列が自動で追加され末尾数字がセットされます。
IniDataSet4.png

書き込み

Dictionary型を指定

SetStringメソッドでセクション、キー、値をセットするとSectionDicプロパティを書き換えます。
また、SectionDicプロパティを使わず、書き換える部分のみのDictionary型を作成して指定することも出来ます。

Sample.vb
' hostを1920.168.0.1から127.0.0.1に変更する。
iniManager.SetString("DATABASE", "Host", "127.0.0.1")
' encをSJISからUTF-8に変更する。
iniManager.SetString("ENCODING", "enc", "UTF-8")
' 書き込み
iniManager.WriteIni(path, iniManager.SectionDic)

WriteIni3.png

DataTable型を指定

例ではDataTable型を指定して2回に分けて書き込みしていますが、DataSet型で一度に書き込むことが出来ます。

Sample.vb
' データテーブルで書き換える
' FONT2のFontColorをredからblueに書き込える
dtFont.Rows(1).Item("FontColor") = "blue"
iniManager.WriteIni(path, dtFont)
' DATA2のStr2のHoge2をHellowに書き込み
dtData.Rows(1).Item("Str") = "Hellow"
iniManager.WriteIni(path, dtData)

WriteIni4.png

ソースコード

IniManagerクラスを作成しています。

IniManager.vb
Imports System.IO
Imports System.Text
Imports System.Text.RegularExpressions

Public Class IniManager
    ' Iniファイル情報格納(OrderedDictionary型)
    Public Property SectionDic() As OrderedDictionary(Of String, OrderedDictionary(Of String, String))
    ' Iniファイル情報格納(DataSet型)
    Public Property SectionDataSet() As DataSet
    ' エンコーディング
    Public Property Encording() As Encoding
    ' セクション番号区切り文字
    Public Property SectionSeparator() As String
    ' キー番号区切り文字
    Public Property KeySeparator() As String
    ' ルート名
    Public Property RootName() As String

    ' セクショングループ用属性値
    Public Const SECTION_GROUP_ATTR As String = "Grp"
    ' パラメーターグループ用属性値
    Public Const PARAMETER_INDEX_ATTR As String = "Idx"
    ' XML用ルート名
    Public Const DEFAULT_ROOT_NAMER As String = "Root"

    ' セクション用正規表現パターン
    Const SECTION_PATTERN As String = "^\s*\[(?<section>[^\]]+)\].*$"
    ' パラメーター用正規表現パターン
    Const PARAMETER_PATTERN As String = "^\s*(?<name>[^=]+)=(?<value>.*?)(\s+;(?<comment>.*))?$"

    ' コンストラクタ
    Public Sub New()
        Encording = Encoding.UTF8
        SectionSeparator = ""
        KeySeparator = ""
        RootName = DEFAULT_ROOT_NAMER
    End Sub

    ''' <summary>
    ''' 設定値を取得する
    ''' </summary>
    ''' <param name="section">セクション名</param>
    ''' <param name="keyname">キー名</param>
    ''' <returns> 設定値</returns>
    Public Function GetString(section As String, keyname As String) As String
        If Not SectionDic.ContainsKey(section) OrElse Not SectionDic(section).ContainsKey(keyname) Then Return ""

        Return SectionDic(section)(keyname)
    End Function

    ''' <summary>
    ''' 設定値を取得する
    ''' </summary>
    ''' <param name="keyname">キー名</param>
    ''' <returns>設定値</returns>
    Public Function GetString(keyname As String) As String
        Return GetString("", keyname)
    End Function

    ''' <summary>
    ''' 設定情報を取得する
    ''' </summary>
    ''' <param name="tableName">テーブル名</param>
    ''' <returns>設定情報</returns>
    Public Function GetTable(tableName As String) As DataTable
        Return SectionDataSet.Tables(tableName)
    End Function

    ''' <summary>
    ''' 設定値を更新する
    ''' </summary>
    ''' <param name="section">セクション名</param>
    ''' <param name="keyname">キー名</param>
    ''' <param name="value">設定値</param>
    Public Sub SetString(section As String, keyname As String, value As String)
        SectionDic(section)(keyname) = value
    End Sub


    ''' <summary>
    ''' Iniファイル読み込み
    ''' </summary>
    ''' <param name="filePath">ファイルパス</param>
    ''' <returns>true : 正常 / false : 異常</returns>
    Public Function ReadIni(filePath As String, Optional isDataSet As Boolean = False) As Boolean

        ' Ini情報のセット
        SectionDic = GetSections(filePath)

        ' DataSet型に変換
        If isDataSet Then Return ToDataSet()

        Return True
    End Function

    ''' <summary>
    ''' DataSet型に変換する
    ''' </summary>
    ''' <returns>true : 正常 / false : 異常</returns>
    Public Function ToDataSet() As Boolean
        SectionDataSet = New DataSet()

        ' XMLデータに変換する
        Dim xml As String = ConvertDicToXML()
        If xml = "" Then Return False

        ' DataSet型に変換する
        Using reader As New StringReader(xml)
            SectionDataSet.ReadXml(reader)
        End Using

        Return True
    End Function

    ''' <summary>
    ''' Iniファイル情報を取得する
    ''' </summary>
    ''' <param name="filePath">ファイルパス</param>
    ''' <returns></returns>
    Public Function GetSections(filePath As String) As OrderedDictionary(Of String, OrderedDictionary(Of String, String))
        Using reader = New StreamReader(filePath, Encording)
            Dim sections = New OrderedDictionary(Of String, OrderedDictionary(Of String, String))(StringComparer.Ordinal)
            Dim regexSection = New Regex(SECTION_PATTERN, RegexOptions.Singleline Or RegexOptions.CultureInvariant)
            Dim regexNameValue = New Regex(PARAMETER_PATTERN, RegexOptions.Singleline Or RegexOptions.CultureInvariant)
            Dim currentSection = String.Empty

            ' セクション名が明示されていない先頭部分のセクション名を""として扱う
            sections(String.Empty) = New OrderedDictionary(Of String, String)()

            While True
                Dim line = reader.ReadLine()

                If line Is Nothing Then Exit While

                ' 空行は読み飛ばす
                If line.Length = 0 Then Continue While

                ' コメント行は読み飛ばす
                If line.StartsWith(";", StringComparison.Ordinal) Then
                    Continue While
                ElseIf line.StartsWith("#", StringComparison.Ordinal) Then
                    Continue While
                End If

                Dim matchNameValue = regexNameValue.Match(line)

                If matchNameValue.Success Then
                    ' name=valueの行
                    sections(currentSection)(matchNameValue.Groups("name").Value.Trim()) = matchNameValue.Groups("value").Value.Trim()
                    Continue While
                End If

                Dim matchSection = regexSection.Match(line)

                If matchSection.Success Then
                    ' [section]の行
                    currentSection = matchSection.Groups("section").Value

                    If Not sections.ContainsKey(currentSection) Then
                        sections(currentSection) = New OrderedDictionary(Of String, String)()
                    End If

                    Continue While
                End If
            End While

            Return sections
        End Using
    End Function

    ''' <summary>
    ''' Iniファイルの書き込み
    ''' </summary>
    ''' <param name="filePath">ファイルパス</param>
    ''' <param name="sections">更新情報</param>
    ''' <returns>true : 正常 / false : 異常</returns>
    Public Function WriteIni(filePath As String, sections As OrderedDictionary(Of String, OrderedDictionary(Of String, String))) As Boolean
        Dim result As Boolean = False

        ' Iniファイル情報を取得する
        Dim dic = GetSections(filePath)

        ' 並び替えた情報を格納
        Dim sortSections = New OrderedDictionary(Of String, OrderedDictionary(Of String, String))(StringComparer.Ordinal)

        For Each sec In dic
            For Each pair In sec.Value
                If sections.ContainsKey(sec.Key) AndAlso sections(sec.Key).ContainsKey(pair.Key) Then
                    If Not sortSections.ContainsKey(sec.Key) Then
                        sortSections(sec.Key) = New OrderedDictionary(Of String, String)()
                    End If

                    sortSections(sec.Key)(pair.Key) = sections(sec.Key)(pair.Key)
                End If
            Next
        Next

        ' 存在しなかったら追記
        For Each sec In sections
            For Each pair In sec.Value
                ' 既に登録済みなら何もしない
                If sortSections.ContainsKey(sec.Key) AndAlso sortSections(sec.Key).ContainsKey(pair.Key) Then
                    Continue For
                End If

                ' 未登録なら追加する
                If Not sortSections.ContainsKey(sec.Key) Then
                    sortSections(sec.Key) = New OrderedDictionary(Of String, String)()
                End If

                sortSections(sec.Key)(pair.Key) = pair.Value
            Next
        Next


        ' セクションの最終キーを格納
        Dim lastdic As New Dictionary(Of String, String)()
        For Each sec In dic
            If dic(sec.Key).Count > 0 Then
                Dim pair = dic(sec.Key).Last()
                lastdic.Add(sec.Key, pair.Key)
            End If
        Next

        result = Write(filePath, sortSections, lastdic)

        Return result
    End Function

    ''' <summary>
    ''' iniファイルの書き込み
    ''' </summary>
    ''' <param name="filePath">ファイルパス</param>
    ''' <param name="sectionName">セクション名</param>
    ''' <param name="keyName">キー名</param>
    ''' <param name="value">値</param>
    ''' <returns>true : 正常 / false : 異常</returns>
    Public Function WriteIni(filePath As String, sectionName As String, keyName As String, value As String) As Boolean
        Dim sections = New OrderedDictionary(Of String, OrderedDictionary(Of String, String))()

        sections(sectionName) = New OrderedDictionary(Of String, String)()
        sections(sectionName)(keyName) = value

        Return WriteIni(filePath, sections)
    End Function

    ''' <summary>
    ''' Iniファイルの書き込み
    ''' </summary>
    ''' <param name="filePath">ファイルパス</param>
    ''' <param name="dsSections">セクション情報</param>
    ''' <returns>true : 正常 / false : 異常</returns>
    Public Function WriteIni(filePath As String, dsSections As DataSet) As Boolean
        Dim sections = New OrderedDictionary(Of String, OrderedDictionary(Of String, String))()

        For Each dt As DataTable In dsSections.Tables
            ToDictionary(dt, sections)
        Next

        Return WriteIni(filePath, sections)
    End Function

    ''' <summary>
    ''' Iniファイルの書き込み
    ''' </summary>
    ''' <param name="filePath"></param>
    ''' <param name="dtSections"></param>
    ''' <returns>true : 正常 / false : 異常</returns>
    Public Function WriteIni(filePath As String, dtSections As DataTable) As Boolean
        If dtSections.Rows.Count = 0 Then Return False

        Dim sections = New OrderedDictionary(Of String, OrderedDictionary(Of String, String))()
        ToDictionary(dtSections, sections)

        Return WriteIni(filePath, sections)
    End Function

    ''' <summary>
    ''' Dictionary型に変換する
    ''' </summary>
    ''' <param name="dtSections">セクション情報</param>
    ''' <param name="sections">更新セクション情報</param>
    ''' <returns>true : 正常 / false : 異常</returns>
    Private Function ToDictionary(dtSections As DataTable, ByRef sections As OrderedDictionary(Of String, OrderedDictionary(Of String, String))) As Boolean
        For Each dr As DataRow In dtSections.Rows
            Dim groupNo As String = ""
            If dtSections.Columns.Contains(SECTION_GROUP_ATTR) Then
                groupNo = dr(SECTION_GROUP_ATTR).ToString()
                If groupNo = "0" Then groupNo = ""
            End If

            Dim sectionName As String = dtSections.TableName & SectionSeparator & groupNo
            If sectionName = RootName Then sectionName = ""
            If Not sections.ContainsKey(sectionName) Then
                sections(sectionName) = New OrderedDictionary(Of String, String)()
            End If

            Dim indexNo As String = ""
            If dtSections.Columns.Contains(PARAMETER_INDEX_ATTR) Then
                indexNo = dr(PARAMETER_INDEX_ATTR).ToString()
            End If

            For Each column As DataColumn In dtSections.Columns
                If column.ColumnName = SECTION_GROUP_ATTR OrElse column.ColumnName = PARAMETER_INDEX_ATTR OrElse column.ColumnName = RootName & "_Id" Then
                    Continue For
                End If

                Dim keyName As String = column.ColumnName & KeySeparator & indexNo
                Dim keyName2 As String = keyName
                If indexNo <> "" Then
                    Dim keyno = GetKeyAndNo(keyName, KeySeparator)
                    keyName2 = keyno.Item1
                End If
                sections(sectionName)(keyName) = dr(keyName2).ToString()
            Next
        Next

        Return True
    End Function

    ''' <summary>
    ''' iniファイルの書き込みメイン処理
    ''' </summary>
    ''' <param name="filePath">ファイルパス</param>
    ''' <param name="sortSections">ソート済更新情報</param>
    ''' <param name="lastdic">最終パラメーター情報</param>
    ''' <returns>true : 正常 / false : 異常</returns>
    Private Function Write(filePath As String, sortSections As OrderedDictionary(Of String, OrderedDictionary(Of String, String)), lastdic As Dictionary(Of String, String)) As Boolean
        Dim isSave As Boolean = False
        Dim isWrite As Boolean = False
        Dim sb As New StringBuilder()
        Dim isExistsSection As Boolean = False

        Using reader = New StreamReader(filePath, Encording)
            Dim sections = New OrderedDictionary(Of String, OrderedDictionary(Of String, String))(StringComparer.Ordinal)
            Dim regexSection = New Regex(SECTION_PATTERN, RegexOptions.Singleline Or RegexOptions.CultureInvariant)
            Dim regexNameValue = New Regex(PARAMETER_PATTERN, RegexOptions.Singleline Or RegexOptions.CultureInvariant)
            Dim currentSection = String.Empty

            ' セクション名が明示されていない先頭部分のセクション名を""として扱う
            sections(String.Empty) = New OrderedDictionary(Of String, String)()

            While True
                Dim line = reader.ReadLine()

                If line Is Nothing Then Exit While

                ' 空行は読み飛ばす
                Dim isContinue As Boolean = (line.Length = 0)
                If Not isContinue Then
                    ' コメント行は読み飛ばす
                    If line.StartsWith(";", StringComparison.Ordinal) Then
                        isContinue = True
                    ElseIf line.StartsWith("#", StringComparison.Ordinal) Then
                        isContinue = True
                    End If

                    ' 全て終わった
                    If sortSections.Count = 0 Then isContinue = True
                End If

                If isContinue Then
                    sb.AppendLine(line)
                    Continue While
                End If

                ' 存在しなかったら追記
                Dim matchNameValue = regexNameValue.Match(line)
                If sortSections.ContainsKey("") AndAlso currentSection = "" Then
                    isExistsSection = True
                End If

                If isExistsSection AndAlso matchNameValue.Success Then
                    Dim newline As String = line
                    ' name=valueの行
                    Dim keyName As String = matchNameValue.Groups("name").Value.Trim()
                    If sortSections(currentSection).ContainsKey(keyName) Then
                        isWrite = True

                        Dim value As String = sortSections(currentSection)(keyName)
                        sortSections(currentSection).Remove(keyName)

                        Dim curvalue As String = matchNameValue.Groups("value").Value.Trim()
                        If curvalue <> "" Then
                            If curvalue <> value Then
                                ' 現在値があるなら置換する
                                If curvalue.Contains(" ") Then
                                    ' 現在値に空白が含まれていた場合、単純な置換(キーやコメントなども置換される可能性がある) 
                                    newline = line.Replace(curvalue, value)
                                Else
                                    ' 現在値に空白が含まれていない場合、値のみ置換 
                                    newline = Regex.Replace(line, "(=\s+|=)([^;|\s]+)(\s+;.*|)", "${1}" & value & "${3}")
                                End If
                                ' 現在値と違う値なら保存する
                                isSave = True
                            End If
                        Else
                            ' = の位置に半角スペースを1つ空けて値をセット
                            newline = Regex.Replace(line, "(=)(\s[^;].*)", "${1} " & value & "${2}")
                        End If
                    End If

                    ' 書き換え行のセット
                    sb.AppendLine(newline)

                    ' 新規パラメーターがあれば最終キー後に追記する
                    If lastdic(currentSection) = keyName Then
                        For Each s In sortSections(currentSection)
                            sb.AppendLine(String.Format("{0} = {1}", s.Key, s.Value))
                        Next
                        sortSections(currentSection).Clear()
                    End If

                    ' セクション内のパラメーターが存在しない
                    If sortSections(currentSection).Count = 0 Then
                        ' セクションを削除する
                        sortSections.Remove(currentSection)
                    End If
                    Continue While
                End If

                isExistsSection = False
                Dim matchSection = regexSection.Match(line)
                If matchSection.Success Then
                    Dim sectionName As String = matchSection.Groups("section").Value.Trim()
                    If sortSections.ContainsKey(sectionName) Then
                        currentSection = sectionName
                        ' 対象のセクション行が存在
                        isExistsSection = True
                    End If
                End If

                ' 行のセット
                sb.AppendLine(line)
            End While
        End Using

        ' 新規セクションとパラメーターを追記する
        For Each sec In sortSections
            ' 未登録のセクションを追加する(一行空行)
            If sb.Length <> 0 Then sb.AppendLine("")
            sb.AppendLine(String.Format("[{0}]", sec.Key))
            isWrite = True
            isSave = True
            For Each pair In sec.Value
                ' パラメーターを追加する
                sb.AppendLine(String.Format("{0} = {1}", pair.Key, pair.Value))
            Next
        Next

        ' 保存処理
        If isWrite AndAlso isSave Then
            File.WriteAllText(filePath, sb.ToString(), Encording)
        End If

        Return True
    End Function


    ''' <summary>
    ''' 末尾数値分割処理
    ''' </summary>
    ''' <param name="value">対象キー</param>
    ''' <param name="separator">区切り文字</param>
    ''' <returns>分割情報</returns>
    Private Function GetKeyAndNo(value As String, separator As String) As Tuple(Of String, Integer)
        If Regex.IsMatch(value, "[0-9]$") Then
            Dim pattern As String = "(?<Key>.*\D)(?<No>\d+$)"
            If separator <> "" Then
                pattern = String.Format("(?<Key>.*){0}(?<No>\d+$)", separator)
            End If

            Dim reg As New Regex(pattern)
            Dim mat As Match = reg.Match(value)
            Dim key As String = mat.Result("${Key}")
            Dim no As String = mat.Result("${No}")

            Return New Tuple(Of String, Integer)(key, Integer.Parse(no))
        Else
            Return Nothing
        End If
    End Function

    ''' <summary>
    ''' セクショングループ件数を取得する
    ''' </summary>
    ''' <returns>セクショングループ件数</returns>
    Private Function GetSectionGroupCount() As OrderedDictionary(Of String, Integer)
        Dim secCount As New OrderedDictionary(Of String, Integer)()

        For Each section In SectionDic
            If section.Key <> "" Then
                Dim key As String = section.Key
                Dim keyno = GetKeyAndNo(section.Key, SectionSeparator)
                Dim cnt As Integer = 1
                If keyno IsNot Nothing Then
                    key = keyno.Item1
                    If secCount.ContainsKey(key) Then
                        cnt = secCount(key)
                        cnt += 1
                    End If
                End If
                secCount(key) = cnt
            End If
        Next

        Return secCount
    End Function

    ''' <summary>
    ''' XMLデータに変換する
    ''' </summary>
    ''' <returns>XMLデータ</returns>
    Private Function ConvertDicToXML() As String
        If SectionDic.Count = 0 Then Return ""

        Dim sb As New StringBuilder()

        sb.AppendLine("<?xml version = '1.0' encoding = 'utf-8' ?>")
        sb.AppendLine([String].Format("<{0}>", RootName))

        ' 同一名カウントチェック
        Dim secCount As OrderedDictionary(Of String, Integer) = GetSectionGroupCount()

        ' XMLデータ生成
        For Each section In SectionDic
            Dim grpno As Integer = -1
            Dim key As String = section.Key
            Dim keyno = GetKeyAndNo(key, SectionSeparator)
            If keyno IsNot Nothing Then
                key = keyno.Item1
                grpno = keyno.Item2
            End If
            ' 複数存在するならグループ扱い
            If grpno = -1 AndAlso key <> "" AndAlso secCount(key) > 1 Then grpno = 0

            ' パラメーターが連番のみかチェック
            Dim isDataGrp As Boolean = True
            For Each pair In section.Value
                If Not Regex.IsMatch(pair.Key, "[0-9]$") Then
                    isDataGrp = False
                    Exit For
                End If
            Next

            Dim grp As String = If(grpno > -1, String.Format(" {0}='{1}'", SECTION_GROUP_ATTR, grpno), "")
            If key <> "" Then sb.Append(String.Format("<{0}{1}", key, grp))

            Dim no As Integer = 0
            Dim oldNo As Integer = -1
            Dim pkey As String = ""
            Dim isFirst As Boolean = False
            For Each pair In section.Value
                pkey = pair.Key
                If key <> "" Then
                    If isDataGrp Then
                        keyno = GetKeyAndNo(pkey, KeySeparator)
                        If keyno IsNot Nothing Then
                            pkey = keyno.Item1
                            no = keyno.Item2
                        End If
                        If no <> oldNo AndAlso no > 0 Then
                            If isFirst Then
                                sb.AppendLine(" />")
                                sb.Append(String.Format("<{0}{1}", key, grp))
                            End If
                            sb.Append(String.Format(" {0}='{1}'", PARAMETER_INDEX_ATTR, no))
                        End If
                        oldNo = no
                        isFirst = True
                    End If
                    sb.Append(String.Format(" {0}='{1}'", pkey, pair.Value))
                Else
                    ' セクションが無い場合
                    sb.AppendLine(String.Format("<{0}>{1}</{0}>", pkey, pair.Value))
                End If
            Next
            If key <> "" Then
                sb.AppendLine(" />")
            End If
        Next
        sb.AppendLine([String].Format("</{0}>", RootName))

        Return sb.ToString()
    End Function

End Class

OrderedDictionary型について

IniManagerクラスの中で、次サイトの「ジェネリック版OrderedDictionary」を使用しています。読み込みだけならDictionary型で充分だったのですが、書き込みする上ではOrderedDictionaryのように順序の保証が必要でした。
ジェネリック版OrderedDictionary - smdn

このジェネリック版OrderedDictionaryは、C#版しか公開されていないのですが、SharpDevelop Ver 4.4のC#からVB.NETへのコンバート機能を使用しました。ちなみに最新版のSharpDevelop Ver 5には変換機能がつかなくなりました。

正しく変換されなかったところは次のように修正しました。

OrderedDictionary.vb
#degine ENABLE_KEYS_VALUES
↓
#Const ENABLE_KEYS_VALUES = True

Public Overloads Function Remove(key As TKey) As Boolean Implements IDictionary(Of TKey, TValue).Remove, ICollection(Of KeyValuePair(Of TKey, TValue)).Remove
↓
Public Overloads Function Remove(key As TKey) As Boolean Implements IDictionary(Of TKey, TValue).Remove

Me(index).Value
↓
Me.Items(index).Value

ライセンスっぽいこと

コード改変や配布は自由です。
このツールによる義務/責任を何ら負いません。

但し、ジェネリック版OrderedDictionary - smdnはMITライセンスとなっています。

最後に

え、C#版が欲しいって、これはVisual Basic Advent Calendarなんですよ。
ウソです、もともとC#で作成したのを、この記事用にVisual Basic用に書き換えました。そのうち、GitHubに公開します。