Type Provider for INI file (draft)

F# Advent Calendar 2012 用の記事です。
たぶん9日目じゃないかと思います。
9日目です。

前回は@smallgeekさんのMono for Android と F#でした。
個人的にはAndroidのアプリもゲームもいつか作ってみたいと思いつつ無限に延期されている状態なのもあって、大変興味深く読ませていただきました。


さて個人的には実用になるといいなあという程度なんですが、
INIファイルの中身をType Providerで操作できたらありがたい人が世の中には何人かいるんじゃなかろうかと思いまして
FsharpxProvidedTypes.fsの力を借りながら実装できたらいいなという次第であります。

話がそれますが、ProvidedTypesの中のProvidedなんたら型さん達はNuGetパッケージマネージャで「Install-Package FSharpx.TypeProviders」しても使えないわけですが、実際に独自のType Providerを作るにあたってはほぼ必須なんじゃないかと思うわけです。公開の予定はないんでしょうかどうでしょうか。

閑話休題
まず最初にINIファイル用Type Provider (draft)の実装コードは以下のようになります。

Type Provider for INI file (draft)

namespace Personal.FSharp.TypeProviders

open System
open System.IO
open System.Reflection
open System.Runtime.InteropServices
open System.Text
open Microsoft.FSharp.Core.CompilerServices
open Samples.FSharp.ProvidedTypes

module Win32 =
    [<DllImport("kernel32", CharSet = CharSet.Unicode)>]
    extern int GetPrivateProfileSectionNamesW(
        byte[] lpszReturnBuffer,
        int nSize,
        string lpFileName)

    [<DllImport("kernel32", CharSet = CharSet.Unicode)>]
    extern int GetPrivateProfileSectionW(
        string lpAppName,
        byte[] lpReturnedString,
        int nSize,
        string lpFileName)

    [<DllImport("kernel32", CharSet = CharSet.Unicode)>]
    extern int GetPrivateProfileStringW(
        string lpAppName,
        string lpKeyName,
        string lpDefault,
        StringBuilder lpReturnedString,
        int nSize,
        string lpFileName)

module Util =
    let private SplitNullTerminatedBuffer(buf : byte[], count : int) =
        // wchar用に文字数の倍の数分だけバッファを読む
        let namesBuf = Encoding.Unicode.GetString(buf, 0, count*2)
        namesBuf.Split([| (char)0 |], StringSplitOptions.RemoveEmptyEntries)

    /// <summary>INIファイル内にあるすべてのセクション名を取得します。</summary>
    /// <param name="fileName">INIファイルの名前。</param>
    let GetAllSectionNames(fileName) =
        let buf = Array.zeroCreate 10240
        let numBuffer = Win32.GetPrivateProfileSectionNamesW(buf, buf.Length, fileName)
        SplitNullTerminatedBuffer(buf, numBuffer)

    /// <summary>INIファイル内の特定セクションにあるすべてのキーを取得します。</summary>
    /// <param name="fileName">INIファイルの名前。</param>
    /// <param name="sectionName">INIファイル内にあるセクション名。</param>
    let GetAllKeysInSection(fileName, sectionName) =
        let buf = Array.zeroCreate 10240
        let numBuffer = Win32.GetPrivateProfileSectionW(sectionName, buf, buf.Length, fileName)
        let values = SplitNullTerminatedBuffer(buf, numBuffer)
        seq { for i in 0 .. values.Length - 1 do
                let kvp = values.[i].Split([|'='|], 2)
                yield kvp.[0] }

    /// <summary>INIファイル内の特定セクションにあるキーの値を取得します。</summary>
    /// <param name="fileName">INIファイルの名前。</param>
    /// <param name="sectionName">INIファイル内にあるセクション名。</param>
    /// <param name="keyName">取得したい値を持つキーの名前。</param>
    /// <param name="defaultValue">デフォルト値。</param>
    let GetSectionValue(fileName, sectionName, keyName, defaultValue) =
        let buf = new StringBuilder(10240)
        Win32.GetPrivateProfileStringW(sectionName, keyName, defaultValue, buf, buf.Capacity, fileName) |> ignore
        buf.ToString()

