为树莓派开发 .NET 应用程序:第 2 部分





5.00/5 (8投票s)
一个用于开发树莓派 .NET 应用程序的框架,包括单元测试、模拟和依赖注入
引言
与任何其他环境一样,树莓派也可以用 .NET 编写应用程序。编写良好的 .NET(和 C#)代码的优点是能够实现更具可扩展性和可维护性的解决方案。在本文中,我将描述一个可用于创建此类解决方案的项目结构。这将包括单元测试、模拟和依赖注入。该应用程序将是一个简单的应用程序,但解决方案结构将能够实现任何大型开发项目的两个关键目标
- 代码应力求实现关注点分离。
- 代码应易于重构。
这两个原则都将在本文后面进行演示。我将使用 .NET Core IoT 库,这是微软用于与树莓派等平台上的低级设备进行交互的官方解决方案。
背景
第 1 部分:在树莓派上轻松设置 .NET Core 并使用 VS Code 进行远程调试
在上一篇文章中,我描述了如何设置一个开发环境,该环境将使使用自动化部署和调试为远程树莓派进行开发变得容易。那篇文章包含几个简单的程序。本文将在此基础上,展示如何创建一个更真实、更大的解决方案。
问题描述
我为这个示例解决方案选择了一个简单的问题。其要求是虚构的,但这是故意的。解决方案结构是重要的部分,通过一个简单的问题,希望能够将重点放在解决方案上。
那么问题是:将有两个按钮开关和两个 LED。我们将创建一个控制台应用程序,可以打开或关闭每个 LED。按钮将识别短按或长按。我们还将有两个测试模式——一种是持续闪烁 LED,直到按钮 2 被长按,另一种是显示消息,说明哪个按钮被按下,这也将通过长按按钮 2 终止。
我已将开关和 LED 连接到以下 GPIO 引脚
显然,这是一个虚构的例子。更真实的解决方案会利用输入和输出做一些事情,并且可能会包含更复杂的设备。在撰写本文时,IOT 项目包含 70 多个设备接口,例如温度传感器、加速度计和光传感器,以及许多有线和无线设备。
解决方案结构
顶层结构如下
> .vscode
> Doc
> RpiBlinkButtonApp
> RpiBlinkButtonLib
> RpiBlinkButtonLibTests
> Scripts
.gitignore
有三个代码文件夹,RpiBlinkButtonApp、RpiBlinkButtonLib 和 RpiBlinkButtonLibTests。在一个更大的解决方案中,可能会有更多的库项目,并且每个库都应该有自己的 Tests
项目。可能会有更多的 Application
项目。此外,请注意,我没有为 App
项目包含单元测试,但对于真实的解决方案,此项目也应该有单元测试。
.vscode 和 Scripts 文件夹基于上一篇文章,并包含用于远程部署和调试的脚本和配置。
关注点分离
本文方法的关键目标之一是关注点分离。这在大型解决方案中很重要,因为通常不可能或不希望为了处理它而理解整个解决方案。为了实现这一点,需要有清晰的抽象层。第一个明显的抽象层是实际的 iot 库本身。值得查看该存储库中的代码,但不一定需要理解所有代码才能使用它。
我们的第一个抽象是拥有一个库项目。在该项目中,我选择创建两个控制器类。LED 控制器类封装了两个 LED——我选择有一个红色和绿色 LED。有两个方法可以打开或关闭红色或绿色 LED。按钮控制器有两个按钮,由于这些按钮稍微复杂一些(例如需要对开关进行去抖),控制器使用另一个类 GpioButton
。然而,使用按钮控制器应该很容易,我已经决定提供事件 ButtonPressed
,它有一个 EventArgs
参数,描述发生了什么类型的事件(按钮 1 或 2 短按或长按)。
重构
代码应易于重构。与旧时代项目开始时设计一成不变不同,现代代码应随着项目的进展而不断重构。包括 VS Code 在内的 IDE 提供了使这变得更容易的工具,但我们还需要确保代码的复杂性不会失控,因为一旦发生这种情况,重构就变得困难,在某些情况下甚至变得不可能。
重构的另一个关键部分是确信重构没有破坏任何东西。这是进行良好单元测试的关键原因之一。
单元测试、模拟和依赖注入
库中的每个类都有自己的测试类。理想情况下,我们只想测试该类并模拟对其他类的调用。实现此目的的最佳方法是模拟被调用的类。我为此使用了 Moq,可以通过 NuGet 轻松安装。模拟的关键部分是使用依赖注入和控制反转。这里的关键思想是我们向类传递实例化对象,而不是类自己创建对象。通过这样做,我们可以传递模拟对象而不是真实对象。一个例子是 ButtonControllerTests
中的 CreateMockedObjects()
private MockButtonCollection CreateMockObjects()
{
var mockObjects = new MockButtonCollection();
mockObjects.Button1 = new Mock<GpioButton>(null);
mockObjects.Button2 = new Mock<GpioButton>(null);
mockObjects.GpioDriver = new Mock<GpioDriver>();
// Mock the methods to setup a pin
mockObjects.GpioDriver.Protected().Setup<bool>("IsPinModeSupported", ItExpr.IsAny<int>(),
ItExpr.IsAny<PinMode>()).Returns(true);
// Create a ButtonController
mockObjects.GpioController = new GpioController(PinNumberingScheme.Logical,
mockObjects.GpioDriver.Object);
mockObjects.ButtonController = new ButtonController(mockObjects.GpioController,
mockObjects.Button1.Object, mockObjects.Button2.Object);
// Initialize the button controller
mockObjects.ButtonController.Initialize();
return mockObjects;
}
另一个例子是我们传递给 GpioButton
类的 DateTimeProvider
类。这可以如下模拟
var mockDateTime = new Mock<IDateTimeProvider>();
var dateNow = DateTime.UtcNow;
mockDateTime.SetupSequence(m => m.UtcNow)
.Returns(dateNow)
.Returns(dateNow.AddMilliseconds(20))
.Returns(dateNow.AddMilliseconds(30));
这使我们能够返回特定值,在这种情况下,对 UtcNow()
的第一次调用获取当前时间,随后的调用获取时间 +20ms,然后是 +30ms。然后我们就可以控制 GpioButton
中的此类代码
else if ((_dateTime.UtcNow - PressedStart).TotalMilliseconds >= LONG_PRESS_DURATION)
{
longReleaseAction();
}
依赖注入的一个问题是使用无法模拟的第三方类。一个很好的例子是 GpioController
。为了使一个类能够被模拟,它需要派生自一个接口(例如 IList
)或者它需要具有虚拟方法。GpioController
不具备这些条件,所以我们无法创建模拟 GpioController
。幸运的是,GpioController
构造函数确实接受 GpioDriver
作为参数。这允许我们创建一个模拟 GpioDriver
。然后我们可以调用 GpioController
,它将调用我们的模拟 GpioDriver
。一个简单的例子是 LedControllerTests
,我们可以在其中检查 SetLed()
方法是否实际写入正确的 gpio pin
public void SetLed_ShouldCall_DriverWriteMethod(string method, int pin, bool on)
{
// Given I have created a mock Gpio driver
var mockDriver = new Mock<GpioDriver>();
// And I have mocked the methods to write to a pin
mockDriver.Protected().Setup<bool>("IsPinModeSupported", ItExpr.IsAny<int>(),
ItExpr.IsAny<PinMode>()).Returns(true);
mockDriver.Protected().Setup<PinMode>("GetPinMode", ItExpr.IsAny<int>())
.Returns(PinMode.Output);
// And created a LedController
var gpioController = new GpioController(PinNumberingScheme.Logical, mockDriver.Object);
var ledController = new LedController(gpioController);
ledController.Initialize();
// When I call the controller - e.g. led.SetRed(true)
typeof(LedController).GetMethod(method).Invoke(ledController, new object[] { on });
// Then I expect the pin to be written to
mockDriver.Protected().Verify("Write", Times.Once(), ItExpr.Is<int>(p => p == pin),
ItExpr.Is<PinValue>(m => m == (on ? PinValue.High : PinValue.Low)));
}
最佳实践是使用接受任何输入(使用 IsAny
)的 Setup()
方法和检查特定参数(使用 Is
)的 Verify()
方法。
运行代码
运行或调试程序有两个步骤。假设树莓派已按照本文的第 1 部分进行设置,则需要两个步骤。这些步骤是
- 设置树莓派名称(通过 Ctrl Shift P)
- 运行,可以从菜单或通常位于左侧的活动栏中运行
关注点
在树莓派 C# 代码上工作就像在任何其他 C# 代码上工作一样容易。我们拥有所有可用的工具,如重构、智能感知等,这些工具使编写树莓派应用程序比我们尝试在树莓派本身上编写应用程序容易得多。
我在这里没有涵盖,但显然,我们可以包含我们会在任何其他 .NET 应用程序中包含的功能,例如实体框架、支持 swagger 的 Web 应用程序等。
为树莓派开发 .NET 与使用 Python、JavaScript 和 C++ 开发不同(所有这些都应该可以通过此处描述的树莓派设置在 VS Code 中实现),因为使用 .NET,C# 代码位于开发机器上,而对于其他语言,源代码(通常)位于目标机器上。我想这两种方式都没有关系(尽管您可能有自己的偏好),但理解这一点可以更清楚地了解发生了什么。
理论上,我们可以在我们的开发机器(无论是 Linux、macOS 还是 Windows)上本地运行代码。但是,gpio 调用将不起作用(如果您尝试,会发现它们会抛出“不支持”异常)。但应该可以创建一个与 GpioDriver
通信的模拟器。不过,这超出了本文的范围!
历史
- 2020 年 5 月 25 日:初始发布