蒋国纲的技术博客

蒋国纲的技术博客

在之前一篇博客《以Windows服务方式运行ASP.NET Core程序》中我讲述了如何把ASP.NET Core程序作为Windows服务运行的方法,而今,我们又遇到了新的问题,那就是:我们的控制台程序,也就是普通的.NET Core程序(而不是ASP.NET Core程序)如何以服务的方式运行呢?

这个问题我们在.NET Core之前早就遇到过,那是是.NET Framework的时代(其实距今也没多远啦),我们是用一个第三方的组件——Topshelf,来解决这个问题的,Topshelf的官网是:http://topshelf-project.com/,它的使用很简单,官网上有具体的描述,对于一个普通的控制台程序而言(通常是一个不需要图形界面的服务),开发和调试的时候,把它当做一个普通的控制台程序来使用,十分方便;而实际部署的时候,通过传入不同的命令行参数,可以使它有了新的行为:安装Windows服务、运行Windows服务、停止/重启Windows服务或者卸载Windows服务。进入跨平台的.NET Core时代之后,Topshelf自然有了支持.NET Core的版本,使用方法与之前的类似,具体在此不表了,因为接下来我们根本不打算使用它!

现在我想要的是:不要引入任何组件,不要对现在控制台程序进行任何修改(ASP.NET Core程序也是控制台程序),开发调试时候不要进行任何复杂的参数配置,一切照旧,仅仅是在部署阶段,把程序当做Windows服务去运行。——你嘚讲吼不吼?

要达到这个目标,就要借助一个神器了,此神器为NSSM,Non-Sucking Service Manager,名字有点拗口,翻译成中文就是:不嗝屁服务管理器。

NSSM的官网是:https://nssm.cc/,十分简陋,但程序功能可是非常强大和全面的,下面我来一步步演示它如何使用。

1,先构建一个简单的服务程序

构建一个简单的服务程序,程序功能描述:程序没有图形界面,仅仅是定时记录一些日志(5秒钟写一下日志),在用户按下<Ctrl>+<C>的时候,程序退出。功能明确,Okay,let's get down to work.

1. 创建一个.NET Core Application,叫MyService

2. Nuget引入Quartz和NLog.Extensions.Logging,一个用来做定时任务,另一个用来log

3. 另外,程序使用了依赖注入,还需要用Nuget引入Microsoft.Extensions.DependencyInjection

4. 给项目增加NLog.Config配置文件,内容是

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      autoReload="true"
      throwExceptions="false"
      internalLogLevel="Off">
  <variable name="theLayout" value="${date:format=HH\:mm\:ss.fff} [${level}][${logger}] ${callsite:className=False:fileName=True:methodName=False} ${message} ${onexception:${newline}}${exception:format=Message,ShortType,StackTrace:innerFormat=Message,ShortType,StackTrace:separator=\r\n:innerExceptionSeparator=\r\n---Inner---\r\n:maxInnerExceptionLevel=5}"/>
  <targets>
    <target name="asyncFile" xsi:type="AsyncWrapper">
      <target name="logfile" xsi:type="File" fileName="${basedir}/log/${shortdate}.log" layout="${theLayout}" encoding="UTF-8" />
    </target>
    <target name="debugger" xsi:type="Debugger" layout="${theLayout}" />
    <target name="console" xsi:type="Console" layout="${theLayout}" />
    <target name="void" xsi:type="Null" formatMessage="false" />
  </targets>
  <rules>
    <logger name="Quartz.*" minlevel="Trace" maxlevel="Info" writeTo="void" final="true" />
    <logger name="*" minlevel="Debug" writeTo="asyncFile" />
    <logger name="*" minlevel="Trace" writeTo="debugger"/>
    <logger name="*" minlevel="Trace" writeTo="console"/>
  </rules>
</nlog>

还要注意的是这个文件必须复制到生成目录去以便程序运行时候能够加载到。

