সরাসরি পর্বে যান
পর্ব ৩৬ · ৯৩-এর মধ্যে · মডিউল ৫

GraphQL — schema, resolver, N+1 আর REST-এর সাথে তুলনা

GraphQL — Schemas, Resolvers, the N+1 Problem and How It Compares with REST

Taught in Bangla, this lesson starts from three round trips for one screen, then covers the GraphQL schema as a contract, resolvers per field, one endpoint where status 200 can still carry errors and partial data, the N+1 problem from resolvers and batching with DataLoader in a measured run, field-level authorization, depth and cost limits and persisted queries, caching, clients and codegen, and a comparison of REST, GraphQL and tRPC.

এই পর্বে যা শিখবেন

  • Over-fetching আর under-fetching কেন হয়, GraphQL কোনটি মেটায় আর কোনটি নতুন করে তৈরি করে
  • Schema, type, query, mutation, variable আর fragment লেখা, “বাজারঘর”-এর জন্য একটি আসল Yoga server চালানো
  • Resolver কীভাবে ধাপে ধাপে চলে, আর partial response ও errors কীভাবে পড়বেন
  • N+1 মাপা আর DataLoader দিয়ে কমানো, আসল SQL log দেখে
  • Field authorization, depth ও cost limit, persisted query আর cache-এর সীমাবদ্ধতা
  • REST, GraphQL আর tRPC-র মধ্যে সৎভাবে বেছে নেওয়া

১ · এক পর্দার জন্য তিনবার যাওয়া

“বাজারঘর”-এর পণ্যের পাতায় ফোনে দেখাতে হবে পণ্যের নাম, দাম, বিক্রেতার নাম আর রিভিউ। আগের পর্বগুলোতে আমরা REST endpoint বানিয়েছি, তাই ফোনের অ্যাপ এখন তিনটি জায়গায় যায়:

GET /products/7          → পণ্যের ২০টি field, দরকার ২টি
GET /sellers/2           → বিক্রেতার ১২টি field, দরকার ১টি
GET /products/7/reviews  → সব রিভিউ, দরকার শুধু rating

এখানে দুটি আলাদা সমস্যা আছে। প্রতিটি উত্তরে দরকারের চেয়ে বেশি ডেটা আসছে, একে বলে over-fetching. আবার এক উত্তরে সব ডেটা পাওয়া যাচ্ছে না, তাই বারবার যেতে হচ্ছে, একে বলে under-fetching.

রেস্তোরাঁর কথা ভাবুন। REST হলো সেট মেনু: “সেট ২” চাইলে ভাত, ডাল, মাছ, সালাদ সব আসবে, সালাদ না খেলেও। আর দই চাইলে ওয়েটারকে আলাদা করে আবার ডাকতে হবে। GraphQL হলো একটি ফর্দ: আপনি লিখে দিলেন “ভাত, মাছ, এক কাপ দই”, ওয়েটার একবারেই নিয়ে এলেন। ফর্দ কিন্তু রান্নাঘরের কাজ কমায় না, বরং তাকে নানা রকম অনুরোধের জন্য তৈরি থাকতে হয়। এই শেষ কথাটিই বাকি পর্বের বিষয়।

সমস্যাটি বড় হয় যখন পর্দা বাড়ে। ফোনের অ্যাপ, ওয়েবসাইট, বিক্রেতার dashboard, তিনটির দরকার আলাদা। REST-এ উপায় দুটি: সবাইকে একই মোটা উত্তর দেওয়া, অথবা প্রতিটি পর্দার জন্য আলাদা endpoint বা BFF বানানো। GraphQL তৃতীয় পথ দেখায়: server একটিই schema প্রকাশ করবে, আর প্রতিটি client নিজের query লিখবে।

এক পর্দা: REST-এ তিন যাত্রা, GraphQL-এ এক যাত্রা

Round trips for one product screen

REST আর GraphQL-এ একটি পর্দার ডেটা আনার পথ REST: তিনবার যাওয়া ফোন GraphQL: একবার যাওয়া ফোন Client field বেছে নেয়, server resolver দিয়ে জোগাড় করে /products/7 /sellers/2 /products/7/reviews POST /graphql product seller reviews
ছবি ৩৬.১ — কাজ কমেনি, কাজের জায়গা বদলেছে: ফোনের যাত্রা কমেছে, server-এর দায়িত্ব বেড়েছে।

একটি হিসাব দেখুন। ধরে নিই ফোন থেকে server পর্যন্ত এক যাওয়া-আসায় (RTT) ৮০ ms লাগে, আর server-এর কাজের সময় বাদ দিই। REST-এ প্রথমে পণ্য আনতে হবে, কারণ বিক্রেতার id তার ভেতরে। তারপর বিক্রেতা আর রিভিউ একসাথে আনা যায়। মোট দুই ধাপ, অর্থাৎ ২ × ৮০ = ১৬০ ms. GraphQL-এ এক ধাপ, ৮০ ms. এই সংখ্যাগুলো ধরে নেওয়া, মাপা নয়; তবে ধীর মোবাইল নেটওয়ার্কে কেন এটি দরকারি ছিল, তা বোঝা যায়।

Facebook-এর নিজের লেখা অনুযায়ী GraphQL-এর শুরু ২০১২ সালের দিকে, যখন তারা মোবাইল অ্যাপ native কোডে নতুন করে লিখছিল। ২০১৫ সালে তারা খসড়া specification আর reference implementation (GraphQL.js) প্রকাশ করে। পরে এর দেখাশোনা Linux Foundation-এর অধীনের GraphQL Foundation-এ যায়। ইতিহাসের বিস্তারিত আর gRPC-র সাথে তুলনা আছে System Design কোর্সের gRPC ও GraphQL পর্বে; এখানে আমরা হাতে বানাব।

২ · Schema: client আর server-এর চুক্তি

GraphQL-এর কেন্দ্রে একটি চুক্তি। সেটি লেখা হয় SDL নামের ছোট ভাষায়। বাজারঘরের পণ্য, বিক্রেতা আর রিভিউর schema হলো এই (সত্যিকারের lab-এর কোড থেকে):

type Seller {
  id: ID!
  name: String!
  phone: String          # শুধু admin পড়তে পারবেন
  balance: Int!          # শুধু admin, ইচ্ছা করেই non-null
  products: [Product!]!
}
type Review { id: ID!  rating: Int!  comment: String  product: Product! }
type Product {
  id: ID!
  name: String!
  price: Int!
  seller: Seller!
  reviews: [Review!]!
  shippingEstimate: String
}
type Query {
  products(first: Int = 10): [Product!]!
  product(id: ID!): Product
}
input ReviewInput { productId: ID!  rating: Int!  comment: String }
type Mutation { addReview(input: ReviewInput!): Review }

কয়েকটি চিহ্ন পড়তে শিখুন:

  • String, Int, ID হলো scalar: এর ভেতরে আর field নেই। Product-এর মতো object type-এর ভেতরে field থাকে।
  • ! হলো non-null: এই field কখনো null হবে না। চিহ্ন না থাকলে field নিজে থেকেই nullable. এই পছন্দ পরে বড় প্রভাব ফেলবে, ৪ নম্বর অংশে দেখবেন।
  • [Product!]! মানে তালিকাটি null নয়, ভেতরের কোনো পণ্যও null নয়।
  • Query, Mutation হলো প্রবেশপথ। Client সব কিছু এখান থেকে শুরু করে।

তিন রকম operation

Operationকাজচলার নিয়ম
queryপড়াএকই স্তরের field সমান্তরালে চলতে পারে
mutationবদলানোওপরের field-গুলো ক্রম মেনে একটির পর একটি চলে
subscriptionঘটনা শোনাএকটি মূল field, ফল আসতে থাকে

Subscription নিয়ে এক কথা: Specification বলে দেয় কী অর্থ, কিন্তু কোন transport-এ চলবে তা বলে না। বাস্তবে WebSocket বা Server-Sent Events ব্যবহার হয়। এই দুটি পথ পর্ব ৬০ আর ৬১-তে আসবে, তাই এখানে আমরা subscription চালাইনি।

Query লেখা: variable আর fragment

Client যা লেখে তাকে বলে query document. দ্বিতীয় বন্ধনীর ভেতরের field-তালিকা হলো selection set. মান সরাসরি query-র লেখায় না বসিয়ে variable-এ পাঠানোই নিয়ম, ঠিক যেমন SQL-এ parameterized query. একই field-দল বারবার লাগলে fragment করা হয়। দুটিই এক জায়গায় (সত্যিকারের run):

query Vars($id: ID!, $withSeller: Boolean!) {
  product(id: $id) {
    ...Card
    seller @include(if: $withSeller) { name }
  }
}
fragment Card on Product { name price }
# variables: {"id":"8","withSeller":true}
{"data":{"product":{"name":"Nakshi Bag","price":850,"seller":{"name":"Mita Crafts"}}}}

উত্তরের আকার query-র আকারের মতোই। এটিই GraphQL-এর সবচেয়ে সহজ আনন্দ: উত্তর কেমন হবে, query পড়েই বোঝা যায়। আর schema server থেকেই জানা যায়, একে বলে introspection; GraphiQL-এর মতো tool এর ওপর দাঁড়িয়ে autocomplete দেয়।

