Visual Studio 2019 で WCF Data Services のクライアントアプリケーションを開発する
過去の記事でサーバ側のアプリケーション(Web API)について書きましたが、クライアントアプリケーションを新しく作ることになりました。
Visual Studio 2015で開発するならば、特に困らないのですが、Visual Studio 2019で開発したいと思い調べました。
手順
- Visual Studio 2019で .NET Framework のWindows フォームを選択しプロジェクトを作成しました。
- プロジェクトを右クリックして、追加→サービス参照と選択し、「サービス参照の追加」画面で WCF Data Services のURLを入力して、移動をクリックします。
- Visual Studio 2015ならば、この後サービスの欄に使用できるサービスが表示され、選択してOKを押すとサービス参照が追加されます。
Visual Studio 2019では、3番でエラーが発生してしまいます。詳細のリンクをクリックすると以下のメッセージが表示されます。
指定された OData API を追加できません。現在 OData API は、OData クライアント コード生成ツールと併用する場合にのみサポートされています。
詳細については、次の資料をご覧ください。
リンク先を読み進めると、OData Connected Service という拡張機能があったので、これで行けるかなと思いインストールして試してみました。
プロジェクトを右クリックして、今度は追加→接続済みサービスと選択して、「その他のサービス」から OData Connected Service を選択して、Address欄に WCF Data Services のURLを入力しました。
次へボタンをクリックして、次の画面へ向かったのですが、そこには何も表示されず・・・。この拡張機能を使うのはあきらめました。
Visual Studio 2019での開発は無理かなと思いつつ、Visual Studio Marketplace で検索して見つけた(個人が開発しているっぽい)Unchase OData Connected Service という拡張機能を使用してみたところ、こちらは動作しました!!!
以下のMS公式サイトのチュートリアルは、動作するのを確認できたので、これで開発を進めてみようと思います。
Visual Studio Marketplace
ちなみに、Web API側を作りたい場合も、プロジェクトテンプレートはなくなっていますが、また別の人が作った拡張機能がありました。
Visual Studio Marketplace
確認環境
- Visual Studio 2019 Professional
以上
Visual Studioのファイルのプロパティのビルド アクションの「コンテンツ」と「なし」の違い
Visual Studioの ファイルのプロパティ のビルド アクションの値で「コンテンツ」と「なし」の違いを調べました。
Visual Studio上でファイルを選択して ファイルのプロパティ に表示される項目のうち、出力ディレクトリにコピー は、値を「常にコピーする」か「新しい場合はコピーする」にすると、選択したファイルが出力ディレクトリにコピーされるので、何を意味するかは明確です。
一方、ビルド アクションは、公式サイトに説明があり、これを読むと「コンテンツ」は何となくわかった気になりますが、
「なし」の項目には、以下のように書かれています。
ファイルはいかなる形でもビルドに含まれません。 この値は、"ReadMe" ファイルなどのドキュメント ファイルで使用できます。
「なし」はビルドに含まれないと書いてあるのに、出力ディレクトリにコピー を適切に設定していれば、出力ディレクトリにコピーされます。
ビルド アクション は「コンテンツ」した方が良いのか、「なし」のままでが良いのか疑問に思っていました。
よくわからないので、「コンテンツ」の説明にあるリンクを辿っていくと以下のサイトに情報がありました。
かいつまんで書くと、
「コンテンツ」は、アセンブリには含まれないけど、ビルド時にアセンブリと関連付けることができる。アセンブリとは独立しているので、個別に更新をすることができる。ビルド時にファイルが必要。XAMLからはファイル名のみで簡単に参照できる。
「なし」は、Site of Origin Filesと呼ばれるタイプを設定する方法。Site of Origin Filesは、アセンブリと関連のないファイルで、実行時に参照したい場合に使用する。こちらも個別に更新をすることができる。XAMLからはPack URI形式で参照できる。ビルド時に存在しないファイルを参照したり、URLで参照することができる。
どう使えば良いのか
XAMLで使うかどうかが判断基準になりそうです。ビルド時にプロジェクトに存在している背景やアイコン等の画像系は「コンテンツ」に設定します。
一方、XAMLで使用する事がないEXEファイルやインストーラは「なし」に設定するのが良さそうです。
検証してみた
実際に、XAMLでImageタグを2つ用意し、ビルド アクションを「コンテンツ」にした画像と、「なし」にした画像のファイル名をそれぞれ指定すると、Visual Studioのデザイナーでは両方とも表示されますが、 デバッグ実行すると、「コンテンツ」にした画像は表示され、「なし」にした画像は表示されませんでした。
確認環境
- Visual Studio 2019 Professional
- .NET Core 3.1
以上
MSIX形式で配布すると、プロジェクトに含めたファイルが見つからない場合の対処方法
WPFプロジェクトに画像ファイルや、実行ファイル等を含めて、プログラムの中から参照したり、実行したりするのは珍しいことではないと思います。
Visual Studio上のソリューションエクスプローラーで対象のファイルをクリックして、プロパティから「出力ディレクトリにコピー」で、「新しい場合はコピーする」や「常にコピーする」を選択してビルドすれば、出力フォルダ(binディレクトリ配下の所定の場所)にコピーされます。
ファイル名(ディレクトリを作成している場合は、ディレクトリ名\ファイル名)を指定するだけで、プログラム上から参照したり、実行したりする事ができます。
Visual Studio上でのデバッグ実行や、出力フォルダにあるアプリケーションを実行した場合は問題ありませんでした。
しかし、それをMSIX形式にパッケージングして配布したら、ファイルが見つかりませんという例外が発生してしまったので、対処方法を調べました。
調査開始
まず、MS公式サイトを確認しましたが、よくわからなかったです。
次に、しばやん(id:shiba-yan)さんが、実行ファイルのパスについて書いていたので、試すことにしました。
加えて、以下のサイトも参考にしながら、取得するパスを追加しました。
techinfoofmicrosofttech.osscons.jp
実際には、以下のコードで試しました。
Logger.Debug($"Assembly.GetExecutingAssembly().Location\n -> {Assembly.GetExecutingAssembly().Location}"); Logger.Debug($"Environment.GetCommandLineArgs()[0]\n -> {Environment.GetCommandLineArgs()[0]}"); Logger.Debug($"Environment.CurrentDirectory\n -> {Environment.CurrentDirectory}"); Logger.Debug($"AppContext.BaseDirectory\n -> {AppContext.BaseDirectory}"); Logger.Debug($"AppDomain.CurrentDomain.BaseDirectory\n -> {AppDomain.CurrentDomain.BaseDirectory}"); Logger.Debug($"MainModule.FileName\n -> {Process.GetCurrentProcess().MainModule?.FileName}"); //階層を下ってディレクトリ、ファイルを全て表示 Logger.Debug("AppDomain.CurrentDomain.BaseDirectory All Entries"); foreach (var entry in Directory.EnumerateFileSystemEntries(AppDomain.CurrentDomain.BaseDirectory, "*.*", SearchOption.AllDirectories)) { Logger.Debug(entry); }
調査結果
Environment.CurrentDirectoryがC:\WINDOWS\System32
を返していて、他はC:\Program Files\WindowsApps
配下のアプリケーション個別のパスを返していました。
AppDomain.CurrentDomain.BaseDirectory
で得られたパスの階層を下っていくと、プロジェクトに含めたファイルがありました。
対処方法
ファイル名だけを指定した場合は、Environment.CurrentDirectoryのパス配下していると想定できるので、
AppDomain.CurrentDomain.BaseDirectory
で得られたパスを使用して、絶対パスを作成することで、プロジェクトに含まれたファイルを参照・実行する事が出来ました。
以上
(小ネタ).NET CoreのWPFアプリケーションから、.NET Frameworkのコンソールアプリケーションを実行する方法
タイトルの内容をやりたい人はあまりいないと思いますが、必要になったので調べました。
以下が作業手順になります。
- .NET CoreのWPFアプリケーション、.NET Frameworkのコンソールアプリケーションプロジェクトをソリューション内にそれぞれ作成します。
- WPFアプリケーションプロジェクトを右クリックし、追加→プロジェクト参照と選択し、コンソールアプリケーションプロジェクトを追加します。
- WPFアプリケーションで、ProcessクラスのStartメソッドを引数に「コンソールアプリケーションをビルドして作成されたexeファイル名」を渡して、呼び出します。
プロジェクト参照を追加することで、ビルド時にコンソールアプリケーションをビルドしたファイルを、WPFアプリケーションの出力フォルダにまとめて配置してくれるので、exeファイル名を指定だけで実行できるのがとても楽です。
補足 コマンドラインでのビルドについて
Visual Studio上で実行する場合は良いのですが、CI/CDのためにコマンドラインでビルドしたい場合、dotnet build
コマンドを実行すると、.NET Framewrokプロジェクトのビルドで失敗してしまいます。
msbuild
コマンドを実行すると、ビルドが成功したため、こちらを使用しました。
動作環境
- TFS 2018 Update 3
- .NET Core 3.1/.NET Framework 4.8
- ビルドマシン:Visual Studio 2019 Professional
以上
Prismを使用してサブウィンドウでRegionを使用した場合に発生したエラーと対処方法・その2
一つ前の記事で、サブウィンドウでもMainWindow(親ウィンドウ)と同一のRegionManagerを使用するように設定した。そうしたら、新たな問題が発生した。
サブウィンドウを閉じて、また開くとエラーが発生する
サブウィンドウでもMainWindow(親ウィンドウ)と同一のRegionManagerを使用するようにした状態で、サブウィンドウを一度閉じて、再び親ウィンドウからサブウィンドウを開くと例外が発生した。
例外ログ
Prism.Regions.Behaviors.RegionCreationException: An exception occurred while creating a region with name 'ContentRegion'. The exception was: System.ArgumentException: Region with the given name is already registered: ContentRegion at Prism.Regions.RegionManager.RegionCollection.Add(IRegion region) at Prism.Regions.Behaviors.RegionManagerRegistrationBehavior.TryRegisterRegion() at Prism.Regions.Behaviors.RegionManagerRegistrationBehavior.StartMonitoringRegionManager() at Prism.Regions.Behaviors.RegionManagerRegistrationBehavior.OnAttach() at Prism.Regions.RegionBehavior.Attach() at Prism.Regions.RegionBehaviorCollection.Add(String key, IRegionBehavior regionBehavior) at Prism.Regions.RegionAdapterBase`1.AttachDefaultBehaviors(IRegion region, T regionTarget) at Prism.Regions.RegionAdapterBase`1.Initialize(T regionTarget, String regionName) at Prism.Regions.RegionAdapterBase`1.Prism.Regions.IRegionAdapter.Initialize(Object regionTarget, String regionName) at Prism.Regions.Behaviors.DelayedRegionCreationBehavior.CreateRegion(DependencyObject targetElement, String regionName).
どうやら、サブウィンドウのRegionを2重に設定しようとして発生しているようだ。
それならば、サブウィンドウのClosingイベントで何らかの処理をすれば良さそうだ。
試しに、Regionに設定しているViewを削除する処理を入れてみたが、エラーは変わらず。
次に、RegionManagerに登録されているRegion自体を削除する処理を入れたところ、サブウィンドウを閉じたり開いたりしても例外が発生しなくなった。
public IRegionManager MainRegionManager { get; } public DelegateCommand ClosingCommand { get; set; } public SubWindowViewModel(IRegionManager regionManager) { MainRegionManager = regionManager; ClosingCommand = new DelegateCommand(ExecuteClosing); } private void ExecuteClosing() { MainRegionManager.Regions.Remove("ContentRegion"); }
動作環境
- .NET Core 3.1
- Prism 7.2
以上
Prismを使用してサブウィンドウでRegionを使用した場合に発生したエラーと対処方法・その1
Prismを使用したWPFアプリケーションを開発していて、サブウィンドウでRegionを使用した場合に発生したエラーと対処方法
次の画面に遷移できない
現象
Regionを使用した画面遷移
ウィンドウのXAMLにRegionを設定し、コードビハインドで、IRegionManagerインターフェースのRegisterViewWithRegionメソッドを呼び出して、ユーザーコントロールとRegionを紐づける。
<Window x:Class="Regions.Views.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:prism="http://prismlibrary.com/" Title="Shell" Height="350" Width="525"> <Grid> <ContentControl prism:RegionManager.RegionName="ContentRegion" /> </Grid> </Window>
コードビハインド
using System.Windows; namespace Regions.Views { public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); regionManager.RegisterViewWithRegion("ContentRegion", typeof(ViewA)); } } }
実行すると、ウィンドウにはユーザーコントロールの内容が表示される。
ユーザーコントロール上にあるボタンをクリックして、次の画面に遷移をしたいとする。Regionに設定されているユーザーコントロールを別のものに置き換えることで、画面遷移したように見せることが出来る。
具体的には、DIの設定時にRegisterForNavigationメソッドを使用してユーザーコントロールを登録しておき、ViewModelでIRegionManagerのRequestNavigateメソッドにRegionNameと表示したいユーザーコントロールを指定することで置き換えることができる。
App.xaml.cs(DI部分)
protected override void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterForNavigation<ViewB>(); }
XAML(遷移ボタン)
<StackPanel Orientation="Horizontal" DockPanel.Dock="Top" Margin="5" > <Button Command="{Binding NavigateCommand}" CommandParameter="ViewB" Margin="5">Navigate to View B</Button> </StackPanel>
ViewModel(遷移処理)
public DelegateCommand<string> NavigateCommand { get; private set; } public MainWindowViewModel(IRegionManager regionManager) { _regionManager = regionManager; NavigateCommand = new DelegateCommand<string>(Navigate); } private void Navigate(string navigatePath) { if (navigatePath != null) _regionManager.RequestNavigate("ContentRegion", navigatePath); }
これはMainWindowでやると問題なく成功する(上記サンプルの詳細はこちらを参照)。
サブウィンドウでRegionを使用する
ある画面をサブウィンドウとして開くために、親ウィンドウから、サブウィンドウのShowメソッドもしくは、ShowDialogメソッドを呼び出した。
var subWindow = _container.Resolve<SubWindow>(); subWindow.ShowDialog();
サブウィンドウでも画面の切り替えをしたいので、サブウィンドウのXAMLにRegionを設定し、同様のコードを書いたが、実行すると次の画面に遷移できない(ユーザーコントロールが切り替わらない)。
対処方法
デバッグ実行でRegionManagerに登録されているRegionを調べてみると、サブウィンドウで指定したRegionNameが存在しない。 MainWindow(親ウィンドウ)でもRegionを設定して試してみると、MainWindowのRegionはあった。
ここから、ViewModelのDIで取得できるのは、MainWindowと関連付けられたRegionManagerであることがわかった。
サブウィンドウのRegionが、MainWindowのRegionManagerに登録できていない。登録がないため、RegionNameを指定しても画面遷移ができない。
調べてみると、サブウィンドウのXAMLで、RegionNameだけではなく、RegionManagerも指定出来ることがわかった。
ViewModelでDIした(MainWindowの)RegionManagerを、サブウィンドウのXAMLでRegionManagerに指定することで、 サブウィンドウでもMainWindowのRegionManagerを使用する事ができた。*1
<ContentControl prism:RegionManager.RegionName="SubContentRegion" prism:RegionManager.RegionManager="{Binding MainRegionManager}"/>
ViewModel
public IRegionManager MainRegionManager { get; } public DelegateCommand<string> NavigateCommand { get; private set; } public SubWindowViewModel(IRegionManager regionManager) { MainRegionManager = regionManager; NavigateCommand = new DelegateCommand<string>(Navigate); } private void Navigate(string navigatePath) { if (navigatePath != null) MainRegionManager.RequestNavigate("SubContentRegion", navigatePath); }
これでRegionNameを分けることで、サブウィンドウでもRegionを使用した画面遷移が出来るようになった。
動作環境
- .NET Core 3.1
- Prism 7.2
以上
*1:IRegionManagerインターフェースのCreateRegionManagerメソッドを呼び出すことで、新しいRegionManagerを取得する事もできる
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
以上