使用 MVVM on SignalR 实现实时 Web 应用 - 第二部分
为 Windows 服务或跨平台 Mono 自托管构建实时 Web 用户界面。
引言
在第一部分中,我介绍了 dotNetify
,这是一个用于实时、交互式 Web 应用程序开发的 C#/JavaScript 库,它在 SignalR 之上提供了 MVVM 风格的数据绑定,具有以下主要优点:
- 允许您以更高程度的关注点分离来架构您的 Web 应用程序:客户端仅保留 UI 组件特定的逻辑,而与 UI 无关的表示和业务逻辑保留在服务器端。
- 消除了实现 RESTful 服务层的需求,使您的代码更精简、更易于维护。
- 您的绝大部分逻辑将位于服务器端,您可以在其中充分利用 C#/.NET 中更好的语言/工具支持,这将使应用 SOLID 原则变得更加容易。
dotNetify
库不仅限于 ASP.NET。在此第二部分中,我将接续第一部分中的实时 Web 图表示例,并将其进一步开发为一个简单的进程监视器,该监视器在浏览器上提供交互式 UI,并且可以在 Windows 服务上托管,或借助 Mono 在 Linux 机器上自托管。
我将使用 Nancy 代替 ASP.NET,它是一个开源的轻量级 Web 框架,非常适合这类应用程序。Nancy 将托管在 OWIN 之上。
架构
我们将要构建的简单进程监视器将是一个可按名称过滤的进程列表。当在列表中选择一个进程时,我们将显示 CPU 和内存使用率的两个图表,并且图表将每秒更新一次。我们将使用 System.Diagnostics
提供的 API 来获取所需的所有数据。
下图显示了组件。我们将把解决方案分为 3 个项目:引用 dotNetify
、Nancy 库及其依赖项的主要类库;自托管控制台应用程序外壳;以及 Windows 服务外壳。
Nancy 主要通过静态文件(HTML、样式、脚本)提供浏览器的初始 HTTP 请求,一旦客户端上的 dotNetify
脚本运行,其余的双向通信将通过 SignalR 完成。
构建解决方案
在这里,我将引导您完成设置 Visual Studio 2015 解决方案的过程,并使用 NuGet 包含库。
设置类库
让我们从创建一个全新的类库项目开始,并将其命名为 DemoLibrary
,然后使用 NuGet 包管理器控制台安装以下包:
Install-Package Microsoft.Owin.Hosting
Install-Package Nancy.Owin
Install-Package DotNetify
在项目中,创建一个 OWIN 启动文件并初始化库。
using System;
using Microsoft.Owin;
using Microsoft.Owin.Hosting;
using Owin;
using DotNetify;
[assembly: OwinStartup(typeof(DemoLibrary.Startup))]
namespace DemoLibrary
{
public class Startup
{
public void Configuration(IAppBuilder app)
{
app.MapSignalR();
app.UseNancy();
// Tell dotNetify which assembly to find the view models.
// In this case, it is this assembly.
VMController.RegisterAssembly(GetType().Assembly);
}
public static IDisposable Start(string url)
{
return WebApp.Start<Startup>(url);
}
}
}
接下来,创建一个 Nancy 模块,HTTP 请求将到达这里。如您所见,它的唯一工作是响应根 URL 请求,提供 HTML 视图。
using Nancy;
namespace DemoLibrary
{
public class HomeModule : NancyModule
{
public HomeModule()
{
Get["/"] = x => View["Dashboard.html"];
}
}
}
Nancy 预先配置了查找静态文件的位置,包括名为“Content”和“Views”的文件夹。所以这就是我们将放置 CSS 文件(在 Content 下)和 HTML 文件(在 Views 下)的地方。但是,“Scripts”似乎不是 Nancy 的约定一部分,因此我们需要通过添加一个 Nancy 引导程序文件来告诉 Nancy 识别它。
using Nancy;
using Nancy.Bootstrapper;
using Nancy.Conventions;
using Nancy.TinyIoc;
namespace DemoLibrary
{
public class Bootstrapper : DefaultNancyBootstrapper
{
protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines)
{
base.ApplicationStartup(container, pipelines);
this.Conventions.StaticContentsConventions
.Add(StaticContentConventionBuilder.AddDirectory("Scripts"));
}
}
}
项目编译时,我们需要将 static
文件复制到 Bin 文件夹,因此现在将添加到项目设置的生成后事件以复制它们。
xcopy /E /Y "$(ProjectDir)Views" "$(ProjectDir)$(OutDir)Views\*" xcopy /E /Y "$(ProjectDir)Content" "$(ProjectDir)$(OutDir)Content\*" xcopy /E /Y "$(ProjectDir)Scripts" "$(ProjectDir)$(OutDir)Scripts\*"
由于我们将创建其他项目,因此让我们让它们都编译到同一个 Bin 文件夹,方法是将输出路径更改为 ..\Bin。现在我们可以实现进程监视器逻辑了。
视图模型
第一个视图模型将是呈现进程列表的视图模型。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Diagnostics;
using DotNetify;
namespace DemoLibrary
{
public class ProcessVM : BaseVM
{
public class ProcessItem
{
public int Id { get; set; }
public string Name { get; set; }
}
public string SearchString
{
get { return Get<string>(); }
set
{
Set( value );
Changed( () => Processes );
}
}
public List<ProcessItem> Processes
{
get
{
return Process.GetProcesses()
.Where( i => String.IsNullOrEmpty( SearchString ) ||
i.ProcessName.StartsWith( SearchString,
StringComparison.InvariantCultureIgnoreCase ) )
.OrderBy( i => i.ProcessName )
.ToList()
.ConvertAll( i => new ProcessItem { Id = i.Id, Name = i.ProcessName } );
}
}
public int SelectedProcessId
{
get { return Get<int>(); }
set
{
Set( value );
Selected?.Invoke( this, value );
}
}
public event EventHandler<int> Selected;
}
}
每个视图模型都派生自 BaseVM
,它提供了 INotifyPropertyChanged
的实现以及相关的 Get
、Set
和 Changed
方法。有 3 个属性将绑定到视图上的控件:
SearchString
,绑定到用于增量搜索的文本框。当用户键入时,此属性将接收字符并为Processes
属性引发更改事件。Processes
,绑定到一个显示进程 ID 和名称的列表视图。SelectedProcessId
,绑定到任何列表项的单击事件。更改时,这将引发Selected
事件。
接下来是呈现 CPU 使用率图表的视图模型:
using System;
using System.Diagnostics;
using DotNetify;
namespace DemoLibrary
{
public class CpuUsageVM : BaseVM
{
private PerformanceCounter _cpuCounter =
new PerformanceCounter
( "Processor", "% Processor Time", "_Total" );
public string Title
{
get { return Get<string>() ?? "Processor Time"; }
set { Set( value ); }
}
public double Data
{
get { return Math.Round( (double) _cpuCounter.NextValue(), 2 ); }
}
public int ProcessId
{
set
{
var processName = GetProcessInstanceName( value );
if ( processName != null )
{
_cpuCounter.Dispose();
_cpuCounter = new PerformanceCounter
( "Process", "% Processor Time", processName );
Title = "Processor Time - " + processName;
}
}
}
public void Update()
{
Changed( () => Data );
PushUpdates();
}
private string GetProcessInstanceName( int id )
{
//... use System.Diagnostic API to return process instance name given the ID ...
}
}
}
与第一部分中的示例一样,Data
属性将绑定到图表控件,但我们不会在这里放置每秒更新一次的计时器,因为我们希望在内存使用率图表视图模型上使用相同的计时器。因此,此类仅提供一个 public
方法来引发属性的更改事件并将更新推送到客户端。
ProcessId
属性设置器会将数据源切换到给定 ID 的进程。此属性将在响应进程列表视图中选择了一个进程后设置。
MemoryUsageVM
类遵循类似的结构,无需进一步说明。这样就到了最后一个视图模型,它需要一些解释。
主视图模型
到目前为止,我们有三个视图模型在网页上呈现不同的视图。如果它们不需要交互,那么这就是全部。但我们确实希望它们交互,以便用户在进程视图中选择一个进程会导致图表将其数据源切换到该进程。因此,需要有一种方法来建立视图模型之间的观察者模式。
dotNetify
的默认行为是独立实例化视图模型,并且仅在请求时实例化。但是,为了支持这种确切的需求,它引入了**主视图模型**的概念。当客户端请求与主视图模型关联的视图模型时,实例化请求将转到主视图模型来完成,这给了它机会对实例执行各种初始化。
以下主视图模型实现了 GetSubVM
以提供已初始化的上述视图模型实例,从而使 ProcessVM
实例可以被观察,并在选择进程时通知其他视图模型。计时器放置在此处,每秒调用一次图表视图模型的 Update
方法,以便它们可以每秒将其更新推送到客户端。
using System.Timers;
using DotNetify;
namespace DemoLibrary
{
public class DashboardVM : BaseVM
{
private Timer _timer;
private MemoryUsageVM _memoryUsageVM = new MemoryUsageVM();
private CpuUsageVM _cpuUsageVM = new CpuUsageVM();
private ProcessVM _processVM = new ProcessVM();
public DashboardVM()
{
_processVM.Selected += ( sender, e ) =>
{
_cpuUsageVM.ProcessId = e;
_memoryUsageVM.ProcessId = e;
};
// Run a timer every second to update the chart.
_timer = new Timer( 1000 );
_timer.Elapsed += Timer_Elapsed;
_timer.Start();
}
public override void Dispose()
{
_timer.Stop();
_timer.Elapsed -= Timer_Elapsed;
base.Dispose();
}
public override BaseVM GetSubVM( string vMTypeName )
{
if ( vMTypeName == typeof( CpuUsageVM ).Name )
return _cpuUsageVM;
else if ( vMTypeName == typeof( MemoryUsageVM ).Name )
return _memoryUsageVM;
else if ( vMTypeName == typeof( ProcessVM ).Name )
return _processVM;
return base.GetSubVM( vMTypeName );
}
private void Timer_Elapsed( object sender, ElapsedEventArgs e )
{
_memoryUsageVM.Update();
_cpuUsageVM.Update();
}
}
}
视图
我们现在将在 Views 文件夹下创建 HTML 视图 Dashboard.html。此处显示的内容已修剪,主要是其 Twitter Bootstrap 样式(.css 文件放置在 Content/css
中),以专注于感兴趣的内容。
<!DOCTYPE html>
<html>
<head>
<link href="/Content/css/bootstrap.min.css" rel="stylesheet">
<script src="/Scripts/require.js"
data-main="/Scripts/app"></script>
</head>
<body>
<div data-master-vm="DashboardVM">
<div class="row">
<!-- Process List -->
<div data-vm="ProcessVM" class="col-md-5">
<div class="input-group">
<span>Search:</span>
<input type="text" data-bind="textInput: SearchString" />
</div>
<div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Process Name</th>
</tr>
</thead>
<tbody data-bind="foreach: Processes">
<tr data-bind="css:
{ 'active info': $root.SelectedProcessId() == $data.Id() },
vmCommand: { SelectedProcessId: Id }">
<td><span data-bind="text:
Id"></span></td>
<td><span data-bind="text:
Name"></span></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="col-md-5">
<!-- CPU Usage Panel -->
<div data-vm="CpuUsageVM">
<div class="panel-heading">
<span data-bind="text: Title"></span>
<div><span data-bind="text:
_currentValue"></span><span>%</span></div>
</div>
<div class="panel-body">
<canvas data-bind="vmOn: { Data: updateChart }" />
</div>
</div>
<!-- Memory Usage Panel -->
<div data-vm="MemoryUsageVM">
<div class="panel-heading">
<span data-bind="text: Title"></span>
<div><span data-bind="text:
_currentValue"></span><span> KBytes</span></div>
</div>
<div class="panel-body">
<canvas data-bind="vmOn: { Data: updateChart }" />
</div>
</div>
</div>
</div>
</div>
</body>
</html>
data-vm
属性标记了命名视图模型的范围,如第一部分中所述,但现在它们嵌套在具有 data-master-vm
属性的标记下,将它们置于命名主视图模型的范围内。
在此视图中,我们使用了标准的 Knockout 数据绑定符号来将各种标记绑定到视图模型属性,例如 textInput
、forEach
、css
和 text
。DotNetify 添加了自己的自定义绑定:
vmOn
,它将在Data
更改事件上调用updateChart
JavaScript 函数。vmCommand
,用于命令绑定。在上面的情况下,当单击表行时,将与关联进程项的Id
属性值发送到SelectedProcessId
。
代码隐藏
为了在 canvas 元素上渲染图表,视图使用了 ChartJS 库(.js 文件放置在 Scripts
中),这需要使用 JavaScript 初始化和更新。在第一部分中,这被称为**代码隐藏**,以模块模式编写,并且基本上是应用程序中处理 UI 特定组件的客户端逻辑。这是相同的 JavaScript 转换为 TypeScript:
declare var Chart: any;
class LiveChartVM {
// Local observable to display current data value.
_currentValue: number = 0;
// On data update, update the chart.
updateChart(iItem, iElement) {
var vm: any = this;
var data = vm.Data();
if (data == null)
return;
if (vm._chart == null) {
vm._chart = this.createChart(data, iElement);
vm._counter = 0;
}
else {
vm._chart.addData([data], "");
vm._counter++;
if (vm._counter > 30)
vm._chart.removeData(); // Remove the oldest data.
}
vm._currentValue(data);
vm.Data(null);
}
// Create the chart with ChartJS.
createChart(iData, iElement) {
var chartData = { labels: [], datasets: [{ data: iData }] };
return new Chart(iElement.getContext('2d')).Line(chartData);
}
}
我们将重用此代码来处理 CPU 和内存使用率图表,只需通过继承它即可。
class CpuUsageVM extends LiveChartVM {}
class MemoryUsageVM extends LiveChartVM {}
为此类库要做的最后一件事是将转译的 JavaScript 文件包含在 app.js 中,这是 dotNetify
的 RequireJS 配置文件。我将那些 TypeScript 文件放在 /Scripts/CodeBehind 下。
require.config({
baseUrl: '/Scripts',
paths: {
// ... dotNetify's dependency libraries ...
"chart": "chart.min",
"live-chart": "CodeBehind/LiveChart",
"cpu-usage": "CodeBehind/CPUUsage",
"mem-usage": "CodeBehind/MemoryUsage",
},
shim: {
// ...
"live-chart": { deps: ["chart"] },
"cpu-usage": { deps: ["live-chart"] },
"mem-usage": { deps: ["live-chart"] }
}
});
require(['jquery', 'dotnetify','cpu-usage', 'mem-usage'], function ($) {
$(function () {
});
});
编译后,请确保从 TypeScript 文件创建了 JavaScript 文件。我对此有些偏执,因为 Visual Studio 仅在保存时构建它们,而当您从存储库执行新获取时,它们不会构建。这就是为什么我通常在签入时包含转译的 JavaScript 文件。
设置自托管控制台应用
现在我们将设置控制台应用程序来自托管该应用程序。这对于测试很有用,稍后我们可以将其用于通过 Mono 部署到其他操作系统。创建一个新的控制台应用程序项目,然后:
- 将输出路径更改为 ..\Bin。
- 添加对
DemoLibrary
的引用。 - 安装此包:
Install-Package Microsoft.Owin.Host.HttpListener
将 Program.cs 中的类替换为这个,您就大功告成了!
class Program
{
static void Main(string[] args)
{
var url = "http://+:8080";
using (DemoLibrary.Startup.Start(url))
{
Console.WriteLine("Running on {0}", url);
Console.WriteLine("Press enter to exit");
Console.ReadLine();
}
}
}
以管理员权限构建并运行此应用程序。在浏览器中转到 localhost:8080
,您应该会看到下面的屏幕:
设置 Windows 服务
现在我们可以将其托管在 Windows 服务中。创建一个新的 Windows 服务项目,然后执行与控制台应用程序相同的操作:将输出路径更改为 ..\Bin,添加对 DemoLibrary
的引用,并安装 Microsoft.Owin.Host.HttpListener
包。然后:
- 打开 Service1.cs
[设计]
,右键单击并选择**添加安装程序**。 - 在
ServiceInstaller1
属性中,设置服务名称。 - 在
ServiceProcessInstaller1
属性中,将帐户更改为LocalSystem
。 - 打开 Service1.cs 并用以下代码替换类:
public partial class Service1 : ServiceBase
{
IDisposable _demo;
public Service1()
{
InitializeComponent();
}
protected override void OnStart(string[] args)
{
var url = "http://+:8080";
_demo = DemoLibrary.Startup.Start(url);
}
protected override void OnStop()
{
if (_demo != null)
_demo.Dispose();
}
}
构建该服务并使用 InstallUtil.exe 注册该服务。启动服务并通过在浏览器中访问 localhost:8080
进行测试。
跨平台与 Mono
要让此应用程序在 Linux 机器上运行,只需很少的操作。您所要做的就是使用 Xamarin Studio(免费版本即可)编译控制台应用程序项目,将整个 Bin 文件夹复制到该 Linux 机器,安装 Mono,然后使用 mono
运行 .exe。这是我在 Koding 的 Ubuntu VM 上运行它时的样子:
在此环境中,该应用程序能够获取进程名称和总 CPU 使用率,但无法获取单个进程信息;这可能是由于 Mono 对 System.Diagnostic
的实现。
至此,本次演示结束。如果您喜欢这里的内容,dotNetify
的网站上有更多示例,网址为 http://dotnetify.net。它是一个免费的开源项目,托管在 Github 上,欢迎参与。