1. 程式人生 > >.NET Core 中的通用主機和後臺服務

.NET Core 中的通用主機和後臺服務

目錄

簡介

我們在做專案的時候, 往往要處理一些後臺的任務. 一般是兩種, 一種是不停的執行,比如訊息佇列的消費者。另一種是定時任務。

在.NET Framework + Windows環境裡, 我們一般會使用 Windows 服務處理這些情形.

但在.Net Core + Linux環境裡, 有沒有類似的解決方案呢? 瞭解的方法有兩種:

  1. Web Host: 建立一個 ASP.Net Core 的 Web 專案(如MVC 或 WebAPI), 然後使用IHostedService或者BackgroundService處理後臺任務. 這種方案是Web專案和後臺任務混雜在一起執行.
  2. Generic Host: 通用主機是 .NET Core 2.1 中的新增功能, 它將HTTP管道從Web Host的API中分離出來, 從而提供更多的主機選擇方案, 比如後臺任務, 非 HTTP 工作負載等. 同時可以方便使用基礎功能如配置、依賴關係注入 [DI] 和日誌記錄等。

Web Host and Host

基本用法

  1. 建立一個控制檯程式並新增Hosting Nuget包。

    Install-Package Microsoft.Extensions.Hosting
    
    Install-Package Microsoft.Extensions.Configuration.EnvironmentVariables
    Install-Package Microsoft.Extensions.Configuration.CommandLine
    Install-Package Microsoft.Extensions.Configuration.Json
    
    Install-Package Microsoft.Extensions.Logging.Console
    Install-Package Microsoft.Extensions.Logging.Debug
  2. 建立一個基於 Timer 的簡單 Hosted Service. 繼承自抽象類 BackgroundService.

    public class TimedHostedService : BackgroundService
    {
        private readonly ILogger _logger;
        private Timer _timer;
    
        public TimedHostedService(ILogger<TimedHostedService> logger)
        {
            this._logger = logger;
        }
    
        protected override Task ExecuteAsync(CancellationToken stoppingToken)
        {
            _timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromSeconds(5));
            return Task.CompletedTask;
        }
    
        private void DoWork(object state)
        {
            _logger.LogInformation(string.Format("[{0:yyyy-MM-dd hh:mm:ss}] Timed Background Service is working.", DateTime.Now));
        }
    
        public override Task StopAsync(CancellationToken cancellationToken)
        {
            _logger.LogInformation("Timed Background Service is stopping.");
            _timer?.Change(Timeout.Infinite, 0);
            return Task.CompletedTask;
        }
    
        public override void Dispose()
        {
            _timer?.Dispose();
            base.Dispose();
        }
    }
  3. Main 函式中新增 Host的相關程式碼.

    static async Task Main(string[] args)
    {
        var builder = new HostBuilder()
            //Host config
            .ConfigureHostConfiguration(configHost =>
            {
                //配置根目錄
              //configHost.SetBasePath(Directory.GetCurrentDirectory()); 
              //讀取host的配置json,和appsetting類似
              //configHost.AddJsonFile("hostsettings.json", true, true); 
              //讀取環境變數,Asp.Net core預設的環境變數是以ASPNETCORE_作為字首的,這裡也採用此字首以保持一致
                configHost.AddEnvironmentVariables(prefix: "ASPNETCORE_");
                //啟動host的時候之可傳入引數
                if (args != null)
                {
                    configHost.AddCommandLine(args);
                }
            })
            //App config
            .ConfigureAppConfiguration((hostContext, configApp) =>
            {
                //讀取應用的配置json
                configApp.AddJsonFile("appsettings.json", optional: true);
                //讀取應用特定環境下的配置json
                configApp.AddJsonFile($"appsettings.{hostContext.HostingEnvironment.EnvironmentName}.json", optional: true);
                //讀取環境變數
                configApp.AddEnvironmentVariables();
                //啟動host的時候可傳入引數
                if (args != null)
                {
                    configApp.AddCommandLine(args);
                }
            })
            //配置服務及依賴注入註冊
            .ConfigureServices((hostContext, services) =>
            {
                //新增TimedHostedService
                services.AddHostedService<TimedHostedService>();
            })
            //日誌配置
            .ConfigureLogging((hostContext, configLogging) =>
            {
                //輸出控制檯日誌
                configLogging.AddConsole();
                //輸出Debug日誌
                if (hostContext.HostingEnvironment.EnvironmentName == EnvironmentName.Development)
                {
                    configLogging.AddDebug();
                }
            });
    
        //使用控制檯生命週期, Ctrl + C退出
        await builder.RunConsoleAsync();
    }
  4. 執行並測試.

