การสร้าง GraphQL API แรกของคุณ

เผยแพร่แล้ว: 2022-03-11

คำนำ

เมื่อไม่กี่ปีที่ผ่านมา Facebook ได้เปิดตัววิธีใหม่ในการสร้าง API แบ็คเอนด์ที่เรียกว่า GraphQL ซึ่งโดยทั่วไปแล้วเป็นภาษาเฉพาะโดเมนสำหรับการสืบค้นข้อมูลและการจัดการ ในตอนแรก ฉันไม่ได้สนใจมันมากนัก แต่ในที่สุด ฉันพบว่าตัวเองมีส่วนร่วมกับโครงการที่ Toptal ซึ่งฉันต้องใช้ back-end APIs ตาม GraphQL นั่นคือตอนที่ฉันก้าวไปข้างหน้าและเรียนรู้วิธีการนำความรู้ที่ฉันเรียนรู้จาก REST ไปใช้กับ GraphQL

เป็นประสบการณ์ที่น่าสนใจมาก และในระหว่างช่วงการใช้งาน ฉันต้องคิดใหม่วิธีมาตรฐานและวิธีการที่ใช้ใน REST API ในลักษณะที่เป็นมิตรต่อ GraphQL มากขึ้น ในบทความนี้ ฉันพยายามสรุปปัญหาทั่วไปที่ต้องพิจารณาเมื่อใช้งาน GraphQL API เป็นครั้งแรก

ห้องสมุดที่จำเป็น

GraphQL ได้รับการพัฒนาภายในโดย Facebook และเผยแพร่สู่สาธารณะในปี 2015 ต่อมาในปี 2018 โปรเจ็กต์ GraphQL ถูกย้ายจาก Facebook ไปยัง GraphQL Foundation ที่เพิ่งจัดตั้งขึ้นใหม่ ซึ่งโฮสต์โดย Linux Foundation ที่ไม่แสวงหากำไร ซึ่งดูแลและพัฒนาข้อกำหนดภาษาการสืบค้นของ GraphQL และข้อมูลอ้างอิง การใช้งานจาวาสคริปต์

เนื่องจาก GraphQL ยังคงเป็นเทคโนโลยีรุ่นเยาว์และมีการใช้งานอ้างอิงเบื้องต้นสำหรับ JavaScript ไลบรารีที่พัฒนาแล้วส่วนใหญ่จึงมีอยู่ในระบบนิเวศ Node.js นอกจากนี้ยังมีบริษัทอื่นอีกสองแห่งคือ Apollo และ Prisma ที่ให้บริการเครื่องมือและไลบรารีโอเพนซอร์สสำหรับ GraphQL โครงการตัวอย่างในบทความนี้จะขึ้นอยู่กับการใช้งานอ้างอิงของ GraphQL สำหรับ JavaScript และไลบรารีที่จัดเตรียมโดยสองบริษัทนี้:

  • Graphql-js – การใช้งานอ้างอิงของ GraphQL สำหรับ JavaScript
  • เซิร์ฟเวอร์ Apollo – เซิร์ฟเวอร์ GraphQL สำหรับ Express, Connect, Hapi, Koa และอื่นๆ
  • Apollo-graphql-tools – สร้าง จำลอง และต่อสคีมา GraphQL โดยใช้ SDL
  • Prisma-graphql-middleware – แยกตัวแก้ไข GraphQL ของคุณในฟังก์ชันมิดเดิลแวร์

ในโลกของ GraphQL คุณอธิบาย API ของคุณโดยใช้สคีมา GraphQL และสำหรับสิ่งเหล่านี้ ข้อมูลจำเพาะจะกำหนดภาษาของตัวเองที่เรียกว่า The GraphQL Schema Definition Language (SDL) SDL นั้นเรียบง่ายและใช้งานง่ายมาก ในขณะเดียวกันก็มีประสิทธิภาพและแสดงออกอย่างดีเยี่ยม

