บล็อก

👶🏻 การฟื้นฟู WordPress ผ่าน GraphQL

Leonardo Losoviz
โดย Leonardo Losoviz ·

WordPress คือ CMS แบบเดิม: ถูกสร้างขึ้นมากกว่า 17 ปีที่แล้ว เต็มไปด้วยโค้ด PHP ที่หากได้โอกาสใหม่ ก็คงจะเขียนในรูปแบบที่แตกต่างออกไป

GraphQL คืออินเทอร์เฟซสมัยใหม่สำหรับการเข้าถึงข้อมูล โปรดสังเกตคำว่า "อินเทอร์เฟซ": GraphQL ไม่สนใจว่าระบบข้อมูลภายในถูกนำไปใช้งานอย่างไร แต่สนใจเพียงวิธีการเปิดเผยข้อมูลเท่านั้น

จะเกิดอะไรขึ้นเมื่อเรานำทั้งสองอย่างมารวมกัน? เราควรออกแบบอินเทอร์เฟซ GraphQL สำหรับการเข้าถึงข้อมูลจาก WordPress อย่างไร?

มีกลยุทธ์ที่ชัดเจนอยู่สองแนวทาง:

  1. เคารพประเพณีเดิม และจัดทำการแมปที่คงโมเดลข้อมูล WordPress ไว้ตามเดิม รวมถึงหนี้ทางเทคนิคที่สะสมมาตลอดหลายปี

  2. แก้ไขหนี้ทางเทคนิค โดยจัดทำอินเทอร์เฟซที่เปิดเผยข้อมูลในรูปแบบนามธรรม ไม่ผูกติดกับ WordPress อย่างจำเป็น

ทั้งสองแนวทางต่างมีข้อดีและข้อเสีย และไม่มีอะไรถูกหรือผิด เป็นเพียงมุมมองส่วนตัวที่ให้ความสำคัญกับพฤติกรรมหนึ่งมากกว่าอีกพฤติกรรมหนึ่ง

สำหรับปลั๊กอิน Gato GraphQL ผมได้เลือกแนวทางที่สอง โดยพยายามสร้าง GraphQL schema ที่แม้จะอิงกับ WordPress และทำงานสำหรับ WordPress แต่ก็ไม่ผูกติดกับ WordPress (เช่น การลบชื่อและความสัมพันธ์ที่ไม่สอดคล้องกัน)

ผลลัพธ์คือ GraphQL ทำให้ WordPress กลับมาเยาว์วัย: แม้เรายังคงมี WordPress เป็น CMS พื้นฐาน พร้อมโค้ด PHP แบบเดิม แต่ชั้นข้อมูลสามารถสร้างขึ้นใหม่ได้ โดยอิงจากสามัญสำนึก ไม่ใช่ประเพณี ชั้นข้อมูลถอยกลับจากวัยรุ่น กลายเป็นเด็กเล็กอีกครั้ง

GraphQL + WordPress เข้ากันได้ดีเยี่ยม

ผลลัพธ์คือ GraphQL schema ที่แสดงถึงโมเดลข้อมูลของ WordPress และยังรองรับ nested mutations อีกด้วย

มาดูกันว่ามันถูกดำเนินการอย่างไร

โมเดลข้อมูลของ WordPress

WordPress มี entities ดังต่อไปนี้:

  • posts
  • pages
  • custom posts
  • องค์ประกอบสื่อ
  • users
  • user roles
  • tags
  • categories
  • comments
  • blocks
  • meta properties
  • อื่นๆ (options, plugins, themes เป็นต้น)

entities เหล่านี้สามารถมีลำดับชั้นได้ ตัวอย่างเช่น post, page และองค์ประกอบสื่อต่างก็เป็น custom post types และ tags กับ categories ต่างก็เป็น taxonomies

นี่คือแผนภาพฐานข้อมูล WordPress ที่แสดงให้เห็นว่าข้อมูลของทุก entity ถูกจัดเก็บไว้อย่างไร:

แผนภาพฐานข้อมูล WordPress

การแมปเป็นสำเนาแน่นอนของแผนภาพ DB หรือไม่?

เมื่อแมปฐานข้อมูล WordPress เป็น GraphQL schema จำเป็นต้องเคารพแผนภาพข้างต้นแบบ 1 ต่อ 1 หรือไม่?

