ようこそ。睡眠不足なプログラマのチラ裏です。

F# Build Tools for Unity(ゲームのやつ) - UniFSharpのご紹介

これは F# Advent Calendar 2014の延長戦、 30 日目の記事です。

f:id:zecl:20141229010705j:plain

書いたきっかけ

結局、25日に間に合いませんで。ゆるふわ #FsAdvent に急遽参加しました。そんなわけで、ML Advent Calendar 2014も合わせてどうぞ。 この記事は非常に誰得でニッチな内容を扱います。ほとんどの場合役には立たないでしょう。F# らしい成分もあまりありませんので、まあ適当に流してください。


UniFSharpとは?

UniFSharpは、私が無職だった(ニートしていた)ときに作成した Unityエディタ拡張 Assetです。割と簡単に導入することができます

f:id:zecl:20141229020738p:plain

Unityでのゲーム開発はMacで開発する方がなんとなく多い印象がありますが、わたしがVisual Studio使いたい勢ということもあり、こちらWindows専用となっています。Mac対応はそのうちするかも(?) というか、リポジトリ公開してますから、誰か適当にうまいことやっちゃってください。


上の動画の内容は少し古いですが、概要は伝わるかと。UniFSharpを使うと、Unityエディタ上のAssets/Create/F# Script/NewBehaviourScript のメニューから選択するだけで、F# Scriptを作成できます。 そして、Unityエディタ上でF# ScriptをDLLにビルドすることができます(MSBuild利用)。 Visual Studio(IDE)とも連携するよう実装しており、これにより F#(関数型プログラミング言語)でUnityのゲーム開発がしやすくなります。いわゆる、"作ろうと思えば作れるの知ってるけど、面倒くさくて誰もやらなかったことをやってみた系のツール"です。まぁ、実際やるといろいろ大変。また、オープンソースヒロインのユニティちゃん(ユニティ・テクノロジーズ・ジャパン提供)をマスコットキャラクターに採用しました。ユニティちゃんの音声で時報やイベント、ビルド結果の通知を受けられるという、本来の目的とはまったく関係のない機能も提供しています。


Unityを使うならまぁ当然 C# 一択です。がまぁ、趣味で使う分にはフリーダム。


1000 人 Unity ユーザがいるとすると、その中の 0.01 人は F# ユーザーかもね(適当)


ちなみに、UniFSharp自体も F# で書かれています(ちょっとC# Scriptが混ざってます)。そう、基本的には F#で Unity のほとんどの部分(エディタだろうがゲームだろうが)を書くことができます。この記事では、UniFSharpが提供する機能および、それがどのように実装されているのかについて書きます。ここで紹介でもしないと、GitHubリポジトリを誰も覗いてくれることもないでしょうし。ハイ。

ご利用の際は、まぁいろいろあると思います(お察し)。覚悟しましょう。

この記事を読むその前に...むろほしりょうたさんの初心者がF#をUnityで使ってみた!という記事をオススメします。


F# Scriptの作成

Unityのエディタ拡張では、カスタムメニューを簡単に作ることができます。

f:id:zecl:20141224002558p:plain

f:id:zecl:20141224002627p:plain


F#で実装する場合、Moduleに定義した関数にMenuItem属性を付けるとよいでしょう。

  [<MenuItem("Assets/Create/F# Script/NewBehaviourScript",false, 70)>]
  let createNewBehaviourScript () = FSharpScriptCreateAsset.CreateFSharpScript "NewBehaviourScript.fs"

  [<MenuItem("Assets/Create/F# Script/NewModule", false, 71)>]
  let createNewModule () = FSharpScriptCreateAsset.CreateFSharpScript "NewModule.fs"

  [<MenuItem("Assets/Create/F# Script/", false, 80)>]
  let createSeparator () = ()

  [<MenuItem("Assets/Create/F# Script/NewTabWindow", false, 91)>]
  let createNewTabEditorWindow () = FSharpScriptCreateAsset.CreateFSharpScript "NewTabWindow.fs"

  [<MenuItem("Assets/Create/F# Script/", false, 100)>]
  let createSeparator2 () = ()

  [<MenuItem("Assets/Create/F# Script/more...", false, 101)>]
  let more () = MoreFSharpScriptWindow.ShowWindow()


UnityのEditorWindowは、ScriptableObjectインスタンス化されたものです。ShowUtilityメソッドを実行すると、必ず手前に表示し続け、タブとして扱えないウィンドウを作れます。C# で作る場合と基本的に同じです。難しくないですね。以下のウィンドウでは、選択されたテンプレートファイルを元に、F# Scriptを生成するという機能を提供しています。

f:id:zecl:20141224002702p:plain


namespace UniFSharp
open System.IO
open UnityEditor
open UnityEngine

type MoreFSharpScriptWindow () =
  inherit EditorWindow ()
  [<DefaultValue>]val mutable index : int

  static member ShowWindow() = 
    let window = ScriptableObject.CreateInstance<MoreFSharpScriptWindow>()
    window.title <- FSharpBuildTools.ToolName + " - F# Script"
    window.ShowUtility()

  member this.OnGUI() =

    let scripts = this.GetFSharpScript()
    this.index <- EditorGUILayout.Popup(this.index, scripts)
    if GUILayout.Button("Create") then
      let fileName = scripts.[this.index] 
      FSharpScriptCreateAsset.CreateFSharpScript fileName

  member this.GetFSharpScript () : string array = 
    Directory.GetFiles(FSharpBuildTools.fsharpScriptTemplatePath, FSharpBuildTools.txtExtensionWildcard)
    |> Array.map (fun x -> Path.GetFileName(x).Replace(Path.GetExtension(x),""))


F# Scriptのテンプレートの例

namespace #RootNamespace#
open UnityEngine

type #ClassName# () =
    inherit MonoBehaviour()
    [<DefaultValue>] val mutable text : string
    member public this.Start () = "start..." |> Debug.Log
    member public this.Update () = "update..." + this.text |> Debug.Log


テンプレートファイルを元に F# Script ファイルを生成したら、その生成したファイルを Unity エディタに Asset として認識させる必要があります。認識をさせないと、Unity の Projectウィンドウ上に表示されません。Assetとして登録する場合、F# Scriptファイルの名前の編集が確定したタイミングで行うようにします。EndNameEditActionクラスを継承し、Actionメソッドをオーバーライドして実装します。AssetDatabase.LoadAssetAtPathで、F# ScriptをUnityEngine.Objectとして読み込み、ProjectWindowUtil.ShowCreatedAssetで、Projetウィンドウ上に表示させることができます。

f:id:zecl:20141224002941p:plain


type FSharpScriptCreateAsset () =
  inherit EndNameEditAction ()

  static member CreateScript defaultName templatePath =
    let directoryName = 
      let assetPath = AssetDatabase.GetAssetPath(Selection.activeObject)
      if String.IsNullOrEmpty (assetPath |> Path.GetExtension) then assetPath
      else assetPath |> getDirectoryName
    if fsharpScriptCeatable directoryName |> not then
      EditorUtility.DisplayDialog("Warning", "Folder name that contains the F# Script file,\n must be unique in the entire F# Project.", "OK") |> ignore
    else
      let icon = Resources.LoadAssetAtPath(FSharpBuildTools.fsharpIconPath, typeof<Texture2D>) :?> Texture2D
      ProjectWindowUtil.StartNameEditingIfProjectWindowExists(0, ScriptableObject.CreateInstance<FSharpScriptCreateAsset>(), defaultName, icon, templatePath)

  static member CreateFSharpScript fileName = 
    let tempFilePath = FSharpBuildTools.fsharpScriptTemplatePath + fileName + FSharpBuildTools.txtExtension
    FSharpScriptCreateAsset.CreateScript fileName (tempFilePath)

  override this.Action(instanceId:int, pathName:string, resourceFile:string) = 
    use sr = new StreamReader(resourceFile, new UTF8Encoding(false))
    use sw = File.CreateText(pathName)
    let filename = Path.GetFileNameWithoutExtension(pathName).Replace(" ","")

    let guid () = System.Guid.NewGuid() |> string
    let text = Regex.Replace(sr.ReadToEnd(), "#ClassName#", filename)
                |> fun text -> Regex.Replace(text, "#ModuleName#", filename)
                |> fun text -> Regex.Replace(text, "#RootNamespace#", FSharpProject.templateRootNamespace pathName)
                |> fun text -> Regex.Replace(text, "#AssemblyName#", FSharpProject.templateAssemblyName pathName)
                |> fun text -> Regex.Replace(text, "#Guid#", guid())
    sw.Write(text)
    AssetDatabase.ImportAsset(pathName)
    let uo = AssetDatabase.LoadAssetAtPath(pathName, typeof<UnityEngine.Object>)
    ProjectWindowUtil.ShowCreatedAsset(uo)


ちなみに、Visual F# Power Tools(VFPT)では、フォルダ名はプロジェクト全体で一意である必要があるので、UnityのProjectウィンドウ上で階層をフリーダムに作られると厄介なので、そのあたりの階層構造も一応 チェックしていたりという感じです。変な階層を作られると、.fsprojファイルがぶっ壊れて開けなくなっちゃいますからね。




Inspectorで F# コードのプレビューを表示

Inspectorウィンドウで F#コードのプレビューを表示するためには、カスタムエディタを作成します。ただし、カスタムエディタはDLLのみでは実装を完結することができないため(謎の制約)、C# Scriptで。 http://forum.unity3d.com/threads/editor-script-dll-and-regular-script-dll-not-adding-custominspector-scripts.107720/

f:id:zecl:20141224002941p:plain

using System.IO;
using UnityEditor;
using UnityEngine;
using UniFSharp;
using Microsoft.FSharp.Core;

[CustomEditor(typeof(UnityEngine.Object), true)]
public class FSharpScriptInspector : Editor
{
    private string code;
    void OnEnable()
    {
        Repaint();
    }

    public override void OnInteractivePreviewGUI(Rect r, GUIStyle background)
    {
        base.OnInteractivePreviewGUI(r, background);
    }

    public override void OnInspectorGUI()
    {
        GUI.enabled = true;

        if (!AssetDatabase.GetAssetPath(Selection.activeObject).EndsWith(".fs"))
        {
            DrawDefaultInspector();
        }
        else
        {
            EditorGUILayout.BeginHorizontal("box");
            GUIStyle boldtext = new GUIStyle();
            boldtext.fontStyle = FontStyle.Bold;
            EditorGUILayout.LabelField("Imported F# Script", boldtext);
            EditorGUILayout.EndHorizontal();

            var targetAssetPath = AssetDatabase.GetAssetPath(target);
            if (!Directory.Exists(targetAssetPath) && File.Exists(targetAssetPath))
            {
                var sr = File.OpenText(targetAssetPath);
                code = sr.ReadToEnd();
                sr.Close();

                GUIStyle myStyle = new GUIStyle();
                GUIStyle style = EditorStyles.textField;
                myStyle.border = style.border;
                myStyle.contentOffset = style.contentOffset;
                myStyle.normal.background = style.normal.background;
                myStyle.padding = style.padding;
                myStyle.wordWrap = true;
                EditorGUILayout.LabelField(code, myStyle);
            }

            var rec = EditorGUILayout.BeginHorizontal();
            if (GUI.Button(new Rect(rec.width - 80, 25, 50, 15), "vs-sln", EditorStyles.miniButton))
            {
                var path = AssetDatabase.GetAssetPath(Selection.activeObject);
                var basePath = FSharpProject.GetProjectRootPath();
                var fileName = PathUtilModule.GetAbsolutePath(basePath, path);
                UniFSharp.FSharpSolution.OpenExternalVisualStudio(SolutionType.FSharp, fileName);
            }

            if (GUI.Button(new Rect(rec.width - 145, 25, 60, 15), "mono-sln", EditorStyles.miniButton))
            {
                UniFSharp.FSharpSolution.OpenExternalMonoDevelop();
            }
            EditorGUILayout.EndHorizontal();
        }
    }
}


Editorを継承しOnInspectorGUIをオーバーライドし、Projectウィンドウで選択されたF# Scriptを読み込んで表示するよう実装します。雑ですが以上。

F# DLL のビルド

Unity上から MSBuildでビルドするだけの簡単なお仕事です。これといって特筆すべきことはありません。誰かソースきれいにして。

namespace UniFSharp
open System
open System.IO 
open System.Diagnostics 
open System.Text 
open System.Xml
open UnityEditor

[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module MSBuild =
  let private initOutputDir outputDirPath = 
    if (not <| Directory.Exists(outputDirPath)) then
      Directory.CreateDirectory(outputDirPath) |> ignore
    else
      Directory.GetFiles(outputDirPath) |> Seq.iter (fun file -> File.Delete(file))
      AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate)

  let private getAssemblyName (projectFilePath:string) = 
    let xdoc = new XmlDocument()
    xdoc.Load(projectFilePath)
    let xnm = new XmlNamespaceManager(xdoc.NameTable)
    xnm.AddNamespace("ns", "http://schemas.microsoft.com/developer/msbuild/2003")
    let node = xdoc.SelectSingleNode("/ns:Project/ns:PropertyGroup/ns:AssemblyName", xnm)
    let node = xdoc.SelectSingleNode("/ns:Project/ns:PropertyGroup/ns:AssemblyName")
    if (node = null) then ""
    else node.InnerText

  let private getAargs (projectFilePath:string) (outputDirPath:string) isDebug =
    let projectFilePath = projectFilePath |> replaceDirAltSepFromSep
    let outputDirPath = outputDirPath |> replaceDirAltSepFromSep

    // http://msdn.microsoft.com/ja-jp/library/bb629394.aspx
    let args = new StringBuilder()
    args.AppendFormat("\"{0}\"", projectFilePath)
        .AppendFormat(" /p:Configuration={0}", if isDebug then "Debug" else "Release")
        .AppendFormat(" /p:OutputPath=\"{0}\"", outputDirPath)
        .Append(" /p:OptionExplicit=true")
        .Append(" /p:OptionCompare=binary")
        .Append(" /p:OptionStrict=true")
        .Append(" /p:OptionInfer=true")
        .Append(" /p:BuildProjectReferences=false")
        .AppendFormat(" /p:DebugType={0}", if isDebug then "full" else "pdbonly")
        .AppendFormat(" /p:DebugSymbols={0}", if isDebug then "true" else "false")
        .AppendFormat(" /p:VisualStudioVersion={0}", "12.0") // TODO 
        //.AppendFormat("{0}", String.Format(" /p:DocumentationFile={0}/{1}.xml", outputDirPath, getAssemblyName projectFilePath))
        .AppendFormat(" /l:FileLogger,Microsoft.Build.Engine;logfile={0}", String.Format("{0}/{1}.log", outputDirPath, if isDebug then "DebugBuild" else "ReleaseBuild"))
        .Append(" /t:Clean;Rebuild")
        |> string

  let getMSBuildPath (version:string) = 
    let msBuildPath = (String.Format(@"SOFTWARE\Microsoft\MSBuild\{0}", version), @"MSBuildOverrideTasksPath") ||> UniFSharp.Registory.getReg
    Path.Combine(msBuildPath, "MSBuild.exe")

  let execute msBuildVersion projectFilePath outputDirPath isDebug outputDataReceivedEventHandler errorDataReceivedEventHandler = 
    use p = new Process()
    outputDirPath |> initOutputDir

    p.StartInfo.WindowStyle <- ProcessWindowStyle.Hidden
    p.StartInfo.CreateNoWindow <- true
    p.StartInfo.UseShellExecute <- true
    p.StartInfo.FileName <- getMSBuildPath msBuildVersion
    p.StartInfo.Arguments <- getAargs projectFilePath outputDirPath isDebug

    if (outputDataReceivedEventHandler = null |> not || errorDataReceivedEventHandler = null |> not) then
        p.StartInfo.UseShellExecute <- false
        p.StartInfo.CreateNoWindow <- true
        p.StartInfo.WindowStyle <- ProcessWindowStyle.Hidden

        if (outputDataReceivedEventHandler = null |> not) then
          p.StartInfo.RedirectStandardOutput <- true
          p.OutputDataReceived.AddHandler outputDataReceivedEventHandler

        if (errorDataReceivedEventHandler = null |> not) then
          p.StartInfo.RedirectStandardError <- true
          p.ErrorDataReceived.AddHandler errorDataReceivedEventHandler

    if p.Start() then
      if (outputDataReceivedEventHandler = null |> not) then
        p.BeginOutputReadLine()

      if (errorDataReceivedEventHandler = null |> not) then
        p.BeginErrorReadLine()

      p.WaitForExit()
      p.ExitCode
    else
      p.ExitCode 


UniFSharpでは、ビルドの結果をユニティちゃんが通知してくれます。ビルドエラーだとこんな感じ

f:id:zecl:20141224003402p:plain


F# Scriptのドラック&ドロップについて

