「.NET」タグアーカイブ

DMMTool(仮名) for Windows開発状況その4


いよいよブックマーク機能を実装しました。

今回は、ブックマーク機能を実装するにあたって利用したMVCという設計手法とXBELという規格の話が中心です。

まずはスクリーンショットをご覧下さい。一般的なウェブブラウザに搭載されているような階層構造を持ったブックマークがウィンドウ左側に表示されているのがわかると思います。

bookmark.png

このブックマーク機能の仕組みですが、MVC(Model-View-Controller)という設計手法に則って設計されています。なぜこのような手法にしたかというと複数のウィンドウ間でブックマーク表示の同期を取らなければならないからです。

このツールは一般的なウェブブラウザ同様、複数のウィンドウを開いて作業を行えるようになっているため、あるウィンドウでブックマークの変更を行った場合、他に開いているウィンドウにもその内容が即座に反映させなくてはなりません。

MVCを使わない場合・・・

まず、標準のTreeViewだけで上記の要件を満たしたブックマーク機能を実装することを考えてみます。

TreeViewの表示内容を弄るにはNodesプロパティを使います。ここにTreeNodeオブジェクトを追加したり削除したりすることによりTreeViewの表示内容を弄ることができます。TreeNodeは名前の通りツリー構造になっていて一つ以上の子TreeNodeを追加することができ、これによりデータの階層的な表示を実現できます。つまり、TreeViewは表示に関する機能と、表示するデータを管理する機能の二つを両方持っているということになります。従ってここにブックマークのデータを入れてやればTreeViewを使ってブックマークデータの編集や表示が行えるというわけです。

ところが、複数のウィンドウが開かれていて、それぞれのウィンドウが同じブックマークの内容を表示したい場合はどうでしょう。当然それぞれのウィンドウがTreeViewを持つことになりますがこの場合、変更があったTreeViewの内容を全ての他のTreeViewにコピーして回らなければなりません。

また、あるウィンドウでブックマークの追加や削除が行われたことをどうやって他のウィンドウが知れば良いのでしょうか。また、他のウィンドウで行われたブックマークの変更を自分のウィンドウに反映させるにはどうすれば良いでしょうか。さらに、もっと多くのウィンドウが開かれたり閉じられたりをしていた場合のTreeViewの管理はどうなるでしょう。

おそらくはデリゲート等を用いてウィンドウ間の通知機構を実装する方法にたどりつきますが、考えただけで面倒です。

このような動作をより簡単に実現させるためには、まず表示部分とデータの部分を独立させなければなりません。その上でデータの変更を表示部分に通知するメカニズムと、表示部分に対して行われた操作によって、データの変更を要求するメカニズムを実装する必要があります。これがいわゆるModel View Controllerという設計手法です。

前述の通り、標準のTreeViewだけで上記の要件を満たしたブックマーク機能を実装するのは非常に面倒です。
なぜならWindows.FormsのTreeViewはView(TreeView)とModel(Nodes)が一体になっているため、データと表示部分の分離がされていないからです。

MVCを使うと・・・

 そこで、TreeViewには単に表示に専念するだけのViewになってもらい、ブックマークのデータを保持するModelを独自に作ります。

 これでViewとModelの分離は行えました。こうするともはやTreeViewのNodesプロパティはブックマークのデータではなく、単なる表示内容のデータになりますから、ブックマークの編集が行われる度にこのデータを他のTreeViewにコピーして回るという面倒なことはしなくても良くなります。

 ここでは、ユーザーがブックマークアイテムをあるフォルダへドラッグして移動したケースを例に、MVCがどのように働くかを説明します。

MVC.png

ViewとModelの関係

Modelにはブックマークデータを管理するツリー構造の他に、ブックマークの追加や削除、移動、コピー、名前の変更、フォルダの作成などを行うメソッドを作っておきます。.NET Frameworkには(頻繁に利用されるデータ構造であるにも関わらず)データのツリー構造を管理するクラスが用意されていません。そこでまずはツリー構造を管理するクラスの自作から始めました。

対してViewはWindows標準のTreeViewを用いますが、データの表示に専念させ、データの管理は全く行わないようにします。また、ドラッグ&ドロップやクリック、アイテムの削除の操作は全てコントローラに通知されるようにします。

