anyenv + ndenv を試す

2018年11月9日 0 開発 , ,

Node 11 へ更新したら mapbox/node-sqlite3 が動作しなくなった。#1063 によれば既に修正の準備はできているものの CI サービス AppVeyor が Node 11 対応していないので待ちとなっているらしい。

これまでも node-sqlite3 のように node-gyp を利用した npm で Node バージョン更新による問題に遭遇してきたが、概ね短期間で対応された。そのため Homebrew で Node を最新バージョンにしていたのだけど、今回は 2 週間を経ても未解決である。ちょうど直近で node-sqlite3 を業務利用する機会があるため、これは困る。

また自身も npm 開発することもあって、いつかは *env 系ツールで複数 Node バージョンを検証できなくてはなぁと思っていた。Current 以外の動作テストは Travis CI に丸投げしていたが、特定 Node バージョンで問題が起きた時は環境を切り替えて検証しなければならない。というわけで今回の問題を契機に anyenv + ndenv を試すことにした。

anyenv をインストールする

riywo/anyenv の手順に従い anyenv をインストール。まずは git clone。HOME 直下に .anyenv という名前で git clone する。

$ git clone https://github.com/riywo/anyenv ~/.anyenv

次に anyenv へパスを通す。手順だと echo でプロファイル末尾に出力しているが、既存の設定を確認してからにしたいので .bash_profile を vi で開く。

$ cd
$ vi .bash_profile

内容を確認して問題なければ好みの位置 (export をまとめている箇所など) や末尾に以下を追記して保存する。

export PATH="$HOME/.anyenv/bin:$PATH"
eval "$(anyenv init -)"

その後、有効化。手順では exec $SHELL -l になっている。今回は .bash_profile を自身で編集したから source コマンドで読み直した。これは単に手癖の話。

$ source .bash_profile

anyenv のインストール手順は Linux や macOS を想定しているようで Windows はどうするのだろう?と思ったが、現在は WSL があるからそちらでどうぞ、ということなのかもしれない。Windows 版のインストール手順をググって見つけた WSLでWindowsにLinux開発環境を構築する – Qiita では実際そうしている。

インストールに成功したことを確認するため、anyenv の提供する *env を表示。

$ anyenv install -l
Available **envs:
  Renv
  crenv
  denv
  erlenv
  exenv
  goenv
  hsenv
  jenv
  luaenv
  ndenv
  nenv
  nodenv
  phpenv
  plenv
  pyenv
  rbenv
  sbtenv
  scalaenv
  swiftenv

無事、成功したようだ。こうして見ると対応環境が多い。今回の対象となる Node 系だけでも ndenv、nenv、nodenv の 3 種類ある。これらを個別にインストールしたり設定するのは非常に面倒なので実にありがたい。

anyenv-update をインストールする

anyenv 管理下の *env を更新するために便利なプラグイン znz/anyenv-update をインストールする。これは anyenv の README からリンクされているので公認と考えてよいだろう。

まずはインストール先となるディレクトリーを作成。anyenv の手順でパスを通してあるから、その配下に入れる。作成後、実際にディレクトリーが存在することを確認。

$ mkdir -p $(anyenv root)/plugins
$ ls $(anyenv root)
README.md   bin         completions envs        libexec     plugins     share

anyenv-update をインストールして存在を確認。

$ git clone https://github.com/znz/anyenv-update.git $(anyenv root)/plugins/anyenv-update
$ ls $(anyenv root)/plugins
anyenv-update

これを入れると

$ anyenv update

により *env をまとめて更新できる。

Homebrew 管理下 Node を削除する

まずはグローバルな npm を確認。必要なら ndenv に移行した後で入れ直すため、名前を控えておくこと。

$ npm ls -g --depth=0
/usr/local/lib
├── asar@0.14.3
├── eslint@5.3.0
├── eslint-config-standard@11.0.0
├── eslint-config-standard-react@6.0.0
├── eslint-plugin-import@2.13.0
├── eslint-plugin-node@7.0.1
├── eslint-plugin-promise@3.8.0
├── eslint-plugin-react@7.10.0
├── eslint-plugin-standard@3.1.0
├── npm@6.4.1
└── npm-check-updates@2.14.2

次にこれらをまとめてアンインストール。その後に npm が残っていないことを確認。