৩ · Resolver: প্রতিটি field-এর পেছনের ফাংশন

Schema শুধু বলে কী পাওয়া যাবে। ডেটা কোথা থেকে আসবে, তা বলে resolver. এবার আসল server বানাই। লেখার সময়ে graphql 17.0.2, graphql-yoga 6.0.1 আর dataloader 2.2.3 ব্যবহার করেছি, Node 24.15-এ। Database হিসেবে Node-এর নিজস্ব node:sqlite, memory-তে, যাতে প্রতিটি SQL আমরা দেখতে পারি। Apollo Server (@apollo/server 5.5.1) একই ধরনের আরেকটি পথ; ধারণা একই, API আলাদা।

npm i graphql graphql-yoga dataloader
node server.js
const resolvers = {
  Query: {
    products: (_, { first }, ctx) =>
      sql(ctx, 'SELECT * FROM products ORDER BY id LIMIT ?', Math.min(first, 50)),
  },
  Product: {
    seller: (p, _, ctx) =>
      sql(ctx, 'SELECT * FROM sellers WHERE id = ?', p.seller_id)[0],
    reviews: (p, _, ctx) =>
      sql(ctx, 'SELECT * FROM reviews WHERE product_id = ?', p.id),
  },
};
export const schema = createSchema({ typeDefs, resolvers });
import { createServer } from 'node:http';
import { createYoga } from 'graphql-yoga';
import { schema } from './schema.js';

const yoga = createYoga({
  schema,
  context: () => ({ stats: { sql: 0 } }),   // প্রতি request-এ নতুন
});
createServer(yoga).listen(4136, '127.0.0.1');

প্রতিটি resolver চারটি জিনিস পায়: parent (ওপরের field-এর ফল), args, context (এই request-এর ভাগ করা জিনিস) আর info. লক্ষ করুন name, price-এর resolver লিখিনি। নিজের resolver না থাকলে default resolver parent থেকে একই নামের property তুলে নেয়। তাই database-এর সারি থেকেই name চলে আসে।

Executor query-কে একটি গাছের মতো হাঁটে: আগে ওপরের field, তার ফল নিয়ে ভেতরের field. “পরে” চেপে আসল ডেটায় দুটি পণ্যের একটি query অনুসরণ করুন:

1query Shop {2  products(first: 2) {3    name4    seller { name }5    reviews { rating }6  }7}
Executor-এর হিসাব
পণ্য
resolver
মোট SQL
  1. Parse আর validate. Yoga লেখাটিকে গাছে বদলে schema-র সাথে মেলায়। ভুল field থাকলে এখানেই থামে, কোনো resolver চলে না। SQL এখনো 0.
  2. Query.products চলল। একটি SQL গেল, দুটি সারি এলো। মোট SQL 1. এই সারিগুলো পরের field-গুলোর parent হবে।
  3. name: নিজের resolver নেই, তাই default resolver সারি থেকে তুলে নিল। SQL লাগল না, মোট এখনো 1.
  4. seller, প্রথম পণ্য: Product.seller resolver seller_id ধরে আরেকটি SQL পাঠাল। মোট 2.
  5. reviews, প্রথম পণ্য: আরেকটি SQL, দুটি রিভিউ এলো (rating 4 আর 5). মোট 3.
  6. দ্বিতীয় পণ্য: একই দুটি resolver আবার চলল, বিক্রেতা একই হলেও। মোট 5, অর্থাৎ 1 + 2 × 2. গুণটি এখানেই লুকিয়ে আছে, ৫ নম্বর অংশে এটি ধরব।
  7. উত্তর তৈরি. সব field শেষ, ফলগুলো query-র আকারে সাজিয়ে একটি JSON ফেরত গেল। মোট SQL 5.

এই হাঁটার নিয়ম নিজে হাতে দেখা যাক। graphql প্যাকেজ এই পাতায় চালানো যায় না, তাই নিচে একটি শেখার মডেল: ২৫ লাইনের মতো একটি ছোট executor, যা একটি selection object ধরে resolver ডাকে। এটি আসল GraphQL নয়; parser, type-যাচাই বা error handling এখানে নেই। Query বদলে চালিয়ে দেখুন।

// শেখার মডেল: আসল graphql প্যাকেজ নয়, শুধু ধারণাটি দেখায়
const db = {
  products: [
    { id: 1, name: "Miniket Rice", price: 420, stock: 9, sellerId: 1 },
    { id: 2, name: "Clay Mug", price: 220, stock: 4, sellerId: 2 },
  ],
  sellers: { 1: { name: "Rahim Store" }, 2: { name: "Mita Crafts" } },
};
const returns = { "Query.products": "Product", "Product.seller": "Seller" };
const resolvers = {
  Query: { products: () => db.products },
  Product: { seller: (p) => db.sellers[p.sellerId] },
};
async function run(type, source, selection) {
  const out = {};
  for (const f in selection) out[f] = null;   // query-র ক্রম ঠিক থাকে
  await Promise.all(Object.keys(selection).map(async (f) => {
    const fn = resolvers[type]?.[f];
    const value = fn ? await fn(source) : source[f];   // resolver নেই: নিজের field
    const sub = selection[f];
    if (sub === true) { out[f] = value; return; }
    const child = returns[type + "." + f];
    out[f] = Array.isArray(value)
      ? await Promise.all(value.map((v) => run(child, v, sub)))
      : await run(child, value, sub);
  }));
  return out;
}
const phone = { products: { name: true, price: true } };
const detail = { products: { name: true, seller: { name: true } } };
run("Query", {}, phone).then((r) => {
  console.log(JSON.stringify(r));
  return run("Query", {}, detail);
}).then((r) => console.log(JSON.stringify(r)));

আউটপুটের প্রথম লাইন: {"products":[{"name":"Miniket Rice","price":420},{"name":"Clay Mug","price":220}]}. ডেটায় stock আর sellerId থাকলেও উত্তরে নেই, কারণ selection-এ চাওয়া হয়নি। দ্বিতীয় লাইনে একই পণ্যের সাথে "seller":{"name":"Rahim Store"} এসেছে, দামটি নেই। আসল গাছ হাঁটার কাজটি run ফাংশনটি নিজেকে ডেকে করছে।

৪ · একটিই ঠিকানা, status 200 আর errors

REST-এ পর্ব ২৮ আর পর্ব ৩৩-এ দেখেছেন, ভুলের খবর দেয় status code. GraphQL-এ সব অনুরোধ একটি ঠিকানায় যায়, POST /graphql, আর একটি অনুরোধে সফল আর ব্যর্থ দুই রকম field থাকতে পারে। তাই ভুলের হিসাব থাকে উত্তরের ভেতরে। সত্যিকারের run, লগইন ছাড়া phone চাইলে (অপ্রয়োজনীয় header বাদ):

$ curl -i http://127.0.0.1:4136/graphql -H 'content-type: application/json' \
    -d '{"query":"{ products(first: 1) { name seller { name phone } } }"}'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"errors":[{"message":"Not allowed to read Seller.phone","locations":[{"line":1,"column":43}],
"path":["products",0,"seller","phone"],"extensions":{"code":"FORBIDDEN"}}],
"data":{"products":[{"name":"Miniket Rice 5kg","seller":{"name":"Rahim Store","phone":null}}]}}

Status 200, অথচ একটি ভুল। এই উত্তরের নাম partial response: যা পাওয়া গেছে তা data-তে, যা যায়নি তা null, আর কারণ errors-এ। path বলে দেয় ঠিক কোন field. extensions.code যন্ত্রে পড়ার মতো কারণ, এটিই client-এর দেখার জিনিস, বার্তার লেখা নয়।

Mutation-এও আংশিক ফল

এক mutation-এ তিনটি রিভিউ পাঠালে কী হয়? দ্বিতীয়টির rating 9, যা নিয়মের বাইরে। নিচের ধাপগুলো সত্যিকারের run থেকে নেওয়া। Field-গুলো ক্রমে চলছে কি না, আর ডেটা কী থেকে যাচ্ছে, লক্ষ করুন:

Mutationএকই অনুরোধে তিনটি field
mutation TwoReviews {
  first:  addReview(input: {productId: "1", rating: 5, comment: "good"}) { id rating }
  second: addReview(input: {productId: "1", rating: 9}) { id }
  third:  addReview(input: {productId: "2", rating: 4}) { id }
}
firstSQL 1, লেখা হয়ে গেল
SQL 1: INSERT INTO reviews ... RETURNING *  ["1",5,"good"]
"first":{"id":"21","rating":5}
secondResolver ফেলে দিল, SQL যায়নি
"second": null
errors[0]: "Rating must be 1 to 5"
path: ["second"]   code: BAD_USER_INPUT
thirdআগেরটি ব্যর্থ, তবু এটি চলল
SQL 2: INSERT INTO reviews ... RETURNING *  ["2",4,null]
"third":{"id":"22"}
উত্তর আর logHTTP 200, errorCount 1
{"errors":[{"message":"Rating must be 1 to 5", ... }],
 "data":{"first":{"id":"21","rating":5},"second":null,"third":{"id":"22"}}}
