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.
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.
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.
// 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?
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.