MVVM

使用 MVVM 编写可测试的表示层

Brent Edwards

下载代码示例

在使用 Windows 窗体时代的传统应用程序的情况下,标准测试做法是布局一个视图,在该视图的代码隐藏文件中编写代码,然后运行该应用程序以进行测试。 幸运的是,在那以后,相关做法有了一些变化。

Windows Presentation Foundation (WPF) 的出现将数据绑定概念提升到了一个全新的水平。 它使得一种称为“模型-视图-视图模型”(MVVM) 的新设计模式得到发展。 通过 MVVM,您可以将表示逻辑与实际表示分离开。 基本上,这意味着您可以在极大程度上避免在视图的代码隐藏文件中编写代码。

对于那些对开发可测试应用程序感兴趣的人来说,这是一项重大改进。 现在,您不必将表示逻辑附加到视图具有自身生命周期的代码隐藏文件(从而使测试变得复杂),而是可以使用普通的旧 CLR 对象 (POCO)。 视图模型没有视图所具有的生命周期约束。 您可以在单元测试中将某个视图模型实例化并进行测试。

在本文中,我将介绍如何着手使用 MVVM 编写应用程序的可测试表示层。 为了帮助说明我的方法,我在这里提供了我曾编写的一个开放源框架(称为 Charmed)中的示例代码,以及随附的示例应用程序(称为 Charmed Reader)。 此框架和示例应用程序可在 GitHub 上找到 (github.com/brentedwards/Charmed)。

我曾在 2013 年 7 月的文章中将该 Charmed 框架作为 Windows 8 框架和示例应用程序进行了介绍 (msdn.microsoft.com/magazine/dn296512)。 随后,在 2013 年 9 月的文章 (msdn.microsoft.com/magazine/dn385706) 中,我又将其作为 Windows 8 和 Windows Phone 8 框架和示例应用程序讨论了如何实现跨平台应用。 在这两篇文章中,我都谈到我为了保持该应用程序的可测试性而做出的决定。 现在,我将再次讨论这些决定,并说明如何实际着手测试该应用程序。 本文中的示例采用 Windows 8 和 Windows Phone 8 代码,但您可以将这些概念和方法应用于任何类型的应用程序。

关于示例应用程序

用于说明我如何着手编写可测试表示层的示例应用程序名为 Charmed Reader。 Charmed Reader 是一个简单的博客阅读器应用程序,可在 Windows 8 和 Windows Phone 8 上运行。 它具有说明我要涉及的要点所需的最少功能。 此应用程序可跨平台运行,并且在两个平台上的运行方式几乎相同,不同之处在于,Windows 8 应用程序会利用某些 Windows 8 特有的功能。 而应用程序基本上相同,具有单元测试所需的足够功能。

什么是单元测试?

所谓单元测试,就是获取离散代码块(单元)并编写以预期方式使用代码的测试方法,然后进行测试,看是否可以取得预期结果。 此测试代码使用某种测试工具框架运行。 有多种适用于 Visual Studio 2012 的测试工具框架。 在该示例代码中,我使用了 Visual Studio 2012(以及更早版本)中内置的 MSTest。 目标是让单一单元测试方法面向某个具体方案。 有时,需要使用多个单元测试方法才能涵盖您预期您的方法或属性适用的所有方案。

单元测试方法应遵循一致的格式,以便于其他开发人员理解。 以下格式通常被视为最佳做法:

  1. Arrange
  2. Act
  3. Assert

首先,为创建被测试类的实例以及它可能具有的任何依赖关系,您可能需要编写一些设置代码。 这是单元测试的 Arrange 部分。

在完成为该单元测试设置实际测试阶段之后,您可以执行相关方法或属性。 这是单元测试的 Act 部分。 您可以使用在 Arrange 部分中设置的参数(如果适用)执行相关方法或属性。

最后,在执行了相关方法或属性后,测试需要验证该方法或属性的运行结果是否与预期完全一致。 这是单元测试的 Assert 部分。 在断言阶段,将会调用断言方法以将实际结果与预期结果进行比较。 如果实际结果符合预期,则单元测试通过。 如果不符合,则测试失败。

我的测试遵循这种最佳实践格式,通常看上去与下面类似:

[TestMethod]
public void SomeTestMethod()
{
  // Arrange
  // *Insert code to set up test
  // Act
  // *Insert code to call the method or property under test
  // Assert
  // *Insert code to verify the test completed as expected
}