operationName: "TwoReviews"
status: 200    errorCount: 1
  1. অনুরোধ এলো। তিনটি field-ই addReview, তাই alias দিয়ে নাম আলাদা করা হয়েছে। Mutation-এর ওপরের field-গুলো ক্রমে চলবে: first, second, third.
  2. first সফল। একটি INSERT গেল, id 21. এই সারি এখন database-এ স্থায়ী।
  3. second ব্যর্থ। Rating 9, তাই resolver error ছুড়ল, SQL যায়নি। ফেরত type Review nullable, তাই শুধু এই field null হলো। বাকি অনুরোধ থামেনি।
  4. third চলল। আগেরটি ব্যর্থ হলেও পরেরটি থামে না। দ্বিতীয় INSERT, id 22.
  5. উত্তর গেল। Status 200, কিন্তু একটি error. Database-এ রিভিউ বেড়েছে ২টি। এটি transaction নয়, কোনো rollback হয়নি। Client পুরো mutation আবার পাঠালে first আর third দুবার লেখা হবে। এক mutation-এ একাধিক লেখা সব-বা-কিছুই-না করতে চাইলে একটি field-এর ভেতরে transaction চালাতে হবে (পর্ব ৪৩).

একটি non-null সব মুছে দিতে পারে

এবার balance: Int! field-টি লগইন ছাড়া চাই। সত্যিকারের run:

query:  { products(first: 1) { name seller { name balance } } }
{"errors":[{"message":"Not allowed to read Seller.balance", ...
"path":["products",0,"seller","balance"], ...}],"data":null}

পণ্যের নাম, বিক্রেতার নাম, সব উধাও, data পুরোটাই null! কারণ balance null হতে পারে না, তাই null তার parent seller-এ উঠল। seller: Seller!-ও non-null, তাই উঠল পণ্যে। [Product!]!-ও, তাই শেষে data-তে। একটি ছোট field একটি পুরো পর্দা নামিয়ে দিল।

নকশার নিয়ম

যে field ব্যর্থ হতে পারে, বিশেষ করে অনুমতি লাগে বা অন্য service থেকে আসে, সেটিকে nullable রাখুন। ! দিন শুধু সেখানে, যেখানে মান না থাকলে parent-টিই অর্থহীন। ! সরানো পরে client-এর জন্য ভাঙনধরা বদল হতে পারে, তাই ভেবে দিন।

অপ্রত্যাশিত ভুল লুকানো থাকে

Lab-এ shippingEstimate resolver সাধারণ Error ছোড়ে, বার্তায় একটি নকল key আছে। Client পেল শুধু এটুকু:

{"errors":[{"message":"Unexpected error.", ... "path":["products",0,"shippingEstimate"],
"extensions":{"code":"INTERNAL_SERVER_ERROR"}}],"data":{"products":[{"name":"Miniket Rice 5kg","shippingEstimate":null}]}}

এটি error masking, Yoga-তে এটি নিজে থেকেই চালু। শুধু GraphQLError ছুড়লে বার্তা client পর্যন্ত যায়। আসল বার্তাটি server-এর log-এ থাকে। Apollo Server বা নিজের লেখা server-এ আচরণ আলাদা হতে পারে, docs দেখে নেবেন।

Monitoring কেন বদলে যায়

এখানেই সবচেয়ে বড় ফাঁদ। ধরুন আপনার dashboard দেখে “কত শতাংশ অনুরোধ 5xx”, আর path ধরে latency ভাগ করে। উপরের masked error-এর সময় Yoga 6-এর নিজের log-এ এলো (সত্যিকারের run, requestId বাদ):

ERR
  err: { message: "courier API refused key example-secret" ... }
INF Request processed
  method: "POST"
  path: "/graphql"
  operationType: "query"
  status: 200
  errorCount: 1
  durationMs: 2

HTTP-র চোখে এটি সুস্থ: status 200. আর সব অনুরোধের path একটিই, /graphql. তাই পর্ব ৩১-এর structured log-এ আরও তিনটি জিনিস যোগ করতে হবে:

  • operationName: query Shop লেখা থাকলে আর client নামটি পাঠালে log-এ operationName: "Shop" আসে। নামহীন query-কে আলাদা করা যায় না, তাই client-এ নাম দেওয়া বাধ্যতামূলক করুন।
  • errorCount আর extensions.code: সতর্কবার্তা বসবে এখানে, 5xx-এ নয়। FORBIDDEN আর INTERNAL_SERVER_ERROR এক জিনিস নয়।
  • প্রতি operation-এর SQL সংখ্যা: আমরা নিচে ঠিক এটিই মেপে N+1 ধরব।

সব কিছু 200 নয়: GraphQL-over-HTTP নামের খসড়া একটি নিয়ম আছে। লেখার সময়ে Yoga-তে দেখা গেল, client Accept: application/graphql-response+json পাঠালে ভুল field-এর query পায় 400, আর সাধারণ application/json-এ একই query পায় 200. চলা শুরু হওয়ার পরের ভুল দুই ক্ষেত্রেই 200. তাই status একা ভুলের মাপকাঠি হতে পারে না। আর GET-এ mutation পাঠালে Yoga 405 দিয়েছে; mutation শুধু POST-এ চলে।

৫ · N+1: সুন্দর query-র আড়ালে ২১টি SQL

আগের ধাপগুলোয় গুণটি দেখেছেন। এবার আসল সংখ্যা। Lab-এ ১০টি পণ্য, ৩ জন বিক্রেতা। Client একটি ছোট query পাঠাল:

query Shop { products(first: 10) { name seller { name } reviews { rating } } }

প্রতিটি SQL যাচ্ছে একটি helper দিয়ে, তাই log-টি আসল (সত্যিকারের run, মাঝের অংশ ছাঁটা):

  SQL  1: SELECT * FROM products ORDER BY id LIMIT ?  [10]
  SQL  2: SELECT * FROM sellers WHERE id = ?  [1]
  SQL  3: SELECT * FROM reviews WHERE product_id = ?  [1]
  SQL  4: SELECT * FROM sellers WHERE id = ?  [1]
  SQL  5: SELECT * FROM reviews WHERE product_id = ?  [2]
  SQL  6: SELECT * FROM sellers WHERE id = ?  [1]
  ...
  SQL 20: SELECT * FROM sellers WHERE id = ?  [3]
  SQL 21: SELECT * FROM reviews WHERE product_id = ?  [10]
{"op":"Shop","sql":21,"errors":0}

N+1 মানে তালিকার ১টি query আর প্রতিটি সারির জন্য N-টি। আমাদের সম্পর্ক দুটি, তাই ১ + ২ × ১০ = ২১. বিক্রেতা মাত্র ৩ জন, অথচ sellers-এ গেল ১০টি query, একই id বারবার। REST-এ এটি একটি ভুল; GraphQL-এ এটি নকশার স্বাভাবিক ফল, কারণ প্রতিটি resolver শুধু নিজের parent-কে চেনে, পাশের পণ্যটিকে নয়।

নিচের মডেলে সংখ্যা বাড়িয়ে দেখুন। এটিও শেখার মডেল, SQL শুধু গোনা হচ্ছে, কোনো database নেই।

// শেখার মডেল: প্রতিটি resolver কতবার ডাকা হলো আর কতটি query গেল
function shop(n) {
  const products = [];
  for (let i = 1; i <= n; i++) products.push({ id: i, sellerId: (i % 3) + 1 });
  return products;
}
function runQuery(n) {
  const calls = {}, sql = [];
  const count = (k) => { calls[k] = (calls[k] || 0) + 1; };
  const resolvers = {
    products() {
      count("Query.products");
      sql.push("SELECT * FROM products LIMIT " + n);
      return shop(n);
    },
    seller(p) {
      count("Product.seller");
      sql.push("SELECT * FROM sellers WHERE id = " + p.sellerId);
    },
    reviews(p) {
      count("Product.reviews");
      sql.push("SELECT * FROM reviews WHERE product_id = " + p.id);
    },
  };
  for (const p of resolvers.products()) {
    resolvers.seller(p);
    resolvers.reviews(p);
  }
  return { calls, sql };
}
for (const n of [1, 10, 100]) {
  const { calls, sql } = runQuery(n);
  console.log("N=" + n, "query:", sql.length, JSON.stringify(calls));
}
console.log(runQuery(10).sql.slice(0, 3).join("\n"));

আউটপুটে N=1 query: 3, N=10 query: 21, N=100 query: 201. N=10-এর সারিতে {"Query.products":1,"Product.seller":10,"Product.reviews":10}, আসল server-এর ২১-এর সাথে মিলে গেছে। query-র লেখা একই থেকেছে, শুধু ডেটা বেড়েছে। তাই development-এ ৫টি পণ্য দিয়ে এই সমস্যা চোখে পড়ে না।

