GraphQL + Express + MySQL 實戰學習筆記

雖然 GraphQL 在 15 年就已經發布,但直至最近我才真正開始學習它,學習的主要原因是因為:

  1. GraphQL 是 Facebook 開發的
  2. 藉由 GraphQL 連接資料,相較 RESTful API 來說可以減少很多 request 的次數
  3. GraphQL 按需索取資料,不需要把所有 data 都抓回來
    …….

這幾個優點再加上想要學習新技術,我終於開始踏出學習 GraphQL 的第一步。

學習影片及文檔:https://graphql.org/graphql-js/https://www.youtube.com/watch?v=0orjXWr-isM&list=PLwDQt7s1o9J7hv4T8G4vJ9_SXKvz_4Q3p

GraphQL特點

  1. 請求需要的屬性。
    • 例如:account 中有 name, age, sex, department…等,可以只取得需要的屬性。
  2. 獲取多個資源只用一個請求。
  3. 描述所有可能型別的系統,便於維護,根據需求平滑演進,添加或者隱藏屬性。

live demo:https://graphql.org/swapi-graphql

GraphQL 與 Restful API 對比

RESTful : Representational State Transfer 表屬性狀態轉移,本質上就是用定義 uri,透過 API 來取得資源。通用系統架構且不受語言限制。

  • RESTful 一個 API 只能返回一個資源,GraphQL 一次可以獲取多個資源
  • RESTful 用不同的 url 來區分資源,GraphQL 用型別區分資源

使用 express 和 graphql

安裝依賴:

npm i express express-graphql graphql

首先引入express 和 graphql 庫:

var express = require('express');
var { graphqlHTTP } = require('express-graphql');
var { buildSchema } = require('graphql');

構建 Schema,在 schema 中定義查詢的語句和類型:

var schema = buildSchema(`
	type Query {
		hello: String
	}
`);

定義查詢所對應的 resolver,也就是查詢對應的處理器:

var root = {
	hello: () => {
		return 'Hello, world!'; // 返回值必須是 string 
	}
}

使用 express + graphql:

var app = express();
app.use("/graphql", graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true, // 調試時設為 true
}));
app.listen(4000);

基本操作

完整程式碼:

const express = require('express');
const { graphqlHTTP } = require('express-graphql');
var { buildSchema } = require("graphql");

var schema = buildSchema(`
	type Query {
		hello: String
	}
`);

var root = {
  hello: () => {
    return "Hello, world!"; // 返回值必須是 string
  }
};

var app = express();
app.use(
  "/graphql",
  graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true // 調試時設為 true
  })
);
const port = 4000;
app.listen(port);
console.log(`http://localhost:${port}`);

開啟 http://localhost:4000/graphql

graphql 1 GraphQL + Express + MySQL 實戰學習筆記
// input
query {
  hello
}
// output
{
  "data": {
    "hello": "Hello, world!"
  }
}

自定義型別

var schema = buildSchema(`
    type Account {
        name: String
        age: Int
        sex: String
        department: String
    }
	type Query {
		hello: String
        accountName: String
        account: Account
	}
`);

var root = {
  hello: () => {
    return "Hello, world!"; // 返回值必須是 string
  },
  accountName: () => {
    return "林志玲"
  },
  account: () => {
    return {
        name: "周杰倫",
        age: 18,
        sex: "男",
        department: "教師"
    }
  }
};

輸入:

query {
  hello
  accountName
  account {
    name
    age
    sex
    department
  }
}

輸出:

{
  "data": {
    "hello": "Hello, world!",
    "accountName": "林志玲",
    "account": {
      "name": "周杰倫",
      "age": 18,
      "sex": "男",
      "department": "教師"
    }
  }
}

型別錯誤

假設 age 為 Int 型別但我輸入的是 String:

// ...
type Account {
    name: String
    age: Int // 數字
    sex: String
    department: String
}
// ...
account: () => {
    return {
        name: "周杰倫",
        age: "十八", // 字串
        sex: "男",
        department: "教師"
    }
}

輸入:

query {
  hello
  accountName
  account {
    name
    age
    sex
    department
  }
}

輸出:

{
  "errors": [
    {
      "message": "Int cannot represent non-integer value: \"十八\"",
      "locations": [
        {
          "line": 6,
          "column": 5
        }
      ],
      "path": [
        "account",
        "age"
      ]
    }
  ],
  "data": {
    "hello": "Hello, world!",
    "accountName": "林志玲",
    "account": {
      "name": "周杰倫",
      "age": null,
      "sex": "男",
      "department": "教師"
    }
  }
}

