ReactiveUI et le modèle MVVM dans les applications WPF

Publié: 2022-03-11

La programmation réactive est un paradigme de programmation asynchrone concerné par les flux de données et la propagation du changement. - Wikipédia

Une fois que vous avez lu cette phrase, vous pourriez toujours vous retrouver de la même manière que moi lorsque je l'ai lue pour la première fois : nulle part plus proche de la compréhension de sa pertinence. Un peu plus de dévouement dans les concepts de base et vous comprendrez rapidement son importance. Fondamentalement, vous pourriez d'abord penser à la programmation réactive comme : "Programmation pilotée par les événements sur les stéroïdes". Imaginez un gestionnaire d'événements comme un flux et considérez chaque déclenchement du gestionnaire comme une nouvelle donnée dans le flux. En un mot, vous vous retrouvez avec une programmation réactive.

Il y a certains concepts que vous voudrez peut-être comprendre avant de vous plonger un peu plus dans la programmation réactive. Les observables sont les objets qui vous donnent accès aux flux dont nous avons parlé. Leur but est de vous donner une fenêtre sur les données du flux. Une fois que cette fenêtre a été ouverte, vous pouvez regarder les données de la manière que vous choisissez en utilisant des opérateurs dessus et ainsi décider quand et comment votre application réagit au flux. Enfin, vous définissez les Observers sur le flux résultant pour définir l'action qui se produira à chaque fois qu'une nouvelle donnée est émise par le flux.

Concrètement, cela signifie simplement que vous avez plus de contrôle sur la façon dont votre application réagit à ce qui se passe, que ce soit votre utilisateur cliquant sur un bouton, votre application recevant une réponse HTTP ou récupérant des exceptions. Une fois que vous commencerez à voir les avantages de l'utilisation de la programmation réactive (qui sont nombreux), vous ne pourrez plus revenir en arrière. C'est simplement parce que la plupart des choses qu'une application fait réagissent d'une certaine manière à une éventualité donnée.

Maintenant, cela ne signifie pas qu'il n'y a pas d'inconvénients à cette nouvelle approche. Tout d'abord, sa courbe d'apprentissage peut être assez raide. J'ai vu de mes propres yeux comment les développeurs (juniors, seniors et architectes, entre autres) ont du mal à comprendre ce qu'ils sont censés écrire en premier, dans quel ordre leur code est exécuté ou comment déboguer les erreurs. Ma recommandation lors de la première introduction de ces concepts est de montrer beaucoup d'exemples. Lorsque les développeurs commenceront à voir comment les choses sont censées fonctionner et être utilisées, ils comprendront.

Je travaillais avec des applications de bureau depuis plus de 10 ans (principalement Visual Basic 6, Java Swing et Windows Forms) avant de mettre la main sur Windows Presentation Foundation (WPF) pour la première fois en 2010. Fondamentalement, WPF a été créé pour remplace Windows Forms, qui est le premier framework de développement de bureau de .NET.

Les principales différences entre WPF et Windows Forms sont substantielles, mais les plus importantes sont :

  • WPF utilise de nouveaux paradigmes de développement qui sont plus robustes et qui ont été minutieusement testés.
  • Avec WPF, vous pouvez avoir un fort découplage de la conception et du codage pour l'interface utilisateur.
  • WPF permet de nombreuses personnalisations et contrôles sur votre interface utilisateur.

Une fois que j'ai commencé à apprendre WPF et ses fonctionnalités, je l'ai absolument adoré ! Je ne pouvais pas croire à quel point le modèle MVVM était facile à implémenter et à quel point la liaison de propriété fonctionnait. Je ne pensais pas trouver quoi que ce soit pour améliorer cette façon de travailler jusqu'à ce que je tombe sur la programmation réactive et son utilisation avec WPF :

Dans cet article, j'espère pouvoir montrer une implémentation très simple d'une application WPF utilisant la programmation réactive avec le modèle MVVM et accéder à une API REST.

L'application pourra :

  • Suivre les voitures et leurs emplacements
  • Prendre des informations extraites d'une source simulée
  • Afficher ces informations à l'utilisateur dans un contrôle WPF Bing Maps

L'architecture

Vous allez créer un client WPF qui utilise un service RESTful Web API Core 2.

Le côté client :

  • WPF
  • Interface utilisateur réactive
  • Injection de dépendance
  • Modèle MVVM
  • Remonter
  • Contrôle WPF de Bing Maps
  • À des fins de test uniquement, nous utiliserons Postman

Côté serveur :

  • API Web .NET C# Core 2
  • Injection de dépendance
  • Authentification JWT

Ce dont vous aurez besoin :

  • Communauté Visual Studio 2017 (ou toute édition que vous pourriez avoir)

Le back-end

Démarrage rapide

