GraphQL Best Practices: สร้าง GraphQL ให้คูลยังไง? ตอนที่ 1
คำเตือน บทความนี้ไม่เหมาะสำหรับผู้เริ่มต้นเขียน GraphQL
เป็นที่ประจักษ์แล้วว่าบริษัทยักษ์ใหญ่หลายแห่งได้มีการใช้งาน GraphQL กันมากขึ้น ทั้ง Facebook (แหงหละ คิดเองแล้วไม่ใช้ได้ไง), Twitter, Github และบริษัทต่อไปอาจเป็นคุณ
เมื่อ GraphQL แพร่ได้ไวดั่ง WannaCrypt คงเป็นเรื่องหลีกเลี่ยงไม่ได้ที่ซักวันเราอาจต้องใช้หรือได้สัมผัสลูบคลำ
เพื่อไม่ให้เพื่อนๆต้องกราบใคร ผมจึงรวบรวมแบบปฏิบัติที่ดีของการออกแบบ GraphQL ไว้ในชุดบทความนี้ งานนี้บอกเลยต่อให้ไม่ซื้อ 1 แถม 1, แม้ตะหลิวจะไม่แถม พวกคุณๆก็ควรอ่านบทความนี้อยู่ดี เสพซิ!
กฎเหล็กข้อ 1: จงหยุดใช้ Viewer Pattern
GraphQL นั้นเป็น Query Language หรือภาษาเพื่อใช้ดึงข้อมูลจาก API ของเรา แน่นอนว่าผู้ใช้งานแต่ละคนอาจได้ผลลัพธ์จากการดึงข้อมูลที่ต่างกัน
นาย A ไม่ได้เป็นสมาชิก เมื่อสอบถามข้อมูลกระทะ จึงได้ข้อมูลของกระทะหน่อมแน้มใบละ 600 บาท แถมเคลือบหินอ่อน 5 ชั้น ในขณะที่นาย B เป็นสมาชิกระดับพรีเมียม ด้วยพลานุภาพสมาชิกกิตติมศักดิ์เขาจึงได้ข้อมูลเป็นกระทะทองแดง เคลือบด้วยไฟนรกหนา 7 ชั้นเชียวละ
เมื่อระบบมีการแบ่งแยกผู้ใช้งาน จึงเลี่ยงไม่ได้ที่จะต้องระบุให้ API ทราบว่าเราคือใคร หรือพ่นศัพท์แสงได้ว่าเราต้องบอกใบ้ว่า currentUser
คือใครนั่นเอง
แบบแผนที่เราปฏิบัติกันเป็นปกติคือการใช้ Viewer Pattern ที่แพร่หลายมาตั้งแต่สมัยสุโขทัย Google ไปทางไหนก็เจอแต่การใช้งานรูปแบบนี้ โดยเฉพาะอย่างยิ่งเมื่อคุณใช้งานคู่กับ Relay
ขั้นตอนการทำงานกับ Viewer Pattern เป็นดังนี้ครับ
- เรียกใช้ Mutation เพื่อคืนค่า
access token
กลับมาให้เรา
1// LoginMutation2{3 createToken: {4 type: CreateTokenPayloadType,5 args: {6 input: { type: new GraphQLNonNull(CreateTokenInputType) }7 },8 resolve: AuthResolvers.createToken9 }10}1112// ตัวอย่างการเรียกใช้งาน13mutation createToken(input: { email: "admin@babelcoder.com", password: "รักทุกคน" }) {14 auth {15 token // << เอา token ตัวนี้ไปใช้งานต่อในข้อ 216 }17}
Client จำ token ตัวนี้เอาไว้ในฐานะที่เป็นตัวบอกว่า currentUser คือใคร
ดึงข้อมูลที่ต้องการ โดยส่ง token ผ่าน Viewer
1// ตัวอย่างการใช้งาน2{3 viewer(token: "ajkup28524sporn2118") {4 pans {5 id,6 name7 }8 }9}
เพียงเท่านี้ธุระของเราก็เสร็จสิ้น เมื่อข้อมูลขึ้นอยู่กับผู้ใช้งาน เราก็แค่ส่ง token เข้าไปใน viewer แบบในตัวอย่างแค่นี้ก็เรียบร้อยแล้ว แหม ใครๆก็ใช้กันนะ ตัวอย่างใน Relay ก็ใช้ อย่ามาโลกสวยว่ามันไม่ดีหน่อยเลย!
Viewer นั้นถือว่าเป็น anti-pattern ครับ นั่นเพราะ GraphQL เป็นเพียง Query Language มันจึงไม่ควรต้องทราบว่ากระบวนการทำ Authentication นั้นเป็นอย่างไร แท้จริงแล้ว token ของเราควรส่งผ่านกระบวนการของ network protocol ที่ใช้คือ HTTP ต่างหาก ไม่ใช่การแทรกเป็นไส้ศึกอยู่ใน GraphQL
สิ่งที่เราควรทำเช่น การส่ง token ผ่าน Authorization Header ของ HTTP แทน ดังนี้
1Authorization: Bearer ajkup28524sporn2118
ทางฝั่งของ API เราก็สร้าง Middleware ขึ้นมาเพื่อแงะเอา token ออกมาจาก Authorization Header หรืออาจใช้ express-jwt
ภายหลังได้ค่า token ออกมาแล้ว เราอาจหา user ในระบบของเราที่สัมพันธ์กับ token นั้นๆ พร้อมทั้งตั้งค่าให้ req.user
มีค่าเท่ากับ currentUser ก็เป็นได้
ขั้นตอนสุดท้าย เราจะส่ง currentUser ของเราไปเป็น context
ภายใต้การเรียกใช้งาน GraphQL ของเราดังนี้
1app.use(2 '/graphql',3 graphqlHTTP((req) => ({4 schema,5 context: {6 user: req.user, // ต๊ะเอ๋ เค้าอยู่นี่7 },8 graphiql: process.env.NODE_ENV !== 'production',9 pretty: process.env.NODE_ENV !== 'production',10 }))11)
เอาหละ เราลองมาเปรียบเทียบข้อเสียของสองวิธีข้างต้นกันดีกว่า เริ่มจาก...
ข้อเสียของ Viewer Pattern
- GraphQL ยุ่งกับ Authentication มากเกินไป ทั้งๆที่ไม่ใช่หน้าที่ของมัน
- โอกาสที่ token หลุดสูงมาก ทางฝั่ง API Server อาจมีการ log requests แน่นอนว่าใครเปิด log ก็จะเจอ token เหล่านั้นทันที!
- แม่ไม่ปลื้ม เพราะต้องส่ง token ไปทุกๆการร้องขอที่ต้องการระบุผู้ใช้งาน
ข้อเสียของการส่ง token ผ่าน header
- ทดสอบผ่าน GraphiQL ได้ยาก
จบเหอะ! อยากเขียนหัวข้ออื่นมั่งละ
กฎเหล็กข้อ 2: Validation ไม่ใช่ GraphQL Errors
GraphQL นั้นมีการจัดการ Errors ด้วยตัวมันเองอยู่ เช่นตอนนี้เราร้องขอข้อมูลของฟิลด์ i
ซึ่งไม่มีอยู่จริง ดังนี้
1{2 allFilms {3 edges {4 node {5 i6 }7 }8 }9}
GraphQL เป็นคนโมโหร้าย มันจะไม่ยอมปล่อยให้เรื่องนี้ผ่านไปง่ายๆแน่ ด้วยศิลปะของแม่ค้านั่งตลาด การตะโกนด่าออกมาเป็น errors จึงถือเป็นการระบายความเครียดแค้นได้ดีที่สุด
1{2 "errors": [3 {4 "message": "Cannot query field \"i\" on type \"Film\". Did you mean \"id\"?",5 "locations": [6 {7 "line": 19,8 "column": 99 }10 ]11 }12 ]13}
โอ้ว แจ่มๆ เมื่อ GraphQL มีการจัดการข้อผิดพลาดอยู่แล้ว ขอเราใช้ซักนิดได้ป๊ะ อย่าทำเป็นหวงตัวไปเลย~
ในกรณีที่เราสร้าง Mutation แน่นอนว่าการส่งข้อมูลเหล่านั้นฝั่ง API Server ก็ต้องมีการตรวจสอบข้อมูลก่อนทำงาน เช่นการสร้างหนังสือนั้น เราต้องระบุชื่อของหนังสือเข้ามาเสมอ หากไม่ระบุย่อมต้องมีการส่งข้อความตอบกลับมาฝั่ง client เพื่อแจ้งให้ผู้ใช้งานทราบ
1mutation createBook(input: { title: "" }) {2 book {3 id,4 title5 }6}
เราคาดหวังว่าข้อความแจ้งเตือนควรอยู่ในรูป JSON ดังนี้
1{2 "errors": {3 "title": "Title is required."4 }5}
แม้เราจะออกแบบข้อความแจ้งเตือนดีแค่ไหน หากไม่รู้จะส่งไปอย่างไรก็คงแย่ แต่ช้าก่อน GraphQL มีส่วนของ Errors หนิ คิดอะไรมากก็โยนเข้าไปใน message ของ errors ซะเลย
1{2 "errors": [3 {4 "message": ""\n{ \n \"errors\": {\n \"title\": \"Title is required.\"\n }\n}\n"",5 "locations": [6 {7 "line": 19,8 "column": 99 }10 ]11 }12 ]13}
เนื่องจากส่วนของ message เป็น string เราจึงต้องเอา Errors ของเราผ่าน JSON.stringify
เสียก่อน เพื่อแปลง JSON ของเราให้อยู่ในรูปแบบ string ของ JSON
หลังจากก้อน Response นี้ส่งกลับไปยัง Client หน้าที่ของ Client มีเพียงตรวจดูว่ามี errors หรือไม่ หากมีก็ทำการเรียก JSON.parse
เพื่อแปลง message ของเราให้กลับมาอยู่ในรูปแบบของ JSON อีกที แค่นี้เราก็ทราบแล้วใช่ไหมละว่า errors ของเราคืออะไร!
บอกเลยวิธีนี้ใครไม่ซื้อ เราซื้อ! ช่างเป็นการผสมผสาน GraphQL Errors กับ Validation Errors ได้อย่างลงตัวเสียจริง แหม... อย่างนี้ต้องจับบรรจุเป็นพยามารวิชาชีพละ
ในโลกของ GraphQL เราพอจะแบ่ง Errors ได้เป็นสองประเภทครับ
- ข้อผิดพลาดภายในระบบหรือความผิดพลาดจากการส่ง Query ที่ไม่สามารถตอบกลับได้ เช่น การร้องขอ
i
ทั้งๆที่ไม่มีi
อยู่... ขอแค่มี you ก็พอ~ - ผู้ใช้งานทะลึ่งทำบางอย่างพลาดเอง เช่นการส่งข้อมูลผิดประเภท เป็นผลทำให้เราต้องส่งฟีดแบคกลับไปบอก
GraphQL Errors นั้นออกแบบมาเพื่อแก้ปัญหา Errors ประเภทที่ 1 ครับ เมื่อเป็นเช่นนี้ Validation Errors ของเราซึ่งเป็น Errors ประเภทที่ 2 จึงไม่ควรจัดการด้วย Errors ของ GraphQL
ค่าแรงขั้นต่ำ 300 ก็ว่าน้อยอยู่แล้ว ยังจะมาเรื่องมากให้ลงทะเบียนคนจนอีก~ GraphQL ไม่ได้กล่าวไว้...
ยังจำกันได้ใช่ไหมครับ GraphQL คือ Query Language หรือภาษาที่ใช้สอบถามข้อมูล หากเรามองว่า Validation Errors คือข้อมูลที่เราต้องการดึงเพื่อนำไปแสดงผลทางฝั่ง client มันจึงสมเหตุสมผลที่จะคืนกลับ Validation Errors ของเราออกมาเป็นข้อมูลเช่นกัน!
1mutation createBook(input: { title: "" }) {2 book {3 id,4 title5 }67 errors {8 title9 }10}
จากตัวอย่างข้างต้น เมื่อเราต้องการทราบว่าข้อมูลที่เราส่งมีข้อผิดพลาดหรือไม่ เราแค่ร้องขอ errors
กลับออกมาก็เพียงพอแล้ว ลั๊ลลา~ ตายตาหลับ
กฎเหล็กข้อ 3: Mutation ต้องสตรอง
การออกแบบ Mutation นั้นเป็นศิลปะขั้นสูง ถ้าออกแบบลวกๆ ใช้เวลาไม่ถึง 3 นาที อย่าว่าแต่ Mutation เลย เส้นมาม่าก็ยังไม่ทันจะสุก ในหัวข้อนี้ของเราเพื่อนๆจะได้ไต่ระดับของการออกแบบ Mutation ให้ดียิ่งขึ้นไปพร้อมๆกัน ลุย!
ภายหลังการทำงานของ Mutation เสร็จสิ้น เราสามารถคืนค่าข้อมูลกลับมาได้ เมื่อการสร้าง Friendship เสร็จสิ้น เราอยากทราบว่าคนที่เป็นเพื่อนกับเราคือใคร เราจึงร้องขอข้อมูล id
และ name
ออกมา ดังนี้
1mutation createFriendship(...) {2 id,3 name4}
ทุกอย่างไปได้สวยงาม ตอนนี้เราได้ข้อมูล id
และ name
ของเพื่อนเราออกมาแล้ว
สมมติตอนนี้เราไม่อยากได้แค่ข้อมูลของเพื่อนเรา แต่เราอยากได้ข้อมูลของตัวเราออกมาตัว เห้ยจะทำไงดีละ ไม่ว่าข้อมูลเราหรือเพื่อนมันก็ต่างมี id
และ name
ที่เป็นฟิลด์ชื่อเดียวกันอยู่
เพื่อไม่ให้บุพการีต้องหนักใจ เราจึงควรห่อฟิลด์ของเราไว้ด้วยชื่อที่เป็นตัวแทนของอ็อบเจ็กต์นั้น ดังนี้
1mutation createFriendship(...) {2 me { // << นี่คือฉัน3 id,4 name5 }6 friend { // << และนั่นคือเธอ7 id,8 name9 }10}
เมื่อส่วนของ payload เรายังซ้อนเป็นอ็อบเจ็กต์ได้ แล้วใยการเรียกใช้ mutation ของเราจะส่งค่าซ้อนไปไม่ได้มั่งละ
1mutation createBook(title: "My Book", desc: "My Book Na Ja") {2 ...3}
แน่นอนว่าการสร้างหนังสือของเราในอนาคตอาจมีฟิลด์อื่นเพิ่มเติม และชื่อของฟิลด์นั้นอาจชนกับชื่อฟิลด์ปัจจุบันที่เราใช้อยู่ เหตุนี้เราจึงควรห่อหุ้มฟิลด์ของเราด้วยอ็อบเจ็กต์
1mutation createBook(book: { title: "My Book", desc: "My Book Na Ja" }) {2 ...3}
แต่ช้าก่อน หากในอนาคต API เรามีเวอร์ชันใหม่ที่ book ของเราจะสร้างได้ต้องส่ง description
เข้ามา แทนที่จะเป็นชื่อย่อแบบ desc
ตายละงานเข้า
หาก mutation ของเราเป็นแบบข้างต้น งานเข้าแน่นอนครับเพราะมันจะไม่สนับสนุน description
ของ API แบบใหม่ ดังนั้นเราควรห่อหุ่ม book ของเราอีกชั้นด้วย input
ดังนี้
1mutation createBook(2 input: {3 book: { title: "My Book", desc: "My Book Na Ja" }4 }5) {6 ...7}
เมื่อไหร่ก็ตามที่เราต้องการแก้ไข API ของเรา เราอาจสร้าง key ขึ้นมาใหม่ภายใต้ชื่อ enhancedBook
หาก client ที่ยังไม่แก้ไขโค้ดก็ยังคงใช้งานรูปแบบเก่าได้ดังแสดงข้างต้น หากต้องการใช้งาน book ตัวใหม่เราก็แค่เปลี่ยนวิธีเรียกใช้ตามนี้
1mutation createBook(2 input: {3 enhancedBook: { title: "My Book", description: "My Book Na Ja" }4 }5) {6 ...7}
อุต๊ะ ค่อยสมกับ Thailand 4.0 หน่อย~
สรุป
Best Practices เราหมายถึงแบบปฏิบัติของการเขียนโปรแกรมที่ดี แน่นอนว่าของแบบนี้บางทีมันก็คือความเห็นส่วนตัวของผู้นำเสนอ Best Practices ที่ดีนั้นต้องนำมาซึ่งรูปแบบการโปรแกรมที่ทำให้โค้ดเราสะอาดขึ้น บำรุงรักษาง่าย เปลี่ยนแปลงทีต้องได้ไว การทดสอบโปรแกรมก็ต้องทำได้โดยไม่ลำบาก และที่สำคัญสุดๆคือ มันต้องเป็นแบบปฏิบัติที่ทำให้โปรแกรมเมอร์หน้าใสอย่างเราๆแฮปปี้ด้วย~ ติดตามตอนต่อไปนะครับ stay tuned!
เอกสารอ้างอิง
leebyron (2016). Auth via viewer vs context. Retrieved May, 15, 2017, from https://github.com/graphql/graphql-js/issues/571#issuecomment-260799453
Caleb Meredith (2017). Designing GraphQL Mutations. Retrieved May, 15, 2017, from https://github.com/graphql/graphql-js/issues/571#issuecomment-260799453
leebyron (2016). What is the best approach to manage custom user errors?. Retrieved May, 15, 2017, from https://github.com/graphql/graphql-js/issues/560#issuecomment-259508214
สารบัญ
- กฎเหล็กข้อ 1: จงหยุดใช้ Viewer Pattern
- กฎเหล็กข้อ 2: Validation ไม่ใช่ GraphQL Errors
- กฎเหล็กข้อ 3: Mutation ต้องสตรอง
- สรุป
- เอกสารอ้างอิง