WPF 애플리케이션의 ReactiveUI 및 MVVM 패턴
게시 됨: 2022-03-11리액티브 프로그래밍은 데이터 스트림 및 변경 전파와 관련된 비동기 프로그래밍 패러다임입니다. – 위키피디아
그 문장을 읽은 후에는 여전히 내가 처음 읽었을 때와 같은 방식으로 끝날 수 있습니다. 관련성을 이해하는 데 더 가깝습니다. 기본 개념에 조금 더 집중하면 그 중요성을 빠르게 이해할 수 있습니다. 기본적으로 리액티브 프로그래밍은 처음에는 "스테로이드에 대한 이벤트 기반 프로그래밍"으로 생각할 수 있습니다. 이벤트 핸들러를 스트림으로 상상하고 핸들러가 실행될 때마다 스트림의 새로운 데이터로 생각하십시오. 간단히 말해서, 결국에는 반응형 프로그래밍이 됩니다.
반응형 프로그래밍에 대해 좀 더 자세히 알아보기 전에 이해하고 싶은 몇 가지 개념이 있습니다. Observable 은 우리가 이야기한 스트림에 대한 액세스를 제공하는 객체입니다. 그들의 목적은 스트림의 데이터에 대한 창을 제공하는 것입니다. 해당 창이 열리면 연산자 를 사용하여 선택한 방식으로 데이터를 볼 수 있으므로 애플리케이션이 스트림에 반응 하는 시기와 방법을 결정할 수 있습니다. 마지막으로 결과 스트림에서 관찰자 를 정의하여 새 데이터가 스트림에서 방출될 때마다 발생할 작업을 정의합니다.
실용적인 측면에서, 이는 사용자가 버튼을 클릭하든, 앱이 HTTP 응답을 수신하든, 예외로부터 복구하든 상관없이 무슨 일이 일어나고 있는지에 대해 애플리케이션이 반응하는 방식을 더 많이 제어할 수 있음을 의미합니다. 리액티브 프로그래밍(많은 것이 있음) 사용의 이점을 보기 시작하면 거의 되돌아갈 수 없을 것입니다. 앱이 하는 일의 대부분은 주어진 결과에 대해 특정한 방식으로 반응하기 때문입니다.
이것은 이 새로운 접근 방식에 단점이 없다는 것을 의미하지는 않습니다. 우선, 학습 곡선이 상당히 가파를 수 있습니다. 나는 개발자들(특히 주니어, 시니어, 아키텍트 등)이 무엇을 먼저 작성해야 하는지, 코드가 실행되는 순서 또는 오류를 디버깅하는 방법을 파악하기 위해 고군분투하는 방법을 직접 보았습니다. 이러한 개념을 처음 도입할 때 제가 추천하는 것은 많은 예를 보여주는 것입니다. 개발자가 사물이 어떻게 작동하고 사용되어야 하는지 보기 시작하면 감을 잡을 것입니다.
저는 2010년에 처음으로 WPF(Windows Presentation Foundation)를 사용하기 전에 10년 이상(대부분 Visual Basic 6, Java Swing 및 Windows Forms) 동안 데스크톱 앱을 사용해 왔습니다. 기본적으로 WPF는 다음을 위해 만들어졌습니다. .NET의 첫 번째 데스크톱 개발 프레임워크인 Windows Forms를 대체합니다.
WPF와 Windows Forms의 주요 차이점은 상당하지만 가장 중요한 차이점은 다음과 같습니다.
- WPF는 보다 강력하고 철저하게 테스트된 새로운 개발 패러다임을 사용합니다.
- WPF를 사용하면 UI에 대한 디자인과 코딩을 강력하게 분리할 수 있습니다.
- WPF를 사용하면 UI에 대한 많은 사용자 지정 및 제어가 가능합니다.
WPF와 WPF의 기능을 배우기 시작하면 정말 마음에 들었습니다! MVVM 패턴을 구현하는 것이 얼마나 쉬운지, 속성 바인딩이 얼마나 잘 작동했는지 믿을 수 없었습니다. 나는 Reactive Programming과 WPF에서의 사용법을 우연히 발견할 때까지 작업 방식을 개선할 수 있는 방법을 찾지 못할 것이라고 생각했습니다.
이 포스트에서는 MVVM 패턴을 사용하여 반응형 프로그래밍을 사용하고 REST API에 액세스하는 WPF 앱의 매우 간단한 구현을 보여드릴 수 있기를 바랍니다.
애플리케이션은 다음을 수행할 수 있습니다.
- 자동차 및 해당 위치 추적
- 시뮬레이션된 소스에서 가져온 정보 가져오기
- Bing Maps WPF 컨트롤에서 사용자에게 이 정보 표시
아키텍처
RESTful Web API Core 2 서비스를 사용하는 WPF 클라이언트를 빌드합니다.
클라이언트 측:
- WPF
- 반응 UI
- 의존성 주입
- MVVM 패턴
- 수리
- Bing 지도 WPF 컨트롤
- 테스트 목적으로만 Postman을 사용합니다.
서버 측:
- .NET C# 웹 API 코어 2
- 의존성 주입
- JWT 인증
필요한 것:
- Visual Studio 2017 커뮤니티(또는 보유하고 있는 모든 에디션)
백엔드
빠른 시작
ASP.NET Core 웹 애플리케이션을 사용하여 새 Visual Studio 솔루션을 시작합니다.
WPF 앱의 백엔드로만 사용할 것이기 때문에 API로 구성합니다.
다음과 유사한 구조의 VS 솔루션으로 끝나야 합니다.
지금까지 REST API 백엔드를 시작하는 데 필요한 모든 것을 갖추었습니다. 프로젝트를 실행하면 JSON 개체를 사용하여 REST 호출에 대한 응답을 표시하는 IIS Express에서 호스팅되는 웹 사이트를 가리키는 웹 브라우저(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 { ... }
이제 서비스를 실행하려고 하면 다음과 같은 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); }
이제 우리는 익명 액세스가 가능한 메서드를 만들었습니다. 즉, 인증되지 않은 클라이언트를 포함하여 모든 클라이언트가 JSON 개체를 포함하고 사용자 이름 문자열과 암호 문자열을 전달하는 POST 메시지를 사용하여 메서드를 호출할 수 있습니다.
Postman으로 검토하면 다음을 얻습니다.
우리가 볼 수 있듯이 인증 방법의 결과는 이제 API에 대해 수행하려는 모든 호출에 대해 토큰으로 사용해야 하는 바로 그 문자열이었습니다.
토큰이 메시지 헤더에 포함되면 유효성 검사가 수행되고 올바른 매개 변수가 전달되면 서비스가 메서드를 실행하고 해당 값을 반환합니다. 예를 들어 이제 values 컨트롤러를 호출하고 토큰을 전달하면 이전과 동일한 결과를 얻습니다.
이제 추적 중인 현재 자동차의 위도와 경도를 가져오는 메서드를 만듭니다. 다시 말하지만, 단순함을 위해 처음에는 임의의 위치로 돌아가서 메서드가 호출될 때마다 고정된 거리만큼 자동차를 움직이기 시작하는 더미 메서드일 것입니다.
먼저 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 앱을 만듭니다. 일단 생성하면 Visual Studio는 다음 구조의 새 프로젝트를 솔루션에 추가합니다.
Bing 지도 컨트롤:
Bing Maps용 WPF 컨트롤을 사용하려면 SDK(위 참조)를 설치하고 WPF 애플리케이션에 대한 참조로 추가해야 합니다. 설치 위치에 따라 DLL이 다른 경로에 있을 수 있습니다. 기본 위치에 설치하고 다음과 같이 추가했습니다.

