使用 MEF MVVM RX MOQ 单元测试在 WPF 中进行实际的系统设计






4.56/5 (3投票s)
该项目是一个简单的温度转换器 WPF 应用程序,允许用户在摄氏度和华氏度之间进行转换。然而,其目标是演示在构建大型前端企业软件时重要的技术。
源代码可在 Temperature Converter CodePlex 网站上找到
前言
该项目是一个简单的温度转换器 WPF 应用程序,允许用户在摄氏度和华氏度之间进行转换。然而,其目标是演示在构建大型前端企业软件时重要的技术。我将引导您完成初始项目启动、系统分析、需求收集和顶层概念逻辑图。之后,我将讨论概念逻辑设计如何指导我们选择框架。随后,我将深入研究编码世界,同时始终将 MVVM 和 SOLID 模式作为指导方针。
在此过程中,我们将学习如何在生产代码中使用 WPF、MEF Discovery Composition 和 IOC、Rx Concurrency 和围绕它的数据建模及其有用的副作用、自定义依赖属性、控件模板和样式模板以及样式触发器。以上所有技术都用于促进 MVVM(但请注意,不要让转换器过于智能,因为我们无法对其进行单元测试)。我们还将编写一个简单的 Log4Net Appender,为我们提供有用的内存日志消息。最后,我们将看到实际的 Rx-Testing 虚拟时间调度 MOQ 单元测试的运用。
系统分析
我研究了各种温度转换器,最终选择谷歌温度转换器,因为它简单、设计简洁且点击次数最少。谷歌温度转换器的外观如下。
我们的最终系统
功能要求。
通过玩耍,我注意到如下功能需求。
非功能性需求
概念设计
我个人更喜欢顶层概念设计。它允许更自然地理解合适的设计模式,并相应地帮助我们建模系统。一旦我们理解了相关的模式和相关设计模式,我们就可以设计相关的数据模型、服务模型和要使用的相关框架。
我们看到右边的文本框观察左边文本框的输入顺序并对更改做出反应。同样,我们看到当用户在右边文本框中输入时,左边的文本框会观察输入顺序并做出反应。
我们在这里看到的是观察者模式的运用。有两个观察者,左序列观察者和右序列观察者。从技术角度来看,有人可能会争论为什么有两个观察者,为什么不直接让 ViewModel 拥有一个处理转换逻辑的观察者。为什么需要观察者,而是直接使用数据绑定和 WPF 行为实现逻辑。好吧,论点是,对任何事情都没有绝对正确的答案。我看待事物的方式是摆脱对技术的偏见,从纯粹自然的形态来看待系统。
文本框就像两个观察者,观察用户输入的数据。数据模型的作用仅是保存数据并提供可观察的编排。数据的实际处理由控制器完成,控制器决定谁得分,谁失分。ViewModel 向上走,将控制器做出的决定通知 View,并要求 View 显示该决定。有两个观察者的原因是,它更自然地描绘了我们正在处理的系统,而 SOLID 模式坚守关注点分离作为其指导的第一个原则。遵循这些原则有助于在产品成熟过程中缓解设计中的未来问题。
这两个观察者都会对主题数据序列的变化做出反应。我们希望进一步以并发方式处理反应操作,而不阻塞 UI 主线程。此外,如果您决定在 UI 中处理并发,我们有一个限制,即任何时候更新视觉对象,工作都必须通过 Dispatcher 发送到 UI 线程。控件本身只能由其所属的线程访问。如果您尝试从另一个线程对控件执行任何操作,您将收到运行时不支持的操作异常。总而言之,对于这个简单的温度转换器来说,并发可能有点过度,但目标是展示构建大型前端企业系统的技术。因此,考虑到我们已经确定了并发需求,我们可以利用许多模式在后台运行一段工作。
new Thread(() => { /* do work */}).Start()
ThreadPool.QueueUserWorkItem(_ => { /* do work */ }, null)
Task.Factory.StartNew(() => { /* do work */ })
syncCtx.Post(_ => { /* do work */ }, null)
Dispatcher.BeginInvoke(() => { /* do work */ })
Rx 已经通过一个名为 IScheduler
的单一接口抽象了上述所有并发机制。鉴于 RX 促进了可观察序列并抽象了并发,因此它是理想的选择框架。观察者自然涉及用户输入的序列数据。
数据模型设计
如上所述,数据模型主要观察数字序列,继承自 IObservable<T>
,其中 T 是序列类型。模型不持久化计算结果序列,只保留最新计算序列值的结果。因此,该结果由属性 Value 表示。数据模型也是一个可观察的主题,因此它聚合了一个类型为 T(在本例中为 decimal)的 _innerObservable Subject<T>
。结果“Value”的逻辑单元由逻辑单元 U 表示。
public class ObservableSequence<T,U> : IObservable<T>
{
private readonly Subject<T> _innerObservable = new Subject<T>();
public event EventHandler ValueChanged = delegate { };
public event EventHandler UnitChanged = delegate { };
private static ILog Log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType);
private T _value;
private U _unit;
public T Value
{
get { return _value; }
set
{
_value = value;
_innerObservable.OnNext(value);
var localValueChanged = ValueChanged;
localValueChanged(this, new EventArgs());
}
}
public U Unit
{
get { return _unit; }
set {
_unit = value;
var localUnitChanged = UnitChanged;
localUnitChanged(this,new EventArgs());
}
}
public IDisposable Subscribe(IObserver<T> observer)
{
return _innerObservable
.DistinctUntilChanged()
.AsObservable()
.Do(o => {Log.Debug(string.Format("OnNext
{0}"
,o));})
.Subscribe(observer);
}
public IObservable<T> GetObservable()
{
return this;
}
}
请注意,此数据模型是通用的基本实现,因此 IObservable<T>
接口也通过方法 GetObservable()
公开,这将允许通过上层接口 ILeftSequenceObserverModel 和 IRightSequenceObserverModel 公开 Observable。上层接口由 MEF 组合、IOC 注入并在 MVVM 中进行编排。如单元测试所示,GetObservable()
接口将允许我们注入 TestScheduler
,它支持虚拟时间调度,是任何 RX 相关测试的绝对必需品。在 MVVM 范例中,虽然 ViewModel 可能不直接使用控件,但 ViewModel 会在 UI Dispatcher 线程上下文上使用 WPF 数据绑定运行和更新 UI。但是,当我们对 ViewModel 进行单元测试时,将没有 dispatcher 线程上下文。因此,我们将使用桥接模式将抽象与实现解耦。如前所述,Rx 已经通过 IScheduler 接口抽象了并发,我们将如下利用它。
public interface ISequenceObserverController
{
void RewireSequenceObservers(UnitConversionMode conversionMode,
TemperatureUnit currentTemperatureUnit, IScheduler subscribeScheduler, IScheduler observeScheduler);
void ToggleUnWireSequenceObservers();
}
ViewModel 用法
public ICommand ObserveInputSequence
{
get
{
return new RelayCommand(d =>
{
Log.Debug("ObserveInputSequence " + ( d == CurrentLeftTemperatureUnit ? UnitConversionMode.LeftToRight : UnitConversionMode.RightToLeft));
_controllerService.RewireSequenceObservers(d == CurrentLeftTemperatureUnit ? UnitConversionMode.LeftToRight :
UnitConversionMode.RightToLeft, d, Scheduler.Default, DispatcherScheduler.Current);
});
}
}
单元测试用法
sequenceControllerService.RewireSequenceObservers(UnitConversionMode.RightToLeft, TemperatureUnit.Celsius, _testScheduler, _testScheduler);
...
...
[SetUp]
public void Setup()
{
_testScheduler = new TestScheduler();
_mockRightObservableModel = new Mock();
_mockLeftObservableModel = new Mock();
_onNextRightObservedCount = 0;
_mockLeftObservableModel.Setup(x => x.GetObservable()).Returns(
() =>
{
return Observable.Create(o =>
{
++_onNextRightObservedCount; //Debug.WriteLine will not be compiled in release
Debug.WriteLine("OnNext => RightObserved Count {0}", _onNextRightObservedCount);
return Observable.Never().StartWith(1M).Subscribe(o);
});
});
_mockRightObservableModel.Setup(x => x.GetObservable()).Returns(
() =>
{
return Observable.Create(o =>
{
++_onNextLeftObserverCount;
Debug.WriteLine("OnNext => LeftObserved {0}", _onNextLeftObserverCount);
return Observable.Never().StartWith(1M).Subscribe(o);
});
});
}
以上是一个测试设置,但有一个细微的“bug”。提示上面的测试将在调试测试中工作,但在使用发布代码的测试中会失败。
[Test]
public void RewireSequenceObserversRightToLeftFailsWhenCurrentUnitIsDifferent()
{
//Arrange
var sequenceControllerService = new SequenceObserverController(_mockRightObservableModel.Object,
_mockLeftObservableModel.Object);
_mockRightObservableModel.SetupProperty(_ => _.Unit, TemperatureUnit.Farenheit);
sequenceControllerService.RewireSequenceObservers(UnitConversionMode.RightToLeft, TemperatureUnit.Celsius, _testScheduler, _testScheduler);
_onNextRightObservedCount = 0;
_onNextLeftObserverCount = 0;
//Act
_testScheduler.AdvanceBy(TimeSpan.FromHours(1).Ticks);
//Assert
Assert.GreaterOrEqual(_onNextRightObservedCount, 0);
Assert.GreaterOrEqual(_onNextLeftObserverCount, 0); //Note we have no NextLeftObserverCount
}
请注意,数据模型还公开事件来松散地通知值和单位的变化。一件有趣的事情是我没有检查事件委托是否等于 null。引用检查是不必要的,因为我将事件委托初始化为无操作委托 {}。还有一件有趣的事情是我没有在 Value Setter 上应用双重检查锁定模式。这是一个潜在的 bug,因为虽然 OnNext
保证它不会重叠;但这种保证是在 RX 实现的角度来看才有意义的。当我们实现自己的逻辑时,我们应该确保 RX 语法完好无损,否则在生产代码中可能会出现意外。我们可以通过在 Setter 上实现双重检查锁定模式来轻松实现这一点。或者,无锁实现将是您将事件委托分配给本地委托,然后调用本地事件委托。由于委托是不可变的,并且线程有自己的私有堆栈,因此我们在不实现任何锁的情况下自然受到保护。我说了很多话,但实现非常简单,如下所示。
public T Value
{
get { return _value; }
set
{
_value = value;
_innerObservable.OnNext(value);
var localValueChanged = ValueChanged;
localValueChanged(this, new EventArgs());
}
}
现在您可以争辩说 _value 没有受到保护。但在这段关键代码中这不是问题,因为我们而是使用了堆栈值,它无论如何都是线程安全的。
我使用 Do 扩展作为副作用记录序列,它不改变序列,并且在 OnNext
之前调用。
public IDisposable Subscribe(IObserver observer)
{
return _innerObservable
.DistinctUntilChanged()
.AsObservable()
.Do(o => {Log.Debug(string.Format("OnNext {0}",o));})
.Subscribe(observer);
}
public IObservable GetObservable()
{
return this;
}
可观察序列控制器
根据用例,当用户在左侧文本框中输入时,左侧文本框成为主题,输入序列被右侧文本框观察,反之亦然。Observable Sequence Controller 负责连接和断开适当的 Observable Sequence 数据模型。特别注意 Toggle Event,控制器断开了两个观察者,因为后续的任何更改都是由于单位转换的更改而不是任何新的数据序列。MEF 将模型组合并注入到 Sequence Controller 中。
public interface ISequenceObserverController
{
void RewireSequenceObservers(UnitConversionMode conversionMode,
TemperatureUnit currentTemperatureUnit, IScheduler subscribeScheduler, IScheduler observeScheduler);
void ToggleUnWireSequenceObservers();
}
[Export(typeof(ISequenceObserverController))]
[PartCreationPolicy(CreationPolicy.Shared)]
public class SequenceObserverController : ISequenceObserverController
{
private static ILog Log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType);
private readonly IRightSequenceObserverModel _rightSequenceObserverModel;
private readonly ILeftSequenceObserverModel _leftSequenceObserverModel;
private IDisposable _disposableLeftSequenceObserver;
private IDisposable _disposableRightSequenceObserver;
[ImportingConstructor]
public SequenceObserverController(IRightSequenceObserverModel rightSequenceObserverModel,
ILeftSequenceObserverModel leftSequenceObserverModel)
{
_rightSequenceObserverModel = rightSequenceObserverModel;
_leftSequenceObserverModel = leftSequenceObserverModel;
}
public void RewireSequenceObservers(UnitConversionMode conversionMode,TemperatureUnit currentTemperatureUnit,IScheduler subscribeScheduler,IScheduler observeScheduler)
{
Log.Debug("SequenceObserverController RewireSequenceObservers");
...
...
}
}
ViewModel 设计
MainPageViewModel 是视图 MainWindow.xaml 的 ViewModel。此 ViewModel 由 ViewModel Locator 定位,并作为视图的 DataContext 注入。模型和控制器被 IOC 注入到 ViewModel 构造函数中。KeyBoardFocussed
属性实现为附加属性的行为,因此我们可以将其与可绑定命令和命令参数关联。请注意,如果使用 IsKeyBoardFocussed
作为触发器,我们将无法在 setter 中使用我们的 viewModel,因为只有依赖属性才能在 setter 中使用。
[ImportingConstructor]
public MainPageViewModel(IMemoryLogAppenderService logAppenderService,ISequenceObserverController controllerService,
IRightSequenceObserverModel rightSequenceObserverModel, ILeftSequenceObserverModel leftSequenceObserverModel)
{
_logAppenderService = logAppenderService;
_logAppenderService.LogAppend += (o, e) => { _logMessages.Add(string.Format("{0} : {1}",e.LoggingEvent.Level.DisplayName,e.LoggingEvent.MessageObject.ToString())); };
Log.Debug("Application is starting");
_controllerService = controllerService;
_rightSequenceObserverModel = rightSequenceObserverModel;
_leftSequenceObserverModel = leftSequenceObserverModel;
_rightSequenceObserverModel.ValueChanged += (o, e) => { RaisePropertyChanged("RightSequence"); };
_rightSequenceObserverModel.UnitChanged += (o, e) => { RaisePropertyChanged("CurrentRightTemperatureUnit"); };
_leftSequenceObserverModel.ValueChanged += (o, e) => { RaisePropertyChanged("LeftSequence"); };
_rightSequenceObserverModel.UnitChanged += (o, e) => { RaisePropertyChanged("CurrentLeftTemperatureUnit"); };
}
public ICommand ObserveInputSequence
{
get
{
return new RelayCommand(d =>
{
Log.Debug("ObserveInputSequence " + ( d == CurrentLeftTemperatureUnit ? UnitConversionMode.LeftToRight : UnitConversionMode.RightToLeft));
_controllerService.RewireSequenceObservers(d == CurrentLeftTemperatureUnit ? UnitConversionMode.LeftToRight :
UnitConversionMode.RightToLeft, d, Scheduler.Default, DispatcherScheduler.Current);
});
}
}
ViewLocator 将 ViewModel 注入为 DataContext
DataContext="{Binding Path=MainPage, Source={StaticResource Locator}}"
Title="Temperature Converter (C) Arup Banerjee, 2015" MinHeight="325" MinWidth="575" MaxWidth="575"
Height="Auto" SizeToContent="WidthAndHeight" Icon="..\Infrastructure\Resources\temperature-64x64.png">
单元测试
上面讨论了一些关键的单元测试技术和设计。下面的测试涵盖了功能规范测试。您可以在附带的代码中查看实现的详细信息。
代码覆盖率
代码覆盖率是一个重要工具,它可以让我们了解代码的使用情况。但我们不应该被整体百分比所迷惑。例如,我不使用附加代码,因此可以安全地删除 InitializeComponent。同样,我只使用通用的 RelayCommand<T>
版本,因为我使用了 commandParameter。然而,在库中保留非通用版本并没有坏处,即使它在这个 ViewModel 中没有使用。ViewModelLocator 显示覆盖率 0%,但这并不完全准确,因为 ViewModelLocator 只使用一次来注入 MainView。覆盖率和快照在 View 出现后很久才开始,届时 ViewModelLocator 已经完成了它的工作。因此,代码覆盖率非常有用,但我们应该明智地使用这些数字。
Nuget 包
Nuget 是一个通用的包管理工具,已用于此项目。
应用程序包列表
<!--?xml version="1.0" encoding="utf-8"?-->
单元测试包列表
<!--?xml version="1.0" encoding="utf-8"?-->
下载代码
您可以下载代码 AlanAamy.Net.TemperatureConverter。请注意,软件包二进制文件不包含在内。构建解决方案时需要在线,它将自动恢复所需的包。