Categories
程式開發

我是如何解決一個基於GraphQL網站的擴展性問題?


人聲

Vocal是一個博客平台,內容廣泛,包羅萬象,甚至還有很多貓貓狗狗。

我是如何解決一個基於GraphQL網站的擴展性問題? 1

在Vocal發佈內容,作者可以獲得報酬。頁面的每一次點擊都會給作者一點酬勞,並且,作者還能接受其他用戶的捐款。一些專業人士用這個平台展示自己的作品,但對大多數用戶來說,這只是一個有趣的愛好,正好順便賺些零花錢。

我是如何解決一個基於GraphQL網站的擴展性問題? 2

據悉,Vocal的母公司是傑里克媒體,而網站的開發工作是與悉尼一家名為智庫的公司合作的。

背景

Thinkmill使用Next.js(基於React的Web框架)構建了一個網站,並與Keystone在MongoDB上提供的GraphQL API通信。 Keystone是基於GraphQL的無頭CMS庫:你可以在JavaScript中定義一個schema,將其連接到數據存儲,並獲取自動生成的GraphQL API以訪問數據。這是一個免費的開源軟件項目,由Thinkmill提供商業支持。

人聲V2

Vocal的初版贏得了關注。它吸引到喜歡自己的用戶群,並且不斷壯大,最後Jerrick Media請Thinkmill幫助開發V2版,並於去年9月成功發布。

V2版本主要涉及用戶界面和功能變更,本文就不提這些內容了。而我所做的事情是:讓新站點更加健壯,更具擴展能力。

數據庫遷移

Thinkmill在使用MongoDB for Vocal時遇到一些可擴展性問題,因此決定將Keystone升級到V5版,以利用新版加入的Postgres支持。

如果你在技術領域從業時間較長,可能還記得00年代末的“NoSQL”風潮。當時的宣傳:諸如Postgres之類的關係型(SQL)數據庫的可擴展性不如MongoDB這樣的“webscale”NoSQL數據庫。

從技術上來說,這是正確的,但是NoSQL數據庫的可擴展性來自各種可以有效處理的查詢。簡單的非關係型數據庫(例如文檔和鍵值數據庫)有自己的用武之地,但是當用作應用的通用後端時,應用通常在超出關係型數據庫理論上的擴展上限前就達到了數據庫的查詢能力極限。

Vocal的大多數數據庫查詢在MongoDB上都沒什麼問題,但是隨著時間的流逝,越來越多的查詢需要手動調整才能正常工作。

在技​​​​術需求方面,Vocal與Wikipedia很像,後者是世界上最大的網站之一。 Wikipedia使用的是MySQL(或更確切地說是它的分叉,MariaDB)。當然,需要一些關鍵的工程設計才能適配Vocal的場景,但我認為在可預見的將來,關係型數據庫不會成為Vocal擴展道路上的絆腳石。

我曾查過一個數據,託管的AWS RDS Postgres實例的成本不到舊的MongoDB實例的五分之一,但Postgres實例的CPU使用率仍低於10%,流量卻比舊站點更多。這主要是由於一些重要的查詢在文檔數據庫架構下向來效率低下所致。

遷移工作又是一個大話題,不過,基本上來說,一位Thinkmill開發人員使用MoSQL構建了一個ETL管道來完成繁重的任務。由於Keystone是一個FOSS項目,因此我也能夠為其GraphQL到SQL的映射貢獻一些性能改進。

對於這類問題,我推薦大家參考Markus Winand的SQL博客:使用Index Luke現代SQL。他的文筆很友好,非專業人士也能看得懂,同時提供了編寫快速高效的SQL所需的大部分理論知識。剩下的理論知識,你可以找一本不錯的數據庫性能主題的教程來學習。

平台

架構

Vocal的V1版是幾個Node.js應用的集合,它們運行在作為CDN的Cloudflare後面一個虛擬私有服務器(VPS)上。我推崇避免過度設計的理念,因此很喜歡這種架構。但到了V2版的開發工作開始時,很明顯Vocal的流量已經不是這種簡單架構能承受了的。處理高峰期的大量流量時,它給Thinkmiller的開發人員留的餘地太少了,並且難以在線上安全部署更新。

下面是V2版的新架構:

我是如何解決一個基於GraphQL網站的擴展性問題? 3

