メルカリ Shops の開発を支える Automation 化

こんにちは。ソウゾウで Software Engineer をしている @sottar です。連載:メルカリShops 開発の裏側 Vol.2の 5 日目を担当させていただきます。

この記事ではメルカリShops における、開発をサポートするための Automation 化について紹介します。

メルカリShops での開発手法

先日のプロダクト組織に関する記事でも言及がありましたが、ソウゾウでは全員ソフトウェアエンジニアを標榜しており、これまで経験がない分野でも積極的にコードを書いていく文化があります。
組織の人数も当初に比べ増えてきており、より様々なバックグラウンドを持ったエンジニアが参画しています。

エンジニアが自らの経験が少ない分野のコードを書くと、その言語ならではの書き方がわかっていなかったり、意図していない変更が起きてしまったりすることが起こりやすくなります。基本的にはコードレビューで防いではいますが、全員ソフトウェアエンジニアという文化をよりサポートするためにさまざまな自動化を導入しています。

この記事ではその中でもとくに frontend, BFF に関係する以下の Automation 化を紹介します。(メルカリShops で採用している技術スタックはこの記事に詳しいです)

  • module graph
  • GraphQL breaking changes
  • Circular-dependency
  • Bundle size
  • Test coverage

なお、文中に出てくるコードはサンプルで実際にこのまま動くものではありません。

module graph

module graphを使い各PRごとに影響のある一覧を表示する仕組みを作っています。
実装した箇所が意図しない箇所に影響が出ていないかの確認や、QAの際に確認するページを把握する助けになっています。

処理の流れを簡単に説明していきます。

1. 差分のあるファイルを抽出

GitHub Actions 上で changed-files アクションを使用して変更のあったファイル一覧を出力します

jobs:
  analytics:
...
      - name: Module Graph - Get changed files
        id: changed-files
        if: github.event.number
        uses: tj-actions/changed-files@v12.2
        with:
          files: |
            path/\to/\src
      - name: Module Graph - List all modified files
        if: github.event.number
        run: |
          echo ${{ steps.changed-files.outputs.all_modified_files }}

2. アプリケーション全体の module の依存構造を取得

GitHub Actions では 1 で取得したファイル一覧を引数に node ファイルを実行します

jobs:
  analytics:
...
      - name: Module Graph - Generate Module Graph
        if: github.event.number
        run: node scripts/rootDependencies.js ${{ steps.changed-files.outputs.all_modified_files }}
...

上の GitHub Actions から実行している rootDependencies.js ではまずコード全体の依存関係を取得し、moduleDepsMap 変数に入れます。

const moduleDepsMap = Object.fromEntries(
  cruiseResult.output.modules
    .map(({ source, dependencies }) => {
      if (source.includes('node_modules') || source.match(/.spec/)) {
        return false;
      }

      const deps = dependencies.filter(({ dependencyTypes }) => {
        return !(
          dependencyTypes.includes('npm') || dependencyTypes.includes('npm-no-pkg')
        );
      });

      const value = deps.map(({ module, ...rest }) => {
        const absPath = resolve(omitExt(join(dirname(source), module)));
        return { ...rest, module: absPath };
      });

      return [source, value];
    })
    .filter(Boolean)
);

node_modules 配下にあるものや test ファイルなどを除外、dependencyTypesnpmnpm-no-pkg なものが含まれているものを除外します

3. 依存しているファイルを取得

GitHub Actions から引数で受け取った変更があったファイル一覧をループし、2で取得した依存関係をもとに、変更した module が依存しているファイルの一覧を取得していきます。
ここでは walk という関数を再帰的に実行して取得しています。

const [, , ...targetedFiles] = process.argv;

targetedFiles.forEach((filePath) => {
  const res = [];
  walk(filePath, [], res);
})

function walk(path, visitedNodes, res) {
  const deps = Object.entries(moduleDepsMap).filter(([, d]) => {
    return d.find(({ module }) => module === path);
  });

  if (deps.length !== 0) {
    for (const [key] of deps) {
      visitedNodes.push(path);
      walk(key, visitedNodes, res);
    }
  } else if (!res.includes(path)) {
    res.push(path);
  }
};

4. markdown に書き出す