某些人虽然使用这种格式,但没有包括用于调用该测试不同部分 (Arrange/Act/Assert) 的注释。 我喜欢用注释将这三个部分分开,只是为了确保保持对实际测试内容或何时进行设置的跟踪。

拥有一整套编写良好的单元测试的另一个好处是,它们可以充当该应用程序的活文档。 查看您的代码的新开发人员可以查看这些单元测试所涉及的不同方案,从而了解您期望如何使用该代码。

规划可测试性

如果您想编写可测试的应用程序,事先规划很有帮助。 您需要对应用程序体系结构进行设计,使其有利于进行单元测试。 静态方法、封装类、数据库访问以及 Web 服务调用都可能使应用程序难于或无法进行单元测试。 但是,如果进行了某种规划,您可以将这些方面对应用程序的影响降到最低。

Charmed Reader 专为阅读博客文章而设计。 下载这些博客文章涉及对 RSS 源的 Web 访问,而对该功能进行单元测试可能会相当困难。 首先,您应能够在断开状态下快速运行单元测试。 在单元测试中依赖 Web 访问可能会违反这些原则。

并且,单元测试应该是可重复的。 由于博客通常会定期更新,因此随着时间的推移,可能无法下载相同的数据。 我事先就知道,如果不提前规划,对用于加载博客文章的功能进行单元测试将是不可能的。

下面是我所知道的需要执行的步骤:

  1. MainViewModel 需要加载用户想要一次阅读的所有博客文章。
  2. 需要从用户已保存的各种 RSS 源下载这些博客文章。
  3. 下载之后,需要将这些博客文章解析为数据传输对象 (DTO) 并提供给视图使用。

如果我将用于下载 RSS 源的代码放在 MainViewModel 中,该代码会立即开始负责更多事情,而不仅仅是加载数据以及让视图与其进行数据绑定以供显示。 随后,MainViewModel 将负责发出 Web 请求并分析 XML 数据。 我实际上想做的是让 MainViewModel 通过调用一个帮助程序来发出 Web 请求并分析 XML 数据。 随后,应该为 MainViewModel 提供代表要显示的博客文章的对象实例。 这些对象实例称为 DTO。

知道这一点后,我可以使加载并解析为 MainViewModel 可以调用的帮助程序对象的 RSS 源抽象化。 不过,要做的事不止这些。 如果我只是创建一个负责 RSS 源数据工作的帮助程序类,则我围绕此功能而为 MainViewModel 编写的任何单元测试最终也会调用该帮助程序类以执行 Web 访问。 如前所述,这就违背了单元测试的目标。 因此,我需要再向前迈一步。

如果我为 RSS 源数据加载功能创建一个界面,就可以将我的视图模型用于该界面(而不是具体的类)。 然后,我可以针对何时运行单元测试(而不是运行应用程序)提供该界面的不同实现形式。 这就是模拟背后的原理。 当我真正运行该应用程序时,我需要用于加载真实 RSS 源数据的真实对象。 在我运行单元测试时,我需要一个只是伪装成加载 RSS 数据、但从不实际访问 Web 的模拟对象。 该模拟对象可以创建可重复且永远不会改变的一致数据。 然后,我的单元测试就能准确确定每次的预期结果。

考虑到这一点,我编写的用于加载博客文章的界面与下面类似:

public interface IRssFeedService
{
  Task<List<FeedData>> GetFeedsAsync();
}

MainViewModel 仅可以使用一种方法(即 GetFeedsAsync)来加载博客文章数据。 MainViewModel 无需关心 IRssFeedService 加载数据或分析数据的方式。 MainViewModel 只需要关心调用 GetFeedsAsync 将会异步返回博客文章数据。 鉴于该应用程序的跨平台性质,这一点尤其重要。

Windows 8 和 Windows Phone 8 具有不同的下载和分析 RSS 源数据的方式。 通过创建 IRssFeedService 界面并让 MainViewModel 与该界面交互(而不是直接下载博客源),我避免了强制 MainViewModel 具有同一功能的多种实现形式。

使用依赖关系注入,我可以确保适时为 MainViewModel 提供正确的 IRssFeedService 实例。 如前所述,我将在单元测试期间提供 IRssFeedService 的一个模拟实例。 有关将 Windows 8 和 Windows Phone 8 代码用作单元测试讨论基础的一件有趣事情是,这些平台目前还没有任何真正的动态模拟框架。 由于模拟是对我的代码进行单元测试的重要部分,我必须自己想出创建模拟的简便方法。 图 1 显示了所得到的 RssFeedServiceMock。