Vocal V2的架構。請求通過CDN到達AWS中的一個負載均衡器。負載均衡器將流量分配給兩個應用,“Platform”和“Website”。 “Platform”是一個Keystone應用,負責在Redis和Postgres中存儲數據。

基本上,兩個Node.js應用被複製並放在一個負載均衡器後面,就這麼簡單。在SRE工作生涯中,我經常見到有工程師設想出比這個複雜很多的可擴展架構,但是我遇到過比Vocal規模大幾個數量級的站點,這些站點仍然只是在數據庫後端的負載平衡器之後複製服務。認真思考下,如果平台架構需要隨著站點的成長而變得愈加複雜,那麼它的可擴展性就不是很高。

提升網站可擴展性的重點是解決許多阻礙擴展的實現細節。

如果流量繼續增長下去,Vocal的架構可能需要添加一些內容,但讓它變得更加複雜的主要原因是新功能。例如,如果(出於某種原因)將來Vocal需要處理實時地理空間數據,那會是和博客帖子需求完全不同的技術怪獸,那時候應該就會有很多架構更改了。

大站點架構中的大多數複雜性來源於功能的複雜性。

如果你不知道如何讓你的架構可擴展,我的建議是讓它盡可能簡單明了。修復非常簡單的架構要比修復非常複雜的架構容易得多,也便宜得多。另外,過於復雜的架構更易出錯,並且這些錯誤也更難以調試。

順便說一句,Vocal碰巧被分成兩個應用,但這並不重要。一個常見的擴展誤區是,以可擴展性的名義過早將應用拆分為一些較小的服務,但拆分應用的位置選錯了,從而導致了更多的可擴展性問題。 Vocal作為單體應用擴展起來是沒問題的,不過拆分的位置選得也挺好。

基礎設施

Thinkmill有一些開發人員擁有AWS的使用經驗,但它主要是一家開發公司,所以在部署新版時需要“搭把手”。我最終在AWS Fargate上部署了新版Vocal,這是Elastic Container Service(ECS)一個相對較新的後端。

在過去,許多人希望ECS成為一種簡單的“將Docker容器作為託管服務運行”的產品,結果發現他們還是要構建和管理自己的服務器集群,於是大失所望。借助ECS Fargate,AWS可以管理集群。它支持運行Docker容器,並具有一些好用的基本功能,例如復制、運行狀況檢查、滾動更新、自動縮放和簡單警報等。

一個很好的替代選項是像App Engine或Heroku這樣的託管平台即服務(PaaS)。 Thinkmill已經將它們用在一些簡單的項目上,但其他項目需要更大的靈活性,所以還不能用它們。一些很大的站點也運行在PaaS上,但是Vocal規模已經大到了自定義雲部署足夠經濟的程度。

另一個明顯的選項是Kubernetes。 Kubernetes比ECS Fargate的功能更多,但價格也昂貴得多——包括資源開銷和維護所需的人員(例如常規節點升級)都更貴。一般來說,我不建議在沒有DevOps專職人員的環境下使用Kubernetes。 Fargate具有Vocal所需的功能,並讓Thinkmill和Jerrick Media專注於網站改進工作,不用操心基礎設施。

還有一個選項是“無服務器”函數產品,例如AWS Lambda或Google Cloud Functions。它們非常適合處理很少或毫無規律的流量,但是(正如我將解釋的那樣)ECS Fargate的自動縮放功能足以滿足Vocal的後端需求。這些產品的另一個優點是,它們使開發人員可以在雲環境中部署事物,而無需了解很多有關雲環境的知識。代價是無服務器產品與開發過程以及測試和調試過程緊密耦合。 Thinkmill內部已經擁有足夠的AWS專業知識來管理Fargate部署,並且只要知道如何製作Node.js Express HelloWorld應用的開發人員就可以應對Vocal的開發工作,而無需了解有關無服務器函數或Fargate的任何知識。

ECS Fargate的明顯缺點是供應商鎖定。但是,避免供應商鎖定就像避免停機一樣是一種折衷。如果你擔心遷移成本,那麼花在平台獨立性上的成本比遷移成本還多的話就沒意義了。 Vocal中特定於Fargate的代碼總數少於500行。

最重要的是,Vocal應用代碼本身與平台無關。它可以在普通的開發人員機器上運行,然後打包到一個Docker容器中,之後在幾乎所有可以支持Docker容器的地方運行,包括ECS Fargate。