มีสองวิธีในการสร้างสคีมา GraphQL: แนวทางที่เน้นโค้ดเป็นหลักและแนวทางที่เน้นสคีมาก่อน

  • ในแนวทางที่ใช้โค้ดเป็นอันดับแรก คุณจะอธิบายสคีมา GraphQL ของคุณเป็นออบเจ็กต์ JavaScript ตามไลบรารี graphql-js และ SDL จะถูกสร้างขึ้นโดยอัตโนมัติจากซอร์สโค้ด
  • ในแนวทางแรกที่ใช้สคีมา คุณจะต้องอธิบายสคีมา GraphQL ของคุณใน SDL และเชื่อมโยงตรรกะทางธุรกิจของคุณโดยใช้ไลบรารี Apollo graphql-tools

โดยส่วนตัวแล้ว ฉันชอบแนวทางที่ใช้สคีมาเป็นอันดับแรก และจะใช้สำหรับโปรเจ็กต์ตัวอย่างในบทความนี้ เราจะใช้ตัวอย่างร้านหนังสือแบบคลาสสิกและสร้างส่วนหลังซึ่งจะจัดเตรียม CRUD API เพื่อสร้างผู้แต่งและหนังสือพร้อม API สำหรับการจัดการผู้ใช้และการตรวจสอบสิทธิ์

การสร้างเซิร์ฟเวอร์ GraphQL พื้นฐาน

ในการรันเซิร์ฟเวอร์ GraphQL พื้นฐาน เราต้องสร้างโปรเจ็กต์ใหม่ เริ่มต้นด้วย npm และกำหนดค่า Babel ในการกำหนดค่า Babel ก่อนอื่นให้ติดตั้งไลบรารีที่จำเป็นด้วยคำสั่งต่อไปนี้:

 npm install --save-dev @babel/core @babel/cli @babel/preset-env @babel/node

หลังจากติดตั้ง Babel แล้ว ให้สร้างไฟล์ชื่อ .babelrc ในไดเรกทอรีรากของโปรเจ็กต์ และคัดลอกการกำหนดค่าต่อไปนี้ที่นั่น:

 { "presets": [ [ "@babel/env", { "targets": { "node": "current" } } ] ] }

แก้ไขไฟล์ package.json และเพิ่มคำสั่งต่อไปนี้ในส่วน scripts ด้วย:

 { ... "scripts": { "serve": "babel-node index.js" }, ... }

เมื่อเรากำหนดค่า Babel แล้ว ให้ติดตั้งไลบรารี GraphQL ที่จำเป็นด้วยคำสั่งต่อไปนี้:

 npm install --save express apollo-server-express graphql graphql-tools graphql-tag

หลังจากติดตั้งไลบรารีที่จำเป็น ในการรันเซิร์ฟเวอร์ GraphQL ด้วยการตั้งค่าขั้นต่ำ ให้คัดลอกข้อมูลโค้ดนี้ในไฟล์ index.js ของเรา:

 import gql from 'graphql-tag'; import express from 'express'; import { ApolloServer, makeExecutableSchema } from 'apollo-server-express'; const port = process.env.PORT || 8080; // Define APIs using GraphQL SDL const typeDefs = gql` type Query { sayHello(name: String!): String! } type Mutation { sayHello(name: String!): String! } `; // Define resolvers map for API definitions in SDL const resolvers = { Query: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } }, Mutation: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } } }; // Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolvers maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); });

หลังจากนี้ เราสามารถเรียกใช้เซิร์ฟเวอร์ของเราโดยใช้คำสั่ง npm run serve และหากเรานำทางในเว็บเบราว์เซอร์ไปยัง URL http://localhost:8080/graphql วิชวลเชลล์เชิงโต้ตอบของ GraphQL ที่เรียกว่า Playground จะเปิดขึ้นในที่ที่เราสามารถทำได้ ดำเนินการสืบค้นข้อมูลและการกลายพันธุ์ของ GraphQL และดูข้อมูลผลลัพธ์

