GraphQL Primer: ewolucja projektowania API (część 2)
Opublikowany: 2022-03-10W części 1 przyjrzeliśmy się, jak ewoluowały interfejsy API w ciągu ostatnich kilku dekad i jak każdy z nich ustąpił miejsca następnemu. Rozmawialiśmy również o niektórych szczególnych wadach używania REST do tworzenia klientów mobilnych. W tym artykule chcę przyjrzeć się, dokąd zmierza projektowanie API klienta mobilnego — ze szczególnym uwzględnieniem GraphQL.
Oczywiście istnieje wiele osób, firm i projektów, które przez lata próbowały zaradzić niedociągnięciom REST: HAL, Swagger/OpenAPI, OData JSON API i dziesiątki innych mniejszych lub wewnętrznych projektów, które starały się zaprowadzić porządek w pozbawiony specyfikacji świat REST. Zamiast brać świat takim, jaki jest i proponować stopniowe ulepszenia lub próbować zebrać wystarczająco dużo różnych elementów, aby zrobić z REST to, czego potrzebuję, chciałbym spróbować eksperymentu myślowego. Mając zrozumienie technik, które działały i nie działały w przeszłości, chciałbym wykorzystać dzisiejsze ograniczenia i nasze znacznie bardziej ekspresyjne języki, aby spróbować naszkicować API, którego potrzebujemy. Popracujmy z doświadczeniem deweloperskim wstecz, a nie implementacją do przodu (patrzę na SQL).
Minimalny ruch HTTP
Wiemy, że koszt każdego żądania sieciowego (HTTP/1) jest wysoki w kilku miarach, od opóźnienia po żywotność baterii. Idealnie byłoby, gdyby klienci naszego nowego interfejsu API potrzebowali sposobu, aby poprosić o wszystkie potrzebne dane w jak najmniejszej liczbie podróży w obie strony.
Minimalne ładunki
Wiemy również, że przeciętny klient ma ograniczone zasoby, pod względem przepustowości, procesora i pamięci, więc naszym celem powinno być wysyłanie tylko informacji, których potrzebuje nasz klient. Aby to zrobić, prawdopodobnie będziemy potrzebować sposobu, w jaki klient może poprosić o określone fragmenty danych.
Czytelny dla człowieka
Z czasów SOAP dowiedzieliśmy się, że interfejs API nie jest łatwy w interakcji, ludzie będą się skrzywić na jego wzmiankę. Zespoły inżynierskie chcą używać tych samych narzędzi, na których polegamy od lat, takich jak curl
, wget
i Charles
oraz karty sieciowej naszych przeglądarek.
Bogate w narzędzia
Kolejną rzeczą, której nauczyliśmy się z XML-RPC i SOAP, jest to, że w szczególności umowy klient/serwer i systemy typów są niezwykle przydatne. Jeśli to w ogóle możliwe, każdy nowy interfejs API miałby lekkość formatu, takiego jak JSON lub YAML, z możliwością introspekcji bardziej ustrukturyzowanych i bezpiecznych typów kontraktów.
Zachowanie lokalnego rozumowania
Z biegiem lat doszliśmy do porozumienia w sprawie pewnych przewodnich zasad dotyczących tego, jak organizować duże bazy kodów – głównym z nich jest „oddzielenie obaw”. Niestety w większości projektów ma to tendencję do rozkładania się w postaci scentralizowanej warstwy dostępu do danych. Jeśli to możliwe, różne części aplikacji powinny mieć możliwość zarządzania własnymi potrzebami w zakresie danych wraz z innymi funkcjami.
Ponieważ projektujemy interfejs API zorientowany na klienta, zacznijmy od tego, jak może wyglądać pobieranie danych w takim interfejsie API. Jeśli wiemy, że musimy wykonywać minimalne podróże w obie strony i że musimy być w stanie odfiltrować pola, których nie chcemy, potrzebujemy sposobu zarówno na przemierzanie dużych zestawów danych, jak i żądanie tylko tych części, które są przydatne dla nas. Wygląda na to, że język zapytań dobrze by tu pasował.
Nie musimy zadawać pytań o nasze dane w taki sam sposób, jak robisz to w przypadku bazy danych, więc imperatywny język, taki jak SQL, wydaje się niewłaściwym narzędziem. W rzeczywistości naszym głównym celem jest przemierzanie wcześniej istniejących relacji i ograniczanie pól, które powinniśmy być w stanie zrobić za pomocą czegoś stosunkowo prostego i deklaratywnego. Branża całkiem dobrze zadomowiła się w JSON dla danych niebinarnych, więc zacznijmy od deklaratywnego języka zapytań podobnego do JSON. Powinniśmy być w stanie opisać potrzebne nam dane, a serwer powinien zwrócić JSON zawierający te pola.
Deklaratywny język zapytań spełnia wymagania dotyczące zarówno minimalnych ładunków, jak i minimalnego ruchu HTTP, ale jest jeszcze jedna zaleta, która pomoże nam w realizacji innego z naszych celów projektowych. Wiele języków deklaratywnych, zapytań i innych, można efektywnie manipulować tak, jakby były danymi. Jeśli zaprojektujemy starannie, nasz język zapytań pozwoli programistom na rozdzielanie dużych żądań i ponowne łączenie ich w sposób, który ma sens dla ich projektu. Używanie takiego języka zapytań pomogłoby nam w osiągnięciu naszego ostatecznego celu, jakim jest zachowanie lokalnego rozumowania.
Istnieje wiele ekscytujących rzeczy, które możesz zrobić, gdy Twoje zapytania staną się „danymi”. Na przykład możesz przechwytywać wszystkie żądania i grupować je w sposób podobny do tego, w jaki wirtualny DOM grupuje aktualizacje DOM, możesz również użyć kompilatora do wyodrębnienia małych zapytań w czasie budowy w celu wstępnego buforowania danych lub możesz zbudować zaawansowany system pamięci podręcznej jak Apollo Cache.
Ostatnią pozycją na liście życzeń API jest oprzyrządowanie. Część z tego już uzyskujemy za pomocą języka zapytań, ale prawdziwa moc pojawia się, gdy połączysz go z systemem typów. Dzięki prostemu schematowi napisanemu na serwerze, istnieją prawie nieskończone możliwości bogatego oprzyrządowania. Zapytania mogą być analizowane statycznie i sprawdzane pod kątem kontraktu, integracje IDE mogą zapewniać wskazówki lub autouzupełnianie, kompilatory mogą przeprowadzać optymalizacje w czasie kompilacji zapytań lub wiele schematów można łączyć w celu utworzenia ciągłej powierzchni interfejsu API.
Projektowanie API, które łączy język zapytań i system typów, może wydawać się dramatyczną propozycją, ale ludzie eksperymentują z tym w różnych formach od lat. XML-RPC forsował wpisywanie odpowiedzi w połowie lat 90., a jego następca, SOAP, dominował przez lata! Niedawno pojawiły się takie rzeczy, jak abstrakcja MongoDB Meteor, Horyzont RethinkDB (RIP), niesamowity Falcor Netflix, którego używają na Netflix.com od lat, a ostatnio jest GraphQL Facebooka. Przez resztę tego eseju skupię się na GraphQL, ponieważ podczas gdy inne projekty, takie jak Falcor, robią podobne rzeczy, społeczność wydaje się faworyzować to w przeważającej mierze.
Co to jest GraphQL?
Po pierwsze muszę powiedzieć, że trochę skłamałem. API, które skonstruowaliśmy powyżej to GraphQL. GraphQL to tylko system typów dla twoich danych, język zapytań do ich przechodzenia - reszta to tylko szczegóły. W GraphQL opisujesz swoje dane jako wykres wzajemnych połączeń, a Twój klient prosi konkretnie o podzbiór danych, których potrzebuje. Dużo mówi się i pisze o wszystkich niesamowitych rzeczach, które umożliwia GraphQL, ale podstawowe koncepcje są bardzo łatwe do opanowania i nieskomplikowane.
Aby te koncepcje były bardziej konkretne i aby pomóc zilustrować, w jaki sposób GraphQL próbuje rozwiązać niektóre problemy z części 1, w pozostałej części tego wpisu zbudujemy API GraphQL, które może zasilać blog w części 1 tej serii. Zanim przejdziemy do kodu, należy pamiętać o kilku rzeczach dotyczących GraphQL.
GraphQL to specyfikacja (nie implementacja)
GraphQL to tylko specyfikacja. Definiuje system typów wraz z prostym językiem zapytań i to wszystko. Pierwszą rzeczą, która z tego wypada, jest to, że GraphQL nie jest w żaden sposób powiązany z konkretnym językiem. Istnieje ponad dwa tuziny implementacji we wszystkim, od Haskella do C++, z których JavaScript jest tylko jedną. Krótko po ogłoszeniu specyfikacji Facebook wydał implementację referencyjną w JavaScript, ale ponieważ nie używa jej wewnętrznie, implementacje w językach takich jak Go i Clojure mogą być jeszcze lepsze lub szybsze.
Specyfikacja GraphQL nie wspomina o klientach ani danych
Jeśli przeczytasz specyfikację, zauważysz, że dwie rzeczy są wyraźnie nieobecne. Po pierwsze, poza językiem zapytań nie ma wzmianki o integracjach z klientami. Narzędzia takie jak Apollo, Relay, Loka i tym podobne są możliwe dzięki projektowi GraphQL, ale w żaden sposób nie są one częścią ani nie są wymagane do korzystania z niego. Po drugie, nie ma wzmianki o żadnej konkretnej warstwie danych. Ten sam serwer GraphQL może i często pobiera dane z heterogenicznego zestawu źródeł. Może żądać danych z pamięci podręcznej z Redis, wyszukiwać adresy z interfejsu API USPS i wywoływać mikrousługi oparte na protobuffach, a klient nigdy nie dostrzeże różnicy.
Stopniowe ujawnianie złożoności
GraphQL dla wielu osób trafił na rzadkie skrzyżowanie mocy i prostoty. Wykonuje fantastyczną robotę, czyniąc proste rzeczy prostymi, a trudnymi możliwymi. Uruchomienie serwera i udostępnianie wpisywanych danych przez HTTP to zaledwie kilka linijek kodu w niemal każdym języku, jaki można sobie wyobrazić.
Na przykład serwer GraphQL może otoczyć istniejący interfejs API REST, a jego klienci mogą otrzymywać dane za pomocą zwykłych żądań GET, tak jak w przypadku interakcji z innymi usługami. Tutaj możesz zobaczyć demo. Lub, jeśli projekt wymaga bardziej wyrafinowanego zestawu narzędzi, możliwe jest użycie GraphQL do wykonywania takich rzeczy, jak uwierzytelnianie na poziomie pola, subskrypcje pub/sub lub wstępnie skompilowane/buforowane zapytania.
Przykładowa aplikacja
Celem tego przykładu jest zademonstrowanie mocy i prostoty GraphQL w ~70 liniach JavaScript, a nie napisanie obszernego samouczka. Nie będę wdawał się zbyt szczegółowo w składnię i semantykę, ale cały kod można uruchomić, a na końcu artykułu znajduje się link do wersji projektu do pobrania. Jeśli po przejściu przez to chcesz zagłębić się trochę głębiej, na moim blogu mam zbiór zasobów, które pomogą Ci zbudować większe, bardziej niezawodne usługi.
Do demonstracji będę używał JavaScript, ale kroki są bardzo podobne w każdym języku. Zacznijmy od przykładowych danych za pomocą niesamowitego Mocky.io.
Autorski
{ 9: { id: 9, name: "Eric Baer", company: "Formidable" }, ... }
Posty
[ { id: 17, author: "author/7", categories: [ "software engineering" ], publishdate: "2016/03/27 14:00", summary: "...", tags: [ "http/2", "interlock" ], title: "http/2 server push" }, ... ]
Pierwszym krokiem jest utworzenie nowego projektu z oprogramowaniem pośredniczącym express
i express-graphql
.
bash npm init -y && npm install --save graphql express express-graphql
Oraz utworzyć plik index.js
z serwerem ekspresowym.
const app = require("express")(); const PORT = 5000; app.listen(PORT, () => { console.log(`Server running at https://localhost:${PORT}`); });
Aby rozpocząć pracę z GraphQL możemy zacząć od modelowania danych w REST API. W nowym pliku o nazwie schema.js
dodaj:
const { GraphQLInt, GraphQLList, GraphQLObjectType, GraphQLSchema, GraphQLString } = require("graphql"); const Author = new GraphQLObjectType({ name: "Author", fields: { id: { type: GraphQLInt }, name: { type: GraphQLString }, company: { type: GraphQLString }, } }); const Post = new GraphQLObjectType({ name: "Post", fields: { id: { type: GraphQLInt }, author: { type: Author }, categories: { type: new GraphQLList(GraphQLString) }, publishDate: { type: GraphQLString }, summary: { type: GraphQLString }, tags: { type: new GraphQLList(GraphQLString) }, title: { type: GraphQLString } } }); const Blog = new GraphQLObjectType({ name: "Blog", fields: { posts: { type: new GraphQLList(Post) } } }); module.exports = new GraphQLSchema({ query: Blog });
Powyższy kod mapuje typy w odpowiedziach JSON naszego API na typy GraphQL. GraphQLObjectType
odpowiada Object
JavaScript , GraphQLString
odpowiada String
JavaScript i tak dalej. Jedynym specjalnym typem, na który należy zwrócić uwagę, jest GraphQLSchema
w ostatnich kilku wierszach. GraphQLSchema
to eksport z poziomu głównego wykresu GraphQL — punkt wyjścia dla zapytań do przechodzenia przez wykres. W tym podstawowym przykładzie definiujemy tylko query
; tutaj możesz zdefiniować mutacje (zapisy) i subskrypcje.
Następnie dodamy schemat do naszego ekspresowego serwera w index.js
. Aby to zrobić, dodamy oprogramowanie pośredniczące express-graphql
i przekażemy mu schemat.
const graphqlHttp = require("express-graphql"); const schema = require("./schema.js"); const app = require("express")(); const PORT = 5000; app.use(graphqlHttp({ schema, // Pretty Print the JSON response pretty: true, // Enable the GraphiQL dev tool graphiql: true })); app.listen(PORT, () => { console.log(`Server running at https://localhost:${PORT}`); });
W tym momencie, chociaż nie zwracamy żadnych danych, mamy działający serwer GraphQL, który udostępnia swój schemat klientom. Aby ułatwić uruchamianie aplikacji dodamy również skrypt startowy do package.json
.
"scripts": { "start": "nodemon index.js" },
Uruchomienie projektu i przejście do https://localhost:5000/ powinno pokazać eksplorator danych o nazwie GraphiQL. GraphiQL będzie ładowany domyślnie, o ile nagłówek HTTP Accept
nie jest ustawiony na application/json
. Wywołanie tego samego adresu URL za pomocą fetch
lub cURL
przy użyciu application/json
zwróci wynik JSON. Zapraszam do zabawy z wbudowaną dokumentacją i pisania zapytań.
Jedyne, co pozostało do zrobienia, aby ukończyć serwer, to podłączyć dane bazowe do schematu. Aby to zrobić, musimy zdefiniować funkcje resolve
. W GraphQL zapytanie jest uruchamiane od góry do dołu, wywołując funkcję resolve
podczas przechodzenia przez drzewo. Na przykład dla następującego zapytania:
query homepage { posts { title } }
GraphQL najpierw posts.resolve(parentData)
a następnie posts.title.resolve(parentData)
. Zacznijmy od zdefiniowania resolvera na naszej liście postów na blogu.
const Blog = new GraphQLObjectType({ name: "Blog", fields: { posts: { type: new GraphQLList(Post), resolve: () => { return fetch('https://www.mocky.io/v2/594a3ac810000053021aa3a7') .then((response) => response.json()) } } } });
Używam tutaj pakietu isomorphic-fetch
, aby wykonać żądanie HTTP, ponieważ ładnie pokazuje, jak zwrócić Promise z przelicznika, ale możesz użyć wszystkiego, co chcesz. Ta funkcja zwróci tablicę postów do typu Blog. Domyślną funkcją rozwiązywania dla implementacji GraphQL JavaScript jest parentData.<fieldName>
. Na przykład domyślnym resolverem dla pola Author's name będzie:
rawAuthorObject => rawAuthorObject.name
Ten pojedynczy program przesłaniający powinien dostarczyć dane dla całego obiektu postu. Nadal musimy zdefiniować przelicznik dla autora, ale jeśli uruchomisz zapytanie, aby pobrać dane potrzebne do strony głównej, powinieneś zobaczyć, jak działa.
Ponieważ atrybut autora w naszym API postów jest tylko identyfikatorem autora, gdy GraphQL szuka obiektu, który definiuje nazwę i firmę i znajduje String, zwróci po prostu null
. Aby podłączyć autora, musimy zmienić nasz schemat posta, aby wyglądał następująco:
const Post = new GraphQLObjectType({ name: "Post", fields: { id: { type: GraphQLInt }, author: { type: Author, resolve: (subTree) => { // Get the AuthorId from the post data const authorId = subTree.author.split("/")[1]; return fetch('https://www.mocky.io/v2/594a3bd21000006d021aa3ac') .then((response) => response.json()) .then(authors => authors[authorId]); } }, ... } });
Teraz mamy w pełni działający serwer GraphQL, który zawiera API REST. Pełne źródło można pobrać z tego linku Github lub uruchomić z tego startera GraphQL.
Być może zastanawiasz się nad narzędziami, których będziesz potrzebować do wykorzystania takiego punktu końcowego GraphQL. Istnieje wiele opcji, takich jak Relay i Apollo, ale na początek myślę, że proste podejście jest najlepsze. Jeśli dużo bawiłeś się GraphiQL, mogłeś zauważyć, że ma długi adres URL. Ten adres URL to tylko zakodowana w URI wersja zapytania. Aby zbudować zapytanie GraphQL w JavaScript, możesz zrobić coś takiego:
const homepageQuery = ` posts { title author { name } } `; const uriEncodedQuery = encodeURIComponent(homepageQuery); fetch(`https://localhost:5000/?query=${uriEncodedQuery}`);
Lub, jeśli chcesz, możesz skopiować i wkleić adres URL bezpośrednio z GraphiQL w następujący sposób:
https://localhost:5000/?query=query%20homepage%20%7B%0A%20%20posts%20%7B%0A%20%20%20%20title%0A%20%20%20%20author%20%7B%0A%20%20%20%20%20%20name%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D&operationName=homepage
Ponieważ mamy punkt końcowy GraphQL i sposób na jego użycie, możemy porównać go z naszym API RESTish. Kod, który musieliśmy napisać, aby pobrać nasze dane za pomocą interfejsu API RESTish, wyglądał tak:
Korzystanie z interfejsu API RESTish
const getPosts = () => fetch(`${API_ROOT}/posts`); const getPost = postId => fetch(`${API_ROOT}/post/${postId}`); const getAuthor = authorId => fetch(`${API_ROOT}/author/${postId}`); const getPostWithAuthor = post => { return getAuthor(post.author) .then(author => { return Object.assign({}, post, { author }) }) }; const getHomePageData = () => { return getPosts() .then(posts => { const postDetails = posts.map(getPostWithAuthor); return Promise.all(postDetails); }) };
Korzystanie z GraphQL API
const homepageQuery = ` posts { title author { name } } `; const uriEncodedQuery = encodeURIComponent(homepageQuery); fetch(`https://localhost:5000/?query=${uriEncodedQuery}`);
Podsumowując, wykorzystaliśmy GraphQL do:
- Zmniejsz dziewięć próśb (lista postów, cztery posty na blogu i autor każdego posta).
- Zmniejsz ilość przesyłanych danych o znaczny procent.
- Korzystaj z niesamowitych narzędzi programistycznych do tworzenia naszych zapytań.
- Napisz znacznie czystszy kod w naszym kliencie.
Błędy w GraphQL
Chociaż uważam, że szum jest uzasadniony, nie ma srebrnej kuli i tak wspaniały jak GraphQL, nie jest pozbawiony wad.
Integralność danych
GraphQL czasami wydaje się być narzędziem stworzonym specjalnie z myślą o dobrych danych. Często działa najlepiej jako rodzaj bramy, łącząc ze sobą różne usługi lub wysoce znormalizowane tabele. Jeśli dane, które wracają z usług, z których korzystasz, są nieuporządkowane i pozbawione struktury, dodanie potoku transformacji danych pod GraphQL może być prawdziwym wyzwaniem. Zakres funkcji rozwiązywania GraphQL to tylko jej własne dane i dane jej dzieci. Jeśli zadanie orkiestracji wymaga dostępu do danych rodzeństwa lub rodzica w drzewie, może to być szczególnie trudne.
Złożona obsługa błędów
Żądanie GraphQL może uruchomić dowolną liczbę zapytań, a każde zapytanie może trafić w dowolną liczbę usług. Jeśli jakakolwiek część żądania nie powiedzie się, a nie całe żądanie, GraphQL domyślnie zwraca częściowe dane. Dane częściowe są prawdopodobnie właściwym wyborem z technicznego punktu widzenia i mogą być niezwykle przydatne i wydajne. Wadą jest to, że obsługa błędów nie jest już tak prosta, jak sprawdzanie kodu statusu HTTP. To zachowanie można wyłączyć, ale najczęściej klienci mają bardziej wyrafinowane przypadki błędów.
Buforowanie
Chociaż często dobrym pomysłem jest użycie statycznych zapytań GraphQL, dla organizacji takich jak Github, które pozwalają na dowolne zapytania, buforowanie sieciowe za pomocą standardowych narzędzi, takich jak Varnish lub Fastly, nie będzie już możliwe.
Wysoki koszt procesora
Parsowanie, walidacja i sprawdzanie typu zapytania to proces związany z procesorem, który może prowadzić do problemów z wydajnością w językach jednowątkowych, takich jak JavaScript.
Jest to tylko problem w przypadku oceny zapytań w czasie wykonywania.
Myśli zamykające
Funkcje GraphQL nie są rewolucją — niektóre z nich istnieją już od prawie 30 lat. To, co sprawia, że GraphQL jest potężny, to to, że poziom dopracowania, integracji i łatwości użycia sprawia, że jest on czymś więcej niż tylko sumą jego części.
Wiele z rzeczy, które osiąga GraphQL, można, przy wysiłku i dyscyplinie, osiągnąć za pomocą REST lub RPC, ale GraphQL zapewnia najnowocześniejsze interfejsy API do ogromnej liczby projektów, które mogą nie mieć czasu, zasobów lub narzędzi, aby samemu to zrobić. Prawdą jest również, że GraphQL nie jest srebrną kulą, ale jego wady wydają się być niewielkie i dobrze rozumiane. Jako ktoś, kto zbudował dość skomplikowany serwer GraphQL, mogę śmiało powiedzieć, że korzyści z łatwością przewyższają koszty.
Ten esej skupia się prawie wyłącznie na przyczynach istnienia GraphQL i problemach, które rozwiązuje. Jeśli to wzbudziło Twoje zainteresowanie, aby dowiedzieć się więcej o jego semantyce i sposobach jej używania, zachęcam Cię do uczenia się w sposób, który najlepiej Ci odpowiada, niezależnie od tego, czy jest to blog, youtube, czy po prostu czytanie źródła (How To GraphQL jest szczególnie dobre).
Jeśli podobał Ci się ten artykuł (lub go nienawidziłeś) i chciałbyś przekazać mi swoją opinię, znajdź mnie na Twitterze jako @ebaerbaerbaer lub LinkedIn pod adresem ericjbaer.