「UniFSharp を使えば F# Script ファイルをUnity上で作れる」とは言っても、実際にScriptファイルとして動作するようには実装していないくて、実際はDLL化したアセンブリをUnityで読み込んで利用しているため、通常は Projectウィンドウに表示しているだけの F# Scriptファイルを、Inspectorウィンドウにドラック&ドロップしGameObjectにComponentとして追加することはできません。UniFSharpでは、アセンブリの内容を解析して、疑似的に F# Scriptファイルをドラッグ&ドロップしているかのような操作感覚を実現しています。

F# DLLとF# ScriptからMonoBehaviourの派生クラスを探索するモードは2種類用意していて、1つは、F# Scriptファイルを読み取って、シンプルな正規表現でクラス名を抽出し、アセンブリからMonoBehaviourの派生クラスを検索する方法。もう一つは、F# ScriptファイルをF# コンパイラサービスを利用して、解析して厳密にクラス名を抽出する方法。前者は精度は低いが早い。後者は精度は高いが遅い。それぞれ一長一短がある。

カスタムエディタということで、F# コンパイラサービスを利用する部分を除いては、またC#

using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEngine;
using UniFSharp;
using System;
using System.Collections.Generic;
using System.Diagnostics;

[CustomEditor(typeof(UnityEngine.Transform))]
public class TransformInspector : Editor
{
    Vector3 position;

    void OnEnable()
    {
        Repaint();
    }

    public override void OnInspectorGUI()
    {
        EditorGUILayout.BeginVertical();
        (this.target as Transform).localRotation = Quaternion.Euler(EditorGUILayout.Vector3Field("Local Rotation", (this.target as Transform).localRotation.eulerAngles));
        (this.target as Transform).localPosition = EditorGUILayout.Vector3Field("Local Position", (this.target as Transform).localPosition);
        (this.target as Transform).localScale = EditorGUILayout.Vector3Field("Local Scale", (this.target as Transform).localScale);
        EditorGUILayout.EndVertical();

        // F# Script Drag % Drop
        if (DragAndDrop.objectReferences.Length > 0 && AssetDatabase.GetAssetPath(DragAndDrop.objectReferences[0]).EndsWith(".fs"))
        {
            DragDropArea<UnityEngine.Object>(null, draggedObjects => 
            {
                var dropTarget = this.target as Transform;


                foreach (var draggedObject in draggedObjects)
                {
                    var outputPath = FSharpProject.GetNormalOutputAssemblyPath();
                    if (!Directory.Exists(outputPath))
                    {
                        EditorUtility.DisplayDialog("Warning", "F# Assembly is not found.\nPlease Build.", "OK");
                        break;
                    }

                    var notfound = true;
                    foreach (var dll in Directory.GetFiles(outputPath, "*.dll"))
                    {
                        var fileName = Path.GetFileName(dll);
                        if (fileName == "FSharp.Core.dll") continue;

                        var assem = Assembly.LoadFrom(dll);
                        IEnumerable<Type> behaviors = null;
                        switch (UniFSharp.FSharpBuildToolsWindow.FSharpOption.assemblySearch)
                        {
                            case AssemblySearch.Simple:
                                var @namespace = GetNameSpace(AssetDatabase.GetAssetPath(draggedObject));
                                var typeName = GetTypeName(AssetDatabase.GetAssetPath(draggedObject));
                                behaviors = assem.GetTypes().Where(type => typeof(MonoBehaviour).IsAssignableFrom(type) && type.FullName == @namespace + typeName);
                                break;
                            case AssemblySearch.CompilerService:
                                var types = GetTypes(AssetDatabase.GetAssetPath(draggedObject));
                                behaviors = assem.GetTypes().Where(type => typeof(MonoBehaviour).IsAssignableFrom(type) && types.Contains(type.FullName));
                                break;
                            default:
                                 break;
                        }

                        if (behaviors != null && behaviors.Any())
                        {
                            DragAndDrop.AcceptDrag();
                            foreach (var behavior in behaviors)
                            {
                                dropTarget.gameObject.AddComponent(behavior);
                                notfound = false;
                            }
                        }
                    }

                    if (notfound)
                    {
                        EditorUtility.DisplayDialog("Warning", "MonoBehaviour is not found in the F # assembly.", "OK");
                        return;
                    }
                }
            }, null, 50);
        }
    }

    public static void DragDropArea<T>(string label, Action<IEnumerable<T>> onDrop, Action onMouseUp, float height = 50) where T : UnityEngine.Object
    {
        GUILayout.Space(15f);
        Rect dropArea = GUILayoutUtility.GetRect(0.0f, 50.0f, GUILayout.ExpandWidth(true));
        if (label != null) GUI.Box(dropArea, label);

        Event currentEvent = Event.current;
        if (!dropArea.Contains(currentEvent.mousePosition)) return;

        if (onMouseUp != null)
            if (currentEvent.type == EventType.MouseUp)
                onMouseUp();
        
        if (onDrop != null)
        {
            if (currentEvent.type == EventType.DragUpdated ||
                currentEvent.type == EventType.DragPerform)
            {
                DragAndDrop.visualMode = DragAndDropVisualMode.Copy;

                if (currentEvent.type == EventType.DragPerform)
                {
                    EditorGUIUtility.AddCursorRect(dropArea, MouseCursor.CustomCursor);
                    onDrop(DragAndDrop.objectReferences.OfType<T>());
                }
                Event.current.Use();
            }
        }
    }

    private string GetNameSpace(string path)
    {
        var @namespace = "";
        using (var sr = new StreamReader(path, new UTF8Encoding(false)))
        {
            var text = sr.ReadToEnd();
            string pattern = @"(?<![/]{2,})[\x01-\x7f]*namespace[\s]*(?<ns>.*?)\n";

            var re = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.Singleline);
            foreach (Match m in re.Matches(text))
            {
                @namespace = m.Groups["ns"].Value.Trim() != "" ? m.Groups["ns"].Value.Trim() + "." : "";
                break;
            }
        }
        return @namespace;
    }

    private string GetTypeName(string path)
    {
        var typeName = "";
        using (var sr = new StreamReader(path, new UTF8Encoding(false)))
        {
            var text = sr.ReadToEnd();
            string pattern = @"(?<![/]{2,}\s{0,})type[\s]*(?<type>.*?)(?![\S\(\)\=\n])";
            var re = new Regex(pattern);
            foreach (Match m in re.Matches(text))
            {
                typeName = m.Groups["type"].Value.Trim();
                break;
            }
        }
        return typeName;
    }

    private string[] GetTypes(string path)
    {
        var path2 = UniFSharp.PathUtilModule.GetAbsolutePath(Application.dataPath, path);
        var p = new Process();
        p.StartInfo.FileName = FSharpBuildToolsModule.projectRootPath + @"Assembly\GN_merge.exe";
        p.StartInfo.Arguments = path2 + " " + "DEBUG";
        p.StartInfo.CreateNoWindow = true;
        p.StartInfo.UseShellExecute = false;
        p.StartInfo.RedirectStandardOutput = true;
        p.Start();
        p.WaitForExit();
        var outputString = p.StandardOutput.ReadToEnd();
        var types = outputString.Split(new string[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries);
        return types;
    }
}


F# コンパイラサービスを使って、F# Scriptファイルから名前空間を含むクラス名の探索はこんな感じ。

module Parser
open System
open Microsoft.FSharp.Compiler.SourceCodeServices
open Microsoft.FSharp.Compiler.Ast

let private checker = InteractiveChecker.Create()
let private getUntypedTree (file, input, conditionalDefines) = 
  let otherFlags = 
    match conditionalDefines with
    | [||] -> [||] 
    | _  -> conditionalDefines |> Array.map (fun x -> "--define:" + x )

  let checkOptions = checker.GetProjectOptionsFromScript(file, input, otherFlags = otherFlags) |> Async.RunSynchronously
  let untypedRes = checker.ParseFileInProject(file, input, checkOptions) |> Async.RunSynchronously
  match untypedRes.ParseTree with
  | Some tree -> tree
  | None -> failwith "failed to parse"

let rec private getAllFullNameOfType' modulesOrNss =
  modulesOrNss |> Seq.map(fun moduleOrNs -> 
    let (SynModuleOrNamespace(lid, isModule, moduleDecls, xmlDoc, attribs, synAccess, m)) = moduleOrNs
    let topNamespaceOrModule = String.Join(".",(lid.Head::lid.Tail))
    //inner modules
    let modules = moduleDecls.Head::moduleDecls.Tail 
    getDeclarations modules |> Seq.map (fun x -> String.Join(".", [topNamespaceOrModule;x]))
    ) |> Seq.collect id

and private getDeclarations moduleDecls = 
  Seq.fold (fun acc declaration -> 
      match declaration with
      | SynModuleDecl.NestedModule(componentInfo, modules, _isContinuing, _range) ->
        match componentInfo with
        | SynComponentInfo.ComponentInfo(_,_,_,lid,_,_,_,_) ->
          let moduleName = String.Join(".",(lid.Head::lid.Tail))
          let children = getDeclarations modules
          seq {
            yield! acc
            yield! children |> Seq.map(fun child -> moduleName + "+" + child) }
      | SynModuleDecl.Types(typeDefs, _range) ->
        let types = 
          typeDefs |> Seq.map(fun typeDef ->
          match typeDef with
          | SynTypeDefn.TypeDefn(componentInfo,_,_,_) ->
          match componentInfo with
          | SynComponentInfo.ComponentInfo(_,typarDecls,_,lid,_,_,_,_) ->
            let typarString = typarDecls |> function | [] -> "" | x -> "`" + string x.Length 
            let typeName = String.Join(".",(lid.Head::lid.Tail))
            typeName + typarString)
        seq {
          yield! acc
          yield! types }
      | _ -> acc
    ) Seq.empty moduleDecls

let getAllFullNameOfType input conditionalDefines = 
  let tree = getUntypedTree("/dummy.fsx", input, conditionalDefines) 
  match tree with
  | ParsedInput.ImplFile(ParsedImplFileInput(file, isScript, qualName, pragmas, hashDirectives, modules, b)) ->
    getAllFullNameOfType' modules 
  | _ -> failwith "(*.fsi) not supported."


module Program
open System
open System.IO 
open Microsoft.FSharp.Compiler.Ast
open Parser

[<EntryPoint>]
let main argv = 
  let cmds = System.Environment.GetCommandLineArgs()
  if cmds.Length < 2 then 0 else

  let fileName = cmds.[1].Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)
  let conditionalDefines = 
    if cmds.Length > 2 then cmds.[2].Split(';') 
    else [||]

  let input = File.ReadAllText(fileName)
  getAllFullNameOfType input conditionalDefines 
  |> Seq.iter(fun x -> printfn "%s" x)
  0


ところで、F# コンパイラサービスの対象フレームワーク.NET Framework4以上です。いまはまだ Unityでこのアセンブリを読み込むことはできません。残念!!なのですが、ここで、Microsoftが提供しているILMergeという神ツールを使う(苦肉の策)ことにより、それを回避し実現してる(アッハイ)。

F# Projectファイルの操作とVisual Studioとの連携

Unityエディタで F# Script を作成することをサポートしたということは、つまり、IDEとの連携もサポートするってことだよね。Projectウィンドウ上でF# Scriptファイルを追加したり、ファイルのパスを移動したり、ファイルを削除したタイミングで.fsprojファイル(XML) の内容が書き換わってくれないと、それぜーんぜん役に立たない。そういうこと。この実装がけっこー面倒くさかった...。

こんな感じ


Assetを追加・削除・移動した際に独自の処理をしたい場合は、AssetPostprocessorを継承して適宜処理を実装する。さらに、それがUnityで標準では扱われないファイルの場合(まさに今回の F# Scriptがこの場合)には、OnPostprocessAllAssetsメソッドを実装する。そこで .fsprojファイルをごにょごにょすることで、これを実現できる。

コードは、こんな雰囲気(あばばばば)

namespace UniFSharp
open System
open System.IO
open System.Linq
open System.Xml.Linq
open UnityEditor
open UnityEngine

type FSharpScriptAssetPostprocessor () = 
  inherit AssetPostprocessor ()
  static let ( !! ) s = XName.op_Implicit s

  static let getXDocCompileIncureds (fsprojXDoc:XDocument) (ns:string) (projectFileType:ProjectFileType) =
    let elements = fsprojXDoc.Root.Elements(!!(ns + "ItemGroup")).Elements(!!(ns + "Compile"))
    elements |> Seq.map (fun x -> x.Attribute(!!"Include").Value |> replaceDirSepFromAltSep)

  static let getNewCompileIncludeElement(ns:string) (file:string) = XElement(!!(ns + "Compile"), new XAttribute(!!"Include", file))
  static let getNewItemGroupCompileIncludeElement (ns:string) (file:string) = XElement(!!(ns + "ItemGroup"), new XElement(!!(ns + "Compile"), new XAttribute(!!"Include", file)))
  static let getXDocComiles (fsprojXDoc:XDocument) (ns:string) = fsprojXDoc.Root.Elements(!!(ns + "ItemGroup")).Elements(!!(ns + "Compile"))

  static let getNotExitsFiles (compileIncludes:seq<string>) (projectFileType:ProjectFileType) =
    let basePath = FSharpProject.getProjectRootPath()
    let files = FSharpProject.getAllFSharpScriptAssets(projectFileType) 
                |> Seq.map (fun x -> getRelativePath basePath x)
    Seq.fold(fun acc file -> 
      let file = file |> replaceDirSepFromAltSep
      if not (compileIncludes |> Seq.exists ((=)file)) then 
        seq { yield! acc
              yield file } 
      else acc) Seq.empty files

  static let addCompileIncludeFiles (fsprojXDoc:XDocument) (ns:string) (compileIncludes:seq<string>) (projectFileType:ProjectFileType) =
    let notExists = getNotExitsFiles compileIncludes projectFileType
    notExists |> Seq.iter (fun file ->
      let newElem = getNewCompileIncludeElement ns file
      let compiles = getXDocComiles fsprojXDoc ns
      if (compiles.Any()) then
        let addPoint () =
          let directoryPoint = 
            compiles |> Seq.toList |> Seq.filter (fun x -> 
              let includeFile = x.Attribute(!!"Include").Value
              let includeDirectory = getDirectoryName(includeFile) |> replaceDirSepFromAltSep 
              let directory = getDirectoryName(file) |> replaceDirSepFromAltSep 
              includeDirectory = directory)

          if directoryPoint.Any() then
            directoryPoint |> Seq.toList
          else compiles|> Seq.toList
        addPoint().Last().AddAfterSelf(newElem)
      else
        let newItemGroupElem = getNewItemGroupCompileIncludeElement ns file
        fsprojXDoc.Root.Add(newItemGroupElem))

  static let getRemoveFiles (compileIncludes:seq<string>) (projectFileType:ProjectFileType) =
    let basePath = FSharpProject.getProjectRootPath()
    Seq.fold(fun acc ``include`` -> 
      let ``include`` = ``include`` |> replaceDirSepFromAltSep
      let files = FSharpProject.getAllFSharpScriptAssets(projectFileType) |> Seq.map (fun x -> getRelativePath basePath x) |> Seq.map (fun x -> x |> replaceDirSepFromAltSep)
      if (not <| files.Contains(``include``)) then 
        seq { yield! acc
              yield ``include`` } 
      else acc) Seq.empty compileIncludes

  static let removeCompileIncludeFiles (fsprojXDoc:XDocument) (ns:string) (compileIncludes:seq<string>) (projectFileType:ProjectFileType) =
    let removedFiles = getRemoveFiles compileIncludes projectFileType
    removedFiles |> Seq.iter (fun file -> 
      let compileItems = (fsprojXDoc.Root.Elements(!!(ns + "ItemGroup")).Elements(!!(ns + "Compile")))
      if compileItems |> Seq.length = 1 && (compileItems |> Seq.exists (fun x -> x.Attribute(!!"Include").Value = file)) then
        let parent = compileItems |> Seq.map(fun x -> x.Parent) |> Seq.head 
        parent.Remove()
      else
        (compileItems |> Seq.filter (fun x -> x.Attribute(!!"Include").Value = file)).Remove())    

  static let createOrUpdateProject (projectFileType:ProjectFileType) =
    let fsprojFileName = FSharpProject.getFSharpProjectFileName(projectFileType)
    let fsprojFilePath = FSharpProject.getFSharpProjectPath(fsprojFileName)
    if (not <| File.Exists(fsprojFilePath)) then
      FSharpProject.createFSharpProjectFile(projectFileType) |> ignore
    else
      let fsprojXDoc = XDocument.Load(fsprojFilePath)
      let ns = "{" + String.Format("{0}", fsprojXDoc.Root.Attribute(!!"xmlns").Value) + "}"
      let compileIncludes = getXDocCompileIncureds fsprojXDoc ns projectFileType
      addCompileIncludeFiles fsprojXDoc ns compileIncludes projectFileType
      removeCompileIncludeFiles fsprojXDoc ns compileIncludes projectFileType
      fsprojXDoc.Save(fsprojFilePath)

  static let deleteProject (projectFileType:ProjectFileType) (assetPath:string) =
    let assetPath = assetPath |> replaceDirSepFromAltSep 
    let fsprojFileName = FSharpProject.getFSharpProjectFileName projectFileType
    if (File.Exists(fsprojFileName)) then
      let basePath = FSharpProject.getProjectRootPath()
      let fsprojXDoc = XDocument.Load(fsprojFileName)
      let ns = "{" + String.Format("{0}", fsprojXDoc.Root.Attribute(!!"xmlns").Value) + "}"
      let compileIncludes = fsprojXDoc.Root
                                      .Elements(!!(ns + "ItemGroup"))
                                      .Elements(!!(ns + "Compile")) 
                            |> Seq.map (fun x -> x.Attribute(!!"Include").Value)
      let compileIncludes = compileIncludes |> Seq.map (fun x -> x |> replaceDirSepFromAltSep)
      fsprojXDoc.Root
                .Elements(!!(ns + "ItemGroup"))
                .Elements(!!(ns + "Compile")) 
                .Where(fun x -> x.Attribute(!!"Include").Value |> replaceDirSepFromAltSep = assetPath).Remove()
      fsprojXDoc.Save(fsprojFileName)
    else ()

  static let createOrUpdateEditor () =
    ProjectFileType.VisualStudioEditor |> createOrUpdateProject
    ProjectFileType.MonoDevelopEditor  |> createOrUpdateProject

  static let createOrUpdateNormal () = 
    ProjectFileType.VisualStudioNormal |> createOrUpdateProject
    ProjectFileType.MonoDevelopNormal  |> createOrUpdateProject

  static let createOrUpdate () = 
    createOrUpdateNormal()
    createOrUpdateEditor()

  static let filterFSharpScript x = x |> Seq.filter(fun assetPath -> Path.GetExtension(assetPath) = FSharpBuildTools.fsExtension)

  static let onImportedAssets(importedAssets) = 
    importedAssets |> filterFSharpScript |> fun _ -> createOrUpdate ()
    UniFSharp.FSharpSolution.CreateSolutionFile()

  static let onDeletedAssets(deletedAssets) = 
    deletedAssets |> filterFSharpScript
    |> Seq.iter (fun assetPath ->
      if (FSharpProject.containsEditorFolder assetPath) then
        deleteProject ProjectFileType.VisualStudioEditor assetPath
        deleteProject ProjectFileType.MonoDevelopEditor assetPath
      else
        deleteProject ProjectFileType.VisualStudioNormal assetPath
        deleteProject ProjectFileType.MonoDevelopNormal assetPath)

  static let onMovedAssets(movedAssets) = 
    movedAssets |> filterFSharpScript
    |> Seq.iter (fun assetPath ->
      let assetAbsolutePath = assetPath |> (getAbsolutePath Application.dataPath)
      let fileName = assetAbsolutePath |> Path.GetFileName 
      if fsharpScriptCeatable assetAbsolutePath |> not then
        EditorUtility.DisplayDialog("Warning", "Folder name that contains the F# Script file,\n must be unique in the entire F# Project.\nMove to Assets Folder.", "OK") |> ignore
        AssetDatabase.MoveAsset(assetPath, "Assets/" + fileName) |> ignore
        AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate))

  static let onMovedFromPathAssets(movedFromPath) = 
    if movedFromPath |> filterFSharpScript |> Seq.exists (fun _ -> true) then
      createOrUpdateNormal()
    
  static member OnPostprocessAllAssets (importedAssets:string array, deletedAssets:string array, movedAssets:string array, movedFromPath:string array) = 
    onImportedAssets importedAssets
    onDeletedAssets deletedAssets
    onMovedAssets movedAssets
    onMovedFromPathAssets movedFromPath