ในโลกของ GraphQL ฟังก์ชัน API แบ่งออกเป็นสามชุด เรียกว่าการสืบค้น การกลายพันธุ์ และการสมัครสมาชิก:

  • แบบสอบถาม ถูกใช้โดยไคลเอนต์เพื่อขอข้อมูลที่ต้องการจากเซิร์ฟเวอร์
  • ไคลเอ็นต์ใช้ การกลายพันธุ์ เพื่อสร้าง/อัปเดต/ลบข้อมูลบนเซิร์ฟเวอร์
  • ไคลเอนต์ใช้การ สมัคร เพื่อสร้างและรักษาการเชื่อมต่อตามเวลาจริงกับเซิร์ฟเวอร์ สิ่งนี้ทำให้ไคลเอนต์สามารถรับเหตุการณ์จากเซิร์ฟเวอร์และดำเนินการตามนั้น

ในบทความของเรา เราจะพูดถึงเฉพาะการสืบค้นและการกลายพันธุ์เท่านั้น การสมัครสมาชิกเป็นหัวข้อใหญ่—พวกเขาสมควรได้รับบทความของตัวเองและไม่จำเป็นในการปรับใช้ API ทุกครั้ง

ประเภทข้อมูลสเกลาร์ขั้นสูง

หลังจากเล่นกับ GraphQL ได้ไม่นาน คุณจะพบว่า SDL มีเฉพาะประเภทข้อมูลพื้นฐาน และประเภทข้อมูลสเกลาร์ขั้นสูง เช่น วันที่ เวลา และวันที่เวลา ซึ่งเป็นส่วนสำคัญของ API ทุกรายการขาดหายไป โชคดีที่เรามีห้องสมุดที่ช่วยเราแก้ปัญหานี้ เรียกว่า graphql-iso-date หลังจากติดตั้งแล้ว เราจะต้องกำหนดประเภทข้อมูลสเกลาร์ขั้นสูงใหม่ในสคีมาของเรา และเชื่อมต่อเข้ากับการใช้งานที่จัดเตรียมโดยไลบรารี:

 import { GraphQLDate, GraphQLDateTime, GraphQLTime } from 'graphql-iso-date'; // Define APIs using GraphQL SDL const typeDefs = gql` scalar Date scalar Time scalar DateTime type Query { sayHello(name: String!): String! } type Mutation { sayHello(name: String!): String! } `; // Define resolvers map for API definitions in SDL const resolvers = { Date: GraphQLDate, Time: GraphQLTime, DateTime: GraphQLDateTime, Query: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } }, Mutation: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } } };

นอกจากวันที่และเวลาแล้ว ยังมีการใช้งานประเภทข้อมูลสเกลาร์ที่น่าสนใจอื่นๆ อีกด้วย ซึ่งอาจเป็นประโยชน์สำหรับคุณขึ้นอยู่กับกรณีการใช้งานของคุณ ตัวอย่างเช่น หนึ่งในนั้นคือ graphql-type-json ซึ่งทำให้เราใช้การพิมพ์แบบไดนามิกในสคีมา GraphQL ของเราและส่งหรือส่งคืนออบเจ็กต์ JSON ที่ไม่ได้พิมพ์โดยใช้ API ของเรา นอกจากนี้ยังมีไลบรารี graphql-scalar ซึ่งช่วยให้เราสามารถกำหนดสเกลาร์ GraphQL ที่กำหนดเองด้วยการฆ่าเชื้อ / การตรวจสอบ / การแปลงขั้นสูง

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

สคีมาแบ่ง

