graphql/dataloader

微介紹

複習一下 graphql server

schema

type User {
  id: ID!
  name: String!
  posts: [Post]!
}

type Post {
  id: ID!
  title: String!
  content: String!
}

type Query {
  users: [User]!
}

針對每個 field 寫 resolver

 

如果沒寫的話就看拿到的 object 有沒有對應的欄位

 

有的話 那個值如果不是 function 就直接用那個值

 

如果是 function 的話就用那個 function 當作 resolver

例如:

      users 有寫 resolver

 

=> 拿到很多 user object

 

=> id 跟 name 有了,直接用

 

=> posts 是一個 function,執行後拿到很多 post object

 

...

query {
  users {
    id
    name
    posts {
      id
      title
    }
  }
}

這個設計讓 server 可以做 lazy evaluation

不管 query 有幾層都容易處理,也讓 schema 可以做到 circular referencing

不過:

跟資料庫要資料的時候,會有一點小問題

1

2

3

4

5

6

7

如果有 n 個 user,就會跟資料庫 query n + 1 次

(這就是所謂的 n + 1 query)

為什麼 query 很多次會是個問題

1. 感覺就比較耗資源

 

2. 因為 connection pool 有限制同時連線數

什麼是 connection pool

和資料庫建立連線很費時,所以大多 server 都會 reuse connection

 

事先建立好 connection,放在 pool 裡,有人需要就拿去用

 

connection pool 都有最大 connection 的數量

 

限制每台 server 佔用的 connection 在合理的範圍

什麼是 connection pool

sequelize 預設最大連線數是 5

 

沒拿到 connection 就要等別人用完

 

所以沒辦法 n 個 query 一起出發

 

n 很大的話就要等很久

 

甚至一個 request 還沒結束還要先等別的 request

server

database

q

q

q

q

q

q

q

q

q

q

q

q

q

q

q

q

q

q

q

q

q

q

q

q

q

提升效能的方法有哪些

query {
  users {
    id
    name
    posts {
      id
      title
    }
  }
}

 

resolver 先看看下一層有哪些 field 是需要的

 

例如這個 query 裡面需要 posts

 

那在跟資料庫要 users 的時候,就順便一起抓他們的 posts

 

或是乾脆不用看,就直接一起抓回來

 

這樣 n + 1 直接變成 1 ~

提升效能的方法有哪些

query {
  users {
    id
    name
    posts {
      id
      title
      comments {
      	id
        content
        user {
          id
          name
          posts {
            id
            title
            comments {
              id
              content
              user {
                id
                name
                posts {
                  id
                  title
                }
              }
            }
          }
        }
      }
    }
  }
}

不過要再下一層或是再下下一層,還是沒有辦法

 

硬要做的話,也容易搞得程式碼亂亂的

graphql/dataloader

是一個源自 FACEBOOK (的一位員工)的套件

 

可以幫助解決這個問題

 

本來是在各個 function 裡面分別跟資料庫要資料

 

用 dataloader 的話

 

換成跟 dataloader 要資料

 

dataloader 收集完 再一起跟資料庫拿

例如本來在 user 的 posts 這個 function 裡面

我們要自己去跟資料庫要 posts ( by userId )

用 dataloader 的話,我們在 function 裡面就不直接跟資料庫 query

而是把 userId 給 dataloader,請他給我們 posts

然後在 dataloader 只要實作 batch function ( array to array ) 就好了

dataloader

key

keys

=====>

values

value

batch function

key

key

key

value

value

value

本來是這樣

用 dataloader 變這樣

User.prototype.posts = function getPosts() {
  return db.models.Post.findAll({
    where: {
      UserId: this.id,
    },
  });
}

User.prototype.posts = function getPosts(args, { postsLoader }) {
  return postsLoader.load(this.id);
}

然後 postsLoader 只要實作 batch 處理的 function

剛剛 function 的 postsLoader 是怎麼傳進來的

先簡介一下 resolver 的 4 個參數

const resolver = (parent, args, context, info) => {
  ...
}
  
  
const apolloServer = new ApolloServer({
  schema,
  context: ({ request }) => ({
    jwtPayload: getJWTPayloadFromRequest(request),
    postsLoader: createPostsLoader(),
  }),
});

每個 request 進來的時候

我們可以設 context

接下來每個 resolver 都拿得到~

loaders/createPostsLoader.js


import DateLoader from 'dataloader';
import { Op } from 'sequelize';
import groupBy from 'lodash/groupBy';
import { db } from '../db';

const batchPosts = async (userIds) => {
  const posts = await db.models.Post.findAll({
    where: {
      UserId: {
        [Op.in]: userIds,
      },
    },
  });

  const postsMap = groupBy(posts, 'UserId');

  return userIds.map((userId) => postsMap[userId]);
};

export default () => new DateLoader(batchPosts);

一次跟 db 拿全部

照 UserId 整理好

要對應原來的順序

keys => values

( array to array )

dataloader 怎麼知道什麼時候可以跟 db 要資料?

我猜:

 

dataloader 只要不在同一個 tick 執行 batch 就好了

 

node: process.nextTick(func) 

node or browser: setTimeout(func)

因為 n 個 user 是同時拿到的

他們的 posts method 會在同一個 callstack 執行

看一下前後差別

來測一下花費的時間

1. apollo server 可以開 tracing

 

可以從 playground 看到每一個 field 花了多少時間

const apolloServer = new ApolloServer({
  schema,
  context: () => ({
    postsLoader: getPostsLoader(),
  }),
  tracing: true,
});

來測一下花費的時間

來測一下花費的時間

2. 也可以自己 log 整個 request 處理的時間

在 apollo server 前面加一個 middleware,設定 response 完要算時間然後 log 出來

app.use((ctx, next) => {
  if (ctx.request.path === '/graphql') {
    const startTime = process.hrtime();

    ctx.res.on('finish', () => {
      const { operationName } = ctx.request.body;
      
      const [aa, bb] = process.hrtime(startTime);
      const elapsedTimeInMs = aa * 1000 + bb / 1e6;

      debugResponseTime(`${operationName}: ${elapsedTimeInMs}ms`);
    });
  }

  return next();
});

沒跟 users 一起抓 posts,也沒有用 dataloader 的結果

用了 dataloader 的結果

dataloader 也有 cache 的功能

同樣的 key 如果有結果了就不用再拿

 

不同 request 如果共用 dataloader 的話就可以共用 cache

參考資料

graphql dataloader

By luyunghsien

graphql dataloader

  • 500