$ npm uninstall npm -g
removed 387 packages in 6.033s
$ npm ls -g --depth=0
-bash: /usr/local/bin/npm: No such file or directory

Node 本体をアンインストール。その後にバージョン表示コマンドを実行して失敗 = Node 消滅を確認。

$ brew uninstall node
Uninstalling /usr/local/Cellar/node/11.1.0... (3,936 files, 47.0MB)
$ node -v
-bash: node: command not found

Homebrew 環境が壊れていないことを確認。

$ brew doctor
Please note that these warnings are just used to help the Homebrew maintainers
with debugging if you file an issue. If everything you use Homebrew for is
working fine: please don't worry or file an issue; just ignore this. Thanks!

Warning: Broken symlinks were found. Remove them with `brew prune`:
  /usr/local/share/man/man5/package-lock.json.5
  /usr/local/share/man/man7/removing-npm.7
  /usr/local/share/man/man7/semver.7

依存関係の警告が出ているので brew prune を実行、回復したことを確認。

$ brew prune
Pruned 3 symbolic links and 10 directories from /usr/local
$ brew doctor
Your system is ready to brew.

これで Homebrew 管理下 Node を削除できた。もし which node して残骸があるようなら rm コマンドなどを実行して消す。私の環境では検出されなかったから、これで終わり。

ndenv をインストールする

anyenv 経由で ndenv をインストールする。実行してみて気づいたのだが anyenv install XXXX だけでは ndenv がコマンドとして認識されず exec $SHELL -l によるシェル再起動が必要だった。

インストール後、バージョン表示して存在を確認。

$ anyenv install ndenv
$ exec $SHELL -l
$ ndenv -v
ndenv 0.4.0-4-ga339097

ndenv の提供する Node バージョンを確認。Node だけでなく io.js も管理されている。

$ ndenv install -l
Available versions:
...前略
v10.12.0
v10.13.0
v11.0.0
v11.1.0
iojs-v1.0.0
後略...

Node バージョン管理

ndenv を用意したので

  • グローバルを最新となる Node 11
  • node-sqlite3 を必要とするプロジェクトでは Node 10

という環境を構築してみる。まずは各バージョンの最新版を入れる。インストール後には ndenv rehash を実行する必要あり。

$ ndenv install v11.1.0
$ ndenv install v10.13.0
$ ndenv rehash

Node 11 をグローバルに設定してから Node と npm のバージョンを確認。

$ ndenv global v11.1.0
$ node -v
v11.1.0
$ npm -v
6.4.1

成功。ndenv では Node と npm を一緒にインストールしてくれる。

試しに簡単なプログラムを実行したり、既存プロジェクトで npm test を起動するなどしてみたところ正常に動作することを確認できた。

次は特定のプロジェクトだけ Node 10 を有効化してみる。対象となる Node プロジェクトに移動してから以下を実行。その後にバージョン確認してみる。

$ ndenv local v10.13.0
$ node -v
v10.13.0

おお、無事に Node 10 が適用されている。すごい。

ndenv help local の解説を読むと .node-version というファイルを作成して ndenv はそこに記述された Node バージョンへ切り替えているとのことだった。設定がファイルになっているため、これをリポジトリーで共有すれば ndenv を利用するユーザー間で Node バージョンを固定することも可能。よい仕組みだ。

実際、node-sqlite3 を利用している既存プロジェクトを Node 10 に固定し、正常に動作することも確認できた。

まとめ

anyenv + ndenv で自由に Node バージョンを制御できる環境を手に入れた。契機となる node-sqlite3 互換問題も解決したので満足度が高い。今回は ndenv を試したが、他の言語による開発でも役立つだろう。

Babel 7 を試す

2018年9月11日 0 開発 , ,

2018/8/27 に Babel 7 がリリースされたので既存プロジェクトに導入してみたので変更や問題点などをまとめる。

参考資料とサンプル プロジェクトは以下。

Scoped Packages

Babel 公式の npm が Scoped Packages に変更された。scope は @babelbabel-XXXX@babel/XXXX という命名へ移行された。私のよく利用するものだと以下のようになる。

{
  "devDependencies": {
    "@babel/core": "^7.0.0",
    "@babel/preset-env": "^7.0.0",
    "@babel/preset-react": "^7.0.0",
    "@babel/register": "^7.0.0"
  }
}

