0%

建立 GraphQL API (3)

自訂純量 Custom Scalars

GraphQL 有一群預設的純量型態可以在任何欄位中使用。Int、Float、String、Boolean 與 ID 之類的純量可以在大部分的情況下使用,但有時你可能需要建立自訂的純量型態來滿足資料的需求。

當我們實作自訂純量時,必須建立一些關於如何序列化和驗證型態的規則。例如,當我們建立 DateTime 型態時,也要定義怎樣的 DateTime 才可以視為有效的。

接下來要在 typeDefs 裡面加入這個自訂的 DateTime 純量型態,並且在 Photo 型態的 created 欄位使用它。我們用 created 欄位來存儲照片被貼出的日期與時間。

index.js
1
2
3
4
5
6
7
8
9
10
const typeDefs = `
...
scalar DateTime

type Photo {
...
created: DateTime!
}
...
`

schema 的每一個欄位都要對應一個解析函式。created 欄位要對應一個 DateTime 型態的解析函式。為 DataTime 建立自訂純量型態的原因是我們想要將任何使用這個純量的欄位解析為 JavaScript Date 型態並加以驗證。

考慮各種用字串來表示日期與時間的方式,以下的字串都代表有效的日期:

  • “7/30/2019”
  • “7/30/2019 1:08:23 PM”
  • “Tue Jul 30 2019 13:08:23 GMT+0800 (Taipei Standard Time)”
  • “2019-07-30T05:08:23.960Z”

我們可以用 JavaScript 將上面的任何字串做成 datetime 物件:

1
2
3
var d = new Date("7/30/2019");
console.log(d.toISOString());
// 2019-07-29T16:00:00.000Z 這是 ISO 格式,注意它的日期

上面的程式用一種格式建立一個新的日期物件,接著將那個 datetime 字串轉換成 ISO 格式的日期字串。

這個 JavaScript Date 不瞭解的東西都是無效的。你可以試著解析下面的資料:

1
2
3
var d = new Date("Tuesday July");
console.log(d);
// Invalid Date

我們想要在查詢照片的 created 欄位時,確定這個欄位回傳的值含有 ISO 日期時間格式的字串。當欄位回傳日期值時,我們會將那個值 serialize (序列化) 為 ISO 格式的字串:

1
const serialize = value => new Date(value).toISOString();

序列化函式會從物件取出欄位值,只要那個欄位含有 JavaScript 物件格式的日期,或任何有效的 datetime 字串
,GraphQL 就會用 ISO datetime 格式回傳它。

當你在 schema 實作自訂的純量之後,就可以在 query 中將它當成引數來使用。假設我們為 allPhotos query 建立一種過濾器。這個 query 可以回傳在指定的日期之後拍攝的照片串列:

1
2
3
4
type Query {
...
allPhotos(after: DateTime): [Photo!]!
}

有這個欄位時,用戶端就可以傳送一個含有 DateTime 值的 query:

1
2
3
4
5
6
query recentPhotos($after: DateTime) {
allPhotos(after: $after) {
name
url
}
}

這可以使用查詢變數來傳送 $after 引數:

1
2
3
{
"after": "7/30/2018"
}

我們想要在 after 引數被傳送到解析函式之前確保它已經被解析成 JavaScript Date 物件了:

1
const parseValue = value => new Date(value);

我們可以使用 parseValue 函式來解析與 query 一起送來的字串的值。 parseValue 函式回傳的值都會被傳給解析函式的引數 args:

1
2
3
4
5
6
7
8
const resolvers = {
Query: {
allPhotos: (parent, args) => {
args.after // JavaScript Date 物件
...
}
}
}

自訂純量必須能夠序列化與解析日期。

我們還有一個地方需要處理日期字串: 當用戶端直接在 query 本身加入日期字串時

1
2
3
4
5
6
query {
allPhotos(after: "7/30/2018") {
name
url
}
}

如果 after 引數不是用查詢變數來傳遞的,它已經被直接加入查詢文件了。我們必須在這個值被解析成抽象語法
樹 (AST) 之後,從 query 取出它才能解析它。在解析這些值之前,我們使用 parseLiteral 函式從查詢文件中取出它們:

1
const parseLiteral = ast => ast.value;

我們用 parseLiteral 函式來取得被直接加入查詢文件的日期值。在本例中,我們只要回傳那個值即可,但在必要時,我們也可以在這個函式內執行額外的解析步驟。

