Babel Coder

GraphQL Best Practices: สร้าง GraphQL ให้คูลยังไง? ตอนที่ 2

advanced

ข้อความเชิงเปรียบเทียบในบทความนี้เป็นเพียงการเพิ่มอรรถรส ผู้เขียนไม่มีเจตนาในการลบหลู่ต่อศาสนาใดๆ

เมื่อพระเจ้าสร้างโลก RESTful API ได้ถูกบรรจุลงสมองโปรแกรมเมอร์เป็นที่เรียบร้อย พวกเขาเหล่านั้นจึงเขียน API ได้โลดโผนไม่ต่างอะไรจากลิงโหนต้นไม้ในป่าอเมซอน

ลูซิเฟอร์ จอมมารผู้ยิ่งใหญ่ ได้ทรยศต่อพระเจ้าด้วยการอวตารมาเป็นพนักงานในบริษัทสีน้ำเงินนามว่า Facebook ความอหังการของพนักงานในร่างจำแลง เขาจึงได้สถาปนารูปแบบเว็บเซอร์วิสขึ้นมาใหม่อีกตัว หมายจะเขย่าบัลลังก์ของพระเจ้าให้สะเทือนซัก 7 ริกเตอร์ นี่คือที่มาของเว็บเซอร์วิสตัวใหม่ที่มีชื่อว่า GraphQL (wikipedia ไม่ได้กล่าวไว้)

ความที่พระเจ้าฝัง REST ใส่หัวโปรแกรมเมอร์มาแต่เกิด เราจึงรู้ว่าควรวางโครงสร้างโปรเจคเช่นไรเพื่อให้จัดการได้ง่ายในสถาปัตยกรรมแบบ REST แต่สำหรับ GraphQL นั้น แรกๆก็ดูง่ายแต่พอใช้ไปใช้มาแล้วเริ่มรู้สึก… นอกเจ้าเจ้านายก็ GraphQL นี่หละที่เป็นพญามารตัวที่สองของชีวิต~

บทความนี้เราจะดำเนินเรื่องต่อจากบทความที่แล้ว GraphQL Best Practices: สร้าง GraphQL ให้คูลยังไง? ตอนที่ 1 ด้วยการแนะนำวิธีจัดการโครงสร้างโปรเจคและแบบปฏิบัติที่ดีอื่นๆในโลกของ GraphQL

พร้อมจะทรยศพระเจ้ารึยัง?

กฎเหล็กข้อที่ 1: จงทำ Nested Mutations

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

จินตนาการถึงเว็บบลอค แน่นอนว่าบลอคของเราต้องประกอบด้วยบทความ และไม่ใช่แค่นั้นประเภทบทความ (Categories) ก็เป็นหนึ่งในสิ่งจำเป็นเช่นกัน

บทความแต่ละชิ้นต้องระบุประเภทบทความ หากเรามี Mutation ชื่อ createArticle สำหรับการสร้างบทความ นั่นหมายความว่าเราต้องระบุ categoryId เข้าไปด้วย เพื่อบอกว่าบทความของเราเป็นบทความประเภทอะไร

mutation {
  createArticle(
    input: {
      article: { 
        title: "BabelCoder rocks!", 
        content: "เพราะนักพัฒนาไม่ได้สื่อสารแค่ภาษาเดียว",
        categoryId: 1
      }
    }
  )
}

ไม่ใช่เรื่องยากที่จะสร้างบทความผ่าน createArticle หากเราทราบ categoryId แล้ว สถานการณ์กลับกันถ้าประเภทบทความนั้นไม่เคยมีมาก่อน เราก็ต้องสร้างประเภทบทความขึ้นมาใหม่เสียก่อน จากนั้นจึงยิงอีก request เพื่อสร้างบทความพร้อมระบุ categoryId จาก request ก่อนหน้า

// ยิง request แรกเพื่อสร้างประเภทบทความ
mutation {
  createCategory(
    input: {
      category: { 
        title: "ruby", 
        content: "The beautiful language"
      }
    }
  )
}

// สมมติว่าผลลัพธ์จาก request แรก
// ทำให้เกิดการสร้าง category ที่มี ID เป็น 2
// เราจึงนำ ID: 2 ไปใช้ประกอบการสร้างบทความตัวใหม่

mutation {
  createArticle(
    input: {
      article: { 
        title: "Webpacker", 
        content: "Using Webpack to manage app-like JavaScript modules in Rails",
        categoryId: 2
      }
    }
  )
}

ทุกอย่างสมบูรณ์แบบยกเว้นเรื่องเดียวคือเราต้องส่ง HTTP requests ถึงสองครั้งด้วยกัน