图 1 RssFeedServiceMock

public class RssFeedServiceMock : IRssFeedService
{
  public Func<List<FeedData>> GetFeedsAsyncDelegate { get; set; }
  public Task<List<FeedData>> GetFeedsAsync()
  {
    if (this.GetFeedsAsyncDelegate != null)
    {
      return Task.FromResult<List<FeedData>>(this.GetFeedsAsyncDelegate());
    }
    else
    {
      return Task.FromResult<List<FeedData>>(null);
    }
  }
}

基本上,我需要能够提供可以设置数据加载方式的委托。 如果您不是面向 Windows 8 或 Windows Phone 8 进行开发,则您很有可能可以使用像 Moq、Rhino Mocks 或 NSubstitute 这样的动态模拟框架。 无论是采用自己的模拟方法还是使用动态模拟框架,所采用的原理是相同的。

在创建了 IRssFeedService 界面并将其注入到 MainViewModel 中(在 IRssFeedService 界面上调用 GetFeedsAsync 的 MainViewModel)以及创建了 RssFeed­ServiceMock 并可供使用之后,现在应该测试 MainViewModel 与 IRssFeedService 的交互。 在这种交互中,我要测试的重要方面是 MainViewModel 可正确调用 GetFeedsAsync,并且返回的源数据与 MainViewModel 通过 FeedData 属性提供的源数据相同。 图 2 中的单元测试会对此进行验证。

图 2 测试源加载功能

[TestMethod]
public void FeedData()
{
  // Arrange
  var viewModel = GetViewModel();
  var expectedFeedData = new List<FeedData>();
  this.RssFeedService.GetFeedsAsyncDelegate = () =>
    {
      return expectedFeedData;
    };
  // Act
  var actualFeedData = viewModel.FeedData;
  // Assert
  Assert.AreSame(expectedFeedData, actualFeedData);
}

每当我对视图模型(或任何其他对象)进行单元测试时,我喜欢用一种帮助程序方法,它可为我提供要测试的视图模型的实际实例。 视图模型可能会随时间发生改变,从而可能会将不同的内容注入到视图模型中,这意味着会有不同的构造函数参数。 如果我在所有单元测试中都创建该视图模型的新实例,然后更改该构造函数的签名,那么我必须随该签名一起更改一整批单元测试。 但是,如果我创建一种帮助程序方法以便创建该视图模型的新实例,那么我只需在一处进行更改。 在本例中,GetViewModel 就是帮助程序方法:

private MainViewModel GetViewModel()
{
  return new MainViewModel(this.RssFeedService, 
    this.Navigator, this.MessageBus);
}

我还使用 TestInitialize 属性来确保在运行每个测试之前重新创建 MainViewModel 依赖关系。 下面就是实现这种情况的 TestInitialize 方法:

[TestInitialize]
public void Init()
{
  this.RssFeedService = new RssFeedServiceMock();
  this.Navigator = new NavigatorMock();
  this.MessageBus = new MessageBusMock();
}

通过此方法,此测试类中的每个单元测试在模拟运行时都具有所有模拟的全新实例。

回来看一下该测试本身,下面的代码可创建我所预期的源数据并设置用于返回该数据的模拟 RSS 源服务:

var expectedFeedData = new List<FeedData>();
this.RssFeedService.GetFeedsAsyncDelegate = () =>
  {
    return expectedFeedData;
  };

请注意,我没有向 expectedFeedData 的列表中添加任何实际 FeedData 实例,因为我不需要这样做。 我只需要确保该列表本身就是 MainViewModel 最终使用的列表。 我不关心该列表中实际有 FeedData 实例时会发生什么,至少这个测试是这样。

该测试 Act 部分的代码行如下:

var actualFeedData = viewModel.FeedData;

我随后可以断言,actualFeedData 是 expectedFeedData 的相同实例。 如果它们不是相同的实例,则 MainViewModel 就不会完成其工作,单元测试就会失败。

Assert.AreSame(expectedFeedData, actualFeedData);

可测试的导航

