Azure App Service + ASP.NET Web API で LINE BOTを作ってみる

LINE BOT APIのトライアルをAzure + ASP.NET WebAPIで試してみました。

LINE BOT API Trial

Azure App Service と Visual Studio 2015 を使用してLINE BOTを作成する手順をまとめてみたいと思います。

今回はひとまず、ユーザーからのメッセージを受け取れることと、BOT側のメッセージをユーザーに返信出来ることが確認できるまでを目標とします。

目次

はじめに

用意するもの

Visual StudioASP.NET WebAppプロジェクトを作成する

  • プロジェクトの新規作成ダイアログで「ASP.NET Webアプリケーション」を選択してプロジェクトを作成します。

f:id:pierre3:20160411224316p:plain

  • ASP.NET 4.6 テンプレートの「Empty」を選択します。
  • 「フォルダーおよびコア参照を追加する」 で「Web API」のみにチェックします。
  • Microsoft Azure クラウドにホストするをチェックし、コンボボックスでWebアプリを選択します。
  • [OK]をクリックすると、Azure 関連の設定画面に進みます。

f:id:pierre3:20160411220822p:plain

f:id:pierre3:20160411221037p:plain

  • ここで入力したWebアプリ名に 「.azurewebsites.net」ドメイン名を付加したものがこのアプリのURLになります。
    • http(s)://linebotapp.azurewebsites.net
  • 各項目を入力後、[OK]をクリックすると WebAPIのプロジェクトが作成されます。

LINE ビジネスアカウントの設定を行う

LINE ビジネスアカウントで LINE Developpers にログインすると、作成したアカウントの詳細が「Channels」で確認できます。

f:id:pierre3:20160412225914p:plain

  • このページの下部にある[Edit]ボタンをクリックして編集画面を開きます。

f:id:pierre3:20160412230306p:plain

BOT APIのCallBack URLを入力する。

  • アカウント名や、アイコン画像はいつでも変えられるようですので、最初は適当な名前でもOKです。
  • CallBack URL に、先ほど作成したWebAppのURLを入力します。
    • HTTPS とする(BOT サーバーはSSL通信が必須だそうです。幸い、Azure では *.azurewebsites.net ドメインの既定の証明書が使用できるため、特に何もしなくてもHTTPSが有効になっています。)
    • ポート番号を含めて入力します。(ここでは、HTTPS の443を入れておきます。)
    • ユーザーのメッセージを受け取り処理を行うコントローラ名までをURLに含めます。(/api/callback)
https://linebotapp.azurewebsites.net:443/api/callback

f:id:pierre3:20160412230655p:plain

サーバーの送信IPアドレスをWhite Listに登録する

Server IP Whitelist にIPアドレスを登録する必要があります。

f:id:pierre3:20160412234924p:plain

IPアドレスの確認方法

Azureポータルから確認できます。
ポータルにログインし、App Service > LineBotApp(アプリ名) > 設定 > プロパティ の「送信IPアドレス」欄に記載されているIPアドレス(複数記載されていますが、全て登録します。)

※ 送信IPアドレスについては、こちらのサイトの説明が参考になります。 http://cloudsteady.jp/faq/2271.html/

f:id:pierre3:20160412235717p:plain

ユーザーのメッセージを受け取るAPIを作成する。

作成したWebAppプロジェクトにコントローラを追加します。

  • Visual Studio のソリューションエクスプローラで 「Controller」フォルダを右クリックして[追加]-[Controller...]を選択します。
  • ダイアログで「Web API 2 コントローラ -空」を選択後、コントローラ名を「CallBackController」として[追加]をクリックします。

f:id:pierre3:20160413220342p:plain f:id:pierre3:20160413220609p:plain

  • 作成されたソースファイルのCallbackControllerクラスに Postメソッドを追加します。 登録した URL "https://linebotapp.azurewebsites.net/api/callback" にPOSTメソッドでリクエストが来ると、このPostメソッドが実行されます。
  • 今回は、メッセージが送受信できることを確認するのが目的なので、届いたメッセージをほぼそのまま返すだけの処理となっています。
using System;
using System.Net.Http;
using System.Threading.Tasks;
using System.Web.Http;
using Newtonsoft.Json;


namespace LineBotApp.Controllers
{

  public class CallbackController : ApiController
  {
      public async Task<HttpResponseMessage> Post()
      {
        var contentString = await Request.Content.ReadAsStringAsync();
 
        dynamic contentObj = JsonConvert.DeserializeObject(contentString);
        var result = contentObj.result[0];

        var client = new HttpClient();
        try
        {
          client.DefaultRequestHeaders
            .Add("X-Line-ChannelID", Properties.Resources.ChannelID);
          client.DefaultRequestHeaders
            .Add("X-Line-ChannelSecret", Properties.Resources.ChannelSecret);
          client.DefaultRequestHeaders
            .Add("X-Line-Trusted-User-With-ACL", Properties.Resources.MID);
                
               
          var res = await client.PostAsJsonAsync("https://trialbot-api.line.me/v1/events",
              new {
                to = new[] { result.content.from },
                toChannel = "1383378250",
                eventType = "138311608800106203",
                content = new {
                  contentType = 1,
                  toType = 1,
                  text = $"「{result.content.text}」と言ったか にゃ?"
                }
              });
          
          System.Diagnostics.Debug.WriteLine(await res.Content.ReadAsStringAsync());
          return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
       }
       catch (Exception e)
       {
         System.Diagnostics.Debug.WriteLine(e);
         return new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError);
       }
    }
  }
   
}
  • メッセージを送信する際のHeaderには、LINE ビジネスアカウントの Channels-Basic information 画面で確認できる ChannelID、ChannelSecret,MID の値を使用します。
    • X-Line-ChannelID : ChannelID
    • X-Line-ChannelSecret : ChannelSecret
    • X-Line-Trusted-User-With-ACL : MID