หลังจากเพิ่มฟังก์ชันการทำงานให้กับสคีมาของคุณแล้ว สคริปต์จะเริ่มเติบโตและเราจะเข้าใจว่าเป็นไปไม่ได้ที่จะเก็บคำจำกัดความทั้งชุดไว้ในไฟล์เดียว และเราจำเป็นต้องแยกออกเป็นชิ้นเล็ก ๆ เพื่อจัดระเบียบโค้ดและทำให้สามารถปรับขนาดได้มากขึ้น ขนาดที่ใหญ่กว่า โชคดีที่ฟังก์ชันตัวสร้างสคีมา makeExecutableSchema ซึ่งจัดเตรียมโดย Apollo ยังยอมรับข้อกำหนดสคีมาและแมปตัวแก้ไขในรูปแบบของอาร์เรย์ ซึ่งจะทำให้เราสามารถแบ่งสคีมาและรีโซลเวอร์แมปออกเป็นส่วนเล็กๆ นี่คือสิ่งที่ฉันได้ทำในโครงการตัวอย่างของฉัน ฉันได้แบ่ง API ออกเป็นส่วนต่อไปนี้:

  • auth.api.graphql – API สำหรับการพิสูจน์ตัวตนและการลงทะเบียนผู้ใช้
  • author.api.graphql – CRUD API สำหรับรายการผู้แต่ง
  • book.api.graphql – CRUD API สำหรับรายการหนังสือ
  • root.api.graphql – รูทของสคีมาและคำจำกัดความทั่วไป (เช่น ประเภทสเกลาร์ขั้นสูง)
  • user.api.graphql – CRUD API สำหรับการจัดการผู้ใช้

ในระหว่างการแยกสคีมา มีสิ่งหนึ่งที่เราต้องพิจารณา ส่วนหนึ่งต้องเป็น root schema และอีกส่วนหนึ่งต้องขยาย root schema ฟังดูซับซ้อน แต่ในความเป็นจริง มันค่อนข้างง่าย ในสคีมารูท เคียวรีและการกลายพันธุ์ถูกกำหนดดังนี้:

 type Query { ... } type Mutation { ... }

และในอีกความหมายหนึ่ง พวกมันถูกกำหนดดังนี้:

 extend type Query { ... } extend type Mutation { ... }

และนั่นคือทั้งหมด

การตรวจสอบและการอนุญาต

ในการใช้งาน API ส่วนใหญ่ มีข้อกำหนดในการจำกัดการเข้าถึงทั่วโลกและจัดเตรียมนโยบายการเข้าถึงตามกฎบางประเภท สำหรับสิ่งนี้ เรา ต้อง แนะนำในรหัสของเรา: การพิสูจน์ตัวตน —เพื่อยืนยันตัวตนของผู้ใช้—และ การอนุญาต เพื่อบังคับใช้นโยบายการเข้าถึงตามกฎ

ในโลกของ GraphQL เช่นเดียวกับโลก REST โดยทั่วไปสำหรับการตรวจสอบสิทธิ์ เราใช้ JSON Web Token ในการตรวจสอบโทเค็น JWT ที่ส่งผ่าน เราต้องสกัดกั้นคำขอที่เข้ามาทั้งหมดและตรวจสอบส่วนหัวการให้สิทธิ์ สำหรับสิ่งนี้ ในระหว่างการสร้างเซิร์ฟเวอร์ Apollo เราสามารถลงทะเบียนฟังก์ชั่นเป็น Context hook ซึ่งจะถูกเรียกพร้อมกับคำขอปัจจุบันที่สร้างบริบทที่แชร์กับตัวแก้ไขทั้งหมด สามารถทำได้ดังนี้:

 // Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema, context: ({ req, res }) => { const context = {}; // Verify jwt token const parts = req.headers.authorization ? req.headers.authorization.split(' ') : ['']; const token = parts.length === 2 && parts[0].toLowerCase() === 'bearer' ? parts[1] : undefined; context.authUser = token ? verify(token) : undefined; return context; } }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); });

ที่นี่ หากผู้ใช้จะส่งโทเค็น JWT ที่ถูกต้อง เราจะตรวจสอบและจัดเก็บวัตถุผู้ใช้ในบริบท ซึ่งจะเข้าถึงได้สำหรับผู้แก้ไขทั้งหมดในระหว่างการดำเนินการตามคำขอ

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