/// <summary>INIファイル内にあるセクションを表します。</summary>
/// <param name="fileName">INIファイルの名前。</param>
/// <param name="sectionName">INIファイル内にあるセクション名。</param>
type IniSection(fileName, sectionName) =
    let keys = seq { for key in Util.GetAllKeysInSection(fileName, sectionName) do yield key }

    /// 現在のセクションの名前。
    member __.Name = sectionName
    /// 現在のセクション内にあるすべてのキー。
    member __.Keys = keys |> Seq.toArray
    /// <summary>
    ///   現在のセクション内にある特定のキーの値を取得します。
    ///   <para>キーが見つからない場合、<paramref name="defaultValue">の値が返されます。</para>
    /// </summary>
    /// <param name="key">取得したい値を持つキーの名前。</param>
    /// <param name="defaultValue">デフォルト値。</param>
    member __.GetValue(key, defaultValue) = Util.GetSectionValue(fileName, sectionName, key, defaultValue)

/// <summary>INIファイルを表します。</summary>
/// <param name="fileName">INIファイルの名前。</param>
type IniFile(fileName) =
    let sections = seq { for name in Util.GetAllSectionNames(fileName) do yield name } |> Seq.cache

    /// INIファイルの名前。
    member __.FileName = fileName
    /// INIファイル内にあるすべてのセクション名。
    member __.Sections = sections |> Seq.toArray
    /// <summary>INIファイル内にある特定のセクションを取得します。</summary>
    /// <param name="name">INIファイル内にあるセクション名。</param>
    member __.GetSection(name) = IniSection(fileName, name)

/// <summary>INIファイル用のType Providerを実装する型。</summary>
/// <param name="config"><see ref="Microsoft.FSharp.Core.CompilerServices.TypeProviderConfig"/>による構成をサポートします。</param>
[<TypeProvider>]
type IniFileTypeProvider(config : TypeProviderConfig) as this =
    inherit TypeProviderForNamespaces()

    let asm = Assembly.GetExecutingAssembly()
    let ns = "Personal.FSharp.TypeProviders"

    // INIファイル用Type Providerの起点となる型の定義。
    // このインスタンスに様々なメンバや型定義を追加していきます。
    let iniTy = ProvidedTypeDefinition(asm, ns, "Ini", Some(typeof<obj>))

    // Type Provider使用時にstatic引数で指定された値。
    // 今回はINIファイルの名前を指定できるようにするので、
    // string型のProvidedStaticParameter1つ用意します。
    let filename = ProvidedStaticParameter("filename", typeof<string>)
    
    // static引数の実体。
    let applyFunc = fun (tyName:string) (parameters:obj[]) ->
        match parameters with
        | [| :? string as filename |] ->
            let ty = ProvidedTypeDefinition(asm, ns, tyName, Some(typeof<IniFile>))

            let resolvedFilename = Path.GetFullPath(Path.Combine(config.ResolutionFolder, filename))
            let iniFile = new IniFile(resolvedFilename)

            // それぞれのセクションと同じ名前のプロパティを追加
            iniFile.Sections
            |> Seq.iter (fun section ->
                let sectionTy = ProvidedTypeDefinition(section, Some(typeof<IniSection>))

                // それぞれのキーと同じ名前のプロパティを追加
                iniFile.GetSection(section).Keys
                |> Seq.iter (fun key ->
                    let keyProp = ProvidedProperty(key, typeof<string>,
                                    GetterCode = fun args -> <@@ (%%args.[0] : IniSection).GetValue(key, null) @@>)
                    sectionTy.AddMember keyProp)

                let prop = ProvidedProperty(section, sectionTy,
                            GetterCode = fun args -> <@@ (%%args.[0] : IniFile).GetSection(section) @@>)
                ty.AddMember prop

                ty.AddMember sectionTy)

            let ctor0 = ProvidedConstructor([],
                            InvokeCode = fun [] -> <@@ IniFile(resolvedFilename) @@>)
            ty.AddMember ctor0

            ty
        | _ -> failwith "Invalid parameter"

    do iniTy.DefineStaticParameters([filename], applyFunc)

    do this.AddNamespace(ns, [ iniTy ])

[<assembly:TypeProviderAssembly>]
do ()

詳細

Win32モジュール

このモジュールにはINIファイル用のWin32 APIをP/Invokeするための定義があります。
今回はINIファイルを読み取る機能しかサポートしないつもりなので、以下の関数しか定義していません。

Wなのは特に強い動機があるわけではないです。
ただし読み取り対象のINIファイルをBOMありUTF-16で保存する必要がある点に注意です。

Utilモジュール

このモジュールはWin32モジュールのラッパー的なものです。
例えばGetPrivateProfileSectionNamesWは全てのセクション名をNULL値('\0')区切りで返すので、それをSystem.Stringの配列として取得できるようにしてます。

IniSection型、IniFile型

