LINE BOT Webhooks の署名検証をC#で実装する

LINE BOT 開発では、ユーザーが送信したメッセージなどのイベントは、指定したURLにHTTPSのPOSTリクエストとして送信されますが、リクエストの送信元が間違いなくLINEからのものであることを確認する必要があります。

LINE API Referenceには以下の様に書かれています。

Signature validation

リクエストの送信元がLINEであることを確認するために署名検証を行わなくてはなりません。 各リクエストには X-Line-Signature ヘッダが付与されています。 X-Line-Signature ヘッダの値と、request body と Channel secret から計算した signature が同じものであることをリクエストごとに 必ず検証してください。

検証は以下の手順で行います。

  1. Channel secretを秘密鍵として、HMAC-SHA256アルゴリズムによりrequest bodyのダイジェスト値を得る。
  2. ダイジェスト値をBASE64エンコードした文字列が、request headerに付与されたsignatureと一致することを確認する。

今回は、これをC#で実装してみましょう。

必要なデータは以下の3つです。

  • リクエストのX-Line-Signature ヘッダに格納されている値
  • リクエストBODYの値
  • Bot アカウントに付与されるChannel Secret(Line DeveloppersのChannels>>Basic Informationで確認できます)

これらを引数にして検証を行う関数を作ります。

using System.Security.Cryptography;
using System.Text;

public bool VerifySignature(string xLineSignature, string requestBody, string channelSecret)
{
    try
    {
        //channnel secret と request body をByte配列に変換する
        var key = Encoding.UTF8.GetBytes(channelSecret);
        var body = Encoding.UTF8.GetBytes(requestBody);
        
        //channel secretをキーにしてHMAC-SHA256アルゴリズムでrequest bodyのダイジェスト値を得る
        using (HMACSHA256 hmac = new HMACSHA256(key))
        {
            var hash = hmac.ComputeHash(body, 0, body.Length);
            //ダイジェスト値をBASE64に変換
            var hash64 = Convert.ToBase64String(hash);
            //X-LINE-Signatureヘッダの値と一致すればOK!!
            return xLineSignature == hash64;
        }
    }
    catch
    {
        return false;
    }
}

これを、Azure Functions(HttpTrigger)で使用する例を以下に示します。

using System.Configuration;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;

public static async Task<HttpResponseMessage> Run(HttpRequestMessage req, TraceWriter log)
{
    //Channel Secretの取得。Channel SecretはAzure ポータルでアプリケーション設定に登録しておきます。
    var channelSecret = ConfigurationManager.AppSettings["ChannelSecret"]);
    //Request Body の取得
    var contentJson = await req.Content.ReadAsStringAsync();
    //X-Line-Signatureヘッダ値の取得
    var xLineSignature = req.Headers.GetValues("X-Line-Signature").FirstOrDefault()
        
    if (string.IsNullOrEmpty(xLineSignature) ||
        !VerifySignature(xLineSignature, contentJson, channelSecret))
    {
        //検証に失敗
        return req.CreateResponse(HttpStatusCode.Forbidden, new { Message = "Signature validation faild." });
    }

    //BOTでやりたい処理...
}

LINEでビンゴゲームができるBOTを作りました

ビンゴゲームの基本機能をASP.NETのWeb APIで、LINEとの連携部分(BOT)をAzure Functionsで作りました。

例によってソースコードGitHubにて公開していますので、覗いてみてください。

github.com

BINGO Web API

ビンゴゲームの基本的な機能は、Azure App ServiceでホストしたASP.NET CoreのWeb APIで提供します。

次のような、ごく基本的な機能のみをREST APIで提供しています。

  • ゲームを作成する
  • ゲームにカードを追加する
  • 番号を引いてゲームを進める
  • 現在のゲームの状態を取得する
  • 現在のカードの状態を取得する

詳しくは、Swaggerさんが生成してくれたAPIリファレンスをご確認ください。 http://bingowebapi.azurewebsites.net/swagger/

LINE BOT Function

LINE BOT 関連の機能は、Azure Functionsで処理しています。

主に次のような処理を行っています

  • LINEユーザーからのメッセージを受け取る
  • メッセージの内容に応じてゲームの開始、参加、進行などを行う。
  • 状況に応じたBINGO Web APIを呼び出す。
  • ゲームの進行状況、その他メッセージを LINEユーザーに送信する

開発環境、Frameworkとか

BINGO WebAPI

Web APIの開発にはVisual Studio 2017を使用しています。
ASP.NET Core Webアプリケーション」のプロジェクトテンプレートを使用して作成しました。

DB関連はAzure SQL Server + EntityFramework Coreを使用しています。

LINE BOT Function

Azure Function Tools for Visual Studio 2017

LINE BOTの開発には,「Azure Function Tools for Visual Studio 2017」というツールを使用しています。

このツールを使用するとAzure Functions の開発がとてもに楽になります。

※ このツールは(2017年7月現在では) プレビュー版のVisual Studio (Visual Studio 2017 Preview(2))でないとインストールできないようです。 お試しする場合は、ご注意を。

Azure Table Strage

ゲームに参加しているLINEのユーザー情報と、BINGO APIの発行するゲームID、カードIDとの紐づけなどの情報はAzure Table Strageに保存しています。

遊び方

LINE BOTを友達登録します

BINGO BOTというアカウントを友達登録するとお試しで遊ぶことができます。

BINGO BOT

友だち追加

※ ただし、開発中のお試し版であることをご承知の上でご利用ください。 また、予告なくサービスが停止したり、仕様が変更されたりする場合がありますので予めご了承ください。

ゲームオーナーとプレーヤー

BINGOゲームを始めるには、ゲームを作成・進行するゲームオーナー1人と、ゲームに参加するプレーヤー(複数人)が必要です。

ゲームオーナーの操作

ゲームを作成する

まずは、ゲームオーナーがゲームを作成します。
やり方は簡単で、BINGO BOT に「0」または「開始」と送るだけです。

合言葉を決める

するとLINE BOTから、合言葉の設定を促すメッセージが帰ってきます。 合言葉は、プレーヤーがゲームに参加するために必要なキーワードです。
20文字まで好きな言葉を返信してください。 合言葉を設定しないこともできます。(その場合は「なし」と返します)

ゲームIDと合言葉をプレーヤーに伝える