ไม่ ไม่จำเป็น แผนภาพฐานข้อมูลคือการนำไปใช้งานจริง ในขณะที่ GraphQL คืออินเทอร์เฟซสำหรับเข้าถึงข้อมูลจากไคลเอนต์ ทั้งสองมีความเกี่ยวข้องกัน แต่สามารถแตกต่างกันได้ GraphQL ไม่สนใจฐานข้อมูล: มันไม่ได้คิดในรูปแบบคำสั่ง SQL หรือรู้ว่ามีตารางฐานข้อมูลชื่อ wp_posts และ wp_users

ดังนั้นเราไม่จำเป็นต้องกังวลมากเกินไปเกี่ยวกับแผนภาพฐานข้อมูลเมื่อสร้าง GraphQL schema สำหรับ WordPress นั่นหมายความว่าเราสามารถสร้าง GraphQL schema ที่แก้ไขหนี้ทางเทคนิคบางส่วนจากโมเดลข้อมูลของ WordPress ได้

การแมปโมเดลข้อมูล WordPress เป็น GraphQL schema

มาเริ่มทำการแมปกัน ขั้นแรก เราแมป entities เดิมเป็น types ให้มากที่สุดเท่าที่เป็นไปได้ จากรายการ entities ในโมเดลข้อมูล WordPress เราได้ types ต่อไปนี้สำหรับ GraphQL schema:

  • Post
  • Page
  • Media
  • User
  • UserRole
  • PostTag
  • PostCategory
  • Comment

จากนั้น เราเพิ่มฟิลด์ที่คาดหวังทั้งหมดให้กับทุก type เพื่อแสดง schema เราสามารถใช้ SDL หรือ Schema Definition Language ได้ (ใช้เพื่อจุดประสงค์ในการทำเอกสารเท่านั้น ปลั๊กอินเองไม่ได้ใช้ SDL ในการเขียนโค้ด schema: ทุกอย่างเป็นโค้ด PHP)

นี่คือฟิลด์ (ในบรรดาอีกหลายฟิลด์) สำหรับ Post:

type Post {
  id: ID!
  title: String
  content: String
  excerpt: String
  publishedAt: Date!
}

นี่คือฟิลด์ (ในบรรดาอีกหลายฟิลด์) สำหรับ User:

type User {
  id: ID!
  name: String
  email: String!
}

เรายังสร้าง connections ที่สอดคล้องกันด้วย ซึ่งเป็นฟิลด์ที่คืนค่า entity อื่น (แทนที่จะเป็น scalar เช่น ตัวเลขหรือสตริง) ตัวอย่างเช่น เราแสดงให้เห็นว่า post มีผู้เขียน และ user เป็นเจ้าของ posts:

type Post {
  author: User!
}
 
type User {
  posts: [Post]
}

ฟิลด์และ connections ยังสามารถรับ arguments ได้ด้วย ตัวอย่างเช่น เราเปิดใช้งาน Post.date ให้สามารถจัดรูปแบบได้ และ User.posts ให้สามารถค้นหารายการและจำกัดจำนวนได้:

type Post {
  date(format: String): Date!
}
 
type User {
  posts(limit: Int, search: String): [Post]
}

เราดำเนินการต่อเช่นนี้กับทุก entity ในโมเดลข้อมูล WordPress เมื่อเสร็จสิ้น เราจะได้ GraphQL schema สำหรับ WordPress ดังที่เห็นได้โดยใช้ Voyager client (มีให้ใช้งานในชื่อ "Interactive Schema" บนเมนูของปลั๊กอิน):

GraphQL schema สำหรับ WordPress

schema นี้มีความคล้ายคลึงกับแผนภาพฐานข้อมูล WordPress แต่ก็มีความแตกต่างมากมาย มาวิเคราะห์กัน

การดำเนินการที่ไม่มี entity จะถูกแมปเป็น Root fields

แผนภาพฐานข้อมูล WordPress แสดงถึงวิธีการจัดเก็บข้อมูล จึงไม่มี "จุดเริ่มต้น" แต่ GraphQL เป็นอินเทอร์เฟซสำหรับดึงข้อมูล จึงต้องมีขั้นตอนเริ่มต้นสำหรับการรัน query

ขั้นตอนเริ่มต้นนี้คือ type Root หรือพูดให้ถูกต้องกว่าคือ types QueryRoot และ MutationRoot (เพื่อจัดการกับ queries และ mutations ตามลำดับ)

ใน type ทั้งสองนี้ เราแมปการดำเนินการทั้งหมดที่ไม่ขึ้นอยู่กับ entity เช่น เมื่อรัน get_posts(), get_users() หรือ wp_signon():

type QueryRoot {
  posts: [Post]!
  users: [User]!
}
 
