开发速度快 10 倍!Airbnb 用 GraphQL+Apollo 做到了

阅读数:3816 2019 年 1 月 4 日

话题:语言 & 开发前端最佳实践

在上个月举行的 GraphQL 峰会上,我做了一场演讲,其中涉及很多实时编码演示,可以看一下视频回顾:

https://youtu.be/JsvElHDuqoA

从参会者的反馈来看,人们非常惊讶我们的开发速度为什么会如此之快,但因为我没有太多时间解释其中的原理,很多人认为这是因为 Airbnb 投入了数年的工程师时间构建了可以支持 GraphQL 的基础设施。但实际上,演示中有 90%的繁重工作都是由 Apollo 的 CLI 工具提供支持的。

在这篇文章中,我将通过部分代码介绍这种快速的开发体验。

将 GraphQL 用于后端驱动的 UI

在演讲中,我们假定开发了一个系统,这个系统有一个动态页面,这个页面基于一个可以返回一系列“section”的查询,这些 section 是响应式的,用于定义页面 UI。

主文件是一个生成文件(稍后我们将介绍如何生成它),如下所示:

复制代码
import SECTION_TYPES from '../../apps/PdpFramework/constants/SectionTypes';
import TripDesignerBio from './sections/TripDesignerBio';
import SingleMedia from './sections/SingleMedia';
import TwoMediaWithLinkButton from './sections/TwoMediaWithLinkButton';
// …many other imports…
const SECTION_MAPPING = {
  [SECTION_TYPES.TRIP_DESIGNER_BIO]: TripDesignerBio,
  [SECTION_TYPES.SINGLE_MEDIA]: SingleMedia,
  [SECTION_TYPES.TWO_PARAGRAPH_TWO_MEDIA]: TwoParagraphTwoMedia,
  // …many other items…
};
const fragments = {
  sections: gql`
    fragment JourneyEditorialContent on Journey {
      editorialContent {
        ...TripDesignerBioFields
        ...SingleMediaFields
        ...TwoMediaWithLinkButtonFields
        # …many other fragments…
      }
    }
    ${TripDesignerBio.fragments.fields}
    ${SingleMedia.fragments.fields}
    ${TwoMediaWithLinkButton.fragments.fields}
    # …many other fragment fields…
`,
};
export default function Sections({ editorialContent }: $TSFixMe) {
  if (editorialContent === null) {
    return null;
  }
  return (
    <React.Fragment>
      {editorialContent.map((section: $TSFixMe, i: $TSFixMe) => {
        if (section === null) {
          return null;
        }
        const Component = SECTION_MAPPING[section.__typename];
        if (!Component) {
          return null;
        }
        return <Component key={i} {...section} />;
      })}
    </React.Fragment>
  );
}
Sections.fragments = fragments;

因为 section 可能会有很多(现在用于搜索的 section 大概有 50 个),所以我们没有需要事先将所有可能的 section 都打包。

每个 section 组件都定义了自己的查询片段,与 section 的组件代码放在一起:

复制代码
import { TripDesignerBioFields } from './__generated__/TripDesignerBioFields';
const AVATAR_SIZE_PX = 107;
const fragments = {
  fields: gql`
    fragment TripDesignerBioFields on TripDesignerBio {
      avatar
      name
      bio
    }
  `,
};
type Props = TripDesignerBioFields & WithStylesProps;
function TripDesignerBio({ avatar, name, bio, css, styles }: Props) {
  return (
    <SectionWrapper>
      <div {...css(styles.contentWrapper)}>
        <Spacing bottom={4}>
          <UserAvatar name={name} size={AVATAR_SIZE_PX} src={avatar} />
        </Spacing>
        <Text light>{bio}</Text>
      </div>
    </SectionWrapper>
  );
}
TripDesignerBio.fragments = fragments;
export default withStyles(({ responsive }) => ({
  contentWrapper: {
    maxWidth: 632,
    marginLeft: 'auto',
    marginRight: 'auto',
    [responsive.mediumAndAbove]: {
      textAlign: 'center',
    },
  },
}))(TripDesignerBio);

