Using Hosted Services in ASP.NET Core to create a "most viewed" background service

Published: Friday 17 July 2020

Read how to implement ASP.NET Core hosted services in .NET 6.

A number of websites have a "Most Viewed" component. It might be the top 10 articles read on a news website. Or, it might be the top 10 products purchased on an online shop.

Now if you are thinking of putting a "Most Viewed" component on your website, you may only want it updated a various times of the day. These components often use a more complex database query with the use of joining and grouping. And more complex database queries will use up more resources.

So, what's the solution for this? Well one way you can do it is to generate this query in the background at regular intervals. The results of the query can be stored in such a way that it's ready to be used for the ASP.NET Core application.

We are going to use Hosted Services in ASP.NET Core to set up a background service. Hosted Services was integrated in ASP.NET Core version 2.1.

Once we have the results, we will store them in a singleton service. With this singleton service, the controller can retrieve and output the results.

The Project

We set up an ASP.NET Core API project. Integrated within the project is a DbContext for Entity Framework, connected with a SQL Server database. We've set up two entities. The first entity is Article. This will basically store a list of articles for a blog, and includes information such as the title and the content.

// Article.cs
public class Article : Base
{
	public string Title { get; set; }

	public string Content { get; set; }       
}

The other entity is related to Article. Named ArticleHit, it creates a new record every time someone visits a particular article. This will help us generate a query of "most viewed" articles.

// ArticleHit.cs
public class ArticleHit : Base
{
	public int ArticleId { get; set; }

	public Article Article { get; set; }

	public static void OnModelCreating(ModelBuilder modelBuilder)
	{
		modelBuilder.Entity<ArticleHit>()
			.HasOne(prop => prop.Article)
			.WithMany()
			.HasPrincipalKey(article => article.Id)
			.HasForeignKey(articleHit => articleHit.ArticleId);
	}
}

Now, you may have noticed that both of these entities inherit a class called "Base". This basically stores properties such as a unique ID, when it was created, last updated or whether it's deleted.

We used the "Base" class a lot in my article entitled "Create CRUD API Endpoints with ASP.NET Core & Entity Framework". You can read more about it and how we use it to set up the entities in our DbContext.

The Services

With the project set up, we can go ahead and implement from services. The first thing we want to do is to set up a class that will store the data for our "Most Viewed" results. We want to store information about the article including the ID and the title. In addition, we want to store how many hits that article has had.

// MostViewedArticleView.cs
public class MostViewedArticleView
{
	public int ArticleId { get; set; }

	public int Hits { get; set; }

	public string Title { get; set; }
}

Next, we are going to set up a service. This service will be responsible for storing our "most viewed" articles list and will be a singleton. As a result, the ASP.NET Core API can be used to retrieve the "most viewed" articles list and display it in an API endpoint.

// IMostViewedArticleService.cs
public interface IMostViewedArticleService
{
	IEnumerable<MostViewedArticleView> MostViewedArticles { get; set; }
}
// MostViewedArticleService.cs
public class MostViewedArticleService : IMostViewedArticleService
{
	public IEnumerable<MostViewedArticleView> MostViewedArticles { get; set; }
}

Now it's time to set up our background service.

Hosted Service

For our background service, we are going to create a class which will inherit the BackgroundService class. The BackgroundService class has an ExecuteAsync abstract method that we need to integrate.

As you can probably guess, the ExecuteAsync method contains the code that is executed when the background service is ran.

Executing The Task

The ExecuteAsync method will generate a query in Entity Framework to get the most viewed articles in the last 60 seconds. Once it's complete, it will delay the task by a minute before it is ran again.

But, how do we get Entity Framework to work in a service like this? What we can do is inject the IServiceProvider interface into our hosted service. Thereafter, we can create a new scoped instance. This scoped instance is specific to our ExecuteAsync method. It's from there that we can get a reference to our DbContext from our localised scope and execute the relevant queries.

Once we have the results, we store them in the MostViewedArticles property in the MostViewedArticleService.

