走在JS上的全棧之路(二)(1/2)

wyfem發表於2021-09-09

(這是一個系列文章:預計會有三期,第一期會以同構構建前後端應用為主,第二期會以 GraphQL 和 MySQL 為主,第三期會以 Docker 配合線上部署報警為主)

作者: 趙瑋龍

重要宣告: 從此不再以 AMC 團隊名稱釋出文章,原因不詳述,所有文章和後續文章將由個人維護,如果你對我的文章感興趣,也請繼續支援和關注,再次宣告-個人還是會保持更新和最新以及前沿技術的踩坑,不僅僅侷限於前端領域!

可能你也發現題目出現了1/2,因為如果介紹 GraphQL 和 MySQL 一起,容易忽略掉中間的很多細節過程,還有篇幅本身問題,我準備把他們拆開來說,我仔細想了下,我先從前端的角度看 GraphQL 如何耦合到我們的專案中,看看它能為我們帶來什麼並且解決了什麼問題(雖然拆開說,篇幅還是非常長的,希望各位感興趣的同學可以先點贊儲存~~~慢慢看),再然後我們看看 node 端如何從資料庫層面支援 GraphQL,還是保留學習的心態~ 虛心向大家學習並且給自己的學習過程留下一些印記。


正片的分界線

什麼是GraphQL,為什麼我們會需要它

先來闡述下什麼是GraphQL

A query language for your API GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. GraphQL provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools.

這是官網上對他的解釋,一種專門為 API 設計的查詢語言: 一種滿足你已有資料查詢需求的 runtime,沒有冗餘並且精確的查詢你需要的資料,也能讓 API 更易於維護和演進。

我仔細回想日常中開發遇到的那些覺得很麻煩的問題:

  • 首先是每次開發需求開始狀態都需要都 mock 的資料,根據後端提供的介面文件去生成自己的 mock 資料無論是公司已有的 mock server 工具還是自己的 mock server 或者是第三的 mock server 這讓開發變得繁瑣,原因是在開發中我需要不停的查詢後端的介面文件制定自己的 mock 資料並且,聯調中還要會因為欄位等等與後端不一致再次更改。
  • 每次請求資源的時候,經常會遇到各種序列·並行請求,核心原因可能是因為後端領域服務或者是資料庫層面的原因,這樣會導致我們在得到我們需求的資源過程變得非常複雜。
  • 由於上面的問題或者哪怕是單個介面我們往往也需要把介面返回的資料 normalize 化,當然就算你不 normalize 也需要處理返回資料拿到你真正對映到 UI 的 data。(後端返回資料往往不是我們真正想要的,或者說不是全部我們都需要的。)
  • 根據 RESTful 請求也就意味著我們需要很多介面,或者說是起很多介面名稱定位資源並且資源定位未必準確,這樣不僅僅浪費IO次數也會產生很多其實沒必要的網路請求。

當然基於上面的問題我也知道現在各個公司本身也有自己 BFF 方案,針對前端做一定的優化。確實解決了上面一些問題。但是再後退一步說如果就 RESTful 本身的問題來思考的化,其實 GraphQL 解決的就是 RESTful 本身解決不了的問題。 我們都知道:

REST -> Representational State Transfer Resources 意味著單一資源無論是一張圖片一個檔案等等對應唯一的資源定位符

那麼問題其實就在這裡,往往隨著現在前端介面的複雜化,我們需要的資源往往不是單一資源了。那麼這種架構本身也確實會有它的短板。

對比之下我們看看為什麼可能會需要GraphQL

先明確一個概念GraphQL是基於SDL -> Schema Definition Language 熟悉資料庫的同學可能對於schema概念比較熟悉,其實我們也可以根據這個名稱去思考它本身Graph(圖),圖的概念本身就是你的data樹形結構。

我們看一下官網首頁的例子:

# 描述的資料schema:
type Project {
  name: String
  tagline: String
  contributors: [User]
}