这就是 Airbnb 后端驱动 UI 的一般性概念。它被用在很多地方,包括搜索、旅行计划、主机工具和各种登陆页面中。我们以此作为出发点,然后演示如何更新已有 section 和添加新 section。

使用 GraphQL Playground 探索 schema

在开发产品时,你希望能够基于开发数据探索 schema、发现字段并测试潜在的查询。我们借助GraphQL Playground实现了这一目标,这个工具是由 Prisma 提供的。

在我们的例子中,后端服务主要是使用 Java 开发的,我们的 Apollo 服务器(Niobe)负责拼接这些服务的 schema。目前,由于 Apollo Gateway 和 Schema Composition 还没有上线,我们所有的后端服务都是按服务名称进行划分的。这就是为什么在使用 Playground 时需要提供一系列服务名。下一级是服务方法,比如 getJourney()。

通过 VS Code 的 Apollo 插件查看 schema

在开发产品时有这么多工具可用真的是太好了,比如在 VS Code 中访问 Git,VS Code 还提供了用于运行常用命令的集成终端和任务。

当然,除此之外,还有其他一些与 GraphQL 和 Apollo 有关的东西!大多数人可能还不知道新的Apollo GraphQL VS Code 插件。它提供的很多功能我在这里就不一一累述了,我只想介绍其中的一个:Schema Tag。

如果你打算基于正在使用的 schema 来 lint 你的查询,需要先决定是“哪个 schema”。默认情况下可能是生产 schema(按照惯例,就是“current”),但如果你需要进行迭代并探索新的想法,可能需要灵活地切换不同的 schema。

因为我们使用的是 Apollo Engine,所以使用标签发布多个 schema 可以实现这种灵活性,并且多个工程师可以在单个 schema 上进行协作。一个服务的 schema 变更被上游合并后,它们会被纳入当前的生产 schema 中,我们就可以在 VS Code 中切换回“current”。

自动生成类型

代码生成的目标是在不手动创建 TypeScript 类型或 React PropType 的情况下利用强大的类型安全。这个很重要,因为我们的查询片段分布在各种组件中,同一个片段会在查询层次结构的多个位置出现,这就是为什么对查询片段做出 1 行修改就会导致 6、7 个文件被更新。

这主要是 Apollo CLI 的功劳。我们正在开发一个文件监控器(名字叫作“Sauron”),不过现在如果有需要,可以先运行:apollo client:codegen --target=typescript --watch --queries=frontend/luxury-guest/**/*.{ts,tsx}。

因为我们将片段和组件放在一起,所以当我们向上移动组件层次结构时,更改单个文件会导致查询中的很多文件被更新。这意味着在与路由组件越接近的位置(也就是树的更上层),我们可以看到合并查询以及所有相关的各种类型的数据。

使用 Storybook 隔离 UI 变更

我们使用Storybook来编辑 UI,它为我们提供了快速的热模块重新加载功能和一些用于启用或禁用浏览器功能(如 Flexbox)的复选框。

我使用来自 API 的模拟数据来加载 story。如果你的模拟数据可以涵盖 UI 的各种可能状态,那么这么做就对了。除此之外,如果还有其他可能的状态(比如加载或错误状态),可以手动添加它们。

复制代码
import alpsResponse from '../../../src/apps/PdpFramework/containers/__mocks__/alps';
import getSectionsFromJourney from '../../getSectionsFromJourney';
const alpsSections = getSectionsFromJourney(alpsResponse, 'TripDesignerBio');
export default function TripDesignerBioDescriptor({
  'PdpFramework/sections/': { TripDesignerBio },
}) {
  return {
    component: TripDesignerBio,
    variations: alpsSections.map((item, i) => ({
      title: `Alps ${i + 1}`,
      render: () => (
        <div>
          <div style={{ height: 40, backgroundColor: '#484848' }} />
          <TripDesignerBio {...item} />
          <div style={{ height: 40, backgroundColor: '#484848' }} />
        </div>
      ),
    })),
  };
}

这个文件完全由 Yeoman(下面会介绍)生成,默认情况下,它提供了来自 Alps Journey 的示例。getSectionsFromJourney() 过滤了部分 section。

