xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

AcrInsight - Azure Container Registry のビューワーを公開しました

タイトルの通りですが、Azure Container Registry (= ACR) のリポジトリにあるイメージを閲覧するツールを作成/公開しました。

なんと (?) 4 日前の 2018/12/05 に Microsoft Connect(); 2018 で公開されたばかりの WPF on .NET Core 3.0 でできています!WPF がオープンソースになった記念にやってみたかったんや...w 画面は以下のような感じで、超ピュアな WPF です。

f:id:xin9le:20181209184606p:plain

モチベーション (= ACR の不便なところ)

最近仕事で ASP.NET Core MVC 製の Web サービスを開発しています。ホスティング先は Web Apps for Containers で、コンテナイメージの格納先としては Azure Container Registry を利用しています。やりたいことが一通りできてそれなりに満足してます。なのですが、そんな利用中の ACR にも不便なところが...!

以下は Azure Portal で Azure Container Registry のリポジトリを見ている画面のキャプチャです。

f:id:xin9le:20181209182448p:plain

リポジトリに格納されているコンテナイメージに紐付いているタグの一覧が閲覧できる特に何てことなさそうな画面ですが、よく見ると個々のイメージとタグの関連付けが全く分かりません。イメージとタグの関係は例えば以下のように 1 対多になっていて、Docker コマンドを叩くときもそうですがイメージ ID (= ACR で言うところの Digest) はよく利用します。

イメージ ID (= Digest) タグ
sha256:abc123 build-1024
latest
sha256:xyz789 build-1023

ここで ACR の課金体系について考えてみます。ACR の課金体系は格納しているコンテナイメージの容量に依存していて、プランに応じた一定範囲を超えるとドンドン増えていきます。なので、一定容量の範囲に抑えようと思うのであれば定期的にイメージを削除しなければなりません。

ではどうやってイメージを削除するのかと言うと、現状は Azure CLI を叩くしかありません。Azure Portal 上からはタグの削除はできるのですが、それはイメージについているタグを消しているだけでイメージ自体の削除にはなっていません。つまり、イメージに関連付いているタグをすべて削除してしまうとタグのついていない宙ぶらりんなコンテナイメージが残ってしまうことになります。Portal 上から見えていないのにひっそりと課金対象だと...??

そんなこんな、イメージとタグの関係をしっかりと把握して適切に削除なり操作をしたいという気持ちが強くなりました。Azure CLI を使えばこれらの詳細な情報は見られるのですが、やっぱり視覚化するのが良いなと思ってツールとして作ることにしました。

WPF on .NET Core 3.0 の始め方

まだプロジェクトテンプレートがないのでほんの少しだけ手間ですが、実は全然難しくありません。

  1. Visual Studio 2019 Preview 1 (執筆時点) をインストール
  2. .NET Core 3.0 をインストール
  3. コマンドラインで dotnet new wpf と入力してプロジェクトを作成

この 3 を実行するとき、プロジェクト名のフォルダをカレントディレクトリとして実行してください。そうするとイイ感じにテンプレートからプロジェクトが生成されます。

ACR の .NET SDK

実は ACR のリポジトリ情報を閲覧/操作する .NET SDK は NuGet に公開されていません。おかげで .NET から API を叩くのが地味に厳しい...。なのですが、幸いにも (?) GitHub にまだ作業途中のような感じのプロジェクトがあったので、それを使って C# から REST API を叩いています。

この SDK がまだ閲覧関連しかサポートしていないので、残念ながらイメージの削除を実装できませんでした。これは追々なんとか対応したいなーと思ってはいます。

おまけ

WPF 開発と言えば MVVM (?) ということで ReactiveProperty を使っています。ちゃんと .NET Core 3.0 でも動きます。ぜひ今後ともご贔屓によろしくお願いします :)

Slack.Integration - Slack 連携ライブラリを公開しました

もはや 2 番煎じ、3 番煎じどころか 10 番煎じくらいのものですが、.NET アプリと Slack を繋ぐライブラリ Slack.Integration を作成し、公開しました!現時点では Incoming Webhook *1 にだけ対応していますが、そのうち Slash Command にも対応させる予定です。

