25 października 2007

Porównanie mechanizmów stronicowania wyników zapytania

Istotnym elementem typowej aplikacji jest przetwarzanie, np. wyświetlanie, pewnej listy obiektów będącej wynikiem wyszukiwania w bazie danych. Bardzo często zdarza się, że jednorazowo potrzebny jest tylko pewien fragment tej listy, np. 10 najnowszych zamówień. Technologia trwałości danych powinna umożliwiać i ułatwiać pobieranie tylko pewnego podzbioru (strony) wyników zapytania i pobieranie kolejnych podzbiorów (stron) w miarę potrzeby. Poniżej pokażę, w jaki sposób implementację stronicowania wyników zapytania wspierają technologie (i specyfikacje) Hibernate, JPA, JDO, JDBC oraz Apache iBATIS Data Mapper. Jest to drugie z kryteriów porównania technologii trwałości danych, jakie tutaj prezentuję. Pierwszym było porównanie ze względu na wsparcie dla budowania dynamicznych zapytań. Wpierw, w artykule "Dynamiczne zapytania i selekcja poprzez przykład czyli Hibernate nokautuje JPA" porównałem Hibernate i JPA. Potem dodałem jeszcze wpis o iBATIS’ie w artykule "Dynamiczne zapytania z Apache iBATIS Data Mapper". Zapraszam do lektury, zwłaszcza, jeśli swoje wybory technologii lubisz opierać na konkretnych przesłankach.

Hibernate

Technologia Hibernate oferuje bardzo wygodny mechanizm stronicowania wyników zapytań. Mechanizm ten jest zaimplementowany na poziomie funkcji interfejsu programisty, nie na poziomie języka zapytań, stronicowanie rezultatu zapytania jest więc niezależne od treści samego zapytania. Fragment kodu pobierający drugą dziesiątkę zamówień (klasa Order) posortowanych po dacie złożenia zamówienia (atrybut placedDate) wygląda następująco:

Query query =
session.createQuery("FROM Order order ORDER BY order.placedDate");

query.setFirstResult(10);
query.setMaxResults(10);

List orders = query.list();

Uruchomienie powyższego kodu spowoduje wygenerowanie odpowiedniego zapytania SQL, uwzględniającego specyfikę danej bazy danych. Hibernate wspiera bardzo wiele relacyjnych baz danych i ich dialekty języka SQL.

JPA - Java Persistence API

Wsparcie dla stronicowania oferowane przez specyfikacje Java Persistence API jest niemal identyczne jak w przypadku technologii Hibernate. Fragment kodu pobierający drugą dziesiątkę zamówień (klasa Order) posortowanych po dacie złożenia zamówienia (atrybut placedDate) wygląda następująco:

Query query = eManager.createQuery
("SELECT order FROM Order order ORDER BY order.placedDate");

query.setFirstResult(10);
query.setMaxResults(10);

List orders = query.getResultList();

JDO - Java Data Objects

Technologia Java Data Objects wspiera stronicowanie wyników zapytania poprzez odpowiednie konstrukcje samego języka zapytań. Samo zapytanie może być wyrażone na dwa sposoby; w postaci pojedynczego obiektu klasy String definiującego jego treść lub poprzez ciąg wywołań odpowiednich funkcji interfejsu programisty. Stosując drugą z możliwości fragment kodu pobierający zamówienia (klasa Order) od 10. do 20. sortując po dacie złożenia zamówienia (atrybut placedDate) wygląda następująco:

Query query = pManager.newQuery(Order.class);

query.setOrdering("placedDate ASC");
query.setRange(10, 20);

Collection orders = (Collection) query.execute();

Możliwe jest także zdefiniowanie zakresu stronicowania jako zmiennych, a następnie wywoływanie wielokrotnie tego samego zapytania z podaniem różnych wartości. Analogiczna do powyższej funkcjonalność byłaby wtedy zaimplementowana następująco:

Query query = pManager.newQuery(Order.class);

query.setOrdering("placedDate ASC");
query.setRange(":1, :2");

Collection orders = (Collection) query.execute(10, 20);

Stosując podejście z zapytaniem wyrażonym jako pojedynczy napis analogiczny kod wygląda następująco:

Query query = pManager.newQuery
("SELECT FROM Order ORDER BY placedDate ASC RANGE :1, :2");

Collection orders = (Collection) query.execute(10, 20);

JDBC

Technologia JDBC służy do uruchamiania zapytań SQL i pobierania ewentualnych wyników tych zapytań. Zapytania są przekazywane do uruchomienia w niezmienionej formie, zatem technologia ta nie oferuje żadnych ułatwień dla implementacji stronicowania wyników zapytań. Elementy składni języka SQL służące do implementacji stronicowania nie są objęte odpowiednim standardem, tak więc zapytanie pobierające określony fragment normalnego wyniku może mieć różną postać w zależności od użytego systemu zarządzania bazą danych. Zapytanie SQL pobierające drugą dziesiątkę zamówień (z tabeli ORDER) posortowanych po dacie złożenia zamówienia (kolumna PLACED_DATE), zgodne ze składnią akceptowaną przez bazy danych MySQL i PostgreSQL wyglądałoby następująco:

SELECT * FROM ORDER
ORDER BY PLACED_DATE
LIMIT 10 OFFSET 10

Apache iBATIS Data Mapper

