GraphQL & Relay 实战

2,199 阅读6分钟

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