5. 增加MyServiceJobFactory.cs

using Quartz;
using Quartz.Spi;
using System;
namespace MyService {
    class MyServiceJobFactory : IJobFactory {
        protected readonly IServiceProvider _container;
        public MyServiceJobFactory(IServiceProvider container) {
            _container = container;
        }
        public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler) {
            return _container.GetService(bundle.JobDetail.JobType) as IJob;
        }
        public void ReturnJob(IJob job) {
        }
    }
}

6. 增加PeriodLoggingJob.cs

using Microsoft.Extensions.Logging;
using Quartz;
using System;
using System.Threading.Tasks;
namespace MyService {
    class PeriodLoggingJob  : IJob {
        private readonly ILogger<PeriodLoggingJob> _logger;
        public PeriodLoggingJob(ILogger<PeriodLoggingJob> logger, IServiceProvider serviceProvider) {
            _logger = logger;
        }
        private void DoLoggingJob() {
            _logger.LogInformation("logging...");
        }
        public Task Execute(IJobExecutionContext context) {
            try {
                DoLoggingJob();
            }
            catch (Exception ex) { //必须妥善处理好定时任务中发生的异常
                _logger.LogError(ex, "执行定时任务发生意外错误");
            }
            returnTask.CompletedTask;
        }
    }
}

7. Program.cs的完整内容如下

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NLog.Extensions.Logging;
using Quartz;
using Quartz.Impl;
using Quartz.Spi;
using System;
using System.Collections.Specialized;
using System.IO;
using System.Threading;
namespace MyService {
    class Program {
        //注册各种服务
        static void RegisterServices(IServiceCollection services) {
            //日志相关
            services.AddSingleton<ILoggerFactory, LoggerFactory>();
            services.AddSingleton(typeof(ILogger<>), typeof(Logger<>));
            services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Trace));
            //定时任务相关
            services.AddSingleton<IJobFactory, MyServiceJobFactory>();
            services.AddSingleton<PeriodLoggingJob>();
        }
        static void Main(string[] args) {
            //注册退出事件处理(响应<Ctrl>+<C>)
            ManualResetEvent exitEvent = new ManualResetEvent(false);
            Console.CancelKeyPress += delegate (object sender, ConsoleCancelEventArgs e) {
                e.Cancel = true;
                exitEvent.Set();
            };
            //处理其它程序关闭事件(如kill),使得程序可以优雅地关闭
            AppDomain.CurrentDomain.ProcessExit += (sender, e) => { exitEvent.Set(); };
            //容器生成
            ServiceCollection services = new ServiceCollection();
            RegisterServices(services);
            using (ServiceProvider container = services.BuildServiceProvider()) {
                //日志初始化
                var loggerFactory = container.GetRequiredService<ILoggerFactory>();
                loggerFactory.AddNLog(new NLogProviderOptions {
                    CaptureMessageTemplates = true,
                    CaptureMessageProperties = true
                });
                string nlogConfigFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "NLog.config");
                NLog.LogManager.LoadConfiguration(nlogConfigFile);
                //记录启动日志
                ILogger<Program> logger = container.GetService<ILogger<Program>>();
                logger.LogInformation("MyService启动.");
                //定时任务配置
                NameValueCollection props = new NameValueCollection { { "quartz.serializer.type", "binary" } };
                StdSchedulerFactory schedulerFactory = new StdSchedulerFactory(props);
                IScheduler scheduler = schedulerFactory.GetScheduler().Result;
                scheduler.JobFactory = container.GetService<IJobFactory>();

                //每天1:00执行APP状态更新任务
                ITrigger periodLoggingJobTrigger = TriggerBuilder.Create().WithIdentity("PeriodLoggingJobTrigger")
                    .StartNow().WithSimpleSchedule(x=>x.WithIntervalInSeconds(5).RepeatForever()).Build();
                IJobDetail checkPasswordOutOfDateJob = JobBuilder.Create<PeriodLoggingJob>().WithIdentity("PeriodLoggingJob").Build();
                scheduler.ScheduleJob(checkPasswordOutOfDateJob, periodLoggingJobTrigger);

                //开启定时服务
                scheduler.Start();
                //----------------------------------------↑↑↑ 程序开始 ↑↑↑----------------------------------------
                exitEvent.WaitOne();
                //----------------------------------------↓↓↓ 程序结束 ↓↓↓----------------------------------------
                //定时任务结束
                scheduler.Shutdown();
                //记录结束日志
                logger.LogInformation("MyService停止.");
            }
        }
    }
}