Fargate的另一個缺點是設置並不簡單。像AWS中的大多數事物一樣,它涉及VPC、子網、IAM策略等概念。所幸這類事物是相當靜態的(不同於需要維護的服務器集群)。

構建可擴展的應用

如果你想輕鬆地運行規模巨大的應用,就有很多事情要做。遵循應用設計的十二要素原則是基礎,這裡不再贅述。

如果員工無法擴展運營的能力,那就沒有必要構建“可擴展”的系統了,這就像將噴氣發動機安裝在獨輪車上一樣。讓Vocal具備可擴展性的關鍵環節是設置諸如CI/CD和基礎架構即代碼之類的事物。同樣,一些部署理念會讓生產與開發環境大相徑庭,所以不值得採用它們。生產與開發間的每一個差異都會減慢應用的開發速度,並可能導致錯誤。

緩存

緩存是一個很大的主題。我之前的一個演講單挑出HTTP緩存講了一下,但這還不夠。本文中我會著重圍繞GraphQL來展開。

首先,一歌重要的警告:每當遇到性能問題時,你可能會想:“是否可以把這個值放入緩存以供將來重用,從而提升性能?”微基準測試幾乎總是會給你肯定的答案。但是,由於緩存一致性之類的問題,濫用緩存會讓你的整個系統變得更慢。下面是我使用緩存前要思考一遍的問題清單:

  • 問問自己是否真的需要通過緩存解決性能問題

  • 再好好思考一遍(非緩存的性能調優技術往往更穩健)

  • 問問自己是否可以改善現有緩存來解決問題

  • 如果其他所有方法均失敗,大概就可以添加新的緩存了

HTTP緩存系統是一直都在的,進而我們知道,在添加額外的緩存前應該設法充分利用HTTP緩存。

另一個很常見的陷阱是使用哈希映射或應用內部的某些內容進行緩存。它在本地開發中效果很好,但在大規模擴展時表現不佳。最好的辦法是使用一個顯式緩存庫,要支持Redis或Memcached之類的可插入後端。

基礎

HTTP規範中有兩種類型的緩存:私有緩存和公用緩存。私有緩存是指不與多個用戶共享數據的緩存,實際上是用戶的瀏覽器緩存。剩下的就是公共緩存,其中包括你所控制的服務器(例如CDN或Varnish或Nginx之類的服務器)和非託管服務器(代理)。在當今的HTTPS世界中,代理緩存很少見,但在某些公司網絡中也能見到。

我是如何解決一個基於GraphQL網站的擴展性問題? 4

緩存查找鍵通常基於URL,因此如果堅持使用“相同內容,相同URL;不同內容,不同URL”規則,緩存就不是什麼大問題。換句話說,為每個頁面提供一個canonical URL,並預防“聰明”的技巧從一個URL返回不同的內容。顯然,這對GraphQL API端點有影響。

你的服務器可以使用自定義配置,但是配置HTTP緩存的主要方法是在Web響應上設置HTTP標頭。最重要的標頭是cache-control。以下內容表示,該行下的所有緩存可能將頁面緩存最多3600秒(一小時):

cache-control: max-age=3600, public

對於用戶特定的頁面(例如用戶設置頁面),重要的是使用private換掉public,以告知公共緩存不要存儲響應,並將其提供給其他用戶。
另一個常見的標頭是vary。這會告訴緩存響應是基於URL以外的因素而變化的。 (它將HTTP標頭添加到URL旁的緩存鍵中)這是一個非常笨的工具,所以我建議盡量改用良好的URL結構,但它的一個重要用例是告訴瀏覽器響應依賴登錄cookie,以便它們在登錄/註銷時更新頁面。

vary: cookie

如果頁面可能會根據登錄狀態而變化,則你甚至在已註銷的公共版本上也需要cahce-control: private(和vary: cookie),以確保響應不會混淆。

其他有用的標頭包括etag和last-modified,這裡就不介紹了。你可能還會見到一些舊的標頭,例如expires和pragma: cache。早在1997年,HTTP/1.1就棄用它們了。

客戶端標頭

鮮為人知的是,HTTP規範允許在客戶端請求中使用cache-control,以減少緩存時間並獲得更新鮮的響應。

