Topshelf 和 Katana:统一的 Web 和服务体系结构
使用 IIS 托管 ASP.NET Web 应用程序已成为业界标准十年有余。构建此类应用程序的过程相对简单,但部署它们则并不容易。部署此类应用程序需要掌握应用程序配置层次结构的精妙知识、IIS 历史版本的细微差别,以及对网站、应用程序和虚拟目录的繁琐设置。许多关键的基础结构通常最终会驻留在应用程序之外的手动配置 IIS 组件中。
如果应用程序不仅仅要满足于 Web 请求,还需要支持长时间运行的请求、重复作业和其他处理工作,则很难在 IIS 中提供支持。解决方案通常就是要创建一个单独的 Windows 服务来托管这些组件。但这需要一个完全独立的部署过程,导致工作量翻倍。最后的办法就是让 Web 和服务进程互相通信。本来很简单的应用程序很快就会变为一个极复杂的应用程序。
图 1 展示了典型的此体系结构。Web 层负责处理快速请求并向系统提供 UI。长时间运行的请求委托给了服务,这样也就可以处理重复作业和处理工作。此外,服务还向 Web 层提供当前和今后工作的状态,以将其包含在 UI 中。
图 1:传统的分离型 Web 和服务体系结构
新方法
幸运的是,新技术不断涌现,可以大幅简化 Web 和服务应用程序的开发和部署工作。归功于 Katana 项目 (katanaproject.codeplex.com) 和由 OWIN (owin.org) 提供的规范,现在可以在不使用 IIS 的情况下自托管 Web 应用程序,同时仍支持许多普遍存在的 ASP.NET 组件,如 WebApi 和 SignalR。Web 自托管还可以与 Topshelf (topshelf-project.com) 一起嵌入最基本的控制台应用程序中,从而轻松创建 Windows 服务。因此,Web 和服务组件可以在同一进程中共存,如图 2 所示。这就免去了用于开发外部通信层、独立项目和独立部署过程的开销。
图 2:统一的 Web 和服务体系结构(包含 Katana 和 Topshelf)
此功能并非是全新的功能。Topshelf 已面世多年,有助于简化 Windows 服务开发,并且许多开放源代码的自托管 Web 框架(如 Nancy)也可使用。不过,直到 OWIN 发展成 Katana 项目,才出现了有望代替在 IIS 中托管 Web 应用程序的业界标准。此外,Nancy 以及许多开放源代码的组件也与 Katana 项目配合使用,让您可以创建一个兼收并蓄的灵活框架。
Topshelf 看似是可选的,但事实并非如此。如果不能简化服务开发,那么自托管 Web 开发的工作效率会变得极低。Topshelf 通过将服务视为控制台应用程序并将其作为服务进行托管,简化了服务开发。在要部署时,Topshelf 会将应用程序作为 Windows 服务自动处理它的安装和启动(完全免去了处理 InstallUtil 的开销);自动处理服务项目和服务组件的细微差别;以及在出错时将调试器附加到服务。Topshelf 还允许在代码中指定多个参数或在通过命令行进行安装的过程中配置多个参数(如服务名称)。
为了说明如何使用 Katana 和 Topshelf 统一 Web 和服务组件,我将构建一个简单的短信应用程序。我将从用于接收短信并排定短信发送队列的 API 入手。这将展示处理长时间运行的请求是多么容易。然后,我将添加一个用于返回未读短信计数的 API 查询方法,这也将展示从 Web 组件查询服务状态是多么容易。
接下来,我将添加一个管理接口来说明自托管 Web 组件仍能够构建内容丰富的 Web 界面。为了完成短信处理,我将添加一个用于按短信在队列中的顺序来发送短信的组件,以展示包含在内的服务组件。
为了突出这种体系结构的最好部分之一,我将创建 psake 脚本来体现部署的简化。
为了着重体现 Katana 和 Topshelf 的组合优势,我打算不详细介绍任何一个项目。有关详细信息,请参阅“Katana 项目入门”(bit.ly/1h9XaBL) 和“使用 Topshelf 轻松创建 Windows 服务”(bit.ly/1h9XReh)。
开始前必须拥有控制台应用程序
Topshelf 旨在以简单的控制台应用程序入手,简化 Windows 服务的开发和部署。若要开始创建短信应用程序,我需要创建一个 C# 控制台应用程序,然后从程序包管理器控制台安装 Topshelf NuGet 程序包。
当控制台应用程序启动时,我需要配置 Topshelf HostFactory,将应用程序作为控制台(在开发环境中)和服务(在生产环境中)进行抽象托管:
private static int Main() { var exitCode = HostFactory.Run(host => { }); return (int) exitCode; }
HostFactory 将返回退出代码,此代码有助于在服务安装过程中发现故障并停止相应操作。托管配置器提供的服务方法可用于指定代表应用程序代码的入口点的自定义类型。Topshelf 将此视为要托管的服务,因为 Topshelf 是一个用于简化 Windows 服务创建的框架:
host.Service<SmsApplication>(service => { });
接下来,我将创建 SmsApplication 类型来包含用于使自托管 Web 服务器和传统 Windows 服务组件加速运行的逻辑。这种自定义类型至少将包含在应用程序启动或停止时要执行的行为:
public class SmsApplication { public void Start() { } public void Stop() { } }
因为我选择使用服务类型的普通旧 CLR 对象 (POCO),因此我向 Topshelf 提供了 lambda 表达式来构建 SmsApplication 类型实例,并指定了启动和停止方法:
service.ConstructUsing(() => new SmsApplication()); service.WhenStarted(a => a.Start()); service.WhenStopped(a => a.Stop());
由于 Topshelf 允许在代码中配置多个服务参数,因此我使用 SetDescription、SetDisplayName 和 SetServiceName 来描述并命名将在生产环境中安装的服务:
host.SetDescription("An application to manage sending sms messages and provide message status."); host.SetDisplayName("Sms Messaging"); host.SetServiceName("SmsMessaging"); host.RunAsNetworkService();
最后,我使用 RunAsNetworkService 来指示 Topshelf 将服务配置为作为网络服务帐户运行。您可以将此帐户更改为适应您环境的任意帐户。有关更多服务选项,请参阅 Topshelf 配置文档 (bit.ly/1rAfMiQ)。
将服务作为控制台应用程序运行与启动可执行文件一样简单。图 3 展示了启动和停止短信可执行文件的输出结果。因为这是控制台应用程序,因此当您在 Visual Studio 中启动应用程序时会看到相同的行为。
图 3:将服务作为控制台应用程序运行
整合 API
在 Topshelf 体系就位后,我便可以开始处理应用程序的 API。由于 Katana 项目提供用于自托管 OWIN 管道的组件,因此我安装 Microsoft.Owin.SelfHost 程序包来整合自托管组件。此程序包引用多个程序包,其中两个程序包对于自托管来说非常重要。首先,Microsoft.Owin.Hosting 提供一套用于托管和运行 OWIN 管道的组件。其次,Microsoft.Owin.Host.HttpListener 提供 HTTP 服务器的实施。
在 SmsApplication 内部,我使用由托管程序包提供的 WebApp 类型创建自托管 Web 应用程序:
protected IDisposable WebApplication; public void Start() { WebApplication = WebApp.Start<WebPipeline>("http://localhost:5000"); }
WebApp 启动方法需要两个参数,一个是用来指定将配置 OWIN 管道的类型的泛型参数,另一个是用来侦听请求的 URL。Web 应用程序是可释放的资源。当 SmsApplication 实例停止时,我释放 Web 应用程序:
public void Stop() { WebApplication.Dispose(); }
使用 OWIN 的一大优势在于我可以利用各种熟悉的组件。首先,我将使用 WebApi 创建 API。我需要安装 Microsoft.AspNet.WebApi.Owin 程序包将 WebApi 整合在 OWIN 管道中。然后,我将创建 WebPipeline 类型来配置 OWIN 管道并插入 WebAPI 中间件。此外,我还将把 WebApi 配置为使用属性路由:
public class WebPipeline { public void Configuration(IAppBuilder application) { var config = new HttpConfiguration(); config.MapHttpAttributeRoutes(); application.UseWebApi(config); } }
我现在可以创建用于接收短信并排定短信发送队列的 API 方法:
public class MessageController :ApiController { [Route("api/messages/send")] public void Send([FromUri] SmsMessageDetails message) { MessageQueue.Messages.Add(message); } }
SmsMessageDetails 包含短信的有效负载。发送操作将短信添加到队列中,以供稍后对短信进行异步处理。MessageQueue 是全局的 BlockingCollection。在实际的应用程序中,这可能意味着您需要考虑其他因素,如持续性和扩展性:
public static readonly BlockingCollection<SmsMessageDetails> Messages;
在分离型 Web 和服务体系结构中,传递长时间运行的请求的异步处理(如发送短信)需要在 Web 和服务进程之间进行通信。通过添加 API 方法来查询服务状态则意味着增加更多的通信开销。使用统一方法则可以在 Web 和服务组件之间轻松共享状态信息。为了展示这一点,我向 API 中添加了 PendingCount 查询:
[Route("api/messages/pending")] public int PendingCount() { return MessageQueue.Messages.Count; }
构建内容丰富的 UI
虽然 API 方便使用,但自托管 Web 应用程序仍需要支持可视界面。我猜想今后 ASP.NET MVC 框架或衍生框架将用作 OWIN 中间件。Nancy 暂时是兼容的,且具有支持 Razor 视图引擎核心部分的程序包。
我将安装 Nancy.Owin 程序包来添加对 Nancy 的支持,并安装 Nancy.Viewengines.Razor 来整合 Razor 视图引擎。为了将 Nancy 插入 OWIN 管道,我需要在注册 WebApi 之后注册它,这样它就不会捕获我映射到 API 的路由。默认情况下,如果找不到资源,那么 Nancy 会返回错误,而 WebApi 会传递自身无法处理回管道的请求:
application.UseNancy();
若要详细了解如何结合使用 Nancy 和 OWIN 管道,请参阅“使用 OWIN 托管 Nancy”(bit.ly/1gqjIye)。
为了构建管理状态界面,我添加了 Nancy 模块并映射了状态路由来呈现状态视图,同时将未读短信计数作为视图模型进行传递:
public class StatusModule :NancyModule { public StatusModule() { Get["/status"] = _ => View["status", MessageQueue.Messages.Count]; } }
此时的视图并不是很具有吸引力,只是显示未读短信的简单计数而已:
<h2>Status</h2> There are <strong>@Model</strong> messages pending.
我将使用一个简单的启动导航栏将视图丰富一点,如图 4 所示。使用启动导航栏需要托管启动样式表的静态内容。
图 4:管理状态页
我可以使用 Nancy 托管静态内容,但 OWIN 的优势在于混合和匹配中间件,因此我将使用属于 Katana 项目的新发布的 Microsoft.Owin.StaticFiles 程序包。StaticFiles 程序包提供文件服务中间件。我将把它添加到 OWIN 管道的起点位置,这样 Nancy 静态文件服务就不会启动了。
application.UseFileServer(new FileServerOptions { FileSystem = new PhysicalFileSystem("static"), RequestPath = new PathString("/static") });
FileSystem 参数指示文件服务器在哪里查找要为其提供服务的文件。我使用的是名为“静态”的文件夹。RequestPath 指定用于侦听此内容请求的路由前缀。在本示例中,我选择映射名称“静态”,但这些名称并不必一致。我使用布局中的以下链接来引用启动样式表(我自然将启动样式表放入静态文件夹内的一个 CSS 文件夹中):
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
关于静态内容和视图的介绍
在我继续讲述之前,我想告诉您一条非常有用的提示,可帮助您开发自托管 Web 应用程序。通常情况下,您会设置要复制到输出目录的静态内容和 MVC 视图,以便自托管 Web 组件可以发现它们与当前执行的程序集相关。不仅这是一项容易被遗忘的负担操作,而且更改视图和静态内容也需要重新编译应用程序,这绝对会扼杀工作效率。因此,我建议不要将静态内容和视图复制到输出目录,而是将中间件(如 Nancy 和 FileServer)配置为映射到开发文件夹。
默认情况下,控制台应用程序的调试输出目录为 bin/Debug,因此在开发环境中,我指示 FileServer 浏览当前目录上面的两个目录来查找包含启动样式表的静态文件夹:
FileSystem = new PhysicalFileSystem(IsDevelopment() ?"../../static" :"static")
然后,为了指示 Nancy 在哪里查找视图,我将创建自定义 NancyPathProvider:
public class NancyPathProvider :IRootPathProvider { public string GetRootPath() { return WebPipeline.IsDevelopment() ?Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"..\..\") :AppDomain.CurrentDomain.BaseDirectory; } }
同样地,如果我是在 Visual Studio 的开发模式下运行,则使用相同的检查方法来浏览基目录上面的两个目录。我已经把 IsDevelopment 的实施留给您自行决定;您可以通过简单的配置设置,也可以编写代码来检测应用程序何时从 Visual Studio 启动。
为了注册此自定义根路径提供程序,我创建了自定义 NancyBootstrapper 并覆盖了默认的 RootPathProvider 属性来创建 NancyPathProvider 的实例:
public class NancyBootstrapper :DefaultNancyBootstrapper { protected override IRootPathProvider RootPathProvider { get { return new NancyPathProvider(); } } }
当我将 Nancy 添加到 OWIN 管道时,我传递选项中的 NancyBootstrapper 实例:
application.UseNancy(options => options.Bootstrapper = new NancyBootstrapper());
发送短信
接收短信只完成了一半工作,应用程序仍需要短信发送过程。这是一个在独立服务中存在的传统过程。在此统一解决方案中,我可以仅添加随应用程序一起启动的 SmsSender。我将把此功能添加到 SmsApplication 启动方法中(在实际应用程序中,您应该添加用于停止和释放此资源的功能):
public void Start() { WebApplication = WebApp.Start<WebPipeline>("http://localhost:5000"); new SmsSender().Start(); }
在 SmsSender 启动方法中,我启动了一个长时间运行的任务来发送短信:
public class SmsSender { public void Start() { Task.Factory.StartNew(SendMessages, TaskCreationOptions.LongRunning); } }
当 WebApi 发送操作收到短信时,它会将其添加到作为拦截集合的短信队列中。我创建了用于拦截短信的 SendMessages 方法。这可能要归功于 GetConsumingEnumerable 后面的抽象层。当一组短信到达时,它会立即开始发送它们:
private static void SendMessages() { foreach (var message in MessageQueue.Messages.GetConsumingEnumerable()) { Console.WriteLine("Sending:" + message.Text); } }
这对于通过加速运行多个 SmsSender 实例来扩展短信发送容量来说无关紧要。在实际应用程序中,您希望通过将 CancellationToken 传递到 GetConsumingEnumerable 来安全停止枚举。若要详细了解拦截集合,则不妨访问 bit.ly/QgiCM7 和 bit.ly/1m6sqlI。
轻松部署
由于使用了 Katana 和 Topshelf,因此开发组合的服务和 Web 应用程序非常简单。这种功能强大的组合的非凡优势之一在于在很大程度上简化了部署过程。我将介绍使用 psake (github.com/psake/psake) 的简单两步部署。我并不是要提供用于实际生产用途的可靠脚本;我只是想展示过程是多么简单,无论您使用哪种工具。
第一步是构建应用程序。我创建了使用解决方案路径调用 MSBuild 的生成任务,并创建了发布版本(输出将最终位于 bin/Release 中):
properties { $solution_file = "Sms.sln" } task build { exec { msbuild $solution_file /t:Clean /t:Build /p:Configuration=Release /v:q } }
第二步是将应用程序作为服务进行部署。我创建了依赖生成任务的部署任务,并声明了用于容纳安装位置路径的交付目录。为简单起见,我只部署到本地目录。然后,我创建了用于指向交付目录中的控制台应用程序可执行文件的可执行变量:
task deploy -depends build { $delivery_directory = "C:\delivery" $executable = join-path $delivery_directory 'Sms.exe'
首先,部署任务将检查交付目录是否存在。如果它能够找到交付目录,则会假设服务已部署。在这种情况下,部署任务将卸载服务并删除交付目录:
if (test-path $delivery_directory) { exec { & $executable uninstall } rd $delivery_directory -rec -force }
接下来,部署任务通过将生成输出复制到交付目录来部署新代码,然后将视图和静态文件夹复制到交付目录:
copy-item 'Sms\bin\Release' $delivery_directory -force -recurse -verbose copy-item 'Sms\views' $delivery_directory -force -recurse -verbose copy-item 'Sms\static' $delivery_directory -force -recurse –verbose
最后,部署任务将安装和启动服务:
exec { & $executable install start }
当您部署服务时,请确保 IsDevelopment 实施返回 false,否则您会在文件服务器找不到静态文件夹时看到“拒绝访问”异常消息。此外,有时在每次部署时重新安装服务也可能会出现问题。如果服务已经安装,那么另一个策略就是停止、更新并启动服务。
如您所见,部署过程在很大程度上得到了简化。完全不需要使用 IIS 和 InstallUtil;部署过程只有一个,而不是两个;也无需担心 Web 和服务层将如何通信。此部署任务可以在您生成统一的 Web 和服务应用程序时反复运行!
展望未来
确定该组合模型是否适合您的最好办法是找一个低风险的项目尝试一下。使用这种体系结构开发和部署应用程序相当简单。这将是不经常发生的学习曲线(例如,如果您对 MVC 使用 Nancy)。不过,即使组合方法行不通,使用 OWIN 也会带来一项非凡优势,即您仍可以使用 ASP.NET 主机 (Microsoft.Owin.Host.SystemWeb) 在 IIS 内部托管 OWIN 管道。请试一试,看看您的想法会是什么样子。
Wes McClure 利用他的专业知识帮助客户快速交付高质量软件,从而大幅提高为客户创造的价值。他喜欢谈论一切与软件开发相关的话题,同时也是一位 Pluralsight 作者,并在 devblog.wesmcclure.com 上描述自己的各种经历。可通过 wes.mcclure@gmail.com 与他联系。