民宿Airbnb愛彼迎是如何使用GraphQL和Apollo快速擴大規模10倍?

banq發表於2019-01-13

全球民宿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)是通用的,並且最終成為整個基礎架構的一部分。但是它們中的許多都和你用它們構建的元件一樣是一次性的。而且他們都沒有讓產品工程師等待基礎架構工程師為他們構建一些東西!



 

相關文章