Webアプリケーションをデプロイする

Controllerを追加し、ビルドが通ったら後はAzure にデプロイするだけです。

  • ソリューションエクスプローラでプロジェクト名を右クリックし、[公開..]を選択すると、Webの発行ウィザードが表示されます。

f:id:pierre3:20160413230129p:plain

  • Web発行ウィザードでは、特に内容を変更せずに[次へ>]で進んでOKです。

f:id:pierre3:20160413230533p:plain

  • ブラウザが立ち上がり、以下のページが表示されたらデプロイ成功です。

f:id:pierre3:20160413231123p:plain

実際にメッセージを送ってみよう

では、実際にLINEアプリに作成したBOTを友達登録して、動作確認をしてみましょう。 (LINE ビジネスアカウントの Channels-Basic information ページに表示されるQRコードを 読み取ることで友達登録できます。)

f:id:pierre3:20160413232225p:plain

送ったメッセージがちゃんと帰ってきましたね。とりあえず、最低限のやり取りが出来ることは確認できました。

Azure に配置したWebアプリをリモートデバッグする。

Azure上のWebアプリのリモートデバッグVisual Studioから簡単に行えます。

  • サーバーエクスプローラで、[Azure]-[App Service]から発行したWebアプリ名を探し、右クリックメニューから[デバッカ―のアタッチ]を選択します。

f:id:pierre3:20160413233239p:plain

まとめ

これでひとまず、LINE BOT開発の環境が整いました。
さて、どんなBOTを作りましょうか?

Atomプラグイン plantuml-viewer で大きなUMLを扱った場合の問題と対処法

以前に紹介したAtomプラグインのplantuml-viewerについて。使っていて少々気になる点がありました。

atom.io

概ね問題なく快適に使えているのですが、変換するUMLのサイズが大きくなってくると
極端にレスポンスが悪くなったり、最後の入力結果が反映されなかったりすることがあります。

plantumlによる変換処理は、子プロセスに投げられてバックグラウンドで処理されるのですが、この部分の処理が以下の様になっているために、前述の問題が起きると考えられます。

  • 1文字入力する度に変換処理が走る(その結果、同時に実行されるプロセスが多くなる)
  • 結果が表示されるのは、処理の実行順ではなく完了した順番

シーケンス図で表すとこんな感じでしょうか

http://pierre3net.azurewebsites.net/Content/image/plantuml.svg

実際問題、プレビューを画像をここまで頻繁に更新する必要はないと思うので、「一定時間入力が無かったら更新する」といった、所謂 Throttle の動作にした方が良いのでは。。。 などと考えていたら、Githubにそのものズバリのプルリクエストが上がっていました。

