Operacje wsadowe są bardzo częstym elementem w naszych systemach. Dlatego również i w Netflix DGS pojawił się mechanizm Data Loader. Ten bardziej zaawansowany element jest bardzo potężny i pozwala na efektywne pobieranie interesujących nas danych. Zobaczmy, jak działa.
Hey! W tym artykule starałem się dosyć dokładnie opisać całkiem zaawansowany mechanizm Data Loader w Netflix DGS od strony użytkowej. Liczę, że po przeczytaniu tego tekstu będzie on dla Ciebie trochę bardziej zrozumiały. Polecam czytanie z kubkiem herbaty lub kawy, bo może to chwilę zająć. I prośba… zostaw komentarz 😊
Zacznijmy od danych
Załóżmy teraz, że nasza klasa Project zawiera tym razem komentarze, które nie są już prostymi napisami. Mogą zawierać coś więcej. Wyglądałoby to mniej więcej tak:
public class Project {
private String id;
private String name;
private String description;
private List<Comment> comments = new ArrayList<>();
// ...
}
oraz:
public class Comment {
private String projectId;
private String id;
private String text;
public Comment(String projectId, String id, String text) {
this.projectId = projectId;
this.id = id;
this.text = text;
}
// ...
}
Po strukturze danych możemy się domyślić, że komentarze będziemy przechowywać oddzielnie, w innym miejscu niż projekty. Na to wskazywałoby pole projectId. Typowo, widać tutaj, że struktura może być przygotowana pod bazę danych z dwiema tabelami, np. projects i comments.
Data Loader i N+1 problem
W sekcji Async Data Fetching dokumentacji Netflix DGS możemy przeczytać, że Data Loader rozwiązuje problem N+1 zapytań.
Co to właściwie znaczy?
Wyobraźmy sobie przez chwilę prostą implementację pobierania komentarzy dla danego projektu:
public List<Comment> findByProjectId(String projectId) {
// ...
}
Wygląda prosto. Ale… no właśnie. Dla klienta usługi chcemy zwracać listę projektów i każdy z nich ma mieć listę własnych komentarzy.
Z prostego liczenia wynika, że jedno zapytanie do API (np. z UI) oznacza 1 pobranie listy projektów, a następnie, o bagatela, dla każdego projektu oddzielnie pobranie listy komentarzy.
Czyli, gdy na liście projektów będzie np. 100 elementów, to zapytań o komentarze będzie również 100 (dla każdego elementu osobno). Czyli łącznie 100+1 zapytań, np. do bazy danych.
Generalnie ten problem jest bardzo popularny. Jeśli zdarzyło Ci się programować np. z wykorzystaniem Hibernate, to bardzo prawdopodobne, że już się mogłeś z nim spotkać.
Czy można inaczej?
Tak. Dość naturalnym byłoby tutaj takie podejście: skoro otrzymałem listę 100 projektów, to nie chcę odpytywać teraz o komentarze pojedynczo. Chciałbym odpytać raz i dostać wszystkie komentarze dotyczące tych 100 projektów.
W kodzie wyglądałoby to mniej więcej następująco:
public List<Comment> loadByProjectIds(List<String> projectIds) {
// ...
}
A jeszcze lepiej w ten sposób:
public Map<String, List<Comment>> loadByProjectIds(Set<String> projectIds)
W tym ostatnim przypadku dla zadanego zbioru identyfikatorów projektów chcemy dostać mapę. Kluczem w niej będzie identyfikator pojedynczego projektu, a wartością lista komentarzy.
W ten sposób tylko jedno wywołanie tej metody będzie potrzebne, żeby uzyskać wszystkie interesujące nas dane o komentarzach.
Trochę implementacji
Do naszego magazynu na dane dodajmy teraz trochę komentarzy:
public class DATA {
// ...
public static final List<Comment> COMMENTS = List.of(
new Comment("1", UUID.randomUUID().toString(), "Lorem ipsum dolor 1"),
new Comment("1", UUID.randomUUID().toString(), "Lorem ipsum dolor 2"),
new Comment("1", UUID.randomUUID().toString(), "Lorem ipsum dolor 3"),
new Comment("1", UUID.randomUUID().toString(), "Lorem ipsum dolor 4"),
new Comment("3", UUID.randomUUID().toString(), "Lorem ipsum dolor 5"),
new Comment("3", UUID.randomUUID().toString(), "Lorem ipsum dolor 6")
);
}
I zaimplementujmy metodę, która pozwoli nam pobrać wszystkie komentarze dla zadanego zbioru identyfikatorów projektów:
@Component
public class CommentService {
public Map<String, List<Comment>> loadByProjectIds(Set<String> projectIds) {
return projectIds.stream()
.map(projectId -> DATA.COMMENTS.stream()
.filter(c -> Objects.equals(c.getProjectId(), projectId))
.collect(Collectors.toList()))
.flatMap(List::stream)
.collect(Collectors.groupingBy(Comment::getProjectId));
}
}
Dla innego kontekstu – gdybyśmy przeszli teraz na SQL to ten fragment kodu odpowiadałbym mniej więcej takiemu zapytaniu:
SELECT * FROM comments WHERE project_id IN (1, 2, 3, 4)
Netflix DGS z Data Loader
Ok, mamy już komponent CommentService. Teraz trzeba zastanowić się, jak mechanika Data Loader pozwala w Netflix DGS związać wszystkie dane.
Popatrzmy na kod implementacji naszego Data Loader.
@DgsComponent
public class CommentsForProjectLoader {
@Autowired
private CommentService commentService;
@DgsDataLoader(name = "comments")
MappedBatchLoader<String, List<Comment>> loader = projectIds ->
CompletableFuture.supplyAsync(() -> commentService.loadByProjectIds(projectIds));
}
Wygląda bardzo ciekawie. Prawda?
Okazuje się, że MappedBatchLoader jest właśnie takim interfejsem realizującym funkcje wczytywania danych jako Data Loader w Netflix DGS.
W tym miejscu implementacja została przygotowana w oparciu o wyrażenie lambda. Jest to możliwe, bo MappedBatchLoader jest interfejsem funkcyjnym. Warto zwrócić tutaj uwagę, że jest to interfejs przystosowany do działania asynchronicznie i zwracany typ CompletableFuture.
Ale to nie wszystko. Bo nasz Data Loader musi zostać jeszcze użyty. A miejscem tego użycia będzie nowy komponent:
@DgsComponent
public class CommentFetcher {
@DgsData(parentType = "Project", field = "comments")
public CompletableFuture<List<Comment>> comments(DataFetchingEnvironment dfe) {
DataLoader<String, List<Comment>> dataLoader = dfe.getDataLoader("comments");
Project project = dfe.getSource();
return dataLoader.load(project.id());
}
}
Deklarujemy w tym przypadku, że powyższy komponent będzie zajmował się rozwijaniem pola comments w ramach typu Project.
Czyli ilekroć DGS trafi na pole comments w obiekcie Project, wówczas posłuży się tym komponentem w celu pobrania danych.
Ile razy ten komponent zostanie wywołany dla 100 projektów? Dokładnie 100. Gdzie jest zatem te super zysk, o którym pisałem wcześniej?
Cała magia tkwi w połączenie komponentu CommentFetcher poprzez DataFetchingEnvironment i DataLoader.
Tak, CommentFetcher w tym przypadku będzie wywołany 100 razy.
Tyle też razy zostanie wywołana metoda na końcu, czyli dataLoader.load(project.id()). Uwaga! Ta metoda pobiera dane dla jednego klucza projektu. A przecież pamiętamy, że my implementowaliśmy metodę, która pobierała dane dla zbioru kluczy projektów!
I właśnie tę magię ukrywa przed nami Data Loader. Ten obiekt pozwala na zebranie zapytań o pojedyncze dane, jak lista komentarzy dla jednego określonego projektu. Następnie, gdy już je zbierze, wówczas dokonuje zapytania zbiorczego. Dlatego dane w takim przypadku zostaną pobrane jednorazowo przez metodę commentService.loadByProjectIds(projectIds).
I to jest właśnie ten zysk. Dzięki mechanice Data Loder jesteśmy w stanie oszczędzić, np. zapytań do bazy danych lub wywołań REST API.
Test końcowy
Możemy teraz sprawdzić, że na tak postawione zapytanie:
{
projects {
id,
name,
comments {
id,
text
}
}
}
nasz system odpowie mniej więcej tak:
{
"data": {
"projects": [
{
"id": "1",
"name": "GraphQL",
"comments": [
{
"id": "c127fceb-db63-4c3e-b0fd-18d2624a4c17",
"text": "Lorem ipsum dolor 1"
},
{
"id": "6308b0fc-5a72-408b-9b65-f84089812f19",
"text": "Lorem ipsum dolor 2"
},
{
"id": "ff60fb3c-94b2-47eb-8c4c-2baa3722d2ea",
"text": "Lorem ipsum dolor 3"
},
{
"id": "b432e43b-764c-420e-a63b-84f0554e9fce",
"text": "Lorem ipsum dolor 4"
}
]
},
{
"id": "2",
"name": "Netflix DGS",
"comments": null
},
{
"id": "3",
"name": "Spring Boot",
"comments": [
{
"id": "40054cdb-4170-4faf-82ae-d7f0bbba7be1",
"text": "Lorem ipsum dolor 5"
},
{
"id": "8007c13e-5ddb-4312-981a-ee7b8c9f442b",
"text": "Lorem ipsum dolor 6"
}
]
},
{
"id": "4",
"name": "Spring Framework",
"comments": null
}
]
}
}
Podsumowanie
Możemy zobaczyć w tym temacie jedną słabość. Bo, co by było, gdybyśmy nie mogli zaimplementować metody commentService.loadByProjectIds(projectIds). Na przykład, gdyby pobranie komentarzy dla pojedynczego projektu było zewnętrzna usługą, która nie miałaby możliwości odpytania wsadowego o kilka elementów na raz.
Prawdę mówiąc nic wielkiego by się nie stało. W tym miejscu trafilibyśmy na typowy problem wielu organizacji. I albo udałoby się dostosować projekt albo trzeba byłoby sobie radzić inaczej. W skrajnym przypadku odpytywać o komentarze dla każdego projektu pojedynczo.
Mam jednak nadzieję, że w tym miejscu widać wyraźnie, gdzie mechanizm Data Loader w Netflix DGS może się bardzo przydać.
Rzeczywiście, jest on trochę zawiły i potrzeba chwili czasu, żeby się do niego przyzwyczaić. Z drugiej jednak strony, pozwala na rozdzielenie pobierania danych wg potrzebnych nam strategii. Co jest zwykle pożądanym podejściem w naszych systemach.
Daj znać w komentarzu, czy już zaczęłaś/zacząłeś korzystać z Netflix DGS? Jestem bardzo ciekawy, jak szybko ta biblioteka będzie robić się popularna.
Fajny ten Netflix DGS. Tutaj znajdziesz więcej informacji o nim:
Nie mamy tutaj do czynienia z problemem N+1 ponieważ zapytań jest N, tyle ile wynosi ilość idków projektów. Ten problem byłby widoczny, gdyby komentarze były pobierane jako Lazy i iterowalibyśmy po nich mając raz już pobrany obiekt projektu. 1 zapytanie dla obiektu projektu + N dla komentarzy = N+1. Poza tym kod z DSG wygląda koszmarnie nieczytelnie 🙂
Hey. Dzięki za komentarz. Ten 1 jest to pierwsze zapytanie pobierające listę projektów. A dalej N zapytań, czyli dla każdego projektu o komentarze osobno. Mam nadzieję, że to wyjaśnia skąd N+1.