前段時間,分享了一篇GraphQL & Relay 初探,主要介紹了 GraphQL 的設計思想和 Relay 的基本應用。
目前,筆者在實際專案中應用 GraphQL+Relay 已經有段時間了,併發布了一個正式版本。整個過程中,踩了不少坑,也摸索出了一些經驗,特此做一下總結分享。
架構&角色分工
對於架構設計與角色分工,一定程度上,依賴於團隊人員的配置。由於我們團隊主要由後端研發組成,前端人數有限,所以還是以“前”和“後”為分界來分工,即前端負責純 Web 端部分的開發,後端來實現後端邏輯以及 GraphQL 層的封裝。
具體而言,每個後端研發負責一個或多個業務模組,每個模組都微服務化,並起一個 GraphQL 或 RESTful API 服務。後端同時還負責維護一個 API Gateway 模組,用來轉發前端過來的請求、鑑權、統一錯誤處理等工作。整個架構如下圖:
也就是說,前端負責 Web 端開發以及 GraphQL 的封裝,後端則負責設計資料庫並提供後端業務操作介面。架構圖可以設計成這樣: 這樣設計的好處,可以最大程度降低前後端之間用於溝通、聯調上的時間成本,使得開發效率最大化。
工作流
由於人員限制,採用了上面提到的第一種,後端微服務化的架構設計,便不可避免的存在一些溝通成本。對此,結合社群已有的解決方案,設計了一個半自動的工作流,如下圖:
其中,核心點在於,指令碼自動化地獲取各 GraphQL 微服務的 Schema,然後做合併,彙總成一個總的 Schema。這個總的 Schema 主要有三個作用:1、供 Relay 框架編譯 Relay 元件;
2、前端 Mock 服務;
3、提供 API 文件(含型別校驗)這樣一來,只要後端開發完成了 schema 的定義,並執行 Server(可以暫時只是假資料),前端即可以一鍵跑起 Mock 服務,開始開發前端元件,而且後端任何的變更,也可以及時同步到前端。
具體實現上,採用了Apollo graphql-tools的remote schema和schema 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,如下:
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 元件,否則切換路由的時候,會產生類似“全屏重新整理”的效果,影響使用者體驗,如下圖:
比如,某個彈窗內的表格資料,可以考慮使用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,並不會有什麼問題。所以,適合自己的才是最好的!
最後,有任何問題,歡迎留言討論,一起學習。