CosmosDb: Pagination, Sort und Filter mit ASP.NET Core

Dieser Beitrag zeigt verschiedene Möglichkeiten zur Abfrage und Verarbeitung großer Datenmengen mit Hilfe der CosmosDb und ASP.Net Core.

Pagination

Pagination ist ein UI Pattern, welches eingesetzt wird, wenn großen Datenmengen performant und übersichtlich dargestellt werden sollen.

CosmosDb unterstützt hierfür die OFFSET-LIMIT-Bedingung, mit der wir eine definierte Anzahl an Ergebnissen einer Anfrage überspringen (OFFSET) und zum anderen begrenzen (LIMIT) können. Der SQL Befehl sieht folgendermaßen aus:

SELECT * FROM USERS
OFFSET <offset> LIMIT <size>;

Viele Benutzeroberflächen wollen dem Benutzer mehr Kontrolle geben, in dem es möglich wird eine Liste von Ergebnissen gezielt durchzublättern. Wie der Name schon verdeutlicht,  wird das Konzept der „Seite“ verwendet, welche  durch die folgenden zwei Parameter definiert ist: 

  • Nummer der gewünschten Seite (z.B. 3)
  • Anzahl der Ergebnisse, die eine Page maximal enthalten kann (z.B. 25)

Für die eigentliche SQL-Abfrage werden diese Parameter wie folgt umgerechnet

  • <offset>: PAGE_SIZE * REQUESTED_PAGE
  • <limit>: PAGE_SIZE

setzen.

Diese Query können wir nun mit LINQ anhand Skip (für OFFSET) und Take (für LIMIT) konstruieren. 

Offset Limit

public async Task<List<DTO>> GetDataAsync(int page, int pageSize)
{
   var res = (await _container.ConfigureAwait(false))
             .GetItemLinqQueryable<DTO>(true, null)
             .Skip(page * pageSize)
             .Take(pageSize)
             .AsEnumerable()
             .ToList();
 
   return res;
}

Die Verwendung der OFFSETLIMITBedingung sorgt für ein gezieltes Übertragen der gewünschten Daten an den Client und reduziert somit dessen Datenmenge. Gegenüber der Datenbank ist dies jedoch nicht der Fall. Die Belastung der Datenbankmit einer OFFSET LIMIT Abfrage steigt mit steigendem Wert von OFFSET an. Dies liegt daran, dass zum Überspringen der Datensätze die Abfrage vollständig ausgeführt werden muss (Filter-Bedingungen und Sortierung) und mindestens alle zu überspringenden Datensätze durchlaufen werden müssen, obwohl diese anschließend nicht zurückgegeben werden.

Datenabfrage über einen Continuation Token

Die CosmosDB bietet die Möglichkeit, von einer Abfrage nur die ersten X Datensätze zu erhalten. Dies würde einem OFFSET von 0 und einem definierten LIMIT von X entsprechen. Technisch gesehen wird jedoch die Abfrage datenbankseitig nach dem Ausgeben der X Datensätze „angehalten“ und zusätzlich ein Continuation-Token zurückgegeben. Mit diesem Token können wir von der gleichen Abfrage wieder eine Menge an Datensätzen abrufen.

Die Eigenschaft MaxItemCount der FeedOptions einer CosmosDb Query bestimmt die maximale Anzahl an Ergebnissen, die die CosmosDb zurückgeben soll. Wenn die Anzahl an Ergebnissen höher als MaxItemCount ist, wird ein ContinuationToken in der Antwort mitgesendet.

