開發速度快10倍!Airbnb用GraphQL+Apollo做到了

在上個月舉行的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';

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章