把 Console 部署成 Windows 服務,四種方式總有一款適合你!

一線碼農發表於2020-11-02

一:背景

1. 講故事

上週有一個專案交付,因為是醫院級專案需要在客戶的區域網獨立部署。 程式: netcore 2.0,作業系統: windows server 2012,坑爹的事情就來了, netcore sdk 一直裝不上,網上找了資料說需要先安裝 Visual C++ Redistributable for Visual Studio 2015, 開開心心下載下來又是安裝失敗,再次找資料說要打一堆 系統補丁,搞了一天!!!???

環境總算是裝好了,因為是 Console 服務程式,還得給它做成 windows service,看公司以前的部署方式都是採用 vs 的 windows service 模板,如下圖:

怎麼說呢,這種方式太老舊了,這篇就來聊聊除了這種還有其他三種很有意思的服務部署方式,乾脆拿在一起比較比較吧!

2. 測試程式碼

為了能更加正規化一些,我在 Console 中監聽 Ctrl + C 事件,程式碼如下:


    public class Program
    {
        public static void Main(string[] args)
        {
            var dir = AppDomain.CurrentDomain.BaseDirectory;


            var cts = new CancellationTokenSource();

            var bgtask = Task.Factory.StartNew(() => { TestService.Run(cts.Token); });

            Console.CancelKeyPress += (s, e) =>
            {
                TestService.Log($"{DateTime.Now} 後臺測試服務,準備進行資源清理!");

                cts.Cancel();
                bgtask.Wait();

                TestService.Log($"{DateTime.Now} 恭喜,Test服務程式已正常退出!");
            };

            TestService.Log($"{DateTime.Now} 後端服務程式正常啟動!");

            bgtask.Wait();
        }
    }

有了這個模板,再定義一個 TestService,用於不斷的執行後臺任務,程式碼如下:


    public class TestService
    {
        public static void Run(CancellationToken token)
        {
            while (!token.IsCancellationRequested)
            {
                Console.WriteLine($"{DateTime.Now}: 1. 獲取mysql");
                System.Threading.Thread.Sleep(2000);
                Console.WriteLine($"{DateTime.Now}: 2. 獲取redis");
                System.Threading.Thread.Sleep(2000);
                Console.WriteLine($"{DateTime.Now}: 3. 更新monogdb");
                System.Threading.Thread.Sleep(2000);
                Console.WriteLine($"{DateTime.Now}: 4. 通知kafka");
                System.Threading.Thread.Sleep(2000);
                Console.WriteLine($"{DateTime.Now}: 5. 所有業務處理完畢");
                System.Threading.Thread.Sleep(2000);
            }
        }

        public static void Log(string msg)
        {
            Console.WriteLine(msg);
            File.AppendAllText(AppDomain.CurrentDomain.BaseDirectory + "//1.log", $"{msg}\r\n");
        }
    }

二:四種服務部署方式

1. 傳統的 Windows Service 模板

相信做過 windowsservice 部署的朋友都知道這種方式,需要在 vs 中新建模板,然後定義一個子類 MySerivce 繼承於 ServiceBase ,重寫父類的 OnStart 和 OnStop 方法,程式碼如下:


    partial class MyService : ServiceBase
    {
        CancellationTokenSource cts = new CancellationTokenSource();

        Task bgtask;

        public MyService()
        {
            InitializeComponent();
        }

        protected override void OnStart(string[] args)
        {
            // TODO: Add code here to start your service.
            bgtask = Task.Factory.StartNew(() => { TestService.Run(cts.Token); });
        }

        protected override void OnStop()
        {
            // TODO: Add code here to perform any tear-down necessary to stop your service.
            cts.Cancel();
            bgtask.Wait();
        }
    }

再重構一下 Main 方法:


    public class Program
    {
        public static void Main(string[] args)
        {
            ServiceBase.Run(new MyService());
        }
    }