Technologia iBATIS Data Mapper nie generuje zapytań SQL a jedynie uruchamia te zdefiniowane przez programistę. Implementując stronicowanie trzeba więc posługiwać się takimi samymi metodami jak w przypadku użycia JDBC. Ograniczanie zakresu wyniku zapytania musi być zaimplementowane w zapytaniu SQL, w sposób właściwy dla używanej bazy danych.

4 komentarze:

Wojciech pisze...

Niestety setMaxResult w Hibernate nie jest idealne. Zdarzyły mi sie sytuacje, kiedy chciałem pobrać obiekt razem z jego relacjami za pomocą jednego zapytania generowanego przez Hibernate i pageowanie na poziomie zapytania zwracało nieprawidłowe wyniki (dołączanie na ślepo do zapytań polecenia 'Limit' czy 'rownum' nie zawze da spodziewane efekty).

Mariusz Lipiński pisze...

Dzięki za komentarz, oczywiście masz rację. Jak wszystkiego Hibernate również trzeba używać rozważnie, nie na ślepo. Pobieranie obiektów wraz z relacjami w jednym zapytaniu powoduje wygenerowanie zapytania SQL, które przeważnie ma więcej wierszy niż wynosi liczba wynikowych obiektów (jest to podzbiór iloczynu kartezjańskiego używanych tabel). Wywołanie setMaxResults() powoduje doklejenie odpowiednich konstrukcji do tegoż zapytania SQL, np. LIMIT dla MySQL i rzeczywiście ogranicza liczbę wierszy będących wynikiem zapytania SQL a nie HQL. Możemy więc czasami dostać w takim przypadku mniej obiektów niż byśmy się spodziewali. Ale czy można oczekiwać, że działanie będzie inne? Mechanizm ten użyty dobrze działa tak jak byśmy chcieli a przecież w tym wszystkim chodzi o to, aby stronicowanie działało efektywnie. Rozwiązaniem jest oczywiście pobieranie relacji w oddzielnych zapytaniach - być może Hibernate powinien tak właśnie robić, tzn. ignorować nasze ustawienie wymuszające pobieranie wszystkiego w jednym zapytaniu, jeśli używamy setMaxResults(). Nie zastanawiałem się nad tym wcześniej, więc nie wiem na ile jest to możliwe/łatwe do zaimplementowania i czy przypadkiem nie zostało już zaimplementowane w którejś z nowszych niż używana przez ciebie wersji. Alternatywą, jeśli chcemy pozostać przy jednym zapytaniu jest ręczne przewijanie otwartego kursora i pobieranie z niego odpowiedniej liczby obiektów.

Mariusz Lipiński pisze...

Uruchomiłem właśnie dla testu starą aplikację używającą TopLink Essentials (JPA) i ku mojemu zdumieniu w generowanych zapytaniach SQL nie pojawia się słówko kluczowe LIMIT ani nic w tym rodzaju, mimo że użyta jest również funkcja setMaxResults(). Czyżby TopLink implementował to właśnie poprzez przewijanie kursora i pobieranie kolejnych obiektów aż nazbiera się ich wystarczająca ilość? Intrygujące. Wie ktoś coś więcej na ten temat?

Mariusz Lipiński pisze...

Fajnie jest rozmawiać z samym sobą - tymniemniej, temat jest interesujący i dałem się wciągnąć, tym bardziej że przy okazji przećwiczyłem pare innych zagadnień które od dawna mnie nurtowały. Po pierwsze, wiem już jak działa w tym względzie TopLink (a dokładnie TopLink Essentials 2.0.1-b08-fcs z dnia 11/08/2007) - pobawiłem się troche debuggerem. Aby ustawić offset, czyli odpowiednik metody setFirstResult() TopLink wywołuje metodę absolute() na obiekcie klasy java.sql.ResultSet - powoduje to, że wyniki pobierane są począwszy od pewnego wiersza rezultatu zapytania SQL. Aby ustawić limit na liczbę wyników wywołuje setMaxRows() na obiekcie java.sql.Statement z wartością podaną dla setFirstResult() + setMaxResults(). Wniosek jest taki, że działa to również na poziomie zapytań SQL, a więc ogranicza liczbę wyników SQL a nie JPAQL. Ale uwaga, przeprowadziłem też testy z Hibernate i okazuje się, że w testowanej przezemnie wersji (Hibernate Core 3.2.4.sp1 i Hibernate Annotations 3.3.0.GA) setMaxResults działa jednak zgodnie z oczekiwaniami nawet gdy pobieramy obiekty również z relacjami. Jak to Hibernate robi? Niestety sądząc na podstawie ostrzeżenia jakie dostałem na konsoli słabo, czyli w pamięci a nie w bazie danych. Oto owe ostrzeżenie:

"WARNING: firstResult/maxResults specified with collection fetch; applying in memory!"

Dla uzupełnienia wywodu przytoczę jeszcze akapit ze strony http://www.hibernate.org/117.html:

"It should be also obvious why resultset row-based "limit" operations, such as setFirstResult(5) and setMaxResults(10) do not work with these kind of eager fetch queries. If you limit the resultset to a certain number of rows, you cut off data randomly. One day Hibernate might be smart enough to know that if you call setFirstResult() or setMaxResults() it should not use a join, but a second SQL SELECT. Try it, your version of Hibernate might already be smart enough. If not, write two queries, one for limiting stuff, the other for eager fetching."

Na razie to tyle. Jak mi starczy energii to napisze więcej na ten temat w kolejnym artykule.