graphql 會告知你錯誤在哪裡,比如上方的例子就是錯誤在 account 中的 age,具體錯誤訊息為 十八 並不是 Int 型別。

安裝 nodemon 監視 node

使用 nodemon 取代 node,可以 hot reload:

npm install -g nodemon

重新運行:

nodemon <Your file name>.js
nodemon GraphQL + Express + MySQL 實戰學習筆記

參數型別與參數傳遞

基本參數型別

  1. String
  2. Int
  3. Float
  4. Boolean
  5. ID (不重複的字串)
  • [類型] 代表陣列,例如 [Int] 代表整數陣列。

要注意的地方是,傳參時記得要定義型別

type Query {
    rollDice(numDice: Int!, numSides: Int): [Int] // 返回的值是整數型陣列
}

PS.驚嘆號代表的是不能為空,即 numDice 不能為空。

舉個例子

const schema = buildSchema(`
    type Query {
        getClassMates(classNo: Int!): [String]
    }
`);

const root = {
  // arg.classNo
  getClassMates({ classNo }) {
    const obj = {
      31: ['張叁', '李四', '王五'],
      61: ['林志玲', '周杰倫', '蔡依林'],
      27: ['李白', '陶淵明', '王維']
    }
    return obj[classNo];
  },
};

輸入:

query {
  getClassMates(classNo: 31)
}

輸出:

{
  "data": {
    "getClassMates": [
      "張叁",
      "李四",
      "王五"
    ]
  }
}

假設不傳任何參數:

query {
  getClassMates
}

會報錯:

{
  "errors": [
    {
      "message": "Field \"getClassMates\" argument \"classNo\" of type \"Int!\" is required, but it was not provided.",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ]
    }
  ]
}

自定義參數型別

graphQL 允許用戶自定義參數型別,通常用來描述要獲取的資源的屬性:

type Account { // 型別
    name: String // 屬性
    age: Int
    sex: String
    department: String
    salary(city: String): Int // 屬性也可以帶參數
}

type Query {
    account(name: String): Account
}

舉個簡單的例子:

const schema = buildSchema(`
    type Account {
      name: String
      age: Int
      sex: String
      department: String
      salary(city: String): Int
    }
    type Query {
        getClassMates(classNo: Int!): [String]
        account(username: String!): Account
    }
`);

const root = {
  // arg.classNo
  getClassMates({ classNo }) {
    const obj = {
      31: ['張叁', '李四', '王五'],
      61: ['林志玲', '周杰倫', '蔡依林'],
      27: ['李白', '陶淵明', '王維']
    }
    return obj[classNo];
  },
  account({ username }) {
    const name = username;
    const sex = "female";
    const age = 18;
    const department = "Engineering";
    const salary = ({ city }) => {
      if(city === "台北" || city === "新北") {
        return 100000;
      }
      return 50000;
    }
    return {
      name,
      sex,
      age,
      department,
      salary
    }
  }
};

輸入:

query {
  getClassMates(classNo: 31)
  account(username: "Tom")
}

輸出:

{
  "data": {
    "getClassMates": [
      "張叁",
      "李四",
      "王五"
    ],
    "account": {
      "name": "Tom",
      "age": 18,
      "sex": "female",
      "department": "Engineering",
      "salary": 50000
    }
  }
}

如果給 salary 傳入參數 台北

account(username: "Tom") {
    name
    salary(city: "台北")
}

輸出為:

{    
    "account": {
        "name": "Tom",
        "salary": 100000
    }
}

常見錯誤

GraphQLError: Syntax Error: Expected Name, found "!".

在定義 query 之前必須加上 type

// wrong
const schema = buildSchema(`
    query {
        getClassMates(classNo: Int!): [String]
    }
`);
// right
const schema = buildSchema(`
    type Query {
        getClassMates(classNo: Int!): [String]
    }
`);

graphQL clients

  • 如何在客戶端訪問 graphql 的 API ?

在伺服端加上這行,公開資料夾供用戶訪問靜態資源:

app.use(express.static("public"));
graphql 2 GraphQL + Express + MySQL 實戰學習筆記