type MutationRoot {
  logUserIn(username: String, password: String): User
}

ฟิลด์ไม่จำเป็นต้องมีชื่อหรือ signature เหมือนกับการดำเนินการที่แทน ตัวอย่างเช่น การเรียกฟิลด์ logUserIn อาจถือว่าเหมาะสมกว่า signOn

mutations ทั้งหมดอยู่ภายใต้ MutationRoot

มีการดำเนินการบางอย่างที่ขึ้นอยู่กับ entity เช่น wp_update_post() ซึ่งถูกนำไปใช้กับ post บางรายการ mutation ที่สอดคล้องกันใน GraphQL schema ต้องถูกเพิ่มใน type MutationRoot เพราะนั่นคือวิธีการทำงานของ GraphQL

การดำเนินการนี้จึงถูกแมปดังนี้:

type MutationRoot {
  updatePost(input: {
    postID: ID!,
    newTitle: String,
    newContent: String
  }): Post
}

ปลั๊กอินนี้ยังรองรับ nested mutations ด้วย ซึ่งนำเสนอเป็นฟีเจอร์ opt-in (เพราะนี่ไม่ใช่พฤติกรรม GraphQL มาตรฐาน) ในกรณีนี้ mutations สามารถเพิ่มได้ภายใต้ type ใดก็ได้ ไม่ใช่แค่ MutationRoot ในกรณีนี้ เราได้:

type Post {
  update(input: {
    newTitle: String,
    newContent: String
  }): Post!
}

การจัดการกับ custom posts

ไม่มีการสืบทอด type ใน GraphQL ดังนั้น เราไม่สามารถมี type CustomPost และประกาศให้ Post และ Page สืบทอดจากมันได้

GraphQL มีสองวิธีในการชดเชยข้อจำกัดนี้: interfaces และ union types

สำหรับวิธีแรก เราสร้าง interface CustomPost สำหรับ schema โดยประกาศฟิลด์ทั้งหมดที่คาดหวังจาก custom post และกำหนดให้ types Post และ Page นำ interface ไปใช้:

interface CustomPost {
  title: String
  content: String
  excerpt: String
  date(format: String): Date!
}
 
type Post implements CustomPost {
  title: String
  content: String
  excerpt: String
  date(format: String): Date!
}
 
type Page implements CustomPost {
  title: String
  content: String
  excerpt: String
  date(format: String): Date!
}

สำหรับวิธีที่สอง เราสร้าง type CustomPostUnion สำหรับ schema ที่คืนค่า custom post types ทั้งหมด:

union CustomPostUnion = Post | Page

และมีฟิลด์ที่คืนค่า type นี้เมื่อเหมาะสม:

type QueryRoot {
  customPost(id: ID): CustomPostUnion
  customPosts: [CustomPostUnion]!
}
 
type User {
  customPosts: [CustomPostUnion]
}
 
type Comment {
  customPost: CustomPostUnion!
}

ดังที่สังเกตได้ ใน GraphQL schema เราจำเป็นต้องระบุอย่างชัดเจนว่าเรากำลังจัดการกับ posts หรือ custom posts เพราะทั้งสองไม่ใช่สิ่งเดียวกัน! การใช้สองสิ่งนี้แทนกันถือเป็นหนี้ทางเทคนิคจาก WordPress ซึ่งเราสามารถแก้ไขได้

ด้วยเหตุนี้ custom post จึงถูกเรียกว่า CustomPost ไม่ใช่ Post เสมอ ฟิลด์ที่จัดการกับ custom posts จะถูกเรียกว่า customPosts ไม่ใช่ posts เสมอ และ argument ของฟิลด์ที่รับ ID สำหรับ custom post จะถูกเรียกว่า customPostID ไม่ใช่ postID (แม้ว่านั่นคือชื่อที่ใช้ในฟังก์ชัน WordPress ที่แมปไว้)

ความคาดหวังจึงชัดเจนเสมอ:

  • ฟิลด์ User.customPosts สามารถคืนค่ารายการ custom post ใดก็ได้ รวมถึง posts และ pages และ User.posts จะคืนค่าเฉพาะ posts เท่านั้น
  • ฟิลด์ Root.setFeaturedImageOnCustomPost สามารถเพิ่มรูปภาพ featured ให้กับ custom post ใดก็ได้ นั่นคือเหตุผลที่ไม่เรียกว่า setFeaturedImageOnPost

ไม่รวม tags (และ categories) ไว้ใน type เดียว