# 你的請求資料:
{
  project(name: "GraphQL") {
    tagline
  }
}

# 你得到的資料:
{
  "project": {
    "tagline": "A query language for APIs"
  }
}
複製程式碼

從上面的例子我們思考下,如果每個資料本身都定義 schema 好處有兩點:

  • 這看起來是不是更加像天然的介面文件
  • 每個欄位都有自己的 scalar(型別),這點對於js本身弱型別來說是個極好的訊息。

既然解決了宣告 schema 和介面文件問題,那它能不能解決多個 IO 請求和複用一個資源定位uri定位所有資源的問題呢?

首先複用一個資源定位 uri 定位所有資源肯定是沒問題的,前面我們提到過既然是你的請求資料結構決定返回資料結構。那麼無論你發出什麼樣的請求都會有相同的對映,服務端是不需要根據uri知道你具體請求什麼資訊了,而是通過你請求的格式(圖)來判斷是時候祭出官方的資源了:

我們還是借用官網的例子來看下:

# 你的請求資源可能涵蓋之前RESTful的許多個介面或者是一個特別大的json資料
# 你可能在懷疑那如果RESTful一個介面也能返回下面的資料豈不是也很完美,沒錯可是如果我跟你說我可能需要的homeWorld 裡的資料是特定的name 和climate呢?我們還需要去url上傳引數,並且實際情況是後端往往覺得這樣的東西我返回給你全部,你自己去拿就好啦。
{
  hero {
    name
    friends {
      name
      homeWorld {
        name
        climate
      }
      species {
        name
        lifespan
        origin {
          name
        }
      }
    }
  }
}

# 對應的schema

type Query {
  hero: Character
}

type Character {
  name: String
  friends: [Character]
  homeWorld: Planet
  species: Species
}

type Planet {
  name: String
  climate: String
}

type Species {
  name: String
  lifespan: Int
  origin: Planet
}
複製程式碼

針對於拿特定資料這個問題為了更好的 (data=>UI),我看到一篇文章說代替之前 redux 的使用經驗特別的好推薦給大家。

我一直覺得這個對話方塊特別的有說服力:

走在JS上的全棧之路(二)(1/2)

下面我們來在我們的專案中實踐下GraphQL

改造上一篇中的ssr

我們先不要一口吃個胖子,先來一步步的改造之前的 ssr 耦合 GraphQL 看看這東西是怎麼玩的。我們的目的是利用 mock 的資料打通前後端流程。

先介紹下我們用到的工具,直接使用 GraphQL 會有一些難度,所以我們採用 Apollo 提供的一些工具:

  • graphql-tag
  • apollo-client
  • apollo-server-koa
  • graphql-tools
  • apollo-cache-inmemory
  • react-apollo

我們會在後面的使用中提到他們的一部分使用方式,當然最好·最全的使用方式是閱讀官方文件

既然我們提到我們不再需要各種url去定義一個資源本身,意味著我們只需要一個介面全部搞定(我並沒有刪掉之前程式碼而是禁掉,方便大家觀察區別):

// apollo模組替代redux
import { ApolloProvider, getDataFromTree } from 'react-apollo';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { SchemaLink } from 'apollo-link-schema';
import { ApolloClient } from 'apollo-client';

// apollo grahql操作模組
import { makeExecutableSchema } from 'graphql-tools';
import { graphqlKoa } from 'apollo-server-koa';

// redux
// const { Provider } = require('react-redux');
// const getStore = require('../common/store').default;

// api字首
const apiPrefix = '/api';

// 引入schema
let typeDefs;
const pathName = './server/schema.graphql';

if (typeof pathName === 'string' && pathName.endsWith('graphql')) {
  const schemaPath = path.resolve(pathName);
  typeDefs = importSchema(schemaPath);
};

// resolvers
let links = [{
  id: 'link-0',
  url: 'www.howtographql.com',
  description: 'Love GraphQL'
},
{
  id: 'link-002',
  url: 'www.howtographql.com',
  description: 'Love GraphQL'
}];