我想测试的该示例应用程序的另外一个重要部分就是导航。 Charmed Reader 示例应用程序使用基于模型视图的导航,因为我想要将视图和视图模型分开。 Charmed Reader 是一个跨平台应用程序,我所创建的视图模型在两个平台上使用,但视图对于 Windows 8 和 Windows Phone 8 来说应是不同的。 原因有很多,但都会归结为这样一个事实,即每个平台都具有略微不同的 XAML。 因此,我并不想让视图模型知道视图的情况,因为这样会将事情复杂化。

出于几种原因,对界面背后的导航功能加以抽象不失为一个解决方案。 头等重要的是,每个平台在导航中都涉及不同的类,而我不想让我的视图模型牵扯到这些差异。 在这两种情况下,都无法对导航中涉及的类进行模拟。 这样,我就从该视图模型抽象出这些问题,并创建了 INavigator 界面:

public interface INavigator
{
  bool CanGoBack { get; }
  void GoBack();
  void NavigateToViewModel<TViewModel>(object parameter = null);
#if WINDOWS_PHONE
  void RemoveBackEntry();
#endif // WINDOWS_PHONE
}

我通过构造函数将 INavigator 注入到 MainViewModel 中,而 MainViewModel 在一个名为 ViewFeed 的方法中使用 INavigator:

public void ViewFeed(FeedItem feedItem)
{
  this.navigator.NavigateToViewModel<FeedItemViewModel>(feedItem);
}

当我查看 ViewFeed 如何与 INavigator 进行交互时,可以看到在编写该单元测试时想要验证的两件事:

  1. 传递到 ViewFeed 中的 FeedItem 就是传递到 NavigateToViewModel 中的同一个 FeedItem。
  2. 传递到 NavigateToViewModel 的视图模型类型是 FeedItemViewModel。

在我实际编写该测试之前,需要创建另一个模拟,此次是用于 INavigator。 图 3 显示了用于 INavigator 的模拟。 我遵循了之前针对每个方法使用委托时的相同模式,以作为在调用该实际方法时执行测试代码的方法。 同样,如果使用支持模拟框架的平台,就不需要创建自己的模拟。

图 3 用于 INavigator 的模拟

public class NavigatorMock : INavigator
{
  public bool CanGoBack { get; set; }
  public Action GoBackDelegate { get; set; }
  public void GoBack()
  {
    if (this.GoBackDelegate != null)
    {
      this.GoBackDelegate();
    }
  }
  public Action<Type, object> NavigateToViewModelDelegate { get; set; }
  public void NavigateToViewModel<TViewModel>(object parameter = null)
  {
    if (this.NavigateToViewModelDelegate != null)
    {
      this.NavigateToViewModelDelegate(typeof(TViewModel), parameter);
    }
  }
#if WINDOWS_PHONE
  public Action RemoveBackEntryDelegate { get; set; }
  public void RemoveBackEntry()
  {
    if (this.RemoveBackEntryDelegate != null)
    {
      this.RemoveBackEntryDelegate();
    }
  }
#endif // WINDOWS_PHONE
}

有了自己的模拟 Navigator 类,我就可以在单元测试中使用它,如图 4 所示。

图 4 使用模拟导航器测试导航

[TestMethod]
public void ViewFeed()
{
  // Arrange
  var viewModel = this.GetViewModel();
  var expectedFeedItem = new FeedItem();
  Type actualViewModelType = null;
  FeedItem actualFeedItem = null;
  this.Navigator.NavigateToViewModelDelegate = (viewModelType, parameter) =>
    {
      actualViewModelType = viewModelType;
      actualFeedItem = parameter as FeedItem;
    };
  // Act
  viewModel.ViewFeed(expectedFeedItem);
  // Assert
  Assert.AreSame(expectedFeedItem, actualFeedItem, "FeedItem");
  Assert.AreEqual(typeof(FeedItemViewModel), 
    actualViewModelType, "ViewModel Type");
}

这个测试实际所关心的是传递的 FeedItem 是否正确以及要导航到的视图模型是否正确。 在使用模拟时,请务必记住,您应该关注特定测试而不必关注其他无关的事情。 在这个测试中,由于我有 MainViewModel 所使用的 INavigator 界面,因此,无需关心实际上是否发生了导航。 这个问题是由针对运行时实例实现 INavigator 的机制进行处理的。 我只需要关心进行导航时为 INavigator 提供正确的参数。

可测试的辅助磁贴