ดังนั้นเราจึงต้องการวิธีสกัดกั้นการสืบค้น GraphQL และวิธีนี้เรียกว่า prisma-graphql-middleware ไลบรารีนี้ช่วยให้เราเรียกใช้โค้ดที่กำหนดเองได้ก่อนหรือหลังเรียกใช้ตัวแก้ไข ปรับปรุงโครงสร้างโค้ดของเราโดยเปิดใช้โค้ดซ้ำและแยกข้อกังวลที่ชัดเจน

ชุมชน GraphQL ได้สร้างมิดเดิลแวร์ที่ยอดเยี่ยมจำนวนหนึ่งโดยใช้ไลบรารีมิดเดิลแวร์ Prisma ซึ่งแก้ไขกรณีการใช้งานเฉพาะบางกรณี และสำหรับการให้สิทธิ์ผู้ใช้ มีไลบรารีที่เรียกว่า graphql-shield ซึ่งช่วยให้เราสร้างเลเยอร์การอนุญาตสำหรับ API ของเรา

หลังจากติดตั้ง graphql-shield เราสามารถแนะนำเลเยอร์การอนุญาตสำหรับ API ของเราดังนี้:

 import { allow } from 'graphql-shield'; const isAuthorized = rule()( (obj, args, { authUser }, info) => authUser && true ); export const permissions = { Query: { '*': isAuthorized, sayHello: allow }, Mutation: { '*': isAuthorized, sayHello: allow } }

และเราสามารถใช้เลเยอร์นี้เป็นมิดเดิลแวร์กับสคีมาของเราได้ดังนี้:

 // Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); const schemaWithMiddleware = applyMiddleware(schema, shield(permissions, { allowExternalErrors: true })); // Build Apollo server const apolloServer = new ApolloServer({ schemaWithMiddleware }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); })

เมื่อสร้างวัตถุเกราะป้องกัน เราตั้งค่า allowExternalErrors ให้เป็นจริง เนื่องจากโดยค่าเริ่มต้น ลักษณะการทำงานของเกราะคือการตรวจจับและจัดการข้อผิดพลาดที่เกิดขึ้นภายในตัวแก้ไข และนี่ไม่ใช่พฤติกรรมที่ยอมรับได้สำหรับแอปพลิเคชันตัวอย่างของฉัน

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

 export const isUserManager = rule()( (obj, args, { authUser }, info) => authUser && authUser.role === 'USER_MANAGER' ); export const permissions = { Query: { userById: isUserManager, users: isUserManager }, Mutation: { editUser: isUserManager, deleteUser: isUserManager } }

อีกสิ่งหนึ่งที่ฉันต้องการพูดถึงคือวิธีการจัดระเบียบฟังก์ชันมิดเดิลแวร์ในโครงการของเรา เช่นเดียวกับคำจำกัดความสคีมาและแมปตัวแก้ไข การแยกตามสคีมาและเก็บไว้ในไฟล์แยกกันจะดีกว่า แต่ต่างจากเซิร์ฟเวอร์ Apollo ซึ่งยอมรับอาร์เรย์ของคำจำกัดความสคีมาและรีโซลเวอร์แม็พและเย็บให้เรา ไลบรารีมิดเดิลแวร์ของ Prisma จะไม่ทำสิ่งนี้และ ยอมรับวัตถุแมปมิดเดิลแวร์เพียงหนึ่งออบเจ็กต์ ดังนั้นหากเราแยกอ็อบเจ็กต์ เราจะต้องต่อกลับด้วยตนเอง หากต้องการดูวิธีแก้ปัญหาของฉัน โปรดดูคลาส ApiExplorer ในโครงการตัวอย่าง

