Introduction
Windows services are a good way to reduce some manual jobs that we have to do in our system. In this piece, we are going to write a Windows service using Asp.Net core. The jobs tasked to this Windows service is as follows:
- Zip the folder and save the file to a particular directory
- Upload the zipped folder to the Azure blob storage
The above-mentioned tasks will be running daily, weekly, or monthly. We are using the Quartz scheduler with Dependency Injection to do these amazing tasks. We are using Nlog to log the details of our tasks. If this interests you, then you are in the right place. Let’s develop our PerfectScheduler.
Source Code
The source code of this project has been uploaded to GitHub. Please feel free to fork, star, create pull requests, etc.
Creating Our Perfect Scheduler
As I mentioned earlier, we are creating a Windows service with Asp.Net core. Technically there is no straight way to do this, as the Windows service with Asp.Net core template is not available in Visual Studio.
So we will be creating an Asp.Net console application and then installing an executable file generated as a Windows service.
Creating an Asp.Net Console Application
Open Visual Studio, search for the project template Console App (.Net Core), and name the solution as per your convenience. I am naming this project Perfect Scheduler, as I am thinking that we can make this Windows service perfect by creating many pull requests.
Once you have created the application, install all of our dependencies so that we don’t need to worry about them later.
Write the Service
As we have installed all of our dependencies, we are ready to create our service. Add a new class with the name BackupService
and inherit the same from IHostedService
, which is part of Microsoft.Extensions.Hosting namespace.
IHostedService
has two methods in it as follows, so we should implement them in our service class:
public interface IHostedService { // // Summary: // Triggered when the application host is ready to start the service. // // Parameters: // cancellationToken: // Indicates that the start process has been aborted. Task StartAsync(CancellationToken cancellationToken); // // Summary: // Triggered when the application host is performing a graceful shutdown. // // Parameters: // cancellationToken: // Indicates that the shutdown process should no longer be graceful. Task StopAsync(CancellationToken cancellationToken); }
StartAsync
The StartAsync method can be implemented as follows:
public async Task StartAsync(CancellationToken cancellationToken) { try { var scheduler = await GetScheduler(); var serviceProvider = GetConfiguredServiceProvider(); scheduler.JobFactory = new CustomJobFactory(serviceProvider); await scheduler.Start(); await scheduler.ScheduleJob(GetDailyJob(), GetDailyJobTrigger()); await Task.Delay(1000); await scheduler.ScheduleJob(GetWeeklyJob(), GetWeeklyJobTrigger()); await Task.Delay(1000); await scheduler.ScheduleJob(GetMonthlyJob(), GetMonthlyJobTrigger()); await Task.Delay(1000); } catch (Exception ex) { throw new CustomConfigurationException(ex.Message); } }
As you can see in the first line of the above code, we are getting the scheduler of Quartz. Let’s create a method and return a scheduler:
private static async Task<IScheduler> GetScheduler() { var props = new NameValueCollection { { "quartz.serializer.type", "binary" } }; var factory = new StdSchedulerFactory(props); var scheduler = await factory.GetScheduler(); return scheduler; }
The next step is to build a service provider so that we can inject our dependencies using Constructor Dependency Injection. By default Quartz is not doing this, so we have to build the configuration our own.
private IServiceProvider GetConfiguredServiceProvider() { var services = new ServiceCollection() .AddScoped<IDailyJob, DailyJob>() .AddScoped<IWeeklyJob, WeeklyJob>() .AddScoped<IMonthlyJob, MonthlyJob>() .AddScoped<IHelperService, HelperService>(); return services.BuildServiceProvider(); }
As you can see, we have configured the services for DailyJob
, WeeklyJob
, MonthlyJob
, and HelperService
. We will be creating these Classes and Interfaces very soon.
Once we get the Service Provider, we can pass this to our Custom Job Factory, which we will be implementing soon. Now, we can start the scheduler and schedule our jobs, please make sure that you are using different Identity names for both Triggers and Jobs. The samples are given below:
#region "Private Functions" private IJobDetail GetDailyJob() { return JobBuilder.Create<IDailyJob>() .WithIdentity("dailyjob", "dailygroup") .Build(); } private ITrigger GetDailyJobTrigger() { return TriggerBuilder.Create() .WithIdentity("dailytrigger", "dailygroup") .StartNow() .WithSimpleSchedule(x => x .WithIntervalInHours(24) .RepeatForever()) .Build(); } private IJobDetail GetWeeklyJob() { return JobBuilder.Create<IWeeklyJob>() .WithIdentity("weeklyjob", "weeklygroup") .Build(); } private ITrigger GetWeeklyJobTrigger() { return TriggerBuilder.Create() .WithIdentity("weeklytrigger", "weeklygroup") .StartNow() .WithSimpleSchedule(x => x .WithIntervalInHours(120) .RepeatForever()) .Build(); } private IJobDetail GetMonthlyJob() { return JobBuilder.Create<IMonthlyJob>() .WithIdentity("monthlyjob", "monthlygroup") .Build(); } private ITrigger GetMonthlyJobTrigger() { return TriggerBuilder.Create() .WithIdentity("monthlytrigger", "monthlygroup") .StartNow() .WithSimpleSchedule(x => x .WithIntervalInHours(720) .RepeatForever()) .Build(); } #endregion
StopAsync
The StopAsync
method can be implemented as follows:
public Task StopAsync(CancellationToken cancellationToken) { return Task.CompletedTask; }
Creating the JobBuilders and TriggerBuilders
Now we can create the Interfaces and Classes for our Jobs, which are Daily, Weekly, and Monthly. To do so, create a new folder called Helpers and another folder called Interfaces inside. Below are the Interfaces you need to create.
IDailyJob
using Quartz; namespace Backup.Service.Helpers.Interfaces { public interface IDailyJob: IJob { } }
IWeeklyJob
using Quartz; namespace Backup.Service.Helpers.Interfaces { public interface IWeeklyJob : IJob { } }
IMonthlyJob
using Quartz; namespace Backup.Service.Helpers.Interfaces { public interface IMonthlyJob : IJob { } }
IHelperService
using System.Threading.Tasks; namespace Backup.Service.Helpers.Interfaces { public interface IHelperService { Task PerformService(string schedule); } }
Create a Custom Job Factory
To implement the Dependency Injection, we need to create our own custom job factory. Create a class inside the Helper folder as follows:
using Quartz; using Quartz.Spi; using System; namespace Backup.Service.Helpers { public class CustomJobFactory : IJobFactory { protected readonly IServiceProvider _serviceProvider; public CustomJobFactory(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; } public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler) { try { return _serviceProvider.GetService(bundle.JobDetail.JobType) as IJob; } catch (Exception ex) { throw new CustomConfigurationException(ex.Message); } } public void ReturnJob(IJob job) { var obj = job as IDisposable; obj?.Dispose(); } } }
Implement the Job Builder Interfaces
Now it is time to implement our Daily, Weekly, and Monthly job builders.
DailyJob
using Backup.Service.Helpers.Interfaces; using Quartz; using System.Threading.Tasks; namespace Backup.Service.Helpers { public class DailyJob : IDailyJob { public IHelperService _helperService; public DailyJob(IHelperService helperService) { _helperService = helperService; } public async Task Execute(IJobExecutionContext context) { await _helperService.PerformService(BackupSchedule.Daily); } } }
WeeklyJob
using Backup.Service.Helpers.Interfaces; using Quartz; using System.Threading.Tasks; namespace Backup.Service.Helpers { public class WeeklyJob : IWeeklyJob { public IHelperService _helperService; public WeeklyJob(IHelperService helperService) { _helperService = helperService; } public async Task Execute(IJobExecutionContext context) { await _helperService.PerformService(BackupSchedule.Weekly); } } }
MonthlyJob
using Backup.Service.Helpers.Interfaces; using Quartz; using System.Threading.Tasks; namespace Backup.Service.Helpers { public class MonthlyJob : IMonthlyJob { public IHelperService _helperService; public MonthlyJob(IHelperService helperService) { _helperService = helperService; } public async Task Execute(IJobExecutionContext context) { await _helperService.PerformService(BackupSchedule.Monthly); } } }
We have configured separate classes for each of the jobs with the dependency IHelperService
injected via constructor. In the future, we should be able to write custom logic for each job here as they are in separate classes.
Implement HelperService
Before we start implementing this service let us configure the NLog now as we will be writing logs from this class.
Configure NLog
To configure NLog, create a new configuration file NLog.config
and edit the content as shown:
<?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"> <targets> <target name="logfile" xsi:type="File" fileName="backupclientlogfile.txt" /> <target name="logconsole" xsi:type="Console" /> </targets> <rules> <logger name="*" minlevel="Info" writeTo="logconsole" /> <logger name="*" minlevel="Debug" writeTo="logfile" /> </rules> </nlog>
Now, create a method SetUpNLog()
and add the codes as below:
private void SetUpNLog() { var config = new NLog.Config.LoggingConfiguration(); // Targets where to log to: File and Console var logfile = new NLog.Targets.FileTarget("logfile") { FileName = "backupclientlogfile.txt" }; var logconsole = new NLog.Targets.ConsoleTarget("logconsole"); // Rules for mapping loggers to targets config.AddRule(NLog.LogLevel.Info, NLog.LogLevel.Fatal, logconsole); config.AddRule(NLog.LogLevel.Info, NLog.LogLevel.Fatal, logfile); // Apply config LogManager.Configuration = config; _logger = LogManager.GetCurrentClassLogger(); }
Please make sure that you have already added a property ILogger _logger
.
We can implement the HelperService
as follows with all the necessary private and public methods:
using Microsoft.Azure.Storage; using Microsoft.Azure.Storage.Blob; using Backup.Service.Helpers.Interfaces; using System; using System.Configuration; using System.IO; using System.IO.Compression; using System.Threading.Tasks; using NLog; namespace Backup.Service.Helpers { public class HelperService : IHelperService { private static ILogger _logger; public HelperService() { SetUpNLog(); } private string FolderToZipLocation { get { var folderToZip = Convert.ToString(ConfigurationManager.AppSettings["FolderToZipLocation"]); if (string.IsNullOrWhiteSpace(folderToZip)) { throw new CustomConfigurationException(CustomConstants.NoFolderToZipLocationSettings); } return folderToZip; } } private string FolderFromZipLocation { get { var folderToZip = Convert.ToString(ConfigurationManager.AppSettings["FolderFromZipLocation"]); if (string.IsNullOrWhiteSpace(folderToZip)) { throw new CustomConfigurationException(CustomConstants.NoFolderFromZipLocationSettings); } return folderToZip; } } private string StorageConnectionString { get { var folderToZip = Convert.ToString(ConfigurationManager.ConnectionStrings["StorageConnectionString"]); if (string.IsNullOrWhiteSpace(folderToZip)) { throw new CustomConfigurationException(CustomConstants.NoStorageConnectionStringSettings); } return folderToZip; } } public async Task PerformService(string schedule) { try { _logger.Info($"{DateTime.Now}: The PerformService() is called with {schedule} schedule"); var fileName = $"{DateTime.Now.Day}_{DateTime.Now.Month}_{DateTime.Now.Year}_{schedule}_backup.zip"; var path = $"{FolderToZipLocation}\\{fileName}"; if (!string.IsNullOrWhiteSpace(schedule)) { ZipTheFolder(path); await UploadToAzureBlobStorage(schedule, path, fileName); _logger.Info($"{DateTime.Now}: The PerformService() is finished with {schedule} schedule"); } } catch (Exception ex) { _logger.Error($"{DateTime.Now}: Exception is occured at PerformService(): {ex.Message}"); throw new CustomConfigurationException(ex.Message); } } private async Task UploadToAzureBlobStorage(string schedule, string path, string fileName) { try { if (CloudStorageAccount.TryParse(StorageConnectionString, out CloudStorageAccount cloudStorageAccount)) { _logger.Info($"{DateTime.Now}: The UploadToAzureBlobStorage() is called with {schedule} schedule"); var cloudBlobClient = cloudStorageAccount.CreateCloudBlobClient(); var cloudBlobContainer = cloudBlobClient.GetContainerReference(schedule); var cloudBlockBlob = cloudBlobContainer.GetBlockBlobReference(fileName); await cloudBlockBlob.UploadFromFileAsync(path); _logger.Info($"{DateTime.Now}: The file is been uplaoded to the blob with {schedule} schedule"); } else { _logger.Error($"{DateTime.Now}: {CustomConstants.NoStorageConnectionStringSettings}"); throw new CustomConfigurationException(CustomConstants.NoStorageConnectionStringSettings); } } catch (Exception ex) { _logger.Error($"{DateTime.Now}: Exception is occured at UploadToAzureBlobStorage(): {ex.Message}"); throw new CustomConfigurationException($"Error when uploading to blob: {ex.Message}"); } } private void ZipTheFolder(string path) { try { _logger.Info($"{DateTime.Now}: The ZipTheFolder() is called "); if (File.Exists(path)) { File.Delete(path); _logger.Info($"{DateTime.Now}: The file with the same name exists, thus deleted the same"); } ZipFile.CreateFromDirectory(FolderFromZipLocation, path); _logger.Info($"{DateTime.Now}: The Zip file is been created"); } catch (Exception ex) { _logger.Error($"{DateTime.Now}: Exception is occured at ZipTheFolder(): {ex.Message}"); throw new CustomConfigurationException($"Error when zip: {ex.Message}"); } } private void SetUpNLog() { var config = new NLog.Config.LoggingConfiguration(); // Targets where to log to: File and Console var logfile = new NLog.Targets.FileTarget("logfile") { FileName = "backupclientlogfile.txt" }; var logconsole = new NLog.Targets.ConsoleTarget("logconsole"); // Rules for mapping loggers to targets config.AddRule(NLog.LogLevel.Info, NLog.LogLevel.Fatal, logconsole); config.AddRule(NLog.LogLevel.Info, NLog.LogLevel.Fatal, logfile); // Apply config LogManager.Configuration = config; _logger = LogManager.GetCurrentClassLogger(); } } }
The method PerformService
(string schedule) will be called for every schedule and it will make sure that the below tasks are performed.
- Zip the folder and save the file to a particular directory
- Upload the zipped file to the Azure blob storage
Here, the values of the variable and the blob storage container names are the same, either daily, weekly, or monthly.
Remember to set the values for the FolderToZipLocation
(where the location the file should be saved), FolderFromZipLocation
(from where the files should be taken), and StorageConnectionString
in the App.config
file.
<?xml version="1.0" encoding="utf-8" ?> <configuration> <startup> <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" /> </startup> <appSettings> <add key="FolderToZipLocation" value="C:\BackupZip"/> <add key="FolderFromZipLocation" value="C:\Backup"/> </appSettings> <connectionStrings> <add name="StorageConnectionString" connectionString=""/> </connectionStrings> </configuration>
Setting Up The Program
As you know, the Program
class is the start of our console application, now it is time to call our BackupService
from the Program
class. Let us edit the code of the Program
class seen below:
using Backup.Service.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using System; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; namespace Backup.Service { public class Program { static async Task Main(string[] args) { var isDebugging = !(Debugger.IsAttached || args.Contains("--console")); var hostBuilder = new HostBuilder() .ConfigureServices((context, services) => { services.AddHostedService<BackupService>(); }); if (isDebugging) { await hostBuilder.RunTheServiceAsync(); } else { await hostBuilder.RunConsoleAsync(); } } } }
If we are running the application locally/debug, we are calling the extension method RunConsoleAsync()
or else we call our own custom extension method RunTheServiceAsync()
. The line services.AddHostedService()
is very important as this is where we register our IHostedService
, which is BackupService
. Below is the code for our extension method:
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using System.Threading; using System.Threading.Tasks; namespace Backup.Service.Extensions { public static class ServiceBaseLifetimeExtension { public static IHostBuilder UseServiceLifetime(this IHostBuilder hostBuilder) { return hostBuilder.ConfigureServices((hostContext, services) => services.AddSingleton<IHostLifetime, ServiceLifeTime>()); } public static Task RunTheServiceAsync(this IHostBuilder hostBuilder, CancellationToken cancellationToken = default) { return hostBuilder.UseServiceLifetime().Build().RunAsync(cancellationToken); } } }
The ServiceLifetime
class is where we override the methods from the ServiceBase
class. The implementation is as follows:
using Microsoft.Extensions.Hosting; using System; using System.ServiceProcess; using System.Threading; using System.Threading.Tasks; namespace Backup.Service { public class ServiceLifeTime : ServiceBase, IHostLifetime { public ServiceLifeTime(IApplicationLifetime applicationLifeTime) { ApplicationLifeTime = applicationLifeTime ?? throw new ArgumentNullException(nameof(applicationLifeTime)); DelayStart = new TaskCompletionSource<object>(); } public IApplicationLifetime ApplicationLifeTime { get; } public TaskCompletionSource<object> DelayStart { get; set; } public Task StopAsync(CancellationToken cancellationToken) { Stop(); return Task.CompletedTask; } protected override void OnStart(string[] args) { DelayStart.TrySetResult(null); base.OnStart(args); } protected override void OnStop() { ApplicationLifeTime.StopApplication(); base.OnStop(); } public Task WaitForStartAsync(CancellationToken cancellationToken) { cancellationToken.Register(() => DelayStart.TrySetCanceled()); ApplicationLifeTime.ApplicationStopping.Register(Stop); new Thread(Run).Start(); return DelayStart.Task; } private void Run() { try { Run(this); DelayStart.TrySetException(new InvalidOperationException("Stopped, without starting the service")); } catch (Exception ex) { DelayStart.TrySetException(ex); } } } }
Creating the Windows Service
As we have already created the Asp.Net Core console application, now it is time to create a windows service from it. Make sure that you had set the RuntimeIdentifier
to win7-x64
and SelfContained
to true
in the properties of your project, this will make sure that all of your dependencies are being added to your executable file so that you don’t need to worry about handling your dependencies manually. To do so, right click on the project and click on Edit Project File. In the end, your csproj
file should be similar to:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>netcoreapp2.1</TargetFramework> <RuntimeIdentifier>win7-x64</RuntimeIdentifier> <SelfContained>true</SelfContained> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.Azure.Storage.Blob" Version="10.0.3" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="2.2.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="2.2.0" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="2.2.0" /> <PackageReference Include="NLog" Version="4.6.6" /> <PackageReference Include="Quartz" Version="3.0.7" /> <PackageReference Include="System.ServiceProcess.ServiceController" Version="4.5.0" /> </ItemGroup> </Project>
Create the Release Configuration
You should also run the dotnet publish
command with the release configuration before you try to install the service, because you need this executable file to install the service.
Open the command prompt with administrator privilege and go to the project root folder using the cd
command. Run the command dotnet publish --configuration=release
.
This will generate everything you wanted. If you run the command correctly, you should see this output:
Now go to the bin
folder and then release
folder, you should see a folder with the name netcoreapp2.1
. Inside the folder, there will be a folder named win7-x64
, this is the folder where your .exe file, log file, and other items reside.
Install the Service
To install our Asp.Net console application as a Windows service, you can use the sc
command. Open the command prompt with administrator privilege and run the command:
sc create BackupService binPath="C:\Users\SibeeshVenu\source\repos\PerfectScheduler\PerfectScheduler\Backup.Service\bin\release\netcoreapp2.1\win7-x64\Backup.Service.exe" start=delayed-auto
And then you can start the service by running the command as sc start BackupService
. This should start your service.
Giving Permission to the Folders
Sometimes you may get a permission issue in your service as it doesn’t have enough permission to read the files from the C drive. To overcome this, you should give enough permission to the user. You can do this by editing the security properties of those folders.
Do the same for the BackupZip
folder as well.
Output
If you run the service correctly, you should see a log file with the name backupclientlogfile.txt
inside your win7-x64 folder
. Once the service is run, the logs will be written as follows.
2019-08-11 21:23:22.1088|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:22 PM: The PerformService() is called with daily schedule 2019-08-11 21:23:22.2497|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:22 PM: The ZipTheFolder() is called 2019-08-11 21:23:22.2497|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:22 PM: The file with the same name exists, thus deleted the same 2019-08-11 21:23:22.2497|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:22 PM: The Zip file is been created 2019-08-11 21:23:22.2895|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:22 PM: The UploadToAzureBlobStorage() is called with daily schedule 2019-08-11 21:23:22.9013|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:22 PM: The file is been uplaoded to the blob with daily schedule 2019-08-11 21:23:22.9061|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:22 PM: The PerformService() is finished with daily schedule 2019-08-11 21:23:23.0116|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:23 PM: The PerformService() is called with weekly schedule 2019-08-11 21:23:23.0265|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:23 PM: The ZipTheFolder() is called 2019-08-11 21:23:23.0418|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:23 PM: The file with the same name exists, thus deleted the same 2019-08-11 21:23:23.0555|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:23 PM: The Zip file is been created 2019-08-11 21:23:23.0618|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:23 PM: The UploadToAzureBlobStorage() is called with weekly schedule 2019-08-11 21:23:23.1519|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:23 PM: The file is been uplaoded to the blob with weekly schedule 2019-08-11 21:23:23.1565|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:23 PM: The PerformService() is finished with weekly schedule 2019-08-11 21:23:24.0175|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:24 PM: The PerformService() is called with monthly schedule 2019-08-11 21:23:24.0329|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:24 PM: The ZipTheFolder() is called 2019-08-11 21:23:24.0461|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:24 PM: The file with the same name exists, thus deleted the same 2019-08-11 21:23:24.0461|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:24 PM: The Zip file is been created 2019-08-11 21:23:24.0633|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:24 PM: The UploadToAzureBlobStorage() is called with monthly schedule 2019-08-11 21:23:24.1239|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:24 PM: The file is been uplaoded to the blob with monthly schedule 2019-08-11 21:23:24.1239|INFO|Backup.Service.Helpers.HelperService|8/11/2019 9:23:24 PM: The PerformService() is finished with monthly schedule
You can also check your Azure Storage account to check whether the files have uploaded correctly or not.