どうして車輪の再発明を?

Incoming Webhook において以下の機能を満たしたものが欲しかった、というのが主な理由です。

  • ドキュメントで見た Incoming Webhook のメッセージ投稿の表現を可能な限り使いたい
  • ASP.NET Core MVC 上で扱うときに HttpClientFactory の TypedClient を使った Dependency Injection に準拠したい
  • 標準の絵文字定義を使いたい
  • 特別扱いされている既定のカラー定義を使いたい

また今後 Slash Command を作るときに、同一のライブラリ内に Slash Command の機能も閉じ込めておきたいなぁという思惑もあります。

メッセージ表現の網羅

ドキュメントからの起こし漏れがなければですが、たぶん提供されているすべての表現ができます。たとえば下記のような感じです。REST API で飛ばす必要がある JSON に忠実なプロパティ名なので、困ったとしてもドキュメントとの対比が容易かと思います。

var payload = new Payload
{
    UserName = "Incoming Webhook",
    Text = "Hello, @xin9le !!\n\nThis posts is test for _Incoming WebHook_ .\nDocument is <https://api.slack.com/incoming-webhooks|here>.",
    Markdown = true,
    Channel = "#random",  // override channel
    LinkNames = true,  // linkify names and channels 
    IconEmoji = KnownEmoji.PlusOne,
    Attachments = new []
    {
        new Attachment
        {
            Fallback = "脱・読みづらいコード!今日から一段上のプログラマーになる方法 5 選",
            Color = Color.AliceBlue.ToHex(),
            PreText = "This text is optional that is displayed at the top of _attachment block_ .",
            AuthorName = "xin9le",
            AuthorLink = "http://xin9le.net",
            AuthorIcon = "https://pbs.twimg.com/profile_images/1047114972118114305/vw07RO7H_normal.jpg",
            Title = "脱・読みづらいコード!今日から一段上のプログラマーになる方法 5 選",
            TitleLink = "http://blog.xin9le.net/entry/2016/02/26/043557",
            Text = "「ソースコードを綺麗に書く」というのは、プログラマーであれば誰しもが心掛けたいと思っている *極めて重要な事柄* です。そもそも「綺麗なコードってなんぞ?」という感じですが、いくつかあると思います。",
            ImageUrl = "http://cdn-ak.f.st-hatena.com/images/fotolife/x/xin9le/20160226/20160226040749.png",
            ThumbUrl = "https://pbs.twimg.com/profile_images/1047114972118114305/vw07RO7H_normal.jpg",
            Footer = "xin9le",
            FooterIcon = "https://pbs.twimg.com/profile_images/1047114972118114305/vw07RO7H_normal.jpg",
            Timestamp = DateTimeOffset.Now.ToUnixTimeSeconds(),
            Fields = new []
            {
                new Field
                {
                    Title = "Unique User",
                    Value = "345",
                    Short = true,
                },
                new Field
                {
                    Title = "Page View",
                    Value = "12345",
                    Short = true,
                },
            },
            Actions = new []
            {
                new Slack.Integration.IncomingWebhook.Action
                {
                    Type = ActionType.Button,
                    Text = "GitHub",
                    Url = "https://github.com/xin9le",
                    Style = ActionStyle.Default,
                },
                new Slack.Integration.IncomingWebhook.Action
                {
                    Type = ActionType.Button,
                    Text = "Blog",
                    Url = "https://blog.xin9le.net",
                    Style = ActionStyle.Primary,
                },
                new Slack.Integration.IncomingWebhook.Action
                {
                    Type = ActionType.Button,
                    Text = "Twitter",
                    Url = "https://twitter.com/xin9le",
                    Style = ActionStyle.Danger,
                },
            },
        },
    },
};

表示サンプル

ASP.NET Core MVC の DI に対応

ASP.NET Core MVC 2.1 から HttpClientFactory がサポートされるようになり、HttpClient のインスタンスをフレームワークに管理させつつ効率的に利用することができるようになりました。ASP.NET Core MVC なサービスから直接 Slack にメッセージを飛ばすようなケースでは、自分でインスタンスを作るのではなく先の HttpClientFactory の機構に乗っかるのが良いだろうと思い、それに準拠させられるようにしました。