最後執行 publish 釋出,用 windows 自帶的 sc 安裝服務。


sc create MyService BinPath=E:\net5\ConsoleApp1\ConsoleApp2\bin\Release\netcoreapp3.1\publish\ConsoleApp2.exe
sc start MyService

為了驗證程式是否執行正常,可以去服務皮膚以及安裝路徑檢視啟動日誌。

接下來說說優缺點吧:

  • 缺點:需要修改程式碼,而且一旦程式碼改完後,就不能再雙擊 exe 執行,導致無法除錯。
  • 優點:不需要額外依賴,全部採用內建技術。

2. 使用開源的 Topshelf

大家有興趣可以看一下它的官網: http://topshelf-project.com 比較輕便簡潔,使用 nuget Install-Package Topshelf 接入專案,按照官方demo我需要在 TestService 中實現 Start 和 Stop 方法,修改如下:


public class TestService
    {
        CancellationTokenSource cts = new CancellationTokenSource();
        CancellationToken token;
        Task bgtask;

        public TestService()
        {
            token = cts.Token;
        }

        public void Start()
        {
            bgtask = Task.Run(() =>
            {
                while (!token.IsCancellationRequested)
                {
                    Log($"{DateTime.Now}: 1. 獲取mysql");
                    System.Threading.Thread.Sleep(2000);
                    Log($"{DateTime.Now}: 2. 獲取redis");
                    System.Threading.Thread.Sleep(2000);
                    Log($"{DateTime.Now}: 3. 更新monogdb");
                    System.Threading.Thread.Sleep(2000);
                    Log($"{DateTime.Now}: 4. 通知kafka");
                    System.Threading.Thread.Sleep(2000);
                    Log($"{DateTime.Now}: 5. 所有業務處理完畢");
                    System.Threading.Thread.Sleep(2000);
                }
            });
        }

        public void Stop()
        {
            cts.Cancel();
            bgtask.Wait();
        }

        public static void Log(string msg)
        {
            Console.WriteLine(msg);
            File.AppendAllText(AppDomain.CurrentDomain.BaseDirectory + "1.log", $"{msg}\r\n");
        }
    }

接下來再改造一下 Main 方法,使用它的 HostFactory 類,程式碼如下:


        public static void Main(string[] args)
        {
            var rc = HostFactory.Run(x =>                                   //1
            {
                x.Service<TestService>(s =>                                   //2
                {
                    s.ConstructUsing(name => new TestService());            //3
                    s.WhenStarted(tc => tc.Start());                         //4
                    s.WhenStopped(tc => tc.Stop());                          //5
                });
                x.RunAsLocalSystem();                                       //6
                x.StartAutomatically();

                x.SetDescription("TestService2 Topshelf Host");                   //7
                x.SetDisplayName("MyService2");                                  //8
                x.SetServiceName("MyService2");                                  //9
            });                                                             //10

            var exitCode = (int)Convert.ChangeType(rc, rc.GetTypeCode());  //11

            Environment.ExitCode = exitCode;
        }

從上面程式碼可以看出,主要還是做一些服務的資訊配置,然後就可以釋出專案,使用 xxx.exe install 進行服務安裝,如下圖:


E:\net5\ConsoleApp1\ConsoleApp5\bin\Release\netcoreapp3.1\publish2>ConsoleApp5.exe install
Configuration Result:
[Success] Name MyService2
[Success] Description TestService2 Topshelf Host
[Success] ServiceName MyService2
Topshelf v4.2.1.215, .NET Framework v3.1.9

Running a transacted installation.

Beginning the Install phase of the installation.
Installing MyService2 service
Installing service MyService2...
Service MyService2 has been successfully installed.

The Install phase completed successfully, and the Commit phase is beginning.

The Commit phase completed successfully.

The transacted install has completed.