幸的是,瀏覽器似乎並未廣泛支持大於0的max-age,但如果你有時在更新後需要一個新的響應,則no-cache會很有用。

HTTP緩存和GraphQL

如上所述,常規緩存鍵是URL。但GraphQL API通常只使用一個端點(我們稱其為/api/)。如果希望一個GraphQL查詢是可緩存的,則需要這個查詢及其參數顯示在URL路徑中,例如/api/?query={user{id}}&variables={"x":99}(忽略URL轉義)。這裡的訣竅是將GraphQL客戶端配置為使用HTTP GET請求進行查詢(例如,為apollo-link-http設置useGETForQueries)。

突變是不能緩存的,因此它們仍需要使用HTTP POST請求。對於POST請求,緩存僅將/api/視為URL路徑,但緩存將完全拒絕緩存POST請求。請記住:GET用於非突變查詢,POST用於突變。在某些情況下,你可能希望避免在查詢裡用GET:因為查詢變量可能包含敏感信息。 URL有出現在日誌文件、瀏覽器歷史記錄和聊天通道中的習慣,因此在URL中留下敏感信息往往不是什麼好主意。無論如何,身份驗證之類的事情都應該作為不可緩存的突變來完成,因此這種情況很少見,但也應該記住。

不幸的是,這裡存在一個問題:GraphQL查詢往往比REST API URL大得多。如果你僅啟用基於GET的查詢,將獲得一些非常大的URL,可能大大超過了〜2000字節的限制,一些流行的瀏覽器和服務器是不接受它們的。一種解決方案是發送某種查詢ID,而不是發送整個查詢。 (類似於/api/?queryId=42&variables={"x":99}.)Apollo GraphQL服務器支持兩種方法來做這件事。

一種方法是從代碼中提取所有GraphQL查詢,並建立一個在服務端和客戶端共享的查找表。它的一個缺點是讓構建過程更加複雜,另一個缺點是它將客戶端項目耦合到服務器項目上,這與GraphQL的賣點相悖。還有一個缺點是,代碼的X版本可能會識別出一組與代碼的Y版本不同的查詢。這是一個問題,因為1)你的複制應用將在更新推出或回滾期間提供多個版本,並且2)客戶端可能會使用緩存的JavaScript,即使你升級或降級服務器也是如此。

另一種方法被Apollo GraphQL稱為自動持久查詢(APQ)。對於APQ,查詢ID是查詢的哈希。客戶端樂觀地向服務器發出請求,通過哈希引用查詢。如果服務器無法識別查詢,則客戶端會在POST請求中發送完整查詢。服務器通過哈希存儲該查詢,以便將來可以識別它。

我是如何解決一個基於GraphQL網站的擴展性問題? 5

HTTP緩存和Keystone5

如上所述,Vocal使用Keystone 5生成其GraphQL API,而Keystone 5與Apollo lGraphQL服務器配合使用。我們在實踐中如何設置緩存標頭?

Apollo支持GraphQL schema上的緩存提示。好在Apollo會收集查詢所涉及的所有內容的所有提示,然後自動計算適當的整體緩存標頭值。例如,考慮以下查詢:

query userAvatarUrl {
    authenticatedUser {
        name
        avatar_url
    }
}

如果name的最長期限為1天,而avatar_url的最長期限為1小時,則整個緩存的最長期限將是1小時,也就是最小的那個值。authenticatedUser取決於登錄cookie,因此它需要一個private提示,該提示會覆蓋其他字段上的public,因此生成的標頭將是cache-control: max-age=3600, private

我將緩存提示支持添加到Keystone列表和字段。下面是一個從文檔向待辦事項列表演示中的字段添加緩存提示的簡單示例:

const keystone = new Keystone({
  name: 'Keystone To-Do List',
  adapter: new MongooseAdapter(),
});
keystone.createList('Todo', {
  schemaDoc: 'A list of things which need to be done',
  fields: {
    name: {
      type: Text,
      schemaDoc: 'This is the thing you need to do',
      isRequired: true,
      cacheHint: {
        scope: 'PUBLIC',
        maxAge: 3600,
      },
    },
  },
});

另一個問題:CORS

跨域資源共享(CORS)規則與基於API的網站中的緩存會出現令人沮喪的衝突。

