แนวคิด ไอเดีย และกลยุทธ์
แนวคิด ไอเดีย และกลยุทธ์การเปรียบเทียบ field arguments และ directives

การเปรียบเทียบ field arguments และ directives

ฟังก์ชันการทำงานเดียวกันในการปรับเปลี่ยนผลลัพธ์ของ field ใน GraphQL มักสามารถทำได้ด้วยสองวิธีที่แตกต่างกัน:

  1. Field arguments: field(arg: value)
  2. Query-type directives: field @directive

(Query-type directives คือ directives ที่ใช้กับ query ฝั่ง client ซึ่งแตกต่างจาก schema-type directives ที่ใช้ผ่าน SDL - Schema Definition Language - เมื่อสร้าง schema บน server เนื่องจาก Gato GraphQL สร้าง schema จาก PHP code ไม่ใช่จาก SDL directives ทั้งหมดจึงเป็นประเภท query และเรียกง่ายๆ ว่า "directives")

ตัวอย่างเช่น การแปลง response ของ field title ให้เป็นตัวพิมพ์ใหญ่สามารถทำได้โดยการส่ง field arg format ที่มีค่า enum UPPERCASE ดังนี้:

{
  posts {
    title(format: UPPERCASE)
  }
}

หรือโดยการใช้ directive @strUpperCase กับ field ดังนี้:

{
  posts {
    title @strUpperCase
  }
}

ในทั้งสองกรณี response จาก GraphQL server จะเหมือนกัน:

{
  "data": {
    "posts": [
      {
        "title": "HELLO WORLD!"
      },
      {
        "title": "FIELD ARGUMENTS VS DIRECTIVES IN GRAPHQL"
      }
    ]
  }
}

เราควรใช้ field arguments และ query-side directives เมื่อใด มีความแตกต่างระหว่างสองวิธีนี้หรือไม่ หรือมีสถานการณ์ใดที่วิธีหนึ่งดีกว่าอีกวิธีหนึ่ง?

Field arguments และ directives เหมาะสำหรับอะไร

การ resolve field ใน GraphQL เกี่ยวข้องกับสองการดำเนินการที่แตกต่างกัน:

  1. การดึงข้อมูลที่ร้องขอจาก entity ที่ถูก query
  2. การใช้ฟังก์ชันการทำงาน (เช่น การจัดรูปแบบ) กับข้อมูลที่ดึงมา

เราสามารถตั้งชื่อสองการดำเนินการนี้ว่า "data resolution" และ "applying functionality" หรือเรียกสั้นๆ ว่า "data" และ "functionality"

ความแตกต่างหลักระหว่าง field arguments และ directives คือ field arguments สามารถใช้ได้ทั้ง "data" และ "functionality" แต่ directives ใช้ได้เฉพาะ "functionality" เท่านั้น

มาดูรายละเอียดเพิ่มเติมว่านี่หมายความว่าอะไร

การ resolve ข้อมูลผ่าน field arguments

Field arguments จะถูกประมวลผลเมื่อ resolve field ดังนั้นจึงสามารถใช้เพื่อดึงข้อมูลจริง เช่น การตัดสินใจว่าจะเข้าถึง property ใดจาก object

ตัวอย่างเช่น resolver code นี้แสดงให้เห็นว่า argument size ถูกใช้เพื่อดึง image source หนึ่งหรืออีกรูปแบบจาก object type Media อย่างไร:

function resolveValue(
  object $mediaObject,
  FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
  if ($fieldDataAccessor->getFieldName() === 'src') {
    $size = $fieldDataAccessor->getValue('size');
    return $this->getMediaTypeAPI()->getImageSrc($mediaObject, $size);
  }
  // ...
}

Field args ยังสามารถใช้ช่วยตัดสินใจว่าต้อง query row หรือ column ใดจาก DB table

ใน query นี้ field argument id ถูกใช้เพื่อ query entity เฉพาะของ type Post ซึ่ง resolver จะแปลเป็น row เฉพาะจาก DB table wp_posts ของ WordPress:

{
  post(by: { id: 1 }) {
    title
  }
}

Table เดียวกันเก็บวันที่ของ post ไว้ในสอง column ที่แตกต่างกันคือ post_modified และ post_modified_gmt (ด้วยเหตุผลด้านความเข้ากันได้แบบย้อนหลัง) ใน query นี้ การส่ง field argument gmt ด้วยค่า true หรือ false จะแปลเป็นการดึงค่าจาก column หนึ่งหรืออีก column:

{
  post(by: { id: 1 }) {
    title
    date(gmt: true)
  }
}