進階使用

整合第三方日誌 Nlog

支援.Net Core的第三方日誌庫有很多, 下面以 Nlog 為例, 整合到 Generic host 裡.

  1. 新增 NLog.Extensions.Hosting Nuget包

    Install-Package NLog.Extensions.Hosting
  2. 新增配置檔案

    新建一個檔案nlog.config(建議全部小寫,linux系統中要注意), 並右鍵點選其屬性,將其“複製到輸出目錄”設定為“始終複製”。檔案內容如下:

    <?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"
          internalLogLevel="info"
          internalLogFile="logs\internal-nlog.txt">
    
      <!-- the targets to write to -->
      <targets>
        <!-- write logs to file -->
        <target xsi:type="File" name="fileTarget" fileName="logs\${shortdate}.log"
                layout="${date}|${level:uppercase=true}|${message} ${exception:format=tostring}|${logger}|${all-event-properties}" />
        <target xsi:type="Console" name="consoleTarget"
                layout="${date}|${level:uppercase=true}|${message} ${exception:format=tostring}|${logger}|${all-event-properties}" />
      </targets>
    
      <!-- rules to map from logger name to target -->
      <rules>
        <logger name="*" minlevel="Trace" writeTo="fileTarget,consoleTarget" />
      </rules>
    </nlog>
      <ItemGroup>
        <None Update="nlog.config">
          <CopyToOutputDirectory>Always</CopyToOutputDirectory>
        </None>
      </ItemGroup>
  3. 修改 Main 方法:

    static async Task Main(string[] args)
    {
        var logger = LogManager.GetCurrentClassLogger();
        try
        {
            var builder = new HostBuilder()
            //Host config
            .ConfigureHostConfiguration(configHost =>
            {
            })
            //App config
            .ConfigureAppConfiguration((hostContext, configApp) =>
            {
            })
            //Services
            .ConfigureServices((hostContext, services) =>
            {
            })
            //Log
            //.ConfigureLogging((hostContext, configLogging) =>
            //{
            //    configLogging.AddConsole();
            //    if (hostContext.HostingEnvironment.EnvironmentName == EnvironmentName.Development)
            //    {
            //        configLogging.AddDebug();
            //    }
            //})
            .UseNLog();
    
            await builder.RunConsoleAsync();
        }
        catch (Exception ex)
        {
            logger.Fatal(ex, "Stopped program because of exception");
            throw;
        }
        finally
        {
            // Ensure to flush and stop internal timers/threads before application-exit (Avoid segmentation fault on Linux)
            LogManager.Shutdown();
        }
    }
  4. 執行並測試.

整合 EF Core

