Yarn workspaces から Lerna に移行した


Yarn workspaces から Lerna に移行した時の知見です。
やや書きかけ項目です。

前提

とあるサービスを提供しており、モノレポで運用している。使用スタックは主に API の Ruby on Rails で GraphQL の API サーバーを構築し、そのフロントエンドを React/ReactNative で作成している。

もともとは別々のリポジトリだったが、提供サービスが増えてくるに従い、下記の問題点が出てきた。

  • CI/CD パイプラインを一々作成する必要があり、反映にも手間がかかる
  • GraphQL の型定義やユーティリティ、細かいところでは Git の Hook など、再利用したいものがある
  • GitHub への招待などが手間になる

そこで、この記事を書く半年前くらいにリポジトリを統合し、モノレポになった。

Rails と それ以外 (TypeScript) でフォルダを分割し、 TypeScript 側は手軽にモノレポをマネジメントできそうな Yarn workspaces を選択した。その時はこれで苦しむことを知らない。

当初のディレクトリ構成は、下記。

.
|-- api/                    # Rails API
|-- docker-compose.yml
|-- package.json            # 開発用のパッケージ (prettier など)
|-- packages/               # TypeScript clients
|    |-- app                # アプリ (React Native)
|    |    |-- src/
|    |    `-- package.json
|    |-- console            # 管理画面 (React Native Web)
|    `-- components         # 共有コンポーネント
|-- prettier.config.js
|-- private/                 # AWS のトークンなど、秘匿ファイル。git-crypt で暗号化
|-- tsconfig.json           # 使いまわしている
|-- tslint.json
`-- yarn.lock

まとめると、

  • サーバーサイドは主に Ruby on Rails
  • iOS/Android アプリ 及びそれに付随する Web サービス(管理画面など)を提供している
    • iOS/Android アプリは React Native を使用
    • Web サービスには React Native Web を使用
    • React Native で作成されたコンポーネントを、一部 Web でも使用している

が、上記の構成では限界を感じたため、 Lerna に移行することにした。

Lerna 移行の動機

React Native で Yarn workspaces は、 Yarn の hoist (巻き上げ)機能により、非常に使いにくいことが分かってきた。

致命的なのは、 react-native-cli は、 React Native における index.jsAppRegistry.registerComponent する場所) の 相対パス./node_modules/react-native/cli.js を見に行くため、巻き上げた先のパスを設定する必要がある。

巻き上げられると、 Root の node_modules を見に行くため、ビルド時に ../../node_modules/react-native/cli.js に設定する必要がある。つまり、 Xcode をいじる必要が出てくる。

また、 Root から実行する際は下記のスクリプトが必要になってくる。

// @see https://github.com/facebook/react-native/issues/25822#issuecomment-531009417

process.chdir('./packages/nupp1-fit')

var cli = require('@react-native-community/cli')
cli.run()

ここまでは、まだ設定すれば問題無いが、 場合によって 巻き上げられないパッケージがあるので、他のパッケージを更新した際に、 React Native がビルドされるかを確認する必要がある。

特に React Native 0.60.0 からの autolink 機能は、 Cocoapod でインストールした時に、暗黙的に ./node_modules 以下を探索するので、../../node_modules に巻き上げられると使えなくなってしまう。

では Yarn の nohoist 機能を使えばよいではないか、という意見がありそうだが、それもうまくいくとは限らない。調べた限りでは、下記のような動作をする。

  • nohoist 機能は、動作しないことがある。
  • 何が巻き上げられるかが不明瞭。多分ロジックを追えば分かるが、各ライブラリの依存関係を調べる必要があり、果てしない。
  • 同一のパッケージ/バージョンがあると巻き上げられるらしい。
    • バージョンが異なる場合、各パッケージの node_modules に保存される。
  • キャッシュが効かないことがある → CIの低速化
  • Symbolic link (.bin/**) が壊れることがある。 (semver など)
    • 多分、多数のパッケージが依存しているパッケージで、バージョンによって bin が違う場合、発生する。
  • 各パッケージでインストールした後、 Root でインストールすると、依存関係を変更してないのに、再度インストールが走る。

この問題を調査していたところ、 Lerna はデフォルトで巻き上げをしない(正確にはするが、 Root の package.json にある場合のみ)ので、これを使って解決できそうな気がした。

実作業・困ったこと

基本的には、こちらのリポジトリを参考にしながら、作業を進めていった。

コマンドが複雑化する

Lerna を入れて、ローカルのパッケージ同士を package.json に記述して依存させると、 yarn add 等は 一切できなくなる

また cross-env や webpack-dev-server など、開発時のみ必要になる一部の devDependencies は Root の package.json のみに記述していたので(これは要改善)、各パッケージ配下では実行できない。

毎回 yarn lerna exec --ignore @my/types --scope @my/package tsc などするのも面倒だったので、 Makefile を作成し、その中に npm scripts に相当するスクリプトを記述することにした。

抜粋するとこんな感じ。

SHELL := /bin/bash
LERNA_OPTION := --stream --parallel --no-bail --ignore @my/types --ignore @my/js-project
NODE_BIN := ./node_modules/.bin

export PATH := $(NODE_BIN):$(PATH)

tsc:                                           # Execute `tsc` in each scripts
    lerna exec $(LERNA_OPTION) tsc

参考: https://qiita.com/Hoishin/items/0e9b4ebee45e3f8cdc29

React Native CLI

React Natvie CLI に Lerna が生成する Symbolic Link を追わせるためには、 metro.config.js を少々いじる必要がある。
React Native 0.62.0-RC3 で利用するときは、下記のような設定が必要になる。

resolver.watchFoldersextraNodeModules の設定がポイント。

参考

const path = require('path')
const { getDefaultConfig } = require('metro-config')

function getProjectModuleDir(m) {
  return path.resolve(__dirname, `node_modules/${m}`)
}

// To allow importing peerDependency from other packages
const modulesResolvedInProject = [
  '@babel/runtime',
  '@react-native-firebase/app',
  '@react-native-firebase/analytics',
  '@react-native-community/push-notification-ios',
  '@react-native-community/async-storage',
  'react',
  'react-is',
  'react-native',
  'react-native-appsflyer',
  'react-native-device-info',
  'react-native-picker',
  'react-native-keyboard-aware-scroll-view',
  'react-native-google-places-autocomplete',
  'react-native-keyboard-spacer',
  'react-native-linear-gradient',
  'react-native-paper',
  'react-native-svg',
  'react-native-vector-icons',
  'react-redux',
  'react-native-repro',
]

const extraNodeModules = modulesResolvedInProject.reduce((acc, m) => {
  return { ...acc, [m]: path.resolve(__dirname, `node_modules/${m}`) }
}, {})

module.exports = (async () => {
  const {
    resolver: { sourceExts, assetExts },
  } = await getDefaultConfig()
  return {
    transformer: {
      babelTransformerPath: require.resolve('react-native-svg-transformer'),
    },
    watchFolders: [
      // To allow finding files outside mobile
      path.resolve(__dirname, '..'),
    ],
    resolver: {
      assetExts: assetExts.filter(ext => ext !== 'svg'),
      sourceExts: [...sourceExts, 'svg'],
      extraNodeModules,
    },
    maxWorkers: 2,
  }
})()

総評

良かったこと

  • きちんと依存関係を各リポジトリの package.json に記述しないと、 Symlink が設定されない。
    • Yarn workspaces は何となくでも簡単に動いてしまったので、これは逆に良かった。
  • Lerna bootstrap の方が yarn install より速い(体感)
  • 今後、 Docker Image の作成をする時に、苦労しなそう
    • 依存関係は、各パッケージでちゃんとインストールしないといけないため
  • 各パッケージ以下でコマンドを並列実行できるようになった
    • それ以前は、 wsrun というものを使っていた

手間取ったこと

  • セットアップ手順が複雑化。 yarn install && yarn lerna bootstrap && yarn postinstall
    • 上記の Makefile で記述したため、解決
  • CI の書き換えなど