また、UnityのProjectウィンドウ上でF# Scriptをダブルクリックした際に、Visual Studio上でそのファイルをアクティブにする動作を実現するために、EnvDTEを利用した。 http://msdn.microsoft.com/ja-jp/library/envdte.dte.aspx


これはきな臭い...。UniFSharpがWindows専用であることが滲み出ているコードですね。はい(真顔)

namespace DTE
open System
open System.Linq 
open System.Runtime.InteropServices
open System.Runtime.InteropServices.ComTypes
open EnvDTE

module AutomateVisualStudio = 
  let is64BitProcess = (IntPtr.Size = 8)
  [<DllImport("kernel32.dll", SetLastError = true, CallingConvention = CallingConvention.Winapi)>]
  extern [<MarshalAs(UnmanagedType.Bool)>] bool IsWow64Process([<In>] IntPtr hProcess, [<Out>] bool& wow64Process)

  [<CompiledName "InternalCheckIsWow64">]
  let internalCheckIsWow64 () = 
    let internalCheckIsWow64 () = 
      if ((Environment.OSVersion.Version.Major = 5 && Environment.OSVersion.Version.Minor >= 1) || Environment.OSVersion.Version.Major >= 6) then
        use p = System.Diagnostics.Process.GetCurrentProcess()
        let mutable retVal = false
        if (not <| IsWow64Process(p.Handle, &retVal)) then
          false
        else
          retVal
      else
        false

    is64BitProcess || internalCheckIsWow64()

  [<CompiledName "Is64BitOperatingSystem">]
  let is64BitOperatingSystem = is64BitProcess || internalCheckIsWow64 ()

  [<CompiledName "GetVisualStudioInstallationPath">]
  let getVisualStudioInstallationPath (version:string) =
    let installationPath = 
      if (is64BitOperatingSystem) then
        Registory.getReg (String.Format(@"SOFTWARE\Wow6432Node\Microsoft\VisualStudio\{0}", version)) "InstallDir"
      else
        Registory.getReg (String.Format(@"SOFTWARE\Microsoft\VisualStudio\{0}", version)) "InstallDir"
    installationPath + "devenv.exe"

  let openExternalScriptEditor vsVersion solutionPath = 
    let p = new System.Diagnostics.Process()
    p.StartInfo.Arguments <- solutionPath
    p.StartInfo.FileName <- getVisualStudioInstallationPath vsVersion
    p.Start()

  [<DllImport("ole32.dll")>]
  extern int CreateBindCtx(uint32 reserved, [<Out>] IBindCtx& ppbc)

  let marshalReleaseComObject(objCom: obj) =
    let i = ref 1
    if (objCom <> null && Marshal.IsComObject(objCom)) then
      while (!i > 0) do
        i := Marshal.ReleaseComObject(objCom)

  let getDTE' (processId:int) (dteVersion:string) =
    let progId = String.Format("!VisualStudio.DTE.{0}:", dteVersion) + processId.ToString()
        
    let mutable bindCtx : IBindCtx = null;
    let mutable rot : IRunningObjectTable= null;
    let mutable enumMonikers :IEnumMoniker = null;
    let mutable runningObject : obj = null
    
    try
      Marshal.ThrowExceptionForHR(CreateBindCtx(0u, &bindCtx))
      bindCtx.GetRunningObjectTable(&rot)
      rot.EnumRunning(&enumMonikers)

      let moniker = Array.create<IMoniker>(1) null
      let numberFetched = IntPtr.Zero
      let cont' = ref true 
      while (enumMonikers.Next(1, moniker, numberFetched) = 0 && !cont') do
        let runningObjectMoniker = moniker.[0]
        let mutable name = null

        try
          if (runningObjectMoniker <> null) then
            runningObjectMoniker.GetDisplayName(bindCtx, null, &name)
        with | :? UnauthorizedAccessException -> () // do nothing

        if (not <| String.IsNullOrEmpty(name) && String.Equals(name, progId, StringComparison.Ordinal)) then
          Marshal.ThrowExceptionForHR(rot.GetObject(runningObjectMoniker, &runningObject))
          cont' := false
    finally
      if (enumMonikers <> null) then
        enumMonikers |> marshalReleaseComObject
      if (rot <> null) then
        rot |> marshalReleaseComObject
      if (bindCtx <> null) then
        bindCtx |> marshalReleaseComObject
    runningObject :?> EnvDTE.DTE

  let tryGetDTE (dteVersion:string) (targetSolutionFullName:string) tryMax =
    let getVisualStudioProcesses () =
      System.Diagnostics.Process.GetProcesses() |> Seq.where(fun x -> try x.ProcessName = "devenv" with | _  ->false)

    try
      let retry = RetryBuilder(tryMax,1.)
      retry {
        return 
          getVisualStudioProcesses() |> Seq.tryPick(fun p ->
            let dte = getDTE' p.Id dteVersion
            if (targetSolutionFullName.ToLower() = dte.Solution.FullName.ToLower()) then
              Some (dte,p)
            else
              None)}
    with | _ -> None

  let showDocument (dte:EnvDTE.DTE) (documentFullName:string) =
      let targetItem = 
        retry{
          let targetItem = dte.Solution.FindProjectItem(documentFullName)
          if (targetItem = null) then 
            return None 
          else
            return Some targetItem }

      match targetItem with
       | None -> ()
       | Some target ->
        if (not <| target.IsOpen(Constants.vsViewKindCode)) then
          target.Open(Constants.vsViewKindCode) |> ignore
          target.Document.Activate()
        else
          target.Document.Activate() 

  let jumpToLine dte documentFullName lineNumber =
    showDocument dte documentFullName
    let selectionDocument = dte.ActiveDocument.Selection :?> EnvDTE.TextSelection
    try
      selectionDocument.GotoLine(lineNumber, true) 
    with | _ -> () 


namespace DTE
open System
open System.Runtime.InteropServices
open System.IO
open Microsoft.Win32

[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module Registory =
  let getReg keyPath valueName = 
    try
      use rKey = Registry.LocalMachine.OpenSubKey(keyPath)
      let location = rKey.GetValue(valueName) |> string
      rKey.Close()
      location
    with e ->
      new Exception(String.Format("registry key:[{0}] value:[{1}] is not found.", keyPath, valueName)) |> raise


namespace DTE
open System
open System.IO
open AutomateVisualStudio

module Program =
   
  [<EntryPoint>]
  let main argv = 
    let cmds = System.Environment.GetCommandLineArgs()
    if cmds.Length < 4 then 0 else

    let vsVersion = cmds.[1] // "12.0"
    let solutionPath = cmds.[2].Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)
    let targetDocumetFileName =  cmds.[3].Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)

    if String.IsNullOrEmpty(vsVersion) then 0 else
    if File.Exists(solutionPath) |> not then 0 else
    if File.Exists(targetDocumetFileName) |> not then 0 else

    let active dte =
      if (cmds.Length = 4) then
        showDocument dte targetDocumetFileName
      else
        let lineNumber =  cmds.[4]
        if (lineNumber <> null) then
          let num = Int32.Parse(lineNumber)
          jumpToLine dte targetDocumetFileName num

    let dte = tryGetDTE vsVersion solutionPath 2
    match dte with
    | None -> 
      if openExternalScriptEditor vsVersion solutionPath then
        let dte = tryGetDTE vsVersion solutionPath 30
        dte |> Option.iter (fun (dte,p) -> active dte; Microsoft.VisualBasic.Interaction.AppActivate(p.Id))
    | Some (dte,p) -> 
      active dte
      Microsoft.VisualBasic.Interaction.AppActivate(p.Id)
    0


あと、Retryビルダー。アッハイ。モナドじゃねえっス。

namespace DTE
open System.Threading

[<AutoOpen>]
module Retry =

  type RetryBuilder(count, seconds) = 
    member x.Return(a) = a
    member x.Delay(f) = f
    member x.Zero() = failwith "Zero" 
    member x.Run(f) =
      let rec loop(n) = 
        if n = 0 then 
          failwith "retry failed"
        else 
          try 
            f()
          with e ->
            Thread.Sleep(seconds * 1000. |> int) 
            loop(n-1)

      loop count

  let retry = RetryBuilder(30,1.)


UniFSharpのオプション画面

ユニティちゃんの背景が印象的な画面です。

f:id:zecl:20141224003556p:plain


このオプション画面で、作成するF# プロジェクトの構成の詳細を設定できます。細かい説明は省きます(雑。

ユニティちゃんの機能もろもろ

ユニティ・テクノロジーズ・ジャパンが無償で提供してくれているユニティちゃんのAsset に同封されている多彩な音声。せっかくあるので使ってみたい。特に「進捗どうですか?」とか使わない手はない。そういや、いわるゆる萌え系だとか痛い系のIDEって結構あるけど、しゃべる感じのやつってあんまりないよなぁ。とかいうのが一応実装動機ということで。

f:id:zecl:20141224003648p:plain


  • ・起動時ボイス(ON/OFF)
  • ・ビルド時ボイス(ON/OFF)
  • ・進捗どうですか?(ON/OFF, 通知間隔指定あり)
  • 時報通知のボイス(ON/OFF)
  • ・イベント通知のボイス(ON/OFF)
  • ・誕生日のお祝い(ON/OFF, 日付を指定)

f:id:zecl:20141224003918p:plain


F#でUnityゲーム開発する気はなくても、Unityをお使いの方で、ユニティちゃんに「進捗どうですか?」とか言われたい人は、まぁ使ってみてくださいという感じで(適当)。

open UnityEngine
open UnityEditor