ตัวอย่างเหล่านี้แสดงให้เห็นว่า field args สามารถปรับเปลี่ยนแหล่งที่มาของข้อมูลเมื่อ resolve field ได้

Directives ไม่สามารถใช้ปรับเปลี่ยนแหล่งที่มาของข้อมูลได้ เนื่องจาก logic ของมันถูกจัดการผ่าน directive resolvers ซึ่งถูกเรียกหลังจาก field resolver ดังนั้นเมื่อถึงเวลาที่ directive ถูกใช้ ค่าของ field จะต้องถูกดึงมาแล้ว

ตัวอย่างเช่น query นี้จะไม่ทำงานเลย:

{
  post @selectEntity(id: 1) {
    title
  }
}

ในตัวอย่างนี้ field post ต้องการให้ระบุ id ของ entity และเนื่องจากไม่ได้ระบุเป็น field argument server จะคืนค่า error:

{
  "errors": [
    {
      "message": "Argument 'id' cannot be empty",
      "extensions": {
        "type": "QueryRoot",
        "field": "post @selectEntity(id:1)"
      }
    }
  ]
}

สรุปได้ว่ามีเพียง field arguments เท่านั้นที่ช่วยดึงข้อมูลที่ resolve field ได้

การใช้ฟังก์ชันการทำงานผ่าน field arguments หรือ directives

เมื่อดึงข้อมูลสำหรับ field แล้ว เราอาจต้องการจัดการค่าของมัน ตัวอย่างเช่น เราสามารถ:

  • จัดรูปแบบ string โดยแปลงเป็นตัวพิมพ์ใหญ่หรือตัวพิมพ์เล็ก
  • จัดรูปแบบวันที่ที่แสดงด้วย string จากรูปแบบ YYYY-mm-dd เริ่มต้นเป็น dd/mm/YYYY
  • mask string โดยแทนที่อีเมลและหมายเลขโทรศัพท์ด้วย ***
  • ระบุค่าเริ่มต้นหากเป็น null หรือว่างเปล่า
  • ปัดตัวเลขทศนิยมให้เหลือ 2 ตำแหน่ง

การดำเนินการเหล่านี้ทั้งหมดเป็นการจัดการกับข้อมูลที่ดึงมาแล้ว ดังนั้นจึงสามารถ code ได้ทั้งใน field resolver (หลังดึงข้อมูลและก่อน return) หรือใน directive resolver ซึ่งจะรับค่าของ field เป็น input การดำเนินการเหล่านี้จึงสามารถ implement ได้ทั้งผ่าน field arguments หรือ directives

ตัวอย่างเช่น field resolver สำหรับ Post.excerpt สามารถระบุค่าเริ่มต้นผ่าน field arg default และเราสามารถกำหนดค่าของ arg default ใน query:

{
  posts {
    excerpt(default: "(No excerpt)")
  }
}

เราสามารถสร้าง directive @default ที่มี directive resolver แบบนี้:

/**
 * Replace all the empty results with the default value
 */
function resolveDirective(
  array $directiveArgs,
  array $objectIDFields,
  array $objectsByID,
  array &$responseByObjectIDAndField
): void {
  foreach ($objectIDFields as $id => $fields) {
    $object = $objectsByID[$id];
    $defaultValue = $directiveArgs['value'];
    foreach ($fields as $field) {
      if (empty($responseByObjectIDAndField[$id][$field])) {
        $responseByObjectIDAndField[$id][$field] = $defaultValue;
      }
    }
  }
}

สองกลยุทธ์นี้เหมาะสมเท่าเทียมกันหรือไม่ มาสำรวจคำถามนี้จากมุมมองที่แตกต่างกัน

Field arguments ได้รับการรองรับใน GraphQL spec ดีกว่า

ขอบเขตที่ directives ได้รับอนุญาตให้ดำเนินการนั้นไม่ได้ถูกกำหนดไว้อย่างชัดเจนใน GraphQL spec ซึ่งระบุว่า:

Directives provide a way to describe alternate runtime execution and type validation behavior in a GraphQL document.

In some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.

นิยามนี้อนุญาตให้ใช้ directives เช่น @include และ @skip ซึ่ง include และ skip field ตามเงื่อนไข และ @stream และ @defer ซึ่งให้ runtime execution ที่แตกต่างกันสำหรับการดึงข้อมูลจาก server

อย่างไรก็ตาม นิยามนี้ไม่ชัดเจนเกี่ยวกับ directives ที่แก้ไขค่าของ field เช่น @strUpperCase ที่แปลง output value "Hello world!" เป็น "HELLO WORLD!"