একটি হিসাব। ধরে নিই database-এর একটি query-তে নেটওয়ার্কসহ ২ ms লাগে আর query-গুলো একটির পর একটি চলে। ১০০টি পণ্যে ২০১ × ২ = ৪০২ ms. একটি পাতায় ২০০ জন একসাথে এলে ৪০,২০০টি query, যেখানে batching-এ ৬০০টি। Client যদি রিভিউর নিচে আরেক স্তর চায়, গুণ আবার বাড়ে: প্রতি রিভিউতে একটি করে query, গড়ে ৫টি রিভিউ হলে ১০০ × ৫ = ৫০০টি বাড়তি। এই সংখ্যাগুলো ধরে নেওয়া মানের ওপর হিসাব।

৬ · DataLoader: জমিয়ে একবারে আনা

ডাকঘরের পিয়নের কথা ভাবুন। প্রতিটি চিঠির জন্য আলাদা করে পাড়ায় না গিয়ে তিনি সকালের সব চিঠি জমান, তারপর একবারে যান। একই ঠিকানার ১০টি চিঠিতে যাত্রা একটিই। DataLoader তাই করে: resolver-গুলো এই tick-এ যত id চাইল জমিয়ে রাখে, একই id দুবার রাখে না, tick শেষে একটি WHERE id IN (...) পাঠায়। এই কৌশলের নাম query batching.

import DataLoader from 'dataloader';

// প্রতি request-এ নতুন: cache কখনো request-এর বাইরে যাবে না
export function createLoaders(ctx) {
  return {
    sellerById: new DataLoader(async (ids) => {
      const marks = ids.map(() => '?').join(',');
      const rows = sql(ctx, `SELECT * FROM sellers WHERE id IN (${marks})`, ...ids);
      const byId = new Map(rows.map((r) => [r.id, r]));
      return ids.map((id) => byId.get(id) ?? new Error(`seller ${id} missing`));
    }),
    reviewsByProduct: new DataLoader(async (ids) => {
      const marks = ids.map(() => '?').join(',');
      const rows = sql(ctx, `SELECT * FROM reviews WHERE product_id IN (${marks})`, ...ids);
      return ids.map((id) => rows.filter((r) => r.product_id === id));
    }),
  };
}

Batch ফাংশনের দুটি কঠিন নিয়ম: ফেরত array-র দৈর্ঘ্য ids-এর সমান হতে হবে, আর ক্রমও একই। Database সারি যে ক্রমে দেয় তা নয়, তাই Map দিয়ে সাজানো হয়েছে। Resolver এখন শুধু ctx.loaders.sellerById.load(p.seller_id) ডাকে। ? placeholder তৈরি হচ্ছে কোড থেকে, id সরাসরি SQL-এ বসছে না, তাই SQL injection-এর পথ বন্ধই থাকে।

একই query, একই ডেটা (সত্যিকারের run):

  SQL  1: SELECT * FROM products ORDER BY id LIMIT ?  [10]
  SQL  2: SELECT * FROM sellers WHERE id IN (?,?,?)  [1,2,3]
  SQL  3: SELECT * FROM reviews WHERE product_id IN (?,?,?,?,?,?,?,?,?,?)  [1,2,3,4,5,6,7,8,9,10]
{"op":"Shop","sql":3,"errors":0}
SQL ২১ → ৩, উত্তরের JSON হুবহু এক
একটি local run-এর ফল, আপনার ডেটায় মেপে নিন

ধাপে ধাপে দুই পথ পাশাপাশি দেখুন:

দুই server একই query চালাচ্ছে
সাধারণ resolver: মোট SQL
DataLoader: মোট SQL
sellerById-এর queue
reviewsByProduct-এর queue
  1. তালিকা এলো। দুই পক্ষই একই SQL চালাল। দুজনেরই মোট 1.
  2. পণ্য ১. সাধারণ resolver সঙ্গে সঙ্গে দুটি SQL পাঠাল, মোট 3. DataLoader শুধু দুটি queue-তে id রাখল, কিছুই পাঠাল না, মোট 1.
  3. পণ্য ২. বিক্রেতা সেই একই, id 1. সাধারণ পথে আবার SQL, মোট 5. Loader cache দেখে বুঝল 1 আগেই আছে, তাই শুধু রিভিউর queue-তে 2 যোগ হলো।
  4. পণ্য ১০ পর্যন্ত। সাধারণ পথে মোট 21. Loader-এর queue-তে তিনজন বিক্রেতা আর দশটি পণ্যের id, মোট SQL এখনো 1. সবই এক tick-এ হয়েছে, কারণ executor একই স্তরের field একসাথে শুরু করে।
  5. Tick শেষ, প্রথম batch. sellers WHERE id IN (1,2,3), মোট 2. দশটি পণ্যই এই তিনজনের ফল ভাগ করে নিল।
  6. দ্বিতীয় batch. reviews WHERE product_id IN (1 … 10), মোট 3. ফলাফল: 21 বনাম 3. ডানের ফল বাঁয়ের সমান, শুধু পথ আলাদা।

“এক tick” বলতে কী বোঝায়, তা নিজে দেখুন। নিচে একটি শেখার মডেল: microtask দিয়ে একটি ছোট DataLoader. Event loop-এর পুরো ছবি আছে পর্ব ২৪-এ।

// শেখার মডেল: ছোট DataLoader; আসল প্যাকেজের মতোই queue, batch আর cache
function tinyLoader(batchFn) {
  const cache = new Map();
  let queue = [];
  function dispatch() {
    const jobs = queue;
    queue = [];
    batchFn(jobs.map((j) => j.key)).then((values) => {
      jobs.forEach((j, i) => j.resolve(values[i]));
    });
  }
  return {
    load(key) {
      if (cache.has(key)) return cache.get(key);   // একই id দ্বিতীয়বার নয়
      const promise = new Promise((resolve) => {
        if (queue.length === 0) queueMicrotask(dispatch);   // tick শেষে পাঠাও
        queue.push({ key, resolve });
      });
      cache.set(key, promise);
      return promise;
    },
  };
}
let queries = 0;
const sellerById = tinyLoader(async (ids) => {
  queries++;
  console.log("SQL:", "SELECT * FROM sellers WHERE id IN (" + ids.join(",") + ")");
  return ids.map((id) => ({ id, name: "Seller-" + id }));
});
const products = [1, 1, 1, 2, 2, 2, 2, 3, 3, 3].map((sellerId) => ({ sellerId }));
Promise.all(products.map((p) => sellerById.load(p.sellerId))).then((sellers) => {
  console.log("পণ্য:", products.length, "SQL:", queries);
  console.log("প্রথম:", sellers[0].name, "শেষ:", sellers[9].name);
});

আউটপুট: SQL: SELECT * FROM sellers WHERE id IN (1,2,3), তারপর পণ্য: 10 SQL: 1. load ১০ বার ডাকা হলেও cache-এ পুরোনো id পেয়ে queue-তে শুধু তিনটি id ঢুকেছে। প্রথম load একটি microtask বসায়। Promise.all(...map...)-এর সব load যেহেতু একই synchronous ধাপে হয়, সবাই সেই microtask চলার আগেই queue-তে পৌঁছে যায়।

DataLoader প্রতি request-এ নতুন বানান। এর cache-এ অনুমতি যাচাই ছাড়াই ডেটা থাকে। পুরো app-এ একটি loader রাখলে এক ব্যবহারকারীর দেখা ডেটা অন্যজন পেতে পারেন, আর পুরোনো মানও আটকে থাকে। Mutation-এর পর একই request-এ আবার পড়লে loader.clear(id) লাগতে পারে। Database query-র দিক থেকে N+1, include আর select মাপার কাজ আসবে পর্ব ৪২-এ।

আরও গভীরে: আসল dataloader 2.2.3-এর সোর্সে batch পাঠানো হয় Promise.resolve().then(() => process.nextTick(fn)) দিয়ে, যাতে আগে থেকে সারিতে থাকা promise-গুলো শেষ হওয়ার সুযোগ পায়; আমাদের মডেলের queueMicrotask তার চেয়ে সরল। README বলছে, এই “Loader” ধারণাটি Facebook-এ ২০১০ সালে Nick Schrock-এর তৈরি। Facebook-এর Haskell library Haxl-ও একই চিন্তার। Batching-এর আরেক পথ হলো query-র গাছ দেখে আগেই একটি SQL JOIN বানানো (look-ahead), তবে তাতে resolver-এর সরলতা যায়।

৭ · Authorization: route নয়, field

REST-এ পর্ব ২৬-এর middleware route-এর সামনে পাহারা বসায়। GraphQL-এ route একটিই। আর একই Seller object নানা পথে পৌঁছানো যায়: products → seller, product → seller, reviews → product → seller. দরজায় পাহারা দিলে পেছনের জানালা খোলা থাকে। তাই অনুমতি যাচাই বসে field-এর ওপর, যাকে বলে field-level authorization:

function requireAdmin(ctx, field) {
  if (ctx.user?.role !== 'admin')
    throw new GraphQLError(`Not allowed to read ${field}`, {
      extensions: { code: 'FORBIDDEN' },
    });
}
const resolvers = {
  Seller: {
    phone(s, _, ctx) { requireAdmin(ctx, 'Seller.phone'); return s.phone; },
  },
};

Lab-এ context request-এর header থেকে ctx.user বানায়। এখানে example-admin-token একটি নকল token, শুধু দেখানোর জন্য; আসল login আসবে পর্ব ৪৫ আর ৪৬-এ। একই query, token সহ (সত্যিকারের run):