测试时我要检查的最后一个方面就是辅助磁贴。 Windows 8 和 Windows Phone 8 中均提供了辅助磁贴,用户可使用这些辅助磁贴将应用程序的元素固定到主屏幕上,从而创建与应用程序特定部分的深层链接。 但是,这两个平台对辅助磁贴的处理方式完全不同,这就意味着,我必须提供与平台相关的实现方式。 尽管存在这种差异,我仍能够为辅助磁贴提供在两个平台上都可以使用的一致界面:

public interface ISecondaryPinner
{
  Task<bool> Pin(TileInfo tileInfo);
  Task<bool> Unpin(TileInfo tileInfo);
  bool IsPinned(string tileId);
}

TileInfo 类是一个 DTO,它将适用于两个平台的属性结合在一起以创建辅助磁贴。 由于每个平台采用 TileInfo 中属性的不同组合,因此,需要以不同方式对每个平台进行测试。 我们具体看一看 Windows 8 版本。 图 5 显示了我的视图模型使用 ISecondaryPinner 的方式。

图 5 中的 Pin 方法中,实际发生了两件事。 第一是对辅助磁贴进行了实际固定。 第二是将 FeedItem 保存到本地存储中。 因此,我需要对这两方面进行测试。 由于此方法基于尝试固定 FeedItem 的结果来更改视图模型上的 IsFeedItemPinned 属性,因此,我还需要对 ISecondaryPinner 测试 Pin 方法的两个可能结果:true 和 false。 图 6 显示了我实现的第一个测试,该测试对成功方案进行测试。

图 5 使用 ISecondaryPinner

public async Task Pin(Windows.UI.Xaml.FrameworkElement anchorElement)
{
  // Pin the feed item, then save it locally to make sure it's still available
  // when they return.
  var tileInfo = new TileInfo(
    this.FormatSecondaryTileId(),
    this.FeedItem.Title,
    this.FeedItem.Title,
    Windows.UI.StartScreen.TileOptions.ShowNameOnLogo |
      Windows.UI.StartScreen.TileOptions.ShowNameOnWideLogo,
    new Uri("ms-appx:///Assets/Logo.png"),
    new Uri("ms-appx:///Assets/WideLogo.png"),
    anchorElement,
    Windows.UI.Popups.Placement.Above,
    this.FeedItem.Id.ToString());
  this.IsFeedItemPinned = await this.secondaryPinner.Pin(tileInfo);
  if (this.IsFeedItemPinned)
  {
    await SavePinnedFeedItem();
  }
}

图 6 测试成功固定

[TestMethod]
public async Task Pin_PinSucceeded()
{
  // Arrange
  var viewModel = GetViewModel();
  var feedItem = new FeedItem
  {
    Title = Guid.NewGuid().ToString(),
    Author = Guid.NewGuid().ToString(),
    Link = new Uri("https://www.bing.com")
  };
  viewModel.LoadState(feedItem, null);
  Placement actualPlacement = Placement.Default;
  TileInfo actualTileInfo = null;
  SecondaryPinner.PinDelegate = (tileInfo) =>
    {
      actualPlacement = tileInfo.RequestPlacement;
      actualTileInfo = tileInfo;
      return true;
    };
  string actualKey = null;
  List<FeedItem> actualPinnedFeedItems = null;
  Storage.SaveAsyncDelegate = (key, value) =>
    {
      actualKey = key;
      actualPinnedFeedItems = (List<FeedItem>)value;
    };
  // Act
  await viewModel.Pin(null);
  // Assert
  Assert.AreEqual(Placement.Above, actualPlacement, "Placement");
  Assert.AreEqual(string.Format(Constants.SecondaryIdFormat,
    viewModel.FeedItem.Id), actualTileInfo.TileId, "Tile Info Tile Id");
  Assert.AreEqual(viewModel.FeedItem.Title,
    actualTileInfo.DisplayName, "Tile Info Display Name");
  Assert.AreEqual(viewModel.FeedItem.Title,
    actualTileInfo.ShortName, "Tile Info Short Name");
  Assert.AreEqual(viewModel.FeedItem.Id.ToString(),
    actualTileInfo.Arguments, "Tile Info Arguments");
  Assert.AreEqual(Constants.PinnedFeedItemsKey, actualKey, "Save Key");
  Assert.IsNotNull(actualPinnedFeedItems, "Pinned Feed Items");
}

