ReactiveUI и шаблон MVVM в приложениях WPF
Опубликовано: 2022-03-11Реактивное программирование — это парадигма асинхронного программирования, связанная с потоками данных и распространением изменений. - Википедия
Как только вы прочтете это предложение, вы все еще можете прийти к тому же выводу, что и я, когда впервые прочитал его: нигде ближе к пониманию его актуальности. Немного больше посвящения основным понятиям, и вы быстро поймете их важность. По сути, сначала вы могли думать о реактивном программировании как о «программировании, управляемом событиями, на стероидах». Представьте себе обработчик событий в виде потока и думайте о каждом срабатывании обработчика как о новом элементе данных в потоке. В двух словах, то, что вы получите, это реактивное программирование.
Есть несколько концепций, которые вы, возможно, захотите понять, прежде чем углубляться в реактивное программирование. Наблюдаемые объекты — это объекты, которые дают вам доступ к потокам, о которых мы говорили. Их цель — дать вам представление о данных в потоке. Как только это окно будет открыто, вы можете просматривать данные любым удобным для вас способом, используя для этого операторы , и, таким образом, решать, когда и как ваше приложение будет реагировать на поток. Наконец, вы определяете наблюдателей в результирующем потоке, чтобы определить действие, которое будет происходить каждый раз, когда поток испускает новые данные.
На практике это просто означает, что вы получаете больше контроля над тем, как ваше приложение реагирует на происходящее, будь то нажатие кнопки пользователем, получение вашим приложением ответа HTTP или восстановление после исключений. Как только вы начнете видеть преимущества использования реактивного программирования (а их много), вы вряд ли сможете вернуться к нему. Это просто потому, что большинство вещей, которые делает приложение, реагируют определенным образом на данную возможность.
Это не означает, что у этого нового подхода нет недостатков. Прежде всего, его кривая обучения может быть довольно крутой. Я своими глазами видел, как разработчики (среди прочих, младшие, старшие и архитекторы) изо всех сил пытаются понять, что они должны писать в первую очередь, в каком порядке выполняется их код или как отлаживать ошибки. Моя рекомендация при первом знакомстве с этими концепциями — показать множество примеров. Когда разработчики увидят, как вещи должны работать и использоваться, они во всем разберутся.
Я работал с настольными приложениями более 10 лет (в основном с Visual Basic 6, Java Swing и Windows Forms), прежде чем впервые прикоснулся к Windows Presentation Foundation (WPF) еще в 2010 году. По сути, WPF был создан для заменяет Windows Forms, которая является первой средой разработки настольных компьютеров .NET.
Основные различия между WPF и Windows Forms существенны, но наиболее важными из них являются:
- WPF использует новые парадигмы разработки, более надежные и тщательно протестированные.
- С WPF вы можете четко разделить дизайн и кодирование пользовательского интерфейса.
- WPF позволяет настраивать и контролировать ваш пользовательский интерфейс.
Как только я начал изучать WPF и его возможности, он мне очень понравился! Я не мог поверить, насколько легко реализовать шаблон MVVM и насколько хорошо работает связывание свойств. Я не думал, что найду что-нибудь, чтобы улучшить этот способ работы, пока не наткнулся на реактивное программирование и его использование с WPF:
В этом посте я надеюсь показать очень простую реализацию приложения WPF с использованием реактивного программирования с шаблоном MVVM и доступом к REST API.
Приложение сможет:
- Отслеживайте автомобили и их местоположение
- Возьмите информацию, полученную из смоделированного источника
- Отобразите эту информацию пользователю в элементе управления Bing Maps WPF.
Архитектура
Вы создадите клиент WPF, использующий службу RESTful Web API Core 2.
Сторона клиента:
- WPF
- Реактивный интерфейс
- Внедрение зависимости
- Шаблон MVVM
- Переоборудовать
- Bing Maps Управление WPF
- Только для целей тестирования мы будем использовать Postman
Сторона сервера:
- .NET С# Веб-API, ядро 2
- Внедрение зависимости
- JWT-аутентификация
Что вам понадобится:
- Сообщество Visual Studio 2017 (или любая другая версия, которая у вас может быть)
Серверная часть
Быстрый старт
Запустите новое решение Visual Studio с веб-приложением ASP.NET Core.
Настройте его как API, так как мы будем использовать его только в качестве серверной части для нашего приложения WPF.
Мы должны получить решение VS со структурой, подобной этой:
На данный момент у нас есть все необходимое для запуска серверной части REST API. Если мы запустим наш проект, он загрузит веб-браузер (тот, который мы установили в Visual Studio), указывающий на веб-сайт, размещенный в IIS Express, который покажет ответ на вызов REST с объектом JSON.
Теперь мы настроим аутентификацию JWT для нашей службы REST.
В конец файла 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 { ... }
Теперь, если мы попытаемся запустить нашу службу, мы получим ошибку 401 Unauthorized, например:
Итак, нам нужно добавить метод для аутентификации наших пользователей. Для простоты мы сделаем это в том же классе 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); }
Теперь мы создали метод с анонимным доступом, что означает, что все клиенты, даже не прошедшие проверку подлинности, смогут вызывать его, используя сообщение POST, содержащее объект JSON и передавая строку для своего имени пользователя и строку для своего пароля.
Когда мы просматриваем его с помощью Postman, мы получаем следующее:
Как мы видим, результатом метода аутентификации была та самая строка, которую нам теперь нужно использовать в качестве токена для каждого вызова API, который мы хотим сделать.
Как только токен включен в заголовки сообщения, происходит проверка, и, если переданы правильные параметры, служба запускает метод и возвращает его значение. Например, если мы теперь вызовем контроллер значений и передаем токен, мы получим тот же результат, что и раньше:
Теперь мы создадим метод для получения широты и долготы для текущего автомобиля, который мы отслеживаем. Опять же, для простоты, это будет просто фиктивный метод, который сначала возвращает случайное местоположение и начинает перемещать машину на фиксированное расстояние каждый раз, когда метод вызывается.
Во-первых, мы настраиваем метод Get(int id)
в классе ValuesController
, чтобы он выглядел следующим образом:
[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. Как только мы его создадим, Visual Studio добавит в наше решение новый проект со следующей структурой.
Управление картами Bing:
Чтобы использовать элемент управления WPF для Bing Maps, нам нужно установить пакет SDK (упомянутый выше) и добавить его в качестве ссылки на наше приложение WPF. В зависимости от того, где вы его установили, DLL может находиться по другому пути. Я установил его в папку по умолчанию и добавил следующим образом:

Далее мы добавим пакеты nuget для reactiveui
, reactiveui-wpf
и refit
наш проект WPF, что позволит нам создавать модели представлений с использованием реактивного программирования, а также использовать наш REST API.
Теперь мы создадим нашу 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>
Важно настроить свойство CredentialsProvider
с помощью собственного ключа Bing Maps.
Чтобы получить доступ к нашему 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
, включив в него внедрение зависимостей (используя Splat, который был добавлен, когда мы включили ссылку на reactiveui
), устанавливаем 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}"; } }
Наконец, когда мы запустим наше приложение, мы сможем увидеть симуляцию движущегося автомобиля в реальном времени, при этом его координаты берутся из REST API каждые 500 мс. Пользователь также может изменить отслеживаемый автомобиль на любой другой идентификатор, и для него будет создан новый набор данных.
Я надеюсь, что этот небольшой пример наглядно показал основы работы с REST API с реактивным программированием в WPF.
Вы всегда можете скачать весь исходный проект из этого репозитория.
Есть несколько областей, которые можно продолжить с этим примером, которые могут помочь вам лучше понять:
- Создайте окно входа и разрешите пользователю входить и выходить.
- Проверка пользовательских данных из базы данных.
- Создавайте разные роли пользователей и ограничивайте определенные методы в REST API, чтобы к ним мог получить доступ только пользователь с определенной ролью.
- Узнайте больше о реактивном программировании, просматривая все операторы и их поведение с помощью Rx Marbles. Rx Marbles — удобное приложение, позволяющее взаимодействовать с потоками и применять операторы к точкам данных в них.
Заключение
Реактивное программирование может оказаться полезным при стремлении достичь управляемого способа использования управляемого событиями программирования, не сталкиваясь с обычными проблемами, присущими этой парадигме. Использовать его для новых разработок так же просто, как добавить пару ссылок на хорошо поддерживаемые библиотеки с открытым исходным кодом. Но, что наиболее важно, его включение в существующие кодовые базы может быть прогрессивным и не должно нарушать обратную совместимость с компонентами, которые его не реализуют. В этой статье речь шла о реактивном программировании для WPF, но есть порты на большинство основных языков и фреймворков, что делает реактивное программирование хорошим приключением для любого разработчика.
Далее в качестве упражнения вам следует:
- Расширить поведение проекта на
- Добавление базы данных для пользователей, автомобилей и местоположений
- Получение местоположения автомобилей из базы данных и показ их пользователю. Разрешить пользователю исследовать движения автомобиля в течение определенного периода времени
- Добавление прав пользователей. Разрешить пользователям-администраторам создавать новые автомобили и пользователей и предоставлять доступ только для чтения обычным пользователям. Добавьте роли в JWT-аутентификацию.
- Просмотрите исходный код реактивных расширений .NET на странице https://github.com/dotnet/reactive.