民宿Airbnb爱彼迎是如何使用GraphQL和Apollo快速扩大规模10倍?

19-01-13 banq
                   

全球民宿Airbnb投入了数年的工程师时间来建立一个几乎无可挑剔的支持GraphQL的基础设施。

GraphQL+后端驱动UI

假定我们已经构建了一个系统,其中基于查询构建非常动态的页面,该查询将返回一些可能的“sections”的数组。这些sections部分是响应式的并且完全定义了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;

由于可能的section列表非常大,我们有一个理智的机制,用于延迟加载组件和服务器渲染,这是另一个帖子的主题。可以这么说,我们不需要将大量捆绑中的所有可能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的Backend-Driven UI的一般概念。它被用于许多地方,包括搜索,旅行计划,主机工具和各种登陆页面。我们以此为出发点,然后在展示如何(1)制作和更新现有部分,以及(2)添加新section。

GraphQL Playground

在构建产品时,您希望能够探索模式,发现字段名称并测试对实时开发数据的潜在查询。我们今天通过GraphQL Playground实现了这一目标,这是Prisma的朋友们的工作。这些工具是Apollo Server的标准配置。

在我们的例子中,后端服务主要用Java编写,他们的模式由我们称为Niobe的Apollo Server拼接在一起。目前,由于Apollo Gateway和Schema Composition还没有上线,我们所有的后端服务都是按服务名称划分的。这是Playground提供的一系列服务名称。

服务名称树中的下一级是服务方法。比如这里案例是getJourney()。由Apollo团队揭示的新概念Schema Composition应该帮助我们构建一个更理智的架构模型。未来几个月会有更多相关信息。

使用Apollo插件在VS代码中查看Schema

我们有很多有用的工具。这包括访问VS Code中的Git,以及用于运行常用命令的集成终端和任务。其中是新的Apollo GraphQL VS代码扩展

详细说明一个功能:模式标签:如果你希望根据schema提示一些查询,比如决定使用哪个Schema?默认是当前production的schema,如果你需要迭代探索新的想法,可以使用provisional schema实现灵活性。

由于我们使用的是Apollo Engine,因此使用标签发布多个模式可以实现这种灵活性,并且多个工程师可以在单个提议的模式上进行协作。一旦提议的服务模式更改在上游合并,并且这些更改在当前生产模式中自然流下,我们可以在VS Code中翻转回“当前”。很酷。

自动生成类型

Codegen的目标是从强大的类型安全性中受益,而无需手动创建TypeScript类型或React PropTypes。这很关键,因为我们的查询fragments分布在使用它们的组件中。这就是为什么对查询片段fragments 进行1行更改会导致6-7个文件被更新; 因为同一个片段fragments 出现在查询层次结构的许多位置 - 与组件层次结构并行。

这部分只不过是Apollo CLI的功能。我们正在研究一个特别花哨的文件监视器(显然名为“ Sauron ”),但是现在apollo client:codegen --target=typescript --watch --queries=frontend/luxury-guest/**/*.{ts,tsx}根据需要运行完全没有问题。能够在rebase期间关闭codegen是很好的,我通常会将我的范围过滤到我正在处理的项目。

我最喜欢的部分是,由于我们将片段与我们的组件共同定位,因此更改单个文件会导致查询中的许多文件在我们向上移动组件层次结构时进行更新。这意味着在路径组件附近的树中更高的位置,我们可以看到合并查询以及它可以通过的所有各种类型的数据。

根本没有魔法。只是Apollo CLI。

使用Storybook隔离UI更改

我们用于编辑UI的工具是Storybook。它是确保您的工作与断点处像素的设计保持一致的理想场所。您可以获得快速热模块重新加载和一些复选框以启用/禁用Flexbox等浏览器功能。

我应用于Storybook的唯一技巧是使用我们模拟mock数据加载故事,mock数据是从API中提取的。如果您的模拟数据真的涵盖了UI的各种可能状态,那么就ok了。除此之外,如果您有其他想要考虑的状态,可能是加载或错误状态,您可以手动添加它们。

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>
      ),
    })),
  };
}

这是Storybook的关键问题。此文件完全由Yeoman(下面讨论)生成,默认情况下它提供了来自Alps Journey的示例。getSectionsFromJourney()只过滤部分。

另外一个黑客j技术:你会注意到我添加了一对div来垂直装入我的组件,因为Storybook在组件周围呈现空白。对于带有边框的按钮或UI来说这很好,但很难准确分辨出组件的开始和结束位置,所以我在那里将它们强行破解了。

既然我们正在谈论所有这些神奇的工具如何能够很好地协同工作以帮助您提高工作效率,我可以说,使用Storybook与Zeplin或Figma并行工作的UI是多么令人愉快。以这种抽象的方式深入挖掘UI会让这个疯狂世界的所有混乱一次远离一个断点,而在那个安静的领域,你每次都很好地处理像素。

自动检索模拟数据

