Note | 前端項目中AWS AppSync的GraphQL API連結

2022 OCT 30

最近在做一個需要用到AWS AppSync的GraphQL API的專案,因為做之前甚至沒有聽說過GraphQL,前前後後受折磨好幾週。期間東問西問各種前端前輩,感覺做過GraphQL相關的人完全沒幾個,所以這篇文章來總結一下這邪物。

什麼是GraphQL

GraphQL是一種為API設計的數據查詢或修改的語言,使client端能夠使用更直觀和彈性的語法來取得或修改數據。其思想有點類似SQL,使用者通過特定的語法來操作(類似於SELECTUPDATE)。

GraphQL API提供一個Schema,client只需要屬於符合Schema的Query,就可以獲得所需的數據。如下例:

typescriptCopied!
// Schema type Query { hello: String! }
graphqlCopied!
# Query query { hello }
jsonCopied!
// Response { "data": { "hello": "world" } }

如果是更複雜的結構:

typescriptCopied!
// Schema type User { id: Number, name: String } type Query { me: User }
graphqlCopied!
# Query query { me { id name } }
jsonCopied!
// Response { "data": { "me": { "id": 1, "name": "Harry Potter" } } }

由此可見,相比普通的RESTful API,GraphQL進行數據獲取的時候,對於服務器端返回的JSON數據有更直觀的表現。

對於前端,GraphQL API有以下優點:

  1. 數據可以按需獲取。不需要的property不寫在Query中,就不會從服務器端返回;
  2. 代碼即文檔,一些接口樣式的反覆確認可以被省去(求求後端的朋友們好好起變量名);
  3. 基本可以省去編寫Swagger的時間,前端直接拿一個URL和一個Schema合集就行了;
  4. Query與返回數據的結構相同,所以前端可以避免很多數據結構錯誤。

和RESTful API有什麼區別?

在典型的RESTful API場景中,請求和響應如下所示:

typescriptCopied!
// requests GET /api/user?id=1 GET /api/address?user_id=1
jsonCopied!
// responses { "id": 1, "name": "Harry Potter" }, { "street": "Diagon Alley", "city": "London" }

如上面的示例代碼所示,上述請求和響應在GraphQL的情境下一般如下所示:

typescriptCopied!
// request POST /graphql query { user (id: 1) { id name address { street city } } }
jsonCopied!
{ "user": { "id": 1, "name": "Harry Potter", "address": { "street": "Diagon Alley", "city": "London" } } }

從這個例子可以看出,GraphQL API和RESTful API有一個很大的不同點在於,所有的API都用同一個URL,也就是向單一端點API發布查詢(所以是POST)。如果將API返回的數據視為圖表,那麼發布的一個查詢可以一次性查閱相關數據,如上例,用一次查詢獲得了RESTful API兩次查詢獲得的數據內容。

對於響應狀態碼,RESTful和GraphQL也有很大區別,RESTful API返回各式各樣的狀態碼,但是GraphQL無論是成功的請求或是失敗的請求都返回200,錯誤內容則體現在響應體中。

API請求方式上,RESTful和GraphQL有以下區別:

請求方式RESTfulGraphQL
數據取得GETQuery
數據插入POSTMutation
數據更新PUT/PATCH/DELETEMutation
數據監視/訂閱-Subscription

訂閱(Subscription)

Query和Mutation很好理解,GraphQL比RESTful多出一個數據監視功能,也就是Subscription。介紹GraphQL的Subscription之前,先看看實時API分為哪些種類:

  1. Polling。客戶端定期發送請求來獲得新數據,這樣的操作很不靈活,很不適合更新不定時,頻率不固定的數據;
  2. Event based subscription。客戶端(可以是多個)進行數據操作的時候通知服務端,服務端接到數據操作之後,以此為觸發點,通知所有的客戶端。這樣的方法需要提前定義通知客戶端的方法;
  3. Live query。客戶端發送請求,響應數據有變化的時候,服務端將新的數據推送給客戶端。和訂閱的主要區別在於,數據本身是實時的,不存在基於事件的概念。

GraphQL的訂閱API是基於事件驅動的,具體來講就是基於Mutation。任何一個客戶端通過Mutation修改了數據,就會觸發數據推送。

以以下訂閱的Schema為例:

graphqlCopied!
type Subscription { subscribeUserStatus(userId: String!): UserStatus (mutations: ["updateUserStatus", "updateUserOrders"]) }

這是一個叫做subscribeUserStatus的訂閱類型,其返回類型是其他地方定義的UserStatus對象。這個訂閱的觸發器有兩個,分別是updateUserStatusupdateUserOrders兩個mutation方法。也就是,只要任意客戶端通過這兩個mutation修改了數據,subscribeUserStatus就會將新的數據推送給所有客戶端。

AWS AppSync相關

本文不涉及後端開發,所以AppSync就簡單介紹一下。

AWS AppSync 是一項無服務器(Serverless)GraphQL和Pub/Sub API服務,可簡化現代化 Web 和移動應用程序的構建過程。AWS AppSync GraphQL API通過提供一个終端節點来安全地查詢或更新来自多个數據庫、微服務和API的數據,从而簡化應用程序開發。

也就是說,AWS AppSync提供僅僅一個終端節點(Endpoint),讓客戶端可以通過這一個終端節點訪問多個數據庫、API和其他的後端系統的數據。

實裝的故事

從這裡開始講講實際項目中的API連結以及我踩的各種各樣的坑。

先介紹一下項目背景。

這個項目是在公司自己做的CMS package上擴張而成的一個內容管理系統,基本開發是php(這一趴與我無關),各種各樣的組件開發是用Vue,項目中有兩個完全用Vue開發的頁面,其中一個用於內容展示(RESTful API),我開發的這一個類似於銀行的叫號管理系統,工作人員可以切換窗口,切換業務類型查看,並且可以進行叫號或者跳過等操作,最重要的是這個系統允許多個工作人員同時操作,為了保持整理券信息的一致性,需要使用實時API來獲得整理券信息的更新。後端的朋友在這裡用了AWS AppSync來開發部署GraphQL API,其中各種各樣的信息訂閱就用到其Subscription API。

  • Vue.js: 3.2 (Global API)
  • Node.js: 14.20 (Security support ends by April 2023)

用Apollo怎麼失敗了?

Apollo是一個類似於AppSync的開發平台,可以用於開發部署GraphQL的API。Apollo也提供各種各樣的前端庫來方便客戶端開發,谷歌上搜GraphQL API連結幾乎都是用他家庫做的項目和教程,所以我也開始著手用Apollo的官方Vue庫來開發。

至少還順利的Query和Mutation實現

由於Query和Mutation與Subscription的原理不同,其請求幾乎可以理解為和RESTful API一樣,所以不同的host也不能搞出花樣來。這裡貼一個簡單的Query的例子:

javascriptCopied!
const apolloClient = new ApolloClient({ uri: 'https://rtapi.example.endpoint/graphql', headers: { 'X-Api-Key': 'some_api_key', }, cache: new InMemoryCache(), }); provideApolloClient(apolloClient); const { onResult } = useQuery( gql` query listData($someInput: String!) { listData(someInput: $someInput) { result { property1 property2 property3 property4 } statusCode } } `, { someInput: someInput.value, }, ); onResult((result) => { dataToDisplay.value = result.data.listData.result; });

基本實現方法是首先定義一個ApolloClient並且用provideApolloClient將其投入使用,再用useQuery方法進行數據請求。其中useQuery的返回值不僅有onResult,還有isLoading: Boolean用於判定數據是否已經到位,subscribeToMore用於後續的數據訂閱。

其中用到一個叫做graphql-tag的庫來進行query字符串的預處理(gql方法),它將把它包起來的GraphQL查詢字符串解析為標準GraphQL AST用於發送請求。

Subscription怎麼就搞不動了?

先說結論,失敗的原因是因為AppSync接收Subscription請求的形式和Apollo發送的請求形式不吻合。

AppSync接受的請求形式:

jsonCopied!
{ "id": id, "type": "start", "payload": { "data": { "query": query, "variables": { variable: variable }, }, "extensions": { authorization: { host: endpoint, "x-api-key": apikey, } }, } }

Apollo發送的請求形式:

jsonCopied!
{ "id": subscriptionId, "type": "start", "payload": { "variables": {}, "extensions": {}, "operationName": subscriptionName, "query": query } }

AppSync接受的请求形式中,本该直接出现query的地方多了一层data,结果就导致了UnsupportedOperation错误的发生:

jsonCopied!
{ "type": "error", "id": "", "payload": { "errors": [ { "errorType": "UnsupportedOperation", "message": "unknown not supported through the realtime channel" } ] } }

除此之外,AppSync還會返回start_ack信息,而這不在Apollo所使用的WebSocket方法的生命週期里,所以可以說兩者几乎是完全不適配。並且,向GraphQL端點發送請求時,需要在端點的字符串後自行處理加上轉換成base64的頭部(包括headerpayload)。由於gql是自動生成,並沒有找到可以自定義請求體結構的方法,所以這條路以失敗告終。

用Amplify來實現

其實最開始就應該用Amplify的,腸子都悔青了⋯⋯用Apollo來實現的感覺就像買了iPhone卻強行要用華為手錶一樣。但是很恐怖的是,網上關於只用Amplify庫而不在AWS上部署Amplify應用程序的文章太少,導致我大走彎路。

先講講是怎麼成功的。

Schema的自動取得等

首先按照AppSync Console的教程,先把GraphQL的Schema全部拉取下來。

shellCopied!
npm install -g @aws-amplify/cli amplify init amplify add codegen --apiId some_api_id amplify codegen

讓我直接暈死的點就在這裡,根據步驟會生成一大堆文件,甚至還會直接告訴你需要部署一個Amplify App在AWS。由於生成的大部分文件都在.gitignore裡,當下我就陷入思考:這麼多本地文件,部署的時候該怎麼辦??

其實到此為止,我們其實只需要用到Amplify cli幫忙取得的Schema文件,也就是./src/graphql中的文件,除此之外,還有一個叫做aws-exports.js的文件,當API KEY之類的過期或更新的時候,amplify codegen會重新幫我們生成這些文件。

.gitignore裡面被添加的那些本地配置文件通通都可以刪除,那些都是需要部署Amplify APP才需要用到的文件。

為了讓全局都可以使用這個GraphQL端點,首先在app.js裡進行配置:

javascriptCopied!
import Amplify from 'aws-amplify'; Amplify.configure({ aws_appsync_graphqlEndpoint: process.env.GRAPHQL_ENDPOINT || '', aws_appsync_authenticationType: 'API_KEY', aws_appsync_apiKey: process.env.APPSYNC_API_KEY || '', });

各大教程裡面都是這樣寫:Amplify.configure(aws_exports);,但如果我需要在部署的時候再多生成一個aws_exports.js文件,那需要很多額外設置,所以aws_appsync_graphqlEndpointaws_appsync_apiKey就交給環境變量來解決了。

API請求

設置結束之後,我們就可以在組件裡進行API請求了。比起Apollo,由於我們有自動生成的Schema文件(其實Apollo也可以使用提前定義的Schema,但是實在是太多我又很懶,所以沒弄),所以實現起來非常快。

首先,編寫這樣的方法:

javascriptCopied!
import { API, graphqlOperation } from 'aws-amplify'; import { listUserData } from '@/../src/graphql/queries'; const fetchUserData = async () => { await API.graphql( graphqlOperation(listUserData, { userGroupId: userGroupId.value, }), ).then((res) => { userData.value = res.data.listUserData.result.sort((a, b) => { return a.userId - b.userId; }); }); };

其中,像一般的promise一樣,用then()來指定有響應之後的操作。同理,mutation也用類似的方法實現:

javascriptCopied!
import { updateUserStatus } from '@/../src/graphql/mutations'; const mutateUserData = async () => { await API.graphql( graphqlOperation(updateTicketStatus, { input: { userId: userId.value, groupId: groupId, userStatus: userStatus, }, }), ) .then(() => { console.log('user status updated!'); }) .catch(() => { console.log('user status update failed!'); }); };

subscribe的實現方法也類似,不過需要加上subscribe()處理:

javascriptCopied!
import { subscribeUserData } from '@/../src/graphql/subscriptions'; const subscribeToUserData = async () => { await API.graphql( graphqlOperation(subscribeUserData, { userGroupId: userGroupId.value, }), ).subscribe({ next: (res) => { if (!res.value.data.subscribeUserData) return; const changedUser = res.value.data.subscribeUserData.result; const changedUserIndex = users.value?.findIndex( (user) => user.userId === changedUser.userId, ); if (changedUserIndex === -1) { users.value.push(changedUser); users.value.sort((a, b) => a.userId - b.userId); } else { users.value[changedUserIndex] = changedUser; } }, error: () => { console.log('error occurred!'); }, }); };

subscribe()方法中,next用於定義正常獲得更新數據之後的操作,error用於定義異常的處理。在上面的例子中,數據更新時只返回一個用戶的數據,所以在next的定義中找到有更新的用戶,並用新數據替換舊數據。

最後,為了讓數據獲取和訂閱的方法在插入組件樹的時候運行,把上面的fetchUserData()subscribeToUserData()放進onMounted()中:

javascriptCopied!
onMounted(async () => { await fetchUserData(); await subscribeToUserData(); });

這樣一來,數據獲取和訂閱就完成了。

實時監測WebSocket的連結狀況

按照項目需求,當網絡出現狀況,WebSocket的網絡連結狀況處於pending的時候,需要顯示網絡故障的文字。可是,網絡故障時,本身就不會返回新數據,所以不能在subscribe()error中定義行為。

這裡可以用到Amplify的Hub.listen()來實時監聽api的連結情況。

javascriptCopied!
import { Hub } from 'aws-amplify'; import { CONNECTION_STATE_CHANGE, ConnectionState } from '@aws-amplify/pubsub'; Hub.listen('api', async (data) => { if (data.payload.event === CONNECTION_STATE_CHANGE) { if ( data.payload.data.connectionState === ConnectionState.ConnectedPendingNetwork ) { shouldDisplayConnectionError.value = true; } else { shouldDisplayConnectionError.value = false; await fetchUserData(); } } });

在Hub監聽api的過程中,有各種各樣的事件數據返回,其中要提取的就是CONNECTION_STATE_CHANGE即網絡連結狀態改變事件,當事件發生時,事件本身作為觸發器觸發狀態的改變。

ConnectionState有以下狀態,可以結合實際需求和情境用於判定等。

markdownCopied!
- Connected - Connected and working with no issues. - ConnectedPendingDisconnect - The connection has no active subscriptions and is disconnecting. - ConnectedPendingKeepAlive - The connection is open, but has missed expected keep alive messages. - ConnectedPendingNetwork - The connection is open, but the network connection has been disrupted. When the network recovers, the connection will continue serving traffic. - Connecting - Attempting to connect. - ConnectionDisrupted - The connection is disrupted and the network is available. - ConnectionDisruptedPendingNetwork - The connection is disrupted and the network connection is unavailable. - Disconnected - Connection has no active subscriptions and is disconnecting.

おまけ - 突然不用API KEY,要用LAMBDA TOKEN來認證??知道這個消息的我的心路歷程

當我開開心心提出review請求的時候,突然被後端告知

我們不用API KEY而是用LAMBDA TOKEN來認證。 具體做法是用戶登錄之後會生成token(存儲在cookies中),而這個token就是用於認證的LAMBDA TOKEN

我打開Amplify.configure()的文檔,卻發現我可以把aws_appsync_authenticationType改成AWS_LAMBDA,卻沒有一個欄位讓我放下token值。

這時候救我命的是突然浮窗出現的graphqlOperation()的文檔,其方法簽名如下:

typescriptCopied!
export declare const graphqlOperation: ( query: any, variables?: {}, authToken?: string, userAgentSuffix?: string, ) => { query: any; variables: {}; authToken: string; userAgentSuffix: string; };

雖然要在訪問API的地方一個一個設置,但是在variable之後是可以直接加上authToken的。

由於API訪問分布在各個組件中,所以在每一個組件中取得一次cookies顯然不現實。我的做法是在app.js(Amplify.configure()的附近)從cookies中獲取一次token,再將其作為全局變量注入各個組件。

javascriptCopied!
import { useCookies } from 'vue3-cookies'; const { cookies } = useCookies(); const accessToken = cookies.get('access_token'); app.provide('accessToken', accessToken);

需要注意的是,在vue的全局API中如果用這樣的語句app.config.globalProperties.foo = 'bar';設置全局變量,在組件中是獲取不到的,需要用到inject()來注入。

在組件中取用的時候如下:

javascriptCopied!
import { inject } from 'vue'; const accessToken = inject('accessToken');

最後把獲取到的token放在graphqlOperation()的第三個參數就大功告成了!

javascriptCopied!
// ... await API.graphql( graphqlOperation( listUserData, { userGroupId: userGroupId.value, }, accessToken ) ).then((res) => { // ...

參考