Démarrez une nouvelle solution Visual Studio avec une application Web ASP.NET Core.

wpf reactiveui : nouvelle application Web Visual Studio ASP.NET Core

Configurez-le comme une API puisque nous ne l'utiliserons que comme back-end pour notre application WPF.

wpf reactiveui : configurer en tant qu'API

Nous devrions nous retrouver avec une solution VS avec une structure similaire à celle-ci :

wpf reactiveui : exemple de solution VS

Jusqu'à présent, nous avons tout ce dont nous avons besoin pour démarrer notre back-end API REST. Si nous exécutons notre projet, il chargera un navigateur Web (celui que nous avons défini sur Visual Studio) pointant vers un site Web hébergé sur IIS Express qui affichera une réponse à un appel REST avec un objet JSON.

Maintenant, nous allons configurer l'authentification JWT pour notre service REST.

À la fin du fichier startup.cs , ajoutez les lignes suivantes.

 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 }; }); }

De plus, dans la méthode ConfigureServices , appelez la méthode que nous venons de créer avant que la méthode AddMvc ne soit appelée.

 public void ConfigureServices(IServiceCollection services) { LoadJwtAuthorization(services); services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); }

Enfin, ajustez la méthode Configure pour qu'elle ressemble à ceci :

 public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseAuthentication(); app.UseMvc(); }

Jusqu'à présent, nous avons établi l'authentification JWT à utiliser sur nos contrôleurs si elle est définie. Ensuite, nous allons ajuster le contrôleur afin qu'il utilise l'authentification que nous avons décrite.

Sur le ValuesController , nous ajouterons le AuthorizeAttribute pour qu'il ressemble à ceci :

 [Route("api/[controller]")] [ApiController] [Authorize] public class ValuesController : ControllerBase { ... }

Maintenant, si nous essayons d'exécuter notre service, nous obtiendrons une erreur 401 non autorisée comme celle-ci :

Erreur non autorisée sur Postman

Nous devrons donc ajouter une méthode pour authentifier nos utilisateurs. Par souci de simplicité ici, nous allons le faire sur la même classe 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); }

Nous avons maintenant créé une méthode avec un accès anonyme, ce qui signifie que tous les clients, même ceux qui ne sont pas authentifiés, pourront l'appeler à l'aide d'un message POST contenant un objet JSON et en passant une chaîne pour son nom d'utilisateur et une chaîne pour son mot de passe.

Lorsque nous l'examinons avec Postman, nous obtenons ceci :

WPF réactif : authentification

Comme nous pouvons le voir, le résultat de la méthode d'authentification était la chaîne même que nous devons maintenant utiliser comme jeton pour chaque appel que nous voulons faire à l'API.

Une fois le jeton inclus dans les en-têtes du message, la validation a lieu et, si les paramètres corrects sont passés, le service exécute la méthode et renvoie sa valeur. Par exemple, si nous appelons maintenant le contrôleur de valeurs et passons le jeton, nous obtenons le même résultat qu'auparavant :

WPF réactif : Authentification 2

Maintenant, nous allons créer une méthode pour obtenir la latitude et la longitude de la voiture actuelle que nous suivons. Encore une fois, pour des raisons de simplicité, ce ne sera qu'une méthode factice qui renverra d'abord un emplacement aléatoire et commencera à déplacer la voiture sur une distance fixe à chaque fois que la méthode est appelée.

Tout d'abord, nous ajustons la méthode Get(int id) dans la classe ValuesController pour qu'elle ressemble à ceci :

 [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(); }

Ensuite, nous ajoutons une nouvelle classe LocationHelper qui gérera les emplacements actuels et futurs des voitures suivies.

 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)>(); }

C'est tout pour le back-end.

La face avant :

Nous allons maintenant créer une nouvelle application WPF. Une fois que nous l'avons créé, Visual Studio ajoutera un nouveau projet avec la structure suivante à notre solution.

Structure de l'application WPF

Contrôle des cartes Bing :

Pour utiliser le contrôle WPF pour Bing Maps, nous devrons installer le SDK (référencé ci-dessus) et l'ajouter en tant que référence à notre application WPF. Selon l'endroit où vous l'avez installée, la DLL peut se trouver sur un chemin différent. Je l'ai installé à l'emplacement par défaut et l'ai ajouté comme suit :

Étape 1 : Faites un clic droit sur la section Références de votre projet WPF puis cliquez sur
Étape 1 : Faites un clic droit sur la section Références de votre projet WPF, puis cliquez sur « Ajouter une référence ».

Étape 2 : Accédez au chemin de votre installation Bing Maps WPF Control.
Étape 2 : Accédez au chemin de votre installation Bing Maps WPF Control.

Étape 3 : Cliquez sur OK pour l'ajouter au projet.
Étape 3 : Cliquez sur OK pour l'ajouter au projet.