另外,我添加了一对 div,因为 Storybook 会在组件周围渲染空格。对于按钮或带有边框的 UI 来说这没什么问题,但很难准确分辨出组件的开始和结束位置,所以我在这里添加了 div。

把所有这些神奇的工具放在一起,可以帮你提高工作效率。如果结合 Zeplin 或 Figma 使用 Storybook,你的生活变得更加愉快。

自动获取模拟数据

为了在 Storybook 和单元测试中使用逼真的模拟数据,我们直接从共享开发环境中获取模拟数据。与代码生成一样,即使查询片段中的一个小变化也会导致模拟数据发生很多变化。这里最困难的部分完全由 Apollo CLI 负责处理,你只需要将生成的代码与自己的代码拼接在一起即可。

第一步只要简单地运行 apollo client:extract frontend/luxury-guest/apollo-manifest.json,你将得到一个清单文件,其中包含了所有的查询。需要注意的是,这个命令指定了“luxury guest”项目,因为我不想刷新所有团队的所有可能的模拟数据。

我的查询分布在很多 TypeScript 文件中,这个命令将负责组合所有的导入。我不需要在 babel/webpack 的输出基础上运行它。

然后,我们只需要添加一小部分代码:

复制代码
const apolloManifest = require('../../../apollo-manifest.json');
const JOURNEY_IDS = [
  { file: 'barbados', variables: { id: 112358 } },
  { file: 'alps', variables: { id: 271828 } },
  { file: 'london', variables: { id: 314159 } },
];
function getQueryFromManifest(manifest) {
  return manifest.operations.find(item => item.document.includes("JourneyRequest")).document;
}
JOURNEY_IDS.forEach(({ file, variables }) => {
  axios({
    method: 'post',
    url: 'http://niobe.localhost.musta.ch/graphql',
    headers: { 'Content-Type': 'application/json' },
    data: JSON.stringify({
      variables,
      query: getQueryFromManifest(apolloManifest),
    }),
  })
    .catch((err) => {
      throw new Error(err);
    })
    .then(({ data }) => {
      fs.writeFile(
        `frontend/luxury-guest/src/apps/PdpFramework/containers/__mocks__/${file}.json`,
        JSON.stringify(data),
        (err) => {
          if (err) {
            console.error('Error writing mock data file', err);
          } else {
            console.log(`Mock data successfully extracted for ${file}.`);
          }
        },
      );
    });
});

我们目前正与 Apollo 团队合作,准备将这个逻辑提取到 Apollo CLI 中。我期待着将来我们只需要指定示例数组,并将它们和查询放在同一个文件夹中,然后根据需要自动生成模拟数据。想象一下我们只需要像这样指定模拟数据:

复制代码
export default {
  JourneyRequest: [
    { file: 'barbados', variables: { id: 112358 } },
    { file: 'alps', variables: { id: 271828 } },
    { file: 'london', variables: { id: 314159 } },
  ],
};

借助 Happo 将屏幕截图测试纳入代码评审

Happo是我用过的唯一的一个屏幕截图测试工具,所以无法将它与其他工具(如果有的话)进行比较。它基本原理是这样的:你推送代码,它渲染 PR 的组件,将其与 master 上的版本进行比较。

如果你在编辑 < Input/> 之类的组件,它会显示你做的修改影响到了哪些依赖 Input 的组件。

不过,最近我们发现 Happo 唯一的不足是屏幕截图测试过程的输入并不总能充分反映出数据的可靠性。不过因为 Storybook 使用了 API 数据,我们会更加有信心。另外,它是自动化的,如果你向查询和组件中添加了一个字段,Happo 会自动将差异包含到 PR 中,让其他工程师、设计师和产品经理看到变更后的视觉后果。

使用 Yeoman 生成新文件

如果你需要多次搭建脚手架,那么应该先构建一个生成器,它可以帮你完成很多工作。除了 AST 转换(我将在下面介绍),这里是三个模板文件:

复制代码
const COMPONENT_TEMPLATE = 'component.tsx.template';
const STORY_TEMPLATE = 'story.jsx.template';
const TEST_TEMPLATE = 'test.jsx.template';
const SECTION_TYPES = 'frontend/luxury-guest/src/apps/PdpFramework/constants/SectionTypes.js';
const SECTION_MAPPING = 'frontend/luxury-guest/src/components/PdpFramework/Sections.tsx';
const COMPONENT_DIR = 'frontend/luxury-guest/src/components/PdpFramework/sections';
const STORY_DIR = 'frontend/luxury-guest/stories/PdpFramework/sections';
const TEST_DIR = 'frontend/luxury-guest/tests/components/PdpFramework/sections';
module.exports = class ComponentGenerator extends Generator {
  _writeFile(templatePath, destinationPath, params) {
    if (!this.fs.exists(destinationPath)) {
      this.fs.copyTpl(templatePath, destinationPath, params);
    }
  }
  prompting() {
    return this.prompt([
      {
        type: 'input',
        name: 'componentName',
        required: true,
        message:
          'Yo! What is the section component name? (e.g. SuperFlyFullBleed or ThreeImagesWithFries)',
      },
    ]).then(data => {
      this.data = data;
    });
  }
  writing() {
    const { componentName, componentPath } = this.data;
    const componentConst = _.snakeCase(componentName).toUpperCase();
    this._writeFile(
      this.templatePath(COMPONENT_TEMPLATE),
      this.destinationPath(COMPONENT_DIR, `${componentName}.tsx`),
      { componentConst, componentName }
    );
    this._writeFile(
      this.templatePath(STORY_TEMPLATE),
      this.destinationPath(STORY_DIR, `${componentName}VariationProvider.jsx`),
      { componentName, componentPath }
    );
    this._writeFile(
      this.templatePath(TEST_TEMPLATE),
      this.destinationPath(TEST_DIR, `${componentName}.test.jsx`),
      { componentName }
    );
    this._addToSectionTypes();
    this._addToSectionMapping();
  }
};

你可以想象一下,原先需要一个下午才能完成的工作现在只需要 2 到 3 分钟就可以完成。

使用 AST Explorer 了解如何编辑现有文件

Yeoman 生成器最困难的部分是如何编辑现有文件,不过,借助抽象语法树(AST)转换,这个任务变得更加容易。

以下是我们如何实现 Sections.tsx 的转换:

复制代码
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
const t = require('babel-types');
const generate = require('babel-generator').default;
module.exports = class ComponentGenerator extends Generator {
  _updateFile(filePath, transformObject) {
    const source = this.fs.read(filePath);
    const ast = babylon.parse(source, { sourceType: 'module' });
    traverse(ast, transformObject);
    const { code } = generate(ast, {}, source);
    this.fs.write(this.destinationPath(filePath), prettier.format(code, PRETTER_CONFIG));
  }
  
  _addToSectionMapping() {
    const { componentName } = this.data;
    const newKey = `[SECTION_TYPES.${_.snakeCase(componentName).toUpperCase()}]`;
    this._updateFile(SECTION_MAPPING, {
      Program({ node} ) {
        const newImport = t.importDeclaration(
          [t.importDefaultSpecifier(t.identifier(componentName))],
          t.stringLiteral(`./sections/${componentName}`)
        );
        node.body.splice(6,0,newImport);        
      },
      ObjectExpression({ node }) {
  // ignore the tagged template literal
        if(node.properties.length > 1){
          node.properties.push(t.objectTypeProperty(
            t.identifier(newKey),
            t.identifier(componentName)
          ));
        }
      }, 
      TaggedTemplateExpression({node}) {
        const newMemberExpression = t.memberExpression(
          t.memberExpression(
            t.identifier(componentName),
            t.identifier('fragments')
    ), t.identifier('fields')
        );
        node.quasi.expressions.splice(2,0,newMemberExpression);
const newFragmentLine = `        ...${componentName}Fields`;
        const fragmentQuasi = node.quasi.quasis[0];
        const fragmentValue = fragmentQuasi.value.raw.split('\n');
        fragmentValue.splice(3,0,newFragmentLine);
        const newFragmentValue = fragmentValue.join('\n');
        fragmentQuasi.value = {raw: newFragmentValue, cooked: newFragmentValue};
        
        const newLinesQuasi = node.quasi.quasis[3];
        node.quasi.quasis.splice(3,0,newLinesQuasi);
      }
    });
  }
};