合言葉を送信すると、ゲームが開始されLINE BOT からゲームIDが返信されます。
このゲームIDと、指定した合言葉を参加するプレーヤーに伝えて、入力してもらいます。

番号を引く

あとは、適当な文字をBINGO BOT に送信するだけです。 1回送信するたびに1つ番号を引きます。

ゲームを終了する

ゲーム進行中に「終了」と入力するとゲームを終了できます。(終了するまで次のゲームを遊ぶことができませんので注意)

f:id:pierre3:20170718230822p:plain f:id:pierre3:20170718231038p:plain f:id:pierre3:20170718231049p:plain f:id:pierre3:20170718231102p:plain

プレーヤーの操作

ゲームに参加する

ゲームに参加するには、BINGO BOTに「1」または「参加」と送ります。

ゲームIDと合言葉を入力する

するとLINE BOTからゲームIDと合言葉を入力するよう促すメッセージが返ってくるので、ゲームオーナーから伝えられたそれらを入力します。

カードを取得する

ゲームへの参加に成功すると、BINGOカードが返信されます。
あとは、ゲームオーナーが番号を引くたびに、BINGO BOTから引いた番号が通知されます。
ゲーム進行中、何かメッセージを送ると、ヒットした番号を反映した現在のカードが取得できます。

f:id:pierre3:20170718231127p:plain f:id:pierre3:20170718231150p:plain f:id:pierre3:20170718231201p:plain f:id:pierre3:20170718231205p:plain f:id:pierre3:20170718231213p:plain

まとめ

まだまだ作り込みが足りていないので、もうしばらくはこいつの開発で楽しめそうです。
とりあえず、BINGOカードは画像にしたいですね。  

あと、今後は開発中にハマったこととか、調べたことなどTIPS的な小ネタを記事にできればいいな。

Azure App Service の継続的配信(プレビュー)を試してみました

Azure App Service の継続的配信(プレビュー)

最近あまり触れていなかったASP.NET系の技術にそろそろじっくり取り組んでみようかな、と思う今日この頃。
ASP.NET Core & Azure App Service で何か作ろうとAzureポータルを触っていると、App Serviceのメニューに「継続的配信(プレビュー)」なる項目を見つけました。
今回はこれを試してみたいと思います。

f:id:pierre3:20170402083040p:plain

どんな機能か

Visual Studio Team Service (VSTS)を利用して、Azure App Serviceへの継続的配信(Continuous Delivery)を行うためのワークフローを構成してくれる機能です。

これを使用することで、GitのリポジトリにコードをPushするだけでVSTSのビルドタスクが走り、コードの取得→ビルド→Unitテスト→デプロイ といった一連の流れを自動化できるようになります。

配信元のソースはVSTSのGit リポジトリ以外にもGitHubリポジトリが指定できます。(その他、GitやSVNのプライベートなリポジトリを指定することもできるようです。)

事前準備

では、早速試してみましょう。今回はGitHubリポジトリと連携させてみます。

サンプルアプリケーション

今回サンプルとして使用するアプリケーションは、Visual Studio 2017のASP.NET Coreのプロジェクトテンプレートを使用して作成したものを使用します。

f:id:pierre3:20170402093448p:plain

  • [ファイル]-[新規作成]-[プロジェクト]で「ASP.NET Core Webアプリケーション(.NET Core)」を選択します。
  • ターゲットフレームワークに「ASP.NET Core 1.1」、テンプレートは「Web API」としました。
  • 「Dockerサポートを有効にする」なるチェックボックスがありますが、今回はOFFで作成します。

こうして作成したWebAPIのアプリケーションを、そのままGitHubに上げておきます。

github.com

VSTSでプロジェクトを作成

Visual Studio Team Service(VSTS)で、このアプリケーション用のプロジェクトを予め作成しておく必要があります。

VSTSのアカウントがない場合は、以下で作っておきましょう。

www.visualstudio.com

アカウントの準備ができたら、プロジェクトを作成しておきます。ここではとりあえずプロジェクト名を決めて作成するだけでOKです。

Azure App Serviceを作成

Azureポータルで、アプリケーションを配置するApp Serviceを作成しておきます。
ここで注意が必要なのは、今回の継続的配信機能を使用するためには、Standerd(S1)以上のサービスプランを選択する必要があるという点です。  
Freeプラン(F1)では使用できないので気を付けてください。

継続的配信(プレビュー)の設定

これで下準備は整いました。早速Azureポータルで「継続的配信(プレビュー)」を使ってみましょう。

詳しい手順は、以下のページ(英語)に掲載されていますので、こちらを参考に進めて頂ければ問題ないと思います。

www.visualstudio.com

動かしてみよう

これで必要な設定はすべて完了し、GitHubにPushするだけで最新のアプリケーションがApp Serviceに配置されるはずです。

はずなのですが・・・

ビルドに失敗します

GitHubへのPushをトリガーにVSTSのビルドタスクが動くところまでは問題なかったのですが、そのままではビルド自体が失敗してしまっていました。

VSTSのサイトで、今回のプロジェクトを開いてビルドタスクの設定を確認してみます。
ビルドタスクの設定は「Build&Release」タブで確認できます。

f:id:pierre3:20170402175617p:plain

  • ビルド設定名部分のリンクをクリックして

f:id:pierre3:20170402175631p:plain

  • Editをクリックすると、以下のようなビルドタスクの設定画面が表示されます。

f:id:pierre3:20170402180140p:plain

ビルドタスクの設定を見直す

既定では、ASP.NET Core(PREVIEW)というテンプレートが使用されるようなのですが、そのままではうまくいきませんでした。
更に、各タスクの設定等を変えていろいろ試してもみたのですが、解決しませんでした。

そこで、ASP.NET Core(PREVIEW)のテンプレートを使うのは止めて、以下のページに掲載されている内容で試してみたところビルドが通るようになりました。

www.visualstudio.com

「Get Sources」を除く既定のタスクを全て削除し、「+Add Task」で新しいタスクに置き換えていきます。

以下に設定内容のスクリーンキャプチャを載せておきます。

f:id:pierre3:20170402211628p:plain

f:id:pierre3:20170402211643p:plain

  • Build Solution
    Visual Studio Buildタスクを実行します。

