Accessing IE’s DOM structure from C#

IE のレンダリング エンジンと言えば Trident と呼ばれる mshtml.dll です。なんと単一の DLL でサイズが 20MB もあります。この Trident、COM 経由でデータにアクセスするための API を実装しているらしい、ということで試してみました。検索すると、わりといろいろな人が試していてメジャーな方法らしい。ウィキにも書いてありました。

Trident (layout engine) – Wikipedia, the free encyclopedia
http://en.wikipedia.org/wiki/Trident_(layout_engine)

基本となる元ネタはこの KB。今回は Win32 ではなく C# で書き直すことにします。

How to get IHTMLDocument2 from a HWND
http://support.microsoft.com/kb/249232/en

上記 KB のプログラムをざっと見ると、ウィンドウ ハンドルに対して WM_HTML_GETOBJECT というウィンドウ メッセージを送ると、COM インターフェースである IHTMLDocument2 が返ってくるらしい。ウィンドウ ハンドルは普通の方法で列挙して、クラス名が "Internet Explorer_Server" であるものを探せばいいようだ。簡単でいい感じ。

IHTMLDocument2 インターフェースのリファレンスは MSDN も載っています。最新版だと IHTMLDocument8 まであるらしい。

Scripting Object Interfaces (MSHTML) (Windows)
http://msdn.microsoft.com/en-us/library/hh801967(v=vs.85).aspx

今回の開発環境はこれで。プログラムは Windows 8 や 8.1 でも動くはずです。さすがに試していませんが、.NET Framework 4.0 を入れれば XP や Vista でも行けるはず・・。

  • OS: Windows 7 SP1 x64
  • IE: Internet Explorer 11 + KB3003057 (Nov. 2014 Update)
  • IDE: Visual Studio 2012
  • CLI: .NET Framework 4.0

適当な C# プロジェクトを作って、リファレンスを追加します。が、ここで一つ罠が。Visual Studio の Reference Manager を見ると、Microsoft HTML Object Library という Type Library が既定で存在しています。これは既に mshtml のライブラリが GAC に登録されているからであり、おそらく .NET か Windows のインストール時に追加されたものと考えられます。

image

手元の環境だと、"C:\Windows\assembly\GAC\Microsoft.mshtml\7.0.3300.0__b03f5f7f11d50a3a" に存在する Microsoft.mshtml.dll がそのライブラリでした。

image

このライブラリを使っても問題はないのですが、IE のアップデート時や累積パッチの適用時に GAC は更新されないらしく、古いのです。開発環境の Windows 7 において Object Explorer から確認すると分かりますが、例えば IHTMLDocument5 インターフェースまでしか存在せず、 IHTMLDocument6 以降がありません。

image

便利なことに、IE 更新時には適用される mshtml.dll  に対応する Type Library ファイル mshtml.tlb も一緒に提供されるので、このファイルからアセンブリ DLL を作れば最新のライブラリを使うことができます。TLB から DLL を作る方法は、以前に紹介した tlbimp というツールが使えます。"Developer Command Prompt for VS2012" を開いてコマンドを実行するだけです。

出力例はこんな感じ。ファイル名は mshtml.ie11.dll にしていますが、何でもよいです。system32 にある mshtml.tlb から DLL を作りましたが、生成された DLL  は 32bit からでも 64bit からでも使うことができます。

E:\VSDev\Projects\iehack> tlbimp /reference:C:\windows\system32\mshtml.tlb /out:mshtml.ie11.dll
Microsoft (R) .NET Framework Type Library to Assembly Converter 4.0.30319.17929
Copyright (C) Microsoft Corporation.  All rights reserved.

TlbImp : error TI2005 : No input file has been specified.

E:\VSDev\Projects\iehack>tlbimp C:\windows\system32\mshtml.tlb /out:mshtml.ie11.dll
Microsoft (R) .NET Framework Type Library to Assembly Converter 4.0.30319.17929
Copyright (C) Microsoft Corporation.  All rights reserved.

TlbImp : warning TI3001 : Primary interop assembly ‘Microsoft.mshtml, Version=7.0.3300.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a’ is already registered for type library ‘C:\windows\system32\mshtml.tlb’.
TlbImp : warning TI3015 : At least one of the arguments for ‘mshtml.ie11.IActiveIMMApp.GetDefaultIMEWnd’ cannot be marshaled by the runtime marshaler.  Such arguments will therefore be passed as a pointer and may require unsafe code to manipulate.
TlbImp : warning TI3016 : The type library importer could not convert the signature for the member ‘mshtml.ie11._userBITMAP.pBuffer’.
TlbImp : warning TI3016 : The type library importer could not convert the signature for the member ‘mshtml.ie11._FLAGGED_BYTE_BLOB.abData’.
TlbImp : warning TI3015 : At least one of the arguments for ‘mshtml.ie11.IEventTarget2.GetRegisteredEventTypes’ cannot be marshaled by the runtime marshaler.  Such arguments will therefore be passed as a pointer and may require unsafe code to manipulate.
TlbImp : warning TI3015 : At least one of the arguments for ‘mshtml.ie11.IEventTarget2.GetListenersForType’ cannot be marshaled by the runtime marshaler.  Such arguments will therefore be passed as a pointer and may require unsafe code to manipulate.
TlbImp : warning TI3016 : The type library importer could not convert the signature for the member ‘mshtml.ie11.tagSAFEARRAY.rgsabound’.
TlbImp : warning TI3015 : At least one of the arguments for ‘mshtml.ie11.ICanvasPixelArrayData.GetBufferPointer’ cannot be marshaled by the runtime marshaler.  Such arguments will therefore be passed as a pointer and may require unsafe code to manipulate.
TlbImp : Type library imported to E:\VSDev\Projects\iehack\mshtml.ie11.dll

 

警告がたくさん出ましたが、よく分からないので (汗) 無視します。

生成された mshtml.ie11.dll をプロジェクトに追加して Object Explorer で見てみると、今度は IHTMLDocument8 がありました。それにしてもクラスやらインターフェースが多いです。アセンブリだけで 15MB 近いサイズになります。

image

あとは素直に C# を書くだけです。

//
// Program.cs
//
// http://support.microsoft.com/kb/249232/en
//

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;

namespace iehack {
    class Program {
        public delegate int WNDENUMPROC(IntPtr hwnd, IntPtr lParam);

        [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
        static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);

        [DllImport("user32.dll", SetLastError = true)]
        static extern int EnumChildWindows(IntPtr hwndParent, WNDENUMPROC lpEnumFunc, IntPtr lParam);

        [DllImport("user32.dll", SetLastError = true)]
        static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);