[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module AudioUtil =

  [<CompiledName "PlayClip">]
  let playClip (clip:AudioClip) =
    let method' = 
      let unityEditorAssembly = typeof<AudioImporter>.Assembly
      let audioUtilClass = unityEditorAssembly.GetType("UnityEditor.AudioUtil")
      audioUtilClass.GetMethod(
          "PlayClip",
          BindingFlags.Static ||| BindingFlags.Public,
          null,
          [|typeof<AudioClip>|],
          null)
    method'.Invoke(null, [|clip|]) |> ignore

Unityエディタ上で、音声ファイルを再生したい系の人は上記のような感じのをひとつこさえておけば、ハカドルかもね。

f:id:zecl:20141224003821p:plain


MonoDevelop、Xamarin、Visual Studioで Unity の F# DLLデバッグ

UniFSharpとは直接は関係ありませんが、Unity で F# DLLをデバッグする方法も紹介しておきたい。

基本的には、Unity ユーザーマニュアルに書いてあるとおりにすればよいです。 Unity プロジェクトでの Mono DLL 使用 / Using Mono DLLs in a Unity Project

ということで、.fsproj のビルド後イベントに、下記のような感じで設定しておくと捗るかもしれません(パスとかは適当に変えて)。

if exist "..\..\Assets\Assembly-FSharp-Editor\$(TargetName).pdb" call "C:\Program Files (x86)\Unity\Editor\Data\Mono\lib\mono\2.0\pdb2mdb.exe" "..\..\Assets\Assembly-FSharp-Editor\$(TargetName).dll"


Visual Studio 2013 Tools for Unityが無償提供され、あらゆるアプリを開発できる最強の開発ツールとの触れ込みのVisual Studio 2013 Community Editionが無償提供されたことで、誰でもVisual Studio で Unityのデバッグ実行ができるようになりました。本当にいい世の中になったものです。F# をお使いなら、 Visual F# Power Toolsも利用できますし、めしうま状態必至。



おまけ

Unityえふしゃーぷまん達


意外といらっしゃる。もちろんこれですべてではない。

Unity は良くできているゲームエンジンなので、F# でも使いたい!という気持ちはわかりますが、一般的には、F# でゲームを作りたいなら MonoGame あたりを選択する方がかしこいんじゃないでしょうか。はい。とは言え、 身の回りにUnity F# マンがいたら、ぜひとも情報交換などしてみたいですね。

ところで、わたくしごとで恐縮ですが、8か月以上という長いニート期間を終え、 12/1 から株式会社グラニで働いております。みなさんご存じ「最先端のC#技術を使った」ゲーム開発をしている会社です。とてもよい環境で仕事をさせていただいています。ということでわたくし現在東京におりますので、F# 談話室がある際にはぜひ遊びに行きたいです。趣味のF#erからは以上です。

VB、C#、F# それぞれのインデクサ。F# コンパイラのソースを読んで。


F# コンパイラのソースを読んで


はぇ〜さん(@haxe) とtwitterにて、以下のようなやり取りがありました。

for m in Regex.Matches(input, pattern) do () で、 MatchCollection から Match に型が解決される件は、わたしがツイートしたとおり。typecheckerのソースを見ればわかるので、まあよし。



問題はその後。


読み返してみると、わたしの発言が支離滅裂なところがあり。そのせいもあって、話が噛み合っていなさすぎてやばい!(はぇ〜さんごめんなさい!)。自分の中ではだいたいわかって納得したような気になっていた・・・のです(ぇ しかし、github の fsharp のソースをまじまじと見ていると、なんだか少しモヤっするものがあったので、改めて F# コンパイラのソースを舐め回すように見てみた。実際にコードも書いて確かめてみた。



すると...、いろいろ間違い(勘違い)をしていたようです(はずかしい)。C#VBのインデクサの仕様、 C# と F# の仕様はそれぞれ異なるということは把握していたのだが、VB と F# のインデックス付きプロパティ(インデクサ)の挙動に差があることは把握できていませんでした。F# では、DefaultMemberAttribute が自動生成されないケースがあるんです。 ΩΩΩ<な、なんだってー!?




C# のインデクサ と VB のインデクサ

C# のインデクサ」と 「VB のインデックス付きプロパティ(インデクサ)」については、岩永さんの「インデクサー(C# によるプログラミング入門) - ++C++」を参照で(丸投げ)。




C#のインデクサのサンプル:その1

public class IndexerSample1
{
    private int[] arr = new int[100];
    public int this[int index]
    {
        get
        {
            if (index < 0 || index >= 100)
            {
                return 0;
            }
            else
            {
                return arr[index];
            }
        }
        set
        {
            if (!(index < 0 || index >= 100))
            {
                arr[index] = value;
            }
        }
    }
}
var sample = new IndexerSample1();
var attrs = System.Attribute.GetCustomAttributes(sample.GetType());
// DefaultMemberAttributeが自動生成される
Debug.Assert(attrs.Any(x => x.GetType () == typeof(DefaultMemberAttribute)));
var attr = attrs.Where(x => x.GetType() == typeof(DefaultMemberAttribute)).Cast<DefaultMemberAttribute>().FirstOrDefault() ;
Debug.Assert(attr.MemberName == "Item");

特に細工をすることなくC#でインデクサ付きの型を作ると、"Item"というメンバ名で DefaultMemberAttribute が自動生成される。問題なし。





C#のインデクサのサンプル:その2

public class IndexerSample2
{
    private int[] arr = new int[100];
    [System.Runtime.CompilerServices.IndexerName("SpecialItem")]
    public int this[int index]
    {
        get
        {
            if (index < 0 || index >= 100)
            {
                return 0;
            }
            else
            {
                return arr[index];
            }
        }
        set
        {
            if (!(index < 0 || index >= 100))
            {
                arr[index] = value;
            }
        }
    }
}
var sample = new IndexerSample2();
var attrs = System.Attribute.GetCustomAttributes(sample.GetType());
// DefaultMemberAttributeが自動生成される
Debug.Assert(attrs.Any(x => x.GetType() == typeof(DefaultMemberAttribute)));
var attr = attrs.Where(x => x.GetType() == typeof(DefaultMemberAttribute)).Cast<DefaultMemberAttribute>().FirstOrDefault();
// IndexerNameAttributeで指定された名前で生成されている
Debug.Assert(attr.MemberName == "SpecialItem");


IndexerNameAttributeで指定した "SpecialItem" というメンバ名で DefaultMemberAttribute が自動生成される。問題なし。




C#のインデクサのサンプル:その3

[System.Reflection.DefaultMember("SpecialItem")]
public class IndexerSample3
{
    private int[] arr = new int[100];
    [System.Runtime.CompilerServices.IndexerName("SpecialItem")]
    public int this[int index]
    {
        get
        {
            if (index < 0 || index >= 100)
            {
                return 0;
            }
            else
            {
                return arr[index];
            }
        }
        set
        {
            if (!(index < 0 || index >= 100))
            {
                arr[index] = value;
            }
        }
    }
}


インデクサ付きの型に対して DefaultMember属性を明示的に指定することはできない。コンパイラに怒られます。




MSDNライブラリ - DefaultMemberAttribute クラス
http://msdn.microsoft.com/ja-jp/library/system.reflection.defaultmemberattribute(v=vs.110).aspx

プロパティは、そのプロパティに引数が存在し、かつ、プロパティ名またはそのいずれかのアクセサーが DefaultMemberAttribute で指定された名前と一致する場合、インデクサー (Visual Basic では既定のインデックス付きプロパティ) としてインポートされます。 格納している型に DefaultMemberAttribute が存在しない場合、その型にはインデクサーは存在しません。 C# コンパイラでは、インデクサーを含むすべての型について、DefaultMemberAttribute を出力します。 C# では、既にインデクサーが宣言されている型に対し、直接 DefaultMemberAttribute で属性を指定するとエラーになります。


MSDNにもそう書いてある。






続いて、VB のインデックス付きプロパティ(インデクサ)について見ていく。


VB のインデックス付きプロパティのサンプル:その1

Public Class IndexerSample4
    Private arr As Array = New Integer(100) {}
    Default Public Property Item(ByVal index As Integer) As String
        Get
            If index < 0 OrElse index >= 100 Then
                Return 0
            Else
                Return arr(index)
            End If
        End Get
        Set(ByVal Value As String)
            If Not (index < 0 OrElse index >= 100) Then
                arr(index) = Value
            End If
        End Set
    End Property
End Class
var sample = new IndexerSample4();
var attrs = System.Attribute.GetCustomAttributes(sample.GetType());
// DefaultMemberAttributeが自動生成される
Debug.Assert(attrs.Any(x => x.GetType() == typeof(DefaultMemberAttribute)));
var attr = attrs.Where(x => x.GetType() == typeof(DefaultMemberAttribute)).Cast<DefaultMemberAttribute>().FirstOrDefault();
Debug.Assert(attr.MemberName == "Item");

特に細工をすることなくVBでインデクサ付きの型を作ると、C# と同様に "Item"というメンバ名で DefaultMemberAttribute が自動生成される。問題なし。




VB のインデックス付きプロパティのサンプル:その2

Public Class IndexerSample5
    Private arr As Array = New Integer(100) {}
    <System.Runtime.CompilerServices.IndexerName("SpecialItem")> _
    Default Public Property Dummy(ByVal index As Integer) As String
        Get
            If index < 0 OrElse index >= 100 Then
                Return 0
            Else
                Return arr(index)
            End If
        End Get
        Set(ByVal Value As String)
            If Not (index < 0 OrElse index >= 100) Then
                arr(index) = Value
            End If
        End Set
    End Property
End Class

var sample = new IndexerSample5();
var attrs = System.Attribute.GetCustomAttributes(sample.GetType());
// DefaultMemberAttributeが自動生成される
Debug.Assert(attrs.Any(x => x.GetType() == typeof(DefaultMemberAttribute)));
var attr = attrs.Where(x => x.GetType() == typeof(DefaultMemberAttribute)).Cast<DefaultMemberAttribute>().FirstOrDefault();
Debug.Assert(attr.MemberName == "Dummy");


DefaultMember属性が暗黙的に生成されるが、IndexerName属性で指定した "SpecialItem" という名前は無視される。実際のプロパティ名(DisplayName)である"Dummy" で作られる。これは知ってた。





VB のインデックス付きプロパティのサンプル:その3

<System.Reflection.DefaultMember("Hoge")> _
Public Class IndexerSample6
    Private arr As Array = New Integer(100) {}
    <System.Runtime.CompilerServices.IndexerName("SpecialItem")> _
    Default Public Property Item(ByVal index As Integer) As String
        Get
            If index < 0 OrElse index >= 100 Then
                Return 0
            Else
                Return arr(index)
            End If
        End Get
        Set(ByVal Value As String)
            If Not (index < 0 OrElse index >= 100) Then
                arr(index) = Value
            End If
        End Set
    End Property
End Class



DefaultMember属性に、インデクサのプロパティ名と異なるメンバ名が指定されると競合が発生する。こんなの初めて書いたw




VB のインデックス付きプロパティのサンプル:その4

<System.Reflection.DefaultMember("Item")> _
Public Class IndexerSample7
    Private arr As Array = New Integer(100) {}
    <System.Runtime.CompilerServices.IndexerName("SpecialItem")> _
    Default Public Property Item(ByVal index As Integer) As String
        Get
            If index < 0 OrElse index >= 100 Then
                Return 0
            Else
                Return arr(index)
            End If
        End Get
        Set(ByVal Value As String)
            If Not (index < 0 OrElse index >= 100) Then
                arr(index) = Value
            End If
        End Set
    End Property
End Class


DefaultMember属性に、インデクサのプロパティ名と同様のメンバ名を指定すると問題ない。C# とは異なり、VB ではインデクサ付きの型でDefaultMember属性を明示的に指定することが可能。

var sample = new IndexerSample7();
var attrs = System.Attribute.GetCustomAttributes(sample.GetType());
// DefaultMemberAttributeが指定されているので、当然存在する
Debug.Assert(attrs.Any(x => x.GetType() == typeof(DefaultMemberAttribute)));
var attr = attrs.Where(x => x.GetType() == typeof(DefaultMemberAttribute)).Cast<DefaultMemberAttribute>().FirstOrDefault();
Debug.Assert(attr.MemberName == "Item");

DefaultMemberAttributeが明示的に指定されているので、当然存在する。メンバ名もそのまんま。ここまでは問題ないです。





F# のインデックス付きプロパティ(インデクサ)


F#のインデックス付きプロパティのサンプル:その1

type IndexerSample8 () =
    let arr : int [] =  Array.zeroCreate 100 
    member this.Item
      with get(index) = 
        if index < 0 || index >= 100 then
          0
        else 
          arr.[index]
      and set index value = 
        if not (index < 0 || index >= 100) then
          arr.[index] <- value
var sample = new IndexerSample8();
var attrs = System.Attribute.GetCustomAttributes(sample.GetType());
// DefaultMemberAttributeが自動生成される
Debug.Assert(attrs.Any(x => x.GetType() == typeof(DefaultMemberAttribute)));
var attr = attrs.Where(x => x.GetType() == typeof(DefaultMemberAttribute)).Cast<DefaultMemberAttribute>().FirstOrDefault();
Debug.Assert(attr.MemberName == "Item");


C#VBと同じ。問題なし。




F#のインデックス付きプロパティのサンプル:その2

type IndexerSample9 () =
    let arr : int [] =  Array.zeroCreate 100 
    [<System.Runtime.CompilerServices.IndexerName("SpecialItem")>]
    member this.Item
      with get(index) = 
        if index < 0 || index >= 100 then
          0
        else 
          arr.[index]
      and set index value = 
        if not (index < 0 || index >= 100) then
          arr.[index] <- value
var sample = new IndexerSample9();
var attrs = System.Attribute.GetCustomAttributes(sample.GetType());
// DefaultMemberAttributeが自動生成される
Debug.Assert(attrs.Any(x => x.GetType() == typeof(DefaultMemberAttribute)));
var attr = attrs.Where(x => x.GetType() == typeof(DefaultMemberAttribute)).Cast<DefaultMemberAttribute>().FirstOrDefault();
Debug.Assert(attr.MemberName == "Item");


C#と異なり、VBと同じ挙動。知ってた。そりゃそーですよね。




F#のインデックス付きプロパティのサンプル:その3

[<System.Reflection.DefaultMember("Item")>]
type IndexerSample10 () =
    let arr : int [] =  Array.zeroCreate 100 
    [<System.Runtime.CompilerServices.IndexerName("SpecialItem")>]
    member this.Item
      with get(index) = 
        if index < 0 || index >= 100 then
          0
        else 
          arr.[index]
      and set index value = 
        if not (index < 0 || index >= 100) then
          arr.[index] <- value
var sample = new IndexerSample10();
var attrs = System.Attribute.GetCustomAttributes(sample.GetType());
// DefaultMemberAttributeが自動生成される
Debug.Assert(attrs.Any(x => x.GetType() == typeof(DefaultMemberAttribute)));
var attr = attrs.Where(x => x.GetType() == typeof(DefaultMemberAttribute)).Cast<DefaultMemberAttribute>().FirstOrDefault();
Debug.Assert(attr.MemberName == "Item");


VBと同じように、明示的にDefaultMember属性を指定することができる。






F#のインデックス付きプロパティのサンプル:その4

[<System.Reflection.DefaultMember("Hoge")>]
type IndexerSample11 () =
    let arr : int [] =  Array.zeroCreate 100 
    [<System.Runtime.CompilerServices.IndexerName("SpecialItem")>]
    member this.Item
      with get(index) = 
        if index < 0 || index >= 100 then
          0
        else 
          arr.[index]
      and set index value = 
        if not (index < 0 || index >= 100) then
          arr.[index] <- value

VBと同じように、競合が発生してエラーとなるかと思いきや・・・、コンパイルが通る!!!

var sample = new IndexerSample11();
var attrs = System.Attribute.GetCustomAttributes(sample.GetType());
// DefaultMemberAttributeが自動生成される
Debug.Assert(attrs.Any(x => x.GetType() == typeof(DefaultMemberAttribute)));
var attr = attrs.Where(x => x.GetType() == typeof(DefaultMemberAttribute)).Cast<DefaultMemberAttribute>().FirstOrDefault();
Debug.Assert(attr.MemberName == "Item");

DefaultMember属性のメンバ名に誤った名称が指定されている場合、それは無視される。実プロパティ名をメンバ名として DefaultMember属性 が自動生成される。これは予想外の動き!!!


しかし、この場合

let sample = new IndexerSample11()
let v = sample.[0]


なぜか、DefaultMember属性のメンバ名が"Hoge"だって言われる。なので、インデクサにアクセスできない。ど、どういうことだってばよ!?



確認のためのソースが悪かったorz

var sample = new IndexerSample11();
var attrs = System.Attribute.GetCustomAttributes(sample.GetType());
// DefaultMemberAttributeが自動生成される
Debug.Assert(attrs.Any(x => x.GetType() == typeof(DefaultMemberAttribute)));
var attrs2 = attrs.Where(x => x.GetType() == typeof(DefaultMemberAttribute)).Cast<DefaultMemberAttribute>();
Debug.Assert(attrs2.Any( x => x.MemberName == "Hoge"));



DefaultMember属性が暗黙的に生成されているんだけど、明示的に指定したやつとだぶっちゃっている。あらまあ。



ってかこれ、バグっぽいちゃーバグっぽいゼ!?






F#のインデックス付きプロパティのサンプル:その5

type IndexerSample12 () =
    let arr : int [] =  Array.zeroCreate 100 
    [<System.Runtime.CompilerServices.IndexerName("SpecialItem")>]
    member this.Dummy
      with get(index) = 
        if index < 0 || index >= 100 then
          0
        else 
          arr.[index]
      and set index value = 
        if not (index < 0 || index >= 100) then
          arr.[index] <- value
var sample = new IndexerSample12();
var attrs = System.Attribute.GetCustomAttributes(sample.GetType());
// DefaultMemberAttributeが自動生成されない
Debug.Assert(!attrs.Any(x => x.GetType() == typeof(DefaultMemberAttribute)));
var attr = attrs.Where(x => x.GetType() == typeof(DefaultMemberAttribute)).Cast<DefaultMemberAttribute>().FirstOrDefault();
Debug.Assert(attr == null);


IndexerSample5のように、VBと同じ挙動をするかと思いきや、なんと、DefaultMemberAttributeが自動生成されないケースがここで発生。これは、F#のインデクサ付き型で、DefaultMemberAttributeが自動生成されないケースが存在すると言うよりも、「F# は、VB とは挙動が異なり、任意の名称のプロパティではでインデクサ付きの型とはならない」と言うのが正しいだろう。なんということでしょう。F# コンパイラによって「DefaultMemberAttributeが自動生成されない」ケースがあった!!!これは知らなかった。意図的なのかどうなのかわからないが、C#VB いずれとも異なる挙動になるように作られている。




ってかこれ、バグっぽいちゃーバグっぽいゼ!?




F# でインデクサにアクセスすることができない。当然、こうなります。





F# コンパイラのソースを見てみようのコーナー

では、コンパイラの中で一体何が起こっているんでしょう。ソースを見てみる。


IL的に DefaultMemberAttribute を自動生成しているらしいこのあたりを引用する。
https://github.com/fsharp/fsharp/blob/master/src/fsharp/ilxgen.fs#L6239

        let defaultMemberAttrs = 
            // REVIEW: this should be based off tcaug_adhoc_list, which is in declaration order
            tycon.MembersOfFSharpTyconSorted
            |> List.tryPick (fun vref -> 
                let name = vref.DisplayName
                match vref.MemberInfo with 
                | None -> None
                | Some memberInfo -> 
                    match name, memberInfo.MemberFlags.MemberKind with 
                    | ("Item" | "op_IndexedLookup"), (MemberKind.PropertyGet  | MemberKind.PropertySet) when nonNil (ArgInfosOfPropertyVal cenv.g vref.Deref) ->
                        Some( mkILCustomAttribute cenv.g.ilg (mkILTyRef (cenv.g.ilg.mscorlibScopeRef,"System.Reflection.DefaultMemberAttribute"),[cenv.g.ilg.typ_String],[ILAttribElem.String(Some(name))],[]) ) 
                    | _ -> None)
            |> Option.toList


「// REVIEW: this should be based off tcaug_adhoc_list, which is in declaration order」のコメントも気になっちゃいますが、それは置いていおいて・・・。ここを起点に全体のソースを舐め回すように眺めてみる。ふむふむなるほど。F# コンパイラさん、IndexerName 属性はまったく見ていないご様子。そして、「let name = vref.DisplayName」を見ればわかるように、実プロパティ名を参照している。そして、実プロパティ名が、"Item" 、"op_IndexedLookup"のいずれかの場合に限り、実プロパティ名を使って DefaultMemberAttribute が暗黙的に生成されていることがわかります。




では、DisplayNameが "op_IndexedLookup"であるとき、とはどんな時か。次のサンプルのようなケースのときである。



F#のインデックス付きプロパティのサンプル:その6

[<System.Reflection.DefaultMember("Hoge")>]
type IndexerSample13 () =
    let arr : int [] =  Array.zeroCreate 100 
    [<System.Runtime.CompilerServices.IndexerName("Fuga")>]
    member this.Hoge
      with get(index) = 
        if index < 0 || index >= 100 then
          0
        else 
          arr.[index]
      and set index value = 
        if not (index < 0 || index >= 100) then
          arr.[index] <- value
var sample = new IndexerSample13();
var attrs = System.Attribute.GetCustomAttributes(sample.GetType());
// DefaultMemberAttributeが自動生成される
Debug.Assert(attrs.Any(x => x.GetType() == typeof(DefaultMemberAttribute)));
var attrs2 = attrs.Where(x => x.GetType() == typeof(DefaultMemberAttribute)).Cast<DefaultMemberAttribute>();
Debug.Assert(attrs2.Any( x => x.MemberName == "Hoge"));



DefaultMemberAttributeはダブっていない。もちろんIndexerName属性も華麗にスルーです。


当然、インデクサにF#からアクセスできる。






自分のためのまとめ

・F# コンパイラは、DefaultMember属性を暗黙的に生成するとき、C#とは異なり、IndexerName属性は華麗にスルーされる。VBと同じ。
・F#のインデックス付きプロパティと、VBのインデックス付きプロパティは違う。思い込みイクナイ。
・F# コンパイラは、インデックス付きプロパティがあっても DefaultMember 属性を暗黙的に生成しない場合がある(てゆーか、それインデクサ付きの型じゃないですしおすし)。
VBC#、F# は、それぞれインデクサの仕様が異なるので気をつけましょう。
・ってかこれ、バグっぽいちゃーバグっぽいゼ!?(DefaultMember属性のダブりとかマジやべぇ)*1



気まぐれでサラッとだけ書くつもりだったのに。なんやかんやで無駄に長くなって疲れた(内容しょぼいのに!)。



お疲れ様でした。

*1:まぁ、ふつーにインデクサを使うだけなら問題にならないので、「仕様です」っちゃー仕様ですね

Retry Monad for Transient Fault Handling (Topaz + FSharpx)


4月14日に札幌で行われた第69回CLR/H勉強会にて、「Retry Monad for Transient Fault Handling - F#とWindows Azure と私 -」と題して、ライトニングトークで発表しました。


Microsoft Enterprise Library 5.0 Integration Pack for Windows Azure(EL5 for Azure)のTopaz および FSharpx を利用してモナドを作りました。Topazを利用する理由は、再利用可能な再試行戦略およびWindows Azure向けの検出戦略が組み込み済みであり、それをそのまま利用したいからです。EL5 for AzureはOSSなので、どのような実装がなされているか実際に確認することができるので、すべてをF#で書き直すこともできますが、それでは車輪の再発明になってしまいます。Retry Monad for Transient Fault Handling は、一時的障害が発生するかもしれない計算について、それぞれ異なるRetryPolicyを適用しながら再試行処理を行います。一時的な障害に対するリトライ処理をひとつの計算として包括的に扱うことができるモナド実装です。このRetryモナドの計算結果は、Choice<’T1,’T2>型で得ることができ、これによりFSharpxで定義済みの Eitherモナドで扱うこともできます。



Retry Monad for Transient Fault Handling

namespace Monad.Retry 
open System

[<AutoOpen>]
module Retry =
  // #r "Microsoft.Practices.TransientFaultHandling.Core"
  // #r "FSharpx.Core"
  open Microsoft.Practices.TransientFaultHandling
  open FSharpx
 
  [<Sealed>]
  type TransientErrorCatchAllStrategy () =
    interface ITransientErrorDetectionStrategy with
      member this.IsTransient (ex : exn)  = true

  [<Sealed>]
  type TransientErrorIgnoreStrategy () =
    interface ITransientErrorDetectionStrategy with
      member this.IsTransient (ex : exn)  = false

  let defaultRetryStrategyName = "DefaultRetry"
  let defaultRetryCount = 3
  let defaultMinBackoff = TimeSpan.FromSeconds(3.0)
  let defaultMaxBackoff = TimeSpan.FromSeconds(90.0)
  let defaultDeltaBackoff = TimeSpan.FromMilliseconds(30.0)

  let (<+) (rp:RetryPolicy<'TResultStrategy>) retrying = rp.Retrying |> Event.add(retrying)

  type RetryPolicies =
    static member NoRetry() = new RetryPolicy<TransientErrorIgnoreStrategy>(0, TimeSpan.Zero)
    static member Retry<'TTransientErrorCatchStrategy when 'TTransientErrorCatchStrategy : (new :unit -> 'TTransientErrorCatchStrategy) and 'TTransientErrorCatchStrategy :> ITransientErrorDetectionStrategy>(retryCount : int , retryInterval : TimeSpan) : RetryPolicy<'TTransientErrorCatchStrategy> =
      new RetryPolicy<'TTransientErrorCatchStrategy>(retryCount, retryInterval)
    static member Retry<'TTransientErrorCatchStrategy when 'TTransientErrorCatchStrategy : (new :unit -> 'TTransientErrorCatchStrategy) and 'TTransientErrorCatchStrategy :> ITransientErrorDetectionStrategy>(retryCount : int , initialInterval : TimeSpan, increment : TimeSpan) : RetryPolicy<'TTransientErrorCatchStrategy> =
      new RetryPolicy<'TTransientErrorCatchStrategy>(retryCount, initialInterval, increment)
    static member Retry<'TTransientErrorCatchStrategy when 'TTransientErrorCatchStrategy : (new :unit -> 'TTransientErrorCatchStrategy) and 'TTransientErrorCatchStrategy :> ITransientErrorDetectionStrategy>(retryStrategy : RetryStrategy) : RetryPolicy<'TTransientErrorCatchStrategy> =
      new RetryPolicy<'TTransientErrorCatchStrategy>(retryStrategy)
    static member RetryExponential<'TTransientErrorCatchStrategy when 'TTransientErrorCatchStrategy : (new :unit -> 'TTransientErrorCatchStrategy) and 'TTransientErrorCatchStrategy :> ITransientErrorDetectionStrategy>(retryCount : int , deltaBackoff : TimeSpan) : RetryPolicy<'TTransientErrorCatchStrategy> =
      let retryStrategy = new ExponentialBackoff(defaultRetryStrategyName, retryCount, defaultMinBackoff, defaultMaxBackoff , deltaBackoff)
      new RetryPolicy<'TTransientErrorCatchStrategy>(retryStrategy)
    static member RetryExponential<'TTransientErrorCatchStrategy when 'TTransientErrorCatchStrategy : (new :unit -> 'TTransientErrorCatchStrategy) and 'TTransientErrorCatchStrategy :> ITransientErrorDetectionStrategy>(retryCount : int , minBackoff : TimeSpan, maxBackoff : TimeSpan, deltaBackoff : TimeSpan) : RetryPolicy<'TTransientErrorCatchStrategy> =
      let retryStrategy = new ExponentialBackoff(defaultRetryStrategyName, retryCount, minBackoff, maxBackoff, deltaBackoff)
      new RetryPolicy<'TTransientErrorCatchStrategy>(retryStrategy)
    static member RetryDefault(?retryCount : int) : RetryPolicy<TransientErrorCatchAllStrategy>=
      let retryCount = defaultArg retryCount defaultRetryCount
      RetryPolicies.RetryExponential<TransientErrorCatchAllStrategy>(retryCount, defaultMinBackoff, defaultMaxBackoff, defaultDeltaBackoff)

  type Retry<'TResult> = Retry of (Lazy<unit -> 'TResult * LastException option>)
  and RetryResult<'TResult> = Choice<'TResult, LastException>
  and LastException = exn

  let exnHandler e = Retry(lazy(fun () -> Unchecked.defaultof<'TResult>, e |> Some))    
  type RetryBuilder (policy : RetryPolicy) = 
    new(?retryCount : int, ?retrying) = 
      let policy = 
        let retryCount = defaultArg retryCount defaultRetryCount
        RetryPolicies.RetryDefault(retryCount)

      retrying |> function
      | None   -> policy <+ (fun e -> printfn "%s" (sprintf "RetryPolicyName:%s, CurrentRetryCount:%d, LastException.Message:%s, Delay:%A" 
                                                            policy.RetryStrategy.Name e.CurrentRetryCount e.LastException.Message e.Delay))
      | Some retrying ->policy <+ retrying
      RetryBuilder(policy)
    
    member this.Bind (m : Retry<'TResult>, bind : ('TResult) -> Retry<'UResult>) : Retry<'UResult> = 
      Retry(lazy(fun () -> 
        m |> function
        | Retry f -> f.Force() |> fun cont -> 
          cont() ||> fun r _ -> r |> bind
        |> function
          | Retry f -> f.Force() 
          |> fun cont -> policy.ExecuteAction(Func<_>(fun () -> cont() ||> fun r _ -> r,None))))
    member this.Return (value : 'TResult) : Retry<'TResult> = 
      Retry(lazy (fun () -> policy.ExecuteAction(L.F<_>(fun () ->  value, None))))
    member this.ReturnFrom (m : Retry<'TResult>) : Retry<'TResult> = 
      m
    member this.Delay (f: unit -> Retry<unit -> 'TResult>)  : Retry<unit -> 'TResult> = 
      Retry(lazy (fun () -> policy.ExecuteAction(L.F<_>(fun () -> f() |> function | Retry f -> f.Force() |> fun cont -> cont() ||> fun f _ -> f(), None)) ||> fun r _ ->  (fun () -> r), None))
    member this.Zero () : Retry<'TResult> = 
      this.Return(Unchecked.defaultof<'TResult>)
    member this.Combine(comp1:Retry<'TResult>, comp2:Retry<'TResult>) = 
      this.Bind(comp1,(fun r -> comp2))

  let retry = new RetryBuilder()

  open Operators
  let inline returnM x = returnM retry x 
  let inline (>>=) m f = bindM retry m f
  let inline (=<<) f m = bindM retry m f
  let inline (<*>) f m = applyM retry retry f m
  let inline ap m f = f <*> m
  let inline map f m = liftM retry f m
  let inline (<!>) f m = map f m
  let inline lift2 f a b = returnM f <*> a <*> b
  let inline ( *>) x y = lift2 (fun _ z -> z) x y
  let inline ( <*) x y = lift2 (fun z _ -> z) x y
  let inline (>>.) m f = bindM retry m (fun _ -> f)
  let inline (>=>) f g = fun x -> f x >>= g
  let inline (<=<) x = flip (>=>) x

  let (|RetryResult|) = 
    let rec result (r:RetryResult<'TResult>) =
      match r with
      | Choice1Of2 v -> v, None
      | Choice2Of2 e -> Unchecked.defaultof<'TResult>, Some(e)
    result

  let run (retryCont : Retry<unit -> 'TResult>) : RetryResult<'TResult> =
    try
      retryCont |> function
      |(Retry f) -> f.Force()() ||> fun r e -> 
        e |> function
        |Some e -> e |> Choice2Of2
        |None   -> r() |> Choice1Of2
    with e -> e |> Choice2Of2



一時的な障害:Windows Azure(クラウド)アプリケーションを開発するにあたって対処しなければならない課題のひとつ

他のクラウドサービスに依存するようなクラウドアプリケーションを開発する場合に開発者が対処しなければならない課題の一つに、“一時的な障害” があります。インフラストラクチャレベルの障害であったり、ネットワークの問題など一時的な条件のために発生する恐れのある障害のことです。この一時的に発生しうる障害は、ほとんどの場合は短い間隔で(ほんの数ミリ秒後に)リトライ処理を行うことで回避することができます。


たとえば、Windows AzureSQL Azureプラットフォームを利用する場合。SQL Azureサービスは、共有リソース上で大規模なマルチテナントデータベースとしてサービスが提供されるので、データベースを利用するすべての利用者に対して良好なエクスペリエンスを提供しなければなりません。そのため、SQL Azureは過剰なリソースの使用や、実行時間の長いトランザクションの発行された場合など、さまざまな理由でサービスへの接続数を抑制して、利用者が意図しないタイミングで接続を切断することがあります。これが、SQL Azureを利用した場合に生じる一時的な障害ということになります。このような障害が発生した場合であってもシームレスなユーザーエクスペリエンスを提供するために、Windows Azureアプリケーション(クラウドアプリケーション)では、一時的な障害によって処理が中断された場合にはリトライを試みるようにアプリケーションを実装する必要があります。


Microsoft Enterprise Library 5.0 Integration Pack for Windows Azureを利用する

一時的な障害に対応するアプリケーションを実装する場合、Microsoft Enterprise Library 5.0 Integration Pack for Windows Azure(以降 EL5 for Azure)を利用するのが有効です。EL5 for Azureは、マイクロソフトの pattern & practice チームによる、マイクロソフト製品やテクノロジを基として、アプリケーションを構築する上でのパターンやベストプラクティスを集めたライブラリの Windows Azure向けの拡張パックです。この拡張ライブラリが提供されるまでは、一時的障害を検知してリトライ処理を行う実装を開発者自身がおのおので組み込まなければなりませんでした。EL5 for Azureには、Transient Fault Handling Application Block (Topaz)という、Windows Azureのプラットフォームに含まれるサービス利用時に発生するさまざまな一時的な障害からWindows Azureアプリケーションを回復させるためのアプリケーションブロックが提供されています。これは、Windows Azure固有の一時的な障害のみならず、オンプレミスアプリケーションで発生するさまざまな一時的な障害に対するリトライ処理についても利用可能なように設計されており、リトライ処理について高いレベルで抽象化されたアプリケーションブロックです(Microsoft.Practices.TransientFaultHandling.Core.dllにまとめらえている)。特に、Windows Azureに特化した組み込みの実装については、SQL AzureWindows Azure ストレージサービス、Windows Azure サービスバス、Windows Azure キャッシングサービス向けの検出戦略がそれぞれ提供されていて、Microsoft.Practices.EnterpriseLibrary.WindowsAzure.TransientFaultHandling.dllに含まれています。



検出戦略と再試行戦略

検出戦略は、ITransientErrorDetectionStrategyインターフェイスを実装して作成することができます。

public interface ITransientErrorDetectionStrategy
{
    bool IsTransient(Exception ex);
}

例外を引数で受け取り、その例外の種類や内部的なメッセージなどを判断して、リトライ処理を行うときは true、 リトライをせずに無視するときは falseを返すように実装するだけの非常にシンプルなインターフェイスです。Windows Azureの一時的な障害に対する4つの組み込み検出戦略として、SqlAzureTransientErrorDetectionStrategy、StorageTransientErrorDetectionStrategy、ServiceBusTransientErrorDetectionStrategy、CacheTransientErrorDetectionStrategyが提供されています。




再試行戦略は、RetryStrategy抽象クラスを継承して作成することができます。

    public abstract class RetryStrategy
    {
        public static readonly int DefaultClientRetryCount = 10;
        public static readonly TimeSpan DefaultClientBackoff = TimeSpan.FromSeconds(10.0);
        public static readonly TimeSpan DefaultMaxBackoff = TimeSpan.FromSeconds(30.0);
        public static readonly TimeSpan DefaultMinBackoff = TimeSpan.FromSeconds(1.0);
        public static readonly TimeSpan DefaultRetryInterval = TimeSpan.FromSeconds(1.0);
        public static readonly TimeSpan DefaultRetryIncrement = TimeSpan.FromSeconds(1.0);
        public static readonly bool DefaultFirstFastRetry = true;

        public static readonly RetryStrategy NoRetry = new FixedInterval(0, DefaultRetryInterval);
        public static readonly RetryStrategy DefaultFixed = new FixedInterval(DefaultClientRetryCount, DefaultRetryInterval);
        public static readonly RetryStrategy DefaultProgressive = new Incremental(DefaultClientRetryCount, DefaultRetryInterval, DefaultRetryIncrement);
        public static readonly RetryStrategy DefaultExponential = new ExponentialBackoff(DefaultClientRetryCount, DefaultMinBackoff, DefaultMaxBackoff, DefaultClientBackoff);

        protected RetryStrategy(string name, bool firstFastRetry)
        {
            this.Name = name;
            this.FastFirstRetry = firstFastRetry;
        }

        public bool FastFirstRetry { get; set; }
        public string Name { get; private set; }
        public abstract ShouldRetry GetShouldRetry();
    }


基本的な実装は、GetShouldRetryメソッドをオーバーライドし、リトライすべきタイミングか否かを表すShouldRetry デリゲートを返すように実装します。

public delegate bool ShouldRetry(int retryCount, Exception lastException, out TimeSpan delay);


ShouldRetry デリゲートは、リトライする回数と最後に発生した例外およびリトライを行うタイミングの遅延間隔を受け取り、リトライ処理を行うべきタイミングか否かを返します。組み込みで、Incremental(再試行と再試行間の増分の時間間隔数を制御する戦略)、FixedInterval(再試行と一定間隔の再試行間を制御する戦略)、ExponentialBackoff(指数関数的な遅延を計算するためのバックオフ戦略)が提供されています。



Transient Fault Handling Application Block (Topaz)によるリトライ処理の基本的な利用方法


Transient Fault Handling Application Block (Topaz)による基本的な利用方法(C#)は、検出戦略と再試行戦略を組み合わせて、RetryPolicyオブジェクトを作成し、そのRetryPolicyオブジェクトにリトライ中の処理を適宜設定し、RetryPolicyオブジェクトのExecuteActionメソッドを呼び出します。ExecuteActionメソッドへは、リトライを行いたい対象の処理を継続渡しスタイルで渡します。

var strategy = new Incremental("Incr1",10, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
var policy = new RetryPolicy<SqlAzureTransientErrorDetectionStrategy>(strategy);

policy.Retrying += (_, e) =>
{
	Console.WriteLine("{0:HH:mm:ss.fff} RetryCount: {1}, ErrorMessage: {2}, StackTrace: {3}",
	    DateTime.Now,
	    e.CurrentRetryCount,
	    e.LastException.Message,
	    e.LastException.StackTrace);
};

var result = policy.ExecuteAction(() =>
{
	// SQL Azureへごにょごにょ

	return "クエリの結果などを返す";
});

EL5 for Azureはオブジェクト指向プログラミングで書かれているライブラリ、FSharpxは関数プログラミングで書かれているライブラリです。これら異なるパラダイムの部品を組み合わせてモナドを作る。とっても面白いですね。



モナドとは

モナドは単なる自己関手の圏におけるモノイド対象だよ。何か問題でも? - フィリップ・ワドラー


圏論を少しかじったことがある人にとっては問題ない説明なのですが、そうではない場合「日本語でおk」と言わざるを得ません。
この説明だけでは少々乱暴すぎるので、MSDN - コンピューテーション式(F#)へのリンクと、F#とモナドの関係について参考になりそうな表を置いておきます。


コンピュテーション式 (F#)
http://msdn.microsoft.com/ja-jp/library/dd233182(v=vs.110).aspx


Haskell F# 数学(圏論)
return return η(単位元:unit)
>>= bind (*)operator
型クラスMonadインスタンスであるように実装する コンピューテーション式で少なくとも Return と Bind の2つのmemberを実装する NA
Monad Computation Expression, Workflow モナドはKleisliトリプルと等価な定義。F# と Haskell の中で定義されるモナドの構造は実際にKleisliトリプル。
functor through a type class definition usually not mentioned 関手(functor)
function function (fun) 射(morphism)
Haskellのデータ型のHask圏 .Netデータ型の圏 グループ、位相、グラフ、微分幾何学
composable functions composable functions 2項演算とモノイド

MSDN - Code Recipe - F#によるモナドの実装方法とモナド則を確認するユニットテスト。 Retry Monad for Transient Fault Handling

モナド則を確認するためのユニットテスト等を含む、このプログラムコードのソリューションファイル一式を、MSDN - Code Recipe よりダウンロードすることができます。

http://code.msdn.microsoft.com/F-Retry-Monad-for-35ee1e72


関連記事
快刀乱麻を断つモナド - F#とIOモナドとコンピューテーション式の奥義と
http://d.hatena.ne.jp/zecl/20110703/p1

knockout.jsを利用したMVVMによるコンポーネント作成。WebGridの基本的な使い方と、Pagerヘルパーの利用。


MSDN - Code Recipeに初投稿しました。

WebGridに関するまとまった説明や、ページングに関するTipsもなぜか少なかった気がしたので、書いてみました。
サンプルコードを書いただけで力尽きたので細かい説明はありませんが、詳細については以下をご覧ください。


knockout.jsを利用したMVVMによるコンポーネント作成。WebGridの基本的な使い方と、Pagerヘルパーの利用。
http://code.msdn.microsoft.com/knockoutjsMVVMWebGridPager-e675c134

いまさらASP.NET MVCのモデルバインダ入門あれこれ。MEFのカスタムエクスポートプロバイダーは設計の幅を広げる。自動拡張型カスタムモデルバインダプロバイダーを作ろう。


http://www.asp.net/



ASP.NET MVC4 Betaがリリースされまして、WebAPIいいね!な今日この頃。誰が言ったか、これから求められるIT技術は、Web、クラウド、関数型言語の三本柱らしいです。とは言っても、世の中にはさまざまな技術が溢れています。.NETerなわたしは月並みですが、ASP.NET MVCWindows Azure、F#を追いかけるつもりです。まぁ、日進月歩の業界ですし、わたし自身飽きっぽかったりするので来年には違うことを言っているかもしれません。最近の私はと言えば、月9ドラマ「ラッキーセブン」でメカオタ少女茅野メイ役を演じている入来茉里さんのファンになりました。スピンオフドラマの「敷島☆珈琲〜バリスタは見た!?〜」も面白い。これからブレイクすること間違いありません。



それはさておき、ASP.NET MVC関連の記事はだんだんと増えてきていますが、なぜか基本中の基本であるカスタムモデルバインダですとか、カスタムモデルバインダプロバイダーに関する記事があまりにも少ない。少なすぎて困っているASP.NET MVC入門者も少なくないと聞いています(要出典)。誰かの役に立つかもしれないということで、いまさらながらASP.NET MVC3のモデルバインダ入門あれこれについてちょっと書いておきます。



このエントリーの主な話題。わりと盛りだくさん。

・カスタムモデルバインダについて
・カスタムモデルバインダプロバイダーについて
Base64でシリアル化可能なモデルと、その汎用モデルバインダについて
・カスタムモデルバインダでアノテーション検証を有効にする
・MEFのカスタムエクスポートプロバイダーについて
・MEFを用いた自動拡張型カスタムモデルバインダプロバイダーについて
・IModelBinderProviderインターフェイスがイケてない説

この記事のサンプルコード一式はSkyDriveへあげておきます。



すてきなモデルバインダ

ASP.NET MVC にはモデルバインダという仕組みがあり、比較的新しいMVCフレームワークで採用されていて、たとえばJavaScript製のMVCフレームワークなんかでもよく採用されているデータバインド手法です。ASP.NET MVCでは、モデルバインダと呼ばれるクラスでリクエストデータ等を使って厳密に型付けされたオブジェクトを作成して、ルーティングやクエリ、フォームパラメータなどに、コントローラーのアクションに対するパラメータの型とのバインディングが管理されます。同名のパラメータについてデータバインドを試みてコントローラのアクションを単純化してくれるし、コントローラー内に「値の変換を行う」というノイズとなる処理がなくなるので、開発者はコントローラー本来の役割の実装に集中できるようなります。素敵ですね。モデルバインディングを実際に実行するのはSystem.Web.Mvc.IModelBinderを実装したクラスで、既定ではSystem.Web.Mvc.DefaultModelBinderクラスが適用されます。この既定で動作するバインダは、文字や数値など.NETで扱う基本的な型や、アップロードされたファイルなど様々な型に対応しています。小規模またはシンプルなシナリオでは、この既定のモデルバインダが自動的に基本的な型をバインドしてくれるので、この動作について特別意識することはあまりないでしょう。ただ、世の中そんなにあまくないのが現実です。大規模または複雑なシナリオでは、既定のバインディングでは十分ではないこともあるでしょう。そのような場合、カスタムモデルバインダ(ModelBinderの拡張)を作成することになります。



既定のモデルバインダが実際にどんな働きをしてくれるのかを一目でわかるように書くと、

[HttpPost]
public ActionResult Create()
{
	var customer = new Customer() 
	{
		CustomerId = Int32.Parse(Request["customerId"]), 
		Description = Request["description"], 
		Kind = (CustomerKind)Enum.Parse(typeof(CustomerKind), Request["kind"]), 
		Name = Request["name"], 
		Address = Request["address"]
	};

	// …

	return View(customer);
};


既定のDefaultModelBinderが処理できる範囲内であれば、上記のような煩雑な型の変換処理をまったく書かなくてよくて、下記のようにシンプルに書けるようになります。

public ActionResult Create(Customer customer) 
{ 
	// … 

	return View(customer);
}


モデルバインダって、とてもかわいいですね。はい。って、ASP.NET MVC3を使ってプログラミングをしている人には当たり前のことでしたね。



モデルバインダの拡張

さて、「大規模または複雑なシナリオでは、既定のバインディングでは十分ではないこともあるでしょう。」と前述しました。そのようなシナリオでは、モデルバインダの拡張、すなわち独自にカスタムモデルバインダを作成することで、さまざなシナリオに対応することができます。



モデルバインダの拡張の方法としては、IModelBinderインターフェイスを実装するか、もしくはIModelBinderを実装している既定のDefaultModelBinderクラスを継承して実装します。IModelBinderインターフェイスを実装する方法の場合は、object BindModel(...)メソッドを実装するだけというシンプル設計。


DefaultModelBinderを継承して作る場合の主な拡張ポイントとしては以下のものがあり、適宜必要なものをオーバーライドして実装します。

object BindModel(...);			// モデルバインド実行
object CreateModel(...);		// モデル型オブジェクト生成
bool OnModelUpdating(...);		// モデル更新開始
void OnModelUpdated(...);		// モデル更新完了
bool OnPropertyValidating(...);		// プロパティ検証開始
void OnPropertyValidated(...);		// プロパティ検証完了

また、拡張した自作のモデルバインダはいくつかの異なるレベルで登録することができて、これにより非常に柔軟にバインディング方法を選択できます。

// Application_Start()で登録する方法
ModelBinders.Binders.DefaultBinder = new CustomModelBinder();
ModelBinders.Binders.Add(typeof(MyModel), new CustomModelBinder());

// Actionの引数に属性で指定する方法
[ModelBinder(typeof(CustomModelBinder))]


他にも、ModelBinderProviderを登録して対応することもできます。これについては後程述べます。



カスタムモデルバインダを作ろう


ではカスタムモデルバインダを作成してみましょう。以下のようなユーザー定義のモデルを含む単純なViewModelをバインドしたい場合を考えます。

namespace ModelBinderSample.Models.ViewModel
{
    public class SampleViewModel0
    {
        public Sample0 Child { get; set; }
    }
}
using System.ComponentModel.DataAnnotations;
using ModelBinderSample.Models.ViewModel;

namespace ModelBinderSample.Models
{
    public enum Hoge
    {
        Test1,
        Test2,
        Test3
    }

    public class Sample0 
    {
        public Hoge Hoge { get; set; }

        [Display(Name = "ただのプロパティ")]
        public string NomalProperty { get; set; }
    }
}


IModelBinderインターフェイスを実装する方法を試してみましょう。例えば、下記サンプルのように実装することができます。object BindModel(...)メソッドの基本実装は、リクエストを適切な型に変換して返してあげる処理を書くだけです。実用性はありませんが下記サンプルのように値を直接編集したりもできますし、他にも値を検証してエラーメッセージを追加したりすることもできます。

using System;
using System.Web;
using System.Web.Mvc;
using ModelBinderSample.Models.ViewModel;

namespace ModelBinderSample.Models.ModelBinder
{
    public class SampleViewModel0Binder : IModelBinder
    {
        public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
        {
            HttpRequestBase request = controllerContext.HttpContext.Request;

            var model = new Sample0()
            {
                Hoge = (Hoge)Enum.Parse(typeof(Hoge), request.Form.Get("Child.Hoge"), false),
                NomalProperty = request.Form.Get("Child.NomalProperty") + "だってばよ!",
            };

            return new SampleViewModel0() { Child = model };
        }
    }
}


ビュー:Sample0/Index.cshtml

@using ModelBinderSample.Models
@using ModelBinderSample.Models.ViewModel
@model SampleViewModel0
           
@{
    ViewBag.Title = "Sample0";
}

<h2>@ViewBag.Message</h2>

@using (Html.BeginForm("Index", "Sample0"))
{

    @Html.TextBoxFor(vm => vm.Child.NomalProperty, new { @style = "width: 350px;" }) 
    @Html.HiddenFor(vm => vm.Child.Hoge)

    <br />    
    <input type="submit" value="送信" />
}

コントローラー:Sample0Controller.cs

using System;
using System.Web.Mvc;
using ModelBinderSample.Models;
using ModelBinderSample.Models.ViewModel;

namespace ModelBinderSample.Controllers
{
    public class Sample0Controller : Controller
    {
        public ActionResult Index()
        {
            ViewBag.Message = "ASP.NET MVC へようこそ";


            var vm = new SampleViewModel0()
            {
                Child = new Sample0()
                {
                    Hoge = Models.Hoge.Test2,
                    NomalProperty = "うずまきナルト",
                }
            };

            return View(vm);
        }

        [HttpPost]
        [AcceptVerbs(HttpVerbs.Post)]
        public ActionResult Index(SampleViewModel0 vm)
        {
            ViewBag.Message = "ASP.NET MVC へようこそ";

            if (!ModelState.IsValid)
            {
                return View(vm);
            }

            return View(vm);
        }

        public ActionResult About()
        {
            return View();
        }
    }
}

モデルバインダの登録

protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();

    // Add ModelBinder
    ModelBinders.Binders.Add(typeof(SampleViewModel0), new SampleViewModel0Binder());

    RegisterGlobalFilters(GlobalFilters.Filters);
    RegisterRoutes(RouteTable.Routes);
}


内容はお粗末ですが、カスタマイズはできました。もう少し踏み込んだカスタマイズについては後半で。


ModelBinderProviderの拡張 : カスタムモデルバインダプロバイダー

モデルの型ごとに適切なモデルバインダを供給するクラス。それがモデルバインダプロバイダー。もっと噛み砕いて言うと、「このモデルの型の場合は、このモデルバインダを使ってバインディングしてくださいね〜」って情報を供給してくれるクラスです。カスタムモデルバインダプロバイダーは、IModelBinderProviderインターフェイスを実装して作ることができます。



SampleViewModel0モデルのカスタムモデルバインダプロバイダーを実装サンプル


SampleViewModel0BinderProvider.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using ModelBinderSample.Models.ModelBinder;
using ModelBinderSample.Models.ViewModel;
using ClassLibrary1;

namespace ModelBinderSample.Models.ModelBinderProvider
{
    public class SampleViewModel0BinderProvider : IModelBinderProvider
    {
        public IModelBinder GetBinder(Type modelType)
        {
            if (modelType == typeof(SampleViewModel0))
                return new SampleViewModel0Binder();
            return new DefaultModelBinder();
        }
    }
}

このサンプルでは、型がSampleViewModel0であるとき、SampleViewModel0Binderを返し、それ以外の型のときは既定のモデルバインダを返しているだけなので、プロバイダーとしてはあまり意味がありません。通常は、さまざまなモデルの型に応じて異なるモデルバインダを返すようなモデルバインダプロバイダーを作ります。


モデルバインダプロバイダーの登録

protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();

    // Add ModelBinderProvider
    ModelBinderProviders.BinderProviders.Add(new SampleViewModel0BinderProvider());

    RegisterGlobalFilters(GlobalFilters.Filters);
    RegisterRoutes(RouteTable.Routes);
}



Base64でシリアル化可能なモデルと、その汎用モデルバインダ

もう少し踏み込んだカスタムモデルバインダの例を見てみます。例としてはあまりよろしくはないですが、こういう実装もできるんだよというサンプルとして、Base64でシリアル化可能なModelをバインドするための汎用的なモデルバインダを作ってみましょう。例えば、ViewModelにユーザー定義の型のプロパティを含むような場合、当然 DefaultModelBinder ではそのような型をバインドできませんので、コントローラーのアクションパラメータとうまくバインドできずに、そのViewModelのプロパティにはnullが設定されてしまいます。そこで任意の型についてBase64形式でシリアル化可能なモデルをバインドするような、汎用的なカスタムモデルバインダを考えてみます。



ひどく曖昧な抽象化ですが、まずシリアル化可能なモデルであることを表すインターフェイスを定義します。BindTypeプロパティでは、バインドする型(つまりはモデル自身の型)を返すように実装します。ToStringメソッドでは、Base64エンコードした文字列を返すように実装します。


ISerializableModel.cs

using System;

namespace ClassLibrary1
{
    public interface ISerializableModel
    {
        Type BindType { get; }
        string ToString();
    }
}



そのインターフェイスを実装しただけの抽象クラス。相変わらず曖昧模糊。


AbustractSerializableModel.cs

using System;

namespace ClassLibrary1
{
    [Serializable]
    public abstract class AbustractSerializableModel : ISerializableModel
    {
        public abstract Type BindType { get; }
        public abstract override string ToString();
    }
}


Base64でシリアル化可能なモデルのカスタムモデルバインダを実装します。下記サンプルのように、自身の型のModelMetadataから、ModelValidatorを取得して自身の型のバリデーションの処理も行うように実装しておくと、カスタムモデルバインダでもアノテーション検証がされるようになり、ViewModelに入れ子となっている場合でも検証を有効にするよう実装することもできます。これは、今回の実装にかかわらず様々な実装で使える方法なので覚えておいて損はないでしょう。


SerializeableModelBinder{T}.cs

using System.Web.Mvc;

namespace ModelBinderSample.Models.ModelBinder.Binder
{
    public class SerializeableModelBinder<T> : DefaultModelBinder
    {
        public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
        {
            if (bindingContext.ModelMetadata.ModelType != typeof(T))
                return base.BindModel(controllerContext, bindingContext);

            var serializedModel = controllerContext.HttpContext.Request[bindingContext.ModelName];
            var model = Serializer.Deserialize(serializedModel);

            ModelMetadata modelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => model, model.GetType());
            ModelValidator compositeValidator = ModelValidator.GetModelValidator(modelMetadata, controllerContext);

            foreach (ModelValidationResult result in compositeValidator.Validate(null))
                bindingContext.ModelState.AddModelError(bindingContext.ModelName + "." + result.MemberName, result.Message);      

            return model;
        }
    }
}

Base64シリアライズとデシリアライズ
Serializer.cs

using System;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;

namespace ModelBinderSample
{
    public static class Serializer
    {
        public static string Serialize(object obj)
        {
            using (MemoryStream stream = new MemoryStream())
            {
                var bf = new BinaryFormatter();
                bf.Serialize(stream, obj);
                return Convert.ToBase64String(stream.GetBuffer());
            }
        }

        public static object Deserialize(string subject)
        {
            using (var stream = new MemoryStream(Convert.FromBase64String(subject)))
            {
                var bf = new BinaryFormatter();
                return bf.Deserialize(stream);
            }
        }
    }
}

Sample1.cs

using System;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.Contracts;
using ClassLibrary1;
using ModelBinderSample.Models.ViewModel;

namespace ModelBinderSample.Models
{
    [Serializable]
    public class Sample1 : AbustractSerializableModel
    {
        public override Type BindType
        {
            get { return this.GetType(); }
        }

        [Display(Name="ただのプロパティ")]
        public string NomalProperty { get; set; }

        public string[] ParamString { get; set; }

        public int[] ParamInt { get; set; }

        public Hoge Hoge { get; set; }

        public override string ToString()
        {
            Contract.Ensures(!string.IsNullOrWhiteSpace(Contract.Result<string>()));
            return Serializer.Serialize(this);
        }
    }
}

Sample2.cs

using System;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.Contracts;
using ClassLibrary1;
using ModelBinderSample.Models.ViewModel;

namespace ModelBinderSample.Models
{
    [Serializable]
    public class Sample2 : AbustractSerializableModel
    {
        public override Type BindType
        {
            get { return this.GetType(); }
        }

        [Display(Name = "必須なプロパティ")]
        [Required(ErrorMessage = "「{0}」は、必須だってばよ!")]
        public string RequiredProperty { get; set; }

        public string[] ParamString { get; set; }

        public int[] ParamInt { get; set; }

        public Hoge Hoge { get; set; }

        public override string ToString()
        {
            Contract.Ensures(!string.IsNullOrWhiteSpace(Contract.Result<string>()));
            return Serializer.Serialize(this);
        }
    }
}


Sample3.cs

using System.ComponentModel.DataAnnotations;
using ModelBinderSample.Models.ViewModel;

namespace ModelBinderSample.Models
{
    public class Sample3 
    {

        [Display(Name = "入力必須なやつ")]
        [Required(ErrorMessage = "「{0}」は、必須だってばよ!")]
        public string RequiredProperty { get; set; }

        public string[] ParamString { get; set; }

        public int[] ParamInt { get; set; }

        public Hoge Hoge { get; set; }
    }
}


モデルバインダの登録

protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();

    // Add ModelBinder
    ModelBinders.Binders.Add(typeof(Sample1), new SerializeableModelBinder<Sample1>());
    ModelBinders.Binders.Add(typeof(Sample2), new SerializeableModelBinder<Sample2>());
    ModelBinders.Binders.Add(typeof(Sample3), new SerializeableModelBinder<Sample3>());

    RegisterGlobalFilters(GlobalFilters.Filters);
    RegisterRoutes(RouteTable.Routes);
}


Sample3クラスは、SerializableでもなければISerializableModelインターフェイスも実装していないので、SerializeableModelBinderクラスによってバインドされませんが、Base64シリアライズできるモデルについては、汎用的なモデルバインダによってバインディングされます。ご利用は計画的に。何が言いたいかというと、必ずしもモデルの型とモデルバインダは1対1の関係というわけではないというわけです。また、「モデルの型」という言い方をしていますが、型以外の判定手段(インスタンスそのものの値や状態)でバインディング方法を変えるという方法を取ることもできます。そこは設計次第です。腕の見せ所ですね。


さて、実装サンプルSerializeableModelBinderクラスを用いることで、Base64シリアライズできるモデルについて汎用的にバインディングできるようになりました。しかしながら、Sample4,Sample5...と新しくシリアライズ可能なクラスを作るたびに、Application_Start()にて、対象となるモデルに対してモデルバインダを登録しなければならないというのは非常に面倒くさいです。われわれ開発者は、自動化できることならなるべく自動化したいという怠け者。



そこで、MEF(Managed Extensibility Framework)を用いて自動拡張型カスタムモデルバインダプロバイダーを作ることを考えてみます。



ExportProviderの拡張 : 任意のインターフェイスの実装をコントラクトとするカスタムエクスポートプロバイダー

さっそく「MEFを用いた自動拡張型カスタムモデルバインダプロバイダー」の作成と行きたいところなんですが、その前に下準備が必要となります。ISerializableModelインターフェイスを実装している具象クラスをコントラクトとするMEFエクスポートが必要になるからです。そのために、任意のインターフェイスの実装をコントラクトとするカスタムエクスポートプロバイダーを作成する必要があります。前回のエントリーではWindows AzureでBlobストレージからMEFのパーツを検索できるカスタムCatalogを紹介しました。今回は、Catalogに比べて、よりピンポイントな条件でエクスポートができる、カスタムエクスポートプロバイダーを紹介します。



MEFの入門記事はわかりやすいものがいくつかありますが、入門よりももう少し踏み込んだ情報はあまりありません。海外記事を含めてもカスタムカタログやカスタムエクスポートプロバイダー等の解説記事や簡単なサンプルは決して多くはありません。MEF(Managed Extensibility Framework)を積極的に使おうと考えた場合、カタログやエクスポートプロバイダーのカスタマイズは必須です。オブジェクト指向なスタイルの開発においては、インターフェイスによる多態は日常茶飯事ですし、任意のインターフェイスの実装をコントラクトとするエクスポートプロバイダーとか、欲しくなるのは自然な流れです。ということで、シンプルなサンプルコードを以下に示します。



InterfaceExportProvider{T}.cs

using System;
using System.Collections.Generic;
using System.ComponentModel.Composition;
using System.ComponentModel.Composition.Hosting;
using System.ComponentModel.Composition.Primitives;
using System.Diagnostics.Contracts;
using System.Linq;
using System.Reflection;
using ClassLibrary1;

namespace ClassLibrary2
{
    public class InterfaceExportProvider<T> : ExportProvider
    {
        private readonly IList<InterfaceExportDefinition> exportDefinitions = new List<InterfaceExportDefinition>();

        public InterfaceExportProvider() : this(() => Assembly.GetExecutingAssembly().GetTypes(), t => true) 
        { 
        }
        public InterfaceExportProvider(Func<Type, bool> predicate) : this(() => Assembly.GetExecutingAssembly().GetTypes(), predicate) 
        {
            Contract.Requires(predicate != null);
        }

        public InterfaceExportProvider(Func<Type[]> factory, Func<Type, bool> predicate)
        {
            Contract.Requires(factory != null);

            var types = factory()
                       .Where(t => !t.IsAbstract)
                       .Where(t => !t.IsInterface)
                       .Where(t => predicate(t));
            ComposeTypes(types);
        }

        protected override IEnumerable<Export> GetExportsCore(ImportDefinition definition, AtomicComposition atomicComposition)
        {
            Contract.Ensures(0 <= this.exportDefinitions.Count);
            return exportDefinitions.Where(ed => definition.ContractName == ed.ContractName)
                                    .Select(ed => new Export(ed, () => Util.New(ed.ServiceType)));
        }

        [ContractInvariantMethod]
        private void ObjectInvariant()
        {
            Contract.Invariant(typeof(T).IsInterface);
        }

        private void ComposeTypes(IEnumerable<Type> serviceTypes)
        {
            Contract.Requires(serviceTypes != null);

            serviceTypes
                .Where(x => !x.IsAbstract)
                .Select(type => new { Type = type, InterfaceType = type.GetInterfaces().Where(t => t == typeof(T)).SingleOrDefault()})
                .Where (x  => x.InterfaceType != null).ToList()
                .ForEach(x =>
                {
                    var metadata = new Dictionary<string, object>();
                    metadata[CompositionConstants.ExportTypeIdentityMetadataName] = AttributedModelServices.GetTypeIdentity(x.Type);
                    var contractName = AttributedModelServices.GetContractName(x.InterfaceType);
                    var exportDefinition = new InterfaceExportDefinition(contractName, metadata, x.Type);
                    exportDefinitions.Add(exportDefinition);
                });
        }
    }
}

例えば上記のクラスをデフォルトコンストラクタインスタンス化した場合、現在実行中のコードを格納しているアセンブリ内のうち、ジェネリックタイプTで指定したインターフェイスをコントラクトとする型についてエクスポートを行います。そういうExportプロバイダー実装です。要するに、ジェネリックタイプTで指定したインターフェイスを実装している具象クラスを検索してオブジェクトグラフのファクトリを行うようなプロバイダーということです。これがあると、オブジェクト指向プログラミングで当たり前のインターフェイスによる多態をひとまとめに"[ImportMany(typeof(インターフェイス))]"というように、Exportできるので嬉しいというわけです。




上記InterfaceExportProviderクラスに合わせて、そのようなコントラクトを満たすExportオブジェクトを表すカスタムExportDefinitionも定義も必要となります。こちらは、ContractNameプロパティとMetadataプロパティをoverrideして実装を上書いているだけのなんの芸もない実装ですので、難しいことは何もないですね。

InterfaceExportDefinition.cs

using System;
using System.Collections.Generic;
using System.ComponentModel.Composition.Primitives;
using System.Diagnostics.Contracts;

namespace ClassLibrary2
{
    public class InterfaceExportDefinition : ExportDefinition
    {
        private readonly string _contractName;
        private readonly Dictionary<string, object> _metaData;

        public InterfaceExportDefinition(string contractName, Dictionary<string, object> metaData, Type type)
        {
            Contract.Requires(metaData != null);
            Contract.Requires(type != null);
            Contract.Ensures(this._contractName == contractName);
            Contract.Ensures(this._metaData == metaData);

            this._contractName = contractName;
            this._metaData = metaData;
            ServiceType = type;
        }

        public Type ServiceType { get; private set; }

        [ContractInvariantMethod]
        private void ObjectInvariant()
        {
            Contract.Invariant(this._metaData != null);
        }

        public override IDictionary<string, object> Metadata
        {
            get 
            {
                Contract.Ensures(this._metaData != null);
                Contract.Ensures(Contract.Result<IDictionary<string, object>>() == this._metaData);
                return this._metaData; 
            }
        }

        public override string ContractName
        {
            get 
            {
                Contract.Ensures(Contract.Result<string>() == this._contractName);
                return this._contractName; 
            }
        }
    }
}


これで、任意のインターフェイスの実装をコントラクトとするカスタムエクスポートプロバイダーができました。オブジェクト指向においては、インターフェイスによる多態は日常茶飯事ですので利用場面はたくさんありそうですね。


MEFを用いた自動拡張型カスタムモデルバインダプロバイダー

では作成したInterfaceExportProviderクラスを用いて、自動拡張してくれるカスタムモデルバインダプロバイダーを実装します。ImportMany属性で、コントラクト型でISerializableModelを指定することで、ISerializableModelインターフェイスを実装している具象クラスをコントラクトとしたエクスポートがなされるので、ISerializableModelインターフェイスを実装しているモデルについて、適切にモデルバインディングしてくれるという寸法です。CompositionContainerフィールドはIDisposableですので、忘れずにIDisposableのイディオムを用いて綺麗にガベコレしてくれるように実装しましょう。


SerializeableModelBinderProvider.cs

using System;
using System.Collections.Generic;
using System.ComponentModel.Composition;
using System.ComponentModel.Composition.Hosting;
using System.Linq;
using System.Web.Mvc;
using ClassLibrary1;
using ClassLibrary2;
using ModelBinderSample.Models.ModelBinder;
using System.Collections.Concurrent;

namespace ModelBinderSample.Models.ModelBinderProvider
{
    public class SerializeableModelBinderProvider : IModelBinderProvider, IDisposable
    {
        private bool disposed;
        private readonly ConcurrentDictionary<Type, Type> _cache = new ConcurrentDictionary<Type, Type>();

        [ImportMany(typeof(ISerializableModel))]
        private IEnumerable<Lazy<ISerializableModel>> _serializableModels = null;
        private CompositionContainer _Container = null;

        private SerializeableModelBinderProvider()
        {
            this.disposed = false;
        }

        public SerializeableModelBinderProvider(Func<Type[]> factory) : this()
        {
            ComposeParts(factory);
        }

        public IModelBinder GetBinder(Type modelType)
        {
            this.ThrowExceptionIfDisposed();

            if (CanBind(modelType))
            {
                var modelBinderType = _cache.GetOrAdd(modelType, typeof(SerializeableModelBinder<>).MakeGenericType(modelType));
                return (IModelBinder)Activator.CreateInstance(modelBinderType);
            }
            return null;
        }

        public bool CanBind(Type modelType)
        {
            if (_cache.ContainsKey(modelType))
                return true;

            var count = _serializableModels.Where(m => m.Value.BindType == modelType).Count();
            if (count > 0)
                return true;
            return false;
        }

        protected void ThrowExceptionIfDisposed()
        {
            if (this.disposed)
            {
                throw new ObjectDisposedException(this.GetType().ToString());
            }
        }

        public void ComposeParts(Func<Type[]> factory)
        {
            this.ThrowExceptionIfDisposed();

            var provider = new InterfaceExportProvider<ISerializableModel>(factory, x => x.IsSerializable);
            _Container = new CompositionContainer(provider);
            _Container.ComposeParts(this);
        }

        protected virtual void Dispose(bool disposing)
        {
            lock (this)
            {
                if (this.disposed)
                {
                    return;
                }

                this.disposed = true;

                if (disposing)
                {
                    if (_Container != null)
                    {
                        _Container.Dispose();
                        _Container = null;
                    }
                }
            }
        }

        public void Dispose()
        {
            this.Dispose(true);
            GC.SuppressFinalize(this);
        }
    }
}


このような汎用的なカスタムモデルバインダプロバイダーを作成することで、Sample4, Samole5...と、シリアル化可能なクラスを次々と定義していくだけで、自動的に拡張されていくカスタムエクスポートプロバイダーを作成することができるというわけです。MEFはユーザーの目に見えるような機能面での拡張のみならず、開発視点においても確実に設計の幅を広げてくれます。MEFは.NET Framework4標準ですので、臆することなくガンガン使っていけるのがうれしいですね。



IModelBinderProviderインターフェイスがイケてない説

まず、System.Web.Mvc.IModelBinderProviderインターフェイスの定義をご覧いただきましょう。

public interface IModelBinderProvider
{
	IModelBinder GetBinder(Type modelType);
}


モデルの型を引数で受け取り、適切なモデルバインダを返すだけのGetBinderメソッドを持つ、とてもシンプルなインターフェイスです。あまりにもシンプルすぎて、モデルバインダプロバイダーがどんなモデルの型を対象としたプロバイダーなのか外部から知るすべもありません。GetBinderメソッドの戻り値が null だったら、次のモデルバインダプロバイダーに処理を委譲する作りになっているので、複数のカスタムモデルバインダプロバイダーが協調して動作するようにするには、サポートしないモデルの型の場合に必ず null を返さなければなりません。「該当する結果がない場合にnullを返して、戻り値側でそれがnullだったら次の処理を...」という仕様はあんましイクナイ(・Α・)と思います。もっと別の方法もあっただろうに...。




あと、おまけ。
Util.cs

using System;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Web.Mvc;

namespace ClassLibrary1
{
    public static class Util
    {
        public static T New<T>()
        {
            Type type = typeof(T);
            Func<T> method = Expression.Lambda<Func<T>>(Expression.Block(type, new Expression[] { Expression.New(type) })).Compile();
            return method();
        }

        public static object New(Type type)
        {
            Func<object> method = Expression.Lambda<Func<object>>(Expression.Block(type, new Expression[] { Expression.New(type) })).Compile();
            return method();
        }

        public delegate TInstance ObjectActivator<TInstance>(params object[] args);
        public static ObjectActivator<TInstance> GetActivator<TInstance>(ConstructorInfo ctor)
        {
            Type type = ctor.DeclaringType;
            ParameterInfo[] paramsInfo = ctor.GetParameters();

            ParameterExpression param = Expression.Parameter(typeof(object[]), "args");
            Expression[] argsExp = new Expression[paramsInfo.Length];

            for (int i = 0; i < paramsInfo.Length; i++)
            {
                Expression index = Expression.Constant(i);
                Type paramType = paramsInfo[i].ParameterType;
                Expression paramAccessorExp = Expression.ArrayIndex(param, index);
                Expression paramCastExp = Expression.Convert(paramAccessorExp, paramType);
                argsExp[i] = paramCastExp;
            }

            NewExpression newExp = Expression.New(ctor, argsExp);
            LambdaExpression lambda = Expression.Lambda(typeof(ObjectActivator<TInstance>), newExp, param);

            ObjectActivator<TInstance> compiled = (ObjectActivator<TInstance>)lambda.Compile();
            return compiled;
        }
    }
}

モデルバインダプロバイダーの登録

protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();

    // Add ModelBinderProvider
    ModelBinderProviders.BinderProviders.Add(new SampleViewModel0BinderProvider());
    ModelBinderProviders.BinderProviders.Add(new SerializeableModelBinderProvider(() => Assembly.GetExecutingAssembly().GetTypes()));

    RegisterGlobalFilters(GlobalFilters.Filters);
    RegisterRoutes(RouteTable.Routes);
}


さてコード中心の記事でしたが、ASP.NET MVC3のカスタムモデルバインダとカスタムモデルバインダプロバイダーについてのサンプルプログラムと、MEFのカスタムエクスポートプロバイダーを利用した自動拡張型のコンポーネント設計の手法について見てきました。モデルバインダの仕組みはASP.NET MVC3のコアコンポーネントのひとつであり基本中の基本ですので、既定のDefaultModelBinderのみに頼るのではなく、このあたりの仕組みや拡張・設計ポイントはしっかり押さえておきたいところです。長々と書きましたが、何かの参考になれば幸いです。


F#はちょい充電中。

Windows AzureとMEFで再デプロイを必要としない拡張(または縮小)可能なクラウドアプリケーション。BlobストレージからMEFのパーツを検索できるカスタムカタログ BlobStorageCatalog を作ろう。

しばらくF#ネタしか書いていませんでした。たまにはC#ネタを置いておきます。
C#ネタというよりは、最近仕事で利用しているWindows AzureとMEFのネタですが。


Managed Extensibility Framework (MEF)とは

Managed Extensibility Framework (MEF) は、.NET Frameworkに最適な拡張可能なアプリケーションを作成するための一連の機能を提供する軽量ライブラリです。既定で提供されているのは属性プログラミングモデルですが、MEFのコアAPIは全く属性に依存していないので、MEFのコアAPIにアクセスする方法を定義したクラスを独自に作成することでリフレクションベースのプログラミングモデルなど、様々なスタイルで利用可能です。




MEFは、.NET Framework 4 およびSilverlight 4で標準利用可能です。なお現在もオープンソースで開発が進められていて、ASP.NET MVC向けのComposition providerなどを含む、MEF 2 Preview 5がCodePlexよりダウンロード可能です。wktkですね。


http://mef.codeplex.com/





「MEFとは、簡単に言うとDIである」というような説明がされがちですが、一般的なIoCコンテナ(DIコンテナフレームワークほど多機能ではありません。MEFは、 IoCコンテナ(DIコンテナ)で言うところの、いわゆるオブジェクトグラフのファクトリをメインとして機能します。つまり、実行時に解決する必要があるクラスのメンバーを動的に認識して処理することができます。しかし逆に言うと、一般的なIoCコンテナ(DIコンテナフレームワークほど豊富な機能はなく、非常にコンパクトな構成です。MEFにもキャッシュ機能があるのは確かですが、インスタンスのキャッシュを最小限しかサポートしません。また、.NET Framework 4 に同梱されているバージョンのMEF では処理のインターセプト(AOPサポート)の機能がまったくありません。純粋に、IoCコンテナ(DIコンテナ)フレームワークのさまざまな機能を求める場合、MEFでは満足できないでしょう。


では、どのような場合にMEFを利用すれば良いのか。それは、アプリケーションが汎用性のある機能拡張(プラグイン,アドイン)を求めているかどうかです。これに該当する場合はMEFの利用が有効で、そうではない場合は、あえてMEFを利用する意味はあまりないでしょう。


拡張(または縮小)可能なクラウドアプリケーション
「拡張(または縮小)可能なクラウドアプリケーション」などと大風呂敷を広げてみたものの、わたしはWindows AzureのWebRoleとWorkerRoleくらいしかかじったことがありません。ここではWindows Azureにおいて、再デプロイを必要としない拡張可能なWebRole、WorkerRoleを作りたいケースについて考えます。


.NET Frameworkで拡張可能なアプリケーションを作りたい場合、有効な方法としてMEFがあると前述しました。Azureに置いてもそれは同じです。しかし、Azureで一度デプロイしたものについて動的に機能拡張をするにはどのようにしたらよいのでしょう。まっさきに思い浮かぶのが、Blobストレージの利用です。Blobストレージに格納してあるアセンブリを、ローカルストレージにダウンロードして、DirectoryCatalogクラスを利用してパーツをExportする方法が考えられます。しかしその方法では、対象のアセンブリについてプロセスが掴んでしまうため、デプロイ済みのサービスについて、動的に動作を拡張 or 変更するということができません。ではどうするか。その場合、ローカルストレージは利用せず、BlobストレージからダウンロードしたアセンブリをAssemblyCatalogクラスを利用して直接パーツをExprotする方法を取るとうまくいきます。でも、これをいちいちプログラミングするのは非常に面倒くさいです。常識的に考えて部品化ですね。Blobストレージ内のアセンブリからMEFのパーツを検索できる専用のカタログクラスであるところの、BlobStorageCatalogとか作っちゃえばいいと思います。



以下、実装サンプルです。


BlobStorageCatalog.cs

using System;
using System.Collections.Generic;
using System.ComponentModel.Composition.Hosting;
using System.ComponentModel.Composition.Primitives;
using System.Diagnostics.Contracts;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using Microsoft.WindowsAzure;
using Microsoft.WindowsAzure.StorageClient;

namespace ClassLibrary2
{
    public class BlobStorageCatalog : ComposablePartCatalog, ICompositionElement
    {
        private readonly object _thisLock = new object();
        private readonly CloudStorageAccount _account = null;
        private readonly static Dictionary<string, byte[]> _dicAssembly = new Dictionary<string, byte[]>();

        public string ContainerName { get; private set; }
        public string BlobName { get; private set; }
        private AssemblyCatalog _innerCatalog = null;
        private int _isDisposed = 0;

        protected BlobStorageCatalog() {}

        public BlobStorageCatalog(CloudStorageAccount account, string containerName, string blobName) : this()
        {
            Contract.Requires(account != null);
            Contract.Requires(!String.IsNullOrWhiteSpace(containerName));
            Contract.Requires(!String.IsNullOrWhiteSpace(blobName));
            Contract.Requires(containerName == containerName.ToLower());

            this._account = account;
            this.ContainerName = containerName;
            this.BlobName = blobName;
        }

        private ComposablePartCatalog InnerCatalog
        {
            get
            {
                this.ThrowIfDisposed();
                lock (this._thisLock)
                {
                    if (_innerCatalog == null)
                    {
                        var catalog = new AssemblyCatalog(LoadAssembly(this._account, this.ContainerName, this.BlobName));
                        Thread.MemoryBarrier();
                        this._innerCatalog = catalog;
                    }
                }
                return _innerCatalog;
            }
        }

        public override IEnumerable<Tuple<ComposablePartDefinition, ExportDefinition>> GetExports(ImportDefinition definition)
        {
            return this.InnerCatalog.GetExports(definition);
        }

        public override IQueryable<ComposablePartDefinition> Parts
        {
            get { return this.InnerCatalog.Parts; }
        }

        private string GetDisplayName()
        {
            return string.Format(CultureInfo.CurrentCulture,
                                "{0} (BlobStorage: ContainerName=\"{1}\", BlobName=\"{2}\") (Assembly=\"{3}\")", 
                                GetType().Name,
                                this.ContainerName,
                                this.BlobName,
                                _innerCatalog.Assembly.FullName);
        }

        public override string ToString()
        {
            return GetDisplayName();
        }

        private void ThrowIfDisposed()
        {
            if (this._isDisposed == 1)
            {
                if (this == null)
                    throw new NullReferenceException(this.GetType().Name);
                throw new ObjectDisposedException(this.GetType().ToString());
            }
        }

        protected override void Dispose(bool disposing)
        {
            try
            {
                if (Interlocked.CompareExchange(ref this._isDisposed, 1, 0) == 0)
                {
                    if (disposing)
                    {
                        if (this._innerCatalog != null)
                        {
                            this._innerCatalog.Dispose();
                        }
                    }
                }
            }
            finally
            {
                base.Dispose(disposing);
            }
        }

        string ICompositionElement.DisplayName
        {
            get { return GetDisplayName(); }
        }

        ICompositionElement ICompositionElement.Origin
        {
            get { return null; }
        }

        private static Assembly LoadAssembly(CloudStorageAccount account, string containerName, string blobname)
        {
            var blobStorage = account.CreateCloudBlobClient();
            var container = blobStorage.GetContainerReference(containerName);

            var blob = container.GetBlobReference(blobname);
            var blobUri = container.Uri + "/" + blobname;

            using (var strm = new MemoryStream())
            {
                blob.DownloadToStream(strm);
                byte[] asseblyBytes = strm.ToArray();
                
                if (!_dicAssembly.ContainsKey(blobUri))
                {
                    _dicAssembly.Add(blobUri, asseblyBytes);
                    return Assembly.Load(asseblyBytes);
                }
                if (Enumerable.SequenceEqual(asseblyBytes, _dicAssembly[blobUri]))
                {
                    return Assembly.Load(_dicAssembly[blobUri]);
                }

                _dicAssembly[blobUri] = asseblyBytes;
                return Assembly.Load(asseblyBytes);
            }
        }
    }
}


だいたいこんな感じですかね。特に問題はないと思いますが、厳密な検証はしていません。実戦投入は計画的に。


拡張(または縮小)可能なWinedow Azure上で動くASP.NET MVC3 Webアプリケーションのサンプル


MEFで拡張可能な計算アプリケーション作成用のインターフェイスを定義

namespace ClassLibrary1
{
    public interface ICalculator
    {
        IEnumerable<char> Symbols { get; }
        string Calculate(int left, int right, char operation);
    }

    public interface IOperation
    {
        int Operate(int left, int right);
    }

    public interface IOperationData
    {
        Char Symbol { get; }
    }

}


足し算と引き算を定義

namespace Calculator
{

    [Export(typeof(IOperation))]
    [ExportMetadata("Symbol", '-')]
    class Subtract : IOperation
    {
        public int Operate(int left, int right)
        {
            return left - right;
        }
    }

    [Export(typeof(IOperation))]
    [ExportMetadata("Symbol", '+')]
    class Add : IOperation
    {
        public int Operate(int left, int right)
        {
            return left + right;
        }
    }
}


足し算と引き算が利用可能





掛け算を新たに定義してビルドし、Calculator.dllを作る

    [Export(typeof(IOperation))]
    [ExportMetadata("Symbol", '*')]
    public class Multiply : IOperation
    {
        public int Operate(int left, int right)
        {
            return left * right;
        }
    }


Calculator.dllをアップロード


ページを再度読み込みなおすと、拡張した掛け算部分が追加されて利用可能に。


という具合に、MEFを利用することで、再度デプロイしなおさなくても、拡張(または縮小)可能なWindows Azureアプリケーションを設計することができます。



このように、MEF は .NET Framework の拡張(または縮小)可能なアプリケーションを構築する際にとても便利なソリューションですが、このとき最も難しい部分は、拡張できるようにアプリケーションを設計することです。これは拡張可能アプリケーション設計そのもの難しさであって、MEFテクノロジそのものが難しいわけではありません。上記のサンプルでは拡張性の乏しい単純な構造の設計となっていますが、より汎用的な拡張が必要なアプリケーションを設計する場合、一気に複雑になります。MEFで提供されているAPIを十分理解した上で、適切な拡張ポイントを見つけ出して、それを将来を見据えたかたちでどれだけ汎用的に拡張できるよう設計できるかがポイントとなってきます。このような設計では、オブジェクト指向の考え方が重要になるでしょう。



ここでは省略しますが、もちろんF#でも同様のアプリケーションを書くことが可能です。
ただF#でMEFを利用する場合はちょっと癖があるんですけどね。でもそれはまた別のお話。


拡張可能なWinedow Azure上で動くASP.NET MVC3 WebアプリケーションのサンプルプログラムをSkyDriveにアップしておきます。
SkyDrive - Azure+MEF.zip

独習ASP.NET第3版を頂きました。

3月26日に参加した第56回CLR/H勉強会にて、WINGSプロジェクトさんより「独習ASP.NET第3版(翔泳社)」を頂きました。
WINGSプロジェクトさん、翔泳社さん、id:naoki0311さん*1CLR/Hスタッフの皆さん、どうもありがとうございます。


独習ASP.NET 第3版

独習ASP.NET 第3版




わたしはこれで再入門しました

その名の通り、ASP.NETの基礎をひととおり独習できる内容となっています。
ASP.NETの特徴や仕組みについて丁寧に解説してあり、理解度チェックとして確認用の練習問題も付いています*2
世間の流れはASP.NET MVCとなりつつありますが、かと言って昔ながらのASP.NET開発の知識が不要というわけではないので、
技術的な背景をしっかり押さえつつ網羅的に学習できる書籍の存在意義は大きいと思います。
こちらの書籍はサンプルコードも豊富(配布ページよりダウンロードすることができる。VB.NETC#有り)なので、
VB.NETあるいはC#の経験さえあれば、スムーズかつ効果的にASP.NETについて学習することができます。
ASP.NETをはじめてみたい方はこの書籍で入門してみてはいかがでしょうか。わたしはこれで再入門しました。


個人的によかったところ

・第2章 ASP.NETの基礎
 基本的なASP.NETの仕組みを学習できた。いままで「なんとなく」だった部分が明確になった。


・第7章 状態管理
 HTTPはステートレスなプロトコルなので、ASP.NETでは複数ページ/リクエスト間での情報維持の方法を熟知している必要がある。
 局面に応じて、どんな状態管理機能を利用するのが良いかの判断基準を得られた。


・第10章 部品化技術
 「部品化」は、ASP.NETに限らずアプリケーション開発では基本的なことで且つ大事なところ。
 HTTPハンドラーとかPage派生クラスとか。ASP.NET3.5との互換性についても触れられている。参考にしたい。


・第11章 Ajax開発
 JavaScriptを用いてAjaxなWebアプリは書いたことがあったが、ASP.NET AJAXは使ったことがなかったので勉強になった。


・付録A IISへの配置
 実際に開発したWebアプリをIIS(7.5)へ配置する方法について解説がある。初心者にやさしい。


おまけ:F#でもASP.NET開発

「独習ASP.NET第3版」 P14より引用

C++、F#はASP.NETの開発には利用できません。一般的に、ASP.NETの開発にはVisual Basic、またはC#を使用します。


一般的にはC#あるいはVB.NETを利用します。はいその通りです。
その通りなのですが、「F#でASP.NET開発」はできないこともないです(C++は知らない)。


id:fitsさん
F# で ASP.NET - なんとなくな Developer のメモ
http://d.hatena.ne.jp/fits/20100906/1283766723


id:bleis-tiftさん
F# でブログアプリを作る (Very Easy 編) - 予定は未定Blog版
http://d.hatena.ne.jp/bleis-tift/20101211/1292074867



F#でのASP.NET開発は、「できなくもないが、あえてする理由もない」というのが現状です。
あえて茨の道を行く必要はまったくありませんが、F#は“できる子”です。


HTML5基礎」を頂きました

先日参加した第57回CLR/H勉強会にて、「HTML5基礎( 毎日コミュニケーションズ)」を頂きました。
WINGSプロジェクトさん、どうもありがとうございます。こちらも読み次第書評を書かせていただきます。


HTML5基礎

HTML5基礎

*1:北兄者からはマクロスFのランカフィギュアを頂きました

*2:600ページ以上あって読み応え十分です