f:id:pierre3:20170402211659p:plain

  • Test Assemblies
    対象のリポジトリにテストプロジェクトが含まれる場合にテストを実行します。

f:id:pierre3:20170402210917p:plain

f:id:pierre3:20170402211715p:plain

  • Archive Files
    ビルド成果物をZIPに圧縮します。

f:id:pierre3:20170402211745p:plain

  • Publish Build Artifacts
    ビルド成果物をサーバーに公開します。

f:id:pierre3:20170402211810p:plain

Build Agentを変更する

もう1つ。ビルド設定画面で「Options」のタブを選択して、右側の「Default agent queue」を”Hosted”から”Hosted VS2017”に変更しておかないとうまくいきませんでした。

f:id:pierre3:20170402184429p:plain

ここまで終えたら、ビルド設定画面の右上にある「Save & Queue」をクリックして、設定したタスクを実行してみます。

f:id:pierre3:20170402215115p:plain

これで、ビルド成果物を作成するところまでは何とか動かすことが確認できたのですが、今度は次のフェーズで失敗してしまいます。

今度はデプロイに失敗する

ビルド成果物の作成が完了したら、Azure App Serviceへ実際に配置を行うReleaseのフェーズに移行するのですが、ここで次のようなエラーが発生して失敗してしまいます。

error: Web Deploy cannot modify the file - ERROR_FILE_IN_USE

どうやら、配置先のDLLがロックされていて上書きできないことが原因のエラーのようです。

※この問題は、GitHubのIssuesでも議論されていました。

Azure Web App Deployment error: Web Deploy cannot modify the file - ERROR_FILE_IN_USE · Issue #1607 · Microsoft/vsts-tasks · GitHub

VSTSで「Release」の設定を見てみると、以下の様になっていました。

f:id:pierre3:20170402224156p:plain

  • 「Deploy Azure App Service to Slot」でStaging用スロットにデプロイ
  • それが成功したら「Manage Azure App Service - Slot Swap」で本番用のスロットにスワップ

という構成になっています。

今回は「Deploy Azure App Service to Slot」でファイルが上書きできずにエラーとなっていました。
これを解決するには、配置先のStagingスロットのサービスを一旦停止してからデプロイを実行する必要がありそうです。

Releaseタスクの設定を変更する

そこで、以下の様に「Deploy Azure App Service to Slot」の前後にサービスの停止、再開を挟むようにしてみたところ無事デプロイされるようになりました。
以下に設定内容を貼っておきます。

  • Manage Azure App Service - Stop App Service

f:id:pierre3:20170402230003p:plain

  • Deploy Azure App Service to Slot

f:id:pierre3:20170402230047p:plain

  • Manage Azure App Service - Start App Service

f:id:pierre3:20170402230059p:plain

Manage Azure App Service - Slot Swap

f:id:pierre3:20170402230111p:plain

まとめ

非常に長くなってしまいましたが、ようやくこれでGitHubリポジトリからAzure App Serviceへの継続的配信の設定はひとまず完了です。

今回試したのはあくまでも(プレビュー)なので、(プレビュー)が取れた暁にはAzureポータルからポチポチと数クリックするだけで済むようになっているかもしれません。(涙目)

CSVファイルの読み書き設定をC#スクリプトで記述するWPFアプリをDesktop App Converterで変換してストアに公開しました

デスクトップアプリをUWPに変換してWindowsストアに公開可能な状態にするDesktop App Converterを試してみたい!
ということで、ブログのネタで作成していたWPFアプリ(CsvEditSharp)をDesktop App Converterに掛けてストアに公開するまでをチャレンジしてみました。

ひとまず、公開まで漕ぎつけることができたので、アプリの宣伝をしておきます。

CsvEditSharp

CsvEditSharpは、CSVファイルの読み書き設定をC#スクリプトで記述するCSVエディタです。
スクリプトでは、C#CSVファイルを扱うためのクラスライブラリCsvHelperAPIを利用して各種設定を記述します。

Windows Storeから無料でダウンロードできます。(Windows10 Anniversary Update 以降のデスクトップPCのみで利用可能)

www.microsoft.com

ソースコードGitHubに公開しています。

github.com

基本操作

設定スクリプトのひな型を生成してCSVファイルを読み込む

初めて扱うCSVファイル等、設定スクリプトが存在していない場合に、CSVファイルの読み込みと同時に設定スクリプトのひな型を生成することができます。

  • ツールバーの「Configuration Script」コンボボックスで "(Auto Genarate)" を選択します

f:id:pierre3:20170106142156p:plain

  • 「Open」ボタンをクリックして読み込むCSVファイルを選択します

  • 以下のダイアログで、自動生成される設定スクリプトの名前、CSVファイルのエンコーディングおよびヘッダレコードの有無を入力して[OK]をクリックします

f:id:pierre3:20161220135026p:plain

次のように、選択したCSVファイルのヘッダ情報を基に設定スクリプトが自動生成されます。

f:id:pierre3:20161220134928p:plain

レコード格納クラスがFieldData という名前で生成されます。

  • クラス内の各プロパティが1つのカラムを表します
  • ヘッダレコードに定義されているカラム名が、そのままプロパティ名となります
  • プロパティのデータ型は全て文字列(string)型となります
  • カラム名にプロパティ名として使用できない文字が含まれる場合、またはヘッダレコードが存在しない場合のプロパティ名にはcolumn_ + カラム番号が割り当てられます

設定スクリプトのひな型をカスタマイズする

設定スクリプトの編集

設定スクリプトを、読み込んだCSVファイルの内容に応じて書き換えます。

Encoding = Encoding.GetEncoding("utf-8");

//Genderの選択肢を定義するenum
enum Gender
{
    Male,
    Female
}

//レコード格納クラスの名前を変更
class Person
{
  //プロパティの型をデータの種類に応じて変更
    public string Name { get; set; }
    public DateTime Birthday { get; set; }
    public Gender Gender { get; set; }public bool Married { get; set; }
    public double PocketMoney { get; set; }
}