當我們建立自訂純量時,需要使用為了處理 DateTime 值而設計的三個函式。我們加入自訂純量 DateTime 的解析函式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { GraphQLScalarType } = require('graphql');
...
const resolvers = {
Query: { ... },
Mutation: { ... },
Photo: { ... },
User: { ... },
DateTime: new GraphQLScalarType({
name: "DateTime",
description: "有效的日期時間值",
parseValue: value => new Date(value),
serialize: value => new Date(value).toISOString(),
parseLiteral: ast => ast.value
})
}

我們使用 GraphQLScalarType 建構式來建立 “自訂純量” 的解析函式。我們將 DateTime 解析函式放在解析函式清單中。當我們建立純量型態時,必須加入三個函式: serializeparseValueparseLiteral,它們會處理任何實作 DateTime 純量的欄位或引數。

我們也要在樣本資料中加入 created 鍵與日期值,使用任何一個有效的日期字串或物件都可以,因為我們建立的欄位會先被序列化再回傳:

models/data.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
exports.photos = [
{
...
created : "3-28-2018"
},
{
...
created : "1-2-2015"
},
{
...
created : "2019-07-30T05:08:23.960Z"
}
]

現在,當我們在選擇組加入 created 欄位時,可以看到這些日期與型態都被格式化成 ISO 日期字串了:

1
2
3
4
5
6
query listPhotos {
allPhotos {
name
created
}
}

接下來的工作只剩下在每張照片被貼出時為它們加上時戳。我們在每張照片加入一個 created 欄位,並且用 JavaScript Date 物件與自訂型態 DateTime 來加上及驗證時戳。

1
2
3
4
5
6
7
8
9
postPhoto(parent, args) {
let newPhoto = {
id: _id++,
...args.input,
created: new Date()
};
photos.push(newPhoto);
return newPhoto;
}

現在當新照片被貼出時,就會被加上它們的建立日期與時間時戳了。

