GraphQL & Relay 實戰

LiuRhoRamen發表於2018-05-15

前段時間,分享了一篇GraphQL & Relay 初探,主要介紹了 GraphQL 的設計思想和 Relay 的基本應用。
目前,筆者在實際專案中應用 GraphQL+Relay 已經有段時間了,併發布了一個正式版本。整個過程中,踩了不少坑,也摸索出了一些經驗,特此做一下總結分享。

架構&角色分工

對於架構設計與角色分工,一定程度上,依賴於團隊人員的配置。由於我們團隊主要由後端研發組成,前端人數有限,所以還是以“前”和“後”為分界來分工,即前端負責純 Web 端部分的開發,後端來實現後端邏輯以及 GraphQL 層的封裝。
具體而言,每個後端研發負責一個或多個業務模組,每個模組都微服務化,並起一個 GraphQL 或 RESTful API 服務。後端同時還負責維護一個 API Gateway 模組,用來轉發前端過來的請求、鑑權、統一錯誤處理等工作。整個架構如下圖:

角色分工架構圖
如果對於前後端人員配置均等或者“大前端”團隊來說,就比較適合按元件/模組來分工了。
也就是說,前端負責 Web 端開發以及 GraphQL 的封裝,後端則負責設計資料庫並提供後端業務操作介面。架構圖可以設計成這樣:
角色分工架構圖 2
這樣設計的好處,可以最大程度降低前後端之間用於溝通、聯調上的時間成本,使得開發效率最大化。

工作流

由於人員限制,採用了上面提到的第一種,後端微服務化的架構設計,便不可避免的存在一些溝通成本。對此,結合社群已有的解決方案,設計了一個半自動的工作流,如下圖:

工作流
其中,核心點在於,指令碼自動化地獲取各 GraphQL 微服務的 Schema,然後做合併,彙總成一個總的 Schema。這個總的 Schema 主要有三個作用:
1、供 Relay 框架編譯 Relay 元件;
2、前端 Mock 服務;
3、提供 API 文件(含型別校驗)這樣一來,只要後端開發完成了 schema 的定義,並執行 Server(可以暫時只是假資料),前端即可以一鍵跑起 Mock 服務,開始開發前端元件,而且後端任何的變更,也可以及時同步到前端。
具體實現上,採用了Apollo graphql-toolsremote schemaschema stitching工具完成微服務 schema 的獲取與合併。同時,使用Mocking根據生成的 Schema 來執行 Mock 服務。
附:Schema 獲取與合併程式碼參考

const schemaPath = path.resolve(__dirname, "../schema/schema.graphql");
const urls = Object.keys(APIGraphQL).map(item => APIGraphQL[item]); // APIGraphQL記錄微服務地址
const links = urls.map(uri => {
  let link = new HttpLink({ uri, fetch });
  link = setContext((request, previousContext) => ({
    headers: {}
  })).concat(link);
  return link;
});

const main = async () => {
  const schemas = await Promise.all(links.map(link => introspectSchema(link)));

  // 在根查詢節點新增一個id欄位,解決Relay框架限制
  const HackSchemaForRelay = makeExecutableSchema({
    typeDefs: `
      type HackForRelay {
        id: ID!
      }

      type Query {
        _hackForRelayById(id: ID!): HackForRelay
      }
    `
  });

  fs.writeFileSync(
    schemaPath,
    printSchema(
      mergeSchemas({
        schemas: [HackSchemaForRelay, ...schemas]
      })
    )
  );

  console.log("Wrote " + schemaPath);
};

main();
複製程式碼

在合併 Schema 時,有個問題需要注意:
不同微服務間的 Schema 不能存在相同名稱的 Type,否則在合併中會被同名的 Type 覆蓋。
在筆者開發中,是通過與後端研發約定一個命名規則來規避這類問題的。後續優化,可以考慮自動新增微服務名稱作為字首以解決此類問題。

專案目錄

以下為專案目錄結構以供參考:

├── package.json
├── publish.sh
├── src
│   ├── index.ejs
│   ├── index.js
│   ├── index.less
│   ├── js
│   │   ├── __generated__
│   │   ├── api
│   │   ├── app.js
│   │   ├── assets
│   │   ├── common
│   │   ├── components
│   │   ├── config
│   │   ├── mutations
│   │   ├── routes.js
│   │   ├── service
│   │   └── utils
│   ├── public
│   │   ├── favicon.ico
│   │   └── fonts
│   ├── schema
│   │   ├── mock
│   │   └── schema.graphql
│   ├── scripts
│   │   └── updateSchema.js
│   └── theme.config.js
├── webpack.config.creator.js
├── webpack.config.js
└── yarn.lock
複製程式碼

其中,src/scripts/updateSchema.js是獲取與合併 schema 的指令碼,Schema 與 Mock 服務一併放在src/schema目錄中。其餘前端元件、包含 Relay 元件,全部放在src/js目錄下。
一個前端元件可以建立一個目錄,目錄由至少三個檔案組成:純 React 元件、元件的樣式以及 Relay 的封裝 Container,如下:

專案目錄
其中的 ProjectListContainer.js 部分程式碼參考:

import { createRefetchContainer, graphql } from "react-relay";
import ProjectList from "./ProjectList";

export default createRefetchContainer(
  ProjectList,
  {
    projectInfoList: graphql`
      fragment ProjectListContainer_projectInfoList on ProjectInfo
        @relay(plural: true) {
        createdTime
        descInfo
        jobProfileInfo {
          ...
        }
        ...
      }
    `
  },
  graphql`
    query ProjectListContainer_RefetchQuery {
      projectInfoList {
        ...ProjectListContainer_projectInfoList
      }
    }
  `
);
複製程式碼

路由

關於前端路由,Relay 官方文件中在路由章節中提到了一些解決方案,但不是很詳細。
筆者在專案中,採用的是相對比較推薦的Found Relay

部分配置程式碼參考:

const routesConf = makeRouteConfig(
  <Route>
    <Route path="login" Component={Login} />
    <Route
      path="logout"
      render={() => {
        api.logout({ payload: {}, api: "" });
        throw new RedirectException({ pathname: "/login" });
      }}
    />
    <Route path="/" Component={MainLayout}>
      <Route path="exception/:statusCode" Component={Exception} />
      <Redirect from="/" to="/project" />
      <Route
        path="project"
        Component={ProjectListContainer}
        query={ProjectListQuery}
        prepareVariables={params => ({})}
      >
        <Route
          path="job/:projectId"
          Component={JobListContainer}
          query={JobListQuery}
        />
      </Route>
    </Route>
  </Route>
);

const Router = createFarceRouter({
  historyProtocol: new BrowserProtocol(),
  historyMiddlewares: [queryMiddleware],
  routeConfig: routesConf,

  render: createRender({
    renderError: ({ error }) => {
      const { status } = error;
      if (status) {
        throw new RedirectException({ pathname: `/exception/${status}` });
      }
    }
  })
});

const mountNode = document.getElementById("root");
ReactDOM.render(<Router resolver={new Resolver(environment)} />, mountNode);
複製程式碼

在結合 Relay 框架使用路由過程中,有幾點需要注意:
1、由於 Relay 元件只有請求到了後端資料才會開始渲染,所以儘量不要將整個頁面作為 Relay 元件,否則切換路由的時候,會產生類似“全屏重新整理”的效果,影響使用者體驗,如下圖:

路由
2、根據實際情況,選擇封裝成QueryRendererFragment Container
比如,某個彈窗內的表格資料,可以考慮使用QueryRenderer,在觸發了開啟彈窗操作後,再由元件主動請求資料,而非Fragment Container,由路由 Container 一口氣拉到所有資料,這樣會影響頁面載入速度,而且也沒有必要;
3、在通常的單頁應用裡,除非是有切換使用者的功能,一般 Relay 的 environment 應只在一處配置,所有 Relay 元件共享。
(關於 QueryRenderer、Fragment Container、environment 可以參考Relay 官方文件

元件封裝

Route 所接受的元件都是Fragment,也就是 Relay 框架所提供的 Fragment Container、Refetch Container 和 Pagintion Container。這三種型別的元件,Relay 本身提供的方法使用起來已經比較簡潔方便了。
但是,如果想要封裝一個可以自己單獨獲取資料的Relay元件,也就是使用QueryRenderer,官方卻沒有提供一個封裝函式。所以,我們可以自己來寫一個:

import { QueryRenderer, graphql } from "react-relay";
import { message, Spin } from "antd";
import environment from "../../config/environment";

const createContainer = ({
  query = "",
  variables = {},
  propsName = ""
}) => Target =>
  class RelayContainer extends React.Component {
    render() {
      return (
        <QueryRenderer
          environment={environment}
          query={query}
          variables={variables}
          render={({ error, props }) => {
            if (error) {
              return null;
            } else if (props) {
              return <Target {...this.props} data={props[propsName]} />;
            }
            return <Spin spinning={true} />;
          }}
        />
      );
    }
  };

export { createContainer };
複製程式碼

在具體使用的時候,可以結合ES7的Decorator,非常簡潔:

@createContainer({
  query: graphql`
    ...
  `,
  propsName: "propsName"
})
class MyComponent extends React.Component {
  static defaultProps = {
    ...
  };

  render() {
    ...
  }
}
複製程式碼

總結

GraphQL+Relay框架的設計思路非常好,也確實能在專案後期迭代中,解放不少生產力。但是,在前期的腳手架搭建以及工作流的梳理、前後端人員配合上,需要多花一點的時間來設計一下。希望本文能給準備使用GraphQL的同學掃清一些障礙。
此外,任何框架和技術都要切忌為了用而用,還是要根據實際需求來決定最佳實踐。比如,即使是一個Relay的專案,也並不一定要求所有的API都是GraphQL,依然可以結合RESTful API,並不會有什麼問題。所以,適合自己的才是最好的!
最後,有任何問題,歡迎留言討論,一起學習。

相關文章