Controllerの働き

ControllerはユーザーがViewで行った操作に従って、Modelに適切なメッセージを送り、データの変更要求を行う役目を持っています。

上の例ではユーザーがブックマークをドラッグして移動しています。するとこの操作はControllerに通知され、Controllerはその通知に従い実際にモデルに対してデータの変更を要求します。ちなみにControllerはModelのデータを直接いじりません。データの操作を行うのはあくまでModelになります。

ModelからViewへの通知

 さて、実際にModelが変更されるとModelからViewへ「Modelの内容が変更されたから表示を更新してください」といった通知が行われます。通知といっても実際にはメソッド呼び出しだったりイベント発生だったりするだけです。ViewはModelの内容を視覚的に認識できる形で画面に表示します。今回の例では、ツリー構造を持ったModelの内容を元に、TreeViewのアイテムの再構築を行っています。

この時大事なことは単一のModelに対してViewは複数存在することができる、ということです。つまり、画面に複数のウィンドウがあり、そこへそれぞれのViewが貼り付けられていたとしても、Modelから各Viewに対して適切に通知が行われれば、あるウィンドウで行ったModelの変更が即座に他のウィンドウのViewにも反映される、ということになります。


複数のViewが存在する場合でも

multipleviews.png

画面に複数のドキュメントウィンドウが開かれている等、複数のViewが存在する状況でも、単にModelはそれぞれのViewに対して同時にModel更新の通知を送ってやれば、各Viewの表示内容が一斉に更新されるわけです。今回ブックマーク機能の実装にあたりMVCを利用した最大の理由が「場合によっては画面に複数のViewが存在する」という理由です。


ドラッグ&ドロップの実装

今回一番時間がかかったのが、ブックマークのドラッグ&ドロップによる編集です。
TreeViewにはドラッグ&ドロップをサポートする機能が標準で搭載されていますが、若干機能不足です。

たとえば標準のドラッグ&ドロップではTreeNodeを他のTreeNodeの上にドラッグし、ドラッグされたTreeNodeの子とすることは簡単にできますが、あるTreeNodeを二つのTreeNodeの間にドラッグして任意の位置にアイテムの挿入を行う、といった機能はありませんから、こういった機能は自前で実装する必要があります。

drag_drop.png

 また、ドラッグ&ドロップにも下記のようなルールを設ける必要があります。

  1. あるフォルダをそのフォルダ自身が内包しているフォルダには移動できないようにする。Windowsのファイルシステムを例にあげて説明すると、C:hogeというフォルダと、C:hogehageという二つのフォルダがあった場合、前者のフォルダを後者のフォルダの内部に移動させる操作はできない、といった具合です。
  2. あるフォルダをそのフォルダ自身の子とすることもできない。
  3. ブックマークの項目をフォルダへ入れて子とすることはできるが、ブックマークの項目をブックマークの項目の子とすることはできない。
  4. 同様にフォルダをブックマーク項目に入れて子とすることもできない。

これらの機能を一つ一つ検証しながら実装していたのですが、あまりにもややこしすぎて時間がかかってしまいました。

ブックマークの保存形式

ブックマークの保存は、ブックマーク記述言語「XBEL」形式で行います。XBELとはXML Bookmarks Exchange Languageの略で、XMLを利用したブックマークの相互利用のための規格です。XML形式ですので、ブックマークの保存の際にはブックマークのツリー構造を再帰的に辿ってXmlDocumentを構築した後、ファイルに出力しています。XBEL形式に関してはこのページを参考にしました。

この形式を利用する利点は、様々なブラウザのブックマークとの相互変換が可能である事です。自作のアプリとはいえ、独自のデータ形式を使ってしまっては後々困ることになりますから、オープンな規格はどんどん利用すべきだと思います。

bookmark_xml.png

さて、次回はWeb版、デスクトップアプリ版両方のDMMアフィリエイトリンク作成ツールに搭載されているキャッシュシステムについて書いてみようかと思います。

.NET FrameworkのBeginGetResponseメソッドが実行中のスレッドをブロックしてしまう件