{"data":{"products":[{"name":"Miniket Rice 5kg","seller":{"name":"Rahim Store","phone":"01700000001"}}]}}

Token ছাড়া এই field-টিই ছিল null আর FORBIDDEN, ৪ নম্বর অংশে দেখেছেন। Field-এর resolver-এ যাচাই রাখলে যে পথেই আসুক ধরা পড়ে। আরও ভালো হলো যাচাইটি পর্ব ২৯-এর service স্তরে রাখা, যাতে REST আর GraphQL একই নিয়ম মানে। কোন ধরনের ত্রুটি থেকে এই সতর্কতা, তা আছে Broken Access Control আর API Security পর্বে। Role আর মালিকানার পুরো নকশা আসবে পর্ব ৪৮-এ।

৮ · Query-র সীমা: depth, cost আর persisted query

REST-এ server ঠিক করে কোন endpoint কত কাজ করবে। GraphQL-এ সেই ক্ষমতা client-এর হাতে। আমাদের schema-তে Product → Seller → Product একটি চক্র, তাই যে কেউ লিখতে পারেন:

{ products(first: 10) { seller { products { seller { products {
  seller { products { name } } } } } } } }

লেখাটি মাত্র দুই লাইন, অথচ server-এর কাজ গুণ হয়ে বাড়ে। এটি সহজ একটি DoS. প্রতিরক্ষা তিন স্তরে।

Depth আর cost limit

Depth limit গভীরতা আটকায়, তবে চওড়া query ধরতে পারে না। সেজন্য query cost: প্রতিটি field-এর জন্য ১ ধরে তালিকার আকার দিয়ে গুণ করা। দুটিই query চালানোর আগে, validation ধাপে মাপা হয়। মডেলে দেখুন (শেখার মডেল, সীমা: depth 6, cost 500):

// শেখার মডেল: query চালানোর আগেই depth আর cost মাপা
const LIST_SIZE = { products: 10, reviews: 5 };   // ধরে নেওয়া গড় আকার
const MAX_DEPTH = 6, MAX_COST = 500;

function measure(selection, depth = 1, mult = 1) {
  let deepest = depth, cost = 0;
  for (const field in selection) {
    cost += mult;
    const sub = selection[field];
    if (sub === true) continue;
    const size = LIST_SIZE[field] ?? 1;
    const inner = measure(sub, depth + 1, mult * size);
    deepest = Math.max(deepest, inner.deepest);
    cost += inner.cost;
  }
  return { deepest, cost };
}
function check(name, query) {
  const { deepest, cost } = measure(query);
  let verdict = "চলবে";
  if (deepest > MAX_DEPTH) verdict = "বাতিল: depth";
  else if (cost > MAX_COST) verdict = "বাতিল: cost";
  console.log(name, "depth", deepest, "cost", cost, verdict);
}
check("তালিকা", { products: { name: true, price: true } });
check("রিভিউসহ", { products: { name: true, reviews: { rating: true } } });
check("চওড়া", { products: { reviews: { product: { reviews: { product: { rating: true } } } } } });
const trap = (d) => (d === 0 ? { name: true } : { seller: { products: trap(d - 1) } });
check("ফাঁদ", { products: trap(4) });

আউটপুট: তালিকা depth 2 cost 21 চলবে, রিভিউসহ depth 3 cost 71 চলবে, চওড়া depth 6 cost 611 বাতিল: cost, ফাঁদ depth 10 cost 122221 বাতিল: depth. তৃতীয় query-র গভীরতা সীমার ভেতরে, ধরা পড়েছে শুধু cost-এ। হিসাবটি: 1 + 10 + 50 + 50 + 250 + 250 = 611. তালিকার আকার এখানে ধরে নেওয়া গড়।

আসল server-এ একই নিয়ম validation rule হিসেবে লিখেছি (limits.js, প্রায় ৪০ লাইন; first-এর মান আগে না জানলে সবচেয়ে খারাপ ৫০ ধরে নেয়)। Fragment-এর ভেতরেও ঢুকতে হয়, নইলে সীমা ফাঁকি দেওয়া যায়। সত্যিকারের run, Accept: application/graphql-response+json সহ:

400 {"errors":[{"message":"Query depth 8 is over the limit 6","extensions":{"code":"DEPTH_LIMIT"}}]}
400 {"errors":[{"message":"Query cost 1801 is over the limit 500","extensions":{"code":"COST_LIMIT"}}]}

দ্বিতীয়টি ছিল { products(first: 50) { reviews { product { reviews { rating } } } } }, হিসাব 1 + 50 + 250 + 250 + 1250 = 1801. সাধারণ rate limit অনুরোধ গোনে; এখানে অনুরোধ একটিই, কাজ অনেক। তাই পরিণত API-তে rate limit-ও অনেক সময় cost দিয়ে মাপা হয়।

সত্যিকারের একটি version-ফাঁদ: প্রথমে আমরা @escape.tech/graphql-armor-max-depth 2.4.2 ব্যবহার করেছিলাম। এটি নিজের dependency হিসেবে graphql 16.14.2 টেনে আনে, অথচ project-এ 17.0.2. দুই কপি থাকায় তার GraphQLError-কে Yoga চিনল না, তাই সীমা ধরা পড়লেও client পেল 500 আর Unexpected error.. npm ls graphql চালিয়ে কারণটি পাওয়া গেছে। এই আচরণ লেখার সময়ের; নতুন version-এ বদলাতে পারে, তাই নিজে যাচাই করুন।

Persisted query: অচেনা query চলবেই না

আপনার ওয়েব আর ফোনের অ্যাপ যে query পাঠায়, তা build-এর সময়েই জানা। তাহলে server-কে যেকোনো লেখা চালাতে দেওয়ার দরকার কী? Persisted query-তে query আগে থেকে server-এ জমা থাকে, client শুধু একটি id বা hash পাঠায়, অনেকটা হোটেলের ক্রমিক নম্বর দেওয়া মেনুর মতো। এটি একটি allowlist. সত্যিকারের run, @graphql-yoga/plugin-persisted-operations 4.0.1 দিয়ে:

# অজানা query-র লেখা পাঠালে
{"errors":[{"message":"PersistedQueryOnly","extensions":{"code":"CANNOT_SEND_PQ_ID_AND_BODY"}}]}

# extensions.persistedQuery.sha256Hash = cheap-list-v1
{"data":{"products":[{"name":"Miniket Rice 5kg","price":420},{"name":"Soybean Oil 1L","price":190}]}}

# তালিকায় নেই এমন id
{"errors":[{"message":"PersistedQueryNotFound","extensions":{"code":"PERSISTED_QUERY_NOT_IN_LIST"}}]}

লক্ষ করুন, এই তিনটি উত্তরই status 200 ছিল। নিজের অ্যাপের জন্য বানানো API-তে এটিই সবচেয়ে শক্ত প্রতিরক্ষা। সবার জন্য খোলা API-তে এটি চলে না, সেখানে depth আর cost limit-ই ভরসা। Automatic Persisted Queries (APQ) আলাদা জিনিস: সেখানে client অচেনা query-ও চলার সময়ে জমা দিতে পারে, তাই এটি bandwidth বাঁচায়, নিরাপত্তা দেয় না।

৯ · Cache, client, codegen আর REST বনাম GraphQL বনাম tRPC

Cache এখানে কঠিন কেন

REST-এর বড় উপহার হলো HTTP নিজেই cache বোঝে: GET /products/7 একটি URL, তাতে Cache-Control আর ETag বসানো যায়, browser আর CDN বিনা চেষ্টায় জমা রাখে (Web Caching ও CDN পর্ব দেখুন)। GraphQL-এ তিনটি কারণে এটি ভাঙে:

  • অনুরোধ সাধারণত POST, আর HTTP cache আর CDN বাস্তবে সাধারণত POST-এর উত্তর জমা রাখে না।
  • URL একটিই, পার্থক্য body-তে। প্রতিটি client-এর query আলাদা, তাই hit rate কমে।
  • এক উত্তরে নানা বয়সের ডেটা মেশানো: দাম মিনিটে বদলায়, বিক্রেতার নাম মাসে। একটি max-age দুটিকে মানায় না। অনুমতি-নির্ভর field থাকলে উত্তর public-ও করা যায় না।

পথ আছে, তবে কাজ বেশি। Yoga-তে GET-এ query চলে (lab-এ 200 এসেছে), আর persisted query-র ছোট id দিলে URL ছোট থাকে, ফলে CDN-এ রাখা সম্ভব। Client-এর দিকে চলে normalized cache: উত্তরকে Product:7-এর মতো চাবিতে ভেঙে রাখা। Server-এর দিকে চলে resolver-স্তরের cache (পর্ব ৪৪).

Client আর codegen, সংক্ষেপে

সাধারণ fetch-এও GraphQL চলে, শুধু JSON body পাঠাতে হয়। দুটি জনপ্রিয় client হলো urql (লেখার সময়ে 5.0.4) আর Apollo Client (4.3.5); এদের cache আর hook আছে। পর্ব ১৯-এর TanStack Query-র সাথেও জোড়া যায়। কোনটি নেবেন তা নির্ভর করে cache কতটা দরকার তার ওপর, আমরা একটিকে সেরা বলছি না।

