iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 14
3
Modern Web

Think in GraphQL系列 第 14

GraphQL 入門: 實作 Custom Scalar Type (Date Scalar Type)

header

今天要來介紹一個非常實用的功能: 建立 Custom Scalar Type 。

前面有提到 GraphQL 預設總共有 5 種 Scalar Type ,分別為 Int, Float, String, Boolean, ID 。

GraphQL 的一大強處就是 type validation ,但當功能需求越開越多時,開始會存取 url 、 date 、 positive integer 等等特定格式的資料,只使用 String 或 Int 做 type validation 的效益不高,有跟沒有一樣。

再者,當使用你 Server 的 Clinet 種類變多 (IOS, Android, PC, MAC,...) ,寬鬆的 type validation 不但會增加存錯資料的風險也會提高 Server 檢查參數的複雜度,於是漸漸地你會發現這五種預設 Scalar Typ 似乎不太夠用,你需要自定義的 Scalar Type 來幫助你達到以下三點:

  1. 讓 Clinet 與 Server 的開發者對於資料格式有共同的認知
  2. 強迫 Client 的送出正確格式的 query
  3. 強迫 Server 的回覆正確格式的 response

接下來就讓我為大家介紹一下如何快速上手創造自己的 Scalar Type 吧!


1. 建立一個 Date Scalar Type

接著就來實作一個 Date Scalar Type ,我們希望的行為有

  1. Client: query 的參數值使用 Date Scalar Type 時需要用 Unix Epoch timestamp in milliseconds (毫秒)。
  2. Server: 接收到 Date Scalar Type 的參數時要轉成 JS 的 Date 物件再給 Resolver 處理。
  3. Server: 回覆 Client 時 Date Scalar Type 的資料要轉成 Unix Epoch timestamp in milliseconds (毫秒) 。

PS. Unix Epoch timestamp in milliseconds 是從 1970-01-01 00:00 到現在的毫秒數,可參考此網站得知目前時間 : EpochConverter

實作一個 Custom Scalar Type 需要兩部分,分別為 Schema 部分與 Resolver 部分。

1-1. 建立 Date Scalar Type - Schema 宣告部分

Schema 部分相當單純,直接宣告後就可以在其他 Object Type 或是 Input Object Type 中使用。

"""
日期格式。顯示時以 Unix Timestamp in Milliseconds 呈現。
"""
scalar Date

# 宣告後就可以在底下直接使用
type Query {
  # 獲取現在時間
  now: Date
  # 詢問日期是否為週五... TGIF!!
  isFriday(date: Date!): Boolean
}

1-2. 建立 Date Scalar Type - Resolvers 實作

有了 Schema 宣告,按照慣例也需要 Resolver 去實作 Scalar Type 的內容!不過 Custom Scalar Type 的 Resolver 實作比較複雜一點,接著我會詳細講解每個部分 (底下我也會講如何直接 import 別人寫好的 Resolver 省去維護的麻煩 XD)

首先要先 import 進來兩個工具,分別為

  1. const { GraphQLScalarType } = require('graphql')
    GraphQLScalarType 是一個用來建造新的 Scalar Type 的 class
  2. const { Kind } = require('graphql/language')
    在建造新的 GraphQLScalarType 時,裡面的 parseLiteral function 會用 Kind 來檢查 Type 是否合乎需求

接著要在 Resolver function 中定義 Custom Scalar Type 的實作方式,而新的 GraphQLScalarType 初始化時需要以下參數:

const resolvers = {
  Date: new GraphQLScalarType({
    name: '',
    description: '',
    serialize(value) {
      // value sent to the client
      return '';
    },
    parseValue(value) {
      // value from the client (variables)
      return '';
    },
    parseLiteral(ast) {
      // value from the client (inline arguments)
      switch(ast.kind) {}
      return '';
    }
  })
}
  1. name (Required) Scalar Type 名稱 (需對上 schema 定義時的名稱)
  2. description (Optional) Scalar Type 介紹
  3. serialize(value) (Required) Server 回覆給 Client 的值。
    當 Server 在 Resolver 處理完資料輸出時,會將結果以 value 傳進來,而 serialize 決定最後輸出的值。
    需注意!這邊輸出的值的型別只要是 JSON 格式允許的值都行,如 Int, String, Object, Array 等等。
  4. parseValue(value) (Required) Client 傳給 Server 的值, value 會從 variables 中獲得。
  5. parseLiteral(ast) (Required) Client 傳給 Server 的值, ast 會從 query 字串中解析出來,而 ast 的值是一個 AST 格式的 Object,舉個例子如下:
{
  kind:"IntValue" // 輸入參數的型別
  loc: {start: 84, end: 97, startToken: Tok, …} // 在 query 中的位址
  value:"1540791381379" // 輸入參數的值 (皆為 string 格式)
}

所以定義一個 Date 的 Resolver Fucntion 會像是這樣:

const { GraphQLScalarType } = require('graphql');
const { Kind } = require('graphql/language');
const resolvers = {
  Date: new GraphQLScalarType({
    name: 'Date',
    description: 'Date custom scalar type',
    serialize(value) {
      // 輸出到前端
      // 回傳 unix timestamp 值
      return value.getTime();
    },
    parseValue(value) {
      // 從前端 variables 進來的 input
      // 回傳 Date Object 到 Resolver
      return new Date(value);
    },
    parseLiteral(ast) {
      // 從前端 query 字串進來的 input
      // 這邊僅接受輸入進來的是 Int 值
      if (ast.kind === Kind.INT) {
        // 回傳 Date Object 到 Resolver (記得要先 parseInt)
        return new Date(parseInt(ast.value, 10)); // ast value is always in string format
      }
      return null;
    }
  }),
}

這邊可能還是有人很困惑為什麼 parseLiteral 傳入的是 ast ,因為當 Client 傳來 query 時, Server 只接收到一個純字串,所以需要靠 GraphQL 去 parse 這個字串成一個 AST Object (Abstract Syntax Tree) 格式供程式去解析 query 的內容。

因此如果 query 是以

query {
  isFriday(date: 1540791381379)
}

來傳入,那麼裡面的 date 參數也會跟著被寫進 AST 中,進而觸發 parseLiteral ,然後我們再從 ast 中取出值來。所以一個 ast 物件會有 kind (輸入參數型別)、loc (query 中的位址)、 value (輸入參數的值 - string 格式)

而如果 query 是搭配 varialbes 方式輸入:

query ($date: Date!) {
  isFriday(date: $date)
}
---
VARIABLES
{
  "date": 1540791381379
}

date 參數就會隨著 varialbes 以 JSON 格式傳進 GraphQL Server,進而觸發 parseValue

最後來看整個程式碼長怎樣:

1-3. 建立 Date Scalar Type - 完整程式

const { ApolloServer, gql } = require('apollo-server');
const { GraphQLScalarType } = require('graphql');
const { Kind } = require('graphql/language');

const typeDefs = gql`
  scalar Date

  type Query {
    # 獲取現在時間
    now: Date
    # 詢問日期是否為週五... TGIF!!
    isFriday(date: Date!): Boolean
  }
`;

const resolvers = {
  Date: new GraphQLScalarType({
    name: 'Date',
    description: 'Date custom scalar type',
    serialize(value) {
      // value sent to the client
      // 輸出到前端
      return value.getTime();
    },
    parseValue(value) {
      // value from the client (variables)
      // 從前端 variables 進來的 input
      return new Date(value);
    },
    parseLiteral(ast) {
      // value from the client (inline)
      // 從前端 inline variables 進來的 input
      if (ast.kind === Kind.INT) {
        return new Date(parseInt(ast.value, 10)); // ast value is always in string format
      }
      return null;
    }
  }),
  Query: {
    now: () => new Date(),
    isFriday: (root, { date }) => date.getDay() === 5
  }
};

const server = new ApolloServer({ typeDefs, resolvers });

server.listen().then(({ url }) => {
  console.log(`? Server ready at ${url}`);
});

1-4. 建立 Date Scalar Type - Demo

說了這麼多,終於要 DEMO 了!

先取得現在的 unix timestamp: 1540791381379 (2018-10-29 5:36 AM +0) 再輸入 query:

query ($date: Date!) {
  now
  parseValueDemo: isFriday(date: 1540791381379)
  parseLiteralDemo:isFriday(date: $date)
}
---
VARIABLES
{
  "date": 1540791381379
}

結果如圖:

img

可以自己在 parseValueparseLiteral 中加入 console.log 來檢查看看背後的機制是否如上所說 ~

另外可以找一下上週五中午十二點的時間:1540555200000 帶進去看看 isFriday 是否正確。

如圖:

img

但是如果 Unix Timestamp 輸入的是 Stirng 的話,因為目前沒有特別處理 String 的狀況,所以會變成如下圖的結果。
img

  • parseValue: 如果 new Date(timestamp)timestamp 為 String 格式會出錯。
  • parseLiteral: 因為 ast.kindKind.STRINGKind.INT,所以會送出 null 值導致之後的 Error。

自行定義雖然較為麻煩,但好處是控制度高,如果有特殊需求的朋友可以考慮。

2. 引入外部套件實作 Custom Scalar Type

不過要自己維護也是蠻麻煩的,用一些線上的套件可以一次使用大量已經寫好的 Custom Scalar Type Resolver 。

這邊推薦 @okgrow/graphql-scalars ,裡面定義很多實用的 Resolver Functions ,只需要 import 進來放進 resolvers 中就行了!

2-1. 引入外部套件 - Schema part

Schema 部分一樣也是要先宣告

scalar DateTime

type Query {
  # 獲取現在時間
  now: DateTime
  # 詢問日期是否為週五... TGIF!!
  isFriday(date: DateTime!): Boolean
}

2-1. 引入外部套件 - Resolver part

const { DateTime } = require('@okgrow/graphql-scalars');

const resolvers = {
  ...,
  DateTime,
  ...
}

就完成了 !
這邊的 DateTime 在 Resolver 時一樣給你 Date Object ,但在 Client 的 query 輸入及 Server 的 response 時會是 ISO 格式,可參考下圖。

可以輸入 query 來測試

query ($date: DateTime!) {
  now
  parseLiteralDemo: isFriday(date: "2018-10-26T10:10:10.000Z")
  parseValueDemo:isFriday(date: $date)
}
---
VARIABLES
{
  "date": "2018-10-10T10:10:10.000Z"
}

結果如圖所示:

img

裡面還有其他常見的 URL, Eamil, USCurrency, JSON 等等格式,可以自己來嘗試!

延伸閱讀: https://medium.com/graphql-mastery/how-to-design-graphql-queries-and-mutations-part-3-custom-scalars-78d441869258

2. 經驗談

那麼什麼時候我們需要 Custom Scalar Type 。

可以參考 Shopify 的建議是:

  1. 當你需要一個有特殊語意的值時,就使用 Custom Scalar Type
  2. 但如果你的值是比較模糊的格式如 email ,這類在 client 端檢查較為複雜的格式的話,建議使用寬一點的 type 如 String
  3. 而如果你的值是定義比較明確如 DateTime 就是要 ISO 格式不接受其他,那就可以考慮使用 Custom Scalar Type 來規範 Client 不能亂傳

而我的經驗是,一開始都先使用 String ,只有當對於特殊語意的 Scalar Type 的需求夠多時,比如每個 Business Model 都要有個 createdAt ,那我就會考慮使用為 createdAt 建立一個 Date Scalar Type 。

不然有時候 Custom Scalar Type 也是把雙面刃,用錯時機點可能造成 Schema 規範過多不夠彈性。


Reference :


上一篇
打造一個 GraphQL API Server 應用:部落格社交軟體 - 4 (加入 database)
下一篇
GraphQL 入門: 給我更多的彈性! 建立自己的 Directives
系列文
Think in GraphQL30

1 則留言

1
imakou
iT邦新手 5 級 ‧ 2018-10-29 22:10:07

簽到支持

fx777 iT邦新手 5 級 ‧ 2018-10-29 22:54:08 檢舉

感恩感恩 QQ

真的寫到快吐血了哈哈~等比賽結束一定要回來把文章修到好 XD

我要留言

立即登入留言