เนื่องจากความไม่ชัดเจนนี้ GraphQL servers, clients และ tools ที่แตกต่างกันอาจพิจารณา directives ในระดับที่แตกต่างกัน ทำให้เกิดความขัดแย้งระหว่างกัน

ตัวอย่างหนึ่งคือ Relay ซึ่งไม่พิจารณา directives สำหรับการ cache ค่าของ field หากเริ่มต้น query:

{
  post(by: { id: 1 }) {
    title
  }
}

...Relay จะ query และ cache ค่า "Hello world!" สำหรับ post ที่มี ID 1 หากจากนั้นเราเรียกใช้ query นี้:

{
  post(by: { id: 1 }) {
    title @strUpperCase
  }
}

...response ควรเป็น "HELLO WORLD!" อย่างไรก็ตาม Relay จะคืนค่า "Hello world!" ซึ่งเป็นค่าที่เก็บใน cache สำหรับ post ที่มี ID 1 โดยไม่สนใจ directive ที่ใช้กับ field

การที่ directives ได้รับอนุญาตให้แก้ไข output value ของ field หรือไม่นั้นอยู่ในพื้นที่สีเทา เนื่องจากไม่ได้รับอนุญาตหรือห้ามอย่างชัดเจนใน GraphQL spec แต่มีตัวบ่งชี้สำหรับทั้งสองสถานการณ์

ในด้านหนึ่ง GraphQL spec ดูเหมือนจะให้ directives มีอิสระในการปรับปรุงและกำหนดค่า GraphQL:

As future versions of GraphQL adopt new configurable execution capabilities, they may be exposed via directives. GraphQL services and tools may also provide any additional custom directive beyond those described here.

ในอีกด้านหนึ่ง spec ไม่ได้พิจารณา directives สำหรับFieldsInSetCanMerge validation หรือCollectFields algorithm GraphQL query ต่อไปนี้ถูกต้อง แต่ไม่แน่ชัดว่า user จะได้รับ response แบบใด:

{
  user(by: { id: 1 }) {
    name
    name @strUpperCase
    name @strLowerCase
  }
}

ขึ้นอยู่กับพฤติกรรมของ GraphQL server response สำหรับ field name อาจเป็น "Leo", "LEO" หรือ "leo"... เราไม่รู้ล่วงหน้า และนั่นคือปัญหา

ปัญหาเดียวกันนี้ไม่เกิดขึ้นกับ field arguments เมื่อ query ต่อไปนี้ถูกเรียกใช้:

{
  user(by: { id: 1 }) {
    name
    name(format: UPPERCASE)
    name(format: LOWERCASE)
  }
}

...spec กำหนดให้ GraphQL server คืนค่า error ดังนั้นค่าของ name จะเป็น null จากนั้นเราจะถูกบังคับให้แนะนำ aliases เพื่อเรียกใช้ query:

{
  user(by: { id: 1 }) {
    name
    ucName: name(format: UPPERCASE)
    lcName: name(format: LOWERCASE)
  }
}

Directives ดีกว่าสำหรับ modularity และการนำ code กลับมาใช้ใหม่

การดำเนินการหลายอย่างที่ directives มอบให้นั้นไม่ขึ้นกับ entity และ field ที่ใช้ ตัวอย่างเช่น @strUpperCase จะทำงานกับ string ใดก็ตาม ไม่ว่าจะใช้กับ title ของ post ชื่อของ user ที่อยู่ของ location หรืออะไรก็ตาม

ผลที่ตามมาคือ code สำหรับ directive นี้ถูก implement เพียงครั้งเดียวในที่เดียว คือ directive resolver คล้ายกับ aspect-oriented programming (ซึ่งเพิ่ม modularity โดยอนุญาตให้แยก cross-cutting concerns) directives ถูกใช้กับ field โดยไม่กระทบ logic ของ field

ในทางตรงกันข้าม การ implement ฟังก์ชันการทำงานเดียวกันผ่าน field argument จะต้องเรียกใช้ code เดียวกันผ่าน field resolver (และผ่าน field resolvers ที่แตกต่างกัน):

function formatString(string $string, string $format): string
{
  if ($format === "UPPERCASE") {
    return strtoupper($string);
  }
  if ($format === "LOWERCASE") {
    return strtolower($string);;
  }
  return $string;
};
 
function resolveValue(
  object $post,
  FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
  $format = $fieldDataAccessor->getValue('format');
  if ($fieldDataAccessor->getFieldName() === 'title') {
    return formatString($post->post_title, $format);
  }
  if ($fieldDataAccessor->getFieldName() === 'excerpt') {
    return formatString($post->post_excerpt, $format);
  }
  if ($fieldDataAccessor->getFieldName() === 'content') {
    return formatString($post->post_content, $format);
  }
  // ...
}