//クラスマッピングの設定も、扱うデータの種類に応じたものに変更
RegisterClassMap<Person>(classMap =>
{
    classMap.Map(m => m.Name).Name("Name");
    classMap.Map(m => m.Birthday).Name("Birthday")
        .TypeConverterOption("M/d/yyyy");
    classMap.Map(m => m.Gender).Name("Gender");
    classMap.Map(m => m.Married).Name("Married")
        .TypeConverterOption(true,"Y")
        .TypeConverterOption(false,"N");
        
    var culture = System.Globalization.CultureInfo.GetCultureInfo("en-us");
    classMap.Map(m => m.PocketMoney).Name("PocketMoney")
        .TypeConverterOption("C")
        .TypeConverterOption(NumberStyles.Currency)
        .TypeConverterOption(culture);
});

編集が完了したら[Run]ボタンをクリックして、結果を確認します。

f:id:pierre3:20170106150619p:plain

編集したコードに問題がなければ、編集後の設定でCSVファイルが再読み込みされ、内容がCSVエディタに表示されます f:id:pierre3:20161220135128p:plain

設定スクリプトの保存

編集した設定スクリプトは[Save] (上書き保存)ボタン、[SaveAs...] (名前を付けて保存)ボタンで保存できます。

[SaveAs...]ボタンをクリックすると、以下のダイアログが表示されます。

f:id:pierre3:20170107221931p:plain

  • 「Save as a new file」を選択

  • 「Save into the current directory as "Default.config.csx"」を選択

    • 設定スクリプトを、読み込み中のCSVファイルと同じフォルダに"Default.config.csx"という名前で保存します。
    • 読み込むCSVファイルと同じフォルダに"Default.config.csx"ファイルが存在する場合、常にこのファイルが設定スクリプトとして使用されます。(「Configuration Script」コンボボックスでの選択は無視されます)

設定スクリプトを指定してCSVファイルを読み込む

対応する設定スクリプトが既に存在する場合、「Configuration Script」で対応するスクリプトの名前を選択後、CSVファイルを読み込みます。

f:id:pierre3:20170107225016p:plain

設定スクリプトの管理

[Settings...]ボタンで表示される以下のダイアログで、作成済みの設定スクリプトの名前変更や削除が可能です。  

f:id:pierre3:20170110130931p:plain

値の編集

CSVエディタ(読み込んだCSVファイル名のタブ)内のセルを直接編集することができます。

クラスマッピングでマップしたプロパティのデータ型に応じて入力方法や入力可能な値が変わります。

  • データ型がenumの場合、enumのメンバーを選択肢としたコンボボックスで値を選択します
  • データ型がboolの場合、チェックボックスのON/OFFでTrue/Falseを切り替えます
  • データ型が数値型やDateTime型の場合、セルに入力した文字列が目的の型に変換できない場合にエラーメッセージを表示します。

f:id:pierre3:20170110224957p:plain

AddValidation() メソッドを使用してより詳細な入力検証を設定することも可能です。

編集したCSVデータの保存

ツールバーの[SaveAs]ボタンをクリックして、編集後のCSVデータを別のCSVファイルとして保存することができます。 CSVエディタに表示されている状態がそのまま保存されます。(Queryメソッドでフィルタ・ソートを行った場合も、表示されている内容がそのまま保存されます。)

f:id:pierre3:20170111104107p:plain

設定スクリプトAPI

Encoding プロパティ

Encoding Encoding { get; set; }

対象CSVファイルのエンコーディングを指定します。 省略した場合は Encoding.Defaultが割り当てられます。

Encoding = Encoding.GetEncoding("utf-8");

RegisterClassMap メソッド

void RegisterClassMap<T>();
void RegisterClassMap<T>(Action<CsvClassMap<T>> propertyMapSetter);
void RegisterClassMap<T>(Action<CsvClassMap<T>> propertyMapSetter, RegisterClassMapTarget target);

CsvHelperを利用したクラスマッピングの設定を記述します。

  • 登録するクラスマッピングオブジェクトを引数に取るデリゲートpropertyMapSetter内に設定内容を記述します。
  • 第2引数で、登録したクラスマッピングを使用するタイミングを指定できます。
    • RegisterClassMapTarget.Reader : 読み込み時のみ
    • RegisterClassMapTarget.Writer : 書き込み時のみ
    • RegisterClassMapTarget.Both : 読み書き両用

クラスマッピング設定の記述方法については、CsvHelper 公式ドキュメントのMappingのセクションを参照ください。

//既定のクラスマッピング設定を使用
RegisterClassMap<Person>();

//読み書き両用
RegisterClassMap<Person>(classMap => {
    classMap.Map(m => m.Name);
    classMap.Map(m => m.Birthday);
    classMap.Map(m => m.Gender);
    classMap.Map(m => m.Married)
        .TypeConverterOption(true,"Y")
        .TypeConverterOption(false,"N");
    classMap.Map(m => m.PocketMoney)
        .TypeConverterOption("C")
        .TypeConverterOption(NumberStyles.Currency);
});

//読み込み時のみ
RegisterClassMap<Person>(classMap => {
    classMap.Map(m => m.Name);
    classMap.Map(m => m.Birthday);
    classMap.Map(m => m.Gender);
    classMap.Map(m => m.Married)
        .TypeConverterOption(true,"Y")
        .TypeConverterOption(false,"N");
    classMap.Map(m => m.PocketMoney)
        .TypeConverterOption("C")
        .TypeConverterOption(NumberStyles.Currency);
}, RegisterClassMapTarget.Reader);

SetConfiguration メソッド

void SetConfiguration(Action<CsvConfiguration> configurationSetter);

引数configurationSetterデリゲートに渡されるCsvConfigurationオブジェクトの値を書き換えることで、CSVファイル読み書き時の詳細な設定を記述することができます。

ここで設定可能な項目の詳細は CsvHelper 公式ドキュメントのConfigurationのセクションを参照ください。

SetConfiguration(config =>
{
    config.HasHeaderRecord = false;
    config.AllowComments = true;
    config.Comment = '#';
    config.Delimiter = ';';
    //etc...
});

AddValidation メソッド

void AddValidation<TType, TMember>(Expression<Func<TType, TMember>> memberSelector, Func<TMember, bool> validation, string errorMessage);

値変更時の入力検証機能を追加することができます。

  • memberSelector デリゲート(式木)で対象となるカラムのプロパティを指定します。
  • validation デリゲートで検証を通過する条件を指定します。
  • errorMessage に検証NG時に表示する文字列を指定します。