以下のような感じで DI に登録し、利用することができます。

// Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    services.AddHttpClient<WebhookClient>();  // supports Typed Client pattern
}
// XxxController.cs

public async Task<IActionResult> DoSomethingAction([FromServices] WebhookClient client)
{
    await client.SendAsync(url, payload);
}

ご利用は NuGet から

すでに NuGet からダウンロードして利用することができます。Slack にメッセージを飛ばしたいという方は、ぜひ使ってみてください。お手軽で便利です。

PM> Install-Package Slack.Integration

*1:Slack への投稿

ASP.NET Core MVC における Required 属性と BindRequired 属性の統合

ASP.NET Core MVC に限らず ASP.NET MVC 時代からそうですが、最もよく利用するモデル検証属性として Required 属性があります。Required の名前の通り「入力必須」であることを表すのですが、実際の挙動は非 null の判定を行うものです。

ですので、以下のように int のような値型に対して Required 属性を付与しても効果はありません。これは既定値 0 が設定されるためです。

public class Person
{
    [Required]  // null 以外を強制できる
    public string Name { get; set; }

    [Required]  // これは特に意味がない
    public int Age { get; set; }
}
public IActionResult Post(Person person)
{
    // Age に値が設定されていなくても検証は valid になる
    if (!this.Model.IsValid)
        return this.BadRequest();

    // do something
}

もはや NotNull 属性の方がネーミングとして分かりやすいんじゃないかと思うくらいです。

ASP.NET MVC 時代の解決方法

これの値型の既定値が入ってしまって入力されているのかが分からない問題を解決するため、ASP.NET MVC 時代には Nullable 型 を利用することで回避してきました。

public class Person
{
    [Required]
    public string Name { get; set; }

    [Required]  // int? なら null 以外かどうかで判断できる
    public int? Age { get; set; }
}

ちっぽけな問題かもしれませんが、int として扱いたいのに int? を強要されるのはあまり好ましいことではありません。

ASP.NET Core MVC 時代の解決方法

今知りたいのは null かどうかではなく、値がバインドされたかどうかです。値型に対して値がバインドされたかどうかを判定するための機能として、ASP.NET Core MVC から BindRequired 属性が追加されました。以下のようにすれば、望んだ通り入力必須の検証を行うことができます。これは嬉しい改善です。

public class Person
{
    [Required]
    public string Name { get; set; }

    [BindRequired]  // 値がバインドされることを強制
    public int Age { get; set; }
}

Required 属性と BindRequired 属性を統合する

BindRequired 属性が追加されて願いは叶ったのですが、参照型と値型で 2 種類の属性を使い分けるのはミスも出やすいですし何より分かりにくいです。そこで、以下のようにバインディングをカスタマイズしてしまうことで [Required] 属性に対してもバインディングを必須にしてみます。

public class BindingRequiredMetadataProvider : IBindingMetadataProvider
{
    public void CreateBindingMetadata(BindingMetadataProviderContext context)
    {
        // パフォーマンスを最大化するため敢えて NO LINQ
        for (var i = 0; i < context.Attributes.Count; i++)
        {
            // [Required] が付いていたらモデルバインドを必須にしちゃう
            if (context.Attributes[i] is RequiredAttribute)
            {
                context.BindingMetadata.IsBindingRequired = true;
                return;
            }
        }
    }
}

機能を有効に利用する場合ば Startup.cs の中で以下のように設定します。

services.AddMvc(o =>
{
    o.ModelMetadataDetailsProviders.Add(new BindingRequiredMetadataProvider());
});

こうすることで、冒頭のように [Required] 属性だけで入力必須を表現することができるようになります。便利!

public class Person
{
    [Required]
    public string Name { get; set; }

    [Required]  // これで入力必須にできる
    public int Age { get; set; }
}

SQL Server Management Studio のテーブルデザイナの列をカスタマイズする

SQL Server Management Studio のテーブルデザイナ、デフォルトでは以下のような画面で表示されます。

f:id:xin9le:20180617161234p:plain