เพื่อลดปริมาณ code ใน resolvers directives จึงเหมาะสมกว่า field arguments

Directives ดีกว่าสำหรับการออกแบบ schema

การเพิ่ม field arguments จะเพิ่มข้อมูลพิเศษใน schema ซึ่งอาจทำให้ schema บวมและไม่สอดคล้องกัน

ตัวอย่างเช่น field argument format จะต้องเพิ่มในทุก field ที่เป็น String และหากไม่ระมัดระวัง อาจไม่สม่ำเสมอในแต่ละ field เช่น ใช้ชื่อต่างกัน ค่าต่างกัน ค่าเริ่มต้นต่างกัน หรือแม้แต่แบ่ง argument เป็นหลาย input:

type Post {
  # Input value is "uppercase" or "strLowerCase"
  title(format: String): String
  content(format: String): String
  excerpt(format: String): String
}
 
type Category {
  # Input name is "case" instead of "format"
  # Input value is an enum StringCase with values UPPERCASE and LOWERCASE
  name(case: StringCase): String
}
 
type Tag {
  # Using a default value
  name(format: String = "strLowerCase"): String
}
 
type User {
  # Using multiple Boolean inputs
  description(useUppercase: Boolean, useLowercase: Boolean): String
}

Directives ช่วยให้เราทำให้ schema กระชับที่สุด:

directive @strUpperCase on FIELD
directive @strLowerCase on FIELD
 
type Post {
  title: String
  content: String
  excerpt: String
}
 
type Category {
  name: String
}
 
type Tag {
  name: String
}
 
type User {
  description: String
}

Directives อาจมีประสิทธิภาพมากกว่า field arguments

ในเวลา execution field argument จะถูกเข้าถึงเมื่อ resolve field ซึ่งเกิดขึ้นทีละ field และทีละ object ตัวอย่างเช่น เมื่อ resolve field title และ content ใน list ของ posts resolver จะถูกเรียกหนึ่งครั้งต่อการรวมกันของ post และ field:

function resolveValue(
  object $post,
  FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
  if ($fieldDataAccessor->getFieldName() === 'title') {
    return $post->post_title;
  }
  if ($fieldDataAccessor->getFieldName() === 'content') {
    return $post->post_content;
  }
  // ...
}

สมมติว่าเราต้องการแปล strings เหล่านี้โดยใช้ Google Translate API ซึ่งเราเพิ่ม argument translateTo:

function executeGoogleTranslate(string $string, string $lang): string
{
  // Execute against https://translation.googleapis.com
  // ...
};
 
function resolveValue(
  object $post,
  FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
  $lang = $fieldDataAccessor->getValue('lang');
  if ($fieldDataAccessor->getFieldName() === 'title') {
    return executeGoogleTranslate($post->post_title, $lang);
  }
  if ($fieldDataAccessor->getFieldName() === 'content') {
    return executeGoogleTranslate($post->post_content, $lang);
  }
  // ...
}

เนื่องจาก logic ถูกเรียกใช้ตามธรรมชาติต่อการรวมกันของ field และ object เราอาจจบลงด้วยการร้องขอการเชื่อมต่อจำนวนมากไปยัง API ภายนอก ทำให้ response ในการ resolve query ช้า

นอกจากนี้ การเรียกใช้แต่ละครั้งแยกจากกันจะไม่อนุญาตให้เชื่อมโยงข้อมูล ดังนั้นคุณภาพของการแปลจะด้อยกว่าหากส่งข้อมูลทั้งหมดรวมกันใน API call เดียว

ตัวอย่างเช่น post title "Power" สามารถแปลได้ดีกว่าหากส่งพร้อมกับ post content ซึ่งทำให้ชัดเจนว่าคำนี้หมายถึง "พลังงานไฟฟ้า"

Gato GraphQL เรียก directive เพียงครั้งเดียว โดยส่งทุก field และ object ที่จะใช้เป็น input เมื่อรับข้อมูลทั้งหมดรวมกัน directive @strTranslate สามารถเรียกใช้ Google Translate เพียงครั้งเดียว โดยส่งทุก field title และ content สำหรับทุก object ดังใน query นี้:

{
  posts(pagination: { limit: 6 }) {
    title @strTranslate(from: "en", to: "fr")
    excerpt @strTranslate(from: "en", to: "fr")
  }
}

Directives สามารถให้วิธีที่มีประสิทธิภาพมากขึ้นในการแก้ไขค่าของ fields เช่น เมื่อโต้ตอบกับ APIs ภายนอก