let idCount = links.length;

const resolvers = {
  Query: {
    info: () => `respect all, fear none!`,
    feed: () => links,
    name: () =>  `趙瑋龍`,
    age: () =>  29
  },
  Mutation: {
    post: (root, args) => {
      const link = {
        id: `link-${idCount++}`,
        description: args.description,
        url: args.url,
      }
      links.push(link)
      return link
    },
    deleteLink: (root, args) => {
      return links.filter(item => item.id !== args.id)
    }
  }
}

// 生成schema
const schema = makeExecutableSchema({
  typeDefs,
  resolvers
})

// 路由
module.exports = function(app, options={}) {
  // 頁面router設定
  app.get(`${staticPrefix}/*`, async (ctx, next) => {
    // graphql介面設定
    const client = new ApolloClient({
      link: new SchemaLink({ schema }),
      ssrMode: true,
      connectToDevTools: true,
      cache: new InMemoryCache(),
    })

    const helmet = Helmet.renderStatic();
    const context = {};
    options.title = helmet.title;

    // restful api redux資料來源
    // const store = getStore();
    // const promises = routes.map(
    //   route => {
    //     const match = matchPath(ctx.path, route);
    //     if (match) {
    //       let serverFetch = route.component.loadData
    //       return serverFetch(store.dispatch)
    //     }
    //   }
    // )

    // const serverStream = await Promise.all(promises)
    // .then(
    //   () => {
    //     return ReactDOMServer.renderToNodeStream(
    //       <Provider store={store}>
    //         <StaticRouter
    //           location={ctx.url}
    //           context={context}
    //           >
    //           <App/>
    //         </StaticRouter>
    //       </Provider>
    //     );
    //   }
    // );

    // graphql提取資料並且渲染dom
    const Html = (
      <ApolloProvider client={client}>
        <StaticRouter
          location={ctx.url}
          context={context}
          >
          <App/>
        </StaticRouter>
      </ApolloProvider>
    );
    const serverStream = await getDataFromTree(Html).then(() => ReactDOMServer.renderToNodeStream(Html));
    // console.log(serverStream.readable);
    await streamToPromise(serverStream).then(
      (data) => {
        options.body = data.toString();
        if (context.status === 301 && context.url) {
          ctx.status = 301;
          ctx.redirect(context.url);
          return ;
        }
        // 把store.getState()替換成client.extract()
        if (context.status === 404) {
          ctx.status = 404;
          ctx.body = renderFullPage(options, client.extract());
          return ;
        }
        ctx.status = 200;
        ctx.set({
          'Content-Type': 'text/html; charset=utf-8'
        });
        ctx.body = renderFullPage(options, client.extract());
    })
    // console.log(serverStream instanceof Stream);
    await next();
  });

  // api路由
  // app.get(`${apiPrefix}/user/info`, async(ctx, next) => {
  //   ctx.body = {
  //     code: 10000,
  //     msg: '',
  //     data: {
  //       name: '趙瑋龍',
  //       age: 29,
  //     }
  //   }
  //   await next();
  // });
  //
  // app.get(`${apiPrefix}/home/info`, async(ctx, next) => {
  //   ctx.body = {
  //     code: 10000,
  //     msg: '',
  //     data: {
  //       title: '你要的網站',
  //       content: '那些年我想過的女孩~',
  //     }
  //   }
  //   await next();
  // });

  // 設定除錯GraphQL-playground
  app.all('/graphql/playground', koaPlayground({
      endpoint: '/graphql',
    })
  );

  // GraphQl api
  app.all('/graphql', graphqlKoa({ schema }));
}
複製程式碼

先來看下路由方面我們宣告瞭兩個路由:

  • /graphql(用於請求資料的介面)
  • /graphql/playground(graphql實現請求介面)

走在JS上的全棧之路(二)(1/2)

走在JS上的全棧之路(二)(1/2)

