WPFアプリケーションのReactiveUIとMVVMパターン
公開: 2022-03-11リアクティブプログラミングは、データストリームと変更の伝播に関係する非同期プログラミングパラダイムです。 –ウィキペディア
その文を読んだ後でも、私が最初に読んだときと同じようになってしまう可能性があります。その関連性を理解するのにこれほど近いところはありません。 基本的な概念にもう少し専念すれば、その重要性をすぐに理解できます。 基本的に、リアクティブプログラミングは、最初は「ステロイドのイベント駆動型プログラミング」と考えることができます。 イベントハンドラーをストリームとして描き、ハンドラーの各起動をストリーム内の新しいデータと考えます。 一言で言えば、最終的にはリアクティブプログラミングです。
リアクティブプログラミングについてもう少し詳しく説明する前に、理解しておく必要のある概念がいくつかあります。 オブザーバブルは、私たちが話しているストリームへのアクセスを提供するオブジェクトです。 それらの目的は、ストリーム内のデータへのウィンドウを提供することです。 そのウィンドウが開かれると、演算子を使用して選択した方法でデータを確認し、アプリケーションがストリームにいつどのように反応するかを決定できます。 最後に、結果のストリームでオブザーバーを定義して、ストリームによって新しいデータムが発行されるたびに発生するアクションを定義します。
実際には、これは、ユーザーがボタンをクリックするか、アプリがHTTP応答を受信するか、例外から回復するかなど、発生していることにアプリケーションがどのように反応するかをより細かく制御できることを意味します。 リアクティブプログラミング(多くあります)を使用する利点を理解し始めると、元に戻すことはほとんどできなくなります。 これは、アプリが行うことのほとんどが、特定の不測の事態に特定の方法で反応するためです。
さて、これはこの新しいアプローチにマイナス面がないという意味ではありません。 まず第一に、その学習曲線はかなり急になる可能性があります。 開発者(ジュニア、シニア、アーキテクトなど)が、最初に何を書くべきか、コードが実行される順序、またはエラーをデバッグする方法を理解するのに苦労している様子を直接見てきました。 これらの概念を最初に紹介するときの私の推奨事項は、多くの例を示すことです。 開発者は、物事がどのように機能し、使用されるのかを理解し始めると、そのコツをつかむでしょう。
2010年に初めてWindowsPresentationFoundation(WPF)を使用する前に、デスクトップアプリ(主にVisual Basic 6、Java Swing、およびWindowsフォーム)を使用してきました。基本的に、WPFは次の目的で作成されました。 .NETの最初のデスクトップ開発フレームワークであるWindowsフォームに取って代わります。
WPFフォームとWindowsフォームの主な違いはかなりありますが、最も重要なものは次のとおりです。
- WPFは、より堅牢で徹底的にテストされた新しい開発パラダイムを使用します。
- WPFを使用すると、UIの設計とコーディングを強力に分離できます。
- WPFを使用すると、UIをさまざまにカスタマイズおよび制御できます。
WPFとその機能を学び始めたら、絶対に気に入りました。 MVVMパターンの実装がいかに簡単で、プロパティバインディングがどれほどうまく機能するかを信じられませんでした。 リアクティブプログラミングとそのWPFでの使用法に出くわすまで、その作業方法を改善するものは何も見つからないと思いました。
この投稿では、MVVMパターンを使用したリアクティブプログラミングを使用したWPFアプリの非常に単純な実装を示し、RESTAPIにアクセスできるようにしたいと考えています。
アプリケーションは次のことができるようになります。
- 車とその場所を追跡する
- シミュレートされたソースから取得した情報を取得します
- この情報をBingMapsWPFコントロールでユーザーに表示します
建築学、建築物、建築様式
RESTful Web APICore2サービスを使用するWPFクライアントを構築します。
クライアント側:
- WPF
- ReactiveUI
- 依存性注入
- MVVMパターン
- 修理
- BingMapsWPFコントロール
- テスト目的でのみ、Postmanを使用します
サーバー側:
- .NET C#WebAPIコア2
- 依存性注入
- JWT認証
必要なもの:
- Visual Studio 2017コミュニティ(またはお持ちのエディション)
バックエンド
クイックスタート
ASP.NETCoreWebアプリケーションを使用して新しいVisualStudioソリューションを開始します。
WPFアプリのバックエンドとしてのみ使用するため、APIとして構成します。
最終的には、次のような構造のVSソリューションになります。
これまでのところ、RESTAPIバックエンドを開始するために必要なものはすべて揃っています。 プロジェクトを実行すると、JSONオブジェクトを使用したREST呼び出しへの応答を表示するIIS ExpressでホストされているWebサイトを指すWebブラウザー(Visual Studioで設定されているブラウザー)が読み込まれます。
次に、RESTサービスのJWT認証を設定します。
startup.cs
ファイルの最後に、次の行を追加します。
static readonly byte[] JwtKey = Encoding.ASCII.GetBytes(@"this is a test key"); private void LoadJwtAuthorization(IServiceCollection services) { services.AddAuthentication(x => { x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(x => { x.Events = new JwtBearerEvents { OnTokenValidated = context => { var userId = int.Parse(context.Principal.Identity.Name); if (userId == 0) { //Handle user validation against DB context.Fail("Unauthorized"); } return Task.CompletedTask; } }; x.RequireHttpsMetadata = false; x.SaveToken = true; x.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(JwtKey), ValidateIssuer = false, ValidateAudience = false }; }); }
また、 ConfigureServices
メソッド内で、 AddMvc
メソッドが呼び出される前に作成したばかりのメソッドを呼び出します。
public void ConfigureServices(IServiceCollection services) { LoadJwtAuthorization(services); services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); }
最後に、次のようにConfigure
メソッドを調整します。
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseAuthentication(); app.UseMvc(); }
これまでのところ、コントローラーで使用されるJWT認証が定義されている場合はそれを確立しました。 次に、説明した認証を使用するようにコントローラーを調整します。
ValuesController
で、 AuthorizeAttribute
を追加して、次のようにします。
[Route("api/[controller]")] [ApiController] [Authorize] public class ValuesController : ControllerBase { ... }
ここで、サービスを実行しようとすると、次のような401Unauthorizedエラーが発生します。
したがって、ユーザーを認証するためのメソッドを追加する必要があります。 ここでは簡単にするために、同じValuesController
クラスで実行します。
[AllowAnonymous] [HttpPost("authenticate")] public IActionResult Authenticate([FromBody]JObject userInfo) { var username = userInfo["username"].ToString(); var password = userInfo["password"].ToString(); //We would validate against the DB if (username != "user" || password != "123") { return BadRequest(new { message = "Username or password is incorrect" }); } // return basic user info (without password) and token to store on the front-end return Ok(CreateUserToken(1)); } private string CreateUserToken(int userId) { var tokenHandler = new JwtSecurityTokenHandler(); var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, userId.ToString()) }), Expires = DateTime.UtcNow.AddDays(7), SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Startup.JwtKey), SecurityAlgorithms.HmacSha256Signature) }; var token = tokenHandler.CreateToken(tokenDescriptor); return tokenHandler.WriteToken(token); }
これで、匿名アクセスを使用するメソッドが作成されました。つまり、認証されていないクライアントも含め、すべてのクライアントがJSONオブジェクトを含むPOSTメッセージを使用してメソッドを呼び出し、ユーザー名に文字列、パスワードに文字列を渡すことができます。
Postmanで確認すると、次のようになります。
ご覧のとおり、authenticateメソッドの結果は、APIに対して実行するすべての呼び出しのトークンとして使用する必要のある文字列そのものでした。
トークンがメッセージのヘッダーに含まれると、検証が行われ、正しいパラメーターが渡されると、サービスはメソッドを実行してその値を返します。 たとえば、値コントローラーを呼び出してトークンを渡すと、以前と同じ結果が得られます。
次に、追跡している現在の車の緯度と経度を取得するメソッドを作成します。 繰り返しになりますが、簡単にするために、これはダミーのメソッドであり、最初はランダムな場所に戻り、メソッドが呼び出されるたびに一定の距離だけ車を動かし始めます。
まず、 ValuesController
クラスのGet(int id)
メソッドを調整して、次のようにします。
[HttpGet("{id}")] public ActionResult<string> Get(int id) { var location = LocationHelper.GetCurrentLocation(id); dynamic jsonObject = new JObject(); jsonObject.Latitude = location.latitude; jsonObject.Longitude = location.longitude; return jsonObject.ToString(); }
次に、追跡されている車の現在および将来の場所を処理する新しいLocationHelper
クラスを追加します。
public static class LocationHelper { private static readonly Random Randomizer = new Random(); private const double PositionDelta = 0.0001d; internal static (double latitude, double longitude) GetCurrentLocation(int id) { if (!Locations.ContainsKey(id)) { Locations.Add(id, default((double latitude, double longitude))); } //This method updates the last known location for the car and simulates its movement UpdateLocation(id); return Locations[id]; } private static void UpdateLocation(int id) { (double latitude, double longitude)loc = Locations[id]; //If the default value is found, randomly assign a starting point. if (loc.latitude == default(double) && loc.longitude == default(double)) { loc = Locations[id] = GetRandomStartingPoint(); } if (Randomizer.Next(2) > 0) { //In this scenario we simulate an updated latitude loc.latitude = loc.latitude + PositionDelta; } else { //Simulated longitude change loc.longitude = loc.longitude + PositionDelta; } Locations[id] = loc; } private static (double latitude, double longitude) GetRandomStartingPoint() { //Set inside the continental US return (Randomizer.Next(31, 49), Randomizer.Next(-121, -75)); } private static readonly Dictionary<int, (double latitude, double longitude)> Locations = new Dictionary<int, (double latitude, double longitude)>(); }
バックエンドは以上です。
フロントエンド:
次に、新しいWPFアプリを作成します。 作成すると、VisualStudioは次の構造の新しいプロジェクトをソリューションに追加します。
Bing Maps Control:
Bing MapsでWPFコントロールを使用するには、SDK(上記参照)をインストールし、WPFアプリケーションへの参照として追加する必要があります。 インストールした場所によっては、DLLが別のパスにある場合があります。 デフォルトの場所にインストールし、次のように追加しました。

次に、 reactiveui
、 reactiveui-wpf
refit
パッケージを追加し、WPFプロジェクトに再適合させます。これにより、リアクティブプログラミングを使用してビューモデルを作成し、RESTAPIを使用できるようになります。
次に、 ViewModel
を作成します。 MainViewModel.cs
という新しいクラスを追加し、次のようにします。
public class MainViewModel : ReactiveObject { #region Private Members private readonly ITrackingService _service; private readonly ISubject<(double latitude, double longitude)> _locationUpdate; #endregion #region Methods public MainViewModel() { _service = Locator.Current.GetService<ITrackingService>(); _locationUpdate = new Subject<(double latitude, double longitude)>(); UpdateCar = ReactiveCommand.Create(() => { var parsedCorrectly = int.TryParse(NewCarToFollow, out int newCar); NewCarToFollow = null; if (!parsedCorrectly) { MessageBox.Show("There was an error reading the number of the car to follow. Please, review it.", "Car Tracking Service", MessageBoxButton.OK, MessageBoxImage.Warning); return; } FollowedCar = newCar; }, canExecute: this.WhenAnyValue(x => x.NewCarToFollow).Select(x => !string.IsNullOrWhiteSpace(x))); /*This Scheduled method is where we get the location for the car being followed every 500 ms. We call the service with the car id, our JWT Token, and transform the result to a ValueTuple (double latitude, double longitude) to pass to our Subject's OnNext method so it can be received by the view */ Scheduler.Default.SchedulePeriodic(TimeSpan.FromMilliseconds(500), () => _service.GetLocation(FollowedCar, App.GetToken()) .Select(jo => ( latitude: double.Parse(jo["Latitude"].ToString()), longitude: double.Parse(jo["Longitude"].ToString()) )).Subscribe(newLocation => _locationUpdate.OnNext(newLocation))); } #endregion #region Properties private string _newCarToFollow; public string NewCarToFollow { get => _newCarToFollow; set => this.RaiseAndSetIfChanged(ref _newCarToFollow, value); } private int _followedCar = 1; public int FollowedCar { get => _followedCar; set => this.RaiseAndSetIfChanged(ref _followedCar, value); } public IObservable<(double latitude, double longitude)> LocationUpdate => _locationUpdate; private ReactiveCommand _updateCar; public ReactiveCommand UpdateCar { get => _updateCar; set => this.RaiseAndSetIfChanged(ref _updateCar, value); } #endregion }
ビューにViewModel
がアタッチされていることをビューに通知して使用できるようにするには、 MainView.xaml.cs
ファイルにいくつかの変更を加える必要があります。
public partial class MainWindow : IViewFor<MainViewModel> { public MainWindow() { InitializeComponent(); ViewModel = Locator.CurrentMutable.GetService<MainViewModel>(); /*Our ViewModel exposes an IObservable with a parameter of type ValueTuple (double latitude, double longitude) and it gets called every time the ViewModel updates the car's location from the REST API.*/ ViewModel.LocationUpdate .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(SetLocation); } private void SetLocation((double latitude, double longitude) newLocation) { //New location for the tracked vehicle. var location = new Location(newLocation.latitude, newLocation.longitude); //Remove previous pin myMap.Children.Clear(); //Center pin and keep same Zoom Level myMap.SetView(location, myMap.ZoomLevel); var pin = new Pushpin { Location = location, Background = Brushes.Green }; //Add new pin to the map myMap.Children.Add(pin); } /// <summary> /// Allows the ViewModel to be used on the XAML via a dependency property /// </summary> public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register("ViewModel", typeof(MainViewModel), typeof(MainWindow), new PropertyMetadata(default(MainViewModel))); /// <summary> /// Implementation for the IViewFor interface /// </summary> object IViewFor.ViewModel { get => ViewModel; set => ViewModel = (MainViewModel)value; } /// <summary> /// Regular property to use the ViewModel from this class /// </summary> public MainViewModel ViewModel { get => (MainViewModel)GetValue(ViewModelProperty); set => SetValue(ViewModelProperty, value); } }
次に、 MainWindow.xaml
ファイルを次のように変更します。
<Window x:Class="WpfApp.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:wpf="clr-namespace:Microsoft.Maps.MapControl.WPF;assembly=Microsoft.Maps.MapControl.WPF" xmlns:local="clr-namespace:WpfApp" DataContext="{Binding ViewModel,RelativeSource={RelativeSource Self}}" d:DataContext="{d:DesignInstance Type=local:MainViewModel, IsDesignTimeCreatable=True}" mc:Ignorable="d" WindowStartupLocation="CenterScreen" Title="Car Tracker" Height="800" Width="1200"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <DockPanel> <StackPanel Orientation="Horizontal" Margin="10" DockPanel.Dock="Left"> <Label>Car to follow</Label> <TextBox Width="50" Text="{Binding NewCarToFollow, UpdateSourceTrigger=PropertyChanged}" VerticalAlignment="Center"/> <Button Margin="15,0,0,0" Content="Update Followed Car" Command="{Binding UpdateCar}"/> </StackPanel> <TextBlock Text="{Binding FollowedCar,StringFormat=Following Car: {0}}" Margin="0,0,10,0" HorizontalAlignment="Right" VerticalAlignment="Center" DockPanel.Dock="Right"/> </DockPanel> <wpf:Map x:Name="myMap" ZoomLevel="15" Grid.Row="1" Margin="10" CredentialsProvider="ENTER-YOUR-BING-MAPS-CREDENTIAL-HERE"/> </Grid> </Window>
独自のBingMapsキーを使用してCredentialsProvider
プロパティを調整することが重要です。
REST APIにアクセスできるようにするために、refitを使用します。 必要なのは、使用するAPIメソッドを説明するインターフェースを作成することだけです。 そのため、次のコンテンツを含むITrackingServiceという新しいインターフェイスを作成します。
public interface ITrackingService { [Post("/api/values/authenticate")] IObservable<string> Authenticate([Body] JObject user); [Get("/api/values/{id}")] IObservable<JObject> GetLocation(int id, [Header("Authorization")] string authorization); }
最後に、依存性注入を含めるようにApp
クラスを変更し( reactiveui
への参照を含めたときに追加されたSplatを使用)、 ServerUri
を設定し(REST APIを実行するときに取得するポートに変更する必要があります)、アプリケーションの最初からログインします。
public partial class App : Application { protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); SetDependencyInjection(); LogIn(); } private void SetDependencyInjection() { Locator.CurrentMutable.RegisterLazySingleton(() => RestService.For<ITrackingService>(ServerUri), typeof(ITrackingService)); Locator.CurrentMutable.RegisterLazySingleton(() => new MainViewModel(), typeof(MainViewModel)); } private static string Token; private const string ServerUri = "http://localhost:54587"; private void LogIn() { try { var userInfo = new JObject { ["username"] = "user", ["password"] = "123" }; Token = Locator.Current.GetService<ITrackingService>() .Authenticate(userInfo) .Wait(); } catch { MessageBox.Show("There was an error validating the user. Is the service up?"); Shutdown(); } } internal static string GetToken() { return $"Bearer {Token}"; } }
最後に、アプリケーションを実行すると、500ミリ秒ごとにREST APIから座標が取得され、移動中の車のリアルタイムシミュレーションを確認できます。 ユーザーは、追跡している車を他のIDに変更することもでき、そのための新しいデータセットが作成されます。
この小さな例が、アクセス可能な方法でWPFのリアクティブプログラミングを使用したRESTAPIの処理の基本を示していることを願っています。
ソースプロジェクト全体をこのリポジトリからいつでもダウンロードできます。
この例を続けるために、理解を深めるのに役立つ可能性のあるいくつかの領域があります。
- ログインウィンドウを作成し、ユーザーがログインおよびログアウトできるようにします。
- データベースからのユーザーデータを検証します。
- さまざまなユーザーロールを作成し、REST APIで特定のメソッドを制限して、特定のロールを持つユーザーのみがそれらにアクセスできるようにします。
- RxMarblesでのすべての演算子とその動作を通過するリアクティブプログラミングの詳細を理解します。 Rx Marblesは、ストリームと対話し、ストリーム内のデータポイントに演算子を適用できる優れたアプリケーションです。
結論
リアクティブプログラミングは、このパラダイムに固有の通常の問題に遭遇することなく、イベント駆動型プログラミングを使用する制御された方法を実現しようと努める場合に有益であることがわかります。 新しい開発にそれを使用することは、十分にサポートされているオープンソースライブラリへの参照をいくつか追加するのと同じくらい簡単です。 ただし、最も重要なことは、既存のコードベースに組み込むことは進歩的であり、それを実装していないコンポーネントとの下位互換性を損なうべきではないということです。 この記事ではWPFのリアクティブプログラミングについて説明しましたが、ほとんどの主要な言語とフレームワークへの移植があり、リアクティブプログラミングをあらゆる種類の開発者にとって良い冒険にしています。
演習として、次に、次のことを行う必要があります。
- プロジェクトの動作を次のように拡張します
- ユーザー、車、場所のデータベースを追加する
- データベースから車の場所を取得し、ユーザーに表示します。 ユーザーが一定期間にわたって車の動きを探索できるようにする
- ユーザー権限の追加。 管理者ユーザーが新しい車とユーザーを作成し、通常のユーザーに読み取り専用アクセスを許可できるようにします。 JWT認証に役割を追加します。
- https://github.com/dotnet/reactiveで.NETリアクティブ拡張機能のソースコードを確認します