Ensuite, nous ajouterons des packages nuget pour reactiveui , reactiveui-wpf et refit à notre projet WPF, ce qui nous permettra de créer des modèles de vue à l'aide de la programmation réactive ainsi que de consommer notre API REST.

Étape 1 : Faites un clic droit sur la section Références de votre projet WPF et cliquez sur Gérer les packages NuGet.
Étape 1 : Faites un clic droit sur la section Références de votre projet WPF et cliquez sur Gérer les packages NuGet.

Étape 2 : Dans l'onglet Parcourir, recherchez `reactiveui`, cliquez sur installer, recherchez `reactiveui-wpf`, cliquez sur installer et enfin, recherchez `refit` et cliquez sur installer.
Étape 2 : Dans l'onglet Parcourir, recherchez `reactiveui`, cliquez sur installer, recherchez `reactiveui-wpf`, cliquez sur installer et enfin, recherchez `refit` et cliquez sur installer.

Nous allons maintenant créer notre ViewModel . Ajoutez une nouvelle classe appelée MainViewModel.cs et faites-la ressembler à ceci :

 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 }

Pour que la vue sache qu'un ViewModel attaché et qu'il puisse l'utiliser, nous devrons apporter quelques modifications au fichier 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); } }

Ensuite, nous modifierons le fichier MainWindow.xaml pour qu'il ressemble à ceci :

 <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>

Il est important d'ajuster la propriété CredentialsProvider avec votre propre clé Bing Maps.

Pour pouvoir accéder à notre API REST, nous utiliserons refit. Tout ce que nous avons à faire est de créer une interface décrivant les méthodes API que nous utiliserons. Ainsi, nous créons une nouvelle interface appelée ITrackingService avec le contenu suivant :

 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); }

Enfin, nous modifions la classe App pour inclure l'injection de dépendances (à l'aide de Splat, qui a été ajouté lorsque nous avons inclus la référence à reactiveui ), définissons le ServerUri (que vous devez modifier sur le port que vous obtenez lorsque vous exécutez l'API REST) ​​et simulons notre connectez-vous au tout début de l'application.

 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}"; } }

Enfin, lorsque nous exécuterons notre application, nous pourrons voir une simulation en temps réel d'une voiture en mouvement avec ses coordonnées extraites de l'API REST toutes les 500 ms. L'utilisateur peut également changer la voiture suivie par n'importe quel autre ID, et un nouvel ensemble de données sera créé pour celui-ci.

J'espère que ce petit exemple a montré les bases de la gestion d'une API REST avec la programmation réactive dans WPF d'une manière accessible.

Vous pouvez toujours télécharger l'intégralité du projet source à partir de ce référentiel.

Il y a certains domaines à poursuivre avec cet exemple qui pourraient vous aider à approfondir votre compréhension :

  • Créez une fenêtre de connexion et autorisez l'utilisateur à se connecter et à se déconnecter.
  • Valider les données utilisateur à partir d'une base de données.
  • Créez différents rôles d'utilisateur et restreignez certaines méthodes dans l'API REST afin que seul un utilisateur avec un certain rôle puisse y accéder.
  • Comprenez davantage la programmation réactive en passant par tous les opérateurs et leur comportement avec Rx Marbles. Rx Marbles est une application soignée qui vous permet d'interagir avec les flux et d'appliquer des opérateurs aux points de données qu'ils contiennent.

Conclusion

La programmation réactive peut s'avérer bénéfique lorsque l'on s'efforce de parvenir à une manière contrôlée d'utiliser la programmation événementielle sans se heurter aux problèmes habituels inhérents à ce paradigme. L'utiliser pour de nouveaux développements est aussi simple que d'ajouter quelques références à des bibliothèques open source bien prises en charge. Mais, plus important encore, son intégration dans les bases de code existantes peut être progressive et ne doit pas rompre la compatibilité descendante avec les composants qui ne l'implémentent pas. Cet article traitait de la programmation réactive pour WPF, mais il existe des ports vers la plupart des principaux langages et frameworks, ce qui fait de la programmation réactive une bonne aventure pour tout type de développeur.

Comme exercice, ensuite, vous devriez :

  • Étendre le comportement du projet en
    • Ajout d'une base de données pour les utilisateurs, les voitures et les emplacements
    • Obtenir l'emplacement des voitures à partir de la base de données et les montrer à l'utilisateur. Permettre à l'utilisateur d'explorer les mouvements d'une voiture sur une période de temps
    • Ajout d'autorisations utilisateur. Laissez les utilisateurs administrateurs créer de nouvelles voitures et de nouveaux utilisateurs et accordez un accès en lecture seule aux utilisateurs réguliers. Ajoutez des rôles à l'authentification JWT.
  • Passez en revue le code source des extensions réactives .NET dans https://github.com/dotnet/reactive