新建 public/index.html 並貼上以下內容:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <button onClick="getData()">獲取資料</button>
</body>
<script>
    function getData() {
        const query = `query Account($username: String!) {
            account(username: $username) {
                name
                age
                sex
                salary(city: "台北")
            }
        }`;
        const variables = { username: "陶淵明" };

        fetch("/graphql", {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                "Accept": "application/json"
            },
            body: JSON.stringify({
                query,
                variables
            })
        })
        .then((r) => r.json())
        .then((res) => console.log(res.data));
    }
</script>
</html>

如此一來我們便能透過 http://localhost:4000/index.html 訪問 index.html,然後點擊 button 獲取資料:

{
    "account": {
        "name": "陶淵明",
        "age": 18,
        "sex": "female",
        "salary": 100000
    }
}
graphql 3 GraphQL + Express + MySQL 實戰學習筆記

city 參數也可以 variables 傳入:

const query = `query Account($username: String!, $city: String) {
            account(username: $username) {
                name
                age
                sex
                salary(city: $city)
            }
        }`;
const variables = { username: "陶淵明", city: "台中" };

使用 Mutations 修改資料

  • 查詢資料使用 query,修改資料使用 mutation
input AccountInput { // 查詢資料: type , 修改資料: input
    name: String
    age: Int
    sex: String
    department: String
    salary: Int
}
type Mutation { // 固定的寫法
    createAccount(input: AccountInput): Account // 方法名:createAccount , 形參: input 型別 AccountInput
    updateAccount(id: ID!, input: AccountInput): Account // 返回值都是 Account 型別
}

寫個簡單的例子:

const express = require("express");
const { graphqlHTTP } = require("express-graphql");
const { buildSchema } = require("graphql");

const schema = buildSchema(`
    input AccountInput {
        name: String
        age: Int
        sex: String
        department: String
    }
    type Account {
        name: String
        age: Int
        sex: String
        department: String
    }
    type Mutation {
        createAccount(input: AccountInput): Account
        updateAccount(id: ID!, input: AccountInput): Account
    }
    type Query {
        accounts: [Account]
    }
`);

let fakeDb = {};

const root = {
  accounts() {
    return fakeDb;
  },
  createAccount({ input }) {
    // 相當於資料庫的保存
    fakeDb[input.name] = input;
    // 返回保存結果
    return fakeDb[input.name];
  },
  updateAccount({ id, input }) {
    // 合併舊的 obj 和新的 obj (相同屬性時, 新的覆蓋舊的)
    const updatedAccount = Object.assign({}, fakeDb[id], input);
    // 更新資料
    fakeDb[id] = updatedAccount;
    return updatedAccount;
  },
};

var app = express();
app.use(
  "/graphql",
  graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true, // 調試時設為 true
  })
);
const port = 4000;
app.listen(port);
console.log(`http://localhost:${port}`);

PS. 對於 GraphQL 來說它必須要有一個 query

新建一個 account:

mutation {
  createAccount(input: {
    name: "陶淵明",
    age: 25,
    sex: "男",
    department: "文學家"
  }) {
  	name
    age
    sex
    department
  }
}

output:

{
  "data": {
    "createAccount": {
      "name": "陶淵明",
      "age": 25,
      "sex": "男",
      "department": "文學家"
    }
  }
}

修改剛剛的 account:

mutation {
  updateAccount(id: "陶淵明", input: {
      name: "五柳先生"
    }) {
      name
  }
}

output:

{
  "data": {
    "updateAccount": {
      "name": "五柳先生"
    }
  }
}

試著 query 一下 account 內容:

            {
                "name": "五柳先生"
            },
            {
                "name": "周杰倫"
            }
        ]
    }
}

常見錯誤

Expected Iterable, but did not find one for field \"Query.accounts\".

因為 fakeDb 為 {} obj,但輸出需要為 array 型別,所以我們需要將 fakeDb 轉為 array:

accounts() {
    var arr = [];
    for (const key in fakeDb) {
        arr.push(fakeDb[key]);
    }
    return arr;
},

如此一來便能 query 所有 account 的結果:

    "accounts": [
      {
        "name": "陶淵明"
      },
      {
        "name": "周杰倫"
      }
    ]
  }
}

如此一來新增、修改及刪除都走過一遍,需要注意幾個坑:

  1. 無論是否需要查詢,都必須要定義至少一個 query。
  2. 傳入 mutation 的時候要注意定義的類型為 input 查詢才是 type

權限驗證與middleware

在實際應用中,我們需要控制只有有權限的人才能請求 API,對於 GraphQL 來說我們需要借助 express 的中間件來實現這個功能。

middleware 的本質就是 function,它在 API 執行之前首先攔截請求,再決定是否能繼續往下走或者攔截住。

const middleware = (req, res, next) => {
  // 如果網址包含 graphql 且 cookie 中沒有 auth 字樣即無權限訪問 API
  if (
    req.url.indexOf("/graphql") !== -1 &&
    (!req.headers.cookie || req.headers.cookie.indexOf("auth") === -1)
  ) {
    res.send(
      JSON.stringify({
        error: "Permission Denied!",
      })
    );
    return;
  }
  // 如果有 auth 字樣就可以繼續往下走
  next();
};
// 註冊middleware
app.use(middleware);

但真正應用中我們會需要使用到 token,這邊只是用 cookie 簡單演示一下流程。

Constructing Types

  1. 使用 graphql.GraphQLObjectType 定義 type:
// 將 schema 這段
const schema = buildSchema(`
	type Account {
        name: String
        age: Int
        sex: String
        department: String
    }
`);
// 改為構造函數的形式
var AccountType = new graphql.GraphQLObjectType({
    name: "Account",
    fields: {
        name: { type: graphql.GraphQLString },
        name: { type: graphql.GraphQLInt },
        name: { type: graphql.GraphQLString },
        department: { type: graphql.GraphQLString },
    }
});
  • 優點:相比於原來使用 buildSchema 報錯時只會定位到 buildSchema 這一行,使用 graphql.GraphQLObjectType 這種構造函數的形式,當有錯誤時就知道是哪一個 type 錯了。

2. 使用 GraphQLObjectType 定義 query:

// 原來的 query
const schema = buildSchema(`
	type Account {
        name: String
        age: Int
        sex: String
        department: String
    }
    type Query {
    	account(username: String): Account
    }
`);
// 改為構造函數的形式
var queryType = new graphql.GraphQLObjectType({
    name: "Query",
    fields: {
        account: {
            type: AccountType,
            args: {
                username: { type: graphql.GraphQLString }
            },
            resolve: function (_, { username }) {
                const name = username;
                const sex = "male";
                const age = 18;
                const department = "開發部";
                return {
                    name,
                    age,
                    sex,
                    department
                }
            }
        }
    }
})

3. 創建 schema:

var schema = new graphql.GraphQLSchema({ query: queryType });

也就是將原來 buildSchema 一步驟做完的事情拆分成 type => query => 把 query 轉成 schema

  • 優點:便於維護,若在意程式碼質量,應先考慮使用 constructing type。但它也會造成程式碼數量上升,這是一把雙刃劍。

4. 完整程式碼:

const express = require("express");
const { graphqlHTTP } = require("express-graphql");
const graphql = require('graphql');

const AccountType = new graphql.GraphQLObjectType({
  name: "Account",
  fields: {
    name: { type: graphql.GraphQLString },
    name: { type: graphql.GraphQLInt },
    name: { type: graphql.GraphQLString },
    department: { type: graphql.GraphQLString },
  },
});

const queryType = new graphql.GraphQLObjectType({
  name: "Query",
  fields: {
    account: {
      type: AccountType,
      args: {
        username: { type: graphql.GraphQLString },
      },
      resolve: function (_, { username }) {
        const name = username;
        const sex = "male";
        const age = 18;
        const department = "開發部";
        return {
          name,
          age,
          sex,
          department,
        };
      },
    },
  },
});

const schema = new graphql.GraphQLSchema({ query: queryType });

const app = express();

app.use(
  "/graphql",
  graphqlHTTP({
    schema,
    graphiql: true, // 調試時設為 true
  })
);
const port = 4000;
app.listen(port);
console.log(`http://localhost:${port}`);

與資料庫結合實戰

創建一個 account table,結構如下:

db GraphQL + Express + MySQL 實戰學習筆記

因為連接資料庫的實戰部分需要使用到 mysql 庫,因此需要先安裝依賴及 cretePool 連接 db:

const mysql = require("mysql");
// 連接 db
const pool = mysql.createPool({
  connectionLimit: 10,
  host: 'localhost',
  user: 'root',
  password: '',
  database: 'dashen'
});

新增資料

修改 createAccount 方法:

const root = {
  accounts() {
    var arr = [];
    return arr;
  },
  createAccount({ input }) {
    const data = {
      name: input.name,
      sex: input.sex,
      age: input.age,
      department: input.department
    }
    return new Promise((resolve, reject) => {
      // 使用 sql 語句新增資料
      pool.query('insert into account set ?', data, (err) => {
        if (err) {
          console.log(err);
          return reject;
        }
        resolve(data);
      });
    });
  },
  updateAccount({ id, input }) {
    return {};
  },
};

試著用 createAccount 新增一筆資料,然後到 db 看看資料是否新增成功

// input
mutation {
  createAccount(input: {
    name: "周杰倫",
    age: 30,
    sex: "男",
    department: "歌手"
  }) {
  	name
    age
    sex
    department
  }
}
// output
{
  "data": {
    "createAccount": {
      "name": "周杰倫",
      "age": 30,
      "sex": "男",
      "department": "歌手"
    }
  }
}

新增成功!

db2 GraphQL + Express + MySQL 實戰學習筆記

查詢資料

修改 accounts 方法:

accounts() {
    return new Promise((resolve, reject) => {
      pool.query('select name, age, sex, department from account', (err, results) => {
        if (err) {
          console.log(err.message);
          return;
        }
        const arr = [];
        for (let i = 0; i < results.length; i++) {
          arr.push({
            name: results[i].name,
            sex: results[i].sex,
            age: results[i].age,
            department: results[i].department,
          });
        }
        resolve(arr);
      });
    });
  }

使用 accounts 方法:

// input
query {
  accounts {
    name
  }
}
// output
{
  "data": {
    "accounts": [
      {
        "name": "周杰倫"
      },
      {
        "name": "新蘭"
      }
    ]
  }
}

修改資料

修改 updateAccount方法:

updateAccount({ id, input }) {
    const data = {
      name: input.name,
      sex: input.sex,
      age: input.age,
      department: input.department
    }
    return new Promise((resolve, reject) => {
      // 幾個問號對應幾個參數
      pool.query('update account set ? where name = ?', [data, id], (err) => {
        if (err) {
          console.log(err.message);
          return;
        }
        resolve(data);
      });
    });
  },

試著修改其中一筆資料:

// input
mutation {
  updateAccount(id: "新蘭", input: {
      name: "小蘭"
  	  age: 40
    }) {
      name
      age
  }
}
// output
{
  "data": {
    "updateAccount": {
      "name": "小蘭",
      "age": 40
    }
  }
}

看看 db 裡面資料是否正確修改:

db3 GraphQL + Express + MySQL 實戰學習筆記

但有個問題是,這樣修改,如果沒有修改的值就會為空↑

再次試著修改,將 input 直接賦給 data

updateAccount({ id, input }) {
    const data = input;
    return new Promise((resolve, reject) => {
      pool.query('update account set ? where name = ?', [data, id], (err) => {
        if (err) {
          console.log(err.message);
          return;
        }
        resolve(data);
      });
    });
  },

修改資料:

// input
mutation {
  updateAccount(id: "小蘭", input: {
      name: "新蘭"
  		age: 20
    }) {
      name
    	age
  }
}
// output
{
  "data": {
    "updateAccount": {
      "name": "新蘭",
      "age": 20
    }
  }
}
db4 GraphQL + Express + MySQL 實戰學習筆記

現在一切正常。

刪除資料

目前我們做了 CRU 但還缺少了 D 的部分,現在我們再來實做 deleteAccount

// ..
type Mutation {
    createAccount(input: AccountInput): Account
    updateAccount(id: ID!, input: AccountInput): Account
    deleteAccount(id: ID!): Boolean
}
// ..
deleteAccount({ id }) {
    return new Promise((resolve, reject) => {
        pool.query('delete from account where name = ?', [id], (err) => {
            if (err) {
                console.log(err.message);
                reject(false);
                return;
            }
            resolve(true);
        })
    });
}

現在來試著刪除一筆資料:

// input
mutation {
  deleteAccount(id: "新蘭")
}
// output
{
  "data": {
    "deleteAccount": true
  }
}

現在新蘭這筆資料被刪除了。

db5 GraphQL + Express + MySQL 實戰學習筆記

如此一來便透過 express + graphQL + mysql 實現了簡單的 CRUD。

0 0 評分數
Article Rating
訂閱
通知
guest

1 Comment
在線反饋
查看所有評論
Kip
Kip
29 日 前

非常詳細 受益良多,感謝!