Apollo GraphQL 在 webapp 中應用的思考

Apollo GraphQL 在 webapp 中應用的思考

原文發表在: https://github.com/kuitos/kui...html

簡介

熟悉 Apollo GraphQL 的同窗可直接跳過這一章,從 實踐 一章看起。前端

GraphQL 做爲 FaceBook 2015年推出的 API 定義/查詢 語言,在歷經了兩年的發展以後,社區已相對發達和完善。對於 GraphQL 的一些基礎概念,本文再也不一一贅述,目前社區相關的文章已經不少,有興趣的同窗能夠去 google,或者直接看GraphQL 官方教程 Apollo GraphQL Server 官方文檔node

Apollo GraphQL 做爲目前社區最流行的 GraphQL 解決方案提供商,提供了從 client 到 server 的一整套完整的工具鏈。在這裏我也準備以 Apollo 爲例,經過一步步搭建 Apollo GraphQL Server 的方式,來給你們展現 GraphQL 的特色,以及個人一些思考(主要是個人思考?)。ios

setup

建立基於 express 的 GraphQL servergit

// server.js
import express from 'express';
import { graphiqlExpress, graphqlExpress } from 'apollo-server-express';
import schema from './models';

const PORT = 8080;
const app = express();

...
app.use('/graphql', graphqlExpress({ schema }));
app.use('/graphiql', graphiqlExpress({
    endpointURL: '/graphql'
}));

if (process.env.NODE_ENV === 'development') {
    glob(path.resolve(__dirname, './mock/**/*.js'), {}, (er, modules) => modules.forEach(module => require(module).default(app)));
}

app.listen(PORT, () => console.log(`> Listening at port ${PORT}`));

執行 node server.js,這樣咱們就能啓動一個 GraphQL server 了。github

注意咱們這裏使用了 apollo-server-express 提供的 graphiqlExpress 插件,graphiql 是一個用於瀏覽器端調試 graphql 接口的 GUI 工具。服務啓動後,咱們在瀏覽器打開 http://localhost:8080/graphiql就能夠看到這樣一個頁面web

定義 API schema

咱們在 server.js 中定義了這樣一個 endpoint : app.use('/graphql', graphqlExpress({ schema }));typescript

這裏傳入的 schema 是什麼呢?它大概長這樣:express

import { makeExecutableSchema } from 'graphql-tools';
// The GraphQL schema in string form
const typeDefs = `
  type User { 
    id: ID!
    name: String
    age: Int
  }
  type Query { user(id: ID!): User }
  schema { query: Query }
`;

// The resolvers
const resolvers = {
  Query: { user({id}) { return http.get(`/users/${id}`)}}
};

// Put together a schema
const schema = makeExecutableSchema({
  typeDefs,
  resolvers
});

app.use('/graphql', graphqlExpress({ schema }));

這裏的關鍵是用了 graphql-tools 這個庫提供的 makeExecutableSchema 組合了 schema 定義和對應的 resolver。resolver 是 Apollo GraphQL 工具鏈中提出的一個概念,什麼用呢?就是在咱們客戶端請求過來的 schema 中的 field 若是在 GraphQL Server 中有對應的 resolver,那麼在返回數據時候,這些 field 就由對應的 resolver 的執行結果填充(支持返回 promise)。npm

客戶端請求

這裏藉助 graphiql 面板的功能來發送請求:

看一下 http request payload 信息:

響應體:

也就是說,不管你是用你熟悉的 http lib 仍是社區的 apollo client,只要按照 GraphQL Server 要求的既定格式發請求就 ok 了。

這裏咱們使用了 GraphQL 中的 variable 語法,事實上在這種須要傳參的動態查詢場景下,咱們應該老是使用這種方式發送請求:即一個 static query + variable 的方式,而不是在運行時動態的生成 query string。這也是官方建議的最佳實踐。

更復雜的嵌套查詢場景

假設咱們有這樣一個場景,即咱們須要取到 User Entity 下的 nick 字段,而 nick 數據並不來自於 user 接口,而是須要根據 userId 調用另外一個接口取得。這時候咱們服務端的代碼須要這樣寫。

// schema
type User {
  id: ID!
  name: String
  age: Int
  nick: String
}
// resolver
User: {
  nick({ id }) {
    return getUserNick(id);
  }
}

resolver 的參數列表中包含了當前所在 Entity 已有的數據,因此這裏能夠直接在函數的入參裏取到已查詢出來的 userId。

看下效果:

服務端的請求:

能夠看到,這裏多出了查詢 nick 的請求。也就是說,GraphQL Server 只有在客戶端提交了包含相應字段的 query 時,纔會真正去發送相應的請求。更多 resolver 說明能夠看這裏