在深入探討問題細節前,先來看最簡單的解決方案:將主站點和API放在同一個域中。如果你的網站和API是從一個域提供的,則無需擔心CORS規則(但你可能要考慮限制Cookie)。如果你的API是專門用於這個網站的,那麼這就是最乾淨的解決方案,你可以愉快地跳過這一部分。

在Vocal V1中,網站(Next.js)和平台(Keystone GraphQL)應用位於不同的域(vocal.media和api.vocal.media)。為保護用戶免受惡意網站的侵害,現代瀏覽器不允許一個網站與另一個網站交互。因此,在允許vocal.media向api.vocal.media發出請求前,瀏覽器將對api.vocal.media進行“飛行前”檢查。這是一個使用OPTIONS方法的HTTP請求,該方法本質上會詢問資源的跨域共享是否可行。在飛行前檢查後,瀏覽器會發出一開始準備發出的正常請求。

“飛行前”檢查也有自己的問題,它們是針對每個URL的。瀏覽器針對每個URL發出新的OPTIONS請求,並且服務器響應會應用於這個URL。服務器不能說vocal.media是所有api.vocal.media請求的可信來源。當所有內容都是對一個api端點的POST請求時,這並不是一個嚴重的問題,但是在為每個查詢提供自己的GET-able URL之後,每個查詢都會遭遇飛行前檢查的延遲。

更令人沮喪的是,HTTP規範表示OPTIONS請求無法緩存,因此你會發現所有GraphQL數據都很好地緩存在用戶旁邊的CDN中,但是瀏覽器每次使用它時都得發出飛行前檢查請求,一路發到原始服務器那裡。

有幾種解決方案(如果你不能單純地使用一個共享域)。

如果你的API非常簡單,則可以使用CORS規則的例外

可以將某些緩存服務器配置為忽略HTTP規範,並始終緩存OPTIONS請求(例如,基於Varnish的緩存和AWS CloudFront)。它並不會達到完全避免飛行前檢查請求的性能水平,但總比默認設置要強。

另一個(確實很討巧的)選項是JSONP。當心:如果你配置不好,可能會捅出安全漏洞。

讓Vocal更好地利用緩存

在底層做好HTTP緩存工作後,我需要讓應用更好地利用它。

HTTP緩存的局限是它在響應級別上是沒有中間選項的。大多數響應都是可緩存的,但如果有一個字節不能緩存,那麼所有緩存都用不了了。

作為博客平台,大多數Vocal數據都是很容易緩存的;但是在舊站點中,由於右上角的一個菜單欄,幾乎沒有哪個頁面可以緩存。對於匿名用戶,菜單欄將顯示邀請用戶登錄或創建帳戶的鏈接。對於已登錄用戶,這個菜單會變為用戶頭像和個人資料菜單。由於頁面會根據用戶登錄狀態而顯示不同內容,因此無法將其緩存在CDN中。

我是如何解決一個基於GraphQL網站的擴展性問題? 6

Vocal的典型頁面。該頁面的大部分內容都是可輕鬆緩存的內容,但是在舊站點中,由於右上角有一個小菜單,因此實際上所有頁面都不可緩存。

這些頁面是由React組件的服務端渲染(SSR)生成的。解決方法是找出所有依賴登錄cookie的React組件,並迫使它們僅在客戶端延遲顯示。現在,服務器返回帶有佔位符的完全通用頁面,佔位符用於登錄菜單欄等內容。當頁面加載到用戶的瀏覽器中時,這些佔位符將通過調用GraphQL API在客戶端填充。通用頁面可以安全地緩存在CDN中。

這種技巧不僅可以提高緩存命中率,而且還能幫助改善人們心理上感知的頁面加載時間。黑屏甚至載入動畫都會讓我們感到不耐煩,但一旦出現第一塊內容,它就會讓我們分心數百毫秒。如果人們點擊社交媒體上的Vocal帖子鏈接,而主要內容立即從CDN發送過來,那麼很少有人會注意到,直到幾百毫秒之後某些組件才能完全互動。

順便說一句,為更快地將第一塊內容呈現在用戶面前,還有一個技巧是在生成SSR響應時對其進行流式處理,而不是等待整個頁面渲染完畢後再發送。不幸的是,Next.js還不支持這種方法