[throttle image updates by KylePDavis · Pull Request #10 · markushedvall/plantuml-viewer · GitHub

これはグッジョブ!と、マージされるのを待っていたのですが一向にマージされる気配が無いので、ひとまず自分の環境だけにでも取り込んでしまいましょう!

plantuml-viewer の画像更新をThrottleにする

修正方法は、プルリクエストの内容を参照して頂く方が早いとは思いますが、ここでもざっくりと説明しておきます。

  • 修正するファイルは1つのみで、以下のパスにあります。
%USERPROFILE%\.atom\packages\plantuml-viewer\lib\plantuml-viewer-view.js
  • このファイルの PlantumlViewerView クラス内に、setTimeout()関数を使用して更新頻度を調整するqueueUpdate()関数を追加します。
function PlantumlViewerView (editor) {

  //...(略)...

  var updateImageTimerId = 0
  function queueUpdate () {
    if (updateImageTimerId) return
    updateImageTimerId = setTimeout(function () {
       updateImage()
       updateImageTimerId = 0
    }, 20)
  }
}
  • setTimeout()の第2引数に、待機時間を指定します。待機時間の間入力が無かった場合に更新処理(updateImage())が実行されます。プルリクエストのコードでは20ミリ秒となっていますが、少し短すぎるような気もします。この辺は実際に動かしながら調節すると良いでしょう。
  • 後は、既存のコードにある 画像更新処理関数updateImage() を作成した queueUpdate() 関数に置き換えるだけです。
// ファイル内のすべてのupdateImage()をqueueUpdate()に置き換える
//...(略)...
function attached () {
  disposables = new CompositeDisposable()
  //updateImage() 置き換え
  queueUpdate()
  if (atom.config.get('plantuml-viewer.liveUpdate')) {
    disposables.add(editor.getBuffer().onDidChange(function () {
      if (loading) {
        waitingToLoad = true
        return
      }
      //updateImage() 置き換え
      queueUpdate()
    }))

    interval = setInterval(function () {
      if (panZoom) {
        if (width !== self.width() || height !== self.height()) {
          //updateImage() 置き換え
          queueUpdate()
            width = self.width()
            height = self.height()
          }
        }
      }, 500)
    }
    //...(略)...

これで、より快適なplantumlライフが送れるようになりました!

Register-ArgumentCompleter で 入力補完機能を自作しよう!

これは、PowerShell Advent Calendar 2015 9日目の記事です。

atnd.org

目次

はじめに

以前、TabExpansion++ というモジュールを使うと、自前の入力補完機能を簡単に作ることが出来る、という内容の記事を書きました。

pierre3.hatenablog.com

この入力補完のカスタマイズ機能ですが、PowerShell v5.0 から標準で使えるようになり、より手軽に使用できるようになっていました。
それに伴い、Register-ArgumentCompleterというコマンドレットが追加されていて、このコマンドレットを実行するだけで、自前の入力補完機能が登録できるようになったようです。

今回は、このRegister-ArgumentCompleterコマンドレットを使って、自前の入力補完機能を実装してみたいと思います。

Register-ArgumentCompleter コマンドレット

まずは、Register-ArgumentCompleter コマンドレットに渡すパラメータを確認してみましょう。

Register-ArgumentCompleter `
    -CommandName (ターゲットコマンド名) `
    -ParameterName (ターゲットパラメータ名) `
    -ScriptBlock (入力候補一覧を作成するスクリプトブロック)
  • CommandName パラメータに、補完機能のを登録するコマンドの名前を指定します。
  • ParameterName パラメータに、補完対象となるパラメータの名前を指定します。
  • ScriptBlock パラメータに、独自の入力候補一覧を作成する処理を記述したスクリプトブロックを指定します。(詳細は後述します)

入力候補の一覧を生成するスクリプトブロック

ScriptBlock パラメータには、System.Management.Automation.CompletionResult オブジェクトをコレクションで返すスクリプトブロックを指定します。
また、スクリプトブロックには、5つのパラメータが渡ってきて、入力途中の文字列などが取得できるようになっています。

スクリプトブロックの概要をざっくりと書くと以下の様になります。

$createCompletionResults = {
    # 以下の5つのパラメータを受け取る。入力中の文字列は $wordToComplete に入ってくる。
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)
    
    $items = (入力候補のソースを取得)    

    # 入力候補の数だけ値を出力する
    foreach($item in $items)
    {
        $completionText = (決定時にパラメータ値として挿入される文字列);
        $listItemText = (入力候補の一覧に表示される文字列);
        $resultType = (入力候補のタイプ。候補の左側に表示されるアイコンを指定できる);
        $toolTip = (ツールチップに表示される文字列);
        
        # CompletionResult オブジェクトとして出力する
        [System.Management.Automation.CompletionResult]::new(
            $completionText,
            $listItemText,
            $resultType,
            $toolTip);   
    }
}

それでは、これを踏まえて実際に入力補完機能を作ってみましょう。

Invoke-Item コマンドレットの入力候補に「最近使った項目」の一覧を出す

Invoke-Item コマンドレットのPathパラメータ入力時に「最近使った項目」の一覧を出して、最近使ったファイルに素早くアクセスできるようにしたいと思います。

  • Invoke-Itemコマンドレットは、指定したファイルを実行したり、フォルダを開いたりするのに使用するコマンドレットです。
    エクスプローラ上で任意のファイルやフォルダをダブルクリックした時と同様の動作をします。

  • 「最近使った項目」は、%AppData%\Microsoft\Windows\Recent フォルダにショートカットとして保存されます。スタートメニューやタスクバーアイコンを右クリックすると表示される「最近使ったもの」一覧で利用されます。

f:id:pierre3:20151203225018p:plain

実装

実装したコードは、以下の通りです。
細かな部分はコード内のコメントと、後述の補足説明をご確認ください。

「最近使った項目」フォルダからショートカットファイルの一覧を取得する

「最近使った項目」(Recent)フォルダは、[System.Environment]::GetFolderPath() メソッドで取得できます。
取得したフォルダ以下のファイルは Get-ChildItem コマンドで取得しています。フォルダ名の下に\*.lnk を付けることで、ショートカットファイル(.lnk)のみを取得するようにしています。
取得したファイルの一覧は、更新日時の新しい順にソート後、先頭から表示したい件数分(ここでは50件)を取得しています。

# Recentフォルダから、最新50件のショートカット(*.lnk)ファイルを取得
$recentFolder = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::Recent);
$items = Get-ChildItem "$($recentFolder)\*.lnk" |
    Sort-Object -Property LastWriteTime -Descending | 
    Select-Object -First 50;

Pathを直接入力する場合にも対応する

「最近使った項目」の一覧から選択だけでなく、Pathを直接入力したい場合にも対応するため、スクリプトブロックの冒頭で以下のコードを入れています。

# "\" が含まれる場合は、通常のPath入力とみなし、独自の入力補完は行わない
# 例えば"C:\" と入力した場合など
if(([string]$wordToComplete).Contains("\") )
{ 
    return; 
}

こうすることで、".\" や、"C:\" などと入力した場合は 通常のPathを補完するダイアログが表示されるようになります。
スクリプトブロックが、なにも返さなかった場合は、通常の補完機能が動作することを利用しています。)

入力済みの文字列がマッチする項目だけを一覧に含める

パラメータに入力途中の文字がある状態で、補完機能が実行された場合、「補完一覧に表示する文字列」に「入力済みの文字列」がマッチする項目のみを一覧に含めるようにしています。

# 一覧に表示する文字列
$listItemText = Split-Path $shortcut.TargetPath -Leaf;
# 入力中の文字列$wordToCompleteがマッチしない項目はスキップ
if($listItemText -notmatch $wordToComplete)
{
    continue;
}

ショートカットの情報はWSHのCreateShortCut()で取得

どうやらショートカットファイルを操作するPowerShellのコマンドはないようなので ショートカットの参照元を取得するために、WSHの機能を使用しています。

$wsh = new-object -comobject WScript.Shell
$shortcut = $Wsh.CreateShortcut("path\to\shortcut");
# 参照元のパス
$shortcut.TargetPath;

実行結果

Invoke-Item の第1引数(-Path)で [Ctrl]+[Space](「最近使った項目」の一覧を表示)

f:id:pierre3:20151207220053p:plain

"ab" を入力後 [Ctrl]+ [Space] (「最近使った項目」で名前に "ab" を含むファイルのみ表示)

f:id:pierre3:20151207220232p:plain

"c:\" を入力 (c ドライブのアイテム一覧を表示)

f:id:pierre3:20151207220256p:plain

ちゃんと、期待通りの動きになっているようです。

まとめ

Register-ArgumentCompleterコマンドレットを使う事で、自前の入力補完機能が簡単に実装できることが分かりました。
この機能が必要となるケースはそれほど多くはないかもしれませんが、使い方によっては非常に有用な機能となり得るのではないでしょうか。

おまけ

Invoke-Item でファイルを開いても、「最近使った項目」が更新されない問題が発覚しました。
どうやら、エクスプローラや標準のファイル選択ダイアログを使わないと「最近使った項目」は更新されないようです。

これはあまり嬉しくないので、Invoke-Item コマンドを実行後に、「最近使った項目」を更新する処理を追加したコマンドレットを作って、これを使う様にしました。

function Invoke-ItemEx
{
    [CmdletBinding()]
    [Alias("iix")]
    Param
    (
        [Parameter(Mandatory=$true, Position=0)]
        [string]
        $Path
    )

    if(-not(Test-Path $Path))
    {
        Write-Error -Exception ([System.ArgumentException]::new(
            "存在するファイル名またはフォルダ名を指定してください","Path"));
        return;
    }

    Invoke-Item -Path  $Path;
    
    # ファイルのみ    
    if(Test-Path $Path -PathType Leaf)
    {
        $Shell = New-Object -ComObject Shell.Application;
        $Shell.AddToRecent($Path);
    }
}

Register-ArgumentCompleter -CommandName Invoke-ItemEx -ParameterName Path `
    -ScriptBlock $recentItemsCompletion
  • 「最近使った項目」への登録は Shell.Application の AddToRecent()を使います。
  • AddToRecent()に、アクセスしたファイルのPathを渡して実行すると、Recentフォルダに以下のショートカットが登録されます。
    • 引数に渡したファイルのショートカット
    • そのファイルを格納しているフォルダのショートカット
  • AddToRecent()にフォルダのPathを渡すと、その1階層上のフォルダのショートカットが登録されてしまいます。
    • これは期待する動作ではないので、AddToRecent()にはファイルのPathのみを渡すようにしました。

UWPのContentDialog や Pageの イベントを {x:Bind } でバインドするとXAML内部エラーになる

UWP のプログラミング中に、以下のエラーに遭遇したのでメモしておきます。

1>C:\Program Files (x86)\MSBuild\Microsoft\WindowsXaml\v14.0\8.2\Microsoft.Windows.UI.Xaml.Common.targets (263,5): Xaml 内部エラー error WMC9999: オブジェクト参照がオブジェクト インスタンスに設定されていません。

ContentDialog の PrimaryButtonClick イベントを{x:Bind }でバインドしたらビルドエラーになった。

次のXAMLで、PrimaryButtonClick="ContentDialog_PrimaryButtonClick" の部分を以下のどちらかに置き換えると、ビルドエラーで上記メッセージが表示されます。

  • PrimaryButtonClick="{x:Bind ViewModel.Execute}" (ViewModel のメソッド)
  • PrimaryButtonClick="{x:Bind ContentDialog_PrimaryButtonClick}" (コードビハインドのイベントハンドラ)

また、これと同じメソッドバインディングを Dialog内に配置したボタンで行った場合は問題ありませんでした。

  • Button Click="{x:Bind ViewModel.Execute}"
<ContentDialog
    x:Class="TestApp1.Views.ContentDialog1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:TestApp1"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Title="TITLE"
    PrimaryButtonText="Button1"
    SecondaryButtonText="Button2"
    PrimaryButtonClick="ContentDialog_PrimaryButtonClick">

    <!-- {x:Bind} を使うと Xaml 内部エラー WMC9999
        PrimaryButtonClick="{x:Bind ViewModel.Execute}"
        PrimaryButtonClick="{x:Bind ContentDialog_PrimaryButtonClick}"
    -->
    <!-- コマンドのバインディングはOK
        PrimaryButtonCommand="{x:Bind ViewModel.TestCommand}"    
    -->

    <Grid>
        <!-- こちらのボタンでは {x:Bind}でも問題なし-->
        <Button Click="{x:Bind ViewModel.Execute}">Content Button</Button>
    </Grid>
</ContentDialog>

また、ContentDialog だけでなく、Pageでも同様のエラーが発生しました。

<Page
    x:Class="TestApp1.Views.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:TestApp1"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    PointerPressed="Page_PointerPressed" >
    <!-- {x:Bind} を使うと Xaml 内部エラー WMC9999
        PointerPressed="{x:Bind Page_PointerPressed}" 
    -->
    
    <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
        <!-- こっちの{x:Bind} はOK -->
        <Button Content="Show Dialog" HorizontalAlignment="Center"
                Click="{x:Bind Button_Click}"/>
    </Grid>
</Page>

試した環境

問題の起きるサンプルコードを以下に置いておきます
https://github.com/pierre3/UWPTestApp

PowerShellでEtwStream

EtwStream.PowerShell

neuecc/EtwStreamPowerShellから使えるようにしてみました。

github.com

Get-TraceEventStream Cmdlet

現在は、このコマンドのみ実装されています。

Get-TraceEventStream [-NameOrGuid] <string> [-DumpWithColor]
[-TraceLevel <TraceEventLevel> {
    Always
    | Critical
    | Error
    | Warning
    | Informational
    | Verbose
}] [<CommonParameters>]

Get-TraceEventStream [-WellKnownEventSource] <string> {
    AspNetEventSource
    | ConcurrentCollectionsEventSource
    | FrameworkEventSource
    | PinnableBufferCacheEventSource
    | PlinqEventSource
    | SqlEventSource
    | SynchronizationEventSource
    | TplEventSource}
[-DumpWithColor] [-TraceLevel <TraceEventLevel> {
    Always
    | Critical
    | Error
    | Warning
    | Informational
    | Verbose}]  [<CommonParameters>]

Object pipeline

出力は、オブジェクトで流れてきますので、PowerShellのコマンドを使ったフィルタリングや加工も可能です。

出力されるオブジェクトは、PSTraceEvent というMicrosoft.Diagnostics.Tracing.TraceEvent をラップした独自クラスとしています。
EtwStream で実装されている TraceEventの拡張メソッドが使えるようになっています。

public string Dump(bool includePrettyPrint = false, bool truncateDump = false);
public void DumpPayload();
public string DumpPayloadOrMessage();
public ConsoleColor? GetColorMap(bool isBackgroundWhite);
public void DumpWithColor(bool withProviderName = false, bool withProcesName = false);

PSEventSource

DumpWithColor Switch

DumpWithColor スイッチで、色付きの簡易ビューアになります。

PS C:\> Get-TraceEventStream -NameOrGuid SampleEventSource -DumpWithColor

DumpWithColor

WellKnownEventSource

WellKownEventSource パラメータで、既知のEventSource のGUID を一覧から指定できます。

WellKnownEventSource

View in GridView-Window

Out-GridView に流すと、GUI上でソートやフィルタリングが自在にできるリッチなEventビューワにもなります。

PS C:\> Get-TraceEventStream SampleEventSource | Out-GridView

EtwStreamPS_Out-GridView_filter.png

EtwStreamPS_Out-GridView_filter2.png

実装

コマンドレットの実装は、こちらの記事を参考にさせて頂きました。

tech.tanaka733.net

参考というか、コードのベース部分は、ほぼそのまま流用させて頂きました。

変更したのは、ソースのIObservableをEtwStream.ObservableEventListener.FromTraceEvent() で取得するようにした事と、 StopProcessing() メソッドをオーバーライドして Ctrl+Break による中断に対応した事位です。

まとめ

ブログのネタになればと、試しに作ってみましたが、予想以上に使えそうな予感がします。

LINQPadのライセンスが無かったり、そもそも職場でフリーソフトのインストールが制限されていて使えない!といったような方の代替手段として使えるかも??

ETW/EventSource によるロギングを試してみる

最近、以下の記事を拝見しました。

neue cc - EtwStream - ETW/EventSourceのRx化 + ビューアーとしてのLINQPad統合

正直、EtwやEventSourceというものに馴染みが無かったのですが、これからは必須の技術となりそうですね。

さて、EventSourceですが、いきなり構造化ログってのもかなりダルいので、まずは非構造化ログをEventSourceで実現するところから初めてみましょう。

という事で、前回の記事で書いた、ScheduledNotifierをベースとした自作のLogger、LogMessageNotifierをEventSourceに置き換えることから初めてみたいと思います。

LogMessageNotifierをEventSourceに変更する

今回は、Logging用のEventSourceに 冒頭の記事の中で掲載されていたLoggerEventSource クラスのコードをそのまま利用させて頂く事とします。

LoggerEventSourceクラスのメソッドをそのまま呼び出しても良いのですが、既存のコードでは、Model側はILoggerインターフェースを通じてLogの書き込みを行っていました。
ひとまずはこの部分は変えずに、LoggerEventSource をILoggger でラップすることで対応します。

using ReactiveBingViewer.Diagnostics;
using System;

namespace ReactiveBingViewer.Notifiers
{
    /// <summary>
    /// Logメッセージ通知オブジェクト
    /// </summary>
    public class EventSourceLogger : ILogger
    {

        public LoggerEventSource EventSource { get; private set; }
        public EventSourceLogger(LoggerEventSource eventSource =null)
        {
            EventSource = eventSource?? LoggerEventSource.Log;
        }

        public void Trace(string message) => EventSource.Verbose(message);
        public void Debug(string message) => EventSource.Debug(message);
        public void Info(string message) => EventSource.Informational(message);
        public void Warn(string message) => EventSource.Warning(message);
        public void Error(string message) => EventSource.Error(message);
        public void Fatal(string message) => EventSource.Critical(message);
        public void Trace(string message, Exception e)
        {
            Trace(message);
            LogException(e);
        }
        public void Debug(string message, Exception e)
        {
            Debug(message);
            LogException(e);
        }
        public void Info(string message, Exception e)
        {
            Info(message);
            LogException(e);
        }
        public void Warn(string message, Exception e)
        {
            Warn(message);
            LogException(e);
        }
        public void Error(string message, Exception e)
        {
            Error(message);
            LogException(e);
        }
        public void Fatal(string message, Exception e)
        {
            Fatal(message);
            LogException(e);
        }


        private void LogException(Exception e)
        {
            EventSource.Exception(e.GetType().FullName, e.StackTrace.ToString(), e.Message);
        }
    }
}

ILoggerをそのまま温存したので Model内でのlogの書き込み部分は変更する必要がありません。

後は、ViewModel のコンストラクタで行っている ReactiveProperty を初期化している部分をEventSourceの型に合わせてほんの少し修正するだけで済みそうです。

public class MainWindowViewModel
{
    //private LogMessageNotifier logger = new LogMessageNotifier();
    private EventSourceLogger logger = new EventSourceLogger();

    public MainWindowViewModel()
    {
        //Modelのコンストラクタに logger を渡す
        webImageStore = new WebImageStore(App.BingApiAccountKey,
            App.VisionApiSubscriptionKey, logger).AddTo(disposables);        
        
        var logListner = EtwStream.ObservableEventListener.FromEventSource(logger.EventSource);

        ////Logファイルに書き出し
        System.Diagnostics.Trace.Listeners.Add(new System.Diagnostics.TextWriterTraceListener("log.txt"));
        //logger.Subscribe(log =>
        logListner.Subscribe(log =>
        {
            System.Diagnostics.Trace.WriteLine(log);
            System.Diagnostics.Trace.Flush();
        }).AddTo(disposables);

        ////Infoレベルのログはステータスバーに表示
        //StatusMessage = logger.Where(x => x.Level == LogLevel.Info)
        //    .Select(x => x.Message)
        //    .ToReadOnlyReactiveProperty();
        StatusMessage = logListner.Where(x => x.Level == System.Diagnostics.Tracing.EventLevel.Informational)
            .Select(x => x.Payload.First().ToString())
            .ToReadOnlyReactiveProperty();

        ////Warn レベル以上 は エラーリストに表示
        ClearErrorLogsCommand = new ReactiveCommand();  //リストクリア用
        //ErrorLogs = logger
        //    .Where(x => x.Level >= LogLevel.Warn)
        //    .ToReadOnlyReactiveCollection(ClearErrorLogsCommand.ToUnit());
        ErrorLogs = logListner.Where(x => (int)x.Level <= (int)System.Diagnostics.Tracing.EventLevel.Warning)
            .Select(x=>$"[{x.Level}]{x.Payload[0]} ({x.Payload[2]})")
            .ToReadOnlyReactiveCollection(ClearErrorLogsCommand.ToUnit());

        ////エラーリスト表示切替。 ErrorLogs にアイテムがある場合のみ表示する
        ErrorLogsVisibility = ErrorLogs
            .CollectionChangedAsObservable()
            .Select(_ => (ErrorLogs.Count > 0) ? Visibility.Visible : Visibility.Collapsed)
            .ToReactiveProperty(Visibility.Collapsed);
    }
}

これで実行してみます。

f:id:pierre3:20151105054819p:plain

おお、ちゃんと動きました。

EtwStream で(超簡易版)Etwビューアを作ってみる

後は、EventSourceとしたことでEtwStream を使えばプロセス外からも参照できるはず。早速LINQPadで...
と思ったのですが無償版ではEtwStream.LingPad をNuGetできないのですね。

幸い、ベースライブラリである EtwStream.Core は 通常の.Netプロジェクトに入れられるようですので、これを利用して簡易ビューアを作ってみたいと思います。

  • WPFアプリケーションで作ります。
  • NuGetでEtwStream とReactiveProperty をインストールします。
PM> Install-Package EtwStream
PM> Install-Package ReactiveProperty
  • ViewModel を定義します。

ObservableEventListener.FromTraceEvent() で 取得したIObservable<TraceEvent> をReactiveCollection に変換します。

using EtwStream;
using Reactive.Bindings;
using System.Linq;
using System.Reactive.Linq;

namespace EtwStreamViewer 
{
    public class MainWindowViewModel
    {
        public ReadOnlyReactiveCollection<string> LogEvents { private set; get; }
        public MainWindowViewModel()
        {
            LogEvents = ObservableEventListener.FromTraceEvent("LoggerEventSource")
                .Select(x =>$"[{x.TimeStamp}][{x.Level}]: {x.FormattedMessage}" )
                .ToReadOnlyReactiveCollection();
        }
    }
}
  • XAML を定義します。

WindowにListBoxを追加して、ReactiveCollection をバインドします。

<Window x:Class="EtwStreamViewer.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:EtwStreamViewer"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">

    <Grid>
        <ListBox ItemsSource="{Binding LogEvents}"></ListBox>
    </Grid>
</Window>

これで準備OK!さっそく実行してみましょう。

f:id:pierre3:20151105070059p:plain

ちゃんと 先ほど修正した ReactiveBingViewer のEvent が補足されています。(すごい!)

ちゃんと作り込んだら本格的なeventビユーワも作れそうですね。

まとめ

いつもにも増してまとまりのない記事になってしまいましたが、取り急ぎお試ししてみました!ということでご容赦下さい。

WPFでReactiveProperty入門 ~アプリケーションのステータスやエラー情報をIObservable で通知する

この記事は、WPFでReactiveProperty入門 ~Bing画像検索ビューアを作る - pierre3のブログ の続編です。

関連記事

  1. WPFでReactiveProperty入門 ~Bing画像検索ビューアを作る - pierre3のブログ
  2. WPFでReactiveProperty入門 ~Bing画像検索ビューアを作る (1. 検索バーの実装) - pierre3のブログ
  3. WPFでReactiveProperty入門 ~ Rxを使って検索結果のサムネイル画像を一括ダウンロードする - pierre3のブログ

(番外編)

サンプルプロジェクト github.com

目次

アプリケーションのステータスやエラーの情報の通知と記録

ReactiveBingViewer(この記事のサンプルアプリ)では、アプリケーション内のステータス情報の通知と記録を以下の通りに行います。

  • ステータスバー
    アプリケーションの現在の状況(「検索中...」や「検索が完了しました」などのメッセージ)をWindow下部のステータスバーに表示します。

f:id:pierre3:20151102111719p:plain

  • エラー通知パネル
    アプリケーション実行中の例外を補足した場合、以下のような通知パネルに例外の内容を表示します。 この通知パネルは、表示する例外メッセージが存在する場合のみ表示されます。右上の[×]ボタンをクリックするとパネル内のメッセージがクリアされてパネル自体も非表示となります。

f:id:pierre3:20151030212912p:plain

  • Logファイルへの記録
    上記通知を含め、アプリケーション内で発生したイベントはLog情報としてファイルへ記録します。

f:id:pierre3:20151029213955p:plain

ModelからViewModelへ ~ IObservable<LogMessage> による通知

LogMessageクラス

Model側で発生したステータスおよびエラー情報(以降まとめてLog情報と呼びます)は、LogMessage オブジェクトに格納されてIObservable<LogMessage> に乗ってViewModelへ通知されます。

LogMessage の定義は以下の通りです。

public class LogMessage
{
    //作成時刻
    public DateTime CreatedAt { get; }
    //メッセージ
    public string Message { get; }
    //Logレベル(Trace < Debug < Info < Warn < Error < Fatal )
    public LogLevel Level { get; }
    //例外オブジェクト
    public Exception Exception { get; }
    //例外オブジェクトの有無
    public bool HasError { get { return Exception != null; } }
    
    public LogMessage(string message, LogLevel level, Exception e = null)
    {
        CreatedAt = DateTime.Now;
        Message = message;
        Level = level;
        Exception = e;
    }

    public override string ToString()
    {
        return (HasError)?
            string.Format("{0}:[{1}] {2} [{3}]", Level.Name, CreatedAt.ToString("G"), Message, Exception.Message) :
            string.Format("{0}:[{1}] {2}", Level.Name, CreatedAt.ToString("G"), Message);   
    }
}

LogMessageNotifierクラス

実際にLog情報の通知を行う、LogMessageNotifier クラスを定義します

  • ScheduledNotifier<T> を継承して、IObservable<T> による通知を行います

ScheduledNotifier<T> は Reactive.Bingings.Notifiers 名前空間に含まれるReactiveProperty のクラスの1つで、IObservable<T> による値の通知を行います。

ScheduledNotifier<T> を含む Notifier系クラスの詳細は ReactivePropertyのNotifier系クラス in C# for Visual Studio 2013 を参照ください。

  • ILoggerインターフェースを実装します。Model側は、このインターフェースのメソッドを使用して通知を行います。
public class LogMessageNotifier : ScheduledNotifier<LogMessage>, ILogger
{
    public LogMessageNotifier(IScheduler scheduler) : base(scheduler) { }
    public LogMessageNotifier() : base() { }

    public void Trace(string message) => Report(LogMessage.CreateTraceLog(message));
    public void Debug(string message) => Report(LogMessage.CreateDebugLog(message));
    public void Info(string message) => Report(LogMessage.CreateInfoLog(message));
    public void Warn(string message) => Report(LogMessage.CreateWarnLog(message));
    public void Error(string message) => Report(LogMessage.CreateErrorLog(message));
    public void Fatal(string message) => Report(LogMessage.CreateFatalLog(message));
    public void Trace(string message, Exception e) => Report(LogMessage.CreateTraceLog(message, e));
    public void Debug(string message, Exception e) => Report(LogMessage.CreateDebugLog(message, e));
    public void Info(string message, Exception e) => Report(LogMessage.CreateInfoLog(message, e));
    public void Warn(string message, Exception e) => Report(LogMessage.CreateWarnLog(message, e));
    public void Error(string message, Exception e) => Report(LogMessage.CreateErrorLog(message, e));
    public void Fatal(string message, Exception e) => Report(LogMessage.CreateFatalLog(message, e));}
}
public interface ILogger
{
    void Trace(string message);
    void Debug(string message);
    void Info(string message);
    void Warn(string message);
    void Error(string message);
    void Fatal(string message);
    void Trace(string message,Exception e);
    void Debug(string message, Exception e);
    void Info(string message, Exception e);
    void Warn(string message, Exception e);
    void Error(string message, Exception e);
    void Fatal(string message, Exception e);
}
public class MainWindowViewModel : IDisposable
{
    private LogMessageNotifier logger = new LogMessageNotifier();
    private WebImageStore webImageStore;
    //...
    public MainWindowViewModel()
    {
        //ModelはILoggerインターフェースを受け付ける
        webImageStore = new WebImageStore(logger);
    }    
}

ViewModelからViewへ ~ IObservable<LogMessage> をReactiveProperty に変換

ViewModelでは、LogMessageNotifier を用途に合わせて以下の様に使用します。

  • ステータス情報(StatusMessage プロパティ、ステータスバーに表示)
    LogMessageNotifier を、 LogLevel.Info のメッセージのみを通すReactiveProperty に変換します
  • エラー情報 (ErrorLogs プロパティ。エラー通知パネルに表示)
    LogMessageNotifier を、 LogLevel.Warn 以上のメッセージを保持する ReactiveCollection に変換します
  • Log情報 (Logファイルに出力)
    LogMessageNotifier を購読(Subscribe) して、OnNext()でLogファイルへの書き込みを行います
public class MainWindowViewModel : IDisposable
{
    private CompositeDisposable disposables = new CompositeDisposable();
    private LogMessageNotifier logger = new LogMessageNotifier();
    private WebImageStore webImageStore;

    // ステータスバー・メッセージ
    public ReadOnlyReactiveProperty<LogMessage> StatusMessage { get; private set; }
    // エラー・メッセージ
    public ReadOnlyReactiveCollection<LogMessage> ErrorLogs { get; private set; }
    // エラー表示状態
    public ReactiveProperty<Visibility> ErrorLogsVisibility { get; private set; }
    // エラー表示クリア用コマンド
    public ReactiveCommand ClearErrorLogsCommand { get; private set; }

    public MainWindowViewModel()
    {
        //WebImageStoreのコンストラクタにLogMessageNotifierのインスタンスを渡す
        webImageStore = new WebImageStore(logger);
        
        //Logファイルの設定。今回はTextWriterTraceListenerで書き込む
        System.Diagnostics.Trace.Listeners.Add(
            new System.Diagnostics.TextWriterTraceListener("log.txt"));
        
        //Logファイルに書き込み        
        logger.Subscribe(log =>
        {
            System.Diagnostics.Trace.WriteLine(log);
            System.Diagnostics.Trace.Flush();
        }).AddTo(disposables);

        //Infoレベルのログはステータスバーに表示
        StatusMessage = logger.Where(x => x.Level == LogLevel.Info)
            .Select(x => x.Message)
            .ToReadOnlyReactiveProperty();
            

        //エラー通知クリア用コマンド
        ClearErrorLogsCommand = new ReactiveCommand();
        //Warn レベル以上のLogメッセージをReactiveCollectionに格納する
        ErrorLogs = logger
            .Where(x => x.Level >= LogLevel.Warn)
            .ToReadOnlyReactiveCollection(ClearErrorLogsCommand.ToUnit());

        //エラーリスト表示切替。 ErrorLogs にアイテムがある場合のみ表示する
        ErrorLogsVisibility = ErrorLogs
            .CollectionChangedAsObservable()
            .Select(_ => (ErrorLogs.Count > 0) ? Visibility.Visible : Visibility.Collapsed)
            .ToReactiveProperty(Visibility.Collapsed);
    }
}

エラー通知パネルのクリアと表示切替

エラー通知メッセージのクリアは、専用のReactiveCommand を実装して行います。

ReactiveCollection 内のアイテムをクリアする場合、IObservableから ReactiveCollection への変換を行うToReactiveCollection() または ToReadonlyReactiveCollection()の引数に ReactiveCommand.ToUnit() を渡すだけでクリア処理を実装することが出来ます。

//エラー通知クリア用コマンド
ClearErrorLogsCommand = new ReactiveCommand();
//Warn レベル以上のLogメッセージをReactiveCollectionに格納する
ErrorLogs = logger
    .Where(x => x.Level >= LogLevel.Warn)
    .ToReadOnlyReactiveCollection(ClearErrorLogsCommand.ToUnit());

また、エラー通知パネルに表示するメッセージが無い場合に、通知パネル自体を非表示とするために、以下の処理を実装しています。

  • エラー通知用コレクションのCollectionChangedイベントをIObservableで取得
  • Select()メソッドで、コレクションのアイテム数が1以上なら System.Windows.VIsibility.VIsible を、0ならSystem.Windows.Visibility.Collapsed を返すように変換する
  • これを ReactivePropertyに変換して、View側の通知パネルにバインドする
//エラーリスト表示切替。 ErrorLogs にアイテムがある場合のみ表示する
ErrorLogsVisibility = ErrorLogs
    .CollectionChangedAsObservable()
    .Select(_ => (ErrorLogs.Count > 0) ? Visibility.Visible : Visibility.Collapsed)
    .ToReactiveProperty(Visibility.Collapsed);

View の定義

該当部分のXAMLを以下に示します。

<!-- ステータスバー -->
<StatusBar DockPanel.Dock="Bottom">
    <StatusBarItem DockPanel.Dock="Right">
        <!-- プログレスパー (省略) -->
    </StatusBarItem>
    <Separator DockPanel.Dock="Right" />
    <StatusBarItem>
        <!-- ステータスメッセージ -->
        <TextBlock Text="{Binding StatusMessage.Value,Mode=OneWay}"/>
    </StatusBarItem>
</StatusBar>

<!-- エラー通知 -->
<Grid DockPanel.Dock="Bottom" Margin="4" Visibility="{Binding ErrorLogsVisibility.Value}">
    <Grid.ColumnDefinitions>
        <ColumnDefinition/>
        <ColumnDefinition Width="Auto"/>
    </Grid.ColumnDefinitions>
    <Rectangle Grid.ColumnSpan="2" Stroke="Chocolate" Fill="LightYellow"/>
    <!-- エラーメッセージ一覧 -->
    <ListBox  Grid.Row="3" ItemsSource="{Binding ErrorLogs}" MaxHeight="120" Margin="0,0,4,0" 
              Background="Transparent" BorderBrush="{x:Null}"/>
    <Button Grid.Column="1" Margin="2" VerticalAlignment="Top" HorizontalAlignment="Right"
            Command="{Binding ClearErrorLogsCommand}" Background="Transparent" 
            FontFamily="Segoe UI Symbol">&#xE10A;</Button>
</Grid>

ソースコード

今回の記事に関連するソースコードの一覧です。
処理の詳細は、こちらをご参照ください。

まとめ

Notifier による通知と、ReactiveProperty を組み合わせることで、Modelから通知されるLog情報を、目的に合わせて柔軟かつスッキリと記述できるようになったと思います。