我們看到根據 ssr 本身的原理,我們把 INITIAL_STATE 換成了GraphQL的資料,這正是我們後面會說道的利用 GraphQL 代替 redux 的方案 聚焦下三個問題。

  • schema (隨著業務的發展我們會把 schema 分出去單獨成檔案,當然如果你的編輯器支援 graphql 語法,你當然更希望以 .graphql 檔案結尾然後擁有IDE的功能)
  • resolvers (隨著資料庫的加入我們下篇文章會說如何做 ORM 對映,這裡先是 mock 資料)
  • ApolloClient (替代掉 createStore)

schema 本身我們希望它寫在單獨的檔案中,例如 .graphql 中,做到拆分邏輯,但是目前 node 還不支援這個結尾檔名,我們用的第三方庫,當然自己做也並不難,就是利用 fs 讀出 utf8 編碼的字串就行。

import { importSchema } from 'graphql-import';
let typeDefs;
const pathName = './server/schema.graphql';

if (typeof pathName === 'string' && pathName.endsWith('graphql')) {
  const schemaPath = path.resolve(pathName);
  typeDefs = importSchema(schemaPath);
};
複製程式碼

再然後看看 schema:

type Query {
  info: String
  """
  the list of Posts by this author
  """
  # Link例項拿到root宣告,每一個field都需要到它上層申明
  feed: [Link!]!
  name: String!
  age: Int!
}

type Link {
  id: ID!
  description: String!
  url: String!
}

type Mutation {
  post(url: String!, description: String!): Link!
  deleteLink(id: ID!): [Link!]!
}

# interface Character  {
#   id: ID!
#   name: String!
#   role: Int!
# }
#
# type Master implements Character {
#
# }
複製程式碼

resolvers 主要解決的是 schema 宣告的欄位處理方式,每個欄位都有自己的 function 這個本身不難理解

但是你會發現,根據你的請求是 query 或者 mutation 會有引數或者一些 resolvers 中互相共享的引數等,這就是這個函式本身的一些引數:

  • root 相當於當前欄位的父級欄位資訊。
  • args: 欄位本身的引數。
  • context: resolver之間本身的共享物件。
  • info: 你的 schema AST 語法樹

主要前三個引數會是經常用到的。

既然服務端定義好了資料,我們可以通過之前的 /graphql/playground 訪問資料看看能否得到想要的結果 我們發現這裡還有我們之前定義的全部 schema 這個文件查詢簡直是太方便啦!

走在JS上的全棧之路(二)(1/2)

至於客戶端程式碼,我們還用 react 來耦合 graphql

import React from 'react';
import Helmet from 'react-helmet';
import PropTypes from 'prop-types';

// import { connect } from 'react-redux';
import { withRouter } from 'react-router'

import { ApolloConsumer, Query } from 'react-apollo';
import gql from 'graphql-tag';

// actions
// import {
//   userInfoAction,
//   homeInfoAction
// } from '../actions/userAction';

// selector
// import {
//   selectQueryDataFromUser,
// } from '../reducers/entities'
//
// const mapStateToProps = (state, ownProps) => {
//   const userInfo = selectQueryDataFromUser(state)
//   return {
//     ...userInfo,
//   }
// };
//
// const mapDispatchToProps = {
//   userInfoAction,
//   homeInfoAction
// };
//
// @connect(mapStateToProps, mapDispatchToProps)

const GET_INFO_AUTH = gql`
{
  info
  feed {
    id
    url
    description
  }
  name
  age
}
`
class Home extends React.Component {
  // static loadData(dispatch) {
  //   return Promise.all([dispatch(userInfoAction()), dispatch(homeInfoAction())])
  // }

  static defaultProps = {
    name: '',
    age: null,
  }

  render() {
    const {
      name,
      age,
    } = this.props
    return (
      <React.Fragment>
        <Helmet>
          <title>主頁</title>
        </Helmet>
        <h1>{name}</h1>
        <h2>{age}</h2>
      </React.Fragment>
    );
  }
}