Schema typed, তাই code generation দিয়ে query থেকেই TypeScript type বানানো যায়, যেমন @graphql-codegen/cli (7.4.6) দিয়ে। Field-এর নাম ভুল হলে build-এই ধরা পড়ে। পর্ব ৩৫-এর OpenAPI থেকে type বানানোর সাথে তুলনা করে দেখবেন, চিন্তা একই।

সৎ সিদ্ধান্তের ছক

এই বিষয়ে community-তে তীব্র মতভেদ আছে। কেউ বলেন GraphQL ছোট দলে বাড়তি বোঝা, কেউ বলেন অনেক client থাকলে এটি ছাড়া চলে না। দুই পক্ষেরই যুক্তি আছে, তাই শর্ত দিয়ে ভাবুন:

দিকRESTGraphQLtRPC
ভালো মানায়Public API, সরল CRUD, ফাইলঅনেক client, নানা পর্দা, গভীর সম্পর্কের ডেটাএকই TypeScript repo-তে client আর server
চুক্তিOpenAPI (ঐচ্ছিক)Schema (বাধ্যতামূলক)TypeScript type নিজেই
অন্য ভাষার clientসহজসহজকঠিন, মূলত TypeScript
HTTP cacheস্বাভাবিকভাবে পাওয়াবাড়তি কাজ লাগেquery GET-এ যায়, cache নিজে সাজাতে হয়
Error ও monitoringStatus code-এ সহজerrors, operation ধরে মাপতে হয়Procedure ধরে মাপা যায়
নিরাপত্তার বাড়তি কাজRoute-এField, depth, cost, introspectionProcedure-এ
শেখার খরচকমবেশি (schema, loader, cache)TypeScript জানলে কম
  • REST দিয়ে শুরু করুন যদি API বাইরের লোক ব্যবহার করবেন, client একটি বা দুটি, আর দল ছোট। পর্ব ৩২–৩৫-এর শৃঙ্খলাই যথেষ্ট।
  • GraphQL ভাবুন যদি অনেক আলাদা client একই ডেটা নানা আকারে চায়, আর দল N+1, limit ও monitoring-এর খরচ বহন করতে পারে।
  • tRPC ভাবুন যদি একই TypeScript project-এ React আর Node থাকে, আর বাইরের client নেই। পর্ব ৩৭-এ দেখবেন।
  • একটি কঠিন সিদ্ধান্ত নয়: বাইরে REST আর ভেতরের পর্দার জন্য GraphQL, এমন মিশ্রণও চলে।

পরীক্ষা ও ইন্টারভিউতে:

  • Over-fetching আর under-fetching কী? বেশি ডেটা আসা, আর এক অনুরোধে সব ডেটা না পাওয়া।
  • Resolver কী? একটি field-এর মান জোগাড়ের ফাংশন, যা parent, args, context আর info পায়।
  • N+1 কীভাবে কমাবেন? DataLoader দিয়ে batch আর প্রতি request-এ cache, অথবা JOIN করে আগেই আনা।
  • GraphQL-এ error-এ status 200 কেন? এক উত্তরে সফল ও ব্যর্থ field থাকতে পারে, তাই ভুল থাকে errors তালিকায়।
  • খারাপ query কীভাবে আটকাবেন? Depth ও cost limit, timeout, আর persisted query.

১০ · AI-এর যুগে GraphQL

AI খুব দ্রুত schema আর resolver লিখে দেয়, আর সেগুলো চলেও। সমস্যা হলো GraphQL-এর ভুল প্রায়ই “চলে”, ধরা পড়ে production-এর ভিড়ে। সাধারণ ভুলগুলো হলো:

AI যা লেখেসমস্যাধরার উপায়
apollo-server প্যাকেজnpm-এ এটি deprecated, এখন @apollo/servernpm view আর official docs
প্রতিটি relation-এ সরাসরি SQLN+1, ছোট ডেটায় চোখে পড়ে নাপ্রতি operation-এ SQL গোনা, ১০০ সারি দিয়ে test
Module-এর ওপরে একটি global DataLoaderব্যবহারকারীদের মধ্যে ডেটা মিশে যাওয়ার ঝুঁকিদেখুন context-এ বানানো হচ্ছে কি না
শুধু Query field-এ authNested পথে সংবেদনশীল field খোলাToken ছাড়া nested query পাঠানোর test
Resolver-এ try/catch করে nullভুল চুপচাপ হারায়, log-এ কিছুই নেইError-এর পথ test করা, errors দেখা
সব field-এ !একটি ভুলে পুরো data nullএকটি field ব্যর্থ করিয়ে উত্তর দেখা
নতুন limit packageপুরোনো বা ভিন্ন graphql version টানতে পারেnpm ls graphql, একটিই কপি আছে কি না

AI-কে বড় করে “GraphQL server বানাও” না বলে সীমা বলে দিন, আর প্রমাণ চান:

আমি Node-এ graphql-yoga দিয়ে একটি অনলাইন শপের GraphQL API বানাচ্ছি। আমার package.json-এর version: [এখানে দিন]. schema: [এখানে দিন]. ১. Product.seller আর Product.reviews-এর জন্য resolver লেখো, প্রতি request-এ নতুন DataLoader দিয়ে। ২. batch ফাংশনের ফল ids-এর ক্রমে ফেরত দিচ্ছ কি না ব্যাখ্যা করো। ৩. কোন field-এ অনুমতি লাগবে তালিকা করো, আর nested পথে সেটি ফাঁকি দেওয়া যায় কি না বলো। ৪. কোন field non-null রাখা ঝুঁকিপূর্ণ বলো। ৫. একটি test লেখো, যা ১০০টি পণ্যে SQL সংখ্যা গুনে দেখাবে। ৬. যে package বা option সম্পর্কে নিশ্চিত নও, তা বলে দাও, বানিয়ে লিখো না।

AI-এর GraphQL কোড হাতে পেলে: যাচাইয়ের তালিকা

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১Facebook-এর সমস্যার সমাধান কি আপনারও সমাধান? GraphQL কোন পরিস্থিতি থেকে এসেছিল, আর সেই শর্ত আপনার আছে কি?

Facebook-এর ২০১৫ সালের প্রকাশিত লেখা অনুযায়ী, ২০১২ সালের দিকে তারা native মোবাইল অ্যাপে News Feed নতুন করে বানাচ্ছিল। আগের API-গুলো দিয়ে পাওয়া ডেটা আর পর্দার দরকারের মধ্যে অমিল ছিল, client-এ অনেক বাড়তি parsing কোড লিখতে হতো। অর্থাৎ শর্তগুলো ছিল খুব নির্দিষ্ট: বিশাল ও গভীর সম্পর্কের ডেটা, ধীর মোবাইল নেটওয়ার্ক, পুরোনো অ্যাপ যা ব্যবহারকারীরা সহজে আপডেট করেন না, আর শত শত product দল যারা server দলের অপেক্ষায় থাকতে চায় না।

এবার নিজের প্রজেক্টে এই শর্তগুলো মিলিয়ে দেখুন। বাজারঘরের যদি শুধু একটি Next.js সাইট থাকে, যার backend আর frontend একই দল লেখে, তাহলে সমস্যাটি বেশি নেই। সেখানে client বদলালে server-ও বদলানো যায়। GraphQL-এর খরচ কিন্তু থেকেই যাচ্ছে: schema, DataLoader, depth limit, operation ধরে monitoring, cache-এর বাড়তি কাজ।

উল্টো দিকে, ফোনের অ্যাপ, ওয়েব, বিক্রেতার dashboard আর partner integration, চারটি client একই ডেটা চাইলে এবং পুরোনো অ্যাপ বছরের পর বছর চললে হিসাব বদলায়। তখন প্রতিটি পর্দার জন্য endpoint বানানোর খরচ GraphQL-এর খরচের চেয়ে বেশি হতে পারে।

আরও একটি কথা: GraphQL একটি সংস্থার সমস্যাও মেটায়। সে দলগুলোর মাঝে একটি চুক্তি দেয়, ফলে frontend দল backend দলের অপেক্ষায় না থেকে নতুন পর্দা বানাতে পারে, যদি দরকারি field আগে থেকেই schema-তে থাকে। ছোট দলে সেই সমস্যাটিই নেই, কারণ একই মানুষ দুই দিকের কোড লেখেন। তাই সিদ্ধান্তের আগে কয়েকটি প্রশ্ন করুন: কতগুলো আলাদা client আছে, পুরোনো client কতদিন বাঁচে, আর N+1 ও limit দেখার মতো মানুষ দলে আছেন কি না।

মূল উপলব্ধি: বড় কোম্পানির tool বড় কোম্পানির শর্তে জন্মায়। আগে শর্ত মেলান, তারপর tool বাছুন।

প্র ০২Error-এ status 200 দেওয়া কি HTTP-র নিয়ম ভাঙা? এই নকশার পক্ষে আর বিপক্ষে কী যুক্তি?

