ASP.NET: Datenaufbereitung entkoppeln

In diesem Blogpost möchte ich auf ein Problem eingehen, mit welchem ich in meinem aktuellen Projekt konfrontiert war. Das grundlegende Problem ist dabei nicht sehr exotisch und kann sehr wahrscheinlich auch in anderen Projekten auftreten, und dort wohl auch ähnlich gelöst werden.

Das Problem & Ausgangslage

Die Applikation, um welche es in meinem Fall ging, ist eine typische datengestützte Applikation: Über verschiedene Views können alle möglichen Daten durch Benutzer erfasst werden, und über andere wieder ausgelesen werden. Während jedoch die bisherigen Views die Daten grösstenteils unverändert so wie sie in die Datenbank gespeichert werden wieder ausgeben, ging es nun darum, neue Features hinzuzufügen, welche die Daten gesamthaft analysieren und die Resultate der Analysen graphisch darstellen. Dabei ist es unumgänglich, dass die Daten in rechenintensiven Operationen ausgewertet und aufbereitet werden. Dies führte dazu, dass die Ladezeiten für die Diagrammdaten beachtlich hoch ausfielen. Dies wurde weiter dadurch verschärft, dass die produktive Applikation eine Low-Tier-Azure Web App ist, deren Spezifikation deutlich tiefer sind als meine lokale Entwicklungsmaschine.

Als Ausgangslage gestaltet sich der Workflow wie folgt: Dem Benutzer wird durch einen regulären MVC-Controller ("AnalyticsController") die View retourniert, in welche die Diagramme eingefügt werden. Die eigentlichen Daten werden jedoch separat mittel eines AJAX-Requests geladen. Dafür wurde ein zusätzlicher Controller ("AnalyticsApiController") erstellt, welcher die Datenaufbereitung startet und das Resultat als JSON retourniert.

Sequenzdiagramm Ausgangslage
Sequenzdiagramm Ausgangslage (Klicken zum Vergrössern)

Während mit diesem Ablauf alles grundsätzlich funktioniert, dauert es über 10 Sekunden, bis die Diagrammdaten geladen wurden:

Lösungskonzept

Als Lösungskonzept wird genutzt, dass sich die Daten, welche analysiert werden müssen, nicht sehr oft verändern, und wenn, dann würde eine einzelne Änderung in den Analysen kaum bemerkbar sein. Deshalb lautet die Idee, die Datenaufbereitung zu entkoppeln. Dies bedeutet, dass die Daten nicht jedes mal, wenn ein Nutzer die Analyse-Seite aufruft die Daten neu aufbereitet werden, sondern dass dies bereits im Vorfeld getan wird, sodass direkt die aufbereiteten Daten gesendet werden können.

 

Genauer soll es einen Job geben, also eine Funktionalität, welche einen Ablauf regelmässig ausführt. In diesem Fall sollen einmal täglich die Daten neu aufbereitet werden und die aufbereiteten Daten in der Datenbank zwischengespeichert werden.

 

Im Sequenzdiagramm sieht das wie folgt aus:

Sequenzdiagramm mit entkoppelter Datenaufbereitung
Sequenzdiagramm mit entkoppelter Datenaufbereitung (Klicken zum Vergrössern)

Umsetzung

