­

基於React和GraphQL的黛夢設計與實現

寫在前面

這是筆者在中秋無聊寫着玩的,假期閑暇之餘憋出來的帖子。麻雀雖小,但五臟俱全,涉及到的方方面面還是蠻全的。所以就設計了一個黛夢(demo)—— 打通了GraphQL的接口與前端交互的流程,並且將數據存入MYSQL,分享下React和GraphQL的使用,大致內容如下:

  • GraphQL的增刪改查接口設計與實現
  • CRUD包mysql的使用
  • React 和 React Hooks的使用

因為涉及到React、GraphQL,還有MySQL的一張用戶表User,所以我本來是想起一個「搞人實驗」的名字,後來斟酌了一下,啊着,太粗暴了。還是文藝點,詩意點,就叫它」黛夢「吧,哈哈哈哈哈哈。

這邊文章着重介紹GraphQL的使用,關於它的一些概念煩請看我去年寫的這篇文章,GraphQL的基礎實踐—— //segmentfault.com/a/1190000021895204

技術實現

技術選型

最近在用taro寫h5和小程序,混個臉熟,所以前端這邊我選用React,因為黛夢也不是很大,所以沒必要做前後端分離,用html刀耕火種意思下得了。後端這塊是Node結合express和GraphQL做的接口,數據庫用的是MySQL。

GraphQL的接口設計

我們先拋開GraphQL,就單純的接口而言。比如說抽象出一個User類,那麼我們對其進行的操作不外乎增刪改查對吧。然後我們再帶上GraphQL,結合已知的業務邏輯去熟悉新技術那麼我們可以這麼一步一步來,一口氣是吃不成胖子的。

  • 先定義用戶實體和相應的接口,不做細節實現,訪問相應的接口能返回相應的預期
  • 定義一個全局變量(或者寫進一個文件)去模仿數據庫操作,返回相應的結果
  • 結合數據庫去實現細節,訪問相應的接口能返回相應的預期

全局變量Mock數據庫的實現

  • 第一步:導包

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

    上面分別倒入了相應的包,express用來創建相應的HTTP服務器,buildSchema用來創建相應的類型、Query和Mutation的定義。graphqlHTTP用來將相應的實現以中間件的形式注入到express中。

  • 第二步: 定義全局變量

    const DB = {
      userlist: [],
    };
    

    這裡定義一個全局變量去模仿數據庫操作

  • 第三步:定義相應的Schema

    const schema = buildSchema(`
      input UserInput {
        name: String
        age: Int
      }
      type User {
        id: ID,
        name: String,
        age: Int
      }
      type Query {
        getUsers: [User]
      }
      type Mutation {
        createUser(user: UserInput): User
        updateUser(id: ID!, user: UserInput): User
      }
    `);
    

    這裡定義了用戶輸入的類型以及用戶的類型,然後Query中的getUsers模擬的是返回用戶列表的接口,返回User實體的列表集。Mutation是對其進行修改、刪除、新增等操作。這裡createUser接收一個UserInput的輸入,然後返回一個User類型的數據,updateUser接受一個ID類型的id,然後一個UserInput類型的user

  • 第四步:對樓上Schema的Query和Mutation的實現

    const root = {
      getUsers() {
        return DB.userlist || [];
      },
      createUser({ user }) {
        DB.userlist.push({ id: Math.random().toString(16).substr(2), ...user });
        return DB.userlist.slice(-1)[0];
      },
      updateUser({ id, user }) {
        let res = null;
        DB.userlist.forEach((item, index) => {
          if (item.id === id) {
            DB.userlist[index] = Object.assign({}, item, { id, ...user });
            res = DB.userlist[index];
          }
        });
        return res;
      },
    };
    
  • 第五步: 創建服務器並暴露想要的端口

    const app = express();
    
    app.use(
      '/api/graphql',
      graphqlHTTP({
        schema: schema,
        rootValue: root,
        graphiql: true,
      })
    );
    
    app.listen(3000, () => {
      console.log('server is running in //localhost:3000/api/graphql');
    });
    
    

    文件地址://gitee.com/taoge2021/study-nodejs/blob/master/07-graphql/express/01-graphql/server-3.js

    打開 //localhost:3000/api/graphql,可以在playground粘貼下樓下的測試用例試一下

    query {
      getUsers {
        id
        name
        age
      }
    }
    
    mutation {
      createUser(user: {name: "ataola", age: 18}) {
        id
        name
        age
      }
    }
    
    
    mutation {
      updateUser(id: "5b6dd66772afc", user: { name: "daming", age: 24 }) {
        id,
        name,
        age
      }
    }
    

    文件地址://gitee.com/taoge2021/study-nodejs/blob/master/07-graphql/express/01-graphql/server-3.query

