iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 15
2
Modern Web

Think in GraphQL系列 第 15

GraphQL 入門: 給我更多的彈性! 建立自己的 Directives

header

今天來跟大家介紹 GraphQL 的一個較少被人用到但十分好用的技巧: Diretives。比較常用於修飾 Schema 的定義來實現一些額外的功能或檢查。實際用起來會像是下圖所示。

img

使否開始有點感受到 Directives 的強大呢!讓我們接著看!


Directives 幫你實現 Type System 做不到的細節

Directives 可視為 GraphQL 的一種語法蜜糖 (sugar syntax),通常用於調整 query 及 schema 的行為,不同場景下可以有以下功能

  1. 影響 query 原有行為,如 @include, @skip 為 query 增加條件判斷
  2. 為 Schema 加上描述性標籤,如 @deprecated 可以用於廢除 schema 的某 field 又避免 breaking change
  3. 為 Schema 添加新功能,例如參數檢查、簡單計算、權限檢查、錯誤處理等等。不過這部分較為複雜,需自行定義。

Directives 可以用在 Client Side 的 query 也可以用於 Server Side 的 Schema Definition ,不過通常比較多用於 Schema Definition 中,一方面比較好維護,另一方面也減輕 Client 的計算負擔。

冷知識:通常在 query 使用的使用的 Directives 稱為 Executable Directive (或稱 Query Directive) , 在 Schema 中使用的稱為 Type System Directive (Schema Directive)。可以看 GraphQL Spec 裡面 (下圖)關於兩者的應用範圍,可以說 Directive 可以應用在任何地方只是實作難度的高地而已~
img
(圖片來源: https://facebook.github.io/graphql/June2018/#sec-Type-System.Directives)


1. Client Side Query + Directives

GraphQL 在 query side 原生支援的 Executable Directive 有兩個(也比較常用),分別為

  1. @include (if: Boolean!) : 用於判斷是否顯示此 field,若 if 為 true 則顯示。可用於 field 及 fragment 展開。
  2. @skip (if: Boolean!) : 用於判斷是否忽略此 field,若 if 為 false 則顯示。可用於 field 及 fragment 展開。

回到我們的社交 Server API 例子 (可點選練習傳送門):

我可以做出以下操作:

# 因為 skip 與 include 的參數都是必填 `!` ,所以在 Variable Definition 時也要加上 `!`
query ($includeFriends: Boolean!, $skipSensitiveData: Boolean!) {
  me {
    name
    ...sensitiveData @skip(if: $skipSensitiveData)
    friends @include(if: $includeFriends) {
      id
    }
  }
}
fragment sensitiveData on User {
  age
  weight
  height
}

----
Varaibles
{
  "includeFriends": false,
  "skipSensitiveData": true
}

請大家先思考一下會出現哪些資料呢? 想好後往下看

{
  "data": {
    "me": {
      "name": "Fong"
    }
  }
}

答案是只會出現 name 而已 XD ,不過當你調整 Variables 從 false,truetrue, false 後就會變成以下:

https://imgur.com/tgiqWuz

在這邊只要透過參數調整, directive 就可以有效減少前端 query 的數量也降低管理的負擔!

2. Server Side Schema Difinition + Directives

前面提到 Directives 可以為 Schema 添加描述性標籤或是添加新功能 (或是兩者兼具),所以我們先從添加描述性標籤開始,介紹一下同樣也是 GraphQL 原生支援的 Type System Directive @deprecated (reason: String = "No longer supported")

2-1. 使用 Type System Directives 標示 Deprecated 範例

今天公司覺得某些男性/女性使用者並不喜歡透露自己的體重,所以決定廢除這個欄位,但直接拿掉又怕系統出現問題,所以決定先 dreprecate 掉,所以讓我們修改 Schema:

type User {
  ...
  "體重"
  weight(unit: WeightUnit = KILOGRAM): Float @deprecated (reason: "It's secret")
}

不過需注意, deprecated 不代表說 Client Side 不能 query ,而是 Client Side 在閱讀 documentation 時,會發現此 field 已經 deprecated ,進而減少使用或修正目前的使用。
如圖所示, 只要 Server 的 Schema 與 Resolver 並未移除掉該 field , User.weight 仍然能取得值,只是在 documentation 中會將 User.weight 歸類為 Deprecated 。
https://imgur.com/kZ0byHe

3. Server Side Schema Difinition + 自定義 Directives

舉一個簡單的例子,實作一個 @uppper 的 Directives 讓回傳的 String 都以大寫形式呈現,我們需要做的有

  1. 引入外部套件
const { SchemaDirectiveVistor } = require('apollo-server');
const { defaultFieldResolver } = require('graphql');
  1. 新增一個繼承 SchemaDirectiveVistor 的 class UpperCaseDirective 來實作此 Directive (可視為 Directive 的 Resolver) ,再來透過 override SchemaDirectiveVistor 裡的相關 function 來做出想要的效果。
    這邊 @upper 只針對欄位,因此只需要實作 visitFieldDefinition
  2. 將步驟 2 新增的 class 放入 ApolloServer 初始化的參數列 在 option 添加新欄位 schemaDirectives 來將以上兩者連接。
  3. 在 Schema 中 (gql tag 裡) 定義新的 Directives directive @upper on FIELD_DEFINITION

接著來看在程式碼中如何實現。

// 1. 引入外部套件
const { ApolloServer, gql, SchemaDirectiveVisitor } = require('apollo-server');
const { defaultFieldResolver } = require('graphql');

// 2. Directive 實作
class UpperCaseDirective extends SchemaDirectiveVisitor {
  // 2-1. ovveride field Definition 的實作
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field;
    // 2-2. 更改 field 的 resolve function
    field.resolve = async function(...args) {
      // 2-3. 取得原先 field resolver 的計算結果 (因為 field resolver 傳回來的有可能是 promise 故使用 await)
      const result = await resolve.apply(this, args);
      // 2-4. 將得到的結果再做預期的計算 (toUpperCase)
      if (typeof result === 'string') {
        return result.toUpperCase();
      }
      // 2-5. 回傳最終值 (給前端)
      return result;
    };
  }
}