// MostViewedArticleHostedService.cs
public class MostViewedArticleHostedService : BackgroundService
{
	protected IServiceProvider _serviceProvider;
	protected IMostViewedArticleService _mostViewedArticleService;

	public MostViewedArticleHostedService([NotNull] IServiceProvider serviceProvider, [NotNull] IMostViewedArticleService mostViewedArticleService)
	{
		_serviceProvider = serviceProvider;
		_mostViewedArticleService = mostViewedArticleService;
	}

	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
		while (!stoppingToken.IsCancellationRequested)
		{
			using (var scope = _serviceProvider.CreateScope())
			{
				var hostedServicesDbContext = (HostedServicesDbContext)scope.ServiceProvider.GetRequiredService(typeof(HostedServicesDbContext));
				var timeFrom = DateTimeOffset.Now.AddSeconds(-60);

				_mostViewedArticleService.MostViewedArticles = hostedServicesDbContext.Set<ArticleHit>()
					.Join(
						hostedServicesDbContext.Set<Article>(),
						articleHit => articleHit.ArticleId,
						article => article.Id,
						(articleHit, article) => new { ArticleHit = articleHit, Article = article }
					)
					.Where(g => g.ArticleHit.Created >= timeFrom)
					.GroupBy(g => g.Article.Id)
					.Select(g => new MostViewedArticleView { ArticleId = g.Key, Title = g.Min(t => t.Article.Title), Hits = g.Count() })
					.OrderByDescending(g => g.Hits)
					.ToList();

			}

			await Task.Delay(new TimeSpan(0, 1, 0));
		}
	}
}

In this method, you can see that we have built up a query where each result gets stored into a MostViewedArticleView class. But why are we explicitly defining a join by using the .Join method? Why not just the .Include method?

Because we are using the .GroupBy method, it tends to lose any reference to any includes when using this. So we need to explicitly define the join in our query to get a reference to Article.

Adding the Services to Startup

At the moment, neither the singleton or the hosted service will create an instance in our ASP.NET Core API. We need to configure the services in our Startup class. In-order to do this, we need to use the AddSingleton and AddHostedService functions respectively.

// Startup.cs
public class Startup
{
	...

	// This method gets called by the runtime. Use this method to add services to the container.
	public void ConfigureServices(IServiceCollection services)
	{
		...
		services.AddSingleton<IMostViewedArticleService, MostViewedArticleService>();
		services.AddHostedService<MostViewedArticleHostedService>();
	}

	...
}

Testing our Service

We created an controller in our ASP.NET Core API project. This has two endpoints. The first creates a new entity in the ArticleHit table when called. The other gets a reference to our MostViewedArticleService singleton class which displays the results of our "most viewed" articles.

// ArticleHitController.cs
[ApiController]
[Route("api/article-hit")]
public class ArticleHitController : Controller
{
	protected readonly HostedServicesDbContext _hostedServicesDbContext;
	protected readonly IMostViewedArticleService _mostViewedArticleService;

	public ArticleHitController([NotNull] HostedServicesDbContext hostedServicesDbContext, [NotNull] IMostViewedArticleService mostViewedArticleService)
	{
		_hostedServicesDbContext = hostedServicesDbContext;
		_mostViewedArticleService = mostViewedArticleService;

	}

	[HttpPost]
	public async Task<IActionResult> CreateAsync(ArticleHit entity)
	{
		await _hostedServicesDbContext.Set<ArticleHit>().AddAsync(entity);
		await _hostedServicesDbContext.SaveChangesAsync();

		return Ok(entity);
	}

	[HttpGet("most-viewed")]
	public IActionResult GetMostViewed()
	{
		return Ok(_mostViewedArticleService.MostViewedArticles);
	}
}

To ensure this works, we used Postman to test out the API endpoints. We created a number of hits for several of our articles. Next, we put a breakpoint at the end of our ExecuteAsync method in MostViewedArticleHosted Service. This is so we know when the background service is ran. Once it's ran, we call our GetMostViewed API endpoint to check that the results have been updated. You can watch back our live stream of us doing it.