redwarrior’s diary

C#, ASP.NET, WEB, G*などの雑多な情報

PasswordBoxのバリデーションをMVVMアーキテクチャで実装する

前提のはなし

WPFでマスクのついたテキストボックスを作成するには、PasswordBoxというコントロールが用意されている。

PasswordBoxに入力した値は、string型のPasswordプロパティから取得できるが、セキュリティ上はSecureString型のSecurePasswordプロパティから取得した方が良いという話になっていた。

.NET Coreでは方針が変更されて、SecurePasswordプロパティは推奨されなくなった。それならばと、Passwordプロパティを使うことにした。

Passwordプロパティは、コードビハインドで使用するならば特に障害は無いが、MVVMアーキテクチャバインディングを利用しようとすると、バインディングできるように設計されていないので、単純にバインディングする事は出来ない。

バインディングできるようにヘルパークラスを作って公開している人がいるため、それを利用させてもらう。詳しくは、以下で説明されている。

mseeeen.msen.jp

garafu.blogspot.com

本題

PasswordBoxに入力した値をViewModelのプロパティにバインディング出来るようになったので、ViewModelにバリデーションを実装した。

上記サイトとあまり変わらないが、以下に作成したヘルパークラス(PasswordBoxHelper.cs)、XAMLの使用箇所と、バリデーションを実装したViewModel(CreateUserViewModel.cs)をのせておく。

PasswordBoxHelper.cs

internal class PasswordBoxHelper : DependencyObject
{
    public static readonly DependencyProperty AttachmentProperty
        = DependencyProperty.RegisterAttached(
            "Attachment",
            typeof(bool),
            typeof(PasswordBoxHelper),
            new FrameworkPropertyMetadata(false, AttachmentProperty_Changed));

    public static readonly DependencyProperty PasswordProperty
        = DependencyProperty.RegisterAttached(
            "Password",
            typeof(string),
            typeof(PasswordBoxHelper),
            new FrameworkPropertyMetadata(default(string), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

    public static bool GetAttachment(DependencyObject dp)
    {
        return (bool)dp.GetValue(AttachmentProperty);
    }

    public static void SetAttachment(DependencyObject dp, bool value)
    {
        dp.SetValue(AttachmentProperty, value);
    }

    public static string GetPassword(DependencyObject dp)
    {
        return (string)dp.GetValue(PasswordProperty);
    }

    public static void SetPassword(DependencyObject dp, string value)
    {
        dp.SetValue(PasswordProperty, value);
    }

    private static void AttachmentProperty_Changed(DependencyObject sender, DependencyPropertyChangedEventArgs e)
    {
        if (!(sender is PasswordBox passwordBox)) return;

        if ((bool)e.NewValue)
        {
            passwordBox.PasswordChanged += PasswordBox_PasswordChanged;
        }
    }

    private static void PasswordBox_PasswordChanged(object sender, RoutedEventArgs e)
    {
        var passwordBox = (PasswordBox)sender;
        SetPassword(passwordBox, passwordBox.Password);
    }
}

XAML抜粋

<UserControl x:Class="MainMenu.Views.CreateUser"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"
             xmlns:helper="clr-namespace:MainMenu.Utilities"
             prism:ViewModelLocator.AutoWireViewModel="True"
             Height="350" Width="500">
--省略
<PasswordBox Grid.Row="4" Grid.Column="1" VerticalAlignment="Top" HorizontalAlignment="Left"
         Width="200" Height="24"
         Validation.ErrorTemplate="{StaticResource ErrorTemplate}"
         helper:PasswordBoxHelper.Attachment="True"
         helper:PasswordBoxHelper.Password="{Binding Password, UpdateSourceTrigger=PropertyChanged}" />
--省略

CreateUserViewModel.cs

using Prism.Mvvm;
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Runtime.CompilerServices;

namespace MainMenu.ViewModels
{
    public class CreateUserViewModel : BindableBase, INotifyDataErrorInfo
    {
        private readonly ErrorsContainer<string> _errors;

        private string _password;

        [Required(ErrorMessage = "パスワードを入力してください")]
        public string Password
        {
            get { return _password; }
            set
            {
                if (SetProperty(ref _password, value))
                {
                    ValidateProperty(value);
                }
            }
        }

        public CreateUserViewModel()
        {
            _errors = new ErrorsContainer<string>(RaiseErrorsChanged);
        }

        protected void ValidateProperty(object value, [CallerMemberName] string propertyName = null)
        {
            var context = new ValidationContext(this) { MemberName = propertyName };
            var validationErrors = new List<ValidationResult>();
            if (!Validator.TryValidateProperty(value, context, validationErrors))
            {
                _errors.SetErrors(propertyName, validationErrors.Select(error => error.ErrorMessage));
            }
            else
            {
                _errors.ClearErrors(propertyName);
            }
        }

        private void RaiseErrorsChanged([CallerMemberName] string propertyName = null)
        {
            ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
        }

        public IEnumerable GetErrors(string propertyName) => _errors.GetErrors(propertyName);

        public bool HasErrors => _errors.HasErrors;

        public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
    }
}

解説

XAML)PasswordBoxコントロールのPasswordプロパティは依存関係プロパティではないので、ヘルパークラスで添付プロパティ(Password)を作成して、VIewModelのプロパティ(Password)とバインディングする。

(PasswordBoxHelper.cs)PasswordBoxのPasswordChangedイベントに処理を追加して、PasswordBoxの値が変わったら、ヘルパークラスの添付プロパティに同じ値を入力する。

XAML)ヘルパークラスの添付プロパティと、VIewModelのプロパティがバインディングされているので、ViewModelのプロパティ(Password)が更新される。