public async Task<Tuple<Object, string>> GetDataAsync(string requestContinuationToken)
       {
           var options = new QueryRequestOptions()
           {
               MaxItemCount = 10
           };
           var responseToken = null;
           var iterator = (await _container.ConfigureAwait(false))
               .GetItemQueryStreamIterator("SELECT * FROM FOO", requestContinuationToken, options);
           if (iterator.HasMoreResults)
           {
               var res =  await iterator.ReadNextAsync().ConfigureAwait(false);
               if (res.IsSuccessStatusCode)
                   responseToken = res.ContinuationToken;
 
               return new Tuple<Object, string>(res.Content, responseToken);
           }
 
           return null;
       }

 Bei der Verwendung des Continuation-Tokens ist es extrem wichtig zu wissen, dass man nur die nächsten X Datensätze abrufen kann. Es gibt keine Möglichkeit die vorherigen Datensätze erneut abzufragen, ohne eine komplett neue Abfrage durchzuführen. Dafür ist die Performance jedoch selbst bei großen Datenmengen im Vergleich zu OFFET-LIMIT sehr gut. 

Durch diese Limitierung ist der Einsatzweck des Continuation-Tokens eher begrenzt, da nur von Anfang an gelesen werden kann. Im Gegensatz zu OFFET-LIMIT kann eine „Seite“ nicht direkt angesprungen werden, ohne alle Daten vorher auszelesen. 

Ein häufiges Einsatzszenario ist eine Liste mit Endless-Scrolling, welche immer am Anfang beginnt. Beim Scrollen werden die nächsten Daten geladen. Ältere Daten werden vom Client zwischengespeichert und können beim Nach-oben-Scrollen erneut angezeigt werden. 

Sorting

LINQ ermöglicht uns mit fünf Operatoren, Elemente eines Queryable oder Enumerable Objekts umzuordnen. 

  1. OrderBy: Sortiert die Elemente einer Liste aufsteigend (A-Z, 0-9) basierend auf einem bestimmten Feld.
  2. OrderByDescending: Sortiert die Elemente einer Liste absteigend (Z-A, 9-0) basierend auf einem bestimmten Feld.
  3. ThenBy: Ordnet die Elemente in einer mehrstufigen Sortierung nach einem weiteren Feld aufsteigend an. Kann nur nach OrderBy oder OrderByDescending verwendet werden.
  4. ThenByDescending: Ordnet die Elemente in einer mehrstufigen Sortierung nach einem weiteren Feld absteigend an. Kann nur nach einer OrderBy oder OrderByDescending verwendet werden.
  5. Reverse: invertiert die Reihenfolge einer Liste

Für OrderBy kann man entweder den default Comparer benutzen (wird verwendet wenn kein anderer eingegeben wurde). Der Comparer implementiert dabei die Logik, wie zwei Datensätze verglichen werden (notwendig bei komplexen Datenstrukturen).

 public async Task<List<People>> GetPeopleWhoseNamesStartWithA()
 {
     var res = (await _container.ConfigureAwait(false))
                 .GetItemLinqQueryable<People>(true, null)
                 .OrderBy(p => p.Name) // sortiert die Personen ansteigend nach Namen
                 .ThenBy(p => p.Age) // sortiert die resultierende Liste nach Alter
                 .AsEnumerable()
                 .ToList();
     
     return res;
 }

Hier wird die Liste der Personen zuerst nach Namen aufsteigend sortiert. Personen mit gleichem Namen werden anschließend nach Alter aufsteigend sortiert.

Filter

Ergebnisse können mit LINQ via die Where-Methode herausgefiltert werden. Wenn z.B. nur die Personen , deren Namen mit „A“ starten gesucht sind, kann man die LINQ Query folgendermaßen aufbauen:

public async Task<List<People>> GetPeopleWhoseNamesStartWithA()
{
    var res = (await _container.ConfigureAwait(false))
             .GetItemLinqQueryable<People>(true, null)
             .Where(p => p.Name.StartsWith("A"))
             .AsEnumerable()
             .ToList();
 
    return res;
}

Die Verwendete Logik in der WhereMethode kann dabei beliebig komplex sein, so lange sie ein true (Datensatz soll in der Ergebnissmenge enthalten sein) oder false (Datensatz soll rausgefiltert werden) zurückgibt.

Schreibe einen Kommentar