第三者の Babel preset/plugin について babel-XXXX 命名を維持させながら、公式 npm を明確に区別できるようになった。webpack など npm で第三者の拡張を許可しているものに、この方法が広まってほしい。

babel.config.js

これまで Babel に関する設定はプロジェクトの package.jsonbabel プロパティーで記述していた。しかしこんな議論があったのと、Babel の設定ファイルとして JavaScript サポートが追加されたので移行することにした。

ファイル名は babel.config.js。機能は Config Files で解説されている。移行後の内容は以下。

module.exports = (api) => {
  const presetEnv = [
    '@babel/preset-env',
    {
      targets: {
        electron: '2.0'
      }
    }
  ]

  return {
    presets: api.env('development') ? [
      presetEnv,
      '@babel/preset-react',
      'power-assert'
    ] : [
      presetEnv,
      '@babel/preset-react'
    ]
  }
}

新形式は JavaScript というか Node モジュールとして定義する。このファイル自体は Babel の対象にならないようなので export default ではなく module.exports としている。アロー関数は現時点の Node LTS である v8.x 系なら対応しているので採用。

モジュールとする関数には api という引数が渡される。これは命名そのままで Babel を操作したり環境情報を取得するためのもの。

api.env() を呼ぶと現在の環境変数名が得られる。api.env('環境変数名') とした場合は、それが指定されていれば true を返す。これを利用して development 時だけ power-assert を処理するようにした。

webpack の DefinePlugin が機能しない?

サンプル プロジェクトでは Babel と webpack を組み合わせている。そして Babel 7 対応は npm 更新と Babel 関連の設定変更だけでよい認識していた。実際、正常に動作する。

しかし babel.config.js 移行にともない webpack の DefinePlugin による development 分岐が本当に機能しているか不安だったので、ログをとってみた。

module.exports = (api) => {
  console.log(api.env())
  // ...後略
}

webpack の設定は以下。

new WebPack.DefinePlugin({
  'process.env.NODE_ENV': JSON.stringify('production')
})

この状態で developmentproduction 両方のビルドを試してみたところ、標準出力には いずれも development が表示されるではないか。webpack の GitHub issues を調べてみたら #2121 を見つけた。issue コメントにあった設定例をみると JSON.stringify せず直に 'production' を設定していたので、そのように修正してみたが結果は変わらず。

気づいていなかっただけで従来の package.json における babel.env 分岐も失敗していた可能性がある。仮に power-assert が処理されても assert が置き換えられるぐらいだろうし、テスト コードは実装側から参照していないため害はなさそう。しかし意図しない動作になっているのは怖いので対策してみた。

方法は単純。webpack 処理で process.env.NODE_ENV を設定するだけ。webpack 処理はすでに --modedevelopmentproduction を分岐していたので

export default (env, argv) => {
  const MAIN = !!(env && env.main)
  const PROD = !!(argv.mode && argv.mode === 'production')
  if (PROD) {
    process.env.NODE_ENV = 'production'
  }
}

とした。これをそれぞれの --mode で実行したところ、babel.config.js 側の api.env() が想定どおりの値を返すことが確認できた。

ちなみに webpack の設定ファイルを webpack.config.babel.js にすると Babel 変換されるため babel.config.js が呼ばれる。しかし webpack に --mode production を指定してもこちらのログは当然ながら常に development だった。

環境変数を npm-scripts から分岐するために cross-env を利用しているなら、webpack CLI より前に production を設定できるかもしれない。ただし webpack を利用しているならビルド系の設定を webpack に集約するほうが管理しやすいのでそうしている。

mocha + power-assert

Babel の Scoped Packages 化にともない npm-scripts へ定義していた mocha + power-assert 実行コマンドも対応させる。まずは関連 npm を更新。

{
  "devDependencies": {
    "babel-preset-power-assert": "^3.0.0",
    "mocha": "^5.2.0",
    "power-assert": "^1.6.0"
  }
}

続けてコマンド。

{
  "scripts": {
    "test": "mocha --require @babel/register src/js/**/*.test.js",
  }
}

--require へ指定するモジュールを babel-register から @babel/register に変えるだけで正常に動作した。

まとめ

いくつか問題はあったものの Babel 公式資料が充実していることもあり、さほど苦労せず移行できた。他のプロジェクトも順次 Babel 7 にする予定。

