แนวคิด ไอเดีย และกลยุทธ์
แนวคิด ไอเดีย และกลยุทธ์การออกแบบแอปพลิเคชันให้ทำงานกับ GraphQL เซิร์ฟเวอร์ที่แตกต่างกัน

การออกแบบแอปพลิเคชันให้ทำงานกับ GraphQL เซิร์ฟเวอร์ที่แตกต่างกัน

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

เราสามารถนำกลยุทธ์นี้ไปใช้กับ GraphQL ได้เช่นกัน GraphQL สามารถทำหน้าที่เป็นตัวกลางระหว่างแอปพลิเคชันกับเซิร์ฟเวอร์ ช่วยให้เราดำเนินการเปลี่ยนแปลงที่จำเป็นทั้งหมดบน GraphQL queries เท่านั้น โดยไม่ต้องแตะต้อง business logic

GraphQL query ทำหน้าที่เป็นอินเทอร์เฟซระหว่าง client และ server เมื่อรัน query GraphQL เซิร์ฟเวอร์จะประมวลผลและส่งคืนข้อมูลที่ต้องการให้ client ข้อมูลมาจากไหน? ได้มาอย่างไร? client ไม่รู้และไม่สนใจ

GraphQL query ทำหน้าที่เป็นอินเทอร์เฟซระหว่าง client และ server

การตอบสนองต่อ 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 categoriescategoriespostCategories
อาร์กิวเมนต์ฟิลด์สำหรับจำกัดจำนวนผลลัพธ์firstpagination.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 เท่านั้น ตามขั้นตอนเหล่านี้:

  1. รักษา GraphQL queries ให้แยกออกจากแอปพลิเคชัน
  2. ปรับชื่อฟิลด์ผ่าน aliases
  3. ปรับรูปร่างของการตอบสนองผ่านฟิลด์ 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 เซิร์ฟเวอร์หนึ่งไปยังอีกตัวหนึ่งได้อย่างง่ายดาย