数日前の事ですが、当サイトで公開しているDMMアフィリエイトの商品リンク作成ツールの利用者の方から「今までに比べてリンクの作成に非常に時間がかかるようになった」とのご報告を頂きました。

今までは遅くても数秒以内に作成できていた物が、早くても10秒以上かかるというのです。

こちらではしばらくプログラムの変更は行っていませんでしたので、単にDMMのサーバーが重くなっているのではと考えていましたが実は完全に私のミスでした。

何が原因だったのか

当サイトのDMMアフィリエイトのリンク作成ツールでは、開発言語としてC#が用いられています。つまりは、.NET Frameworkの機能を利用して実装されているということです。

プログラム中では、ユーザーの問い合わせに応じてDMMのサーバーから実際に商品情報と商品パッケージ画像を取得するのですが、この時に.NET FrameworkのBeginGetResponseメソッドを用いています。実はこのメソッドにちょっとした問題がありました。

このメソッドはインターネットリソースの非同期要求を開始するメソッドであり、通常このメソッドが呼ばれた瞬間に、要求の完了・未完を問わずに一瞬で呼び出し元に制御が返ってきます。

ところが、条件によってはBeginGetResponseメソッドから制御が帰らずにスレッドが長時間ブロックされたままになってしまうことがあります。私のツールがリンクの作成に非常に時間がかかるようになったのもこれが原因でした。

どんな条件の時にスレッドがブロックされるのか

例によって、海外サイトを検索していたら次のサイトを発見しました。

webrequest.begingetresponse is taking too much time when the url is invalid
https://stackoverflow.com/questions/1232139/webrequest-begingetresponse-is-taking-too-much-time-when-the-url-is-invalid

要約するとインターネットからのリソースの取得は非同期的に行われるけどDNSの解決なんかは内部で同期的に実行されるから、無効なURLなんかを指定してDNSの解決に失敗したりするとスレッドがその間ブロックされてしまうとのこと。

ところが。
私のプログラムでは無効なURLは指定していませんし、そもそもDMMの商品URLに通常のブラウザからアクセスするとあっという間に結果が表示されます。

しかし、同時にとある事を思い出しました。

実は数日前にApacheの実行ユーザをWindowsサービスのデフォルトユーザから、セキュリティをガチガチに固めた「Apache」というユーザ変更しました。そして改めて検証してみると、Apacheをデフォルトユーザで起動した場合にはリンク作成ツールは今まで通りの速度で動作するのですが、ユーザを「Apache」に変更すると10秒以上の遅延が発生するのです。

ひょっとして、ユーザ「Apache」のインターネット接続設定がおかしいのでは?と思い、ユーザ「Apache」でログオンしていみたところ・・・。

容疑者発見!

internetoption.png

「インターネットオプション」を開いてみたところなぜか「設定を自動的に検出する」というチェックボックスがオンになっていました。物は試しにこのチェックボックスをオフにして再度リンク作成ツールをテストしてみると、見事に遅延は解消されました。犯人はおまえだったのか。

参考までに、なんでこのような現象が起きるのかを調べてみたのですが、マイクロソフトの英語版公式リファレンス によると、こんな記述がありました。

The BeginGetResponse method requires some synchronous setup tasks to complete (DNS resolution, proxy detection, and TCP socket connection, for example) before this method becomes asynchronous. As a result, this method should never be called on a user interface (UI) thread because it might take some time, typically several seconds. 

要約:このメソッドは非同期状態になる前に、いくつかの同期タスク(例えば、DNS解決、プロキシ検出、TCPソケット接続等)を完了する必要があり、場合によってはそれらの同期タスクには数秒を要するため、このメソッドはUIスレッドから呼び出されるべきではありません!

 なお、日本語版のリファレンスには載っていませんでした・・・。


まとめ

 というわけで、どうやらスレッドをブロックしている要因はDNS解
決だけではなく、プロキシ検出なども含まれるため、「設定を自動的に検出する」オプションにチェックが入っているにもかかわらず設定の検出に失敗したり時間を要した場合には、その間スレッドはブロックされてしまうっぽいです。


 ところで、デフォルトでは「設定を自動的に検出する」はオフになるはずなのですが、なぜオンになっていたのでしょうね。