การตรวจสอบความถูกต้อง

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

 import { UserInputError } from 'apollo-server-express'; import passwordValidator from 'password-validator'; import { isEmail } from 'validator'; const passwordSchema = new passwordValidator() .is().min(8) .is().max(20) .has().letters() .has().digits() .has().symbols() .has().not().spaces(); export const validators = { Mutation: { signup: (resolve, parent, args, context) => { const { email, password, rePassword } = args.signupReq; if (!isEmail(email)) { throw new UserInputError('Invalid Email address!'); } if (password !== rePassword) { throw new UserInputError('Passwords don\'t match!'); } if (!passwordSchema.validate(password)) { throw new UserInputError('Password is not strong enough!'); } return resolve(parent, args, context); } } }

และเราสามารถใช้เลเยอร์เครื่องมือตรวจสอบเป็นมิดเดิลแวร์กับสคีมาของเรา พร้อมด้วยเลเยอร์การอนุญาตดังนี้:

 // Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); const schemaWithMiddleware = applyMiddleware(schema, validators, shield(permissions, { allowExternalErrors: true })); // Build Apollo server const apolloServer = new ApolloServer({ schemaWithMiddleware }); apolloServer.applyMiddleware({ app })

N + 1 แบบสอบถาม

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

 extend type Query { books: [Book!]! ... } extend type Mutation { ... } type Book { id: ID! creator: User! createdAt: DateTime! updatedAt: DateTime! authors: [Author!]! title: String! about: String language: String genre: String isbn13: String isbn10: String publisher: String publishDate: Date hardcover: Int } type User { id: ID! createdAt: DateTime! updatedAt: DateTime! fullName: String! email: String! }

ในที่นี้ เราจะเห็นว่าประเภท User มีความสัมพันธ์แบบหนึ่งต่อกลุ่มกับประเภท Book และความสัมพันธ์นี้จะแสดงเป็นช่องผู้สร้างใน Book แมปตัวแก้ไขสำหรับสคีมานี้ถูกกำหนดดังนี้:

 export const resolvers = { Query: { books: (obj, args, context, info) => { return bookService.findAll(); }, ... }, Mutation: { ... }, Book: { creator: ({ creatorId }, args, context, info) => { return userService.findById(creatorId); }, ... } }

หากเราดำเนินการสืบค้นหนังสือโดยใช้ API นี้ และดูบันทึกคำสั่ง SQL เราจะเห็นสิ่งนี้:

 select `books`.* from `books` select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? ...

ง่ายต่อการเดา—ระหว่างการดำเนินการ ตัวแก้ไขถูกเรียกในครั้งแรกสำหรับการสืบค้นหนังสือ ซึ่งส่งคืนรายชื่อหนังสือ จากนั้นวัตถุหนังสือแต่ละเล่มจะถูกเรียกว่าตัวแก้ไขฟิลด์ของผู้สร้าง และลักษณะการทำงานนี้ทำให้เกิดการสืบค้นฐานข้อมูล N + 1 หากเราไม่ต้องการที่จะระเบิดฐานข้อมูลของเรา พฤติกรรมแบบนั้นก็ไม่ดีนัก

เพื่อแก้ปัญหาการสืบค้น N + 1 นักพัฒนา Facebook ได้สร้างโซลูชันที่น่าสนใจมากที่เรียกว่า DataLoader ซึ่งอธิบายไว้ในหน้า README ดังนี้:

“DataLoader เป็นยูทิลิตี้ทั่วไปที่จะใช้เป็นส่วนหนึ่งของเลเยอร์การดึงข้อมูลของแอปพลิเคชันของคุณ เพื่อให้ API ที่ง่ายขึ้นและสม่ำเสมอบนแหล่งข้อมูลระยะไกลต่างๆ เช่น ฐานข้อมูลหรือบริการเว็บผ่านแบตช์และการแคช”

ไม่ใช่เรื่องตรงไปตรงมามากที่จะเข้าใจว่า DataLoader ทำงานอย่างไร เรามาดูตัวอย่างการแก้ปัญหาที่แสดงไว้ด้านบนก่อน แล้วจึงอธิบายเหตุผลเบื้องหลัง