পক্ষের যুক্তি হলো, HTTP-র একটি উত্তরে একটিই status থাকে, অথচ GraphQL-এর একটি উত্তরে দশটি field থাকতে পারে, যার নয়টি সফল। HTTP-তে “আংশিক সফল”-এর কোনো সাধারণ status নেই। পণ্যের নাম আর দাম ঠিকই এলো, শুধু রিভিউ এলো না; একে 500 বললে client পুরো উত্তর ফেলে দিতে পারে, যা ব্যবহারকারীর জন্য খারাপ।

বিপক্ষের যুক্তিও শক্ত। HTTP-র চারপাশে বহু বছরে তৈরি সব যন্ত্র status দেখে কাজ করে: load balancer, CDN, proxy, retry, access log, uptime monitor, error-rate alert. এগুলো সবাই 200 দেখে ভাবে সব ঠিক। পুরো database নেমে গেলেও, ঠিকমতো নকশা না করলে, আপনার dashboard সবুজ থাকতে পারে। REST-এর problem details-এর মতো নকশা (পর্ব ৩৩) HTTP-র সাথে মিলে চলে, GraphQL অনেকটা তার ওপর দিয়ে চলে।

মাঝের পথ তৈরি হচ্ছে। graphql.org-এর লেখা অনুযায়ী GraphQL-over-HTTP নামের খসড়ায় নতুন media type application/graphql-response+json আছে। এতে data থাকলে 2xx, কিন্তু চালানোই না গেলে 4xx বা 5xx. Lab-এ আমরা দেখেছি একই ভুল query এই header-এ 400 আর পুরোনো application/json-এ 200 পায়। তবে আংশিক ফলে 200-ই থাকে, আর আমাদের persisted query-র ভুলগুলোও পুরোনো পথে 200 ছিল।

তাই বাস্তব পরামর্শ হলো status-এর ওপর আর নির্ভর না করা। Operation-এর নাম, errors-এর সংখ্যা আর code দিয়ে আলাদা metric বানান, আর আগে থেকে ঠিক করুন কোন code-এ alert যাবে। Client-এ শুধু response.ok দেখলে হবে না, errors-ও দেখতে হবে।

মূল উপলব্ধি: বিতর্কটি নিয়ম নিয়ে নয়, যন্ত্র নিয়ে। HTTP-র ওপর যা যা ভর করে আছে, সবগুলোকে নতুন করে শেখাতে হয়, আর সেটিই GraphQL-এর একটি লুকানো দাম।

প্র ০৩Depth limit থাকলেই কি query নিরাপদ? Public API আর নিজের অ্যাপের API-তে প্রতিরক্ষা কেন আলাদা?

না। আমাদের lab-এই তার প্রমাণ: { products(first: 50) { reviews { product { reviews { rating } } } } } query-টি মাত্র ৫ স্তর গভীর, সীমা ৬-এর ভেতরে। তবু cost দাঁড়ায় ১৮০১, কারণ তালিকা গুণ হয়। গভীরতা নয়, গুণফলই আসল খরচ। আবার alias দিয়ে একই দামি field এক query-তে শত বার চাওয়া যায়, তাও depth বাড়ায় না, তাই alias-ও গোনার মধ্যে আসা দরকার।

Cost হিসাবের সীমাও আছে। এটি একটি অনুমান: কোন তালিকা কত বড়, তা database না দেখে জানা যায় না। তাই সবচেয়ে খারাপটি ধরে নিতে হয়, নইলে ফাঁকি থাকে; আবার বেশি কড়া হলে সৎ query আটকায়। পাশাপাশি first-এর একটি সর্বোচ্চ সীমা রাখা জরুরি (আমাদের resolver-এ Math.min(first, 50)), আর পুরো operation-এর timeout-ও।

Public API-তে যে কেউ যেকোনো query লিখতে পারেন, সেটিই API-র মূল্য। তাই সেখানে cost-ভিত্তিক সীমা ছাড়া পথ নেই। GitHub-এর মতো প্রকাশ্য GraphQL API-র docs-এ node সংখ্যা আর point ধরে সীমার বর্ণনা পাওয়া যায়, এটি সেই চিন্তার একটি উদাহরণ।

নিজের অ্যাপের API আলাদা। সেখানে সব query আপনার নিজের কোডেই লেখা, build-এর সময় জানা। তাই persisted query দিয়ে তালিকার বাইরের সব লেখা বন্ধ করা যায়। তখন depth limit আর দ্বিতীয় স্তরের প্রতিরক্ষা। লেখার সময়ে graphql.org-এর security পাতা এই তালিকাভিত্তিক ধরনকে “trusted documents” বলে, আর “persisted query” নামটিকে persisted documents-এর পুরোনো, কিছুটা ভুল নাম হিসেবে উল্লেখ করে; অনেক টুলে অবশ্য পুরোনো নামটিই চলে। দাম হলো deploy-এর ঝামেলা: নতুন query server-এ আগে পৌঁছাতে হয়, আর পুরোনো অ্যাপের query-গুলো তালিকায় রেখে দিতে হয়।

দুই ক্ষেত্রেই শেষ কথা হলো মাপা। কোন operation কত সময় নিল, আর কতটি SQL পাঠাল, তা log থেকে দেখলে সীমাগুলো অনুমানের বদলে সত্যিকারের ব্যবহারের ওপর বসানো যায়।

মূল উপলব্ধি: আগে জিজ্ঞেস করুন “কে query লেখে?” আপনি নিজে লিখলে দরজা বন্ধ করুন; সবাই লিখলে খরচ মাপুন।

প্র ০৪DataLoader-এর cache প্রতি request-এ কেন? আর batching কি সব সময় N+1-এর সেরা উত্তর?

DataLoader-এর README নিজেই বলে, এই cache-কে Redis-এর মতো ভাগ করা application cache ভাবা যাবে না, এটি একটি request-এর ভেতরের স্মৃতি। দুটি কারণ। প্রথম, অনুমতি: ধরুন একটি loader database থেকে একটি সারি এনে রাখল, আর সেই সারিতে এমন তথ্য আছে যা শুধু একজনের দেখার কথা। Loader পুরো app-এ এক হলে পরের ব্যবহারকারীও cache থেকে তা পেতে পারেন। দ্বিতীয়, পুরোনো ডেটা: এই cache নিজে থেকে মেয়াদ জানে না, তাই দীর্ঘদিন চললে memory বাড়ে আর দাম পুরোনোই থাকে।

এক request-এর ভেতরে cache থাকার লাভও আছে: একই query-র দুই শাখায় একই বিক্রেতা এলে একবারই আনা হয়, আর উত্তরে একই object-এর দুই মান দেখা যায় না।

Batching সব সময় সেরা নয়। প্রথমত, IN (...)-এ হাজার id গেলে query ভারী হয়, আর ভালো index ছাড়া ধীর হয়। দ্বিতীয়ত, এটি সাধারণত এক স্তরে এক batch আনে, তাই তিন স্তরের গাছে তিনবার যেতে হয়; একটি JOIN হয়তো একবারে পারত। তৃতীয়ত, প্রতি পণ্যের “সেরা ৩টি রিভিউ”-এর মতো সীমা IN-এ সহজে হয় না; তখন window function বা lateral join লাগে। কিছু tool তাই পুরো query-র গাছ দেখে একটি SQL বানায়, যেমন PostGraphile-এর মতো project. তাতে গতি বাড়ে, কিন্তু business logic resolver থেকে সরে যায়।

এই trade-off ORM-এও একই। include দিয়ে আগে আনবেন না পরে batch করবেন, সেই আলোচনা পর্ব ৪২-এ। আর মনে রাখুন, batch-এর পরেও মাপতে হবে; অনুমান যথেষ্ট নয়।

মূল উপলব্ধি: DataLoader জাদু নয়, এক request-এর ভেতরের সংক্ষিপ্ত স্মৃতি আর একটি জমিয়ে পাঠানোর নিয়ম। সীমাটি জানলেই এটি নিরাপদ।

প্র ০৫GraphQL-এ field নিজে থেকে nullable কেন? আর “GraphQL-এ version লাগে না” কথাটি কতটা সত্য?

অনেক typed ভাষায় উল্টো নিয়ম: মান না থাকলে আলাদা করে বলতে হয়। GraphQL ইচ্ছা করেই field nullable রেখেছে, কারণ একটি উত্তরের পেছনে অনেক উৎস থাকতে পারে। database, অন্য service, cache, যেকোনোটি ব্যর্থ হতে পারে। Nullable হলে শুধু সেই field null হয়, বাকি উত্তর বাঁচে। আমাদের lab-এ phone nullable, তাই পণ্যের নাম এসেছে; balance: Int! non-null, তাই পুরো data null. একই অনুমতির ভুল, ফল সম্পূর্ণ আলাদা।

দামও আছে। Client-এর TypeScript-এ সব কিছু string | null, তাই প্রতি জায়গায় যাচাই লেখা ক্লান্তিকর। আবার null মানে “মান নেই”, নাকি “ভুল হয়েছে”, তা field দেখে বোঝা যায় না, errors মেলাতে হয়। এই সমস্যা নিয়ে GraphQL community-তে আলোচনা চলছে; নতুন কোনো সিদ্ধান্ত হলো কি না, তা specification-এ দেখে নেবেন।

