简体   繁体   中英

How to fail gracefully if custom configuration settings are not present/valid during ASP.NET Core 2 application startup (hosted in a Windows Service)?

I'm implementing a Web API using ASP.NET Core 2 (targeting .NET Framework 4.6.1) which is hosted in a Windows Service.

I've started with the example in Host an ASP.NET Core app in a Windows Service , but am having trouble implementing the following additional requirements:

  • When the Windows Service (or possibly the Web API it hosts) starts or restarts, I want to read some configuration (which happens to be in the Registry, but could be anywhere).

    • If this configuration is missing or invalid then the Web API/Windows Service should not start (or should stop if has already started), should log some error message and, ideally, signal some failure to the Service Control Manager (eg ERROR_BAD_CONFIGURATION = 1610).
    • If the configuration is present and valid, the Windows Service should start, start the Web API, and the configuration settings should be available to the Web API's Controllers (via Dependency Injection).

It isn't clear to me where in the Windows Service/Web application the logic to read & validate the custom config should go.
Depending on where I put the logic, if the config is invalid, there needs to be a graceful way to halt further start-up progress and to shutdown anything already started.
I can't see any obvious hooks for this in the ASP.NET, or Windows Services frameworks.

I've considered putting the read & validate logic in the following locations, but each comes with some problem(s):

  1. In Main(), before calling IWebHost.RunAsService()
  2. In a notification method of CustomWebHostService (maybe OnStarting() ?)
  3. During the initialisation of the Web API's Startup class ( Configure() or ConfigureServices() )

Can anyone shed any light onto how this can be done?

Option 1: In Main() before IWebHost.RunAsService()

This seems to be the cleanest way to fail fast, since if the config is missing/bad, then the Windows Service won't even be started.
However, this means that when the Service Control Manager starts the hosting executable, we won't register with it (via IWebHost.RunAsService() ) so the SCM returns the error:

[SC] StartService FAILED 1053:

The service did not respond to the start or control request in a timely fashion.

Ideally, the Service Control Manager should be aware of the reason for failure to start-up, and could then log this to the Event Log.
I don't think this is possible unless we register with the Service Control Manager, which brings us to Option 2.

Option 2: During the Windows Service startup

In a previous incarnation of the Web API (in WCF) I subclassed ServiceBase , acquired and validated config in ServiceBaseSubclass.OnStart() and, if the config was invalid, set this.ExitCode and called Stop() as per the suggestion in What is the proper way for a Windows service to fail? . Like this:

partial class WebServicesHost : ServiceBase
{
    private ServiceHost _webServicesServiceHost;

    // From https://msdn.microsoft.com/en-us/library/windows/desktop/ms681385(v=vs.85).aspx
    private const int ErrorBadConfiguration = 1610;

    protected override void OnStart(string[] args)
    {
      base.OnStart(args);

      var customConfig = ReadAndValidateCustomConfig();
      if (customConfig != null)
      {
        var webService = new WebService(customConfig);
        _webServicesServiceHost = new ServiceHost(webService);
        _webServicesServiceHost.Open();
      }
      else
      {
        // Configuration is bad, stop the service
        ExitCode = ErrorBadConfiguration;
        Stop();
      }
    }
}

When you then used the Service Control Manager to start and query the Windows Service, it correctly reported the failure:

C:\> sc start MyService

SERVICE_NAME: MyService
        TYPE               : 10  WIN32_OWN_PROCESS
        STATE              : 2  START_PENDING
                                (NOT_STOPPABLE, NOT_PAUSABLE, IGNORES_SHUTDOWN)
        WIN32_EXIT_CODE    : 0  (0x0)
        SERVICE_EXIT_CODE  : 0  (0x0)
        CHECKPOINT         : 0x0
        WAIT_HINT          : 0x7d0
        PID                : 10764
        FLAGS              :

C:\> sc query MyService

SERVICE_NAME: MyService
        TYPE               : 10  WIN32_OWN_PROCESS
        STATE              : 1  STOPPED            <-- Service is stopped
        WIN32_EXIT_CODE    : 1610  (0x64a)         <-- and reports the reason for stopping
        SERVICE_EXIT_CODE  : 0  (0x0)
        CHECKPOINT         : 0x0
        WAIT_HINT          : 0x0

In ASP.NET Core 2, if I subclass WebHostService (as described in Host an ASP.NET Core app in a Windows Service ), then there only appear to be hooks for notification of the Windows Service's startup workflow progress (eg OnStarting() or OnStarted() ), but no mention of how to stop the service safely.