ทำไม type PostTag (และเช่นเดียวกับ PostCategory) จึงถูกตั้งชื่อเช่นนั้น แทนที่จะเป็นแค่ Tag?

เพราะเมื่อรัน query นี้ (ที่ product เป็น CPT) ผลลัพธ์จากฟิลด์ tags ของ posts และ products จะแตกต่างกันเสมอ ไม่ทับซ้อนกัน:

query {
  posts {
    tags {
      id
      name
    }
  }
  products {
    tags {
      id
      name
    }
  }
}

Tags ที่เพิ่มใน posts จะไม่ปรากฏเมื่อดึง tags สำหรับ products และในทางกลับกัน (ยกเว้นกรณีที่ product ใช้ taxonomy post_tag ด้วย แต่ก็ยังสามารถแสดงด้วย type PostTag ได้) สิ่งนี้ไม่ใช่ปัญหาใหญ่ใน WordPress เพราะสามารถถือว่า items เหล่านี้เป็นแถวต่างๆ จากตารางฐานข้อมูลเดียวกัน แต่สำคัญมากสำหรับ GraphQL ซึ่งมีการกำหนด type อย่างเข้มแข็ง

ดังนั้น การแยก entities เหล่านี้ออกจากกัน ภายใต้ types ของตัวเอง จึงเป็นการออกแบบที่ดี และมี tags ของ posts ถูกคืนค่าภายใต้ type PostTag และหากปลั๊กอินแบบกำหนดเองนำ product CPT ของตัวเองมาใช้ ก็ควรใช้ type ProductTag สำหรับ tags ของมัน

การมอบเอกลักษณ์ให้กับ media items

Media entities ใน WordPress เป็น custom post types เพียงเพราะมันสะดวกจากมุมมองการนำไปใช้งาน อย่างไรก็ตาม GraphQL schema สามารถหลีกเลี่ยงหนี้ทางเทคนิคนี้ได้ และสร้างแบบจำลอง media elements เป็น entity ที่แตกต่างออกไป ไม่ใช่เป็น custom posts

ซึ่งนำไปสู่การตัดสินใจต่อไปนี้สำหรับ GraphQL schema:

  • เมื่อ query ฟิลด์ customPosts จะไม่ดึง media elements
  • type Media ไม่ได้ implement interface CustomPost และจะไม่เป็นส่วนหนึ่งของ type CustomPostUnion
  • type Media ไม่มีฟิลด์หลายฟิลด์ที่คาดหวังจาก custom post type เช่น excerpt, date และ status แต่มีเฉพาะฟิลด์ที่คาดหวังจาก media element:
type Media {
  id: ID!
  src: String!
  width: Int
  height: Int
}

การระบุและแมป enums

ในบางสถานการณ์ WordPress ใช้ค่าคงที่จากชุดที่กำหนด ตัวอย่างเช่น status ของ post สามารถเป็นได้เฉพาะ "publish", "draft", "pending" หรือ "trash"

ใน GraphQL เราสามารถจัดการสิ่งเหล่านี้เป็น enums (แทนที่จะเป็น strings) และสร้าง enumeration type ที่สอดคล้องกัน ตามมาตรฐาน GraphQL enums ควรเขียนเป็นตัวพิมพ์ใหญ่ ดังนี้:

enum CUSTOM_POST_STATUS {
  PUBLISH
  DRAFT
  PENDING
  TRASH
}

อย่างไรก็ตาม ในกรณีนั้น query จะไม่สามารถใช้โต้ตอบกับ WordPress โดยตรงได้ เพราะการรัน get_posts( [ "post_status" => "PUBLISH" ] ) จะไม่ทำงาน

ดังนั้น เพื่อเป็นการประนีประนอม เราคงค่า enum เหล่านี้ไว้เป็นตัวพิมพ์เล็ก:

enum CUSTOM_POST_STATUS {
  publish
  draft
  pending
  trash
}

การแมป types เพิ่มเติม

Blocks ไม่ปรากฏโดยตรงในแผนภาพฐานข้อมูล WordPress เพราะถูกจัดเก็บใน wp_posts (ไม่มีตาราง wp_blocks) แต่ถึงกระนั้นก็ยังเป็น entity ที่แตกต่างออกไป

ดังนั้น เราจึงแนะนำ type Block เพื่อแมป blocks:

type Post {
  blocks: [Block]
}
 
type Block {
  type: String!
  attributes: JSONObject
}

สมัครรับจดหมายข่าวของเรา

ติดตามการอัปเดตทั้งหมดของ Gato GraphQL