// 3. 定義新的 Directive
const typeDefs = gql`
  directive @upper on FIELD_DEFINITION

  type Query {
    hello: String @upper
  }
`;

// Provide resolver functions for your schema fields
const resolvers = {
  Query: {
    hello: (root, args, context) => {
      return 'Hello world!';
    }
  }
};

// 4. Add directive to the ApolloServer constructor
const server = new ApolloServer({
  typeDefs,
  resolvers,
  // 4. 將 schema 的 directive 與實作連接並傳進 ApolloServer。
  schemaDirectives: {
    upper: UpperCaseDirective
  }
});

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

這邊特別解釋一下因為 UpperCaseDirective 在宣告時 (directive @upper on FIELD_DEFINITION) 只應用於 FIELD_DEFINITION ,所以我們只需要 override visitFieldDefinition 一項,若想知道其他 Type 要 override 的 function 的話,可見 Apollo 官方提供的 API 如以下程式碼:

class SomeDirective extends SchemaDirectiveVisitor {
  visitSchema(schema: GraphQLSchema) {}

  visitObject(object: GraphQLObjectType) {}

  visitFieldDefinition(field: GraphQLField<any, any>) {}

  visitArgumentDefinition(argument: GraphQLArgument) {}

  visitInterface(iface: GraphQLInterfaceType) {}

  visitInputObject(object: GraphQLInputObjectType) {}

  visitInputFieldDefinition(field: GraphQLInputField) {}

  visitScalar(scalar: GraphQLScalarType) {}

  visitUnion(union: GraphQLUnionType) {}

  visitEnum(type: GraphQLEnumType) {}

  visitEnumValue(value: GraphQLEnumValue) {}
}

更多範例可以上 Apollo Server 2 - Implementing directives 上查詢,另外很多 directive 的實作都可以找到套件只要下載來傳入 ApolloServer 就 ok 了!

個人覺得他強大的地方還有可以做 ACL (Access Control List) 也就是權限管理,比如今天 Query 的 me 的資料只能給有登入的使用而不開放給沒有登入的 guest , 或是 me.age 只能給朋友看到,甚至是只有管理者 (Admin 可以刪除使用者)