DataGrid として表示される項目が非常に少なく、編集するに頻繁に使用する「既定値」や「説明」のプロパティが下部の PropertyGrid になっているのが非常に煩わしい...!ということで、デザイナをカスタマイズして使いやすくしちゃいましょう。

カスタマイズ方法

DataGrid のカスタマイズは [ツール] - [オプション] などからはできず、レジストリを直接編集することで行います。以下のレジストリキーを辿りましょう。14.0 の部分は製品バージョンなので、ご利用中のバージョンに読み替えてください。

HKEY_CURRENT_USER\Software\Microsoft\SQL Server Management Studio\14.0\DataProject

中にある以下の項目の値を書き換えてみましょう。

SSVPropViewColumnsSQL70
SSVPropViewColumnsSQL80
変更前 変更後
1,2,6; 1,2,6,7,17;

SQL Server Management Studio を再起動すると、テーブルデザイナが以下のように変わります!これは捗る...!

f:id:xin9le:20180617164933p:plain

カスタマイズ可能な項目

表示/非表示したい項目の番号をレジストリに追加/削除すれば良いということになります。対応表は以下の通り。お好みのデザイナにカスタマイズしてみましょう!

プロパティ
1 Column Name
2 Data Type
3 Length
4 Precision
5 Scale
6 Allow Nulls
7 Default Value
8 Identity
9 Identity Seed
10 Identity Increment
11 Row GUID
12 Nullable
13 Condensed Type
14 Not for Replication
15 Formula
16 Collation
17 Description

gRPC / MagicOnion 入門 (17) - 切断検知と自動再接続

gRPC はサーバーとクライアントが常時コネクションを張っている状態です。このコネクションが切断されたタイミングを検知して後処理や再接続処理をしたい、というのはよくパターンかと思います。実は、生の gRPC で切断検知をするのは実はかなり面倒です。MagicOnion はそのあたりを上手くラップ*1し、扱いやすい形として提供してくれています。

今回は、それらを利用した切断検知と再接続の手法について見ていきます。

クライアント側で切断を検知

例えば、サーバーがダウンしたりなどしてコネクションが切断されたことをクライアント側で検知する方法は以下のようにします。

static async Task MainAsync()
{
    var channel = new Channel("localhost", 12345, ChannelCredentials.Insecure);
    var context = new ChannelContext(channel, () => "xin9le");

    //--- 切断検知を仕込む
    context.RegisterDisconnectedAction(() =>
    {
        Console.WriteLine("Disconnected");
    });

    //--- 接続待機
    await context.WaitConnectComplete();

    //--- 接続されてから 10 秒以内にサーバーを落とすと「Disconnected」と表示される
    await Task.Delay(10000);
}

ChannelContext.RegisterDisconnectedAction で切断のタイミングをフックすることができます!超簡単!

また、ここまで長らくお行儀悪く書いてこなかったのですが、ChannelContextDispose するのが良いです。以下の例のように Dispose しても切断検知が走ります。

static async Task MainAsync()
{
    var channel = new Channel("localhost", 12345, ChannelCredentials.Insecure);
    var context = new ChannelContext(channel, () => "xin9le");

    //--- 切断検知を仕込む
    context.RegisterDisconnectedAction(() =>
    {
        Console.WriteLine("Disconnected");
    });

    //--- 接続待機
    await context.WaitConnectComplete();

    //--- 切断する
    Console.WriteLine("1");
    await Task.Delay(1000);
    Console.WriteLine("2");

    context.Dispose();  // 切断!

    Console.WriteLine("3");
    await Task.Delay(1000);
    Console.WriteLine("4");

    Console.ReadLine();
}

//--- 結果
/*
1
2
3
Disconnected
4
*/

チャンネルのシャットダウン

これまたお行儀悪くずっと書いてこなかったのですが、gRPC の Channel はプロセスを終了する前にシャットダウンすることが強く推奨されています。また、シャットダウンを検知して後処理を行うこともできるようになっています。以下のような感じです。