バリデーションをしない場合は、PasswordBoxの値がバインディングしたViewModelのプロパティから取得できれば良い、つまり画面からソースへの反映ができれば良く、ViewModelのプロパティをPasswordBoxに反映する、つまりソースから画面への反映は必要無いので、バインドモードはOneWayToSourceでも良いはず。

バリデーションはTextBoxコントロールみたいに、Validation.ErrorTemplateを使用した。

バリデーションを使用した場合は、ViewModelのバリデーション結果によって、画面を更新する(メッセージの表示等)ため、バインドモードはTwoWayを指定する必要があった。指定しないとバリデーションを設定しても行われなかった。

動作環境

  • .NET Core 3.1
  • Prism 7.2

以上

TFS 2018 で MSIXの発行を行う

Visual Studio 2019からのMSIX形式のアプリパッケージの作成は、他サイトを参考にして出来るようになったので、TFS上でビルドする方法を調査しました。

一応、今回は以下の状態で始めました。

  • .NET Core 3.1の32bitのWPFアプリケーション
  • Visual Studio 2019の画面操作で、アプリパッケージの作成が一度成功している
  • 配布方法は、サイドローディング
  • コード署名証明書は、証明書ストアから選択している
  • アプリケーション バンドルの生成は、常に行う

まずは、コマンドラインでビルドをしてみましたが、さっそく以下のエラーが発生。

error NETSDK1047:
資産ファイル 'C:\Users\(省略)\obj\project.assets.json'  に '.NETCoreApp,Version=v3.1/win-x86' のターゲットがありません。
復元が実行されたこと、および 'netcoreapp3.1' がプロジェクト の TargetFrameworks に含まれていることを確認してください。
プロジェクトの RuntimeIdentifiers に 'win-x86' を組み込む必要が生じる可能性もあります。 [C:\Users\(省略).csproj]

MSIXパッケージでは、自己完結型デプロイメントをするため、RuntimeIdentifiersの設定が必要らしいです。

techcommunity.microsoft.com

csprojファイルに以下を追加します。

<RuntimeIdentifiers>win-x86</RuntimeIdentifiers>

以下のコマンドで、コマンドラインからの作成が出来ました。(Developer PowerShell for VS 2019で実行しました)

> msbuild (プロジェクト名).wapproj /p:Configuration=Release /p:AppxBundlePlatforms=x86

プロパティなしで実行することも可能で、その場合はDebug構成のx86プラットフォームが選択されていました。指定しなかったプロパティは、wapprojファイルやappxmanifestファイルの値が使用されるようです。パッケージの作成とコード署名証明書による署名も行われていました。

TFSでのビルド、発行

コマンドラインからのビルドが出来たので、いよいよTFSでのビルドに入ります。

このサイトが参考になりました。対象がTFSではなく、後継のAzure DevOps(Azure Pipelines)なので、設定ファイルがYAMLで書かれているため、読むのがちょっと大変でした。

docs.microsoft.com

この記事は、MSDN Magagine 2019 の記事の一部ですが、PDF版もダウンロード可能です。PDF版はWeb版で省略された図や画面も含まれるので、PDF版を見た方が理解しやすいです。

docs.microsoft.com

コマンドラインでのビルドが出来ているので、それをタスクに置き換えます。