export default withRouter(
  () => (
    <Query
      query={GET_INFO_AUTH}
      >
      {
        ({ loading, error, data }) => {
          if (loading) return "loading..."
          if (error) return  `Error! {error.message}`
          return (
            <Home
              age={data.age}
              name={data.name}
              />
          )
        }
      }
    </Query>
  )
);
複製程式碼

這裡有一個叫 Query 的高階元件,當然也有 Mutation,具體你可以查閱官方文件。 我們會發現這個高階元件把 fetch 包裹起來暴露給我們需要的 data, loading 之類的資料供我們渲染 UI。

如何替代 redux 做資料管理

相關 redux 本身的概念和它解決了哪些問題,如果你看興趣可以看這裡,當然我們這裡探討的是利用 GraphQL 去替代 redux。我們從上面的結構化·精確請求能發現,如果我們能直接請求需要 UI 渲染的資料,就會省去很多處理資料和 normalize 化的過程,但是還有一個主要的問題沒有解決,就是除去 server data 以外,還有很多本地的 data 處理,比如按鈕展示隱藏 boolean,或者說本地的 data 和 server data 關聯的問題,這就是為什麼在 redux 中我們會把他們放在一起管理,那麼 GraphQL 如果能解決這個問題並且也有一個全域性唯一類似於 store 一樣的資料來源,這樣我們就不需要 mobx·redux 之類的資料管理庫了,很幸運的是 Apollo 確實幫我們這麼做了,下面我們來介紹下這個功能。既然用到本地的資料,最合適的例子還是大家熟悉的 TodoList(在 home 頁新增這個):

// 我們新建一個todoForm 的檔案寫我們的todoList元件
import React from 'react';
import { graphql, compose } from 'react-apollo';
import { withState } from 'recompose';
import {
  addTodoMutation,
  clearTodoMutation,
  todoQuery,
} from '../../client/queries';

const TodoForm = ({
  currentTodos,
  addTodoMutation,
  clearTodoMutation,
  inputText,
  handleText,
}) => (
  <div>
    <input
      value={inputText}
      onChange={(e) => handleText(e.target.value)}
      />
    <ul>
    {
      currentTodos.map((item, index) => (<li key={index}>{item}</li>))
    }
    </ul>
    <button 
      onClick={() => {
        addTodoMutation({ variables: { item: inputText } })
        handleText('')
    }}>
      Add
    </button>
    <button 
      onClick={(e) => clearTodoMutation()}
    >
      clearAll
    </button>
  </div>
)

const maptodoQueryProps = {
  props: ({ ownProps, data: { currentTodos = [] } }) => ({
    ...ownProps,
    currentTodos,
  }),
};

export default compose(
  graphql(todoQuery, maptodoQueryProps),
  graphql(addTodoMutation, { name: 'addTodoMutation' }),
  graphql(clearTodoMutation, { name: 'clearTodoMutation' }),
  withState('inputText', 'handleText', ''),
)(TodoForm)

// queries.js
import gql from 'graphql-tag';

// 這裡的@寫法是directives,可以檢視上面的官方文件
const todoQuery = gql`
  query GetTodo {
    currentTodos @client
  }
`;

const clearTodoMutation = gql`
  mutation ClearTodo {
    clearTodo @client
  }
`;

const addTodoMutation = gql`
  mutation addTodo($item: String) {
    addTodo(item: $item) @client
  }
`;

export {
  todoQuery,
  clearTodoMutation,
  addTodoMutation,
}
複製程式碼

看下效果:

走在JS上的全棧之路(二)(1/2)

(右邊的 chrome 外掛是 apollo)

我們先看下幾個問題:

  • compose 裡一堆奇怪的東西是幹嘛的。
  • maptodoQueryProps 是什麼
  • graphql() 是什麼鬼。。

第一個問題:

不知道大家有沒有在寫react的時候,習慣 stateless components 的形式呢? 我個人比較偏愛這種寫法,當然啦它也有自己的不足,就是沒有 state 和生命週期,但是人們肯定不會放棄使用它們,甚至有人想的更加極致就是程式碼裡暴露都是這種 FP 風格的寫法,於是就有了recompose,如果你有興趣可以研究它的文件使用下。這裡不是這次的重點,我們帶過,其實為了實現你的 UI 層的抽離,比如把邏輯層抽離在 HOC 高階元件裡,比如上面你看到的 withState 就是一個高階元件,宣告的 state 和相應的 function,你可能會好奇問什麼要這樣寫呢?

// 我們設想下如果我們採用 Mutation 和 Query 元件巢狀的模式避免不了出現下面的形式(是不是感覺有點像回撥地獄呢?):
<Mutation>
  {
    ...
    <Query>
      {
        ...
      }
    </Query>
    ...
  }
</Mutation>

// recompose也提供了組合多個高階元件的模式 compose, 當然 apollo 也有(相當於a(b(c())))

compose(a, b, c)

// 這樣的程式碼看起來會不會舒服很多呢?
複製程式碼

第二個問題:

maptodoQueryProps 是什麼? 用過 react-redux 的同學肯定熟悉 mapStateToProps 和 mapDispatchToProps 這兩個函式,這裡沒有 dispatch 的概念,但是作者也是深受之前這個庫的影響,想把 mutation, query data 也通過這種模式有一個 props 的對映。當然這裡不止是 props 一個 key 具體可以參考這裡,所以其實是把 props.data(query) 和 props.mutation(mutation) 分別按照自己對於 props 的需求對映到 UI 元件上(是不是很像 selector)。

第三個問題:

這裡是我們主要要解釋的,大家一定好奇,這個 todoList 邏輯呢?我們的reducer 去哪啦?

import {
  todoQuery,
} from './queries';

const todoDefaults = {
  currentTodos: []
};

const addTodoResolver = (_obj, { item }, { cache }) => {
  const { currentTodos } = cache.readQuery({ query: todoQuery });
  const updatedTodos = currentTodos.concat(item);
  
  cache.writeQuery({
    query: todoQuery, 
    data: { currentTodos: updatedTodos }
  });
  return null;
};

const clearTodoResolver = (_obj, _args, { cache }) => {
  cache.writeQuery({
    query: todoQuery,
    data: todoDefaults
  });
  return null;
};

export {
  addTodoResolver,
  clearTodoResolver,
  todoDefaults,
}
複製程式碼

還記得我們前面說 apollo-server 裡的 resolver 處理 schema 相應欄位的邏輯嗎?這裡的概念基本類似,apollo 還是利用 resolver 去處理欄位級別的邏輯,你可能會問這不是 reducer 的概念,沒錯這裡完全不是 redux 的理念,而是對於 AST 語法樹的一種處理而已(所以這裡也沒有強迫你去用 pure function 處理, 並且強調 reducer 的可組合拆分性,這是我覺得非常難過的地方,它失去了 redux 核心理念,換來一堆我根本就不想學的 api 和引數,哎。這個 apollo 在我認為就是 api 太多,本人之所以一直很欣賞 react+redux 解決方案,就因為靈活度很高並且 api 很少,這種做法也算是抽離了邏輯層吧)

這裡有4個api,這裡有詳細的文件,這4個 api 分別操作 query 和 fragment,但是就我個人而言真的沒有 reducer 容易理解並且靈活性強,期待你們的看法!

我們會隨著專案深入繼續說一些 GraphQL 的概念和使用方法,也希望感興趣的你可以留言交流。這裡面東西確實是很多,坑也很多,所以沒有涉及到的地方,我們以後還是開個專題來討論下 GraphQL 很多快取策略包括 redies 使用以及如何鑑權的方案(專案後面會涉及到部分,但是並不全面,敬請期待!)

因為這次程式碼改動量比較大,我還是把原始碼放在這裡,希望大家不要覺得我耍流氓只說不放原始碼! (如果你喜歡的話給個 star 吧!)

相關文章