ในโครงการตัวอย่างของเรา DataLoader ถูกกำหนดเช่นนี้สำหรับฟิลด์ผู้สร้าง:

 export class UserDataLoader extends DataLoader { constructor() { const batchLoader = userIds => { return userService .findByIds(userIds) .then( users => userIds.map( userId => users.filter(user => user.id === userId)[0] ) ); }; super(batchLoader); } static getInstance(context) { if (!context.userDataLoader) { context.userDataLoader = new UserDataLoader(); } return context.userDataLoader; } }

เมื่อเรากำหนด UserDataLoader แล้ว เราสามารถเปลี่ยนตัวแก้ไขของฟิลด์ผู้สร้างได้ดังนี้:

 export const resolvers = { Query: { ... }, Mutation: { ... }, Book: { creator: ({ creatorId }, args, context, info) => { const userDataLoader = UserDataLoader.getInstance(context); return userDataLoader.load(creatorId); }, ... } }

หลังจากการเปลี่ยนแปลงที่ใช้ หากเราดำเนินการค้นหาหนังสืออีกครั้งและดูบันทึกคำสั่ง SQL เราจะเห็นสิ่งนี้:

 select `books`.* from `books` select `users`.* from `users` where `id` in (?)

ในที่นี้ เราจะเห็นว่าการสืบค้นฐานข้อมูล N + 1 ลดลงเหลือสองข้อความค้นหา โดยรายการแรกจะเลือกรายการหนังสือ และรายการที่สองจะเลือกรายการผู้ใช้ที่แสดงเป็นผู้สร้างในรายการหนังสือ ตอนนี้ เรามาอธิบายว่า DataLoader บรรลุผลนี้ได้อย่างไร

คุณลักษณะหลักของ DataLoader คือการแบทช์ ในระหว่างขั้นตอนการดำเนินการเดียว DataLoader จะรวบรวมรหัสที่แตกต่างกันทั้งหมดของการเรียกใช้ฟังก์ชันโหลดแต่ละรายการ จากนั้นเรียกใช้ฟังก์ชันแบตช์ด้วยรหัสที่ร้องขอทั้งหมด สิ่งสำคัญอย่างหนึ่งที่ต้องจำไว้คือ อินสแตนซ์ของ DataLoaders ไม่สามารถใช้ซ้ำได้ เมื่อเรียกใช้ฟังก์ชันแบตช์แล้ว ค่าที่ส่งคืนจะถูกแคชในอินสแตนซ์ตลอดไป ด้วยเหตุนี้ เราจึงต้องสร้างอินสแตนซ์ใหม่ของ DataLoader ต่อแต่ละขั้นตอนการดำเนินการ เพื่อให้บรรลุสิ่งนี้ เราได้สร้างฟังก์ชัน getInstance แบบคงที่ ซึ่งจะตรวจสอบว่าอินสแตนซ์ของ DataLoader ถูกนำเสนอในออบเจกต์บริบทหรือไม่ และหากไม่พบ ให้สร้างขึ้นมา จำไว้ว่าอ็อบเจ็กต์บริบทใหม่จะถูกสร้างขึ้นสำหรับแต่ละเฟสของการดำเนินการ และมีการแชร์กับตัวแก้ไขทั้งหมด

ฟังก์ชันการโหลดแบบกลุ่มของ DataLoader ยอมรับอาร์เรย์ของ ID ที่ร้องขอที่แตกต่างกันและส่งคืนคำสัญญาซึ่งแก้ไขอาร์เรย์ของออบเจ็กต์ที่เกี่ยวข้อง เมื่อเขียนฟังก์ชันการโหลดแบบกลุ่ม เราต้องจำสิ่งสำคัญสองประการ:

  1. อาร์เรย์ของผลลัพธ์ต้องมีความยาวเท่ากับอาร์เรย์ของ ID ที่ร้องขอ ตัวอย่างเช่น หากเราขอรหัส [1, 2, 3] อาร์เรย์ของผลลัพธ์ที่ส่งคืนต้องมีสามวัตถุเท่านั้น: [{ "id": 1, “fullName”: “user1” }, { “id”: 2, “fullName”: “user2” }, { “id”: 3, “fullName”: “user3” }]
  2. ดัชนีแต่ละรายการในอาร์เรย์ของผลลัพธ์ต้องสอดคล้องกับดัชนีเดียวกันในอาร์เรย์ของ ID ที่ร้องขอ ตัวอย่างเช่น หากอาร์เรย์ของ ID ที่ร้องขอมีลำดับดังต่อไปนี้: [3, 1, 2] ดังนั้นอาร์เรย์ของผลลัพธ์ที่ส่งคืนต้องมีอ็อบเจ็กต์ในลำดับเดียวกันทุกประการ: [{ "id": 3, “fullName”: “user3” }, { “id”: 1, “fullName”: “user1” }, { “id”: 2, “fullName”: “user2” }]