다음으로 reactiveui
, reactui reactiveui-wpf
및 refit
에 대한 nuget 패키지를 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>
고유한 Bing Maps 키로 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); }
마지막으로 종속성 주입( reactiveui
에 대한 참조를 포함할 때 추가된 Splat 사용)을 포함하도록 App
클래스를 수정하고 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}"; } }
마지막으로 애플리케이션을 실행하면 500ms마다 REST API에서 좌표를 가져와 움직이는 자동차의 실시간 시뮬레이션을 볼 수 있습니다. 또한 사용자는 추적 중인 차량을 다른 ID로 변경할 수 있으며 이에 대한 새로운 데이터 세트가 생성됩니다.
이 작은 예제가 WPF에서 반응형 프로그래밍을 사용하여 REST API를 액세스 가능한 방식으로 처리하는 기본 사항을 보여주었기를 바랍니다.
이 리포지토리에서 언제든지 전체 소스 프로젝트를 다운로드할 수 있습니다.
이해를 돕기 위해 이 예제를 계속 진행해야 하는 몇 가지 영역이 있습니다.
- 로그인 창을 만들고 사용자가 로그인 및 로그아웃할 수 있도록 합니다.
- 데이터베이스에서 사용자 데이터의 유효성을 검사합니다.
- 특정 역할을 가진 사용자만 액세스할 수 있도록 다른 사용자 역할을 만들고 REST API에서 특정 방법을 제한합니다.
- Rx Marbles를 사용하여 모든 연산자와 동작을 통해 반응 프로그래밍에 대해 더 많이 이해하십시오. Rx Marbles는 스트림과 상호 작용하고 스트림의 데이터 포인트에 연산자를 적용할 수 있는 깔끔한 애플리케이션입니다.
결론
리액티브 프로그래밍은 이 패러다임에 내재된 일반적인 문제에 부딪치지 않고 이벤트 기반 프로그래밍을 사용하는 제어된 방법을 달성하려고 할 때 유용할 수 있습니다. 새로운 개발에 사용하는 것은 잘 지원되는 오픈 소스 라이브러리에 몇 가지 참조를 추가하는 것만큼 간단합니다. 그러나 가장 중요한 것은 이를 기존 코드베이스에 통합하는 것이 점진적일 수 있으며 이를 구현하지 않는 구성 요소와의 하위 호환성이 손상되어서는 안 된다는 것입니다. 이 기사에서는 WPF용 반응형 프로그래밍을 다루었지만, 반응형 프로그래밍을 모든 종류의 개발자에게 좋은 모험으로 만드는 대부분의 주요 언어 및 프레임워크에 대한 포트가 있습니다.
연습으로 다음을 수행해야 합니다.
- 다음과 같이 프로젝트의 동작을 확장합니다.
- 사용자, 자동차 및 위치에 대한 데이터베이스 추가
- 데이터베이스에서 자동차의 위치를 가져와 사용자에게 보여줍니다. 사용자가 일정 기간 동안 자동차의 움직임을 탐색하도록 허용
- 사용자 권한 추가. 관리 사용자가 새 자동차와 사용자를 생성하고 일반 사용자에게 읽기 전용 액세스 권한을 부여할 수 있습니다. JWT 인증에 역할을 추가합니다.
- https://github.com/dotnet/reactive에서 .NET 반응 확장에 대한 소스 코드 검토