INIファイルの中身を表します。
これらの型はType Provider経由で公開される型のベースとなるものです。
つまり、これらの型に定義されたメンバ+Type Providerで動的に用意されるメンバが使えるようになる予定です。

IniFile型

INIファイルに含まれる全てのセクション名を返すSectionsプロパティがあります。
また、特定のセクションをIniSection型として取得できるGetSectionメソッドもあります。

IniSection型

セクションに含まれる全てのキー名を返すKeysプロパティがあります。
また、キーの値を文字列として取得するGetValueメソッドもあります。

IniFileTypeProvider型

INIファイル用のType Providerを実装する型です。

Type Providerの実装に必要な属性:TypeProviderAttribute, TypeProviderAssemblyAttribute

Type Providerを実装する場合、型にはTypeProviderAttributeを指定する必要があります。

[<TypeProvider>]
type IniFileTypeProvider(config : TypeProviderConfig) as this =

また、アセンブリ自体にもTypeProviderAssemblyAttributeを指定しなければいけません。

[<assembly:TypeProviderAssembly>]
do ()

そのほかに、Type Providerを実装する型はITypeProviderインターフェイスを実装しないといけないのですが、これはFsharpxのTypeProviderForNamespaces型で実装されているので楽できます。

type IniFileTypeProvider(config : TypeProviderConfig) as this =
    inherit TypeProviderForNamespaces()
Type Providerとして公開する型の定義

ProvidedTypeDefinitionを作った後、TypeProviderForNamespacesのAddNamespaceに渡してあげるだけで型を公開できます。
なんと簡単なことでしょうか。

    let asm = Assembly.GetExecutingAssembly()
    let ns = "Personal.FSharp.TypeProviders"
    let iniTy = ProvidedTypeDefinition(asm, ns, "Ini", Some(typeof<obj>))
(中略)
    do this.AddNamespace(ns, [ iniTy ])

こうするだけで、このアセンブリを参照するコードからは「Personal.FSharp.TypeProviders.Ini」という型が使えるようになります。

static引数の定義

static引数はProvidedTypeDefinitionのDefineStaticParametersメソッドを呼ぶことで追加できます。
このメソッドの1番目の引数には、ProvidedStaticParameterのリストを指定できます。
今回は対象となるINIファイルのパスが1つ指定できればよいので、ProvidedStaticParameterのインスタンス1つを渡します。

    let filename = ProvidedStaticParameter("filename", typeof<string>)
    do iniTy.DefineStaticParameters([filename],

2番目の引数には、static引数を受け取った際に何をするかという、Type Providerのまさに本体となるコードを渡します。

    do iniTy.DefineStaticParameters([filename], applyFunc)
applyFunc
    let applyFunc = fun (tyName:string) (parameters:obj[]) ->

このメソッドの1番目には、Type Providerとして呼び出された型の名前が渡ってきます。
今回の場合、「Ini, filename="xxxxx.ini"」みたいな文字列が受け取れるんですが、たぶんこの値を使って何か動作を変えるというケースは稀なんじゃないかと思います。

2番目の引数には、static引数で指定された値がobjectの配列として渡ってきます。
なので実装的には適切な型へとキャストして使うことになります。

        match parameters with
        | [| :? string as filename |] ->

今回は要素が1つで、しかも文字列型の場合にだけ機能するように実装します。

static引数付きで呼ばれた後の動作を決定する

先に説明した通り、IniFile型をベースにして、そこにさらに各セクション名、さらに各キー名を持ったプロパティを追加したいという目論見です。

let ini = new Personal.FSharp.TypeProviders.Ini<"Test.ini">()
printfn "%s" ini.MySection1.MyKey1

みたいなコードで使えるようにしたいという感じです。
そこでまずはIniFile型をベースにしたProvidedTypeDefinitionを用意します。

            let ty = ProvidedTypeDefinition(asm, ns, tyName, Some(typeof<IniFile>))

さらに、この型にコンストラクタを追加します。

            let resolvedFilename = Path.GetFullPath(Path.Combine(config.ResolutionFolder, filename))
            let ctor0 = ProvidedConstructor([],
                            InvokeCode = fun [] -> <@@ IniFile(resolvedFilename) @@>)
            ty.AddMember ctor0

ProvidedConstructorという名前と、1番目の引数[]からもわかる通り、これは引数無しのコンストラクタ定義です。
また、呼び出された時にどうするべきかというコードがコードクォートとしてInvokeCodeプロパティに指定しています。
コードクォート内ではIniFile型のコンストラクタを呼び出すだけですが、INIファイルの名前はTypeProviderConfigのResolutionFolderプロパティとの組み合わせになっています。
こうすることで、相対パスでファイルを指定してもうまいことファイルが見つかるようになるような気がします。

このように、公開する型にメンバを追加するにはAddMemberを呼びます。
AddMemberにはProvidedConstructorやProvidedProperty、ProvidedMethodなどのインスタンスを指定できます。
これらのProvidedXXX型は先のFsharpxコードで定義されています。
至れり尽くせりです。
さらに言えば、AddXmlDocメソッドを呼び出して各メンバにドキュメントコメントを用意するべきなんですが、ここでは割愛します。

そしていったんIniFileのインスタンスを作ってから、各セクションと各キーを走査して、プロパティを追加していきます。

            // それぞれのセクションと同じ名前のプロパティを追加
            iniFile.Sections
            |> Seq.iter (fun section ->
                let sectionTy = ProvidedTypeDefinition(section, Some(typeof<IniSection>))

                // それぞれのキーと同じ名前のプロパティを追加
                iniFile.GetSection(section).Keys
                |> Seq.iter (fun key ->
                    let keyProp = ProvidedProperty(key, typeof<string>,
                                    GetterCode = fun args -> <@@ (%%args.[0] : IniSection).GetValue(key, null) @@>)
                    sectionTy.AddMember keyProp)

                let prop = ProvidedProperty(section, sectionTy,
                            GetterCode = fun args -> <@@ (%%args.[0] : IniFile).GetSection(section) @@>)
                ty.AddMember prop

                ty.AddMember sectionTy)

興味深いところといえば、ProvidedPropertyのGetterCodeに指定しているコードクォートでしょうか。

                                    GetterCode = fun args -> <@@ (%%args.[0] : IniSection).GetValue(key, null) @@>)