結合MySQL的實現

這裡就不像樓上一樣展開了,直接貼代碼吧

const express = require('express');
const { buildSchema } = require('graphql');
const { graphqlHTTP } = require('express-graphql');
const { cmd } = require('./db');

const schema = buildSchema(`
  input UserInput {
    "姓名"
    name: String
    "年齡"
    age: Int
  }
  type User {
    "ID"
    id: ID,
    "姓名"
    name: String,
    "年齡"
    age: Int
  }
  type Query {
    "獲取所有用戶"
    getUsers: [User]
    "獲取單個用戶信息"
    getUser(id: ID!): User
  }
  type Mutation {
    "創建用戶"
    createUser(user: UserInput): Int
    "更新用戶"
    updateUser(id: ID!, user: UserInput): Int
    "刪除用戶"
    deleteUser(id: ID!): Boolean
  }
`);

const root = {
  async getUsers() {
    const { results } = await cmd('SELECT id, name, age FROM user');
    return results;
  },
  async getUser({ id }) {
    const { results } = await cmd(
      'SELECT id, name, age FROM user WHERE id = ?',
      [id]
    );
    return results[0];
  },
  async createUser({ user }) {
    const id = Math.random().toString(16).substr(2);
    const data = { id, ...user };
    const {
      results: { affectedRows },
    } = await cmd('INSERT INTO user SET ?', data);
    return affectedRows;
  },
  async updateUser({ id, user }) {
    const {
      results: { affectedRows },
    } = await cmd('UPDATE user SET ? WHERE id = ?', [user, id]);
    return affectedRows;
  },
  async deleteUser({ id }) {
    const {
      results: { affectedRows },
    } = await cmd('DELETE FROM user WHERE id = ?', [id]);
    return affectedRows;
  },
};

const app = express();

app.use(
  '/api/graphql',
  graphqlHTTP({
    schema: schema,
    rootValue: root,
    graphiql: true,
  })
);

app.use(express.json());
app.use(express.urlencoded({ extended: false }));

app.use(express.static('public'));

app.listen(3000, () => {
  console.log('server is running in //localhost:3000/api/graphql');
});

這裡跟全局變量不同的是,我這邊對所有字段和方法增加了相應的注釋(GraphQL就是好, 接口即文檔),然後封裝了mysql數據庫的操作方法,引入後去實現相關的接口。

MYSQL增刪改查的封裝

這裡簡單點,我們期望是傳入一條SQL和相應的參數,返回相應的執行結果。

const mysql = require('mysql');

const pool = mysql.createPool({
  host: '122.51.52.169',
  port: 3306,
  user: 'ataola',
  password: '123456',
  database: 'test',
  connectionLimit: 10,
});

function cmd(options, values) {
  return new Promise((resolve, reject) => {
    pool.getConnection(function (err, connection) {
      if (err) {
        reject(err);
      } else {
        connection.query(options, values, (err, results, fields) => {
          if (err) {
            reject(err);
          } else {
            resolve({ err, results, fields });
          }
          connection.release();
        });
      }
    });
  });
}

module.exports = {
  cmd,
};

這裡導入了Mysql這個npm包,在它的基礎上創建了一個連接池,然後暴露一個cmd方法,它返回一個Promise對象,是我們上面傳入sql和參數的結果。

文件地址如下://gitee.com/taoge2021/study-nodejs/blob/master/07-graphql/express/01-graphql/db.js

有的時候我們寫代碼,不可能一次就寫成我們想要的結果,比如可能寫錯了一個單詞啊,或者參數什麼,所以這裡需要對增刪改查的sql做測試,具體的如下:

const { cmd } = require('./db');

// insert
// (async () => {
//   const res = await cmd('INSERT INTO user SET ?', {
//     id: 'beb77a48b7f9f',
//     name: '張三',
//     age: 100,
//   });
//   console.log(res);
// })();

// {
//   error: null,
//   results: OkPacket {
//     fieldCount: 0,
//     affectedRows: 1,
//     insertId: 0,
//     serverStatus: 2,
//     warningCount: 0,
//     message: '',
//     protocol41: true,
//     changedRows: 0
//   },
//   fields: undefined
// }

// delete
// (async () => {
//   const res = await cmd('DELETE FROM user WHERE id = ?', ['beb77a48b7f9f']);
//   console.log(res);
// })();

// {
//   error: null,
//   results: OkPacket {
//     fieldCount: 0,
//     affectedRows: 1,
//     insertId: 0,
//     serverStatus: 2,
//     warningCount: 0,
//     message: '',
//     protocol41: true,
//     changedRows: 0
//   },
//   fields: undefined
// }

// update
// (async () => {
//   const res = await cmd('UPDATE user SET ? where id = ?', [
//     { name: '大明', age: 25 },
//     'beb77a48b7f9f',
//   ]);
//   console.log(res);
// })();

// {
//   error: null,
//   results: OkPacket {
//     fieldCount: 0,
//     affectedRows: 1,
//     insertId: 0,
//     serverStatus: 2,
//     warningCount: 0,
//     message: '(Rows matched: 1  Changed: 1  Warnings: 0',
//     protocol41: true,
//     changedRows: 1
//   },
//   fields: undefined
// }

// select
// (async () => {
//   const res = await cmd('SELECT id, name, age FROM user');
//   console.log(res);
// })();

// {
//   error: null,
//   results: [ RowDataPacket { id: 'beb77a48b7f9f', name: '大明', age: 25 } ],
//   fields: [
//     FieldPacket {
//       catalog: 'def',
//       db: 'test',
//       table: 'user',
//       orgTable: 'user',
//       name: 'id',
//       orgName: 'id',
//       charsetNr: 33,
//       length: 765,
//       type: 253,
//       flags: 20483,
//       decimals: 0,
//       default: undefined,
//       zeroFill: false,
//       protocol41: true
//     },
//     FieldPacket {
//       catalog: 'def',
//       db: 'test',
//       table: 'user',
//       orgTable: 'user',
//       name: 'name',
//       orgName: 'name',
//       charsetNr: 33,
//       length: 765,
//       type: 253,
//       flags: 0,
//       decimals: 0,
//       default: undefined,
//       zeroFill: false,
//       protocol41: true
//     },
//     FieldPacket {
//       catalog: 'def',
//       db: 'test',
//       table: 'user',
//       orgTable: 'user',
//       name: 'age',
//       orgName: 'age',
//       charsetNr: 63,
//       length: 11,
//       type: 3,
//       flags: 0,
//       decimals: 0,
//       default: undefined,
//       zeroFill: false,
//       protocol41: true
//     }
//   ]
// }

// select
(async () => {
  const res = await cmd('SELECT id, name, age FROM user WHERE id = ?', [
    'beb77a48b7f9f',
  ]);
  console.log(res);
})();

// {
//   error: null,
//   results: [ RowDataPacket { id: 'beb77a48b7f9f', name: '大明', age: 25 } ],
//   fields: [
//     FieldPacket {
//       catalog: 'def',
//       db: 'test',
//       table: 'user',
//       orgTable: 'user',
//       name: 'id',
//       orgName: 'id',
//       charsetNr: 33,
//       length: 765,
//       type: 253,
//       flags: 20483,
//       decimals: 0,
//       default: undefined,
//       zeroFill: false,
//       protocol41: true
//     },
//     FieldPacket {
//       catalog: 'def',
//       db: 'test',
//       table: 'user',
//       orgTable: 'user',
//       name: 'name',
//       orgName: 'name',
//       charsetNr: 33,
//       length: 765,
//       type: 253,
//       flags: 0,
//       decimals: 0,
//       default: undefined,
//       zeroFill: false,
//       protocol41: true
//     },
//     FieldPacket {
//       catalog: 'def',
//       db: 'test',
//       table: 'user',
//       orgTable: 'user',
//       name: 'age',
//       orgName: 'age',
//       charsetNr: 63,
//       length: 11,
//       type: 3,
//       flags: 0,
//       decimals: 0,
//       default: undefined,
//       zeroFill: false,
//       protocol41: true
//     }
//   ]
// }

在測試完成後,我們就可以放心地引入到express和graphql的項目中去了。額,這裡的服務器我就不避諱打星號了,快到期了,有需要的同學可以連上去測試下,這裡用的也是測試服務器和賬號哈哈哈,沒關係的。

相關的query文件在這://gitee.com/taoge2021/study-nodejs/blob/master/07-graphql/express/01-graphql/server-4.query

貼張圖

React的前端設計

關於React項目的搭建,可以看下我之前寫的這篇文章://www.cnblogs.com/cnroadbridge/p/13358136.html

在React中,我們可以通過Class和Function的方式創建組件,前者通過Class創建的組件,具有相應的生命周期函數,而且有相應的state, 而後者通過Function創建的更多的是做展示用。自從有了React Hooks之後,在Function創建的組件中也可以用state了,組件間的復用更加優雅,代碼更加簡潔清爽了,它真的很靈活。Vue3中的組合式API,其實思想上有點React Hooks的味道。

構思頁面

根據後端這邊提供的接口,這裡我們會有張頁面,裏面有通過列表接口返回的數據,它可以編輯和刪除數據,然後我們有一個表單可以更新和新增數據,簡單的理一下,大致就這些吧。

  • 增刪改查接口的query

      function getUser(id) {
        const query = `query getUser($id: ID!) { 
          getUser(id: $id) {
            id,
            name,
            age
          }
        }`;
    
        const variables = { id };
    
        return new Promise((resolve, reject) => {
          fetch('/api/graphql', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              Accept: 'application/json',
            },
            body: JSON.stringify({
              query,
              variables,
            }),
          })
            .then((res) => res.json())
            .then((data) => {
              resolve(data);
            });
        })
      }
    
      function getUsers() {
        const query = `query getUsers { 
          getUsers {
            id,
            name,
            age
          }
        }`;
    
        return new Promise((resolve, reject) => {
          fetch('/api/graphql', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              Accept: 'application/json',
            },
            body: JSON.stringify({
              query,
            }),
          })
            .then((res) => res.json())
            .then((data) => {
              resolve(data)
            });
        });
      }
    
      function addUser(name, age) {
        const query = `mutation createUser($user: UserInput) { 
          createUser(user: $user)
        }`;
    
        const variables = {
          user: {
            name, age
          }
        };
        return new Promise((resolve, reject) => {
          fetch('/api/graphql', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              Accept: 'application/json',
            },
            body: JSON.stringify({
              query,
              variables
            }),
          })
            .then((res) => res.json())
            .then((data) => {
              resolve(data)
            });
        });
      }
    
      function updateUser(id, name, age) {
        const query = `mutation updateUser($id: ID!, $user: UserInput) { 
          updateUser(id: $id, user: $user)
        }`;
    
        const variables = {
          id,
          user: {
            name, age
          }
        };
        return new Promise((resolve, reject) => {
          fetch('/api/graphql', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              Accept: 'application/json',
            },
            body: JSON.stringify({
              query,
              variables
            }),
          })
            .then((res) => res.json())
            .then((data) => {
              resolve(data)
            });
        });
      }
    
      function deleteUser(id) {
        const query = `mutation deleteUser($id: ID!) { 
          deleteUser(id: $id)
        }`;
    
        const variables = {
          id
        };
        return new Promise((resolve, reject) => {
          fetch('/api/graphql', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              Accept: 'application/json',
            },
            body: JSON.stringify({
              query,
              variables
            }),
          })
            .then((res) => res.json())
            .then((data) => {
              resolve(data)
            });
        })
      }
    
    

    上面通過自帶的fetch請求,分別實現了對給出的graphql接口的相關請求

  • UserPage頁面組件

      // 頁面
      const UserPage = () => {
        const [userList, setUserList] = React.useState([]);
        const [userForm, setUserForm] = React.useState({ id: '', name: '', age: '', type: 'add' });
        const [isReload, setReload] = React.useState(false)
        const [id, setId] = React.useState('');
        React.useEffect(() => {
          refreshUserList();
        }, []);
    
        React.useEffect(() => {
          if (isReload) {
            refreshUserList();
          }
          setReload(false);
        }, [isReload]);
    
        React.useEffect(() => {
          if (id) {
            getUser(id).then(res => {
              const { data: { getUser: user } } = res;
              setUserForm({ type: 'edit', ...user });
            })
          }
        }, [id]);
    
        function refreshUserList() {
          getUsers().then(res => {
            const { data: { getUsers = [] } } = res;
            setUserList(getUsers);
          })
        }
    
        return (<div>
          <UserList userList={userList} setReload={setReload} setId={setId} />
          <UserOperator setUserForm={setUserForm} userForm={userForm} setReload={setReload} />
        </div>);
      };
    

    這裡用了兩個React Hooks的鉤子, useState使得函數組件可以像Class組件一樣可以使用state, useEffect它接受兩個參數,第一個是函數,第二個是一個數組,數組中的元素的變化會觸發這個鉤子的函數的執行。

  • UserList列表組件

      const UserList = (props) => {
        const { userList, setReload, setId } = props;
        const userItems = userList.map((user, index) => {
          return <UserItem key={user.id} user={user} setReload={setReload} setId={setId} />
        });
        return (<ul>{userItems}</ul>);
      };
    
  • UserItem單條數據項組件

      // 數據項
      const UserItem = (props) => {
        const { user, setReload, setId } = props;
    
        function handleDelete(id) {
          deleteUser(id).then(res => {
            const { data: { deleteUser: flag } } = res;
            if (flag) {
              setReload(true);
            }
          })
        }
    
        function handleEdit(id) {
          setId(id);
        }
    
        return (<li>
          {user.name}: {user.age}歲
          <span className="blue pointer" onClick={() => handleEdit(user.id)}>編輯</span>
          <span className="red pointer" onClick={() => handleDelete(user.id)}>刪除</span>
        </li>);
      };
    
  • UserOperator 操作組件

  // 新增
  const UserOperator = (props) => {
    const [id, setId] = React.useState('');
    const [name, setName] = React.useState('');
    const [age, setAge] = React.useState('');
    const { setUserForm, userForm, setReload } = props;

    function handleChange(e, cb) {
      cb(e.target.value)
    }

    function handleSubmit() {
      const { type } = userForm;
      if (type === 'edit') {
        updateUser(id, name, Number(age)).then(res => {
          const { data: { updateUser: flag } } = res;
          if (flag) {
            setReload(true);
            setId('');
            setName('');
            setAge('');
          } else {
            alert('更新失敗');
          }
        })
      } else if (type === 'add') {
        if (name && age) {
          addUser(name, Number(age)).then(res => {
            const { data: { createUser: flag } } = res;
            if (flag) {
              setReload(true);
              setId('');
              setName('');
              setAge('');
            } else {
              alert('添加失敗');
            }
          });
        }
      }
      setUserForm({ ...userForm, type: 'add' })
    }

    React.useEffect(() => {
      const { id, name, age } = userForm
      setId(id);
      setName(name);
      setAge(age);
    }, [userForm]);

    return (<div>
      <span>姓名:</span><input type="text" value={name} onChange={e => handleChange(e, setName)} />
      <span>年齡:</span><input type="number" value={age} onChange={e => handleChange(e, setAge)} />
      <button onClick={() => handleSubmit()}>{BUTTON_MAP[userForm.type]}</button>
    </div>)
  }
  • 根組件
const App = (props) => {
    return (<div><h2>{props.title}</h2><UserPage /></div>);
  };

  const root = document.getElementById('root');
  ReactDOM.render(<App title="A Simple GraphQL Demo With React Design By ataola, Have Fun!" />, root);

文件如下://gitee.com/taoge2021/study-nodejs/blob/master/07-graphql/express/01-graphql/public/index.html

總結

刀耕火種的時代已然是離我們很遠,人類文明發展到現在已然是可以用微波爐煤氣灶燒飯做菜,上面的例子只是介紹了GraphQL的使用,並且結合React打通了這樣一個流程。實際上在開發中,我們往往會採用社區一些成熟的技術棧,比如你需要進一步了解GraphQL,可以去了解下Apollo這個庫。那麼前後端的架構就可以是 react-apollo,vue-apollo, 後端的話比如express-apollo,koa-apollo等等。我們在學開車的時候,往往是學手動擋的帕薩特,而在買汽車的時候,往往是喜歡買自動擋的輝騰,因為它比較符合人類文明的發展趨勢,雖然外表上看上去和帕薩特差不多,但是自動擋着實是文明的進步啊!