与前面的测试相比,这个测试中涉及的设置略多一些。 首先,在控制器之后,我设置了一个 FeedItem 实例。 请注意,我针对 Title 和 Author 都在 Guid 上调用 ToString。 这是因为,我不关心实际值是什么,只关心它们具有我能够与断言部分中的值进行比较的值。 由于 Link 是一个 Uri,我需要一个有效 Uri 才能进行比较,因此我提供了一个 Uri。 同样,实际 Uri 是什么并不重要,只要它是有效的就可以。 其余设置涉及到确保捕获用于固定和保存的交互,以便与断言部分进行比较。 确保此代码实际测试成功方案的关键是 PinDelegate 返回 true(表示成功)。

图 7 显示的测试大部分是相同的,只不过是一个不成功方案。 PinDelegate 返回 false 可确保该测试中关注不成功方案。 在不成功方案中,还需要在断言部分验证没有调用 SaveAsync。

图 7 测试不成功固定

[TestMethod]
public async Task Pin_PinNotSucceeded()s
{
  // Arrange
  var viewModel = GetViewModel();
  var feedItem = new FeedItem
  {
    Title = Guid.NewGuid().ToString(),
    Author = Guid.NewGuid().ToString(),
    Link = new Uri("https://www.bing.com")
  };
  viewModel.LoadState(feedItem, null);
  Placement actualPlacement = Placement.Default;
  TileInfo actualTileInfo = null;
  SecondaryPinner.PinDelegate = (tileInfo) =>
  {
    actualPlacement = tileInfo.RequestPlacement;
    actualTileInfo = tileInfo;
    return false;
  };
  var wasSaveCalled = false;
  Storage.SaveAsyncDelegate = (key, value) =>
  {
    wasSaveCalled = true;
  };
  // Act
  await viewModel.Pin(null);
  // Assert
  Assert.AreEqual(Placement.Above, actualPlacement, "Placement");
  Assert.AreEqual(string.Format(Constants.SecondaryIdFormat,
    viewModel.FeedItem.Id), actualTileInfo.TileId, "Tile Info Tile Id");
  Assert.AreEqual(viewModel.FeedItem.Title, actualTileInfo.DisplayName,
    "Tile Info Display Name");
  Assert.AreEqual(viewModel.FeedItem.Title, actualTileInfo.ShortName,
    "Tile Info Short Name");
  Assert.AreEqual(viewModel.FeedItem.Id.ToString(),
    actualTileInfo.Arguments, "Tile Info Arguments");
  Assert.IsFalse(wasSaveCalled, "Was Save Called");
}

编写可测试的应用程序有一定的挑战性。 测试涉及用户交互的表示层挑战性更大。 事先知道将要编写可测试的应用程序,您就可以在每一步中做出有利于实现可测试性的决策。 您还可以注意不利于应用程序测试的各种情况,并提出解决这些问题的方法。

在这三篇文章中,我讨论了如何使用 MVVM 模式编写可测试的应用程序,具体是针对 Windows 8 和 Windows Phone 8 的应用程序。 在第一篇文章中,我介绍了如何编写可测试的 Windows 8 应用程序,同时仍利用不易于由这些应用程序本身测试的 Windows 8 特有功能。 第二篇文章介绍了如何开发适用于 Windows 8 和 Windows Phone 8 的可测试、跨平台的应用程序。 在这篇文章中,我介绍了如何着手测试我事先通过努力保证可测试性的应用程序。

MVVM 是一个广泛的主题,具有许多不同的解释。 我很高兴能够分享我对这样一个令人感兴趣的主题的解释。 我在使用 MVVM 的过程中发现了很多有价值的功能,尤其是在可测试性方面。 我还发现对可测试性的探索很有启发性并且有实际用途,我很高兴分享编写可测试应用程序的方法。

Brent Edwards是 Magenic 的一名首席咨询顾问,这是一家定制应用程序开发公司,主要从事 Microsoft 系列产品和移动应用程序的开发。他还是位于明尼阿波利斯的 Twin Cities Windows 8 User Group 的联合创始人。可通过 brente@magenic.com 与他联系。

衷心感谢以下技术专家对本文的审阅:Jason Bock (Magenic)
Jason Bock 是 Magenic (www.magenic.com) 的一名见习主管。 他还是《Metaprogramming in .NET》一书 (www.manning.com/hazzard) 的合著者。 您可在 jasonbock.net 上或在 Twitter 上与他联系:@jasonbock.