AddValidation<Person,DateTime>(
    m => m.Birthday , 
    dt => dt <= DateTime.Now.Date,
    "Cannot enter a future date.");

AddValidation<Person, double>(
    m => m.PocketMoney , 
    n => (n > 0) && (n < 10000.0),
    "PocketMoney must be in the range $0 to $10000.");

Query メソッド

void Query<T>(Func<IEnumerable<T>, IEnumerable<T>> query);
void Query<T>(Action<IEnumerable<T>> query);

データのフィルタ、ソート

LINQの拡張メソッドを利用して、表示するデータのフィルタ、ソートを行うことができます。

Query<Person>(source => source
    .Where(m => m.Gender == Gender.Female )
    .Where(m => m.Married )
    .OrderBy(m => m.PocketMoney) );

データの一括更新

ForEach()拡張メソッドを使用して、データを書き換えることも可能です。

Query<Person>( record => record
    .Where( m => m.Gender == Gender.Male )
    .Where( m => m.Married )
    .ForEach( m =>
    {
        m.Name += " *";
        m.PocketMoney = 0;
    })
);

(Queryメソッドは、設定スクリプトのタブではなく、CSVデータ表示タブの下部にあるテキストエディタ内に記述します。下記のようなコードを記述後、[Execute]ボタンをクリックすることで、コードが実行され、表示に反映されます。)

f:id:pierre3:20170110161754p:plain

C#のソースコードからPlantUMLのクラス図を生成するアプリ(改)をリリースしました

サンプルコードの棚卸

以前、以下の記事で作成したC#ソースコードからPlantUMLを生成するサンプルプログラムですが、(長らく放置状態でしたが)
少し手直しをして、それなりに使えるようにしました。

pierre3.hatenablog.com

pierre3.hatenablog.com

 PlantUmlClassDiagramGenerator

リポジトリはこちら

github.com

こちらからバイナリ(.zip)をダウンロードできます。ぜひお試しください。

Release v0.5.0.0-beta

使い方

PlantUmlClassDiagramGeneratorはコンソールアプリケーションです。 以下の様にパラメータを指定して実行します。

C:\> PlantUmlClassDiagramGenerator.exe InputPath [OutputPath] [-dir] ^
 [-public | -ignore IgnoreAccessibilities] [-excludePaths ExcludePathList]
  • InputPath (必須)
    入力するソースコードのファイル名またはディレクトリ名を指定します。
    ディレクトリを指定した場合、サブフォルダ以下を含めた全ての.csファイルが変換の対象となります。
  • OutputPath (省略可)
    出力先のファイル名またはディレクトリ名を指定します。
    省略した場合、変換元の.csファイルと同じディレクトリに変換後の.pumlファイルが出力されます。
  • -dir (省略可)
    InputPath および OutputPath がディレクトリ名の場合にこのオプションを指定します。
  • -public (省略可)
    クラス、構造体のパブリックメンバーのみを出力する場合に指定します。
  • -ignore (省略可)
    出力対象外とするアクセシビリティをカンマ区切りで指定します。
    (例): -ignore private,protected
  • -excludePaths (省略可)
    除外するファイル名またはディレクトリ名を指定します。-dir オプションを指定した場合のみ有効
    (例): -excludePaths obj,Properties\AssemblyInfo.cs

使用例

例として、こちらのソースコードを変換してみます。

github.com

ソリューションディレクトリをC:\Source\CsvEditSharpとして、プロジェクトCsvEditSharp以下の.csファイルをまとめて変換する場合を例にします。
出力先はC:\Source\CsvEditSharp\Documents\umlとします。

C:\> PlantUmlClassDiagramGenerator.exe C:\Source\CsvEditSharp\CsvEditSharp ^
 C:\Source\CsvEditSharp\Documents\uml -dir -public -excludePaths obj,Properties 

今回は、パブリックメンバーのみを出力対象としました。
また、objフォルダ内に自動生成される.cs ファイルと、Propertiesフォルダ内のAssemblyInfo.csファイルは変換対象から除外するようにしました。

変換結果は以下で確認できます。

CsvEditSharp/Documents/uml at master · pierre3/CsvEditSharp · GitHub

ちなみに、ディレクトリ単位で変換を行った場合、全ての出力ファイルを !include で参照したinclude.pumlをInputPath内に出力するようにしています。

@startuml
!include .\\App.xaml.puml
!include .\\Models\ColumnValidation.puml
!include .\\Models\CompletionData.puml
!include .\\Models\CsvConfigFileManager.puml
!include .\\Models\CsvEditSharpConfigurationHost.puml
!include .\\Models\CsvEditSharpWorkspace.puml
!include .\\Models\CustomBooleanConverter.puml
!include .\\Models\EnumerableExt.puml
!include .\\Models\GenerateConfigSettings.puml
!include .\\Models\ICsvEditSharpConfigurationHost.puml
!include .\\Models\RegisterClassMapTarget.puml
!include .\\Models\SaveConfigSettings.puml
//...(中略)...
!include .\\ViewModels\GenerateConfigDialogViewModel.puml
!include .\\ViewModels\MainWindowViewModel.puml
!include .\\ViewModels\SaveConfigDialogViewModel.puml
!include .\\Views\GenerateConfigDialog.xaml.puml
!include .\\Views\MainWindow.xaml.puml
!include .\\Views\SaveConfigDialog.xaml.puml
@enduml

これをそのままPlantUMLで画像に変換すると、こんな感じになります↓

http://pierre3net.azurewebsites.net/content/image/include.svg

主な変更点

以前の記事に書いた仕様からの主な変更点は以下の通りです。

ネストクラスの扱い

ネストされたクラスは、ネストのまま変換されてPlantUMLでエラーとなってしまっていましたが、
今回、ネストクラスは外に展開して、+-- で関連付けするように修正しました。