_updateFile 是使用 Babel 进行 AST 转换的样板代码。这里最关键的是 _addToSectionMapping,并且你可以看到:

  • 它在程序层面插入了一个新的导入声明。
  • 在两个对象表达式中,具有多个属性的那个是 section 映射,我们将在那里插入一个键值对。
  • gql 片段是标记模板字面量,我们想在那里插入 2 行,第一行是成员表达式,第二行是“quasi”表达式中的一个。

如果执行转换的代码看起来令人生畏,我只能说,这对我来说也是如此。在写这些转换代码之前,我也还没用过 quasi。

好在 AST Explorer 可以很容易地解决这类问题。这是同一个转换在 Explorer 中的显示。在四个窗格中,左上角包含源文件,右上角包含已解析的树,左下角包含建议的变换,右下角包含变换后的结果。

通过查看解析后的树,你就知道如何应用转换和测试它们了。

从 Zeplin 或 Figma 中提取模拟内容

Zeplin 和 Figma 的出现都是为了让工程师能够直接提取内容来提升产品开发效率。

如上所示,要提取整个段落的副本,只要在 Zeplin 中选择内容,并单击侧栏中的“复制”图标。

在 Zeplin 中,可以先选择图像,并单击侧栏“Assets”里的“下载”图标来提取图像。

自动化照片处理

照片处理管道肯定是 Airbnb 特有的。我想要强调的是 Brie 创建的用来包装现有 API 端点的“Media Squirrel”。如果没有 Media Squirrel,我们就没有这么好的方法可以将我们机器上的原始图像转换为 JSON 对象,更不用说可以使用静态 URL 作为图像的源。

在 Apollo Server 中拦截 schema 和数据

这部分工作仍在进行中,还不能作为最终的 API。我们想要做的是拦截和修改远程 schema 和远程响应。因为虽然远程服务是事实的来源,但我们希望能够在规范化上游服务 schema 变更之前对产品进行迭代。

因为 Apollo 近期路线图中包含了 Schema Composition 和 Distributed Execution,所以我们没有详细地解释所有细节,只是提出了基本概念。

实际上,Schema Composition 允许我们定义类型,并像下面这样执行某些操作:

复制代码
type SingleMedia {
  captions: [String]
  media: [LuxuryMedia]
  fullBleed: Boolean
}
  
extend type EditorialContent {
  SingleMedia
}

在这种情况下,schema 知道 EditorialContent 是一个联合,因此通过扩展它,它真的可以知道另一种可能的类型。

将 Berzerker 响应代码修改如下:

复制代码
import { alpsPool, alpsChopper, alpsDessert, alpsCloser } from './data/sections/SingleMediaMock';
{1}
const mocks: { [key: string]: (o: any) => any } = {
  Journey: (journey: any) => ({
    ...journey,
    editorialContent: [
      ...journey.editorialContent.slice(0, 3),
      alpsPool,
      ...journey.editorialContent.slice(3, 9),
      alpsChopper,
      ...journey.editorialContent.slice(9, 10),
      alpsDessert,
      ...journey.editorialContent.slice(10, 12),
      alpsCloser,
      ...journey.editorialContent.slice(12, 13),
    ],
  }),
};
export default mocks;

这里并没有使用 mock 填补 API 的空白,而是让它们保持原样,并根据你提供的东西对内容进行覆盖。

结论

Apollo CLI 负责处理所有与 Apollo 相关的事情,让你能够以更有意义的方式连接这些实用程序。其中一些用例(如类型的代码生成)是通用的,并且最终成为整个基础设施的一部分。

更多内容,请关注前端之巅(ID:frontshow)

英文原文:https://medium.com/airbnb-engineering/how-airbnb-is-moving-10x-faster-at-scale-with-graphql-and-apollo-aa4ec92d69e2