拆分響應以提高可緩存性的想法也適用於GraphQL。使用一個請求查詢多個數據的能力是GraphQL的典型優勢,但如果響應的不同部分具有不同的可緩存性,則將它們拆分開來會更好。舉一個簡單的例子,Vocal的分頁組件需要知道頁面數以及當前頁面的內容。最初,組件在一個查詢中獲取了這兩個組件,但是由於頁面總數在所有頁面中都是恆定的,因此我將其設為單獨的查詢,以便對其緩存。

緩存的好處

緩存的明顯好處是可以減少Vocal後端服務器上的負載。這當然很好,但是依靠緩存來增加容量是很危險的,因為當你終於有一天要刪除緩存時還是需要一個備份計劃。

改進的響應能力是啟用緩存的更好理由。

其他一些好處可能不太明顯。流量高峰往往是高度本地化的。如果擁有大量社交媒體關注者的某人分享了指向某個頁面的鏈接,Vocal將會獲得大量流量,但大部分流量只會訪問該頁面及其資產。這就是為什麼緩存擅長吸收最龐大的流量峰值,讓後端流量模式相對更平滑,更方便自動縮放來處理。

另一個好處是適度降級。即使後端由於某種原因陷入嚴重的麻煩,網站仍將通過CDN緩存為最受歡迎的部分提供服務。

其他性能改進

正如我經常說的,擴展的秘訣不是讓事情變得複雜,而是讓事情的複雜程度不超出需求,然後徹底修復所有阻止擴展的問題。

這裡有一個提示:對於分佈式系統中的調試難題,最困難的部分通常是獲取正確的數據以查看正在發生的狀況。很多時候,我陷入困境時只是在調來調去,靠猜想行事,而不是想著如何找到正確的數據。有時這是可行的,但它不適合處理棘手的問題。

一個相關的技巧是,你可以獲取系統中每個組件的實時數據(甚至只是tail -F下的日誌文件),把它們放在一個監視器中的幾個窗口中,然後在另一個監視器中點擊站點來看這些數據的變化,這樣可以學到很多東西。比如說:“為什麼切換這個複選框會在後端生成數十個數據庫查詢?”

這裡有一個解決方法的示例。某些頁面需要花費好幾秒的時間來渲染,但僅在部署環境中,且僅在SSR中才會這樣。監控儀表板沒有顯示任何CPU使用率高峰,並且應用沒有在使用磁盤,這暗示該應用可能正在等待網絡請求,也許正在等待後端。在開發環境中,我可以通過sysstat工具觀察該應用的工作情況,以記錄CPU/RAM/磁盤使用情況,以及Postgres語句日誌記錄和常規應用日誌。 Node.js支持使用bpftrace之類的工具來跟踪HTTP請求的探針,但因為一些很蠢的原因,它們在開發環境中不起作用,所以我在源代碼中找到了探針,並使用請求日誌記錄構建了自定義的Node.js構建。我使用tcpdump記錄網絡數據,結果發現了問題:對於網站提出的每個API請求,都在對Platform建立一個新的網絡連接。 (如果這個方法還是找不到原因的話,我想我會在應用中添加請求跟踪。)

網絡連接在本地計算機上速度很快,但在真實網絡上花費的時間無法忽略不計。設置加密連接(例如在生產環境中)花費的時間甚至更長。如果你要向一台服務器(例如API)發出大量請求,則應該保持連接的啟用狀態並儘量重用它。瀏覽器會自動執行此操作,但是默認情況下,Node.js不會啟用,因為它不知道你是否在發出更多請求。這就是為什麼問題僅在SSR中出現的原因。像許多漫長的調試會話一樣,最後的修復其實非常簡單:只需配置SSR即可保持連接活躍。較慢頁面的渲染時間大幅下降了。

如果你想了解更多關於此類內容的信息,我強烈建議你閱讀《高性能瀏覽器網絡手冊》(可在線免費閱讀),並遵循Brendan Gregg發布的指南

小結

實際上,我們還可以做很多事情來改善Vocal的性能,但我們並沒有全部做完。在初創公司進行SRE工作與在大公司作為永久僱員進行SRE工作,這兩者之間存在著很大的差異。我們有目標、預算和發布日期的約束,現在Vocal V2已經運行9個月,並且保持著健康的增長速度。

原文鏈接:

https://theartofmachinery.com/2020/06/29/scaling_a_graphql_site.html