这就是整个服务程序的完整内容,本来我可以提供一个更简单的程序,这里啰里啰嗦写了这么一大堆,目的还是让初学者更加清楚.NET Core的程序结构和运行方式。其中内容包括:NLog的使用、Quartz的使用、容器及依赖注入的入门例子、如何处理程序关闭事件等,也许你想问“为什么要引入Quartz,搞这么复杂,弄个Timer不行吗?”当然行,但Quartz更强大,而且更适合给大家演示容器与依赖注入的使用。

8. 试运行程序

运行这个程序,输出几条日志信息后,以<Ctrl>+<C>来结束程序的运行,这样会在程序目录下产生log目录及日志文件,文件的内容大致如下:

19:03:37.117 [Info][MyService.Program] (d:\work\MyService\MyService\Program.cs:55) MyService启动.
19:03:37.637 [Info][MyService.PeriodLoggingJob] (d:\work\MyService\MyService\PeriodLoggingJob.cs:15) logging...
19:03:42.536 [Info][MyService.PeriodLoggingJob] (d:\work\MyService\MyService\PeriodLoggingJob.cs:15) logging...
19:03:47.535 [Info][MyService.PeriodLoggingJob] (d:\work\MyService\MyService\PeriodLoggingJob.cs:15) logging...
19:03:49.293 [Info][MyService.Program] (d:\work\MyService\MyService\Program.cs:80) MyService停止.

9. 发布程序

选择publish,在publish的目标目录下产生一堆文件,将这些文件复制到D:\Service\MyService目录下,一会儿我们要用到这个目录。

2,NSSM配置

首先要获取NSSM程序,当然是要到官网下载,版本选择最新版,尽管它声称是pre-release版,但功能杠杠的,没有任何影响,而正式版(非pre-release)则是2014年的了,太旧了。下载下来后找到对应的exe文件,叫nssm.exe。(注意有32位版和64位版的分别)
 
它是个绿色软件,不需要安装,仅此一个exe文件,把这个文件复制到C:\Windows\System32目录下,之后经常要用。
 
在Windows命令行中直接敲nssm,会出现它的帮助提示。

1. 安装服务

>nssm install MyService
出现配置界面(注意,需要管理员权限)
配置选项比较多,这是我的配置,供参考:
以Windows服务方式运行.NET Core程序-LMLPHP
 
点“Install service”即将服务安装好了。我们打开Windows服务来查看所安装的服务:
 
以Windows服务方式运行.NET Core程序-LMLPHP
 
服务已经安装完毕,一切准备就绪。
 
2. 启动服务
>nssm start MyService

其它一些操作
其实不用我说大家也应该知道了:

  • nssm status MyService 查看服务状态
  • nssm stop MyService 停止服务
  • nssm restart MyService 重启服务
  • nssm edit MyService 重新配置服务的参数
  • nssm remove MyService 删除服务

其余的请自行参考nssm的使用手册。

注意事项:需要用管理员身份来执行上面这些命令,否则会出现访问拒绝的错误。

3,分享一些想法

