PasswordBoxのバリデーションをMVVMアーキテクチャで実装する
前提のはなし
WPFでマスクのついたテキストボックスを作成するには、PasswordBoxというコントロールが用意されている。
PasswordBoxに入力した値は、string型のPasswordプロパティから取得できるが、セキュリティ上はSecureString型のSecurePasswordプロパティから取得した方が良いという話になっていた。
.NET Coreでは方針が変更されて、SecurePasswordプロパティは推奨されなくなった。それならばと、Passwordプロパティを使うことにした。
Passwordプロパティは、コードビハインドで使用するならば特に障害は無いが、MVVMアーキテクチャでバインディングを利用しようとすると、バインディングできるように設計されていないので、単純にバインディングする事は出来ない。
バインディングできるようにヘルパークラスを作って公開している人がいるため、それを利用させてもらう。詳しくは、以下で説明されている。
本題
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の設定が必要らしいです。
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で書かれているため、読むのがちょっと大変でした。
この記事は、MSDN Magagine 2019 の記事の一部ですが、PDF版もダウンロード可能です。PDF版はWeb版で省略された図や画面も含まれるので、PDF版を見た方が理解しやすいです。
コマンドラインでのビルドが出来ているので、それをタスクに置き換えます。
ただし、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でした。
ビルドエージェントを動かしているPCには、Visual Studio 2019がインストールされていて、エラーになる直前にアップデートした事を思い出しました。
サイトを参考にビルド定義を編集して「NuGet Tool インストーラー」で4.4.1としていたところを5.5.0に変更したところ、ビルドが成功するようになりました。
インストールされているNuGetのバージョンを確認するには、Microsoft Visual Studio のバージョン情報を見れば良いようです。
以上
追伸
つい最近、Visual Studio 2019 を 16.8以降にアップデートした後も、ビルドが失敗しました。その時のエラーメッセージは、Error NETSDK1005 netcoreapp3.1
と出ていました。
エラーメッセージで調べると以下のサイトがヒットしました。
.NET 5対応のために、修正が入ったのかなと思い、こちらもビルド定義の「NuGet Tool インストーラー」のバージョンを5.5.0から5.8.0にしたところビルドが成功するようになりました。
ClickOnceで使用できたコード署名が、MSIXで使用できない理由を調査し、対応した
背景
この間、ClickOnceの発行をする時に、使用したコード署名証明書(コードサイニング証明書)をPCの証明書ストアにインストールしたので、 .NET Coreアプリケーションの配布形式であるMSIXでも試してみました。
MSIX形式で配布する方法を調べると、以下のサイトに詳しくのっていました。
サイトを参考に、.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は依存パッケージが設定されているため、一つインストールすれば必要なものが全て入るようになっている。
以上
TFS 2018 で ClickOnce の発行を行う
少し前に作成したWPFアプリケーションは、対象OSとしてWindows 8.1も含まれるため、フレームワークとして.NET Framework 4.5.1を使用しました。
Webアプリ(ASP.NET MVC)は、TFS上でビルド(XAMLではない)、リリースが出来るようにしたので(下記参照)
今回はデスクトップアプリケーションのビルド、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変数は、変数タブで作成し、値としてプロジェクト名、ソリューション名を登録しました。
つまづいた点
最初はファイルのコピータスクで、配布用フォルダへのコピーまでやろうとしたが、ソースフォルダ(リポジトリのルートパス)からのコピーになってしまい、階層が深くなってしまったので、分けることにしました。
成果物の公開タスクで、ファイルが圧縮されてしまわないか心配しましたが大丈夫でした(フォルダの内容がそのままコピーされました)
- TFS 2018 Update 3
- .NET Framework 4.5.1
以上
Entity Framework CoreのInsert高速化 & InsertOrUpdateを行うライブラリ
数万件単位のレコードを登録するバッチアプリケーションを作成することになった。
公開されているWeb APIからデータを取得して、かなり編集をしてから登録するため、ストアドプロシージャは適していないし、生のSQLでやるのもミスが多そうなので避けたい。となると、Entity Frameworkを使うことになる。最新は Entity Framework Coreという名前になっているので、こちらを使った。
登録用データの作成までは良かったが、実際に動かしてみると、それなりに時間がかかった。スケジューラーに組み込んでいる他の処理との関係で、登録にかかる時間は短ければ短いほど良いので、大きく変えずに高速化する方法を調べると、公式サイトに以下のページがあった。
ここで紹介されているライブラリ「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を見てもわからないため、数件ずつのリストに分割して呼び出すようにした。
以上