type User {
  ...
  @friendOnly
  age
}

Query {
  @isAuthenticated
  me: User
}

type Mutation {
  @auth(requires: "ADMIN")
  deleteUser(id: ID!): User
}

讓我們來用之前社交軟體的例子來試試看 @isAuthenticated 吧 !

4. @isAuthenticated 實作

一樣分為兩部分: Schema 與 Resovler (IsAuthenticatedDirective class)

4-1. @isAuthenticated - Schema Definition

Schema 部分非常簡單,而我們目前僅用於 FIELD_DEFINITION 上~

directive @isAuthenticated on FIELD_DEFINITION

type Query {
  me: User @isAuthenticated
}

4-2. @isAuthenticated - Resolver

接著來定義 IsAuthenticatedDirective class 。

class IsAuthenticatedDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field;
    field.resolve = async function(...args) {
      const context = args[2];
      // 檢查有沒有 context.me
      if (!context.me) throw new ForbiddenError('Not logged in~.');

      // 確定有 context.me 後才進入 Resolve Function
      const result = await resolve.apply(this, args);
      return result;
    };
  }
}

const resolvers = {
  Query: {
    // 這邊純做資料存取邏輯
    me: (root, args, { me, userModel }) => userModel.findUserByUserId(me.id),
    ...
  }
}

const server = new ApolloServer({
  typeDefs,
  resolvers,
  schemaDirectives: {
    // 一樣要記得放進 ApolloServer 中
    isAuthenticated: IsAuthenticatedDirective
  }
});

4-2. @isAuthenticated - Demo

接著就讓我們 Demo 看看!來個不帶 tokenme 試試看~結果如下圖

img

成功!

延伸閱讀: Apollo Draft specification for GraphQL Schema Decorators

5. 其他有趣的 @directive

type User {
  firstName: String
  lastName: String
  fullName: String @computed(value: "$firstName $lastName")
}

type Query {
  me: User
}
;
  • date formating 規定時間的格式。雖然前一天有提到可以實作自己的 Date Scalar Type ,不過也可以透過 directive 來做更彈性的時間格式管理。
directive @date(format: String) on FIELD_DEFINITION

scalar Date

type Post {
  published: Date @date(format: "mmmm d, yyyy")
}

或甚至把 format 交給 Client 決定!

directive @date(
  defaultFormat: String = "mmmm d, yyyy"
) on FIELD_DEFINITION

scalar Date

type Query {
  today: Date @date
}
directive @length(max: Int) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION

type Query {
  books: [Book]
}

type Book {
  title: String @length(max: 50)
}

type Mutation {
  createBook(book: BookInput): Book
}

input BookInput {
  title: String! @length(max: 50)
}

其實 Apollo 還有另一種 directiveResolvers 的作法,有點類似 middleware 的概念,實作起來雖然功能較少但更簡單!不過 Apollo 未來不一定會繼續支援,所以要用的請小心 QQ ,可參考 這篇

雖然 Directive 功能強大,但目前 Directive 的應用還不算廣且還有許多改進空間 (實作難度偏高),所以可以審視自身需求來判斷是否真的要新增一個 Directive 。

而自定義 Directive 目前在 Schema Defintion 比較常見 (如 SchemaDirectiveVisitor 字面所言),在 query 方面比較少見,以 Apollo 官方文件說法 來看,他們認為 Directive 比較適合用於 Schema 這邊,一方面比較好維護 Directive ,另一方面也可以減少 Client 的計算負擔,不過 Apollo 也說如果真的有這類需求他們也會考慮未來實作出來 (其實現在就有實驗性質的 Query Directive @defer 讓 Client 在請求大量資料時可以延遲取得,可參考這篇


Reference:


上一篇
GraphQL 入門: 實作 Custom Scalar Type (Date Scalar Type)
下一篇
GraphQL 入門: Interface & Union Type 的多型之旅
系列文
Think in GraphQL30

1 則留言

0
kpman
iT邦新手 5 級 ‧ 2018-11-08 00:52:01

@skiip typo,煩請修正一下~

fx777 iT邦新手 5 級 ‧ 2018-11-08 01:38:10 檢舉

感謝回報!

我要留言

立即登入留言