EF Core的整合相對比較簡單, 基本上和我們在 MVC / WebAPI 中用法一樣.

  1. 新增 Nuget 包

    // SQL Server
    Install-Package Microsoft.EntityFrameworkCore.SqlServer
  2. 新增實體

    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int Count { get; set; }
    }
    
    public class ProductConfiguration : IEntityTypeConfiguration<Product>
    {
        public void Configure(EntityTypeBuilder<Product> builder)
        {
            builder.Property(t => t.Name).IsRequired().HasMaxLength(100);
    
            builder.HasData(new Product { Id = 1, Name = "Test_Prouct_1", Count = 0 });
        }
    }
  3. 新增資料庫上下文

    public class HostDemoContext : DbContext
    {
        public HostDemoContext(DbContextOptions<HostDemoContext> options) : base(options)
        { }
    
        public DbSet<Product> Products { get; set; }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            modelBuilder.ApplyConfiguration(new ProductConfiguration());
        }
    }
  4. 新增資料庫連線字串到 appsettings.json 並修改 Main 方法

    {
      "sqlserverconnection": "Server=.\\SQLEXPRESS;Database=GenericHostDemo;Trusted_Connection=True;"
    }
    //Services
    .ConfigureServices((hostContext, services) =>
    {
        var connectionString = hostContext.Configuration["sqlserverconnection"];
        services.AddDbContext<HostDemoContext>(o => o.UseSqlServer(connectionString), ServiceLifetime.Singleton);
    
        services.AddHostedService<TimedHostedService>();
    })
  5. 在 Hosted Service 中使用 Context

    private readonly HostDemoContext _context;
    
    public TimedHostedService(HostDemoContext context)
    {
        this._context = context;
    }
    
    private void DoWork(object state)
    {
        int id = 1;
        var product = _context.Products.Find(id);
        product.Count++;
        _context.SaveChanges();
    
        _logger.LogInformation($"Processed {product.Name} at {DateTime.Now:yyyy-MM-dd hh:mm:ss}, current count is {product.Count}.");
    
        //_logger.LogInformation(string.Format("[{0:yyyy-MM-dd hh:mm:ss}] Timed Background Service is working.", DateTime.Now));
    }
  6. 遷移Migration

    1. 通過 Nuget 新增引用 Install-Package Microsoft.EntityFrameworkCore.Tools
    2. 建立 DesignTimeDbContextFactory 類

      不同於Web專案, 執行Add-Migration遷移命令時候由於拿不到連線字串可能會報錯 Unable to create an object of type 'HostDemoContext'. Add an implementation of 'IDesignTimeDbContextFactory<HostDemoContext>' to the project, or see https://go.microsoft.com/fwlink/?linkid=851728 for additional patterns supported at design time.

      解決方法:建立一個與DbContext同一目錄下的DesignTimeDbContextFactory檔案,然後實現介面中的方法CreateDbContext,並配置ConnectionString:

      public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<HostDemoContext>
      {
          public HostDemoContext CreateDbContext(string[] args)
          {
              var builder = new DbContextOptionsBuilder<HostDemoContext>();
              builder.UseSqlServer("Server=.\\SQLEXPRESS;Database=GenericHostDemo;Trusted_Connection=True;");
              return new HostDemoContext(builder.Options);
          }
      }
    3. 生成遷移. 開啟Package Manager Console,執行命令 Add-Migration InitialCreate
    4. 更新遷移到資料庫. 執行命令 Update-Database

  7. 執行並測試.

消費 MQ 訊息

接下來實現一個後臺任務用於監聽並消費 RabbitMQ 訊息.
這裡使用庫EasyNetQ, 它是一款基於RabbitMQ.Client封裝的API庫,正如其名,使用起來比較Easy,它把原RabbitMQ.Client中的很多操作都進行了再次封裝,讓開發人員減少了很多工作量。詳細請參考 https://github.com/EasyNetQ/EasyNetQ

  1. 新增 EasyNetQ 相關 Nuget 包

    install-package EasyNetQ
    install-package EasyNetQ.DI.Microsoft
  2. 建立後臺服務 RabbitMQDemoHostedService

    public class RabbitMQDemoHostedService : BackgroundService
    {
        private readonly ILogger _logger;
        private readonly IBus _bus;
    
        public RabbitMQDemoHostedService(ILogger<RabbitMQDemoHostedService> logger, IBus bus)
        {
            this._logger = logger;
            this._bus = bus;
        }
    
        protected override Task ExecuteAsync(CancellationToken stoppingToken)
        {
            _bus.Subscribe<DemoMessage>("demo_subscription_1", HandleDemoMessage);
            return Task.CompletedTask;
        }
    
        private void HandleDemoMessage(DemoMessage demoMessage)
        {
            _logger.LogInformation($"Got Message : {demoMessage.Id} {demoMessage.Text} {demoMessage.CreatedTime}");
        }
    }
  3. Program 裡面配置相關服務

    //Services
    .ConfigureServices((hostContext, services) =>
    {
        //Rabbit MQ
        string rabbitMqConnection = hostContext.Configuration["rabbitmqconnection"];
        services.RegisterEasyNetQ(rabbitMqConnection);
    
        services.AddHostedService<RabbitMQDemoHostedService>();
    })
  4. 啟動 MQ 傳送程式來模擬訊息的傳送並測試.