Import している module によって import 文が ~/ で始まっていたり ./ で始まっているものがあるのでその差異を吸収し、一覧を markdown に出力します。

targetedFiles.forEach((filePath) => {
  const res = [];
  walk(filePath, [], res);  

  res.forEach((path) => {
    // path の format 処理
  });

  const output = `## Module Graph
    // markdown を作成
  `
  writeFileSync(outfile, output);
});

最後にここで出力した markdown を GitHub の PR にコメントするようにしています。

Circular dependency

module が循環参照されていると修正を行いにくく、潜在的にバグを生み生み出しやすい構造になってしまいます。
それを防ぐために機械的に循環参照されているかを検証し、されている場合は test を fail するようにしています。

npm script で dependency-cruiser を使用し、GitHub Actions を使用して PR ごとに test を行っています。

GraphQL breaking changes

GrapqhQL の変更を検知して、schema の validation を行っています。
意図しない破壊的変更が行われていたり、対応漏れの箇所がないかどうかを確認するのに役立っています。

graphql-inspector を使用し、graphQL のコードに差分がある場合に、git の main branch にある schema とコミットされた schema の差分を確認しています。

npm script にコマンドを追加し GitHub Actions で実行しています。

{
  "scripts": {
        "test:schema": "graphql-inspector diff 'git:origin/main:./schema/schema.gql' './schema/schema.gql'",
  }
...
}

このように変更があった schema を検知し Breaking / Dangerous / Safe な変更をそれぞれ出してくれます。
breaking change が一つでもあった場合には test が fail するようにしています。

Bundle Size

コミット前後で 50B 以上差分があったページの一覧を markdown に書き出して GitHub の PR にコメントするようにしています。

メルカリShops では Next.js を使用しているため、 build 時に生成される build-manifest.json を用いてページごとに出力されたファイルを取得することが出来ます。
取得したファイルそれぞれに対して gzip を行い gzip後のファイルサイズを取得しページごとに計算しています。

build-manifest.json から、next.js で生成されたファイルを取得し gzip 後のファイルサイズを合計しています。

const bundle = require('../.next/build-manifest.json');

const pageSizes = Object.entries(bundle.pages).map(([key, value]) => {
  const size = value
    .map((filename) => {
      const bytes = readFileSync(join(process.cwd(), prefix, filename));
      const gzipped = gzipSync(bytes);

      return gzipped.byteLength;
    })
    .reduce((s, b) => s + b, 0);

  return { path: key, size };
});

コミット前後でのそれぞれのファイルサイズの差分を計算し、最終的にこのような形で GitHub の PR 上にコメントしています。
SEOに重要なページと 50B 以上の差分があったページの一覧が表示されています。

Test coverage

メルカリShops では Jest を用いてテストコードを書いています。
Jest の coverageThreshold option を使用し最低限のカバレッジを担保するようにしています。

ソウゾウにはQAエンジニアも在籍しており、立ち上げ当初はテストの部分を QA を行うことでカバーしていました。そのため、当初はあまりテストを書いていなかったのですが、ここ 2, 3ヶ月でテストを書くようにしており、現状のカバレッジ率から下げないような coverage threshold を設定しています。

Jest はテスト実行時に --coverage option をつけて実行することで /coverage 配下に coverage report を出力してくれます。
その出力された coverage report を GutHub Actions の cron で定期的に実行し、GCS に upload しています。

Cypress を用いて E2E のテストも行っているのですが、今後の課題として E2E のカバレッジと Unit Test のカバレッジをマージして出力し、よりテストがカバーしている箇所を明確にしていきたいです。

まとめ

メルカリShops ではこれらの Automation 化を用いて経験が浅い領域でも安心して取り組むことができる環境を作ることをサポートしています。
ちなみに、執筆にあたって GitHub Actions を管理する .github/workflowsディレクトリ配下のファイル数を数えてみたところには 53 ファイルありました。他にどのような仕組みが導入されているのか気になる方はぜひカジュアル面談などでお話しましょう!

ソウゾウではメンバーを大募集中です。ソウゾウに興味を持った方がいればぜひご応募お待ちしています。詳しくは以下のページをご覧ください。

またカジュアルに話だけ聞いてみたい、といった方も大歓迎です。こちらの申し込みフォームよりぜひご連絡ください!

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加