2018年快过去了,回顾这一年来,我觉得我在公司所做的最大且重要的一件事情就是推动了.NET Core的应用,将能迁移的.NET Framework的程序都迁移至.NET Core了,为什么要这么干?最最主要的原因当然是要跨平台,原先ASP.NET开发的网站,只能运行于Windows平台,它们得依赖于IIS!Windows(作为服务器)本身就是一个非常复杂的系统,有着各种令人眼花缭乱的配置,加上IIS,就更加令人感到困惑,我同意IIS是功能强大的服务器程序,但它真的过于复杂,设计不合理,很难用,让我等菜鸟频频掉到它的坑里爬不出来。IIS并不是一个能够自由选择版本的软件,它的版本通常认为与Windows操作系统绑定,微软官方并不建议安装与Windows操作系统原生版本不一致的IIS,所以现在甚至还有公司继续在用IIS6,而各个版本的IIS的行为却不尽相同,默认IIS并不带安装ASP.NET组件,所以在Windows系统和IIS刚部署好的时候,想直接运行ASP.NET网站居然还不行,要自己去安装ASP.NET的支持,完成后还需要使用一条额外的命令来注册ASP.NET组件,另外还可能遇到稀奇古怪的问题,大多数问题可以通过安装若干个补丁解决(如ASP.NET MVC的路由不起作用导致网站无法访问的问题),而有时则不会那么顺利,你得仔细看看这些补丁是否符合当前操作系统及IIS版本,甚至操作系统的语言版本也会影响你所要安装的补丁。IIS与ASP.NET程序之间的关系也是令人很懵逼,我想让我的ASP.NET程序自始至终运行着就是做不到,尽管应用程序池里似乎有这个选项,我在StackOverflow上针对相关问题进行过讨论,有不少人顶我,但也有人说不行(我猜跟IIS版本还有关系),ASP.NET程序空闲一段时间后便被IIS踢掉——即便你的主机不差内存,你无法肯定IIS一运行你的程序就跟着跑起来,也无法肯定你的程序什么时候在运行,什么时候被踢掉,这是个类似薛定谔的猫的问题,你的ASP.NET程序就通常处于这么一种“叠加态”,你得看一看才知道确切它是否在运行,这一看,才使得程序从“叠加态”坍缩为“生态”或“死态”,且从“死态”转入“生态”还需要耗费好些时间,表现为第一次打开页面时候的长时间卡顿,跟客户演示系统,有时候会很尴尬。我曾经为了让程序不被IIS踢掉,还手工写了一个KeepAlive的小程序,定时去get我的网站的首页,实在奇葩。微软对此的解释是:IIS并不是为long-term程序设计的,你想在IIS里做一个准时的定时服务,那是相当不妥,根本不是为这种事情设计的,所以不好用不能怪我。我承认这当然是一种设计,但ASP.NET网站除了提供网页之外,跑一些后台服务也应该是很正常的吧?没办法,于是我将服务和网站分开,中间用总线沟通,听起来很cool?——其实这是一段悲伤的往事,不过说来话长,以后有机会再提了。.NET Core出现了,ASP.NET Core也和它一起到来,2.0版开始就是一个很完善的版本,我想是时候上了,这是工作量很大的差事,但为了将来更好的发展,我们必须经历这个艰难的爬坡,所幸的是现在一切都已转入正轨,我预想的目的达到了。

.NET Core的一大特点就是程序都可以独立运行,包括ASP.NET Core程序,不再依赖于IIS,我可以根据业务的需要,将系统划分为多个模块,方便开发分工和测试,这些模块甚至不需要部署在同一台主机上,极大提高了灵活性。一般来说,我还是推荐将程序部署至Linux环境,理由依旧是Linux作为服务器操作系统的使用体验远远好于Windows,Windows实在太过复杂了!但也有例外,如果遇到缺乏Linux支持技术的客户的情况,那就把程序部署到他们的Windows主机上吧,无所谓,反正.NET Core是跨平台的。

不知这是不是我2018年的最后一篇博客,如果是,上面这段文字就算是我对今年自己的主要工作总结吧。

12-10 02:49