(中略)
                            GetterCode = fun args -> <@@ (%%args.[0] : IniFile).GetSection(section) @@>)

このargs引数の1番目にはthisが渡ってきているので、それを適切な型にキャストしてからメンバーメソッドを呼び出すようにしています。
以上で完成です。

動作確認

1.別のVisual Studioを起動して、[F# アプリケーション]のプロジェクトを新規作成します。
2.作成したアセンブリを参照追加します。
2.1.[型プロバイダーのセキュリティ]ダイアログが表示されるので、[有効化]を選択します

3.テスト用のINIファイルを用意します。
3.1.INIファイルはBOMありUTF-16エンコーディングで保存するようにします。

[MySection1]
MyKey1=MyValue1
MyKey2

[MySection2]
MyKey1=MyValue1
MyKey2=MyValue2
MyKey3=MyValue3

[MySection3]

4.テスト用のコードを用意します。

    let ini = new Personal.FSharp.TypeProviders.Ini<"Test.ini">()

そうするとIntelliSenseが機能して、いい感じになるんじゃないかと思います。


[<EntryPoint>]
let main argv = 
    let ini = new Personal.FSharp.TypeProviders.Ini<"Test.ini">()
    printfn "%s" ini.MySection1.MyKey1
    printfn "%s" ini.MySection1.MyKey2

    0 // 整数の終了コードを返します

先のINIファイルを使って上のコードを実行すると、結果は以下のようになります。

MyValue1

続行するには何かキーを押してください . . .

Tips to Debug Type Provider

Type Providerをデバッグする場合は以下のようにすると色々はかどるんじゃないかと思います。

  • Type Providerを作成中のプロジェクトのプロパティで[デバッグ]の[開始動作]を[外部プログラムの開始]にして「devenv.exe」へのパスを指定する
  • [開始オプション]では別途作成したテスト用のプロジェクトを含むソリューションのファイルパスを指定する

あと、テスト用のプロジェクトではDebugとReleaseのビルドに対応できるように、fsprojファイルを編集してノード以下ので$(ConfigurationName)マクロを使うといいんじゃないでしょうか。

    
      ..\IniTypeProvider\bin\$(ConfigurationName)\IniTypeProvider.dll
    

TODO

INIファイルからセクション情報やらをとってくるときに、10240バイト固定になっているので、大きいサイズのファイルを対象にするときちんと動作しません。
なのでそのあたりの面倒をちゃんと見られるようにできればdraftを外せるんじゃないかなと思います。
けれどもWin32のAPI的にそこらへんを厳密に処理できるようになっているかどうか怪しいので。。。
そんなに巨大なファイルにするくらいならINIファイルじゃない別の手段を検討したほうがまともだという気もします。
という言い訳でした。