এবার version. GraphQL-এ URL-এ /v2 সাধারণত থাকে না। নতুন field যোগ করলে পুরোনো client-এর কিছু যায় আসে না, কারণ client শুধু নিজের চাওয়া field পায়। পুরোনো field-এ @deprecated বসানো যায়, আর operation log দেখে জানা যায় কে এখনো সেটি চায়। REST-এ এই হিসাব অনেক কঠিন, কারণ server জানে না client উত্তরের কোন অংশ পড়ে।

কিন্তু “version লাগে না” পুরো সত্য নয়। Field মুছে ফেললে, type বদলালে, বা উত্তরের কোনো field-এর ! সরালে client ভাঙতে পারে। পুরোনো মোবাইল অ্যাপ যদি বছরের পর বছর সেই field চায়, schema-তে সেটি রেখে যেতেই হয়। অর্থাৎ version-এর কাজ মোছেনি, চলে গেছে schema-র শৃঙ্খলায়। পর্ব ৩৫-এর versioning আলোচনার সাথে মিলিয়ে পড়ুন।

মূল উপলব্ধি: Nullable মানে ভুলের জন্য নকশায় জায়গা রাখা। আর চুক্তি বদলানোর দায় কোনো tool মোছে না, শুধু রূপ বদলায়।

অনুশীলন

আগে নিজে করুন, তারপর উত্তর মিলিয়ে নিন।

  1. Numerical: একটি query ২৫টি পণ্য আনে। প্রতি পণ্যে seller আর reviews চাওয়া হয়েছে, আর প্রতি পণ্যে গড়ে ৪টি রিভিউ, প্রতিটি রিভিউতে author আলাদা resolver-এ আসে। বিক্রেতা মোট ৬ জন। (ক) DataLoader ছাড়া মোট SQL কত? (খ) সব relation-এ DataLoader দিলে কত? (গ) প্রতি SQL ৩ ms আর একটির পর একটি চললে সময়ের পার্থক্য কত?

    (ক) তালিকা ১ + seller ২৫ + reviews ২৫ + author ২৫ × ৪ = ১ + ২৫ + ২৫ + ১০০ = ১৫১.

    (খ) তালিকা ১, seller batch ১, reviews batch ১, author batch ১ = ৪. Author-এর batch পরের tick-এ যায়, কারণ রিভিউ আসার পরেই author-এর id জানা যায়।

    (গ) ১৫১ × ৩ = ৪৫৩ ms বনাম ৪ × ৩ = ১২ ms, পার্থক্য ৪৪১ ms. Batch query আসলে একটু ধীর হবে, তাই এটি ওপরের সীমার মতো হিসাব।

  2. Output অনুমান করুন: Schema: type Query { me: User }, type User { name: String! email: String! }. email-এর resolver error ছোড়ে। Query { me { name email } }-এর উত্তরে data কী হবে? email যদি String (nullable) হতো?

    প্রথম ক্ষেত্রে email null হতে পারে না, তাই null উঠে যায় parent me-তে। me nullable, তাই এখানেই থামে: {"data":{"me":null},"errors":[...]}. name-ও হারিয়ে গেল।

    দ্বিতীয় ক্ষেত্রে শুধু email null: {"data":{"me":{"name":"...","email":null}},"errors":[...]}. দুই ক্ষেত্রেই errors[0].path হবে ["me","email"].

  3. নকশা ও সমালোচনা: একজন বাজারঘরের জন্য লিখলেন: type User { id: ID! email: String! passwordHash: String! orders: [Order!]! } আর auth শুধু Query.me-তে। Order-এ আছে user: User!. এই নকশায় অন্তত তিনটি সমস্যা ধরুন।
    • passwordHash schema-তেই থাকা উচিত নয়। Schema হলো প্রকাশ্য চুক্তি; database-এর সব column তাতে যাওয়ার দরকার নেই।
    • Auth শুধু প্রবেশপথে। Order.user ধরে অন্য পথে একই User-এ পৌঁছানো যায়, তাই email-এর মতো field-এ নিজের যাচাই লাগবে।
    • email: String!-এ অনুমতির error উঠে গিয়ে পুরো Order মুছে দিতে পারে। Nullable করা নিরাপদ।
    • User → orders → user → orders একটি চক্র, তাই depth limit আর তালিকায় first লাগবে। orders-এ pagination নেই (পর্ব ৩৪).
  4. Command: graphql-yoga-র একটি ছোট server চালান (type Query { hello: String }), তারপর curl দিয়ে ভুল field { helo } পাঠান, একবার সাধারণভাবে, একবার -H 'accept: application/graphql-response+json' সহ। Status দুটি মিলিয়ে দেখুন, আর সিদ্ধান্ত লিখুন: monitoring-এ কোনটি দেখবেন?
    curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4136/graphql \
      -H 'content-type: application/json' \
      -H 'accept: application/graphql-response+json' \
      -d '{"query":"{ helo }"}'

    আমাদের lab-এ একই ধরনের ভুল ({ products(first: 1) { nam } }) curl দিয়ে পাঠিয়ে (সত্যিকারের run) সাধারণভাবে পেয়েছি 200, আর এই header-এ 400. আপনার version-এ ফল আলাদা হতে পারে, সেটি দেখাই অনুশীলনের অংশ। সিদ্ধান্ত: status-এর ওপর ভরসা না করে উত্তরের errors আর extensions.code গুনুন।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি GraphQL query server-এ যে ক্রমে চলে, সাজান (▲▼ চেপে সরান)

  1. Client POST /graphql-এ query আর variable পাঠায়
  2. Server লেখাটি parse করে গাছ বানায়
  3. Schema আর depth ও cost limit দিয়ে validate হয়
  4. ওপরের স্তরের resolver চলে, তারপর ভেতরের স্তরের
  5. Query-র আকারে JSON সাজিয়ে data আর errors ফেরত যায়

একটি ফোনের অ্যাপ শুধু পণ্যের নাম দেখাতে পুরো পণ্যের ২০টি field নামাচ্ছে। এই সমস্যার নাম কী?

  • Under-fetching
  • Over-fetching
  • N+1
  • Query batching

৫০টি পণ্যের একটি GraphQL query-তে প্রতি পণ্যের seller আর reviews আলাদা resolver-এ সরাসরি SQL চালায়। batching ছাড়া মোট কতটি SQL যাবে?

  • ৩টি
  • ৫১টি
  • ১০১টি
  • ১৫০টি

একটি GraphQL উত্তর এলো status 200, body-তে {"data": {"product": {"name": "Tea", "stock": null}}, "errors": [{"message": "...", "path": ["product", "stock"]}]}. এর সঠিক ব্যাখ্যা কোনটি?

  • সব সফল, কারণ status 200
  • পুরো অনুরোধ ব্যর্থ, data ফেলে দিতে হবে
  • stock-এর মান নেই, এটি কোনো ভুল নয়
  • আংশিক সফল: name এসেছে, stock field-টি ব্যর্থ হয়েছে

DataLoader instance সাধারণত কোথায় বানানো উচিত?

  • প্রতি request-এ, context তৈরির সময়
  • Module-এর ওপরে একবার, পুরো app-এর জন্য
  • প্রতিটি resolver call-এর ভেতরে
  • Browser-এ, client-এর কোডে

একটি API-তে Seller.phone শুধু admin পড়তে পারবেন। Seller-এ পৌঁছানো যায় products, product আর reviews দিয়ে। অনুমতি যাচাই কোথায় রাখা সবচেয়ে নিরাপদ?

  • POST /graphql route-এর middleware-এ
  • Seller.phone field-এর resolver বা service স্তরে
  • React component-এ, লুকিয়ে রেখে
  • শুধু Query.products resolver-এ

HTTP cache আর CDN GraphQL-এ সাধারণত সহজে কাজে আসে না। মূল কারণ কোনটি?

  • উত্তর JSON বলে
  • GraphQL HTTPS-এ চলে না বলে
  • সাধারণত একই URL-এ POST যায়, আর query প্রতিবার আলাদা হতে পারে
  • GraphQL-এর ডেটা সব সময় দ্রুত বদলায় বলে

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Schema চুক্তি, resolver তার পেছনের কাজ; client নিজে field বেছে নেয়।
  2. Status 200 মানে সফল নয়; errors, path আর extensions.code দেখুন, monitoring-ও সেভাবে বানান।
  3. যে field ব্যর্থ হতে পারে তাকে nullable রাখুন; ! থেকে null ওপরে উঠে পুরো উত্তর মুছতে পারে।
  4. N+1 মাপুন, DataLoader প্রতি request-এ বানান: lab-এ SQL ২১ থেকে ৩ হয়েছে।
  5. Field-এ অনুমতি, depth ও cost limit, persisted query; আর REST-কে পুরোনো ভাববেন না, শর্ত দেখে বাছুন।

পরের পর্বে: আজ schema-কে আলাদা ভাষায় লিখলেন। যদি client আর server দুটিই TypeScript হয়, তাহলে আলাদা ভাষাই বা কেন? পর্ব ৩৭-এ tRPC আর ভাগ করা schema দিয়ে type-safe API বানাব।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান