การออกแบบแอปพลิเคชันให้ทำงานกับ GraphQL เซิร์ฟเวอร์ที่แตกต่างกัน
"การเขียนโค้ดต่ออินเทอร์เฟซ ไม่ใช่ต่อการใช้งานจริง" คือแนวปฏิบัติของการเรียกใช้ฟังก์ชันไม่ตรงๆ แต่ผ่านสัญญา (contract) ที่ระบุว่าต้องการ input อะไรและ output ที่คาดหวังคืออะไร โดยซ่อนรายละเอียดการใช้งานจริงไว้ กลยุทธ์นี้ช่วยแยกแอปพลิเคชันออกจากการใช้งาน ผู้ให้บริการ หรือสแต็กที่เฉพาะเจาะจง ทำให้สามารถสลับระหว่างกันได้โดยไม่ต้องเปลี่ยนโค้ดแอปพลิเคชัน
เราสามารถนำกลยุทธ์นี้ไปใช้กับ GraphQL ได้เช่นกัน GraphQL สามารถทำหน้าที่เป็นตัวกลางระหว่างแอปพลิเคชันกับเซิร์ฟเวอร์ ช่วยให้เราดำเนินการเปลี่ยนแปลงที่จำเป็นทั้งหมดบน GraphQL queries เท่านั้น โดยไม่ต้องแตะต้อง business logic
GraphQL query ทำหน้าที่เป็นอินเทอร์เฟซระหว่าง client และ server เมื่อรัน query GraphQL เซิร์ฟเวอร์จะประมวลผลและส่งคืนข้อมูลที่ต้องการให้ client ข้อมูลมาจากไหน? ได้มาอย่างไร? client ไม่รู้และไม่สนใจ

การตอบสนองต่อ query จะมีรูปร่างเหมือนกับ query สำหรับ GraphQL query นี้:
{
post(by: { id: 1 }) {
id
title
}
}...การตอบสนองจะเป็น:
{
"data": {
"post": {
"id": 1,
"title": "Hello world!"
}
}
}หาก query เดียวกันมีพารามิเตอร์ต่างกัน ข้อมูลที่ส่งคืนจะต่างกัน แต่รูปร่างจะคงเดิม ซึ่งหมายความว่าตราบใดที่ query ไม่เปลี่ยน แอปพลิเคชันไม่จำเป็นต้องเปลี่ยน logic ในการอ่านและประมวลผลข้อมูล และไม่สำคัญว่า GraphQL เซิร์ฟเวอร์ตัวไหนกำลังรัน query อยู่
ดังนั้นเราจึงสามารถสลับจาก GraphQL เซิร์ฟเวอร์หนึ่งไปยังอีกตัวหนึ่งได้อย่างราบรื่น
Queries ขึ้นอยู่กับ GraphQL schema
อย่างไรก็ตาม ย่อหน้าสุดท้ายนั้นมองโลกในแง่ดีเกินไปเล็กน้อย เพราะ GraphQL query อาจต้องเปลี่ยนแปลงขึ้นอยู่กับ GraphQL เซิร์ฟเวอร์ที่ใช้ พูดให้ชัดขึ้นคือ query นั้นอ้างอิงกับ GraphQL schema และหากเซิร์ฟเวอร์ต่างกันเปิดเผย schema ต่างกัน query ก็จะแตกต่างกันด้วย
ตัวอย่างเช่น GraphQL เซิร์ฟเวอร์ที่ใช้ Cursor Connections Specification อาจรัน query ดังนี้:
{
categories(first: 10000) {
edges {
node {
categoryId
description
id
name
slug
}
}
}
}และเซิร์ฟเวอร์อีกตัวที่ใช้ pagination แบบ WordPress (เช่น Gato GraphQL) จะรัน query เดียวกันแบบนี้:
{
postCategories(pagination: { limit: 10000 }) {
id
description
globalID
name
slug
}
}เราสามารถเห็นความแตกต่างระหว่าง queries ทั้งสอง:
| คุณสมบัติ | เซิร์ฟเวอร์ #1 | เซิร์ฟเวอร์ #2 |
|---|---|---|
| ฟิลด์ post categories | categories | postCategories |
| อาร์กิวเมนต์ฟิลด์สำหรับจำกัดจำนวนผลลัพธ์ | first | pagination.limit |
ฟิลด์ id ของออบเจกต์แทนความหมายว่า | ID เฉพาะทั่วโลก | ID เฉพาะสำหรับประเภทนั้น |
| รูปร่างของ query | ลึกกว่าเพราะมี edges.node | แบนกว่า |
การเปลี่ยน query จากเซิร์ฟเวอร์แรกด้วย query ที่เทียบเท่าจากเซิร์ฟเวอร์ที่สองในแอปพลิเคชันเพียงอย่างเดียวไม่ได้ผล เพราะ logic ยังคงเข้าถึงข้อมูลจากการตอบสนองตามรูปร่างและฟิลด์ของ query เดิม
วิธีแก้ปัญหาที่เป็นไปได้คือการเปลี่ยน logic สำหรับดึงข้อมูลใน client ด้วย ตัวอย่างเช่น logic ต่อไปนี้:
const categories = data?.data.categories.edges.map(({ node = {} }) => node);...สามารถเปลี่ยนเป็นแบบนี้:
const categories = data?.data.postCategories;แต่นั่นคือสิ่งที่เราต้องการหลีกเลี่ยง เราต้องการให้การเปลี่ยนแปลงน้อยที่สุด โดยแก้ไขเฉพาะอินเทอร์เฟซ (GraphQL query) และปล่อยให้ business logic ไม่ถูกแตะต้อง
โชคดีที่สามารถเชื่อมช่องว่างเหล่านี้ได้โดยแก้ไข GraphQL queries เท่านั้น ตามขั้นตอนเหล่านี้:
- รักษา GraphQL queries ให้แยกออกจากแอปพลิเคชัน
- ปรับชื่อฟิลด์ผ่าน aliases
- ปรับรูปร่างของการตอบสนองผ่านฟิลด์
self
มาดูกันว่าผ่าน 3 ขั้นตอนเหล่านี้ เราสามารถปรับแอปพลิเคชันให้ชี้ไปยัง GraphQL เซิร์ฟเวอร์ที่แตกต่างกันได้อย่างไร
รักษา GraphQL queries ให้แยกออกจากแอปพลิเคชัน
การแยก GraphQL queries ออกจาก application logic ต้องการ:
- จัดเก็บ GraphQL query แต่ละตัว (หรือกลุ่มของ queries) ในไฟล์แยกต่างหาก และทั้งหมดในโฟลเดอร์เฉพาะ
- export queries และ import เข้าไปในแอปพลิเคชัน
ตัวอย่างเช่น เราสามารถวาง GraphQL query ทุกตัวในไฟล์แยกต่างหากภายใต้ src/data และ export:
// file `src/data/categories.js`
export const QUERY_ALL_CATEGORIES = gql`
{
categories(first: 10000) {
edges {
node {
databaseId
description
id
name
slug
}
}
}
}
`;แอปพลิเคชันสามารถ import และใช้ GraphQL query:
import { QUERY_ALL_CATEGORIES } from 'data/categories';
export async function getAllCategories() {
const apolloClient = getApolloClient();
const data = await apolloClient.query({
query: QUERY_ALL_CATEGORIES,
});
const categories = data?.data.categories.edges.map(({ node = {} }) => node);
return {
categories,
};
}ด้วยการตั้งค่านี้ การแก้ไขทั้งหมดจะต้องดำเนินการกับไฟล์ภายใต้ src/data เท่านั้น
ปรับชื่อฟิลด์ผ่าน aliases
field alias สามารถใช้เปลี่ยนชื่อฟิลด์ในการตอบสนองจาก GraphQL เซิร์ฟเวอร์ที่สองให้เป็นชื่อของฟิลด์นั้นในเซิร์ฟเวอร์แรก
วิธีนี้ทำให้ฟิลด์ postCategories, id และ globalID สามารถดึงข้อมูลโดยใช้ชื่อที่แอปพลิเคชันคาดหวัง: categories, categoryId และ id ตามลำดับ:
{
categories: postCategories(pagination: { limit: 10000 }) {
categoryId: id
description
id: globalID
name
slug
}
}โปรดสังเกตว่าฟิลด์ categories มีอาร์กิวเมนต์ first ในขณะที่ฟิลด์ postCategories ที่สอดคล้องกันใช้อาร์กิวเมนต์ pagination.limit อย่างไรก็ตาม เนื่องจากอาร์กิวเมนต์ฟิลด์ไม่ได้ปรากฏในชื่อฟิลด์ในการตอบสนอง เราจึงไม่ต้องกังวลเรื่องนี้
ปรับรูปร่างของการตอบสนองผ่านฟิลด์ self
ความท้าทายสุดท้ายนั้นยุ่งยากกว่าเล็กน้อย: เราต้องปรับรูปร่างของการตอบสนอง โดยเพิ่มระดับพิเศษสำหรับ edges และ node ที่มาจาก Cursor Connections spec
เพื่อให้บรรลุสิ่งนี้ เราจะแนะนำฟิลด์ self ให้กับทุก type ใน GraphQL schema ซึ่งสะท้อนออบเจกต์เดิมที่มันถูกใช้คืนกลับมา:
type QueryRoot {
self: QueryRoot!
}
type Post {
self: Post!
}
type User {
self: User!
}ฟิลด์ self ช่วยให้สามารถเพิ่มระดับพิเศษให้กับ query โดยไม่ออกจากออบเจกต์ที่กำลัง query อยู่ การรัน query นี้:
{
__typename
self {
__typename
}
post(by: { id: 1 }) {
self {
id
__typename
}
}
user(by: { id: 1 }) {
self {
id
__typename
}
}
}...จะได้การตอบสนองนี้:
{
"data": {
"__typename": "QueryRoot",
"self": {
"__typename": "QueryRoot"
},
"post": {
"self": {
"id": 1,
"__typename": "Post"
}
},
"user": {
"self": {
"id": 1,
"__typename": "User"
}
}
}
}ตอนนี้เราสามารถใช้ self เพื่อเพิ่มระดับ nodes และ edge แบบเทียม:
{
categories: self {
edges: postCategories(pagination: { limit: 10000 }) {
node: self {
categoryId: id
description
id: globalID
name
slug
}
}
}
}ประเภทของออบเจกต์ใน GraphQL schema สำหรับ edges และ self นั้นแตกต่างกันอย่างชัดเจน แต่ไม่เป็นปัญหาสำหรับแอปพลิเคชัน เพราะมันไม่ได้โต้ตอบกับออบเจกต์จริงที่จำลองใน GraphQL เซิร์ฟเวอร์ แต่รับข้อมูลเป็น JSON object และข้อมูลสำหรับฟิลด์จากออบเจกต์ PostConnection หรือ Post ก็จะเหมือนกัน
โปรดสังเกตว่าฟิลด์ categories ถูก resolve ผ่าน self และ edges ถูก resolve ผ่าน postCategories ไม่ใช่ในทางกลับกัน เพื่อให้ cardinality ของ elements ที่ส่งคืนตรงกับที่กำหนดโดยฟิลด์ที่ใช้ Cursor Connections spec:
type RootQuery {
categories: RootQueryToCategoryConnection
}
type RootQueryToCategoryConnection {
edges: [RootQueryToCategoryConnectionEdge]
}
type RootQueryToCategoryConnectionEdge {
node: Category
}หาก GraphQL query ที่ปรับแล้วเป็นในทางกลับกัน (คือ query categories: postCategories และ edges: self) การเข้าถึงข้อมูลจะล้มเหลว เพราะ data.categories จะเป็น array ดังนั้น data.categories.edges จะเกิดข้อผิดพลาดเมื่อรัน:
const categories = data?.data.categories.edges.map(({ node = {} }) => node);ปรับ queries ทั้งหมด
หลังจากใช้กลยุทธ์เดียวกันกับ GraphQL queries ทั้งหมดใน src/data แอปพลิเคชันก็สามารถสลับจาก GraphQL เซิร์ฟเวอร์หนึ่งไปยังอีกตัวหนึ่งได้อย่างง่ายดาย