class OuterClass 
{
  class InnerClass 
  {
    struct InnerStruct 
    {

    }
  }
  • PlantUML
class OuterClass{

}
class InnerClass{

}
<<struct>> class InnerStruct {

}
OuterClass +- InnerClass
InnerClass +- InnerStruct

f:id:pierre3:20161217211654p:plain

ジェネリクス型の変換

PlantUMLでは、ジェネリクス型は型引数の数が異なっても同じクラスとして認識されてしまうようです。

class GenericsType {
}
class GenericsType<T1> {
}
class GenericsType<T1,T2> {
}

PlantUMLでは、上記は全て GenericsType クラスとして認識され、ダイアグラムは最後に記述した定義で上書きされてしまいます。

そこで今回は、以下の様に"クラス名に型引数の数を付加したもの"を変換後のクラス名とするようにしました。

class "GenericsType`1"<T1>{
}
class "GenericsType`2"<T1,T2>{
}

f:id:pierre3:20161217211751p:plain

継承関係

クラス、インターフェースを継承しているクラスは、ベースクラスと<|--で結んで継承関係を表すようにしました。

abstract class BaseClass
{
    public abstract void AbstractMethod();
    protected virtual int VirtualMethod(string s) => 0;
}
class SubClass : BaseClass
{
    public override void AbstractMethod() { }
    protected override int VirtualMethod(string s) => 1;
}

interface IInterfaceA {}
interface IInterfaceA<T>:IInterfaceA
{
    T Value { get; }
}
class ImplementClass : IInterfaceA<int>
{
    public int Value { get; }
}
  • PlantUML
abstract class BaseClass {
    + {abstract} AbstractMethod() : void
    # <<virtual>> VirtualMethod(s:string) : int
}
class SubClass {
    + <<override>> AbstractMethod() : void
    # <<override>> VirtualMethod(s:string) : int
}
interface IInterfaceA {
}
interface "IInterfaceA`1"<T> {
    Value : T <<get>>
}
class ImplementClass {
    + Value : int <<get>>
}
BaseClass <|-- SubClass
IInterfaceA <|-- "IInterfaceA`1"
"IInterfaceA`1" "<int>" <|-- ImplementClass

f:id:pierre3:20161217211830p:plain

C#スクリプト エディタに入力補完機能を実装する

この記事は、以下の記事の続きとなります。

この記事のサンプルコードはこちら github.com

これまでは、読み込んだ設定スクリプトをTextBoxコントロールに表示していたのですが、
今回、もう少し"コードエディタっぽい" エディタに変更して、ソースコードの表示・編集が出来るようにしたいと思います。

目次

C#スクリプトエディタにAvalonEditを使う

AvalonEditは、WPF用の高機能テキストエディタライブラリで、ソースコードシンタックスハイライトや入力補完機能を備えています。
今回は設定スクリプトの表示、編集用にこのライブラリを使用したいと思います。

AvalonEdit by icsharpcode

NuGet からインストール可能です。

PM> Install-Package AvalonEdit

XAML での使用例は以下の通りです。
AvalonEdit:TextEditor というコントロールを配置します。シンタックスハイライトや、行番号の表示有無などが指定可能です。

<Window x:Class="CsvEditor.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:CsvEditor"
        xmlns:avalonEdit="http://icsharpcode.net/sharpdevelop/avalonedit"
        mc:Ignorable="d"
        Title="MainWindow" Height="580" Width="960">
    <Grid>
        <!-- 中略 -->
        <avalonEdit:TextEditor 
            x:Name="configEdit"
            Document="{Binding ConfigurationDoc,Mode=TwoWay}"
            SyntaxHighlighting="C#"
            ShowLineNumbers="True"
            FontFamily="Consolas" FontSize="10Pt">
        </avalonEdit:TextEditor>
        <!-- 中略 -->
    </Grid>
</Window>

C#スクリプトエディタに入力補完機能を付ける

AvalonEdit の入力補完ウィンドウ

AvalonEdit には、入力補完ウィンドウを表示する仕組みが備わっています。

公式ドキュメント(http://avalonedit.net/documentation/ の"Code Completion")のサンプルを参考に以下のような記述を行うだけで、補完ウィンドウの表示自体は簡単に実装することが可能です。

//MainWindowのコードビハインド
public partial class MainWindow : Window
{
    private CompletionWindow completionWindow;

    public MainWindow()
    {
        InitializeComponent();
        //イベントハンドラを登録
        configEdit.TextArea.TextEntered += TextArea_TextEntered;
        configEdit.TextArea.TextEntering += TextArea_TextEntering;
    }
    //文字入力中
    private void TextArea_TextEntering(object sender, System.Windows.Input.TextCompositionEventArgs e)
    {
        //補完Windowが開いている間
        if (e.Text.Length > 0 && completionWindow != null)
        {
            //英数字以外が入力された場合
            if (!char.IsLetterOrDigit(e.Text[0]))
            {
                //選択中のリストの項目をエディタに挿入する                
                completionWindow.CompletionList.RequestInsertion(e);
            }
        }
        // HandledはTrueにしない
        // e.Handled=true;
    }
    //文字入力後
    private async void TextArea_TextEntered(object sender, System.Windows.Input.TextCompositionEventArgs e)
    {
        // ピリオドを入力
        if (e.Text == ".")
        {
            //入力補完Windowを生成
            completionWindow = new CompletionWindow(configEdit.TextArea);
            //補完リストに表示するアイテムをコレクションに追加する
            //---> ここは、編集内容に応じて適切な入力候補を追加するように書き換える!
            IList<ICompletionData> data = completionWindow.CompletionList.CompletionData;
            data.Add(new CompletionData("item1"));
            data.Add(new CompletionData("item2"));
            data.Add(new CompletionData("item3"));
            //<---
            //Windowを表示
            completionWindow.Show();
            completionWindow.Closed += delegate
            {
                completionWindow = null;
            };
        }
    }
}
  • 入力候補一覧には、ICompletionData を実装したクラスのインスタンスを追加してあげる必要があります。
public class CompletionData : ICompletionData
{
    //入力候補一覧(UI)に表示する内容
    public object Content { get; set; }
    //ツールチップに表示する説明文
    public object Description { get; set; }
    //入力候補の左側に表示するアイコン
    public ImageSource Image { get; set; }
    //表示順の優先度?
    public double Priority { get; set; }
    //アイテム選択時に挿入される文字列
    public string Text { get; set; }
    //アイテム選択後の処理
    public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs)
    {
        textArea.Document.Replace(completionSegment, Text);
    }
}

Roslyn Workspaces API の Recommender を利用して入力候補一覧を取得する

AvalonEdit を利用することで、入力補完Window 自体を表示することができるのは分かったのですが、肝心の入力候補の一覧はどうやって作ればよいのでしょうか?

Roslyn の Workspaces API 辺りにコード補完の機能があったような。。。
と調べてみると、どうやら Microsoft.CodeAnalysis.Recommendations.Recommender クラスのGetRecommendedSymbolsAtPositionAsync というメソッドが使えそうだという事が分かりました。

Roslyn Workspases API もNuGetで追加します。

PM> Install-Package Microsoft.CodeAnalysis.Workspaces

Recommender.GetRecommendedSymbolsAtPositionAsync()

(このメソッドに関する情報が非常に少ないため、メソッド名と戻り値等から予測すると)
このメソッドは、ソースコード上の指定した場所(position)におけるコード補完候補の一覧を IEnumerable<ISymbol> で返してくれるメソッドのようです。

シグネチャは以下の通り。引数にはposition のほかに、SemanticModel と Workspace を渡す必要があるようです。

public static Task<IEnumerable<ISymbol>> GetRecommendedSymbolsAtPositionAsync(
    SemanticModel semanticModel,
    int position,
    Workspace workspace,
    OptionSet options = null,
    CancellationToken cancellationToken = default(CancellationToken));
(引数1) semanticModel

SemanticModelは、C#スクリプトを実行する際に作成する Script<T>オブジェクトから取得できるCompilation オブジェクトから取得可能なので、これを指定すれば良さそうです。

private void CompileScript(string code)
{
    Script<object> script = CSharpScript.Create(code, ScriptOptions.Default
        .WithReferences(
            typeof(object).Assembly,
            typeof(CsvReader).Assembly,
            typeof(ICsvEditorConfigurationHost).Assembly)
        .WithImports(
            "System",
            "System.Collections.Generic",
            "System.Linq"),
        typeof(ICsvEditorConfigurationHost));
    //SemanticModel の取得
    var compilation = script.GetCompilation();
    var syntaxTree = compilation.SyntaxTrees.First();
    var semanticModel = compilation.GetSemanticModel(syntaxTree);
    //Workspaceの作成
    //...(略)
}
(引数2) positon

ソースコード上の位置(エディタで補完機能を呼び出した際のキャレット位置)を指定すれば良さそう。

(引数3) workspace

ソースコードはWorkspace の形で渡してあげる必要があります。

Workspace を一から作成するには AdhocWorkspace クラスを使用します。

//Workspaceの作成
Workspace workspace = new AdhocWorkspace();
//空のprojectを作成
string projectName = "CsvEditorConfig";
ProjectId projectId = ProjectId.CreateNewId();
VersionStamp versionStamp = VersionStamp.Create();
ProjectInfo projectInfo 
    = ProjectInfo.Create(projectId, versionStamp, projectName, projectName, LanguageNames.CSharp);
Project project = workspace.AddProject(projectInfo);
//Documentを追加
SourceText sourcetext = Microsoft.CodeAnalysis.Text.SourceText.From(code);
workspace.AddDocument(project.Id, "Config.csx", sourcetext);

ICompletionData のコレクションを作成する

AvalonEdit の CompletionWindow に渡す入力候補ICompletionData インターフェースを実装したオブジェクトである必要がありました。

次は、GetRecommendedSymbolsAtPositionAsync() メソッドから取得したISymbolのコレクション を ICompletionData のコレクションに変換して返すメソッドを作ります。

public class MainWindowViewModel : BindableBase
{
    private Script<object> script;
    private SemanticModel semanticModel;
    private AdhocWorkspace workspace = new AdhocWorkspace();
    