要使用逼真的模拟数据提供Storybook和我们的单元测试,我们希望直接从共享开发环境中提取模拟数据。与codegen一样,即使查询section中的一个小变化也应该触发模拟数据中的许多小变化。在这里,类似地,困难部分完全由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是一个直接的life-saver。它是我用过的唯一的屏幕截图测试工具,所以我不够精明,无法将其与替代品进行比较,如果有的话,但是基本的想法是你推送代码,然后它会关闭并呈现所有组件在PR中,将其与master上的版本进行比较。

这意味着如果您编辑组件<Input />,它将显示对使用输入的组件的影响,包括您意外修改的搜索栏。

您认为您的更改被包含多少次才发现其他十个团队开始使用您构建的内容,并且您的更改中断了十个中的三个?没有Happo,你可能不知道。

直到最近,Happo唯一的缺点是我们的Storybook 变体(截图测试过程的输入)并不总能充分反映可靠的数据。既然Storybook正在利用API数据,我们就会感到更加自信。另外,它是自动的。如果您添加字段到查询中,然后向组件添加字段,Happo会自动将差异发布到您的PR,让坐在您旁边的工程师,设计师和产品经理看到您所做的更改的视觉后果。

使用Yeoman生成新文件

如果你需要多次搭建一堆文件,你应该构建一个生成器。它会把你变成你的军队。比如在2-3分钟内创建类似下面的内容:

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();
  }
};

Yeoman产生器不需要等待基础设施团队或大规模的多季度项目参与协作。

使用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);        
      },
      Object {
     // ignore the tagged template literal
        if(node.properties.length > 1){
          node.properties.push(t.objectTypeProperty(
            t.identifier(newKey),
            t.identifier(componentName)
          ));
        }
      }, 
      TaggedTemplate {
        const newMemberExpression = t.member,
            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,你看到:

  • 在Program 层面,它会插入一个新的 Import Declaration。
  • 在两个对象表达式中,具有多个属性的对象表达式是我们的Section映射,在那里插入键/值对。
  • Tagged标签模板文字是我们的gql片段,我们想在那里插入2行,第一行是成员表达式,第二行是一组“quasi”表达式中的一行。

如果执行转换的代码看起来令人生畏,我只能说对我来说也是如此。在写这个转变之前,我没有遇到过quasis,可以说我发现它们是quasi-confusing准混淆的(#DadJokes)。

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

查看解析后的树会立即告诉您用Babel术语编写的代码结构(您知道这是一个Tagged Template Literal,对吧?),这样就可以了解如何应用转换和测试他们。

AST转换在Codemods中也起着至关重要的作用。看看我朋友Joe Lencioni关于此事的这篇文章

从Zeplin或Figma中提取模拟内容

Zeplin和Figma都是为了让工程师直接提取内容以促进产品开发而构建的。

自动化照片处理......通过构建Media Squirrel

照片处理管道肯定是Airbnb特有的。我要强调的部分实际上是Brie在创建“Media Squirrel”以包装现有API端点方面的贡献。没有Media Squirrel,我们没有很好的方法将我们机器上的原始图像转换为包含来自图像处理管道的内容的JSON对象,更不用说有我们可以用作图像源的静态URL。

Media Squirrel的内容是,当你需要许多人需要的日常事务时,不要犹豫,建立一个有用的工具,每个人都可以使用前进。这是Airbnb zany文化的一部分,这是我非常重视的习惯。

截取Apollo Server中的模式和数据

关于最终的API,这部分仍在进行中。我们想要做的关键事情是(a)拦截远程模式并对其进行修改,以及(b)拦截远程响应并对其进行修改。原因是虽然远程服务是事实的来源,但我们希望能够在正式化上游服务中的模式更改之前对产品进行迭代。

借助Apollo近期路线图中的Schema Composition 和分布式执行,我们不想猜测一切都会如何精确地工作,所以我们只提出了基本概念。

Schema Composition应该让我们能够定义类型并按照以下方式执行某些操作:

type SingleMedia {
  captions: [String]
  media: [LuxuryMedia]
  fullBleed: Boolean
}
  
extend type EditorialContent {
  SingleMedia
}

注意:在这种情况下,模式知道EditorialContent是一个联合union,因此通过扩展它,我们真的要求它知道另一种可能的类型。

修改Berzerker响应的代码如下所示:

import { alpsPool, alpsChopper, alpsDessert, alpsCloser } from './data/sections/SingleMediaMock';

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;

这个想法是在Apollo Server Mock API中找到的。在这里,使用你的API中替代mock, 根据API提供的内容主动覆盖现有的内容。这更像是一种模拟我们想要的那种API。

结论

比任何一个技巧更重要的是更快地更异常地移动和自动化尽可能多,特别是在样板,类型和文件创建方面。

Apollo CLI负责处理所有特定于Apollo的域,从而使您能够以对您的用例有意义的方式连接这些实用程序。

其中一些用例(如类型的codegen)是通用的,并且最终成为整个基础架构的一部分。但是它们中的许多都和你用它们构建的组件一样是一次性的。而且他们都没有让产品工程师等待基础架构工程师为他们构建一些东西!