從輸出資訊來看已經安裝成功,大家感覺這種方式優缺點如何?

  • 缺點:需要安裝第三方工具包,需要修改程式碼,而且還挺大的。。。
  • 優點:雙擊也可除錯,實現了系統的一些內建監聽,比如 Ctrl + C

3. 使用微軟新內建的 Hosting

說到這個 Hosting 相信大家不會陌生,在 netcore 中不管是 Console, MVC,WebApi 都是 Console 模式,比如我新建一個如下 WebApi。

這裡我就有想法了,能不能把 Main 中的 Hosting 扣出來給我的服務用,那真的是??了,還別說,真的可以,安裝一個 hosting + for windowsservice 即可。


nuget Install-Package Microsoft.Extensions.Hosting
nuget Install-Package Microsoft.Extensions.Hosting.WindowsServices

值得慶幸的是,包的最小依賴是 .NETStandard 2.0 ,意味著 .NET Framework 4.6.1 + 和 .NetCore 2.0 + 都可以用的上,??

接下來就是改造,讓 TestService 重寫的父類 BackgroundService 中的 ExecuteAsync 方法,如下程式碼:


    public class TestService : BackgroundService
    {
        protected override Task ExecuteAsync(CancellationToken stoppingToken)
        {
            return Task.Run(() =>
            {
                while (!stoppingToken.IsCancellationRequested)
                {
                    Log($"{DateTime.Now}: 1. 獲取mysql");
                    System.Threading.Thread.Sleep(2000);
                    Log($"{DateTime.Now}: 2. 獲取redis");
                    System.Threading.Thread.Sleep(2000);
                    Log($"{DateTime.Now}: 3. 更新monogdb");
                    System.Threading.Thread.Sleep(2000);
                    Log($"{DateTime.Now}: 4. 通知kafka");
                    System.Threading.Thread.Sleep(2000);
                    Log($"{DateTime.Now}: 5. 所有業務處理完畢");
                    System.Threading.Thread.Sleep(2000);
                }
            });
        }

        public static void Log(string msg)
        {
            Console.WriteLine(msg);
            File.AppendAllText(AppDomain.CurrentDomain.BaseDirectory + "1.log", $"{msg}\r\n");
        }
    }

然後再改造 Main 方法。


    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
            .UseWindowsService()
            .ConfigureServices((hostContext, services) =>
            {
                services.AddHostedService<TestService>();
            });
    }

哇! 是不是熟悉的程式碼映入眼前,雙擊 Console 是不是更加熟悉了哈~~~

最後可以用 sc 命令做成服務。

  • 缺點:有少量的程式碼侵入性,引入的依賴稍多
  • 優點:微軟正派血統,功能強大,內建日誌支援

4. nssm 第三方工具

前面三種要麼是內建模板,要麼是安裝 dll 的方式,那有沒有一種真的可以對程式碼 零侵入 呢? 大千世界無奇不有,可以看一下這款工具:http://www.nssm.cc ,你無需修改任何程式碼, 直接釋出程式碼後用下面命令安裝即可:


C:\Windows\system32>cd C:\xcode\soft\nssm-2.24\win64

C:\xcode\soft\nssm-2.24\win64>nssm  install TestService3 E:\net5\ConsoleApp1\ConsoleApp6\bin\Release\netcoreapp3.1\publish\ConsoleApp6.exe && nssm start TestService3
Service "TestService3" installed successfully!
TestService3: START: 操作成功完成。

看到沒有,我真的沒有動任何程式碼,服務就安裝完成了。

  • 缺點:需要安裝第三方工具
  • 優點:對程式碼零侵入

三:總結

如果讓我選擇的話,我喜歡 3+4 的組合,程式碼層面我更願意使用 微軟新的 Hosting 承載,服務部署上更喜歡 nssm,畢竟它比 sc 靈活強大的多,不知道大家更喜歡哪一種部署方式呢? 歡迎留言補充! ???

更多高質量乾貨:參見我的 GitHub: dotnetfly

圖片名稱

相關文章