Für dieses Projekt wird mit ASP.NET MVC gearbeitet, sowie Entity Framework für den Datenzugriff, wobei das Repository-Pattern verwendet wird. Für das Job Scheduling wird zusätzlich das Framework Quartz.Net verwendet (https://www.quartz-scheduler.net/).

 

JobScheduler

In der JobScheduler-Klasse wird bestimmt, wann und wie oft der Job ausgeführt werden soll.

public class JobScheduler
{
    private static IScheduler _scheduler;
 
    public static async void RegisterJobs(IKernel kernel)
    {
        _scheduler = new StdSchedulerFactory()
            .GetScheduler()
            .GetAwaiter()
            .GetResult();
        _scheduler.JobFactory = new NinjectJobFactory(kernel);
 
        await _scheduler.Start();
 
        ScheduleAnalyticsJob();
    }
 
    private static async void ScheduleAnalyticsJob()
    {
        var job = JobBuilder.Create<AnalyticsJob>()
            .WithIdentity("AnalyticsJob")
            .Build();
 
        var intervalAsString = WebConfigurationManager
            .AppSettings["ExecuteAnalyticsJobIntervalTime"];
        var startHourAsString = WebConfigurationManager
            .AppSettings["ExecuteAnalyticsJobStartHour"];
 
        var isIntervalValid = TimeSpan.TryParse(
            intervalAsString, out var interval);
        var intervalHours = isIntervalValid ? interval.Hours : 24;
 
        var isStartHourValid = TimeSpan.TryParse(
            startHourAsString, out var startHourAsDateTime);
        var startHour = isStartHourValid ? 
            startHourAsDateTime.Hours : 0;
 
        var trigger = TriggerBuilder.Create()
            .WithDailyTimeIntervalSchedule(s =>
                s.WithIntervalInHours(intervalHours)
                    .OnEveryDay()
                    .StartingDailyAt(
                        TimeOfDay.HourAndMinuteOfDay(startHour, 0)))
            .Build();
 
        await _scheduler.ScheduleJob(job, trigger);
        await _scheduler.TriggerJob(job.Key);
    }
 
    public static void StopJob()
    {
        _scheduler.Shutdown();
    }
}

web.config

In der web.config-Datei wird konfiguriert, wann und wie oft der Job ausgeführt werden soll.

  <appSettings>
    <add key="ExecuteAnalyticsJobIntervalTime" value="23:59:00"/>
    <add key="ExecuteAnalyticsJobStartHour" value="0"/>
  </appSettings>

AnalyticsJob

Die eigentliche Job-Klasse. Diese Klasse bereitet die Daten auf und speichert oder aktualisiert die entsprechenden Daten in der Datenbank. Für die Datenpersistierung wurde eine neue Tabelle angelegt und entsprechend ein neues Repository erstellt. Da in meinem Fall mehrere Diagramme mit vor-aufbereiteten Daten existieren, wird für jedes Diagramm ein eigener Eintrag in dieser Tabelle angelegt. Die Zuordnung geschieht mit einem Enum, welcher nicht benötigt wird, wenn es nur ein Diagramm gibt. Der Übersichtlichkeit halber wurden aus dem folgenden Code-Ausschnitt alle ausser einem Diagramm entfernt.

 

Die Daten werden direkt im Job JSON-serialisiert und in serialisierter Form abgespeichert. Die Logik für die Datenaufbereitung ist in die AnalyticsDataService-Klasse ausgelagert. Diese Klasse liest Rohdaten aus der Datenbank aus, bearbeitet diese und konvertiert sie dann in DTO's.

public class AnalyticsJob : IAnalyticsJob
{
    private readonly IAnalyticsDataService 
        _analyticsDataService;
    private readonly IAnalyticsDataRepository 
        _analyticsDataRepository;
    private readonly IUnitOfWork _unitOfWork;
 
    public AnalyticsJob(
        IAnalyticsDataService analyticsDataService,
        IAnalyticsDataRepository analyticsDataRepository,
        IUnitOfWork unitOfWork)
    {
        _analyticsDataService = analyticsDataService;
        _analyticsDataRepository = analyticsDataRepository;
        _unitOfWork = unitOfWork;
    }
 
    public async Task Execute(IJobExecutionContext context)
    {
        await GenerateTechnologyRadarData();
    }
 
    private async Task GenerateTechnologyRadarData()
    {
        var data = await Task.Run(() => 
            _analyticsDataService.GetTechnologyRadarData());
        UpdateOrAddAnalyticsDataToDatabase(
            AnalyticsDataType.TechnologyRadar, data);
    }
 
 
    private void UpdateOrAddAnalyticsDataToDatabase(
        AnalyticsDataType dataType, object data)
    {
        var dataSerialized = 
            Newtonsoft.Json.JsonConvert.SerializeObject(data);
 
        var existingData = _analyticsDataRepository.Get()
            .SingleOrDefault(d => d.TypeKey == dataType);
 
        if (existingData != null)
        {
            existingData.Data = dataSerialized;
            _analyticsDataRepository.Update(existingData);
        }
        else
        {
            _analyticsDataRepository.Add(new AnalyticsData
            {
                TypeKey = dataType,
                Data = dataSerialized
            });
        }
        _unitOfWork.SaveChanges();
    }
}

AnalyticsApiController

Controller für die API-Requests, welcher die Diagrammdaten als JSON retoruniert. Dieser Controller prüft für jeden API-Endpoint, ob bereits aufbereitete Daten vorliegen. Falls ja, werden die aufbereiteten Daten geladen und 1:1 retourniert. Ansonsten müssen die Daten zuerst noch frisch aufbereitet werden und dann erneut serialisiert werden, bevor sie gesendet werden können. Wenn alles wie geplant funktioniert, sollte Letzteres aber nie nötig sein, da es zu jedem Diagramm immer bereits aufbereitete Daten geben sollte.

public class AnalyticsApiController : Controller
{
    private readonly IAnalyticsDataService 
        _analyticsDataService;
    private readonly IAnalyticsDataRepository 
        _analyticsDataRepository;
 
    public AnalyticsApiController(
        IAnalyticsDataService analyticsDataService,
        IAnalyticsDataRepository analyticsDataRepository)
    {
        _analyticsDataService = analyticsDataService;
        _analyticsDataRepository = analyticsDataRepository;
    }
 
    [Route("analytics/api/technologyRadar")]
    [HttpGet]
    public ContentResult GetTechnologyRadarData()
    {
        var existingData = _analyticsDataRepository.Get()
            .SingleOrDefault(d => 
                d.TypeKey == AnalyticsDataType.TechnologyRadar)?
            .Data;
 
        var data = existingData ?? 
            Newtonsoft.Json.JsonConvert.SerializeObject(
                _analyticsDataService.GetTechnologyRadarData());
 
        return Content(data, "application/json");
    }
}

Resultat

Mit dieser veränderten Architektur gelang es, die Ladezeiten von vormals >10 Sekunden auf nun unter eine Sekunde zu bringen, selbst auf dem schwächeren Produktiv-System.