ただし、TFS 2018 だと「Visual Studioのビルド」タスクが、VS 2017までしか対応していないため、MSBuildタスクを使用し、ビルドを実行するPCのMSBuild へのパスを設定する必要があったので、以下のように設定しました。

MSBuildタスク
プロジェクト
**/*.wapproj

MSBuild へのパス
C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe

プラットフォーム
x86

構成
Release

MSBuild 引数
/p:AppxPackageDir=$(build.artifactstagingdirectory)\AppxPackages /p:AppxBundlePlatforms=x86

最後に、AppxPackagesフォルダを、配布用フォルダにコピーします。発行場所は「TFS」ではなく「A file share」を指定しました。ここはClickOnceの発行と同じですね。

成果物の公開タスク
発行するためのパス
$(build.artifactstagingdirectory)\AppxPackages

成果物名
AppxPackagesの中身が格納されるフォルダ名

ファイル共有パス
成果物が格納されるフォルダの親フォルダ

動作環境

  • TFS 2018 Update 3
  • .NET Core 3.1
  • ビルドマシン:Visual Studio 2019 Professional

以上

TFS 2018 のビルドが NuGet restore タスクで失敗したので調査する

少し前にソースコードの修正や、ビルド定義の編集をしていないのに、TFSのビルドが失敗するようになっていたので調査しました。

エラーメッセージ

MSBuild auto-detection: using msbuild version '16.5.0.12403' from 'C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\bin'.
Use option -MSBuildVersion to force nuget to use a specific version of MSBuild.
NuGet.CommandLine.CommandLineException:
Error parsing solution file at C:\TfsBuildsAgents\VstsAgent2\_work\1\s\XXXProduct.sln:
呼び出しのターゲットが例外をスローしました。
The project file could not be loaded.
ファイルまたはアセンブリ 'Microsoft.Build.Framework, Version=15.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'、またはその依存関係の 1 つが読み込めませんでした。
指定されたファイルが見つかりません。C:\TfsBuildsAgents\VstsAgent2\_work\1\s\XXXProduct.sln

エラーメッセージで調べてみると、MSBuildのバージョンに対して、指定しているNuGetのバージョンが古いらしい。使用しているのは、NuGet 4.4.1でした。

stackoverflow.com

ビルドエージェントを動かしているPCには、Visual Studio 2019がインストールされていて、エラーになる直前にアップデートした事を思い出しました。

サイトを参考にビルド定義を編集して「NuGet Tool インストーラー」で4.4.1としていたところを5.5.0に変更したところ、ビルドが成功するようになりました。

インストールされているNuGetのバージョンを確認するには、Microsoft Visual Studio のバージョン情報を見れば良いようです。

blog.house-soft.info

以上

追伸

つい最近、Visual Studio 2019 を 16.8以降にアップデートした後も、ビルドが失敗しました。その時のエラーメッセージは、Error NETSDK1005 netcoreapp3.1と出ていました。

エラーメッセージで調べると以下のサイトがヒットしました。

https://developercommunity.visualstudio.com/content/problem/1248649/error-netsdk1005-assets-file-projectassetsjson-doe.html

.NET 5対応のために、修正が入ったのかなと思い、こちらもビルド定義の「NuGet Tool インストーラー」のバージョンを5.5.0から5.8.0にしたところビルドが成功するようになりました。

ClickOnceで使用できたコード署名が、MSIXで使用できない理由を調査し、対応した

背景

この間、ClickOnceの発行をする時に、使用したコード署名証明書(コードサイニング証明書)をPCの証明書ストアにインストールしたので、 .NET Coreアプリケーションの配布形式であるMSIXでも試してみました。

MSIX形式で配布する方法を調べると、以下のサイトに詳しくのっていました。

devlog.grapecity.co.jp

サイトを参考に、.NET CoreのWPFアプリケーションを作成し、Windows アプリケーション パッケージ プロジェクト(以下、パッケージプロジェクト)をソリューションに追加しました。

パッケージプロジェクトを右クリックして、「公開」⇒「アプリ パッケージの作成」と進んで、「署名方法の選択」画面から、「Store から選択」をクリックします。

この後、証明書の選択ウィンドウが出てくるのですが、画面には「証明書がありません」のメッセージ。

??と思い、現在のソリューションを一旦終了して、.NET Frameworkプロジェクトを開き、プロジェクトのプロパティから署名メニューを選択すると、コード署名証明書はあります。「ストアからの選択」にも表示されます。

本題

ClickOnceで使用できたコード署名が、MSIXで使用できない理由を調べました。

パッケージプロジェクトの画面では、テスト用の証明書を作成する事が出来ます。作成したファイルは、プロジェクトにpfxファイルとして保存されます。

テスト用証明書を作成して、それを証明書ストアにインストールしてみました。その後、「Store から選択」を押してみると、インストールしたテスト用証明書が選択できます。

また、対象のコード署名証明書をファイルにエクスポートして、「ファイルから選択」でエクスポートしたファイルを選択してみました。すると「選択された証明書はコード署名には無効です。」というエラーメッセージが表示されました。

という事は、ClickOnceの発行に使用したコード署名証明書に問題がありそうです。エラーメッセージから検索したり、対象のコード署名証明書とテスト用証明書のフィールドを見比べると以下がわかりました。

フィールド:基本制限(basic constraints, oid=2.5.29.19)の値に Subject Type=End Entity が設定されていない。

アプリケーション パッケージへの署名 (Windows ストア アプリ) | Microsoft Docs

パッケージ署名用証明書を作成する - MSIX | Microsoft Docs

以前にルート証明書について調べたときに、この値はCAかどうかを指定すると書いてあったので、ルート証明書ではないので、Subject Typeは空欄にしていました。前述のようにClickOnceでは使えていました。

以下のように、PowerShellのNew-SelfSignedCertificateコマンドレットを使用して、基本制限を設定したテスト用証明書その2を作成したところ、「Store から選択」に表示されました。

> New-SelfSignedCertificate -FriendlyName "証明書を人が区別するための名前" -DnsName "団体名" -TextExtension @("2.5.29.19={text}") -Type CodeSigningCert -CertStoreLocation "Cert:\CurrentUser\My" -Subject "CN=xxx,O=yyy,OU=zzz,C=JP"

対応

テスト用ではないコード署名証明書は、Active Directory証明書サービス(Active Directory Certificate Services:AD CS)で発行されるため、修正方法を調べて発行しなおしてもらいました。

やったことは、AD CS上でのテンプレートの修正です。具体的には、互換性タブでの対象OSの変更と、拡張機能の基本制限の「有効にする」をチェックしました。

修正後に証明書を更新してもらったところ、新しいコード署名証明書が「Store から選択」に表示されました。

以上

Entity Framework で SQLite を使用する

NuGetでSystem.Data.SQLiteパッケージをインストールし、以下をApp.configに追加する。

  <entityFramework>
    <providers>
      <provider invariantName="System.Data.SqlClient"
                type="System.Data.Entity.SqlServer.SqlProviderServices, EntityFramework.SqlServer" />
      <provider invariantName="System.Data.SQLite"
                type="System.Data.SQLite.EF6.SQLiteProviderServices, System.Data.SQLite.EF6" />
      <provider invariantName="System.Data.SQLite.EF6"
                type="System.Data.SQLite.EF6.SQLiteProviderServices, System.Data.SQLite.EF6" />
    </providers>
  </entityFramework>
  <system.data>
    <DbProviderFactories>
      <remove invariant="System.Data.SQLite.EF6" />
      <add name="SQLite Data Provider (Entity Framework 6)" invariant="System.Data.SQLite.EF6"
           description=".NET Framework Data Provider for SQLite (Entity Framework 6)"
           type="System.Data.SQLite.EF6.SQLiteProviderFactory, System.Data.SQLite.EF6" />
      <remove invariant="System.Data.SQLite" />
      <add name="SQLite Data Provider" invariant="System.Data.SQLite"
           description=".NET Framework Data Provider for SQLite"
           type="System.Data.SQLite.SQLiteFactory, System.Data.SQLite" />
    </DbProviderFactories>
  </system.data>

リンク先を見てもらえばわかるが、System.Data.SQLiteは依存パッケージが設定されているため、一つインストールすれば必要なものが全て入るようになっている。

www.nuget.org

以上

TFS 2018 で ClickOnce の発行を行う

少し前に作成したWPFアプリケーションは、対象OSとしてWindows 8.1も含まれるため、フレームワークとして.NET Framework 4.5.1を使用しました。

Webアプリ(ASP.NET MVC)は、TFS上でビルド(XAMLではない)、リリースが出来るようにしたので(下記参照)

redwarrior.hateblo.jp

今回はデスクトップアプリケーションのビルド、ClickOnceによる発行をTFS上で試しました。

はじめに

TFSはビルド、リリースに分かれていて、ビルドが対象のビルドを行い、リリースで各環境への配置を行います。 ASP.NET MVCでは、リリースでIISの設定を行うため、ビルドとリリースに分けて使いました。

しかし、ClickOnceは、MSBuildのパラメータでビルド/発行を切り替えるし、発行先は1か所なので、リリースを使用せず、ビルドだけを使用しました。

作業開始

まず、「.NET デスクトップ」テンプレートを使用してビルド定義を作成しました。

WPFアプリケーションで、Prismを使用するためにPrism Template Packでプロジェクトを作成したところ、NuGetパッケージの管理方式に「PackageReference」を使用しており、リポジトリにpackagesフォルダが存在せず、リポジトリに使用するDLLを含めなくなりました。

そのため、NuGet関連タスクは削除せずに残すことにして、早速、ビルドを試しました。これはテンプレートのままで上手くいきました。

次に、ClickOnceの発行を試しました。「ソリューションのビルド」タスクのMSBuild引数で/target:publishとすると、ClickOnce用のファイルが、app.publishフォルダに作成されます。

VSの画面から発行した場合とは違い、サーバへのファイルコピーは行われません。

以前にコマンドラインから実行したときは、csprojファイルを編集して、コピー処理を追加しました。これを今回はTFSのビルド定義のタスクで行う必要があります。

いろいろと試してみた結果、最終的にはファイルのコピー用に、二つのタスクを作成しました。

ファイルのコピータスク

ビルド結果から、app.publishフォルダだけをコピーします

ソースフォルダ
$(system.defaultworkingdirectory)

コンテンツ
**\bin\$(BuildConfiguration)\app.publish\**
!**\bin\$(BuildConfiguration)\app.publish\$(VsProject).exe

ターゲット フォルダー
$(build.artifactstagingdirectory)
成果物の公開タスク

app.publishフォルダを、配布用フォルダにコピーします。発行場所は、「TFS」ではなく「A file share」を指定しました。

発行するためのパス
$(build.artifactstagingdirectory)\$(VsSolution)\bin\$(BuildConfiguration)\app.publish

成果物名
app.pubslihの中身が格納されるフォルダ名

ファイル共有パス
成果物が格納されるフォルダの親フォルダ

VsProject、VsSolution変数は、変数タブで作成し、値としてプロジェクト名、ソリューション名を登録しました。

つまづいた点

最初はファイルのコピータスクで、配布用フォルダへのコピーまでやろうとしたが、ソースフォルダ(リポジトリのルートパス)からのコピーになってしまい、階層が深くなってしまったので、分けることにしました。

成果物の公開タスクで、ファイルが圧縮されてしまわないか心配しましたが大丈夫でした(フォルダの内容がそのままコピーされました)

以上

Entity Framework CoreのInsert高速化 & InsertOrUpdateを行うライブラリ

数万件単位のレコードを登録するバッチアプリケーションを作成することになった。

公開されているWeb APIからデータを取得して、かなり編集をしてから登録するため、ストアドプロシージャは適していないし、生のSQLでやるのもミスが多そうなので避けたい。となると、Entity Frameworkを使うことになる。最新は Entity Framework Coreという名前になっているので、こちらを使った。

登録用データの作成までは良かったが、実際に動かしてみると、それなりに時間がかかった。スケジューラーに組み込んでいる他の処理との関係で、登録にかかる時間は短ければ短いほど良いので、大きく変えずに高速化する方法を調べると、公式サイトに以下のページがあった。

docs.microsoft.com

github.com

ここで紹介されているライブラリ「EFCore.BulkExtensions」を導入すると、Insert高速化 & InsertOrUpdateが出来るようになった。具体的な記録は取っていないけど10万レコードで試した所、確かに速くなっているように感じた。InsertOrUpdate機能も要件にマッチしていたので、これを採用することにした。

サイトの方に使い方がのっているが、簡単なサンプルを残しておく。削除は要件になかったので、使用せず。

// EntityクラスBookを作成(POCO)
Book book = new Book {
    // プロパティを設定
}

// Insert Or Update処理。SQLのMERGE文が呼び出される
// contextは、Entity Frameworkで作成したDbContextのサブクラス
// 1レコードでもリストを作成する
context.BulkInsertOrUpdate(new List<Book> { book });

実際に使用した時は、リストに全て含めると、内1件でエラーが発生した時にどこでエラーになったかがSQLを見てもわからないため、数件ずつのリストに分割して呼び出すようにした。

以上