Paging und Sorting mit Spring Data JPA
In meinem Blog-Beitrag "Anwendungsentwicklung mit Spring Boot und Thymeleaf" habe ich ein einfaches Anwendungsbeispiel zur Verwaltung von Studierenden entwickelt. Initial enthält die Datenbank zu diesem Beispiel über 100 Datensätze mit individuellen Angaben zu den verwalteten Studierenden.
Obwohl es sich hier nicht um grosse Datenmengen handelt, ist deren Darstellung "am Stück" (z.B. in Form einer HTML Tabelle) nicht empfehlenswert, da das Scrollen in einer langen Liste tendenziell als benutzer-unfreundlich empfunden wird. Zudem fehlt im genannten Anwendungsbeispiel die Möglichkeit, die Liste nach verschiedenen Kriterien (z.B. Vorname, PLZ, etc.) zu sortieren.
In diesem Blog-Beitrag werde ich zeigen, wie diese fehlenden Features (Paging und Sorting) serverseitig mit Spring Data JPA realisiert werden können.
Das Problem der Darstellung von grossen Datenmengen
Ein Problem, mit welchem viele Anwendungen konfrontiert sind, ist die benutzerfreundliche bzw. übersichtliche Darstellung grosser Datenmengen.
Bei der Wahl einer geeigneten Lösung spielt die Menge der darzustellenden Daten eine zentrale Rolle: Handelt es sich beispielsweise um 50, 5'000 oder um 5'000'000 Datensätze?
Unter Paginierung verstehen wir in diesem Zusammenhang eine Aufteilung von Daten, so dass jeweils nur eine Teilmenge davon auf einer Seite (z.B. innerhalb einer HTML-Tabelle im Browser) angezeigt wird. Den meisten Lesern sind solche Lösungen von Webs-Shops mit einer Stückelung der vorhandenen Artikelliste bekannt, durch welche man sich durchklicken muss, um die nächste Seite mit weiteren Produkten zu sehen.
Eine solche Paginierung kann entweder "clientseitig" oder "serverseitig" realisiert werden.
- Bei einer serverseitigen Paginierung gibt das serverseitige Backend nur eine Teilmenge der vom Client angeforderten Daten zurück. Zusammen mit der Teilmenge liefert das Backend auch die Gesamtzahl der Ergebnisse, welche der Suchanfrage des Clients entsprechen. Als weitere Information wird auch der Bereich (die "Seite") geliefert, in welchem wir uns innerhalb der Suchanfrage befinden. Dies bedeutet also, dass der Client jeweils nur einen Teil der Ergebnisse abruft. Zusätzliche Elemente werden nur bei Bedarf geladen.
- Im Falle der clientseitigen Paginierung liefert das Backend dem Client bei einer Abfrage alle Daten in einem grossen Stück. Die Daten werden sodann clientseitig gecashed und stückweise dem/der Nutzer/in präsentiert.
Beide Lösungsansätze haben verschiedene Vor- wie auch Nachteile. Die konkrete Wahl einer Lösung muss im Kontext des jeweiligen Anwendungsfalles getroffen werden. Hat die Anwendung beispielsweise das Potenzial, schnell zu wachsen, empfiehlt sich im Regelfall eine serverseitige Paginierung. Ich werde im Rahmen dieses Blog-Beitrages ausschliesslich auf serverseitige Paginierung eintreten.
Serverseitiges Paginierung mit Spring Data JPA
Der vollständige Source-Code steht auf meinem Gitlab-Repository zum Herunterladen bereit (siehe Hinweise am Ende dieses Blog-Beitrags).
Ausgangspunkt für die unten entwickelte Lösung ist die in meinem Blog-Beitrag "Anwendungsentwicklung mit Spring Boot und Thymeleaf" diskutierte Beispielanwendung "Management von Studierendendaten". Das dabei entwickelte StudentRepository erweitert das Interface JpaRepository, welches seinerseits das PagingAndSortingRepository Interface verwendet. Vom PagingAndSortingRepository nutze ich die beiden nachstehenden Methoden:
- findAll(Pageable pageable) - gibt ein Page Objekt mit Entitäten zurück, welche der im Pageable-Objekt angegebenen Paging-Beschränkung entsprechen.
- findAll(Sort sort) - gibt alle Entitäten sortiert nach den angegebenen Optionen zurück. Hier wird kein Paging angewendet.
Diese Methoden stelle ich dem Controller via Service zur Verfügung, d.h. der StudentService wird wie folgt ergänzt:
@Service
public class StudentService {
private StudentRepository studentRepository;
private Validator validator;
public StudentService(StudentRepository studentRepository, Validator validator) {
this.studentRepository = studentRepository;
this.validator = validator;
}
...
public Page<Student> getPaginatedStudents(final int pageNumber, final int pageSize) {
final Pageable pageable = PageRequest.of(pageNumber - 1, pageSize);
// Der Seitenindex beginnt bei 0, nicht 1
return studentRepository.findAll(pageable);
}
Ein Test dieser zusätzlichen Methode könnte wie folgt aussehen. Es wird dabei angenommen, dass die initiale Datenbank über 100 Datensätze enthält.
@Test
@DisplayName("Find Students by page")
public void findPaginatedStudentsWithoutSorting() throws StudentPageException {
int pageSize = 10;
int pageNumber = 1;
Page<Student> pagedStudents = studentService.getPaginatedStudents(pageNumber,pageSize);
assertEquals(pagedStudents.get().count(), pageSize);
}
@Test
@DisplayName("Find Students by page out of range")
public void findPaginatedStudentsWithoutSortingOutOfRange() {
int pageSize = 10;
int pageNumber = 1000; // out of range
assertThrows(StudentPageException.class, () -> {
studentService.getPaginatedStudents(pageNumber, pageSize);
});
}
Die hier genutzte Repository-Methode findAll(Pageable pageable) gibt standardmässig ein Page-Objekt zurück. Ein Page-Objekt gibt neben der Liste der Studierenden auf der aktuellen Seite weitere nützliche Informationen zurück: Ein Page-Objekt enthält z.B. die Anzahl der Gesamtseiten, die Nummer der aktuellen Seite sowie die Angabe, ob es sich bei der aktuellen Seite um die erste oder die letzte Seite handelt. Damit das Page-Objekt die Anzahl der Gesamtseiten zurückgeben kann, muss eine zusätzliche count()-Abfrage getätigt werden, welche Overhead-Kosten verursacht. Falls es für einen Client nicht notwendig ist, die Anzahl der Gesamtseiten zu kennen, kann auf das Slice-Objekt ausgewichen werden. Grundsätzlich ist das Slice-Objekt dem Page-Objekt sehr ähnlich, enthält jedoch keine Information zur Anzahl der Gesamtseiten. Slice wird üblicherweise benutzt, wenn von der aktuellen Seite aus gesehen lediglich zur nächsten bzw. der vorherigen Seite navigiert werden soll.
Damit Slice benutzt werden kann, muss im Repository eine benutzerdefinierte Methode hinzugefügt werden, wie nachstehendes Beispiel illustrieren soll:
public interface StudentRepository extends CrudRepository<Student, Long>
{
public Slice<Student> findByLastName(String lastName, Pageable pageable);
}
Damit der Client auf die oben dargestellten Service-Methoden zugreifen kann, müssen wir den Controller aus der Beispielanwendung ergänzen:
@GetMapping("/")
String viewHomePage(Model model) throws StudentPageException {
return getStudentsPaginated(1, model);
}
...
@GetMapping("/page/{pageNo}")
String getStudentsPaginated(@PathVariable (value = "pageNo") int pageNo, Model model) throws StudentPageException {
Page<Student> page = studentService.getPaginatedStudents(pageNo, pageSize);
List<Student> students = page.getContent();
model.addAttribute("currentPage", pageNo);
model.addAttribute("totalPages", page.getTotalPages());
model.addAttribute("totalStudents", page.getTotalElements());
model.addAttribute("students", students);
model.addAttribute("pageSize", pageSize);
return "pagedstudentlist";
}
Schliesslich muss auch die Thymeleaf Seite mit der Liste der Studierenden ergänzt werden, damit die obige Controller Methode genutzt werden kann. Konkret müssen entsprechende Buttons zur Navigation zwischen den Seiten eingefügt werden:
....
<div class="container float-left">
<!-- Button "Next Page" -->
<a th:if="${currentPage < totalPages}" th:href="@{'/page/' + ${currentPage + 1}}"
class="btn btn-secondary"> Next > </a>
<a th:if="${currentPage == totalPages}" class="btn btn-secondary disabled"> Next > </a>
<!-- Button "Previous Page" -->
<a th:if="${currentPage > 1}" th:href="@{'/page/' + ${currentPage - 1}}"
class="btn btn-secondary">< Previous</a>
<a th:if="${currentPage == 1}" class="btn btn-secondary disabled">< Previous</a>
<!-- Button "First Page" -->
<a th:if="${currentPage > 1 }" th:href="@{'/page/1' }"
class="btn btn-secondary"><< First </a>
<a th:if="${currentPage == 1}" class="btn btn-secondary disabled"><< First </a>
<!-- Button "Last Page" -->
<a th:if="${currentPage < totalPages}" th:href="@{'/page/' + ${totalPages}}"
class="btn btn-secondary"> Last >></a>
<a th:if="${currentPage == totalPages}" class="btn btn-secondary disabled"> Last >></a>
</div>
...
Das Ergebnis all dieser Ergänzungen sieht wie folgt aus:
Sortieren mit Spring Data JPA
Die bereits im obigem Kapitel benutzte Repository Methode findAll(Pageable pageable) kann bei Bedarf die im Page-Objekt zurückgegebenen Listen nach einem vorgegebenen Kriterium sortieren. Hierzu fügt man dem Pageable-Object neben der Seitennummer und der Seitengrösse noch ein Objekt vom Typ Sort hinzu. Dieses enthält einerseits den Namen des Attributes, nach welchem sortiert werden soll sowie die Sortierrichtung (auf- oder absteigend).
Angewendet auf das Anwendungsbeispiel muss also die Service-Methode getPaginatedStudent() wie folgt ergänzt werden:
public Page<Student> getPaginatedStudentsSorted(final int pageNumber, final int pageSize, String sortBy, String sortDirection ) throws StudentPageException {
final Pageable pageable;
Sort sort = sortDirection.equalsIgnoreCase(Sort.Direction.ASC.name()) ? Sort.by(sortBy).ascending() : Sort.by(sortBy).descending();
pageable = PageRequest.of(pageNumber - 1, pageSize, sort);
Page<Student> pagedResult = studentRepository.findAll(pageable);
if(pagedResult.hasContent()) {
return pagedResult;
} else {
throw (new StudentPageException());
}
}
In der aufrufenden Controller Methode sind folglich nachstehende Anpassungen vorzunehmen. Der Methode getStudentsPaginated() werden zwei zusätzliche Attribute ("sortField", "sortDir") in der Ausprägung @RequestParam übergeben. Die Inhalte dieser Parameter werden zudem dem Model übergeben, damit diese in der Thymeleaf Seite entsprechend ausgewertet werden können.
Ebenfalls ergänzt werden muss die viewHomePage() Methode, welche die Liste der Studierenden initial aufruft. Konkret muss der darin vorhandene Aufruf der Methode getStudentsPaginated() die beiden genannten zusätzlichen Attribute (mit je einem initialen Wert) ebenfalls enthalten.
@GetMapping("/")
String viewHomePage(Model model) throws StudentPageException {
return getStudentsPaginated(1, "id", "asc", model);
}
....
@GetMapping("/page/{pageNo}")
String getStudentsPaginated(@PathVariable (value = "pageNo") int pageNo,
@RequestParam("sortField") String sortField,
@RequestParam("sortDir") String sortDir,
Model model) throws StudentPageException {
Page<Student> page = studentService.getPaginatedStudentsSorted(pageNo, pageSize, sortField, sortDir);
List<Student> students = page.getContent();
model.addAttribute("currentPage", pageNo);
model.addAttribute("totalPages", page.getTotalPages());
model.addAttribute("totalStudents", page.getTotalElements());
model.addAttribute("students", students);
model.addAttribute("pageSize", pageSize);
model.addAttribute("sortField", sortField);
model.addAttribute("sortDir", sortDir);
model.addAttribute("reverseSortDir", sortDir.equals("asc") ? "desc" : "asc");
return "pagedstudentlist";
}
Ebenso muss die Thymeleaf Seite mit der Liste der Studierenden erneut ergänzt werden. Damit die Datensätze nach einem bestimmten Attribut sortiert werden können (auf- oder absteigend), muss dieses in geeigneter Form ausgewählt werden können. In der vorliegenden Thymeleaf Seite ergänzen wir die Spaltenüberschriften der Tabelle mit einem Link-Ausdruck (Link Expression "th:href"), welche das zu sortierende Feld sowie die Sortierrichtung festlegt. Die Sortierfunktion wurde in Form eines "ASC/DESC -Switch" implementiert.
<th scope="col">
<a th:href="@{'/page/' + ${currentPage} + '?sortField=id&sortDir=' + ${reverseSortDir}}">Id </a>
<span th:if="${sortField == 'id' && sortDir == 'asc'}"><span style="color: coral;">↑</span></span>
<span th:if="${sortField == 'id' && sortDir == 'desc'}"><span style="color: coral;">↓</span></span>
</th>
Der besseren Übersicht halber wird je nach vorhandener Sortierrichtung neben dem aktiven Sortier-Attribut ein Pfeil in vertikaler Richtung dargestellt:

Das Ergebnis all dieser weiteren Ergänzungen sieht wie folgt aus:
Zusammenfassung
In diesem Blog-Beitrag habe ich das Sortieren und das stückweise Abrufen von Datensätzen mit Hilfe von Spring Data JPA erläutert. Hierfür habe ich mein Anwendungsbeispiel aus dem Blog-Beitrag "Anwendungsentwicklung mit Spring Boot und Thymeleaf" entsprechend ergänzt.
Das vorliegende Ergebnis kann als Ausgangslage für weitere Ausbauschritte verwendet werden.
Ausblick
In weiteren Blog-Beiträgen werde ich auf der Basis des hier entwickelten Beispiels zeigen, welche weiteren Schritte für eine produktionsreife Anwendung notwendig sind. So werde ich näher auf Themen wie Security Mechanismen mit Spring Security, Entwicklung von RESTFul Web Services mit Spring Boot etc. eingehen.
Source Code
Den vollständigen Source Code zu dieser Beispielanwendung finden Sie auf meinem GitHub Repository unter https://gitlab.com/jfr1/student-mgmt/-/tree/feature_paging_and_sorting.