เราต่างทราบกันดีครับว่าเราสามารถส่งหลาย Mutations ด้วย request เดียวใน GraphQL ได้ โดยลำดับการทำงานของ Mutations เหล่านั้นจะทำตามลำดับจากบนลงล่าง เมื่อเป็นเช่นนี้ทำไมเราจึงไม่ส่งทั้งสอง Mutations ไปใน request เดียวเลยหละ?

GraphQL ไม่มีหลักการของการเข้าถึงผลลัพธ์จาก Mutation ตัวก่อนหน้าแล้วนำมาใช้กับ Mutation ตัวถัดไป ดังนั้นเราจึงไม่สามารถเอา categoryId จาก Mutation ของการสร้างประเภทบทความ มาใช้งานกับ createArticle ได้

mutation {
  createCategory(
    input: {
      category: { 
        title: "ruby", 
        content: "The beautiful language"
      }
    }
  )

  createArticle(
    input: {
      article: { 
        title: "Webpacker", 
        content: "Using Webpack to manage app-like JavaScript modules in Rails",
        categoryId: $categoryId // << เราไม่มีวิธีเอาผลลัพธ์จาก Mutation ก่อนหน้ามาใช้
      }
    }
  )
}

อาศัยหลักการของ Side loading จาก REST API มาประยุกต์กับ Mutation ทำให้เราสามารถสร้างทั้งประเภทบทความและบทความพร้อมกันได้ใน request เดียว

mutation {
  createArticle(
    input {
      category: { 
        title: "ruby", 
        content: "The beautiful language"
      }
      article: { 
        title: "Webpacker", 
        content: "Using Webpack to manage app-like JavaScript modules in Rails"
      }
    }
  )
}

ใน Mutation ชื่อ createArticle หากเราระบุ category เข้าไปด้วยนั่นหมายความว่าเราต้องการสร้าง category ไปพร้อมๆกับบทความ เมื่อ request ไปถึงเซิฟเวอร์ สิ่งที่เราต้องทำมีเพียงสร้าง category ขึ้นมาก่อน เมื่อสร้างสำเร็จจึงกำหนด ID ของ category นั้นให้เป็นประเภทบทความของ article ตัวที่เราต้องการสร้างต่อไป

กฎเหล็กข้อที่ 2: จงใช้ context คู่การทำ dependency injection

การใช้งาน GraphQL มักตามมาด้วยการเชื่อมต่อฐานข้อมูลหรือบริการภายนอกอื่นๆเสมอ เมื่อเราต้องการ resolve ด้วยการเชื่อมต่อฐานข้อมูล สิ่งที่เราทำมักเป็นเช่นนี้

export default {
  createUser: {
    type: CreateUserPayloadType,
    args: {
      input: { type: new GraphQLNonNull(CreateUserInputType) }
    },
    resolve: (_, { input: { email, password } }) => {
      db.User.create(email, password) // ตำแหน่งเจ้าปัญหา
    }
  }
}

การเรียกใช้งานฐานข้อมูลโดยตรงจากใน resolver จะทำให้ resolver ของเราผูกติดกับบริการภายนอกมากเกินไป การทำ unit test ก็จะยากตามด้วยเช่นกัน

express-graphql อนุญาตให้เราระบุ context เพื่อให้สามารถเรียกใช้งาน context ได้จากทุกๆ resolver

app.use('/graphql', graphqlHTTP(req => ({
  schema,
  context: {
    db // << ตรงนี้
  },
  graphiql: process.env.NODE_ENV !== 'production',
  pretty: process.env.NODE_ENV !== 'production'
})))

เมื่อเราส่ง context ให้ resolver ตอนนี้เราสามารถเข้าถึง db ได้โดยตรงจากพารามิเตอร์ตัวที่สามของ resolver

export default {
  createUser: {
    type: CreateUserPayloadType,
    args: {
      input: { type: new GraphQLNonNull(CreateUserInputType) }
    },
    resolve: (_, { input: { email, password }, context }) => {
      context.db.User.create(email, password)
    }
  }
}

ด้วยวิธีนี้การทดสอบโปรแกรมของเราจะง่ายขึ้น เราไม่ต้อง stub ฐานข้อมูลเราอีกต่อไป แค่ส่ง dummy เข้ามาผ่านทางพารามิเตอร์ในฐานะของ context ก็เป็นอันเสร็จพิธี

