以Windows服務方式執行.NET Core程式
在之前一篇部落格《以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
出現配置介面(注意,需要管理員許可權)
配置選項比較多,這是我的配置,供參考:
點“Install service”即將服務安裝好了。我們開啟Windows服務來檢視所安裝的服務:
服務已經安裝完畢,一切準備就緒。
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年的最後一篇部落格,如果是,上面這段文字就算是我對今年自己的主要工作總結吧。