整合 Quartz

Quartz 是一個開源作業排程框架, quartznet的詳細資訊請參考 http://www.quartz-scheduler.net/ , 整合 Quartz 可以幫助提供比只使用定時器更強大的功能.

  1. 新增 Quartz 相關 Nuget 包

    Install-Package Quartz
    Install-Package Quartz.Plugins
  2. 新建 Quartz 配置類 QuartzOption

    // More settings refer to:https://github.com/quartznet/quartznet/blob/master/src/Quartz/Impl/StdSchedulerFactory.cs
    public class QuartzOption
    {
        public QuartzOption(IConfiguration config)
        {
            if (config == null)
            {
                throw new ArgumentNullException(nameof(config));
            }
    
            var section = config.GetSection("quartz");
            section.Bind(this);
        }
    
        public Scheduler Scheduler { get; set; }
    
        public ThreadPool ThreadPool { get; set; }
    
        public Plugin Plugin { get; set; }
    
        public NameValueCollection ToProperties()
        {
            var properties = new NameValueCollection
            {
                ["quartz.scheduler.instanceName"] = Scheduler?.InstanceName,
                ["quartz.threadPool.type"] = ThreadPool?.Type,
                ["quartz.threadPool.threadPriority"] = ThreadPool?.ThreadPriority,
                ["quartz.threadPool.threadCount"] = ThreadPool?.ThreadCount.ToString(),
                ["quartz.plugin.jobInitializer.type"] = Plugin?.JobInitializer?.Type,
                ["quartz.plugin.jobInitializer.fileNames"] = Plugin?.JobInitializer?.FileNames
            };
    
            return properties;
        }
    }
    
    public class Scheduler
    {
        public string InstanceName { get; set; }
    }
    
    public class ThreadPool
    {
        public string Type { get; set; }
    
        public string ThreadPriority { get; set; }
    
        public int ThreadCount { get; set; }
    }
    
    public class Plugin
    {
        public JobInitializer JobInitializer { get; set; }
    }
    
    public class JobInitializer
    {
        public string Type { get; set; }
        public string FileNames { get; set; }
    }
  3. 重寫 JobFactory

    public class JobFactory : IJobFactory
    {
        private readonly IServiceProvider _serviceProvider;
    
        public JobFactory(IServiceProvider serviceProvider)
        {
            _serviceProvider = serviceProvider;
        }
    
        public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler)
        {
            var job = _serviceProvider.GetService(bundle.JobDetail.JobType) as IJob;
            return job;
        }
    
        public void ReturnJob(IJob job)
        {
            var disposable = job as IDisposable;
            disposable?.Dispose();
        }
    }
  4. 編寫 Quartz Hosted Service

    public class QuartzService : BackgroundService
    {
        private readonly ILogger _logger;
        private readonly IScheduler _scheduler;
    
        public QuartzService(ILogger<QuartzService> logger, IScheduler scheduler)
        {
            _logger = logger;
            _scheduler = scheduler;
        }
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            _logger.LogInformation("Start quartz scheduler...");
            await _scheduler.Start(stoppingToken);
        }
    
        public override async Task StopAsync(CancellationToken cancellationToken)
        {
            _logger.LogInformation("Stop quartz scheduler...");
            await _scheduler.Shutdown(cancellationToken);
            await base.StopAsync(cancellationToken);
        }
    }
  5. 編寫一個測試 Job

    public class TestJob : IJob
    {
        private readonly ILogger _logger;
    
        public TestJob(ILogger<TestJob> logger)
        {
            _logger = logger;
        }
    
        public Task Execute(IJobExecutionContext context)
        {
            _logger.LogInformation(string.Format("[{0:yyyy-MM-dd hh:mm:ss}] TestJob is working.", DateTime.Now));
            return Task.CompletedTask;
        }
    }
  6. 準備 appsettings.json 檔案

    "quartz": {
        "scheduler": {
          "instanceName": "GenericHostDemo.Quartz"
        },
        "threadPool": {
          "type": "Quartz.Simpl.SimpleThreadPool, Quartz",
          "threadPriority": "Normal",
          "threadCount": 10
        },
        "plugin": {
          "jobInitializer": {
            "type": "Quartz.Plugin.Xml.XMLSchedulingDataProcessorPlugin, Quartz.Plugins",
            "fileNames": "quartz_jobs.xml"
          }
        }
    }
  7. 準備 Quartz 的排程檔案 quartz_jobs.xml, 並右鍵點選其屬性,將其“複製到輸出目錄”設定為“始終複製”。

    <?xml version="1.0" encoding="UTF-8"?>
    
    <job-scheduling-data xmlns="http://quartznet.sourceforge.net/JobSchedulingData"
            xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            version="2.0">
    
        <processing-directives>
            <overwrite-existing-data>true</overwrite-existing-data>
        </processing-directives>
    
        <schedule>
            <job>
              <name>TestJob</name>
              <group>TestGroup</group>
              <description>TestJob Description</description>
              <job-type>GenericHostDemo.Jobs.TestJob, GenericHostDemo</job-type>
              <durable>true</durable>
              <recover>false</recover>
            </job>
            <trigger>
              <simple>
                <name>TestJobTrigger</name>
                <group>TestGroup</group>
                <description>TestJobTrigger Description</description>
                <job-name>TestJob</job-name>
                <job-group>TestGroup</job-group>
                <repeat-count>-1</repeat-count>
                <repeat-interval>5000</repeat-interval>
              </simple>
            </trigger>
    
        </schedule>
    </job-scheduling-data>
  8. Program 註冊 Quartz Hosted Service 和 TestJob

    //Quartz
    services.AddSingleton<IJobFactory, JobFactory>();
    services.AddSingleton(provider =>
    {
        var option = new QuartzOption(hostContext.Configuration);
        var sf = new StdSchedulerFactory(option.ToProperties());
        var scheduler = sf.GetScheduler().Result;
        scheduler.JobFactory = provider.GetService<IJobFactory>();
        return scheduler;
    });
    services.AddHostedService<QuartzService>();
    
    //Jobs
    services.AddSingleton<TestJob>();
  9. 執行並檢視結果.