        [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
        static extern uint RegisterWindowMessage(string lpString);

        [Flags]
        public enum SendMessageTimeoutFlags : uint {
            SMTO_NORMAL = 0x0,
            SMTO_BLOCK = 0x1,
            SMTO_ABORTIFHUNG = 0x2,
            SMTO_NOTIMEOUTIFNOTHUNG = 0x8,
            SMTO_ERRORONEXIT = 0x20
        }

        [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
        public static extern IntPtr SendMessageTimeout(
            IntPtr hWnd,
            uint Msg,
            IntPtr wParam,
            IntPtr lParam,
            SendMessageTimeoutFlags fuFlags,
            uint uTimeout,
            out IntPtr lpdwResult);

        [DllImport("oleacc.dll", PreserveSig = false)]
        [return: MarshalAs(UnmanagedType.Interface)]
        static extern object ObjectFromLresult(IntPtr lResult,
             [MarshalAs(UnmanagedType.LPStruct)] Guid refiid, IntPtr wParam);

        private static int EnumWindowsProc(IntPtr hwnd, IntPtr lParam) {
            // http://msdn.microsoft.com/en-us/library/windows/desktop/ms633576(v=vs.85).aspx
            // The maximum length for lpszClassName is 256.

            StringBuilder ClassName = new StringBuilder(256);
            int Ret = GetClassName(hwnd, ClassName, ClassName.Capacity);
            if (Ret != 0) {
                if (string.Compare(ClassName.ToString(), "Internet Explorer_Server", true, CultureInfo.InvariantCulture) == 0) {
                    var TargetList = GCHandle.FromIntPtr(lParam).Target as List<IntPtr>;
                    if (TargetList != null) {
                        TargetList.Add(hwnd);
                    }
                }
            }

            return 1;
        }

        private static int EnumTopWindowsProc(IntPtr hwnd, IntPtr lParam) {
            EnumChildWindows(hwnd, EnumWindowsProc, lParam);
            return 1;
        }

        static uint WM_HTML_GETOBJECT = 0;

        public static object GetDom(IntPtr Window, Guid InterfaceType) {
            const int Timeout = 1000;

            if (WM_HTML_GETOBJECT == 0) {
                WM_HTML_GETOBJECT = RegisterWindowMessage("WM_HTML_GETOBJECT");
            }

            IntPtr Result = IntPtr.Zero;
            SendMessageTimeout(Window, WM_HTML_GETOBJECT,
                IntPtr.Zero, IntPtr.Zero,
                SendMessageTimeoutFlags.SMTO_ABORTIFHUNG,
                Timeout,
                out Result);

            return ObjectFromLresult(Result, InterfaceType, IntPtr.Zero);
        }

        List<IntPtr> WindowHandles;

        public void Run() {
            WindowHandles = new List<IntPtr>();
            var ListHandle = GCHandle.Alloc(WindowHandles);
            EnumChildWindows(IntPtr.Zero, EnumTopWindowsProc, GCHandle.ToIntPtr(ListHandle));

            int i = 0;
            foreach (var ie in WindowHandles) {
                uint pid, tid;
                tid = GetWindowThreadProcessId(ie, out pid);

                Console.WriteLine("[{0}] hWnd = 0x{1:x}, pid = 0x{2:x4}, tid = 0x{3:x4}", i++, ie.ToInt64(), pid, tid);

                var dom2 = GetDom(ie, typeof(mshtml.ie11.IHTMLDocument2).GUID) as mshtml.ie11.IHTMLDocument2;
                if (dom2 != null) {
                    Console.WriteLine("url = " + dom2.url);

                    var dom6 = GetDom(ie, typeof(mshtml.ie11.IHTMLDocument6).GUID) as mshtml.ie11.IHTMLDocument6;
                    if (dom6 != null) {
                        Console.WriteLine("docmode = " + dom6.documentMode);
                    }
                }
            }
        }

        static void Main(string[] args) {
            var p = new Program();
            p.Run();
        }
    }
}

やろうと思えばもっと工夫できそうな気もしますが、データが取得できることが確認できればいいので最低限で。このサンプルを実行すると、ウィンドウ クラス名が "Internet Explorer_Server" であるウィンドウ全てに対して、そのコントロールが開いている URL と DocMode を表示します。

DocMode とは、IE8 から導入された機能です。主に HTML レイアウトの後方互換性を維持するために、古いバージョンの IE のレンダリング モードをある程度まで再現することができるようになっています。例えば IE8 向けに作られたサイトで、IE11 で開くとレイアウトが崩れてしまうような場合には、HTML 内の meta 要素で DocMode を明示的に 8 に指定して回避できることがあります。

Specifying legacy document modes (Internet Explorer)
http://msdn.microsoft.com/en-us/library/jj676915(v=vs.85).aspx

IE8/IE9の「ブラウザーモード」と「ドキュメントモード」のまとめ: 小粋空間
http://www.koikikukan.com/archives/2011/02/07-005555.php

手元の環境で実行した結果がこんな感じです。IE の各タブだけでなく、以下の例では Windows Live Writer で使われている Web コントロールの情報も表示されています。

E:\VSDev\Projects\iehack\bin\x64\Release>iehack.exe
[0] hWnd = 0x40914, pid = 0x1d94, tid = 0x0d6c
url =
https://www.google.com/?gws_rd=ssl
docmode = 11
[1] hWnd = 0x1505aa, pid = 0x1c58, tid = 0x2010
url =
https://www.facebook.com/
docmode = 10
[2] hWnd = 0x804e6, pid = 0x1c58, tid = 0x1094
url = about:blank
docmode = 11
[3] hWnd = 0x204d2, pid = 0x118c, tid = 0x0948
url = file://C:\Users\John\AppData\Local\Temp\WindowsLiveWriter1286139640\B8DD92B2061F\index.htm
docmode = 7

このサンプルでは試していませんが、KB249232 の例で背景色を変更しているように、値を取得するだけではなく、変更することもできます。しかし DocumentMode プロパティを始めとして、読み取り専用のプロパティもあります。

[.NET] [Asm] Managed Code Debugging with SOS extension

久々の更新です。ようやく .NET デバッグが実戦でも通用するレベルになってきたので、初歩を紹介します。もちろん .NET デバッグといっても Visual Studio を使うのではなく、ntsd やら windbg といった Windows デバッガーを使います。Windows デバッガーの利点としては、稼働環境へファイルをコピーするだけでいい、リモート デバッグが可能、Visual Studio より細かいことができる、などが挙げられます。コンピューターの動作を理解するのにも役立ちますし、慣れてくると Visual Studio より速くデバッグできるようになります。それと、デバッガーの黒い画面を開いて仕事をしていると、周りから見ても 「仕事をしている感」 が醸し出されて便利です。(なんだそれは

ただし、まだ .NET Framework の動きはほとんど理解しきれていないので、細かい説明は端折ります。そのうち覚えます。

.NET のデバッグを行うためには、SOS と呼ばれるデバッガー エクステンション DLL が必要になります。ただし、これは .NET Framework に含まれているので、別途ダウンロードする必要はありません。

%windir%\Microsoft.NET\Framework\<.NET バージョン>\SOS.dll
%windir%\Microsoft.NET\Framework64\<.NET バージョン>\SOS.dll

SOS とは、"Son Of Strike" の略です。じゃあ Strike って何ぞ、という話ですが、これは以下のブログにそのエピソードが詳細に書かれています。もともと CLR 開発チームが "Lightning" という名前で作っていたものを、デバッガー エクステンションにしたときに "Strike" という名前に変えて、そこから一部のコードを取り除いたものだから "Son Of Strike" だそうです。

この記事には、そんな小話だけでなく SOS に関する非常に詳細な説明が書かれています。

SOS Debugging of the CLR, Part 1 – Jason Zander’s blog – Site Home – MSDN Blogs
http://blogs.msdn.com/b/jasonz/archive/2003/10/21/53581.aspx

MSDN だとこんなページもあります。

SOS.dll (SOS Debugging Extension)
http://msdn.microsoft.com/en-us/library/bb190764.aspx

このブログでは、細かいことは抜きにして早速デバッグしてみましょう。シンボルの設定は必ず行って下さい。順番が逆ですが、デバッグ環境の作り方をそのうち記事にするかもしれません。

Use the Microsoft Symbol Server to obtain debug symbol files
http://support.microsoft.com/kb/311503/en

今回の検証環境はこんな感じです。
現時点で最新の環境を使っていますが、Windows 7 でも XP でも同じことができるはずです。

  • OS: Windows Server 2012
  • CLR: 4.0.30319.18010 (.NET Framework 4.5)
  • IDE: Visual Studio 2012
  • Debugger: 6.2.9200.16384 (Windows Kit 8.0)

まずは、適当なプログラムを書きます。C# です、お決まりですね。もちろん F# でもいいです。

using System;
using System.IO;

namespace cssandbox {
    class Program {
        static void Main(string[] args) {
            var Prog = new Program();
            Prog.Print(Console.Out);
        }

        string mMessage1;
        string mMessage2;

        Program() {
            mMessage1 = "Hello!";
            Sub();
        }

        Program(string s) {
            mMessage1 = s;
            Sub();
        }

        void Sub() {
            mMessage2 = DateTime.Now.ToString();
        }

        void Print(TextWriter Writer) {
            Writer.WriteLine(mMessage1);
            Writer.WriteLine(mMessage2);
        }

    }
}

これを Debug 構成でビルドして、デバッガーから起動します。私は ntsd 派なのでこんな感じです。ntdll!LdrpDoDebuggerBreak で止まるはずで、これはネイティブ コードのデバッグと同じです。というか、まだこのタイミングでは CLR がロードされていないので、マネージド コードは存在しません。

image

Microsoft (R) Windows Debugger Version 6.2.9200.16384 X86
Copyright (c) Microsoft Corporation. All rights reserved.

CommandLine: cssandbox.exe
Symbol search path is: srv*d:\websymbols*
http://msdl.microsoft.com/download/symbols
Executable search path is:
ModLoad: 005e0000 005e8000   cssandbox.exe
ModLoad: 77820000 77977000   ntdll.dll
ModLoad: 6f2f0000 6f33a000   C:\Windows\SysWOW64\MSCOREE.DLL
ModLoad: 77630000 77760000   C:\Windows\SysWOW64\KERNEL32.dll
ModLoad: 76f20000 76fc6000   C:\Windows\SysWOW64\KERNELBASE.dll
(9a4.b10): Break instruction exception – code 80000003 (first chance)
eax=00000000 ebx=00000003 ecx=be050000 edx=00000000 esi=00000000 edi=00000000
eip=778c054d esp=0076f864 ebp=0076f890 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
ntdll!LdrpDoDebuggerBreak+0x2b:
778c054d cc              int     3
0:000>

 

ここで重要なのが、CLR が動作するプラットフォームと、利用するデバッガーのプラットフォームを一致させておくことです。OS が 64 bit だったとしても、デバッグするアプリが 32bit で動作する場合は、32bit のデバッガーを使わないといけません。SOS.dll はデバッグ対象の CLR とバージョンとプラットフォームが一致していないと動かないのですが、当然 64bit のデバッガー プロセスから 32bit の SOS.dll をロードできないため、このような制限が生まれます。デバッガーを起動する前にデバッグ対象の動作プラットフォームを調べておきましょう。

SOS のロード

SOS のロードで一番単純な方法は、以下のように SOS.dll のパスを直接指定する方法です。

0:000> .load C:\Windows\Microsoft.NET\Framework\v4.0.30319\SOS.dll

32bit デバッガーから 64bit の SOS を読もうとすると、以下のように怒られます。

0:000> .load C:\Windows\Microsoft.NET\Framework64\v4.0.30319\SOS.dll
The call to LoadLibrary(C:\Windows\Microsoft.NET\Framework64\v4.0.30319\SOS.dll) failed, Win32 error 0n193
    "%1 is not a valid Win32 application."
Please check your debugger configuration and/or network access.

以上のようにプラットフォームの違いは判別されますが、.NET Framework バージョンの違いは判別されないので、以下のように .NET 2.0 の SOS はロードできてしまいます。これだと後々の SOS のコマンドが正しく動きません。

0:000> .load C:\Windows\Microsoft.NET\Framework\v2.0.50727\SOS.dll

いちいち .NET Framework のバージョンを調べるのが面倒くさい、という人のために .loadby というコマンドが存在します。こんな感じに使います。

0:000> sxe ld:clr
0:000> g
ModLoad: 6d500000 6db92000   C:\Windows\Microsoft.NET\Framework\v4.0.30319\clr.dll
eax=00000000 ebx=00800000 ecx=00000000 edx=00000000 esi=00000000 edi=7e60d000
eip=77860fe8 esp=0076f4e4 ebp=0076f53c iopl=0         nv up ei pl nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000206
ntdll!NtMapViewOfSection+0xc:
77860fe8 c22800          ret     28h
0:000> .loadby sos clr

.loadby を使うと、指定したモジュールと同じところにある拡張 DLL をロードさせることができます。したがって、CLR.dll がロードされた後のタイミングで、clr.dll と同じところの SOS をロードすることで、適切なバージョンの SOS をロードすることができます。

デバッグするプログラムによっては clr で .loadby できず、代わりに mscoreei や mscorwks を使うことがあります。実現したいことは、CLR と同じ SOS をロードするだけなので、困ったら CLR のバージョンを調べて絶対パス指定で .load すれば OK です。

ブレーク ポイント

デバッグは、ブレークポイントを設定するところから始まります。そんなわけで、前述のサンプル プログラムの Main 関数で止めてみましょう。ネイティブ コードと違って、x コマンドは使えません。

単純な方法は、SOS の !name2ee コマンドを使う方法です。一気にやるとこんな感じです。

0:000> sxe ld:clrjit
0:000> g
(9a4.b10): Unknown exception – code 04242420 (first chance)
ModLoad: 6ee60000 6eece000   C:\Windows\Microsoft.NET\Framework\v4.0.30319\clrjit.dll
eax=00000000 ebx=00800000 ecx=00000000 edx=00000000 esi=00000000 edi=7e60d000
eip=77860fe8 esp=0076e5dc ebp=0076e634 iopl=0         nv up ei pl nz na po nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000202
ntdll!NtMapViewOfSection+0xc:
77860fe8 c22800          ret     28h
0:000> !name2ee cssandbox!cssandbox.Program.Main
Module:      008e2e94
Assembly:    cssandbox.exe
Token:       06000001
MethodDesc:  008e37ac
Name:        cssandbox.Program.Main(System.String[])
Not JITTED yet. Use !bpmd -md 008e37ac to break on run.
0:000> !bpmd -md 008e37ac
MethodDesc = 008e37ac
Adding pending breakpoints…
0:000> g
(9a4.b10): CLR notification exception – code e0444143 (first chance)
JITTED cssandbox!cssandbox.Program.Main(System.String[])
Setting breakpoint: bp 00AA0077 [cssandbox.Program.Main(System.String[])]
Breakpoint 0 hit
eax=00000000 ebx=0076f31c ecx=024a22cc edx=00000000 esi=00000000 edi=0076f290
eip=00aa0077 esp=0076f264 ebp=0076f278 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
00aa0077 90              nop
0:000>

ブレークポイントを設定するのに使いたいコマンドは !bpmd です。bp コマンドは、メモリにロードされているコードのアドレスを直接指定しますが、上記の例では、!name2ee コマンドを使って取得した Method Descriptor という値を使ってブレーク ポイントを設定しています。

コマンドの出力にもありますが、.NET の大きな特徴として、実行時 (JIT) コンパイルが行われることが挙げられます。当然、まだコンパイルされていないメソッドに対して bp コマンドは使えません。そこで、Method Descriptor を間接的に使ってブレークポイントを設定するわけです。Method Descriptor や EEClass といった CLR の内部構造については、以下の記事などを参考にして下さい。まだよく知らないのですわ・・・スミマセン。

JIT and Run: .NET Framework の内部: CLR がランタイム オブジェクトを作成するしくみ — MSDN Magazine, 2005 年 5 月
 http://msdn.microsoft.com/ja-jp/magazine/ee216336.aspx

アプリケーションを起動して clr.dll がロード直後のタイミングでは、CLR の内部構造がほとんど何もできていないので、!name2ee すら実行することができず、以下のようなエラーが出ます。

0:000> !name2ee cssandbox!cssandbox.Program.Main
Failed to obtain AppDomain data.
Failed to request module list.

そこで今回は、clrjit.dll がロードされるタイミングを sxe で止めて、そのときに !name2ee を実行しました。

!bpmd した後の出力結果を見ると、JIT されたタイミングで bp コマンドが実行されているのが分かります(紫字部分)。JIT されてしまえば、ネイティブ コードと同じように扱うことができます。ただし、コンパイルされたコードはネイティブのものとは微妙に印象が異なるのが面白いところです。

ブレークポイントで止まっている状態で、以下のコマンドを実行してみます。

0:000> bl
0 e 00aa0077     0001 (0001)  0:****
0:000> !name2ee cssandbox!cssandbox.Program.Main
Module:      008e2e94
Assembly:    cssandbox.exe
Token:       06000001
MethodDesc:  008e37ac
Name:        cssandbox.Program.Main(System.String[])
JITTED Code Address: 00aa0050
0:000> r
eax=00000000 ebx=0076f31c ecx=024a22cc edx=00000000 esi=00000000 edi=0076f290
eip=00aa0077 esp=0076f264 ebp=0076f278 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
00aa0077 90              nop
0:000> k
ChildEBP RetAddr
WARNING: Frame IP not in any known module. Following frames may be wrong.
0076f278 6d502652 0xaa0077
0076f284 6d51264f clr!CallDescrWorkerInternal+0x34
0076f2d8 6d512e95 clr!CallDescrWorkerWithHandler+0x6b
0076f350 6d5c74ec clr!MethodDescCallSite::CallTargetWorker+0x152
0076f47c 6d5c7610 clr!RunMain+0x1aa
0076f6f0 6d651dc4 clr!Assembly::ExecuteMainMethod+0x124
0076fbf4 6d651e67 clr!SystemDomain::ExecuteMainMethod+0x614
0076fc50 6d651f7a clr!ExecuteEXE+0x4c
0076fc90 6d65416a clr!_CorExeMainInternal+0xdc
0076fccc 6f27f5a3 clr!_CorExeMain+0x4d
0076fd04 6f2f7efd mscoreei!_CorExeMain+0x10a
0076fd1c 6f2f4de3 MSCOREE!ShellShim__CorExeMain+0x7d
0076fd24 77658543 MSCOREE!_CorExeMain_Exported+0x8
0076fd30 7787ac69 KERNEL32!BaseThreadInitThunk+0xe
0076fd74 7787ac3c ntdll!__RtlUserThreadStart+0x72
0076fd8c 00000000 ntdll!_RtlUserThreadStart+0x1b

まず、!bpmd によって自動的に bp コマンドが実行されたので、bl で確認できます。!name2ee コマンドを実行すると、JIT されたコードが 00aa0050 にロードされていることが分かります。bp された場所と若干ずれていますね。本当は 00aa0077 ではなく 00aa0050 で止まってほしいところです。止めたいところを !name2ee で調べて、JIT されていれば、bp コマンドを手動で打つこともできます。

また、r や k といったいつものコマンドも使うことができます。ただし、0xaa0077 のアドレスなどはシンボル名で解決されていません。スタックを見ると、clr.dll から cssandbox.exe が呼ばれていることが分かります。ステップ実行もできます。

変数を見る

目的の所で止めたら、変数の値を見たくなります。頑張れば dd コマンドを使えないこともないですが、.NET オブジェクトについては、SOS のエクステンションに頼ることになります。

cssandbox.exe に適当なパラメーターを指定して起動し、args の値を見る場合の例を示します。

Microsoft (R) Windows Debugger Version 6.2.9200.16384 X86
Copyright (c) Microsoft Corporation. All rights reserved.

CommandLine: cssandbox.exe ABCD 漢字
Symbol search path is: srv*d:\websymbols*
http://msdl.microsoft.com/download/symbols
Executable search path is:
ModLoad: 00af0000 00af8000   cssandbox.exe
ModLoad: 77820000 77977000   ntdll.dll
ModLoad: 6f2f0000 6f33a000   C:\Windows\SysWOW64\MSCOREE.DLL
ModLoad: 77630000 77760000   C:\Windows\SysWOW64\KERNEL32.dll
ModLoad: 76f20000 76fc6000   C:\Windows\SysWOW64\KERNELBASE.dll
(11a4.cf0): Break instruction exception – code 80000003 (first chance)
eax=00000000 ebx=00000003 ecx=18f00000 edx=00000000 esi=00000000 edi=00000000
eip=778c054d esp=00c7f76c ebp=00c7f798 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
ntdll!LdrpDoDebuggerBreak+0x2b:
778c054d cc              int     3
0:000> sxe ld:clrjit
0:000> g
(11a4.cf0): Unknown exception – code 04242420 (first chance)
ModLoad: 6ee60000 6eece000   C:\Windows\Microsoft.NET\Framework\v4.0.30319\clrjit.dll
eax=00000000 ebx=00800000 ecx=00000000 edx=00000000 esi=00000000 edi=7ef7d000
eip=77860fe8 esp=00c7e4ec ebp=00c7e544 iopl=0         nv up ei pl nz na po nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000202
ntdll!NtMapViewOfSection+0xc:
77860fe8 c22800          ret     28h
0:000> .loadby sos clr
0:000> !bpmd cssandbox.exe cssandbox.Program.Main
Found 1 methods in module 00d72e94…
MethodDesc = 00d737ac
Adding pending breakpoints…
0:000> g
(11a4.cf0): CLR notification exception – code e0444143 (first chance)
JITTED cssandbox!cssandbox.Program.Main(System.String[])
Setting breakpoint: bp 01220077 [cssandbox.Program.Main(System.String[])]
Breakpoint 0 hit
eax=00000000 ebx=00c7f22c ecx=02d122cc edx=00000000 esi=00000000 edi=00c7f1a0
eip=01220077 esp=00c7f174 ebp=00c7f188 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
01220077 90              nop
0:000> !dso
OS Thread Id: 0xcf0 (0)
ESP/REG  Object   Name
ecx      02d122cc System.Object[]    (System.String[])
00C7F184 02d122cc System.Object[]    (System.String[])
00C7F200 02d122cc System.Object[]    (System.String[])
00C7F35C 02d122cc System.Object[]    (System.String[])
00C7F394 02d122cc System.Object[]    (System.String[])
0:000> !da 02d122cc
Name:        System.String[]
MethodTable: 6c8eae88
EEClass:     6c5abb70
Size:        24(0x18) bytes
Array:       Rank 1, Number of elements 2, Type CLASS
Element Methodtable: 6c93afb0
[0] 02d122e4
[1] 02d122fc
0:000> !do 02d122e4
Name:        System.String
MethodTable: 6c93afb0
EEClass:     6c54486c
Size:        22(0x16) bytes
File:        C:\Windows\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorl
ib.dll
String:      ABCD
Fields:
      MT    Field   Offset                 Type VT     Attr    Value Name
6c93c770  40000aa        4         System.Int32  1 instance        4 m_stringLength
6c93b9a8  40000ab        8          System.Char  1 instance       41 m_firstChar
6c93afb0  40000ac        c        System.String  0   shared   static Empty
    >> Domain:Value  010234f0:NotInit  <<
0:000> !do -nofields 02d122fc
Name:        System.String
MethodTable: 6c93afb0
EEClass:     6c54486c
Size:        18(0x12) bytes
File:        C:\Windows\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorl
ib.dll
String:      漢字
0:000>

Main 関数で止めた後、!dso と !do コマンドを呼び出しています。!dso は、スタックに積まれているオブジェクトの一覧を表示するもので、!do はオブジェクトを表示するコマンドです。.オブジェクトが型情報を持っているので、dt と違って型を明示する必要がなくて便利です。dynamic 型でやるとどうなるんですかね。配列の場合は !do ではなく !da を使います。

!dso の結果を見ると args の値が ecx レジスターに入っています。実際、.NET のメソッドは fastcall で呼ばれるようです。

アセンブラを見る

!bpmd を使えばメソッドの先頭で止めることができますが、メソッドの途中で止める場合には、JIT されたコードのアセンブラを見ないといけません。ネイティブと同じように u や uf コマンドを使うことができます。例えば Main メソッドのアセンブラを uf で見るとこんな感じです。

0:000> !name2ee cssandbox cssandbox.Program.Main
Module:      00d72e94
Assembly:    cssandbox.exe
Token:       06000001
MethodDesc:  00d737ac
Name:        cssandbox.Program.Main(System.String[])
JITTED Code Address: 01220050
0:000> uf 01220050
01220050 55              push    ebp
01220051 8bec            mov     ebp,esp
01220053 83ec14          sub     esp,14h
01220056 33c0            xor     eax,eax
01220058 8945f4          mov     dword ptr [ebp-0Ch],eax
0122005b 8945f0          mov     dword ptr [ebp-10h],eax
0122005e 8945ec          mov     dword ptr [ebp-14h],eax
01220061 894dfc          mov     dword ptr [ebp-4],ecx
01220064 833d6031d70000  cmp     dword ptr ds:[0D73160h],0
0122006b 7405            je      01220072

0122006d e84270576c      call    clr!JIT_DbgIsJustMyCode (6d7970b4)

01220072 33d2            xor     edx,edx
01220074 8955f8          mov     dword ptr [ebp-8],edx
01220077 90              nop
01220078 b9f037d700      mov     ecx,0D737F0h
0122007d e87e20b4ff      call    00d62100
01220082 8945f4          mov     dword ptr [ebp-0Ch],eax
01220085 8b4df4          mov     ecx,dword ptr [ebp-0Ch]
01220088 ff151038d700    call    dword ptr ds:[0D73810h]
0122008e 8b45f4          mov     eax,dword ptr [ebp-0Ch]
01220091 8945f8          mov     dword ptr [ebp-8],eax
01220094 8b45f8          mov     eax,dword ptr [ebp-8]
01220097 8945f0          mov     dword ptr [ebp-10h],eax
0122009a e875d3686b      call    mscorlib_ni+0x36d414 (6c8ad414)
0122009f 8945ec          mov     dword ptr [ebp-14h],eax
012200a2 8b4df0          mov     ecx,dword ptr [ebp-10h]
012200a5 8b55ec          mov     edx,dword ptr [ebp-14h]
012200a8 3909            cmp     dword ptr [ecx],ecx
012200aa ff15e037d700    call    dword ptr ds:[0D737E0h]
012200b0 90              nop
012200b1 90              nop
012200b2 8be5            mov     esp,ebp
012200b4 5d              pop     ebp
012200b5 c3              ret
0:000>

いやー、硬派ですね。というのも、ほとんどの call 命令のオペランドが生アドレスだからでしょうか。

実は、アセンブラを見る時も SOS に頼ることができます。それが !U です。また、プライベート シンボルがある場合には、.lines を使うことでネイティブ コードと同じように行番号を表示させることができます。こんな感じです。

0:000> .lines
Line number information will be loaded
0:000> !U .
Normal JIT generated code
cssandbox.Program.Main(System.String[])
Begin 01220050, size 66

d:\VSDev\Projects\cssandbox\Program.cs @ 11:
01220050 55              push    ebp
01220051 8bec            mov     ebp,esp
01220053 83ec14          sub     esp,14h
01220056 33c0            xor     eax,eax
01220058 8945f4          mov     dword ptr [ebp-0Ch],eax
0122005b 8945f0          mov     dword ptr [ebp-10h],eax
0122005e 8945ec          mov     dword ptr [ebp-14h],eax
01220061 894dfc          mov     dword ptr [ebp-4],ecx
01220064 833d6031d70000  cmp     dword ptr ds:[0D73160h],0
0122006b 7405            je      01220072
0122006d e84270576c      call    clr!JIT_DbgIsJustMyCode (6d7970b4)
01220072 33d2            xor     edx,edx
01220074 8955f8          mov     dword ptr [ebp-8],edx
>>> 01220077 90              nop

d:\VSDev\Projects\cssandbox\Program.cs @ 12:
01220078 b9f037d700      mov     ecx,0D737F0h (MT: cssandbox.Program)
0122007d e87e20b4ff      call    00d62100 (JitHelp: CORINFO_HELP_NEWSFAST)
01220082 8945f4          mov     dword ptr [ebp-0Ch],eax
01220085 8b4df4          mov     ecx,dword ptr [ebp-0Ch]
01220088 ff151038d700    call    dword ptr ds:[0D73810h] (cssandbox.Program..ctor(), mdToken: 06000002)
0122008e 8b45f4          mov     eax,dword ptr [ebp-0Ch]
01220091 8945f8          mov     dword ptr [ebp-8],eax

d:\VSDev\Projects\cssandbox\Program.cs @ 13:
01220094 8b45f8          mov     eax,dword ptr [ebp-8]
01220097 8945f0          mov     dword ptr [ebp-10h],eax
0122009a e875d3686b      call    mscorlib_ni+0x36d414 (6c8ad414) (System.Console.get_Out(), mdToken: 06000945)
0122009f 8945ec          mov     dword ptr [ebp-14h],eax
012200a2 8b4df0          mov     ecx,dword ptr [ebp-10h]
012200a5 8b55ec          mov     edx,dword ptr [ebp-14h]
012200a8 3909            cmp     dword ptr [ecx],ecx
012200aa ff15e037d700    call    dword ptr ds:[0D737E0h] (cssandbox.Program.Print(System.IO.TextWriter), mdToken: 06000005)
012200b0 90              nop

d:\VSDev\Projects\cssandbox\Program.cs @ 14:
012200b1 90              nop
012200b2 8be5            mov     esp,ebp
012200b4 5d              pop     ebp
012200b5 c3              ret
0:000>

かなり読みやすくなりました。

コンストラクターについて

上のアセンブラで call 命令の部分を見ると、cssandbox.Program..ctor() というメソッドを呼び出す箇所があることに気づきます。コードを見ればすぐに分かりますが、これはコンストラクターです。コンストラクターは、内部的に .ctor というメソッドとして扱われるようです。ctor の先頭のドットも含めてメソッド名なので、完全修飾名にするとドットが連続する不思議な名前になります。

当然、!name2ee で Method Descriptor を見つけることもできます。Program.Program や Program.new のような名前では検索できないことを確認して下さい。今回は 2 つのコンストラクターをオーバーロードしていますので、両方とも検出され、パラメーターの種類も出してくれます。

0:000> !name2ee cssandbox cssandbox.Program.Program
Module:      00d72e94
Assembly:    cssandbox.exe
0:000> !name2ee cssandbox cssandbox.Program.new
Module:      00d72e94
Assembly:    cssandbox.exe
0:000> !name2ee cssandbox cssandbox.Program..ctor
Module:      00d72e94
Assembly:    cssandbox.exe
Token:       06000002
MethodDesc:  00d737b8
Name:        cssandbox.Program..ctor()
Not JITTED yet. Use !bpmd -md 00d737b8 to break on run.
———————–
Token:       06000003
MethodDesc:  00d737c0
Name:        cssandbox.Program..ctor(System.String)
Not JITTED yet. Use !bpmd -md 00d737c0 to break on run.
0:000>

[SAP] [C#] SAP GUI Scripting from .NET

SAP GUI Scripting という機能があります。eCATT (まともに使ったことはない) のように、SAP GUI を外部から操作できる機能です。テスト シナリオの実行やテスト データ入力などを大量処理する場合に使えそうです。HP Quality Center やら HP Load Runner はこの機能を利用しているんでしょうかね。

これはあくまでも SAP GUI を操作する機能ですので、アプリケーション サーバーを直接操作する BAPI や Enterprise Services とは全く別の機能です。

この SAP GUI Scripting、実際どのぐらい使われているかどうかは不明です。古くからある機能なので、公開されている情報も古いものが多い気がします。VBScript やネイティブ アプリ、.NET からも操作できるような記述がありますが、SAP Note に添付されているサンプルが VB6 で書かれたとおぼしきものだったり。いいサンプルなんですけど。

そんなわけで、.NET で SAP GUI Scripting を操作してみました。

まず、SAP GUI Scripting の実体は単なる ActiveX コントロールです。VBA から SAP にアクセスするときに、SAP GUI の ActiveX コントロールを使っている人はそこそこいるような、いないような。ファイルは sapfewse.ocx です。SAP GUI をインストールすると以下のパスに作成されます。

C:\Program Files (x86)\SAP\FrontEnd\SAPgui\sapfewse.ocx

image

それさえ分かれば、あとは普通に使えます。手順は以下の通り。

  1. TypeLib を使って OCX ファイルから IDL ファイルを作成
  2. IDL をコンパイルして TLB ファイルを作成
  3. TLBIMP を使って TLB ファイルから .NET アセンブリを作成
  4. .NET アプリを書く

環境はこんな感じ。SDK は新しいの入れておかないと駄目だなー。

OS: Windows 7 SP1
IDE: Visual Studio 2010 SP1
SDK: Windows SDK  v7.0A (まずい 7.1 入れてない・・・)
SAP GUI: 720 PL3

1. TypeLib を使って OCX ファイルから IDL ファイルを作成

Windows SDK に付属している OLE-COM Object Viewer を管理者特権で開いて下さい。Visual Studio をインストールすると勝手にインストールされると思います。

image

メニューから File > View TypeLib を選んで下さい。

image

[ファイルを開く] ダイアログが表示されるので、前述の sapfewse.ocx を開いて下さい。

image

メニューから File > Save As… を選択し、適当なところに IDL ファイルを保存して下さい。ファイルの末尾が切れる場合は、右ペインのテキストをコピペしてテキスト ファイルとして保存して下さい。

image

ファイルはこんな感じです。以前の記事で RPC サーバー / クライアントを作ったときにも IDL ファイルは出てきました。あのときは手で書きましたが、このように既存のオブジェクトから自動生成することもできます。

image

 

2. IDL をコンパイルして TLB ファイルを作成

ネイティブだったら IDL ファイルを使ってそのまま RPC クライアントを作ればいいのですが、.NET の場合はもう数ステップ必要です。

次に IDL ファイルをコンパイルして TLB ファイルを作る必要があります。Windows SDK に含まれている midl.exe を使います。これも RPC のときに使いました。Visual Studio Command Prompt を管理者特権で開き、以下のコマンドを実行します。

> midl sapfewse.IDL

image

が、エラーとなります。おーん。

.\sapfewse.IDL(601) : error MIDL2025 : syntax error : expecting a type specification near "GuiComponent"

.\sapfewse.IDL(601) : error MIDL2026 : cannot recover from earlier syntax errors; aborting compilation

syntax error とか・・・自動生成したものをそのまま使ってるんですがね。
エラーとなっている 601 行付近はこんな感じです。

    [
      uuid(93A37525-9118-4731-92A7-B93DF1E34455)
    ]
    dispinterface _Dsapfewse {
        properties:
            [id(0x00007d01), readonly           
]
            BSTR Name;
            [id(0x00007d0f), readonly           
]
            BSTR Type;
            [id(0x00007d20), readonly           
]
            long TypeAsNumber;
            [id(0x00007d21), readonly           
]
            VARIANT_BOOL ContainerType;
            [id(0x00007d19), readonly           
]
            BSTR Id;
            [id(0x00007d26), readonly           
]
            GuiComponent* Parent;
            [id(0x00007d13), readonly      ← 601 行目     
]

インターフェース _Dsapfewse のメンバーを定義しているところです。GuiComponent というキーワードが認識されていないようなので、定義を探してみます。すると、701 行目に定義が見つかります。原因はこれですね。物事には順番ってものがあります。

[
  uuid(ABCC907C-3AB1-45D9-BF20-D3F647377B06),
  noncreatable
]
coclass GuiComponent {
    [default] dispinterface ISapComponentTarget;
};

GuiComponent の定義を _Dsapfewse の定義の上に移動させます。こんな感じ。

[
  uuid(ABCC907C-3AB1-45D9-BF20-D3F647377B06),
  noncreatable
]
coclass GuiComponent {
    [default] dispinterface ISapComponentTarget;
};

[
  uuid(93A37525-9118-4731-92A7-B93DF1E34455)
]
dispinterface _Dsapfewse {
    properties:
        [id(0x00007d01), readonly           

さて、再チャレンジ。またエラーでございます。

image

.\sapfewse.IDL(612) : error MIDL2025 : syntax error : expecting a type specification near "GuiComponentCollection"
.\sapfewse.IDL(612) : error MIDL2026 : cannot recover from earlier syntax errors; aborting compilation

今度は GuiComponentCollection か。というわけで、今度は GuiComponentCollection の定義を _Dsapfewse の上に移動します。で、また違う箇所でエラ・・・。

試行錯誤の上、以下のような修正をしてコンパイル エラーがクリアされました。やれやれ手間のかかる・・・。でもまあ、定義の順番を入れ替えるだけでよかったから幸運なのかも。

  • GuiComponent の定義を _Dsapfewse の前に移動
  • GuiComponentCollection の定義を _Dsapfewse の前に移動
  • GuiUtils の定義を _Dsapfewse の前に移動
  • GuiCollection の定義を _Dsapfewse の前に移動
  • GuiConnection の定義を _Dsapfewse の前に移動
  • GuiSession の定義を _DsapfewseEvents の前に移動
  • GuiFrameWindow の定義を ISapSessionTarget の前に移動
  • GuiSessionInfo の定義を ISapSessionTarget の前に移動
  • GuiVComponent の定義を ISapWindowTarget の前に移動
  • GuiScrollbar の定義を ISapScreenTarget の前に移動
  • GuiContextMenu の定義を ISapScreenTarget の前に移動
  • GuiComboBoxEntry の定義を ISapComboBoxTarget の前に移動
  • GuiTab の定義を ISapTabbedPane の前に移動

image

コンパイルが通ると、sapfewse.tlb というファイルが生成されます。これはバイナリ ファイルです。

image

 

3. TLBIMP を使って TLB ファイルから .NET アセンブリを作成

TLB ファイルができたら、同じく Windows SDK に含まれる tlbimp を使うと .NET アセンブリを生成してくれます。

以下のコマンドを実行します。ここはノー エラーで通った。

e:\dropbox\sapfewse> tlbimp sapfewse.tlb  /out:sapfewse.dll
Microsoft (R) .NET Framework Type Library to Assembly Converter 4.0.30319.1
Copyright (C) Microsoft Corporation.  All rights reserved.

TlbImp : Type library imported to e:\dropbox\sapfewse\sapfewse.dll

e:\dropbox\sapfewse>

無事、アセンブリである sapfewse.dll をゲットすることができました。

image

 

4. .NET アプリを書く

ここまでが事前準備です。では Visual Studio を起動して .NET アプリを書きます。例によって言語は C# を使いますが、VB でも F# でも動くはずです。

普通に C# の Window Forms Application プロジェクトを作成して下さい。
ソリューションのフォルダーに、.NET アセンブリを作ったときの作業用フォルダーを丸ごとコピーしておくとよいです。

image

Solution Explorer の References を右クリックし、Add Referene… を選択して下さい。
Add Reference ダイアログボックスの Browse タブから、先の手順で作った sapfewse.dll を選んで OK をクリックして下さい。をや、TLB ファイルはそのまま使えるのか。これは知らなかった。

image

名前空間 sapfewse が追加されました。Object Explorer で開いてみると、GuiApplication などのクラスが確認できます。

image

ボタンとテキスト ボックスを配置します。

image

ボタンをクリックしたときのイベント ハンドラーに、以下の 2 行を追加します。ええ、2 行だけです。
GuiApplication のインスタンスを作って、OpenConnection を呼ぶだけです。

sapfewse.GuiApplication SapGuiApp = new sapfewse.GuiApplication();
SapGuiApp.OpenConnection(textBox1.Text);

ソースはこんな感じになります。上の 2 行以外は何もいじっていません。

image

SAP GUI がインストールされている PC 上でプログラムを起動します。
テキスト ボックスに SAP GUI の接続エントリの名称を入力し、Logon をクリックすると接続できます。

この環境では、下記のように "NetWeaver 7.02" という名前のエントリを作ってあるので、"NetWeaver 7.02" と入力します。

image

image

Logon をクリックすると、"A script is opening a connection to system NetWeaver 7.02" という確認のポップアップが表示されます。もちろん OK をクリックします。

image

やけにクラシックな SAP GUI の画面が起動してきました。スキンの設定が変えられるかどうかは試していません。

image

何はともあれ、ログオンはできます。ツールバーとか変ですけど。

image

以上が C# から SAP GUI Scripting を操作するサンプルでした。SAP GUI を起動するサンプルでしたが、既存の SAP GUI セッションにアタッチして値の入力やトランザクションの実行を操作することもできます。むしろそういう使われ方をするほうが多いかもしれません。

オブジェクトのリファレンスは SDN で公開されています。以下のページから SAP GUI Scripting API をダウンロードして下さい。

SAP GUI Scripting
http://www.sdn.sap.com/irj/sdn/index?rid=/webcontent/uuid/007084d7-41f4-2a10-9695-d6bce1673c2f

例えば、サンプルで使った GuiApplication::OpenConnection はこんな感じです。

image

[.NET] [C#] Visual Studio Async CTP を試してみる

話題の Visual Studio Async CTP を試してみました。次期 .NET のバージョンに組み込まれる予定の機能の一つです。C# と VB の言語仕様に async と await という 2 つのキーワードを追加し、言語仕様として非同期処理を実装できるというものです。実は、F# 2.0 には既に async キーワードが実装されていたりしますが。

.NET の非同期処理といえば Begin/End パターンですが、それに置き換わるものかと思います。まだ全然使いこなせていませんが、コード量は明らかに減るわけで、確かにこれは便利そうです。もちろん、言語仕様を複雑化することに対する批判もありますけどね。

サンプルは、YAHOO! の 「東日本大震災 写真保存プロジェクト 写真検索 API」 を使って写真をダウンロードし、それを非同期処理で表示するというものです。主な流れはこんな感じで。

  1. REST API を読んで、画像 URL の一覧が書かれた XML を取得
  2. XML を LINQ to XML でパースして、URL を抽出
  3. 画像データをダウンロード
  4. ListView と ImageList コントロールを使って一覧表示

けっこう単純です。Async の威力を示すため、まず同期処理バージョンを先につくってから、それを非同期処理に書き換えてみます。

1. 準備

Async CTP を利用するには Visual Studio 2010 SP1 が必要です。Express 版は無料なので、持っていない人はここからダウンロードして下さい。
http://www.microsoft.com/visualstudio/en-us/products/2010-editions/express

もちろん Async CTP をインストールしなければいけません。ダウンロード場所は下記 URLです。インストールは、ウィザードにしたがって進めるだけです。Async CTP をインストールすると、大量のサンプルが付随してくるので、それを見るだけで覚えられます。Microsoft にありがちですが、むしろサンプルが大きすぎて読みづらいです。私は匙を投げましたw
http://www.microsoft.com/download/en/details.aspx?id=9983

今回は YAHOO! Japan の提供する API を利用するので、http://developer.yahoo.co.jp/ からアプリケーション ID を取得して下さい。まあ何でもいいんですけど。

2. プロジェクトの作成

Visual Studio を開き、File > New > Project メニューから Windows Forms Application プロジェクトを作成します。ここでは C# を使います。Visual Basic でも構いません。

image

Async CTP には 非同期の拡張メソッドが追加されており、それを async や await を使う新方式で呼び出す、というのが基本の使い方です。その拡張メソッドは、.AsyncCtpLibrary.dll という名前の .NET アセンブリに含まれています。

DLL ファイルは、なぜかサンプルの入っているフォルダーにしか見つからなかったので、とりあえずこれを追加します。
%userprofile%\Documents\Microsoft Visual Studio Async CTP\Samples\AsyncCtpLibrary.dll

Solution Browser から References を右クリックし、[Add Reference…] を選択して下さい。表示されるダイアログで AsyncCtpLibrary.dll を選択して [OK] をクリックして下さい。

image

プロジェクトのプロパティから、Application > Target framework の選択で [.NET Framework 4] を選択して下さい。これは System.Net などを使うためです。

image

Async 以外に使うアセンブリを追加します。先ほどと同様の Add References のダイアログで、今度は .NET タブから以下のアセンブリを追加します。

  • System.Net
  • System.Web

image

Solution Explorer がこんな感じになります。

image

3. フォームの作成

以下のコントロールを貼りつけます。

  • ListView, ImageList – 画像の一覧表示
  • TextBox – 検索キーワード
  • Buttom – 検索実行
  • TextBox – クエリとなる URL を表示
  • ComboBox x2 – 検索結果数、表示画像サイズ

めんどくさいのでコントロール名は全部デフォルトで。

image

んで、こんな感じになります。コントロールのプロパティなどはお好みで調整して下さい。

image

4. コードを書く(同期処理)

以下のようなコードを書きます。これは通常の同期処理です。

//
// Form1.cs
//
//
// References
//
//
http://msdn.microsoft.com/en-us/library/dd250937.aspx (XML)
//
http://www.atmarkit.co.jp/fdotnet/special/linqtoxml/linqtoxml_01.html (Linq to XML)
//
http://www.atmarkit.co.jp/fdotnet/dotnettips/336listviewimage/listviewimage.html (ImageList)
//

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

using System.Xml.Linq;
using System.Net;
using System.Web;

namespace CSSandbox {
    public partial class Form1 : Form {
        const string AppId = "YAHOO! のアプリケーション ID";

        public Form1() {
            InitializeComponent();
        }

        private Size[] mImageSizeList = new Size[] {
            new Size { Width= 64, Height= 48},
            new Size { Width= 128, Height= 96},
            new Size { Width= 256, Height= 192},
        };

        private void Form1_Load(object sender, EventArgs e) {
            comboBox1.Items.Clear();
            comboBox1.Items.AddRange(new string[] {"10", "50", "100"});

            comboBox2.Items.Clear();
            for (int i = 0; i < mImageSizeList.Length; ++i)
                comboBox2.Items.Add(ImageSizeCaption(i));

            comboBox1.SelectedIndex = 0;
            comboBox2.SelectedIndex = 0;

            SetImageSize();
        }

        private string ImageSizeCaption(int index) {
            if (index >= 0 && index < mImageSizeList.Length) {
                return String.Format("{0}x{1}",
                    mImageSizeList[index].Width,
                    mImageSizeList[index].Height);
            }
            else {
                return "N/A";
            }
        }

        private void SetImageSize() {
            imageList1.ImageSize = mImageSizeList[comboBox2.SelectedIndex];
        }

        private void button1_Click(object sender, EventArgs e) {
            listView1.Items.Clear();
            imageList1.Images.Clear();
            SetImageSize();

            Cursor OldCursor = Cursor.Current;
            Cursor.Current = Cursors.WaitCursor;

            Search(textBox1.Text, 1, int.Parse(comboBox1.Text));

            Cursor.Current = OldCursor;
        }

        private void Search(string QueryString, int Start, int NumResults) {
            string requestString =
              "
http://shinsai.yahooapis.jp/v1/Archive/search?"
                + "AppId=" + AppId
                + "&query="
                + HttpUtility.UrlEncode(QueryString, Encoding.UTF8)
                + "&hard_flag=true"
                + "&sort=%2Dorg_time"
                + "&results=" + NumResults
                + "&start=" + Start;

            textBox2.Text = requestString;

            var SearchReq = HttpWebRequest.Create(requestString);
            var SearchRep = SearchReq.GetResponse();

            XElement XmlDoc = XElement.Load(SearchRep.GetResponseStream());
            XNamespace Namespace = "
http://shinsai.yahooapis.jp";

            var query = from element
                        in XmlDoc.Descendants(Namespace + "ThumbnailUrl")
                        select element;

            int ImageIndex= 0;
            foreach (var item in query) {
                var DownloadReq = System.Net.WebRequest.Create(item.Value);
                var DownloadRep = DownloadReq.GetResponse();

                if (DownloadRep.ContentType == "image/jpeg") {
                    var Original =
                      Image.FromStream(DownloadRep.GetResponseStream());
                    listView1.Items.Add(item.Value, ++ImageIndex);
                    imageList1.Images.Add(Original);
                }
            }
        }
    }
}

よく見ると、実は LINQ to XML は不要だったりします。単に使ってみたかっただけです、はい。

API の仕様はここに載っています。クエリ オプションは他にもあります。
http://developer.yahoo.co.jp/webapi/shinsai/archive/v1/search.html

さて、これでプロジェクトをビルドし、適当に検索ワードとオプションを選択して Go ボタンを押すと、画像が表示されます。

image

しかし同期処理なので、画像を表示中はウィンドウがフリーズした状態になります。表示画像数を 100 にして検索すると、10 秒以上待たされます。そこで非同期処理の登場です。

5. コードを書き直す(非同期処理)

まあ、この記事の趣旨からして、いとも簡単に非同期処理に変更できるわけですね。さて、何行ぐらい変えればいいと思いますか。

正解は 4 行です。青字で示した行が変更箇所です。

//
// Form1.cs
//
//
// References
//
//
http://msdn.microsoft.com/en-us/library/dd250937.aspx (XML)
//
http://www.atmarkit.co.jp/fdotnet/special/linqtoxml/linqtoxml_01.html (Linq to XML)
//
http://www.atmarkit.co.jp/fdotnet/dotnettips/336listviewimage/listviewimage.html (ImageList)
//

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

using System.Xml.Linq;
using System.Net;
using System.Web;

using System.Threading.Tasks;

namespace CSSandbox {
    public partial class Form1 : Form {
        const string AppId = "YAHOO! のアプリケーション ID";

        public Form1() {
            InitializeComponent();
        }

        private Size[] mImageSizeList = new Size[] {
            new Size { Width= 64, Height= 48},
            new Size { Width= 128, Height= 96},
            new Size { Width= 256, Height= 192},
        };

        private void Form1_Load(object sender, EventArgs e) {
            comboBox1.Items.Clear();
            comboBox1.Items.AddRange(new string[] {"10", "50", "100"});

            comboBox2.Items.Clear();
            for (int i = 0; i < mImageSizeList.Length; ++i)
                comboBox2.Items.Add(ImageSizeCaption(i));

            comboBox1.SelectedIndex = 0;
            comboBox2.SelectedIndex = 0;

            SetImageSize();
        }

        private string ImageSizeCaption(int index) {
            if (index >= 0 && index < mImageSizeList.Length) {
                return String.Format("{0}x{1}",
                    mImageSizeList[index].Width,
                    mImageSizeList[index].Height);
            }
            else {
                return "N/A";
            }
        }

        private void SetImageSize() {
            imageList1.ImageSize = mImageSizeList[comboBox2.SelectedIndex];
        }

        private void button1_Click(object sender, EventArgs e) {
            listView1.Items.Clear();
            imageList1.Images.Clear();
            SetImageSize();

            Cursor OldCursor = Cursor.Current;
            Cursor.Current = Cursors.WaitCursor;

            Search(textBox1.Text, 1, int.Parse(comboBox1.Text));

            Cursor.Current = OldCursor;
        }

        private async void Search(string QueryString,
                                  int Start, int NumResults) {

            string requestString =
              "
http://shinsai.yahooapis.jp/v1/Archive/search?"
                + "AppId=" + AppId
                + "&query="
                + HttpUtility.UrlEncode(QueryString, Encoding.UTF8)
                + "&hard_flag=true"
                + "&sort=%2Dorg_time"
                + "&results=" + NumResults
                + "&start=" + Start;

            textBox2.Text = requestString;

            var SearchReq = HttpWebRequest.Create(requestString);
            // var SearchRep = SearchReq.GetResponse();
            var SearchRep = await SearchReq.GetResponseAsync();

            XElement XmlDoc = XElement.Load(SearchRep.GetResponseStream());
            XNamespace Namespace = "
http://shinsai.yahooapis.jp";

            var query = from element
                        in XmlDoc.Descendants(Namespace + "ThumbnailUrl")
                        select element;

            int ImageIndex= 0;
            foreach (var item in query) {
                var DownloadReq = System.Net.WebRequest.Create(item.Value);
                // var DownloadRep = DownloadReq.GetResponse();
                var DownloadRep = await DownloadReq.GetResponseAsync();

                if (DownloadRep.ContentType == "image/jpeg") {
                    var Original =
                      Image.FromStream(DownloadRep.GetResponseStream());
                    listView1.Items.Add(item.Value, ++ImageIndex);
                    imageList1.Images.Add(Original);
                }
          }
        }
    }
}

あら不思議。これで 100 枚の画像を表示させてもウィンドウがフリーズしません。

変更箇所が 4 行というのはけっこう少ないほうかと思います。その中でも、ポイントは GetResponseAsync でしょうか。これが Async CTP で追加された拡張メソッドの 1 つです。拡張メソッドは、ネットワーク I/O 系の操作を中心に用意されており、基本的には Windows Azure や Windows Phone 7 で利用されることを目的としているようです。モバイル アプリを作るにはけっこう強力な機能だと思います。

もちろん、実際に追加されている拡張メソッドは、Object Browser から見ることができます。

image

サンプルが適当すぎて微妙ですが、例外処理を書くときが格段に楽になります。というのも、Begin~End のようにコールバック関数を使うような実装だと、複数箇所に try~catch 文を配置しなければなりませんが、async だと、同期処理と同じように例外を捕捉できます。サンプルの例だと、GetResponseAsync を try~catch で囲めばそれで終わりです。簡単ですね。

async には、タイムアウトやキャンセル処理などを実装することもできますが、それはまたの機会に書きます。また、デバッグするとどのように見えるのか、といったところもまだ勉強中です。

しかしまあ、このサンプルだと、非同期処理で画像が追加されている最中にウィンドウを操作できるのはいいのですが、コントロールの再描画処理の層で表示がチカチカしてクールではありません。考え物です・・・。

[Windows Azure] [C#] Blob を操作するコマンドライン ツールの作成

Windows Azure の Blob サービスを使うにあたって、自分用の CUI ツールを作ったので、一応ソースを載せておきます。世にはもっと便利なツールが出回っていると思いますが、特徴はこんな感じです。

  • コマンドライン
  • 非同期ダウンロード/アップロード
  • アップロード時にメタデータを指定
  • MIME の Content-Type にレジストリの HKCR から取ってきた値を指定

GUI だったら Azure Storage Explorer というのが便利そうです。

http://azurestorageexplorer.codeplex.com/

開発/動作環境はこれ。

  • OS: Windows 7 SP1 x64
  • IDE: Visual Studio 2010 SP1
  • SDK: .NET Framework 4.0 + Windows Azure SDK 1.4

作っていて気づいた注意点など。

  • Azure SDK をアセンブリに追加する際、 Target Framework を ".NET Framework 4" に変更する必要がある
    (デフォルトは ".NET Framework 4 Client Profile" になっている)
    image
  • CloudBlobClient.GetContainerReference や CloudBlobContainer.GetBlobReference では、コンテナーやブロブの存在確認はできず、存在しなくてもインスタンスが取得できる。存在確認をするためには、FetchAttributes を呼び出して、例外 StorageClientException を補足しなければならない。
    http://msdn.microsoft.com/en-us/library/microsoft.windowsazure.storageclient.cloudblobclient.getcontainerreference.aspx
  • CloudBlob からブロブ名を取得するプロパティがない?
    CloudBlob.Uri.LocalPath だと "/コンテナ名/ブロブ名" になってしまうので、CloudBlob.Uri.Segments.Last() という苦肉の策を使う。

非同期処理については、もちろん CloudBlob.BeginUploadFromStream と CloudBlob.BeginDownloadToStream を使うわけですが、コールバック関数や EndUploadFromStream, EndDownloadToStream は使わなかった。アップロードの前後で記述する関数が変わるのもおかしいから、というのが理由ですが、これって .NET 的に普通なのかが不明。なにぶん独習 C# ぐらいの知識しかないので、Begin/End パターンへの理解が乏しい。

コンテナの追加と削除、ページ ブロブやブロック ブロブとしての操作は実装していません。

もっと単純なものにする予定だったのが、無駄に凝ってしまった結果がこれ。実質半日ぐらいかかってしまった。
青字の部分は、自分の Azure アカウントに応じて変更して下さい。

最近またコーディング スタイルを変えている。テーマは脱ハンガリアン。変数名の先頭を大文字にするのに抵抗がなくなってきた。でもメンバ変数の頭には m を付けようと思っている。

//
//
//

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;

using Microsoft.Win32; // registry
using Microsoft.WindowsAzure;
using Microsoft.WindowsAzure.StorageClient;

namespace AzureBlob {
    class BlobStorageException : Exception {
        public BlobStorageException(string Message, bool Dump)
            : base(Message) {
            Console.WriteLine(Message + "\n");
            if (Dump) DumpUsage();
        }

        public BlobStorageException(string Message,
                                    bool Dump, Exception Base)
            : base(Message, Base) {
            Console.WriteLine(Message + "\n");
            if (Dump) DumpUsage();
        }

        private void DumpUsage() {
            Console.WriteLine(@"Usage:
   AzureBlob /list     <container>
   AzureBlob /info     <container> <blobname>
   AzureBlob /delete   <container> <blobname>
   AzureBlob /upload   <container> <blobname> <file> <key1:val1> <key2:val2>
   AzureBlob /download <container> <blobname> <file>
");
        }
    }

    class Program {
        static void Main(string[] args) {
            Program p = new Program();

            try {
                p.ParseArguments(args);
                Console.WriteLine("Done.\n");
            }
            catch (BlobStorageException) {
                Console.WriteLine("Failed.\n");
            }
        }

        enum CommandType { List = 0, Info, Delete, Upload, Download };
        struct ArgumentType {
            public CommandType Type;
            public string Command;
            public int MinimumArguments;
            public bool BlobMustExist;
            public ArgumentType(CommandType t, string s, int n, bool b) {
                Type = t; Command = s; MinimumArguments = n;
                BlobMustExist = b;
            }
        }
        static ArgumentType[] ArgumentTypes = {
            new ArgumentType(CommandType.List,     "/list",     2, false),
            new ArgumentType(CommandType.Info,     "/info",     3, true),
            new ArgumentType(CommandType.Delete,   "/delete",   3, true),
            new ArgumentType(CommandType.Upload,   "/upload",   4, false),
            new ArgumentType(CommandType.Download, "/download", 4, true),
        };
       
        private string PrimaryAccessKey = "ほげほげABCD==";
        private string StorageAccount = "ストレージアカウント名";
        private CloudBlobContainer GetBlobContainer(string ContainerName,
                                                    bool IsCreate) {
            CloudStorageAccount Account = new CloudStorageAccount(
              new StorageCredentialsAccountAndKey(StorageAccount,
              Convert.FromBase64String(PrimaryAccessKey)), false);
            CloudBlobClient BlobClient = Account.CreateCloudBlobClient();
            CloudBlobContainer BlobContainer =
              BlobClient.GetContainerReference(ContainerName);

            //BlobContainerPermissions Permissions =
            //  new BlobContainerPermissions();
            //Permissions.PublicAccess =
            //  BlobContainerPublicAccessType.Container;
            //mContainer.SetPermissions(Permissions);

            if (!IsCreate) {
                try {
                    BlobContainer.FetchAttributes();
                }
                catch (StorageClientException) {
                    return null;
                }
            }

            // BlobContainer.CreateIfNotExist();
            return BlobContainer;
        }

        string mFile = null;
        string mBlobName = null;
        CloudBlobContainer mBlobContainer = null;
        CloudBlob mBlob = null;

        private void ParseArguments(string[] Arguments) {
            if (Arguments.Length < 1)
                throw new BlobStorageException(
                  "Some parameter are missing.", true);

            int CommandIndex = -1;
            for ( int i=0 ; i<ArgumentTypes.Length ; ++i ) {
                if (Arguments[0].ToLower() == ArgumentTypes[i].Command) {
                    CommandIndex = i;
                    break;
                }

            }

            if (CommandIndex == -1)
                throw new BlobStorageException("Bad command.", true);

            ArgumentType Command= ArgumentTypes[CommandIndex];

            if (Arguments.Length < Command.MinimumArguments)
                throw new BlobStorageException(
                  "Some parameter are missing.", true);

            mBlobContainer = GetBlobContainer(Arguments[1], false);
            if (mBlobContainer == null)
                throw new BlobStorageException(
                  string.Format("The container `{0}` does not exist.",
                    Arguments[1]), false);

            if (Command.Type == CommandType.List) {
                OnList();
                return;
            }

            mBlobName = Arguments[2];
            mBlob = mBlobContainer.GetBlobReference(mBlobName);
            if (Command.BlobMustExist) {
                try {
                    mBlob.FetchAttributes();
                }
                catch (StorageClientException) {
                    throw new BlobStorageException(
                      string.Format("The blob `{0}` does not exist.",
                        mBlobName), false);
                }
            }

            switch (Command.Type) {
            case CommandType.Info:
                OnInfo();
                break;
            case CommandType.Delete:
                OnDelete();
                break;
            case CommandType.Upload:
                mFile = Arguments[3];
                string[] Metadata = new string[Arguments.Length – 4];
                Array.Copy(Arguments, 4, Metadata, 0, Arguments.Length – 4);
                OnUpload(Metadata);
                break;
            case CommandType.Download:
                mFile = Arguments[3];
                OnDownload();
                break;
            }
        }

        private void OnList() {
            Console.WriteLine("[Blobs in Container]");
            foreach (var b in mBlobContainer.ListBlobs())
                Console.WriteLine(b.Uri.Segments.Last());
            Console.WriteLine("");
        }

        private void OnInfo() {
            Console.WriteLine("[Basics]");
            Console.WriteLine(" URI         : {0}", mBlob.Uri.ToString());
            Console.WriteLine(" LocalPath   : {0}", mBlob.Uri.LocalPath);
            Console.WriteLine("");
            Console.WriteLine("[Properties]");
            Console.WriteLine(" Length      : {0:#,#} bytes",
              mBlob.Properties.Length);
            Console.WriteLine(" Content-Type: {0}",
              mBlob.Properties.ContentType);
            Console.WriteLine("");
            Console.WriteLine("[Metadata]");
            foreach (var key in mBlob.Metadata.AllKeys)
                Console.WriteLine(" {0}: {1}", key, mBlob.Metadata[key]);
            Console.WriteLine("");
        }

        private void OnDelete() {
            mBlob.Delete();
            Console.WriteLine("");
        }

        private void Progress(int n) {
            char[] bars = { ‘|’, ‘/’, ‘―’, ‘\’ };
            Console.SetCursorPosition(0, Console.CursorTop);
            Console.Write(bars[n % 4]);
        }

        private string TickToDuration(long Tick) {
            Tick /= (1000 * 1000 * 10); // covert fron 100nsec to sec
            return string.Format("{0:0#}m{1:0#}s", Tick / 60, Tick % 60);
        }

        private void OnDownload() {
            //mBlob.DownloadToFile(mFile);
           
            IAsyncResult Result= null;
            try {
                FileStream SerializedFile = new FileStream(
                  mFile, FileMode.Create,
                  FileAccess.ReadWrite, FileShare.None);
                Result = mBlob.BeginDownloadToStream(
                  SerializedFile, null, null); // no use of callback
            }
            catch (Exception e) {
                throw new BlobStorageException("File I/O error.", false, e);
            }

            Console.Write("| Downloading…");

            bool IsSeekable = true;
            try {
                Console.SetCursorPosition(0, Console.CursorTop);
            }
            catch {
                // not seeakable console
                IsSeekable = false;
                Console.WriteLine("");
            }

            int n = 0;
            while (Result != null && !Result.AsyncWaitHandle.WaitOne(100)) {
                if (IsSeekable) Progress(n);
                n = (n + 1) % 4;
            }

            Console.WriteLine("");
        }

        private void OnUpload(string[] Metadata) {
            // mBlob.UploadFile(mFile);

            IAsyncResult Result = null;
            try {
                FileStream SerializedFile = new FileStream(
                  mFile, FileMode.Open, FileAccess.Read, FileShare.Read);
                // mBlob.UploadFromStream(SerializedFile);
                Result = mBlob.BeginUploadFromStream(
                  SerializedFile, null, null); // no use of callback
            }
            catch (Exception e) {
                throw new BlobStorageException("File I/O error.", false, e);
            }

            Console.Write("| Uploading…");

            bool IsSeekable = true;
            try {
                Console.SetCursorPosition(0, Console.CursorTop);
            }
            catch {
                // not seeakable console
                IsSeekable = false;
                Console.WriteLine("");
            }

            long StartTime = DateTime.Now.Ticks;

            int n = 0;
            while (Result!=null && !Result.AsyncWaitHandle.WaitOne(100)) {
                if ( IsSeekable ) Progress(n);
                n = (n + 1) % 4;
            }

            long EndTime = DateTime.Now.Ticks;

            Console.WriteLine("Uploading done – [{0}]\n",
              TickToDuration(EndTime – StartTime));

            mBlob.Properties.ContentType =
              GetContentType(Path.GetExtension(mFile));
            Console.WriteLine("Content-Type set -> {0}",
              mBlob.Properties.ContentType);
            mBlob.SetProperties();

            string key, value;
            foreach (string s in Metadata) {
                int pos= s.IndexOf(‘:’);
                if (pos == 0) {
                    Console.WriteLine("Metadata {0} is skipped", s);
                    continue;
                }
                else if (pos == -1) {
                    key = s; value = "";
                    Console.WriteLine("Metadata set -> {0}", key);
                }
                else {
                    key = s.Substring(0, pos);
                    value = s.Substring(pos + 1);
                    Console.WriteLine("Metadata set -> {0}: {1}",
                      key, value);
                }

                mBlob.Metadata.Add(key, value);
            }
            mBlob.SetMetadata();

            Console.WriteLine("");
        }

        private string GetContentType(string Extenstion) {
            RegistryKey RegKey= Registry.ClassesRoot.OpenSubKey(Extenstion);
            if (RegKey == null)
                return ""; // Extension is not registered

            string RegValue = RegKey.GetValue("Content Type") as string;
            return RegValue == null ? "" : RegValue;
        }
    }
}

[.NET] [C#] ウィンドウを閉じると通知アイコンで実行されるプログラム

最近業務外でこそこそと C#.NET のプログラムを書いていて、その中で出てきた Tips。ちなみに、通知 “トレイ” という表現は全くの間違いで、通知エリアにおける通知アイコンというのが正式な表現らしいです。分かりにくいことに、通知エリアもタスクバーの一部なので、「じゃあ本来のタスクバーのボタンの名前は?」と聞かれると分かりません。タスクボタン?ですかね。

閑話休題。.NET で通知アイコンを表示させるのは簡単です。 NotifyIcon クラスを使います。下記 URL にあるサンプルを見れば一目瞭然。

http://msdn.microsoft.com/ja-jp/library/system.windows.forms.notifyicon(VS.80).aspx

このプログラムをベースに、ウィンドウを閉じたときにタスクボタンが消えて、通知アイコンだけになるプログラムを作ります。タスクボタンは、プログラムがウィンドウを持っているときに表示されているので、ウィンドウを閉じればタスクボタンは消えます。ウィンドウを閉じたときにアプリケーションが終了してしまってはまずいので、 Application.Run メソッドに Form を渡してはダメです。

さて、 NotifyIcon インスタンスはどこに持たせればいいのでしょうか。もし、ウィンドウを閉じる=破棄する というのであれば、当然 NotifyIcon は Form に持たせるわけにはいかず、 Application クラスを継承してなんちゃらかんちゃら、という風に Form の外側に持たせる必要があります。しかし、ウィンドウの × ボタンを押すたびにウィンドウが破棄され、通知アイコンをダブルクリックするなどしたときに、またウィンドウが初期状態に戻ってしまうというのも現実的な仕様ではありません。Form.Show() ではなく、 Form.ShowDialog() を使ってウィンドウをモーダル表示させると、ウィンドウを閉じても破棄されません。通知アイコンをダブルクリックなどしたときに再度 Form.ShowDialog() を呼び出すと、閉じる前の状態でウィンドウが復活します。

これを使えば Form クラスの中に NotifyIcon を持たせてもよさそうなものですが、個人的には違和感を感じます。例えアプリケーションがウィンドウを一つしか持っていなかったとしても、通知アイコンというのは概念的には「アプリケーションそのもの」を指しているわけで、そのアプリケーションのフロントエンドに過ぎないフォームオブジェクトが通知アイコンを所有しているというのは設計的におかしい気がするのです。

以上を踏まえて Application.Run のオーバーロード一覧を見ると、 Application.Run(ApplicationContext) といういかにも使えそうなものがあります。ユーザ定義の ApplicationContext クラスを作って、そこに通知アイコンなど、アプリケーション関連処理を持たせれば万事解決です。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Forms;

using System.IO;
using System.Reflection;

namespace CSSandbox {
    static class Program {
        /// <summary>
        /// アプリケーションのメイン エントリ ポイントです。
        /// </summary>
        [STAThread]
        static void Main() {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);

            Application.Run(new MyApplicationContext(new Form1()));
        }
    }

    class MyApplicationContext : ApplicationContext {
        private Form mForm;
        private NotifyIcon mNotifyIcon;
        private ContextMenu mContextMenu;

        public MyApplicationContext(Form f) {
            mForm = f;

            InitNotifyTray();
            f.ShowDialog();
        }

        private void InitNotifyTray() {
            MenuItem menu = new MenuItem();
            menu.Index = 0;
            menu.Text = "E&xit";
            menu.Click += new System.EventHandler(menuItem1_Click);

            mContextMenu = new ContextMenu();
            mContextMenu.MenuItems.Add(menu);

            mNotifyIcon= new NotifyIcon();
            mNotifyIcon.Icon = new Icon("appicon.ico");
            mNotifyIcon.ContextMenu = mContextMenu;
            mNotifyIcon.Text = "NotifyIcon Tooltip";
            mNotifyIcon.Visible = true;

        }

        private void notifyIcon1_DoubleClick(object Sender, EventArgs e) {
            if ( !mForm.Visible )
                mForm.ShowDialog();
        }

        private void menuItem1_Click(object Sender, EventArgs e) {
            Application.Exit();
        }

    }
}

コンテキストメニューを ApplicationContext に持たせる必要もないのですが、なんとなく。

このプログラムで目的は達成したような気がしますが、アプリケーションを終了させても通知アイコンが通知エリアから消えないという現象が起きます。アプリケーションがクラッシュした時と同じようなことが起こっていて、マウスポインタをその通知アイコン上に持っていくと通知アイコンは消えます。再描画イベントが発生しない、とかなんでしょうかね。理由は不明です。

このままでは気持ち悪いので、試しに通知アイコンの破棄を明示的に行ったところ、無事に通知アイコンは自動的に消えるようになりました。すなわち、 ApplicationContext のコンストラクタに次のようにイベントハンドラを追加し、

public MyApplicationContext(Form f) {
    mForm = f;
    Application.ApplicationExit += new EventHandler(OnQuit);

    InitNotifyTray();
    f.ShowDialog();
}

OnQuit 関数を追加します。

private void OnQuit(object sender, EventArgs e) {
    mNotifyIcon.Dispose();
}

最後にもう一点。通知アイコンをアイコンファイルからロードしているあたりが何とも滑稽なので、これをリソースから読み込むように変えたほうがよさそうです。で、リソースからバイナリを読み込む方法をいろいろ調べても意外とすんなり出てこない。以下のサイトの方法を試してみてもなぜかうまくいかず、というか Win32API の世界よりめんどくさくなっている気がしておかしい。

http://support.microsoft.com/kb/319292/en
http://dobon.net/vb/dotnet/programing/resourcemanager.html

と、 30 分ほど困っていたら、偶然見つけることができました。リソースは既にインスタンスとして与えられているじゃないか、と。

  mNotifyIcon.Icon = Properties.Resources.Icon1;

瞬殺。.NET 本には書いてありそうですね。知ってて当たり前なのだろうか。