    public async Task<IEnumerable<CompletionData>> GetCompletionListAsync(int position,string code)
    {
        CompileScript(code);
        if (semanticModel == null || workspace == null)
        {
            return Enumerable.Empty<CompletionData>();
        }
        var items = await Recommender.GetRecommendedSymbolsAtPositionAsync(
            semanticModel, position, workspace);
        return items.Select(symbol => new CompletionData()
        {
            //ListBoxに表示する内容
            Content = symbol.Name,
            //エディタに挿入する文字列
            Text = symbol.Name,
            //ツールチップに表示する文字列
            Description = symbol.ToMinimalDisplayString(semanticModel, position)
        });
    }
}

これをViewModelに実装して、View側 (コードビハインド) に記述した TextArea_TextEntered イベントハンドラで呼び出すようにします。
また、ピリオド(.)のほかに[Ctrl]+[Space]キーでも入力補完ウィンドウを表示するようにOnKeyDown() メソッドをオーバーライドします。

public partial class MainWindow : Window
{
    private CompletionWindow completionWindow;
    private MainWindowViewModel VM { get { return DataContext as MainWindowViewModel; } }

    private async void TextArea_TextEntered(object sender, 
        System.Windows.Input.TextCompositionEventArgs e)
    {
        if (VM == null) { return; }
        // Open code completion after the user has pressed dot:
        if (e.Text == ".")
        {
            await ShowCompletionWindow();
        }
    }

    protected override async void OnKeyDown(KeyEventArgs e)
    {
        base.OnKeyDown(e);
        //Ctrl+Space でも表示するように
        if (e.Key == Key.Space && e.KeyboardDevice.Modifiers.HasFlag(ModifierKeys.Control))
        {
            e.Handled = true;
            await ShowCompletionWindow();
        }
    }    

    private async Task ShowCompletionWindow()
    {
        if (VM == null) { return; }
        completionWindow = new CompletionWindow(configEdit.TextArea);
        IList<ICompletionData> data = completionWindow.CompletionList.CompletionData;

        var completionItems = await VM.GetCompletionListAsync(
            configEdit.TextArea.Caret.Offset + 1,
            configEdit.Document.Text + " ");

        foreach (var item in completionItems)
        {
            data.Add(item);
        }
        completionWindow.Show();
        completionWindow.Closed += delegate
        {
            completionWindow = null;
        };
    }
}

実行してみよう

長くなってしまいましたが、これで一通りの実装が完了しました。
それでは、実行結果を見てみましょう。

f:id:pierre3:20160727223655g:plain

  • ソースコードの編集箇所に応じた入力候補がちゃんと表示されています。
  • デリゲートの中の引数でも補完が利くようです。

中々いい感じに動いていますね。

※ あとは、以下の2点を何とかすれば完璧なのですが、今回はあきらめました。

まとめ