static async Task MainAsync()
{
    var channel = new Channel("localhost", 12345, ChannelCredentials.Insecure);

    //--- シャットダウン検知
    channel.ShutdownToken.Register(() =>
    {
        Console.WriteLine("Shutdown");
    });

    //--- チャンネルをシャットダウン
    Console.WriteLine("1");
    await channel.ShutdownAsync();
    Console.WriteLine("2");

    Console.ReadLine();
}


//--- 結果
/*
1
Shutdown
2
*/

MagicOnion が提供する ChannelContext を利用している場合、終了処理は以下のような感じになると思います。

static async Task MainAsync()
{
    var channel = new Channel("localhost", 12345, ChannelCredentials.Insecure);
    var context = new ChannelContext(channel, () => "xin9le");

    //--- シャットダウン検知 / 切断検知
    channel.ShutdownToken.Register(() =>
    {
        Console.WriteLine("Shutdown");
    });
    context.RegisterDisconnectedAction(() =>
    {
        Console.WriteLine("Disconnected");
    });

    //--- 接続待機
    await context.WaitConnectComplete();
    await Task.Delay(1000);

    //--- 切断する
    Console.WriteLine("1");
    context.Dispose();
    Console.WriteLine("2");
    await channel.ShutdownAsync();
    Console.WriteLine("3");

    Console.ReadLine();
}

//--- 結果
/*
1
2
Shutdown
Disconnected
3
*/

サーバー側で切断検知

サーバー側でも接続していたクライアントがいなくなったことを検知して後処理を行いたいケースはよくあります。サーバー側での検知は以下のように行います。

public class SampleApi : ServiceBase<ISampleApi>, ISampleApi
{
    public async UnaryResult<Nil> Sample()
    {
        this.GetConnectionContext().ConnectionStatus.Register(() =>
        {
            Console.WriteLine("Disconnect detected!!");
        });
        return Nil.Default;
    }
}

ConnectionContext.ConnectionStatusCancellationToken 型になっていて、クライアントの切断が検知されたときに Cancel が発行される仕組みになっています。その Cancel に反応できるように Register メソッドで事前に処理を登録しておく感じです。

例えばクライアント側を以下のように実装したとすると、ChannelContext.Dispose を呼び出したタイミングでサーバー側で切断検知され「Disconnected detected!!」が表示されます。

static async Task MainAsync()
{
    var channel = new Channel("localhost", 12345, ChannelCredentials.Insecure);
    var context = new ChannelContext(channel, () => "xin9le");

    await context.WaitConnectComplete();
    var client = context.CreateClient<ISampleApi>();
    await client.Sample();  // サーバー側で切断検知できるようにする
    await Task.Delay(1000);

    context.Dispose();  // ここを呼び出すとサーバー側で切断検知が走る
    await channel.ShutdownAsync();
    Console.ReadLine();
}

クライアントの自動再接続を行う

トンネルに入って出たときや、サービスの一時的なダウンから復旧した場合などは、自動的に再接続して復旧してほしいものです。そう言った処理も先の切断検知のタイミングを利用すれば実現できます。例えば以下のような感じです。

static async Task MainAsync()
{
    var channel = new Channel("localhost", 12345, ChannelCredentials.Insecure);
    var context = new ChannelContext(channel, () => "xin9le");

    //--- 再接続処理
    context.RegisterDisconnectedAction(async () =>
    {
        Console.WriteLine("Reconnecting...");
        await context.WaitConnectComplete();  // 再接続待ち
        Console.WriteLine("Reconnected");
    });

    //--- 接続待機
    Console.WriteLine("Connecting...");
    await context.WaitConnectComplete();
    Console.WriteLine("Connected");

    //--- この待ち時間にサーバーを落としたり立ち上げたりしてみましょう
    await Task.Delay(30000);

    //--- 切断する
    Console.WriteLine("Shutdown");
    context.Dispose();
    await channel.ShutdownAsync();
    Console.ReadLine();
}

//--- 実行例
/*
Connecting...
Connected
Reconnecting...
Reconnected
Shutdown
*/

再接続には多少時間がかかりますが、自動でコネクションを復旧できるメリットは非常に大きいので是非実装にチャレンジしてみてください。

*1:Deplex Streaming を使って死活監視をしている