破壊的な変更があるため、このブログも含む古い記事を読むと混乱するかもしれない。ソフトウェアに関する調べ物をするときに資料の日時と言及対象のバージョンを確認するのは鉄則。とはいえ利用する機会のありそうなものならメジャー更新は定点観測するようにしておくと、知識更新の差分を小さくできてよい感じ。

今回は試さなかったけれど Babel 7 の目玉機能に TypeScript 対応がある。私としては ECMAScript で型定義が標準化されることを望んでいる。しかし vscode で優遇されていることもあり TypeScript に移行したくなってきた。あとで試すかも。

npm rewire を試す

2018年7月18日 0 開発 ,

最近 React SFC (Stateless Functional Components) や Redux の影響を受けて、ES.next な環境でもクラスより関数で実装するようにしている。ES Modules であれ Node であれ個別に関数を外部公開できるからクラスを使用せずとも実装には十分だし、むしろ関数を積極的に採用することで外部依存を引数に限定できる。

しかし困ったことが。非公開の関数を単体テストする手段がない。

そもそも非公開なものをテスト対象とするのは悪手では?と言われればそのとおり。しかし公開関数が単一単純でも内部で多くの非公開な関数へ依存しているなら、それらを個別に単体テストしたくなるだろう。外部からみたら公開関数を単体テストしているつもりでも実際には結合テストなわけで。

クラスなら今のところアクセス指定子がないため、非公開メソッドは命名を工夫する慣習 (アンダースコアを接頭辞にする) があるだけだから

export default class Sample {
  publicMethod () {
  }

  _privateMethod () {
  }
}

と定義してあれば _privateMethod を呼び出せる。

一方、モジュール スコープに定義された非公開の関数を外部参照するためには Java や C# でいうところのリフレクション的な処理が必要になる。というわけで、ようやく rewire の話。

rewire

非公開な関数をテストする方法やツールについて調べたたら以下の記事をみつけた。

まさにほしかったもの。記事が書かれたのは 2013 年ということもあり ES2015 以降で動くのか不安だったが

を見ると現在もメンテされているようだ。では試してみよう。まずは環境から。package.json からテストに関するものを抜粋。

{
  "scripts": {
    "test": "mocha --timeout 50000 --require babel-register src/**/*.test.js"
  },
  "devDependencies": {
    "babel-cli": "^6.26.0",
    "babel-preset-env": "^1.7.0",
    "babel-preset-power-assert": "^2.0.0",
    "babel-register": "^6.26.0",
    "mocha": "^5.2.0",
    "power-assert": "^1.6.0",
    "rewire": "^4.0.1"
  }
}

この環境で shortcode.js というファイルに定義された以下の非公開 API をテストしてみる。

const trimLineBreak = (text) => {
  return text.replace(/^[\n]|[\n]$/g, '')
}

テスト コードは以下。

import assert from 'assert'
import Rewire from 'rewire'

describe('trimLineBreak', () => {
  const Module = Rewire('./shortcode.js')
  const trimLineBreak = Module.__get__('trimLineBreak')

  it('trimLineBreak', () => {
    let actual = trimLineBreak('\nText\n')
    assert(actual === 'Text')

    actual = trimLineBreak('\n\nText\n')
    assert(actual === '\nText')
  })
})

npm test を実行すると非公開にも関わらず trimLineBreak を呼び出しテストは成功。assert の引数を書き換えて意図的にテストを失敗させても、きちんと power-assert による失敗の詳細が表示された。

rewire の使い方について。まずは rewire を読み込む。

import Rewire from 'rewire'

次に非公開の関数が定義されているモジュールを rewire 経由で読み込む。

const Module = Rewire('./shortcode.js')

最後にモジュールから非公開 API の名前を指定して参照を得る。

const trimLineBreak = Module.__get__('trimLineBreak')

これで非公開 API を呼び出せる。なお関数だけでなく変数も読み込めるため、テストに非公開な定数が必要な場合でも rewire でいける。実に便利。

余談だがクラスの非公開メソッドについて。tc39/proposals によると 2018/7 時点で

が Stage 3 なので近いうちに事情が変わる可能性はある。これに対して rewire が効くのかは babel-preset-env にきたら改めて試すかも。