postPhoto
1
2
3
4
5
6
7
8
9
10
11
12
mutation newPhoto {
postPhoto(input: {
name: "範例照片 haha",
description: "這是範例照片 after custom scalar"
}) {
id
name
description
url
created
}
}
results
1
2
3
4
5
6
7
8
9
10
{
"data": {
"postPhoto": {
"id": "5",
"name": "範例照片 haha",
"description": "這是範例照片 after custom scalar",
"url": "https://fakeimg.pl/120x160/?text=5",
"created": "2019-07-30T08:04:57.022Z"
}
}

抽象語法樹 (abstract syntax tree, AST)

GraphQL query 文件是個字串。當我們傳送 query 給 GraphQL API 時,字串會被解析成抽象語法樹,並且在操作執行之前進行驗證。抽象語法樹 (AST) 是一種代表 query 的階層式物件。AST 是個含有內嵌欄位的物件,裡面的欄位代表 GraphQL query 的細節。

解析程序的第一個步驟是將字串解析成一堆較小的片段,這個步驟包括將關鍵字、引數,甚至括號與冒號解析成單獨的標記,這個程序稱為詞法分析 (lexing 或 lexical analysis)。接下來將詞法分析後的 query 解析成 AST。使用 AST 可讓動態修改與驗證 query 的工作輕鬆許多。

GraphQL query 是一種 GraphQL 文件。文件至少要有一個定義(Definition),也可能有一串定義。

  • 定義只有可能是兩種型態: OperationDefinition 或 FragementDefinition。
  • 一個 OperationDefinition 只能含有三種操作型態: mutation、query 或 subscription。
  • 每一個操作定義都有 OperationType 與 SelectionSet。

在每一個操作後面的大括號 { } 內都有該操作的 SelectionSet,它們就是我們查詢的欄位。

GraphQL 可以遍歷這個 AST 並且用 GraphQL 語言與目前的 schema 來驗證它的細節。如果查詢語言的語法是正確的,且 schema 含有我們請求的欄位與型態,該操作就會執行。如果情況不是如此,就會回傳特定的錯誤。

此外,AST 物件比字串容易修改。如果我們想要在 query 附加另外的欄位資料,可直接修改 AST。我們只要為特定的操作加入一個額外的 SelectionSet 就可以了。AST 是 GraphQL 很重要的成分。每一項操作都會被解析成 AST,以便對它進行驗證並最終執行它。

模塊化的 GraphQL 架構

到此我們會發覺 index.js 文件隨著 schema 與 resolvers 而不斷的增長,我們不能繼續都將程式碼定義在一個單一的文件中,我們必須模組化。

模式拼接 (Schema stitching) 是 GraphQL 中的一個強大功能。它是將多個 GraphQL 模式合併到一個模式中。目前,我們應用程序中的模式尚屬單純,但將來可能需要使用多個模式和模式拼接更複雜的操作。其中每個模式都匹配一種類型 (例如 User 使用者類型,Photo 照片類型),該操作需要合併兩個 GraphQL 模式,以便能使用 GraphQL 伺服器的 API 訪問整個 GraphQL 模式。這是架構拼接背後的基本動機之一。

甚至可以更進一步,最終可能會出現微服務或第三方平台,這些平台各有其專用的 GraphQL API,然後可以將它們合併到一個 GraphQL 架構中,併接成為單一的事實來源。最後,客戶端可以使用整個模式,而該模式由多個應用領域驅動的微服務組成。

在我們的例子中,讓我們從 GraphQL schema 和 resolvers 的技術問題開始分離。之後,您將按使用者(User)和照片(Photo)的應用域(domain)分隔。

首先在我們的專案目錄下建立兩個子目錄: schema 與 resolvers,我們要將原先定義在 index.js 的 typeDefs 與 resolvers 的程式碼分離出來,並放入這子目錄中。

schema/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
const { gql } = require("apollo-server-express");

module.exports = gql`
type Query {
sayHello: String!
totalPhotos: Int!
allPhotos: [Photo!]!
}

input PostPhotoInput {
name: String!
category: PhotoCategory = PORTRAIT
description: String
}

type Mutation {
postPhoto(input: PostPhotoInput!): Photo!
}

enum PhotoCategory {
SELFIE
PORTRAIT
ACTION
LANDSCAPE
GRAPHIC
}

scalar DateTime

type Photo {
id: ID!
url: String!
name: String!
description: String
category: PhotoCategory!
postedBy: User!
taggedUsers: [User!]!
created: DateTime!
}

type User {
appLogin: ID!
name: String
avatar: String
postedPhotos: [Photo!]!
inPhotos: [Photo!]!
}
`;

GraphQL schema 的定義是一個字符串,原先我們使用 JavaScript 的模板文字 (template literals) 編寫。這裡改用 gql,gql 是一種 JavaScript 模板文字標記,用於將 GraphQL 查詢字符串解析為標準 GraphQL AST或抽象語法樹。

原先使用字符串編寫並沒甚麼錯, 但是,如果您嘗試添加額外的欄位,或要將多個 GraphQL 文件或查詢合併在一起,則一般的字符串操作起來不怎麼方便。

再來就是 resolvers 子目錄下的 index.js:

resolvers/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const { users, photos, tags } = require("../models/data");
const { GraphQLScalarType } = require("graphql");
const timestamp = require('monotonic-timestamp');

module.exports = {
Query: {
sayHello: () => "哈囉,台南! 就從這裡開始。",
totalPhotos: () => photos.length,
allPhotos: () => photos
},
Mutation: {
postPhoto(parent, args) {
let newPhoto = {
id: timestamp().toString(),
...args.input,
created: new Date()
};
photos.push(newPhoto);
return newPhoto;
}
},
Photo: {
url: parent => `https://fakeimg.pl/120x160/?text=${parent.id}`,
postedBy: parent => {
return users.find(u => u.appLogin === parent.appUser);
},
taggedUsers: parent =>
tags
// 回傳一個只含當前照片的 tag 陣列
.filter(tag => tag.photoID === parent.id)
// 將 tag 陣列轉換成 userID 陣列
.map(tag => tag.userID)
// 將 userID 陣列轉換成使用者物件陣列
.map(userID => users.find(u => u.appLogin === userID))
},
User: {
postedPhotos: parent => {
return photos.filter(p => p.appUser === parent.appLogin);
},
inPhotos: parent =>
tags
// 回傳一個只含有當前使用者的 tag 陣列
.filter(tag => tag.userID === parent.appLogin)
// 將 tag 陣列轉換成 PhotoID 陣列
.map(tag => tag.photoID)
// 將 photoID 陣列轉換成照片物件陣列
.map(photoID => photos.find(p => p.id === photoID))
},
DateTime: new GraphQLScalarType({
name: "DateTime",
description: "有效的日期時間值",
parseValue: value => new Date(value),
serialize: value => new Date(value).toISOString(),
parseLiteral: ast => ast.value
}),
};

注意這裡的 models/data 路徑不一樣了。也改用 monotonic-timestamp 套件來產生照片的 id,可以使用 npm 安裝此套件。

1
npm install monotonic-timestamp --save

最後就要修改專案目錄下的 index.js

index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const express = require('express');
const { ApolloServer } = require('apollo-server-express');
const typeDefs = require('./schema');
const resolvers = require('./resolvers');

const app = express();

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

server.applyMiddleware({ app });

app.get('/', (req, res) => res.end("Welcome to the GraphQL Sample API"));

app.listen({ port: 4000 }, () => {
console.log(`GraphQL Server running @ http://localhost:4000${server.graphqlPath}`);
});

把 schema 與 resolvers 加進來,現在整個專案的入口點變的清爽多了。但故事還沒完,如果 schema 與 resolvers 越來越多,是不是也可以再以應用域 (domain) 模組化?

回到 schema 目錄下,我們還需要再改造 schema/index.js。建立兩個新檔案 user.js 與 photo.js 我們要將
User 與 Photo 分開定義。

schema/user.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { gql } = require("apollo-server-express");

module.exports = gql`
extend type Query {
sayHello: String!
}

type User {
appLogin: ID!
name: String
avatar: String
postedPhotos: [Photo!]!
inPhotos: [Photo!]!
}
`;
schema/photo.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const { gql } = require("apollo-server-express");

module.exports = gql`
extend type Query {
totalPhotos: Int!
allPhotos: [Photo!]!
}

extend type Mutation {
postPhoto(input: PostPhotoInput!): Photo!
}

input PostPhotoInput {
name: String!
category: PhotoCategory = PORTRAIT
description: String
}

enum PhotoCategory {
SELFIE
PORTRAIT
ACTION
LANDSCAPE
GRAPHIC
}

scalar DateTime

type Photo {
id: ID!
url: String!
name: String!
description: String
category: PhotoCategory!
postedBy: User!
taggedUsers: [User!]!
created: DateTime!
}
`;

注意這理 Query 和 Mutation 類型的 extend 語句。每個文件只描述自己的實體,包含類型及其關係。 關係可以是來自不同文件的類型,例如,與 User 使用者類型關係的 Photo 照片類型,即使類型是定義在不同的文件。由於現在有多種類型,因此需要擴展類型 (extend)。

接下來,在 schema/index.js 中為它們定義共享基類型:

schema/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const { gql } = require("apollo-server-express");

const userSchema = require('./user');
const photoSchema = require('./photo');

const linkSchema = gql`
type Query {
_: Boolean
}

type Mutation {
_: Boolean
}

type Subscription {
_: Boolean
}
`;

module.exports = [linkSchema, userSchema, photoSchema];

在此我們定義一個 linkSchema 共享基類型,它會使用其他特定於應用域 schema 中的 extend 語句進行擴展,它使用的是一種併接模式(Schema Stitching)。

現在我們也要分離解析器的映射,resolvers/user.js 與 resolvers/photo.js:

resolvers/user.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const { photos, tags } = require("../models/data");

module.exports = {
Query: {
sayHello: () => "哈囉,台南! 就從這裡開始。"
},

User: {
postedPhotos: parent => {
return photos.filter(p => p.appUser === parent.appLogin);
},
inPhotos: parent =>
tags
// 回傳一個只含有當前使用者的 tag 陣列
.filter(tag => tag.userID === parent.appLogin)
// 將 tag 陣列轉換成 PhotoID 陣列
.map(tag => tag.photoID)
// 將 photoID 陣列轉換成照片物件陣列
.map(photoID => photos.find(p => p.id === photoID))
}
};
resolvers/photo.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const { users, photos, tags } = require("../models/data");
const { GraphQLScalarType } = require("graphql");
const timestamp = require('monotonic-timestamp');

module.exports = {
Query: {
totalPhotos: () => photos.length,
allPhotos: () => photos
},
Mutation: {
postPhoto(parent, args) {
let newPhoto = {
id: timestamp().toString(),
...args.input,
created: new Date()
};
photos.push(newPhoto);
return newPhoto;
}
},
Photo: {
url: parent => `https://fakeimg.pl/120x160/?text=${parent.id}`,
postedBy: parent => {
return users.find(u => u.appLogin === parent.appUser);
},
taggedUsers: parent =>
tags
// 回傳一個只含當前照片的 tag 陣列
.filter(tag => tag.photoID === parent.id)
// 將 tag 陣列轉換成 userID 陣列
.map(tag => tag.userID)
// 將 userID 陣列轉換成使用者物件陣列
.map(userID => users.find(u => u.appLogin === userID))
},
DateTime: new GraphQLScalarType({
name: "DateTime",
description: "有效的日期時間值",
parseValue: value => new Date(value),
serialize: value => new Date(value).toISOString(),
parseLiteral: ast => ast.value
})
};
resolvers/index.js
1
2
3
4
const userResolvers = require('./user');
const photoResolvers = require('./photo');

module.exports = [userResolvers, photoResolvers];

由於 Apollo Server 也接受一個解析器映射陣列,resolvers/index.js 文件中導入所有解析器映射,並將它們 export 為解析器映射列表。現在的模組化好多了。

接下來我們需要把資料移往資料庫了。

接續下一篇 建立 GraphQL API (4)