前段时间,分享了一篇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,并不会有什么问题。所以,适合自己的才是最好的!
最后,有任何问题,欢迎留言讨论,一起学习。