วิธีการส่ง dependency ผ่านทาง context ไปเพียงหนึ่งในหลายวิธีเพื่อให้ resolver ไม่ผูกติดกับสิ่งที่เป็น context มากเกินไป เรายังมีวิธีอื่นที่ดีกว่าวิธีนี้มากๆแต่ขอเก็บไว้เขียนต่อในบทความอื่นนะฮะ

กฎเหล็กข้อที่ 3: จงอย่าปล่อยให้ผู้ใช้งานเห็นโค้ดของเรา

กรณีที่เราดันทะลึ่งเขียนโค้ดพลาดและไม่มีการดักจับข้อผิดพลาดอย่างดีพอ โค้ดของเราก็จะถูก GraphQL ปล่อยออกสู่สาธารณะในฐานะนักโทษรอประหารที่ทำผิดพลาดแล้วไม่จำ

export default {
  createUser: {
    type: CreateUserPayloadType,
    args: {
      input: { type: new GraphQLNonNull(CreateUserInputType) }
    },
    resolve: (_, { input: { email, password } }) => {
      db.User.creat(email, password) // ต้องเป็น create ไม่ใช่ creat
    }
  }
}

ตัวอย่างข้างต้นเราพิมพ์ create ผิด เพราะลืมใส่ตัว e เข้าไป เมื่อเราร้องขอข้อมูลผ่าน Mutation นี้ สิ่งที่เราได้รับกลับออกไปจึงเป็น…

{
  "errors": [
    {
      "message": "db.User.creat is not a function",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "createUser"
      ]
    }
  ],
  "data": {
    "createUser": null
  }
}

db.User.creat is not a function การปล่อยบรรทัดนี้ให้หลุดออกไป เท่ากับให้ผู้ใช้งานระบบเห็นโค้ดที่เราเขียนเชียวนะ วั๊ยปล่อยไก่เต็มๆ

เราสามารถแก้ปัญหานี้ได้ด้วยการใช้ GraphQL Errors หลังจากทำการติดตั้งและตั้งค่าตามคู่มือใน Github เราจะพบสิ่งใหม่ที่ต่างไปจาก Errors แบบเดิมๆ

{
  "errors": [
    {
      "message": "Internal Error: 92b75d6c-432f-4cb1-9c7c-22806b77db2a",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "createUser"
      ]
    }
  ],
  "data": {
    "createUser": null
  }
}

เมื่อเราร้องขอข้อมูลผ่าน Mutation เจ้าปัญหา ตอนนี้จะพบว่าข้อผิดพลาดของโค้ดเราไม่ได้ถูกประจารออกมาแล้ว ข้อความใหม่ที่ไฮโซ ทำให้เราทราบว่ามีข้อผิดพลาดเกิดขึ้นภายในด้วยหมายเลข 92b75d6c-432f-4cb1-9c7c-22806b77db2a

เมื่อเราย้อนไปดู log ทางฝั่งเซิฟเวอร์ของเรา เราจะพบ stack trace ของ ID ดังกล่าวที่จะช่วยให้เราเข้าใจข้อผิดพลาดของเรามากขึ้น ดังนี้

TypeError: db.User.creat is not a function: 92b75d6c-432f-4cb1-9c7c-22806b77db2a
    at create (babelcoder/api/app/modules/users/resolvers.js:10:39)
    at _callee$ (babelcoder\api\node_modules\graphql-errors\lib\index.js:68:19)
    at tryCatch (babelcoder\api\node_modules\babel-runtime\node_modules\regenerator-runtime\runtime.js:65:40)
    at Generator.invoke [as _invoke] (babelcoder\api\node_modules\babel-runtime\node_modules\regenerator-runtime\runti
me.js:299:22)
    at Generator.prototype.(anonymous function) [as next] (babelcoder\api\node_modules\babel-runtime\node_modules\rege
nerator-runtime\runtime.js:117:21)

สรุป

เช่นเดียวกับ REST API ครับ เรามีหลายท่าในการสร้างและจัดการ GraphQL รูปแบบหนึ่งก็อาจเหมาะกับสถานการณ์หนึ่งแต่ไม่ได้หมายความว่า GraphQL จะมีรูปแบบตายตัวชนิดห้ามแก้ไขเปลี่ยนแปลง

เรายังมีอีกหลายเรื่องที่น่าสนใจเกี่ยวกับการออกแบบ GraphQL ครับ ถ้าเพื่อนๆคนไหนชอบก็ปักตะไคร้รอได้ตรงนี้ฮะ

เอกสารอ้างอิง

Graphcool (2017). Improving DX with Nested Mutations. Retrieved Sep, 5, 2017, from https://blog.graph.cool/improving-dx-with-nested-mutations-698c508339ca


แสดงความคิดเห็นของคุณ


No any discussions