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.js
( AppRegistry.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.watchFolders
と extraNodeModules
の設定がポイント。
参考
- https://github.com/facebook/metro/issues/1#issuecomment-527863738
- https://github.com/facebook/metro/issues/7#issuecomment-356616505
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 の書き換えなど