Process.Start()で起動したプロセスのメインウィンドウのハンドルが取得できない場合の対処法


 現在C#にて、以下のような仕様でプロセス間通信を行うアプリケーションを作っているのですが、プロセスAから起動したプロセスBのメインウィンドウのハンドルが取得できないという問題に遭遇しました。

  • ユーザーの操作でプロセスAがプロセスBを起動。
  • プロセスAはプロセスBのメインウィンドウにWM_COPYDATAを用いてデータを送信。
  • プロセスBはプロセスAから受けた指示に従って各種動作を行う。

 一番最初に書いたコードは、次のコードです。
このコードを実行すると、プロセスは起動できるのですがWaitForInputIdle()で起動したプロセスのメッセージループ突入まで待機しているにも関わらず、メインウィンドウのハンドルが取得できずに0が返って来ることがあります。

            //プロセスを生成する。
            Process p = new Process();
            p.StartInfo.FileName = appPath;
            p.StartInfo.Arguments = args;
            p.StartInfo.UseShellExecute = false;
 
            p.Start();
 
            //念のため待つ。
            p.WaitForInputIdle();

            // ウィンドウのハンドルを取得するのだが、
// IEや.NETアプリを立ち上げるとゼロになってしまう場合がある。
            IntPtr hMainWindow = p.MainWindowHandle;

 notepad.exeなどのシンプルなアプリケーションの場合には正しくウィンドウハンドルを取得できるのですが、Internet Explorerや自作の.NETアプリケーションの場合には0が返ってきます。

 どうやら挙動を詳しく調べてみると、一部のアプリケーションを起動した場合にはWaitForInputIdle()がきちんとWaitしてくれていないようで、メインウィンドウが生成される前にMainWindowHandleを取得してしまいゼロが取得されるようでした。

 おそらく一部のアプリケーションでは、ウィンドウ生成後メッセージループ突入という組み方がされていないためこのような現象が起こるのでは、と推測します。

 そこで、以下のようなコードを作成し再びテストを行ったところ、InternetExplorerや.NETアプリケーションを起動してもウィンドウハンドルを取得することができるようになりました。

            //プロセスを生成する。
            Process p = new Process();
            p.StartInfo.FileName = appPath;
            p.StartInfo.Arguments = args;
            p.StartInfo.UseShellExecute = false;
 
            p.Start();
 
            //念のため待つ。
            p.WaitForInputIdle();
 
            //ウィンドウハンドルが取得できるか、
//生成したプロセスが終了するまで待ってみる。
            while (p.MainWindowHandle == IntPtr.Zero && 
p.HasExited == false)
            {
                System.Threading.Thread.Sleep(1);
                p.Refresh();
            }
 
            // hMainWindow != IntPtr.Zeroの場合にはウィンドウハンドルを
//取得できたと思われる。
            IntPtr hMainWindow = p.MainWindowHandle;

 このコードでは、WaitForInputIdle();で念のためメッセージループの開始を待った後に、さらに、本当にウィンドウハンドルが取得できたかどうかをチェックし、取得できるまでループを回す、という事を行っています。

 その際に、起動したプロセスが異常終了した時の事も考慮し、プロセスが終了してしまった場合にはループを抜けるようにしています。

 今のところはこのやり方で、メインウィンドウのハンドルは取得できています。

 ただしこのコードにはタイムアウト処理が入っていません。もし起動したプロセスがなんらかの原因でウィンドウの生成に失敗した場合、起動元のプロセスがフリーズしてしまいますので、実際にはタイムアウト処理も入れた方が良いでしょう。

 さらに.NET Frameworkのドキュメントによりますと、Windows Formで生成したフォームのウィンドウハンドルはフォームの有効期間中に不変であるという保証はないそうです。(ShowInTaskbarプロパティを操作すると変わったりするようです) 従って、ウィンドウハンドルを取得しそこへウィンドウメッセージを送るという方式でプロセス間通信を実現する場合には、取得したメインウィンドウのハンドルを保持しておくのではなく、メッセージ送信前に取得しなおしておいたほうが確実かもしれません。