ในตัวอย่างของเรา เราตรวจสอบให้แน่ใจว่าลำดับของผลลัพธ์ตรงกับลำดับของ ID ที่ร้องขอด้วยรหัสต่อไปนี้:

 then( users => userIds.map( userId => users.filter(user => user.id === userId)[0] ) )

ความปลอดภัย

และสุดท้ายแต่ไม่ท้ายสุด ฉันต้องการพูดถึงความปลอดภัย ด้วย GraphQL เราสามารถสร้าง API ที่ยืดหยุ่นมากและให้ความสามารถที่หลากหลายแก่ผู้ใช้ในการสืบค้นข้อมูล สิ่งนี้ให้อำนาจค่อนข้างมากในด้านไคลเอนต์ของแอปพลิเคชันและอย่างที่ลุงเบ็นกล่าวว่า "พลังอันยิ่งใหญ่มาพร้อมกับความรับผิดชอบที่ยิ่งใหญ่" หากไม่มีการรักษาความปลอดภัยที่เหมาะสม ผู้ใช้ที่ประสงค์ร้ายสามารถส่งคำถามราคาแพงและทำให้เกิดการโจมตี DoS (Denial of Service) บนเซิร์ฟเวอร์ของเราได้

สิ่งแรกที่เราทำได้เพื่อปกป้อง API ของเราคือปิดใช้งานการวิปัสสนาของสคีมา GraphQL โดยค่าเริ่มต้น เซิร์ฟเวอร์ GraphQL API จะเปิดเผยความสามารถในการพิจารณาสคีมาทั้งหมด ซึ่งโดยทั่วไปจะใช้โดยวิชวลเชลล์แบบโต้ตอบ เช่น GraphiQL และ Apollo Playground แต่ก็มีประโยชน์มากสำหรับผู้ใช้ที่เป็นอันตรายในการสร้างการสืบค้นที่ซับซ้อนตาม API ของเรา . เราสามารถปิดใช้งานสิ่งนี้ได้โดยการตั้งค่าพารามิเตอร์ introspection เป็นเท็จเมื่อสร้าง Apollo Server:

 // Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema, introspection: false }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); })

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

 query { authors { id, fullName books { id, title authors { id, fullName books { id, title, authors { id, fullName books { id, title authors { ... } } } } } } } }

เป็นที่ชัดเจนว่าด้วยการซ้อนที่เพียงพอ แบบสอบถามดังกล่าวสามารถระเบิดเซิร์ฟเวอร์ของเราได้อย่างง่ายดาย เพื่อจำกัดความลึกของข้อความค้นหา เราสามารถใช้ไลบรารีที่เรียกว่า graphql-depth-limit เมื่อเราติดตั้งแล้ว เราสามารถใช้การจำกัดความลึกเมื่อสร้าง Apollo Server ดังนี้:

 // Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema, introspection: false, validationRules: [ depthLimit(5) ] }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); })

ในที่นี้ เราจำกัดความลึกสูงสุดของข้อความค้นหาไว้ที่ห้ารายการ

Post Scriptum: การย้ายจาก REST เป็น GraphQL เป็นเรื่องที่น่าสนใจ

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

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