部署

通用主機的部署其實就是讓它在後臺執行。
在Linux下面讓程式在後臺執行方式有很多種,比如通過systemctl, Supervisor等。
也可以把它部署在Docker裡面.

BackgroundService 和 IHostedService

在 .NET Core 2.1 之前, 需要自己實現 IHostedService 介面來建立服務. 在 .NET Core 2.1 中, 考慮到大多數後臺任務在取消令牌管理和其他典型操作方面有相似的需求,微軟提供了一個非常方便的抽象基類BackgroundService. 原始碼如下:

// Copyright (c) .NET Foundation. Licensed under the Apache License, Version 2.0. 
/// <summary>
/// Base class for implementing a long running <see cref="IHostedService"/>.
/// </summary>
public abstract class BackgroundService : IHostedService, IDisposable
{
    private Task _executingTask;
    private readonly CancellationTokenSource _stoppingCts = 
                                                   new CancellationTokenSource();

    protected abstract Task ExecuteAsync(CancellationToken stoppingToken);

    public virtual Task StartAsync(CancellationToken cancellationToken)
    {
        // Store the task we're executing
        _executingTask = ExecuteAsync(_stoppingCts.Token);

        // If the task is completed then return it, 
        // this will bubble cancellation and failure to the caller
        if (_executingTask.IsCompleted)
        {
            return _executingTask;
        }

        // Otherwise it's running
        return Task.CompletedTask;
    }
    
    public virtual async Task StopAsync(CancellationToken cancellationToken)
    {
        // Stop called without start
        if (_executingTask == null)
        {
            return;
        }

        try
        {
            // Signal cancellation to the executing method
            _stoppingCts.Cancel();
        }
        finally
        {
            // Wait until the task completes or the stop token triggers
            await Task.WhenAny(_executingTask, Task.Delay(Timeout.Infinite,
                                                          cancellationToken));
        }

    }

    public virtual void Dispose()
    {
        _stoppingCts.Cancel();
    }
}

原始碼

GitHub

參考