  • AvalonEdit は 高機能なテキストエディタを簡単に実装できていいぞ!
  • コンテキストに応じた入力候補の取得には Microsoft.CodeAnalysis.Recommendations.Recommender が使えるよ!

CSVのクラスマッピングの定義をC#スクリプトで記述する (その3: ValidationRule)

この記事は、以下の記事の続きです

github.com

目次

入力可能なデータの条件を指定できるようにする

前回は、CsvHelperのテキスト変換処理(TypeConverter)ラップしたConverterを設定することで、DataGridColumn の変換処理をカスタマイズしました。
今回は、DataGridColumnの検証(Validation)機能を利用して、カラム毎に入力値の制限を付けられるようにしたいと思います。

C#スクリプト側の記述

スクリプト側に公開するインターフェースに以下のメソッドを追加して、カラム(データ格納クラスのプロパティ)毎に入力制限を設定できるようにします。

public interface ICsvEditorConfigurationHost
{
    void AddValidation<TType,TMember>(
        Expression<Func<TType, TMember>> memberSelector,
        Func<TMember, bool> validation,
        string errorMessage);
}
  • 第1引数: 式木(デリゲート)でデータ格納クラスの対象プロパティを指定
  • 第2引数:「対象プロパティの値を受け取りboolを返すデリゲート」で、入力可能な値の条件を指定
  • 第3引数: 入力制限に引っかかった場合のエラーメッセージを指定

スクリプト内での記述例は以下の通り。(前回の記事と同じサンプルデータを使用)

//生年月日は今日以前のみ
AddValidation<Person, DateTime>(prop => prop.Birthday,
    m => m <= DateTime.Today,
    "未来の日付は入力できません");

//お小遣いは ¥0 以上 ¥10,000 以下
AddValidation<Person, int>(prop => prop.PocketMoney,
    m => (m >= 0) && (m <= 10000),
    "入力可能な範囲は ¥0~¥10,000 です。");

AddValidation メソッドの実装は以下の通りです。 引数に渡した設定値をプロパティ名をキーとした辞書に登録します。

public class CsvEditorConfigurationHost
{
    public IDictionary<string, ColumnValidation> ColumnValidations { get; } 
        = new Dictionary<string, ColumnValidation>();

    public void AddValidation<TType, TMember>(Expression<Func<TType, TMember>> memberSelector, 
        Func<TMember, bool> validation, string errorMessage)
    {
        //式木からプロパティ名を取得
        MemberExpression memberExpression = null;
        if (memberSelector.Body.NodeType == ExpressionType.Convert)
        {
            var body = (UnaryExpression)memberSelector.Body;
            memberExpression = body.Operand as MemberExpression;
        }
        else if (memberSelector.Body.NodeType == ExpressionType.MemberAccess)
        {
            memberExpression = memberSelector.Body as MemberExpression;
        }
        if (memberExpression == null)
        {
            throw new ArgumentException("Not a member access", nameof(memberSelector));
        }
        //プロパティ名をキーにして辞書に登録
        ColumnValidations.Add(memberExpression.Member.Name, 
            new ColumnValidation(m => validation((TMember)m), errorMessage));
    }
}
//設定値格納用
public class ColumnValidation
{
    public Func<object, bool> Validation { get; }
    public string ErrorMessage { get; }
    public ColumnValidation(Func<object, bool> validation, string errorMessage)
    {
        Validation = validation;
        ErrorMessage = errorMessage;
    }
}

DataGridColumn に ValidationRuleを設定する

スクリプトで指定された条件からDataGridColumnバインドするValidationRule クラスを作成します。

public class DataGridColumnValidationRule : ValidationRule
{
    private Func<object, bool> isValidate;
    private object errorContent;

    public DataGridColumnValidationRule(Func<object, bool> isValidate, object errorContent)
    {
        if (isValidate == null) { throw new ArgumentNullException(nameof(isValidate)); }
        if (errorContent == null) { throw new ArgumentNullException(nameof(errorContent)); }

        ValidationStep = ValidationStep.ConvertedProposedValue;
        this.isValidate = isValidate;
        this.errorContent = errorContent ?? "invalid value";
    }

    public override ValidationResult Validate(object value, CultureInfo cultureInfo)
    {
        return (isValidate(value)) ?
            ValidationResult.ValidResult :
            new ValidationResult(false, errorContent);
    }
}

前回のConverterの指定と同様にAutoGeneratingColumnイベントのハンドラ内でDataGridColumnのBindingオブジェクトにValidationRuleを追加します。

public class MainWindow
{
    private void DataGrid_AutoGeneratingColumn(object sender, DataGridAutoGeneratingColumnEventArgs e)
    {
        if (VM == null) { return; }

        var converter = VM.GetDataGridColumnConverter(e.PropertyName);
        if (converter == null) { return; }

        if (!string.IsNullOrEmpty(converter.HeaderName))
        {
            e.Column.Header = converter.HeaderName;
        }

        var textColumn = e.Column as DataGridTextColumn;
        if (textColumn != null)
        {
            textColumn.EditingElementStyle = (Style)Resources["textColumnStyle"];
        }

        var binding = (e.Column as DataGridBoundColumn)?.Binding as Binding;
        if (binding != null)
        {
            binding.Converter = converter;
            //VMからValidationRuleを取得してBindingに設定        
            var validationRule = VM.GetDataGridColumnValidation(e.PropertyName);
            if (validationRule != null)
            {
                binding.ValidationRules.Add(validationRule);
            }
        }
    }
}

public class MainWindowViewModel
{
    public DataGridColumnValidationRule GetDataGridColumnValidation(string propertyName)
    {
        ColumnValidation columnValidaiton;
        if (host.ColumnValidations.TryGetValue(propertyName, out columnValidaiton))
        {
            return new DataGridColumnValidationRule(columnValidaiton.Validation, columnValidaiton.ErrorMessage);
        }
        return null;
    }
}

実行例

「生年月日」と「お小遣い」に入力可能範囲外の値を入力してみます。

f:id:pierre3:20160722230823p:plain

期待通りセルの背景が赤くなり、ツールチップには指定したエラーメッセージが表示されています。

まとめ

これで入力ミスも柔軟にチェックすることが出来るようになりました。