Looking at the WebHostService source and decompiling ServiceBase makes me think that it would be a very bad idea to call ServiceBase.Stop() from CustomWebHostService.OnStarting() since I think this leads to using a disposed object.

class CustomWebHostService : WebHostService
{
  // From https://msdn.microsoft.com/en-us/library/windows/desktop/ms681385(v=vs.85).aspx
  private const int ErrorBadConfiguration = 1610;

  public CustomWebHostService(IWebHost host) : base(host)
  {
  }

  protected override void OnStarting(string[] args)
  {
    base.OnStarting(args);

    var customConfig = ReadAndValidateCustomConfig();
    if (customConfig == null)
    {
      ExitCode = ErrorBadConfiguration;
      Stop();
    }
  }
}
// Code from https://github.com/aspnet/Hosting/blob/2.0.1/src/Microsoft.AspNetCore.Hosting.WindowsServices/WebHostService.cs
public class WebHostService : ServiceBase
{
  protected sealed override void OnStart(string[] args)
  {
    OnStarting(args);

    _host
      .Services
      .GetRequiredService<IApplicationLifetime>()
      .ApplicationStopped
      .Register(() =>
      {
        if (!_stopRequestedByWindows)
        {
          Stop();
        }
      });

    _host.Start();

    OnStarted();
  }

  protected sealed override void OnStop()
  {
    _stopRequestedByWindows = true;
    OnStopping();
    _host?.Dispose();
    OnStopped();
  }

Specifically, CustomWebHostService.OnStarting() would call ServiceBase.Stop() which, in turn, would call WebHostService.OnStop() which disposes this._host . Then the second statement in WebServiceHost.OnStart() is executed, using this._host after it has been disposed.

Confusingly, this approach actually appears to "work" since CustomWebHostService.OnStarted() doesn't end up getting called in my experiments. However, I suspect this is because an exception is thrown beforehand. This doesn't seem like something that should be relied upon, so doesn't feel like a particularly robust solution.

What is the proper way for a Windows service to fail during its startup suggests a different way, by deferring the ServiceBase.Stop() call (ie letting the Windows Service start, then stopping it the config turned out to be bad) but this appears to allow the Web API to begin starting-up before sweeping the legs out from under it by stopping the Windows Service at some arbitrary time in the future.

I think I'd rather not start the Web API if the Windows Service can tell that it shouldn't start, or get the Web API start-up to read the config and shut itself down (see Option 3).

Also, it is still not clear how the customConfig instance could be made available to the ASP.NET Core 2 Web Service classes.

Option 3: During the initialisation of the Web Service's Startup class

This would seem to be the best location for reading the configuration since the config logically belongs to the Web Service, and is not related to hosting.

I discovered the IApplicationLifetime interface has a StopApplication() method (see IApplicationLifetime section of Hosting in ASP.NET Core ). This seems ideal.

public class Program
{
  public static void Main(string[] args)
  {
    if (Debugger.IsAttached)
    {
      BuildWebHost(args).Run();
    }
    else
    {
      BuildWebHost(args).RunAsCustomService();
    }
  }

  public static IWebHost BuildWebHost(string[] args) =>
      WebHost.CreateDefaultBuilder(args)
          .UseStartup<Startup>()
          .UseUrls("http://*:5000")
          .Build();
}

public class Startup
{
  public Startup(IConfiguration configuration)
  {
    Configuration = configuration;
  }

  public IConfiguration Configuration { get; }

  // This method gets called by the runtime. Use this method to add services to the container.
  public void ConfigureServices(IServiceCollection services)
  {
    services.AddMvc();
  }

  // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
  public void Configure(IApplicationBuilder app, IApplicationLifetime appLifetime, IHostingEnvironment env)
  {
    appLifetime.ApplicationStarted.Register(OnStarted);
    appLifetime.ApplicationStopping.Register(OnStopping);
    appLifetime.ApplicationStopped.Register(OnStopped);

    var customConfig = ReadAndValidateCustomConfig();
    if (customConfig != null)
    {
      // TODO: Somehow make config available to MVC controllers

      app.UseMvc();
    }
    else
    {
      appLifetime.StopApplication();
    }
  }

  private void OnStarted()
  {
    // Perform post-startup activities here
    Console.WriteLine("OnStarted");
  }

  private void OnStopping()
  {
    // Perform on-stopping activities here
    Console.WriteLine("OnStopping");
  }

