Node.js 蠶食計劃(七)—— MongoDB + GraphQL + Vue 初體驗

首先需要搭建一個簡單的應用

前端部分不多贅述,如果確實沒接觸過 Vue 項目,可以參考我的《Vue 爬坑之路》系列

後端服務可以參考之前的文章《Node.js 蠶食計劃(六)—— MongoDB + Koa 入門》

完整的項目地址:https://github.com/wisewrong/Test-GraphQL-App,結合項目食用本文更香哦~

 

 

一、Mongoose

在上一篇文章《Node.js 蠶食計劃(六)》裏,直接使用了 mongodb 中間件來連接數據庫,並嘗試着操作數據庫

但我們一般不會直接用 MongoDB 的原生函數來操作數據庫,Mongoose 就是一套操作 MongoDB 數據庫的接口

 

1. Schema 與 Model

Schema 是 Mongoose 的基礎,用來定義集合的數據模型,也就是傳統意義上的表結構

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

// 影片信息
const MovieSchema = new Schema({
  name: String,         // 影片名稱
  years: Number,        // 上映年代
  director: String,     // 導演
  category: [String],   // 影片類型
  comments: [           // 影評
    {
      author: String,
      createdAt: {
        type: Date,
        default: Date.now(),
      },
      updatedAt: {
        type: Date,
        default: Date.now()
      }
    }
  ],
});

module.exports = mongoose.model('Movie', MovieSchema);

上面的最後一行代碼,是基於定義好的 Schema 生成 Model,我們可以通過 Model 來操作數據庫

mongoose.model('ModelName', SchemaObj)

這裏的 model() 方法可以接收兩個參數,第二個參數是創建好的 Schema 實例

第一個參數 ModelName 是數據庫中集合 (collection) 名稱的單數形式,Mongoose 會查找名稱爲 ModelName 複數形式的集合

對於上例,Movie 這個 model 就對應數據庫中 movies 這個 collection,如果數據庫沒有對應的集合會自動創建

 

2. Model 的增刪改查

在 mongoose 中是通過操作 Model 來實現數據庫的增刪改查

< 新增 >

Model.create(data, callback)

< 查詢 >

// 返回所有符合查詢條件 conditions 的數據
Model.find(conditions, callback);
// 返回找到的第一個文檔
Model.findOne(conditions, callback);
// 只針對主鍵 _id 查詢
Model.findById('_id', callback);

< 修改 >

// 批量修改符合條件 conditions 的數據
Model.updateMany(conditions, update, options, callback)
// 修改指定 id 的數據
Model.findByIdAndUpdate(id, update, options , callback)
// 修改第一個符合查詢條件的數據
Model.updateOne(conditions, update, options , callback)
// 替換第一個符合查詢條件的數據
Model.replaceOne(conditions, update, options , callback)

< 刪除 >

// 刪除符合條件的所有數據
Model.remove(conditions, callback);
// 刪除指定 id 的數據
Model.findByIdAndRemove(id, options, callback);

比如封裝一個插入數據的方法:

const Movie = require('../mongodb/models/movie');

// 新建電影
const createMovie = (req) => {
  return Movie.create(req);
}

// 更新電影信息
const updateMovie = (req) => {
  return Movie.findByIdAndUpdate(req._id, req, {
    new: true,
  });
}

// 保存電影
const saveMovie = async (ctx, next) => {
  const req = ctx.request.body;
  // 校驗必填
  if (!req.name) {
    return { message: '影片名稱不能爲空' }
  }
  const data = req._id
    ? await updateMovie(req)
    : await createMovie(req);
  return { data };
};

module.exports = {
  saveMovie,
};

mongoose 也有更規範的查詢條件,可以參考官網的 Query 配置

 

3. 連接數據庫

使用 mongoose.connect 連接數據庫,可以在 connect 方法中傳入第二個參數作爲回調

也可以通過 mongoose.connection.on 來監聽相應的事件 

/* /mongodb/index.js */

const mongoose = require("mongoose");
const { dbUrl } = require("../config");
// const dbUrl = 'mongodb://127.0.0.1:27017/Movie'; // 數據庫地址

const connect = () => {
  // mongoose.set('debug', true)
  mongoose.connect(dbUrl);

  mongoose.connection.on("disconnected", () => {
    mongoose.connect(dbUrl);
  });

  mongoose.connection.on("error", (err) => {
    console.error('Connect Failed: ', err);
  });

  mongoose.connection.on("open", async () => {
    console.log('🚀 Connecting MongoDB Successfully 🚀');
  });
};

 

4. 接口實現

基於這些 API,我們就可以搭建一個相對規範的傳統後端服務

首先創建 model,然後創建 controller,在 controller 中引入 model,並使用 model 來操作數據庫

然後還可以通過 koa-router 來實現傳統接口

/* /router/api/movie.js */

const router = require('koa-router')();
const { apiPrefix } = require('../../config');
// const apiPrefix = '/api';
const movieController = require('../../controllers/movie');

router.prefix(apiPrefix);

router.post('/movie/save', movieController.saveMovie);
router.get('/movie/list', movieController.getMovie);
router.delete('/movie/delete/:id', movieController.deleteMovie);

module.exports = router;

最後只要在 app.js 中引入相應模塊,一個簡單的傳統服務就搭建好了

// app.js
const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const api = require('./router/api');

// 連接數據庫
require('./mongodb');

const app = new Koa();

app.use(bodyParser());

// 註冊 API
for (const key in api) {
  const router = api[key];
  app.use(router.routes()).use(router.allowedMethods());
}

app.listen({port: 3200});

但這樣的傳統服務,接口的出參都是由後端決定的

如果業務調整,接口出參需要新增一個字段,就需要後端和前端同時迭代

而如果使用 GraphQL 的話,這種改動就不用後端的小夥伴參與了

 

 

二、GraphQL

GraphQL 是一種新的 API 定義和查詢語言,它使前端能夠聲明式地獲取數據,從一定程度上自定義接口出參

像上圖這樣,接口的響應會按照入參的結構返回出參。概念性的優點就不多贅述,實際感受之後才能明白它的優勢

先在項目中引入 koa-graphql 和 GraphQL.js 備用

npm install graphql koa-graphql --save

 

在 GraphQL 中,Schema 是定義整個查詢語言的入口

schema {
  query: Query
  mutation: Mutation
}

Schema 有一個必須定義的 query 類型,用來執行查詢操作;還有一個可選的 mutation,處理增刪改操作

這兩種類型其實都是 graphql.GraphQLObjectType 類型

構建一個 Schema 可以使用 graphql.buildSchema 或者構建類型 graphql.GraphQLSchema,先介紹一下 buildSchema

const Schema = buildSchema(`
  type Query {
    getList: [Movie]
    getDetail: [Movie]
  }
  type Mutation {
    add(post: input): [Movie],
  }
`)

這裏的 type Query 就是定義上面提到的 schema 中必須包含的 query 類型

需要注意的是,在類型下定義的字段,並不是像 mongoose 中 schema 定義的文檔結構

這個字段只是聲明一種類型,而類型的值取決於對應的 resolve 處理函數,所以將這個字段當作查詢指令更便於理解

上面  Query.getList: [Movie] 表示通過 getList 指令能夠返回一個數組,數組的每個元素是一個 Movie 類型,這個 Movie 是我們需要定義的另一個類型

/* /graphql/schema.js - 使用 buildSchema 創建的 GraphQL Schema */

const { buildSchema } = require('graphql');

const Schema = buildSchema(`
  type Query {
    getAllMovie: [Movie]
  }
  type Movie {
    _id: String,
    name: String,
    years: String,
    director: String,
  }
`)
// 暫時不用 Mutation

module.exports = Schema;

這樣就定義了一個包含 name、years 等四個字段的 Movie 類型,一個簡單的 Schema 就定義好了

然後來改造 controllers,引入 koa-graphql、剛纔定義的 Schema,以及之前用 mongoose 生成的 Model

/* /controllers/movie.js */

const graphqlHTTP = require('koa-graphql');
const MovieSchema = require('../graphql/schema');
const Movie = require('../mongodb/models/movie');

// GraphQL 類型處理函數
const root = {
  getAllMovie: async () => {
    return Movie.find({});
  }
}

// 查詢所有電影
const getMovie = graphqlHTTP({
  schema: MovieSchema,
  rootValue: root,
  graphiql: true
});

module.exports = {
  getMovie,
};

用 koa-graphql 提供的 graphqlHTTP 方法作爲接口的 handler 函數,並傳入定義好的 schema

這裏有一個 rootValue 對象,用來配置 schema 類型的具體操作函數,比如上面就定義了 getAllMovie 的操作函數

然後接口路徑還是按之前的方式配置:

/* /router/api/movie.js */

const router = require('koa-router')();
const movieController = require('../../controllers/movie');

router.all('/movie/list', movieController.getMovie);

module.exports = router;

一個簡單的 GraphQL 服務就完成了,接下來處理前端的請求

請求的時候需要攜帶 JSON 格式的參數,所以通常使用 post 請求

最主要的是,需要設置請求頭 'Content-Type': 'application/json'

然後按照 schema 的格式設置入參,比如查詢 schema 中 query 類型下的 getAllMovie:

request.post('/api/movie/list', {
  query: `{
    getAllMovie {
      _id,
      name,
    }
  }`
});

可以看到響應的結果爲:

 我們在 GraphQL 中定義的 Movie 類型有 name 等四個字段,但入參中只設置了 name 和 _id,所以出參也只有 name 和 _id

如果把入參也改爲四個字段:

 後端邏輯不用調整,請求結果就會變成:

 Cool~

 

 

 三、GraphQL 構建類型

上面的 Schema 是使用 buildSchema 定義的,但 buildSchema 接收的類型參數只能是一整個字符串

如果我們複用某些自定義類型就不太方便,而且字段的處理函數需要寫在 rootValue 裏面,不方便模塊化管理

所以更推薦使用 GraphQLSchema 構建類型

const { GraphQLSchema, GraphQLObjectType } = require('graphql');

const schema = new GraphQLSchema({
  query: new GraphQLObjectType(),
  mutation: new GraphQLObjectType(),
});

GraphQLObjectType 是構建 Schema 類型的基本方法,包括 query 和 mutation 在內的所有類型都需要通過該構造函數構建

我們先嚐試用構建類型的方式,來改寫將上面 buildSchema 定義的 Schema

/* /graphql/schema.js - 構建類型 */

const { GraphQLSchema, GraphQLObjectType } = require('graphql');
const getAllMovie = require('./query/movie.js');

const RootQuery = new GraphQLObjectType({
  name: 'RootQueryType',
  fields: {
    getAllMovie,
  }
});

module.exports = new GraphQLSchema({
  query: RootQuery,
  // mutation: RootMutation,
});

這裏定義了一個 RootQuery 類型,對應的是之前的:

這裏的 getAllMovie 是由 Movie 類型組成的數組,需要另外構建:

/* /graphql/types/movies.js - 定義 Movie 類型 */
const graphql = require('graphql');

const { 
  GraphQLObjectType, 
  GraphQLList, 
  GraphQLString, 
  GraphQLInt,
} = graphql;

const MovieType = new GraphQLObjectType({
  name: 'Movie',
  fields: () => ({
    _id: { type: GraphQLString }, // String
    name: { type: GraphQLString }, 
    years: { type: GraphQLInt },  // Int
    poster: { type: GraphQLString },
    director: { type: GraphQLString },
    category: { type: new GraphQLList(GraphQLString) }, // [String]
  })
});

module.exports = MovieType;

在定義 Movie 類型下的具體字段 fields 的時候,需要通過對象的形式規定類型 type

這裏的 type 不能像之前那樣直接寫 String、Boolean,而是使用 graphql 中提供的類型對象

 

現在定義好了 Movie 類型,但是 getAllMovie 返回的是 Movie 類型組成的數組,還有一個對應的處理函數,所以我們要單獨維護一個 getAllMovie 對象

/* /graphql/query/movies.js - 定義 getAllMovie 字段 */

const { GraphQLList } =  require('graphql');
const movieGraphQLType = require('../types/movie.js');
const Movie = require('../../mongodb/models/movie.js');

module.exports = {
  type: new GraphQLList(movieGraphQLType),
  args: {},
  resolve() {
    return Movie.find({})
  }
}

注意我們導出的對象包含 type、args、resolve 三個字段,而我們剛纔定義 Movie 類型的時候,fields 字段對象也包含一個 type 字段

沒錯,這裏導出的對象其實就一個 field,而每個 field 都可以包含 type、args、resolve

其中 type 不用再提,resolve 就是該字段對應的處理函數,對應上面 buildSchema 小節中 rootValue 中的字段

args 用來描述 resolve 方法接收的參數,在後面介紹 mutation 的時候會介紹


由於每個 filed 都可以是一個獨立的類型,而每個類型可以配置自己的 resolve 處理函數,所以在 GraphQL 可以很方便的執行復雜查詢

只要在響應的類型中配置好 resolve,前端只需要調一次接口就能獲取到多個文檔的數據

 

到此爲止,我們已經完成了從 buildSchema 到構建類型的改造,由於在 field 字段中定義了 resolve,所以就可以不用定義 rootValue 了

/* /controllers/movie.js */

const graphqlHTTP = require('koa-graphql');
const MovieSchema = require('../graphql/schema');// 查詢所有電影
const getMovie = graphqlHTTP({
  schema: MovieSchema,
  graphiql: true
});

module.exports = {
  getMovie,
};

 

 

四、使用 mutation 執行增刪改

上面提到了 args,它用來描述 resolve 方法的參數

首先來看一下前端怎麼在 resolve 方法中傳參

看起來就和我們平時用的 function 一樣,但這裏面大有玄機

首先如果參數是一個 String,就需要手動添加雙引號,而且只能是雙引號

如果用單引號會報錯(主要是爲了避免文本中帶有單引號的情況 desc: "I'm Wise" )

Syntax Error: Unexpected single quote character ('), did you mean to use a double quote (")?

如果參數是 Int 類型,就不能添加引號  years: ${data.years}, 

如果參數是數組類型,需要用 JSON.stringify 轉換

由於對參數類型的處理較爲複雜,可以封裝一個處理參數的工具函數來統一處理

// 這只是我簡單嘗試之後的感想,如果小夥伴有更好的處理思路,一定要在評論區留言,感謝 🤝

 

知道了怎麼向 resolve 方法傳參(不只是 mutation,query 也可以傳參),再來說說 args:

它可以像定義 fields 一樣定義接收的參數,如果 args 裏只寫了一個參數,而接口入參傳了入了多個,接口會返回錯誤

如果入參傳的參數少了是可以的,只要必填項 GraphQLNonNull 沒落下

然後可以從 resolve 的第二個參數中獲取到前端傳過來的參數,再通過 Mongoose 生成的 Model 來操作數據 

需要注意的是,前端在發送 mutation 請求的時候,要在 query 中聲明 mutation

定義好了 mutation,按照之前構建 RootQuery 對象的方式構建 RootMutation,並賦值給 schema,一個具有基本功能的 GraphQL 服務就完成了

如果對項目結構還不太清晰,可以看一下項目倉庫:https://github.com/wisewrong/Test-GraphQL-App

再回頭捋一下,其實後端服務只定義了一個接口,而具體的操作都是在前端分工

這樣雖然增加了前端的工作量,但也增加了前端的靈活性,讓後端的小夥伴能專注於數據庫的設計和優化

其實 GraphQL 早在 2015 年就發佈了,卻一直沒有推廣開,當時尤大大還做了一波分析

GraphQL 爲何沒有火起來? - 尤雨溪的回答 

但時至今日,GraphQL 已經得到了廣泛認可,有許多大廠已經開始廣泛使用(比如 TX 的 CSIG)

特別是對於有全棧發展興趣的小夥伴,學一下 GraphQL 是很有必要的,這樣我就不至於只能看國外的文章來學 GraphQL 了

 

 

參考文章:

《你必須要懂得關於 mongoose 的一小小部分》

《Why GraphQL is the future》

《How to GraphQL》

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章