其餘

在真實的生產環境中,咱們一般會有更多更復雜的場景,好比接口的權限認證、分頁、緩存、批量提交、schema 模塊化等需求,好在社區都有相對應的一些解決方案,這不是本文的重點因此不在這裏一一介紹了,有興趣的能夠去看下我以前寫的 graphql-server-startkit,或者官方的 demo

實踐

若是你真實的使用過 Apollo GraphQL,你會經歷以下過程:

  1. 定義一個 schema 用於描述查詢入口

    // schema.graphql
    type User {
        id: ID!
        name: String
        nick: String
        age: Int
        gender: String
    }
    type Query {
        user(id: ID!): User
    }
    schema {
        query: Query
    }
  2. 編寫 resolver 解析對應類型

    const resolvers = {
        Query: {
            user(root, { id }) {
                return getUser(id);
            }
        },
        User: {
            nick({ id }) {
                return getUserNick(id);
            }
        }
    };
  3. 編寫客戶端請求代碼調用 GraphQL 接口,一般咱們會封裝一個 get 方法

    function getUser(id) {
      // 以 axios 爲例
      return axios.post('/graphql', { query: 'query userQuery($id: ID!) {↵    user(id: $id) {↵    id↵    name↵    nick↵  }↵}', operationName: "userQuery", variables: {id}});
    }

    若是你的項目中加入了靜態類型系統,那麼你的代碼可能就會變成這樣:

    // 以 ts 爲例
    interface User {
      id: number
      name: string
      nick: string
      age: number
      gender: string
    }
    function getUser(id: number): User {
      return axios.post('/graphql', { query: 'query userQuery($id: ID!) {↵    user(id: $id) {↵    id↵    name↵    nick↵  }↵}', operationName: "userQuery", variables: {id}});
    }

寫到這裏你可能已經發現,不只是 entity 類型定義,就鏈接口的封裝,咱們在服務端和客戶端都重複了一遍(雖然一個用的 GraphQL Type Language 一個用的 TS)… 這仍是最簡單的場景,若是業務模型複雜起來,你在兩端須要重複的代碼會更多(好比類型的嵌套定義和 resolve)。這時候你可能會想起 DRY 原則,而後開始思考有沒有什麼方式可使得類型及接口定義能兩端複用,或者根據一端的定義自動生成另外一端的代碼?甚至你開始懷疑,到底有沒有引入 GraphQL 的必要?

思考

GraphQL 做爲一個標準化並自帶類型系統的 API Layer,其工程價值我也再也不過多廣告了。只是在實踐過程當中,既然咱們沒法徹底避免服務端與客戶端的實體與接口定義重複(使用 apollo-codegen 能夠避免一部分),並且對於大部分小團隊而言,運維一個 productive nodejs system 實際上都是力有未逮。那麼咱們是否是能夠考慮在純客戶端構建一個類 GraphQL 的 API Layer 呢?這樣既能夠有效的避免編碼重複,也能大大的下降對團隊的要求,可操做的空間也比增長一個 nodejs 中間層大得多。

咱們能夠回憶一下,一般對於一個前端而言,促使咱們須要一個 API Layer 的緣由是什麼:

  1. 後端接口設計不夠 restful,命名垃圾,用的時候看見那個*同樣的 url 就難受。
  2. 後端同窗只願意寫 microservice,提供聚合服務的 web api 被認爲沒有技術含量,不肯意寫。你須要一個數據,他告訴你須要調 a、b、c 三個接口,而後根據 id 組裝合併。
  3. 接口返回的數據格式各類嵌套及不合理,不是前端想要的結構。
  4. 接口返回的數據字段命名隨意或者風格不統一,我有強迫症用這種接口會發瘋。
  5. 後端返回的 數據格式/字段名 一旦變了,前端視圖綁定部分的代碼須要修改。

一般狀況下,碰到這些問題,你可能去跟後端同窗力排衆議,要求他們提供調用體驗更良好設計更優雅的接口。沒錯這很好,畢竟爲了追求完美去跟各類人撕(跟後端撕、跟產品撕、跟UI撕)是一個前端工程師基本的職業素養。可是若是你天天都被撕逼弄得心力交瘁,甚至是你根本找不到撕的對象(好比數據來源接口來着幾個不一樣部門,甚至是一些祖傳的沒人敢動的接口),這些時候大概就是你迫切但願有一個 API Layer 的時候了。

如何在客戶端實現一個 API Layer

其實很簡單,你只須要在客戶端把 Apollo Server 中要寫的 resolvers 寫一遍,而後配上一些性能提高手段(如緩存等),你的 API Layer 就完成了。

好比咱們在src下新建一個 loaders/apis 目錄,全部的數據拉取接口都放在這裏。好比這樣:

// UserLoader.ts
export interface User {
  id: number
  name: string
  nick: string
}

export default class UserLoader {
  
  async getUser(id: number): User {
    const base = await Promise.all([http.get('//xxx.com/users/${id}'), this.getUserNick(id)]);
    const user = base.reduce((acc, info) => ({...acc, ...info}), {});
    return user;
  }
  
  getUserNick(id: number): string {
    return http.get(`//xxx.com/nicks/${id}`);
  }
}

而後在你業務須要的地方注入相應 loader 調用接口便可,如:

import { inject } from 'mmlpx';
import UserLoader from './UserLoader';
// Controller.ts
export default class Controller {
  
  @inject(UserLoader)
  userLoader = null;
  
  async doSomething() {
    // ...
    const user = await this.userLoader.getUser(this.id);
    // ...
  }
}

若是你不喜歡依賴注入的方式,loaders/apis 層直接 export function getUser 也能夠。

若是你碰到了上面描述的第 三、4 、5 三種問題,你可能還須要在這一層作一下數據格式化。好比這樣:

async getUser(id: number): User {
  const base = await Promise.all([http.get('//xxx.com/users/${id}'), this.getUserNick(id)]);
  const user = base.reduce((acc, info) => ({...acc, ...info}), {});
  
  return {
    id: user.id,
    name: user.user_name, // 重命名字段
    nick: user.nick.userNick  // 剔除原始數據中無心義的層次結構
  };
}

通過這一層的數據處理,咱們就能確保咱們的應用運行在前端本身定義的數據模型之下。這樣以後後端接口不管是數據結構仍是字段名的變動,咱們只須要在這一層作簡單調整便可,而不會影響到咱們上層的業務及視圖。相應的,咱們的業務層邏輯再也不會直接對接接口 url,而是將其隱藏在 API Layer 下,這樣不只能提高業務代碼的可讀性,也能作到眼不見爲淨。。。

總結

熟悉 GraphQL 的同窗可能會很快意識到,我這不過是在客戶端作了一個簡單的 API 封裝嘛,並不能解決在 GraphQL 出現以前的 lots of roundtrips 及 overfetching 問題。但事實上是 roundtrip 的問題咱們能夠經過客戶端緩存來緩解(若是你用的是 axios 你可能須要 axios-extensions ),並且 roundtrip 的問題其實本質上咱們不過是將客戶端的 http 開銷轉移到服務端了而已。在客戶端與服務端均不考慮緩存的狀況,客戶端反而會少一個請求。。。overfetching 問題則取決於 backend service 的粒度,若是 endpoint 不夠 micro,即使是 GraphQL,也會出現接口數據冗餘問題,畢竟 GraphQL 不生產數據,它只是數據的搬運工。。。而若是 endpoint 粒度足夠小,那麼我在客戶端 API 層多開幾個接口(換成 Apollo 也要多寫幾個 resolver),同樣能夠按需取數據。服務端 API Layer 只有一個不可替代的優點就是,若是咱們的數據源接口是不支持跨域或者僅內網可見的,那麼就只能在服務端開個口子作代理了。另一個優點就是,GraphQL Server 的 http 開銷是可控的,畢竟機器是咱們本身控制,而客戶端的環境則不可控(http 開銷受終端設備及網絡環境影響,好比低版本瀏覽器或者低速網絡,均會致使 http 開銷的性能權重增大)。

可能有同窗會說,服務端 API Layer 部署一次任何系統均可以共享其服務,而客戶端 API Layer 的做用域只在某一項目。其實,若是咱們把某一項目須要共享的 API Layer 打成一個 npm 包發佈出去,不也能達到一樣的效果嗎,不少平臺的 js sdk 不都是這個思路麼(這裏只討論 web 開發範疇)。

在我看來,不論你是否會搭建一個服務端的 API Layer,咱們其實都須要有一個客戶端 API Layer 從數據源頭來保證客戶端數據的模型統一及一致性,從而有足夠的能力應對接口的變遷。若是你考慮的再遠一點,在 API Layer 服務的業務模型層,咱們一樣須要有一套獨立的 Service/Model Layer 來應對視圖框架的變遷。這個暫且按下不表,後面會再寫篇文字來詳細說一下個人思路。

事實上,對於大部分團隊而言,客戶端 API Layer 已經夠用了,增長一層 GraphQL 並非那麼必要。並且若是沒有很好的支持將客戶端接口轉換成 GraphQL Schema 和 resolver 的工具時,咱們並不能很愉快的 coding,畢竟兩端重複的工做仍是有點多。

相關文章
相關標籤/搜索