  private void OnStopped()
  {
    // Perform post-stopped activities here
    Console.WriteLine("OnStopped");
  }
}

This "works" when debugging in Visual Studio (ie the process starts stand-alone and serves the Web API if the config is good, or shuts down cleanly if the config is bad), although the log messages appear in a slightly odd order which makes me suspicious:

OnStopping
OnStarted
Hosting environment: Development
Content root path: [redacted]
Now listening on: http://[::]:5000
Application started. Press Ctrl+C to shut down.
OnStopped

When starting as a Windows Service where the config is invalid, the Service remains running, but the Web API always responds with 404 (Not Found).
Presumably, this means that the Web API has shut down, but the Windows Service hasn't been able to notice this, so hasn't shut itself down.

Looking again at the IApplication documentation, I notice it says:

The IApplicationLifetime interface allows you to perform post-startup and shutdown activities.

This suggests that I really shouldn't be calling StopApplication() during the start-up sequence. However, I can't see a way of deferring the call until after the application has started.

If that is not possible, is there another way to signal to ASP.NET Core 2 that the Startup.Configure() function has failed and the application should shutdown?

Conclusion

Any suggestions on a good way to achieve the above requirements would be welcomed, as well as pointing out that I've grossly misunderstood Windows Services and/or ASP.NET Core 2! :-)

In a Web API service hosted in an HTTP.sys Windows service, I read configuration in the Window service's OnStart method. You could add an else statement to if (File.Exists(configurationFile)) , log, then throw an exception to halt startup.

using System;
using System.IO;
using System.ServiceProcess;
using Cotg.Core2.Contract;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Newtonsoft.Json.Linq;


namespace Company.Test.Service
{
    public class WindowsService : ServiceBase
    {
        public static AppSettings AppSettings;
        public static ICoreLogger Logger;
        private IWebHost _webHost;


        protected override void OnStart(string[] Args)
        {
            // Parse configuration file.
            string contentRoot = AppDomain.CurrentDomain.BaseDirectory;
            const string environmentalVariableName = "ASPNETCORE_ENVIRONMENT";
            string environment = Environment.GetEnvironmentVariable(environmentalVariableName) ?? EnvironmentNames.Development;
            string configurationFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "AppSettings.json");
            if (File.Exists(configurationFile))
            {
                JObject configuration = JObject.Parse(File.ReadAllText(configurationFile));
                AppSettings = configuration.GetValue(environment).ToObject<AppSettings>();
            }
            // Create logger.
            Logger = new ConcurrentDatabaseLogger(Environment.MachineName, AppSettings.AppName, AppSettings.ProcessName, AppSettings.AppLogsDatabase);
            // Create Http.sys web host.
            Logger.Log("Creating web host using HTTP.sys.");
            _webHost = WebHost.CreateDefaultBuilder(Args)
                .UseStartup<WebApiStartup>()
                .UseContentRoot(contentRoot)
                .UseHttpSys(Options =>
                {
                    Options.Authentication.Schemes = AppSettings.AuthenticationSchema;
                    Options.Authentication.AllowAnonymous = AppSettings.AllowAnonymous;
                    Options.MaxConnections = AppSettings.MaxConnections;
                    Options.MaxRequestBodySize = AppSettings.MaxRequestBodySize;
                    // Allow external access to service by opening TCP port via an Inbound Rule in Windows Firewall.
                    // To run service using a non-administrator account, grant the user access to URL via netsh.
                    //   Use an exact URL: netsh http add urlacl url=http://servername:9092/ user=domain\user.
                    //   Do not use a wildcard URL: netsh http add urlacl url=http://+:9092/ user=domain\user.
                    Options.UrlPrefixes.Add(AppSettings.Url);
                })
                .Build();
            _webHost.Start();
            Logger.Log($"Web host started.  Listening on {AppSettings.Url}.");
        }


        protected override void OnStop()
        {
            _webHost?.StopAsync(TimeSpan.FromSeconds(60));
        }
    }
}

I don't bother with ASP.NET Core's ConfigBuilder since I need a strongly-typed configuration class in the Windows service startup before the ASP.NET Configure & ConfigureServices methods are called. I just register my AppSettings class with ASP.NET Core's dependency injection so it's available to any controllers that need it.

public void ConfigureServices(IServiceCollection Services)
{
    Services.AddMvc();
    // Configure dependency injection.
    Services.AddSingleton(typeof(AppSettings), WindowsService.AppSettings);
    Services.AddSingleton(typeof(ICoreLogger), WindowsService.Logger);
}

AppSettings.json has entries for all environments:

{
  "Dev": {
    "Key1": "Value1",
    "Key2": "Value2"
  },
  "Test": {
    "Key1": "Value1",
    "Key2": "Value2"
  },
  "Prod": {
    "Key1": "Value1",
    "Key2": "Value2"
  }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM