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 を試したが、他の言語による開発でも役立つだろう。

npm 開発で再び Babel を導入することにした

2017年4月17日 0 開発 , , ,

以前、npm 開発で脱 Babel してみるという記事を書いた。そして二ヶ月ほど特に問題なく運用できていたのだが、babel-preset-env を試してみたら考えが変わった。

脱 Babel を決めた時点では latest で常時 ES5 変換か plugin を細かく組み合わせることを想定していた。しかし babel-preset-env なら明示的に Node のバージョンを指定することで必要最小の変換をおこなえる。Node としては ES Modules 以外の ES.next 仕様へ積極対応しているため、Active な LTS を下限としておけば変換は ES Modules + α ぐらいで済む。

というわけで Babel を再導入することにした。

プロジェクト構成

Babel を再導入した akabekobeko/npm-xlsx-extractor の構成例。

.
├── package.json
├── examples/
├── dist/
└── src/
    ├── bin
    └── lib
名前 内容
package.json プロジェクト設定ファイル。
dist/ リリース用ディレクトリ。ビルドによって動的生成される。.gitignore 対象。
src/ 開発用ディレクトリ。
src/bin npm を CLI として実行した時のコードを格納するディレクトリ。
src/ npm を Node として実行した時のコードを格納するディレクトリ。

よくある構成だとこれに mocha などのユニット テストを格納するため test/ があるものだけど、Babel 再導入にあたりテストは対象となるコードのあるディレクトリに併置した。

プロジェクト設定

プロジェクト設定はすべて package.json に定義。必要最小の内容を抜粋する。

{
  "engines": {
    "node": ">= 6"
  },
  "main": "dist/lib/index.js",
  "bin": "dist/bin/index.js",
  "files": ["dist"],
  "esdoc": {
    "source": "./src",
    "destination": "./esdoc",
    "test": {"type": "mocha", "source": "./src"}
  },
  "babel": {
    "presets": [
      ["env", {"targets": {"node": 6}}]
    ],
    "env": {
      "development": {
        "presets": [
          "power-assert"
        ]
      }
    }
  },
  "scripts": {
    "test": "mocha --timeout 50000 --compilers js:babel-register src/**/*.test.js",
    "start": "npm run watch",
    "esdoc": "esdoc",
    "eslint": "eslint ./src",
    "build": "babel src --out-dir dist --ignore *.test.js,typedef.js",
    "watch": "babel src --out-dir dist --ignore *.test.js,typedef.js --watch",
    "prepare": "npm run build"
  },
  "devDependencies": {
    "babel-cli": "^6.24.1",
    "babel-preset-env": "^1.3.3",
    "babel-preset-power-assert": "^1.0.0",
    "babel-register": "^6.23.0",
    "esdoc": "^0.5.2",
    "eslint": "^3.15.0",
    "eslint-config-standard": "^6.2.1",
    "eslint-plugin-promise": "^3.4.2",
    "eslint-plugin-standard": "^2.0.1",
    "mocha": "^3.2.0",
    "power-assert": "^1.4.2"
  }
}

順に解説する。

ビルド設定

babel-preset-env を "node": 6 にする。2017/4/1 をもって Node v4 LST は Maintenance になったので、Active な v6 LTS を下限としている。すべての dependencies が v4 対応しているなら Babel がよしなに変換してくれるので "node": 4 にしてもよい。

Babel 再導入を機にコードを ES2015 以降の仕様で書き直した。Node は v6 で大半の ES2015 仕様に対応したので大きな変換は Modules と CommonJS 変換ぐらいとなる。ビルド関係の npm-scripts は以下。

{
  "scripts": {
    "build": "babel src --out-dir dist --ignore *.test.js,typedef.js",
    "watch": "babel src --out-dir dist --ignore *.test.js,typedef.js --watch"
  }  
}

ユニット テストは src/ 内で対象となるコードと併置している。また ESDoc 用に class や module として定義されないものを記述した typedef.js がある。これらは Babel 変換する意味がないので bbel-cli の --ignore オプションで除外。コード検証は基本的にユニット テストを利用するが、npm link で CLI の実操作を試しやすくするために watch タスクを定義している。

ビルド結果の出力先は dist/ にしている。以前はプロジェクト直下に bin/lib/ を生成していたが、現時点の npm react 構造を参考に変えた。node_modules の中身を気にするユーザーはそれほどいないし package.jsonmainbin を明示できるのだから階層が深くなっても問題ないはず。

注意点がひとつ。過去に Babel で変換していた時もそうしていたのだが、export default class XlsxExtractor が CommonJS 化されると exports.default = XlsxExtractor; になり、これをそのまま require した場合、constructor を利用できなくなる。この問題を回避するためには

import XlsxExtractor from './xlsx-extractor.js'
module.exports = XlsxExtractor

という仲介処理を定義し、それを npm の main にエントリー ポイント指定する。このファイルは以下のように変換され、

'use strict';

var _xlsxExtractor = require('./xlsx-extractor.js');

var _xlsxExtractor2 = _interopRequireDefault(_xlsxExtractor);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

module.exports = _xlsxExtractor2.default;

通常の require で読んだ状態と等しくなる。Babel 変換対象の範囲内であればうまく解決されるのだが、npm としてエントリー ポイントを提供する場合は忘れずに対応しておく。

私はこれを忘れてリリースして examples が動かず冷や汗をかいた。

ユニット テスト、ESLint、ESDoc

wip-testable-js.md の影響でテスト用コードと対象を併置。具体的には以下となる。

.
├── bin
│   ├── cli.js
│   ├── cli.test.js
│   └── index.js
└── lib
    ├── index.js
    ├── xlsx-extractor.js
    ├── xlsx-extractor.test.js
    ├── xlsx-util.js
    └── xlsx-util.test.js

もともと Atom などテキスト エディター上でテストと対象コードを同時にタブ表示しても区別しやすくするため a.js のテストは a.test.js と命名していた。そのため併置によるファイル名の競合は発生しない。またきっかけとなった gist にも解説されているとおり

  • テストから対象への相対参照パスが短縮される
    • 併置されているため '../../src/lib/a.js'./a.js と書ける
    • ディレクトリ構造を変更してもテストと対象の併置を維持すればパス変更しなくて済む
  • テストと対象の距離が近い
    • これ以上ない近さ
    • 往復が容易なのでテストを書いたり内容を確認する負担が軽減される
  • テストの有無を視認しやすい
    • *.test.js の併置を確認すればよい
    • なければテストなしと判断できる

といったメリットがある。あらゆるコードが src 内に集約される。よってユニット テスト、ESLint、ESDoc もこのディレクトリだけを対象にすればよい。

{
  "esdoc": {
    "source": "./src",
    "destination": "./esdoc",
    "test": {
      "type": "mocha",
      "source": "./src"
    }
  },
  "scripts": {
    "test": "mocha --timeout 50000 --compilers js:babel-register src/**/*.test.js",
    "esdoc": "esdoc",
    "eslint": "eslint ./src"
  }
}

あと ESDoc で CommonJS をサポートするためには esdoc-node が必要だが ESDoc 公式 plugin ではないため ESDoc Hosting Service 上で動かせない。この点については作者の @h13i32maru さんと Twitter 上で相談して plugin の公式化を検討していただいている。しかし今回の対応で Babel 再導入を前提にコードを ES.next で書き直した。よって esdoc-node は不要となり、そのまま ESDoc Hosting Service を利用できる。

publish

Babel 再導入により npm を publish する際にビルドが必要となる。これは以前、prepublish で定義していたが npm v4 から deprecated になったので prepare を利用するように修正。

{
  "main": "dist/lib/xlsx-extractor.js",
  "bin": "dist/bin/index.js",
  "files": ["dist"],
  "scripts": {
    "build": "babel src --out-dir dist --ignore *.test.js,typedef.js",
    "prepare": "npm run build"
  }  
}

ビルド結果を dist にしているため、files の内容がスッキリした。

まとめ

対象 Node に応じたコード修正コストを babel-preset-env に丸投げできるのは便利だ。もっと早く知っていれば脱 Babel しなかったかもしれない。依然として変換品質の懸念はあるものの、自前で Node の Release note や ECMAScript 6 compatibility table を調べて対応するよりもはマシなはず。

他の npm も Babel 再導入とユニット テスト併置などを反映させてゆく予定。

併置といえば Web フロント エンド開発だと CSS Modules により View コンポーネントと CSS の関係も見直されている。これも面白い潮流だ。React の JSX において関心と技術の分離は異なるという話があった。ユニット テストや CSS も対象と密なら近接するのが自然である。

Transpiler や Bundler を前提とすることで JavaScript における動作環境や技術の分離に関する問題は解決されつつあり、より本質的な関心事にもとづいて設計できる時代になったと感じる。

babel-preset-env を試す

2017年3月29日 0 開発 , , ,

npm として配布するものは純粋な Node 機能のみで構成したいため脱 Babelしたが、Web フロントエンドや Electron では最新の ECMAScript 機能を利用したい。

というわけで、これまでは Babel + babel-preset-latest で JavaScript を変換してきた。しかし latest だと Web ブラウザーや Electron が最新規格に対応しても個別に変換を無効化するのが難しい。

例えば ES2015 Classes は大半の Web ブラウザーが対応済みにも関わらず

var Sample = function () {
  function Sample() {
    _classCallCheck(this, Sample);
  }
}

のように変換されてしまう。一方、機能単位で変換を無効にできるとしても Web ブラウザー毎の対応状況を調べるのは実に面倒。ECMAScript 6 compatibility table をマメにチェックしながら下限となる環境を決める必要がある。

こうした悩みを解決するツールとして babel-preset-env が提供されている。用途を latest と比較すると以下のようになる。

preset 用途
latest 最新 ECMAScript を常時 ES5 相当に変換。
env 最新 ECMAScript を指定された環境に基づき最小限 ES5 に変換。

対象環境を考えるのが面倒で変換コストや品質を気にしないなら latest、なるべく無駄な変換をなくしたいなら env を採用することになるだろう。

試してみる

babel-preset-env を試してみる。

Web フロントエンドや Electron だと Babel 単体よりも Browserify + babelifywebpack + babel-loader のように bundler と組み合わせて利用する機会が多い。しかし bundler 部分も含めると Babel が変換した結果に限定してチェックするのが難しいため実行には babel-cli を採用することにした。

変換対象として ES2015 と ES2016 の代表的な機能を使用した 2 種類の JavaScript を定義。まずは sample.js

// ES2015
export default class Sample {
  message (text) {
    console.log(text)
  }

  async asyncFunc () {
    const wait = (n) => {
      return new Promise((resolve) => setTimeout(() => resolve(n), n))
    }

    await wait(1000)
    console.log('finish!!')
  }

  static func (text = 'sample') {
    console.log(text)
  }
}

// ES2016: Exponentiation Operator
export function pow (a = 0, b = 0) {
  return a ** b
}

// ES2016: Array.prototype.includes
export function includes (arr = [], value) {
  return Array.isArray(arr) ? arr.includes(value) : false
}

sample.js を参照する index.js

import Sample, { pow, includes } from './sample.js'

{
  const sample = new Sample()
  sample.message('Message1')
}

Sample.func('Message2')

console.log(pow(2, 3))
console.log(includes([1, 2, 3], 2))

これらを babel-preset-env の README に掲載されている Examples と現時点で最新の Electron v1.6 向け設定で変換する npm-scripts を定義した。Babel の設定も含めて package.json で完結しているため長いけれど全掲載する。

{
  "name": "using-babel-preset-env",
  "version": "1.0.0",
  "description": "",
  "author": "akabeko",
  "license": "MIT",
  "main": "index.js",
  "keywords": [
    "babel-preset-env"
  ],
  "repository": {
    "type": "git",
    "url": "https://github.com/akabekobeko/examples-web-app.git"
  },
  "babel": {
    "env": {
      "default": {
        "presets": [["env"]]
      },
      "chrome52": {
        "presets": [
          ["env", {
            "targets": {
              "chrome": 52
            }
          }]
        ]
      },
      "chrome52webpack_loose": {
        "presets": [
          ["env", {
            "targets": {
              "chrome": 52
            },
            "modules": false,
            "loose": true
          }]
        ]
      },
      "browserslist": {
        "presets": [
          ["env", {
            "targets": {
              "chrome": 52,
              "browsers": ["last 2 versions", "safari 7"]
            }
          }]
        ]
      },
      "node_current": {
        "presets": [
          ["env", {
            "targets": {
              "node": "current"
            }
          }]
        ]
      },
      "debug_output": {
        "presets": [
          ["env", {
            "targets": {
              "safari": 10
            },
            "modules": false,
            "useBuiltIns": true,
            "debug": true
          }]
        ]
      },
      "include_exclude_plugins_buildin": {
        "presets": [
          ["env", {
            "targets": {
              "browsers": ["last 2 versions", "safari >= 7"]
            },
            "include": ["transform-es2015-arrow-functions", "es6.map"],
            "exclude": ["transform-regenerator", "es6.set"]
          }]
        ]
      },
      "electron": {
        "presets": [
          ["env", {
            "targets": {
              "electron": 1.6
            }
          }]
        ]
      }
    }
  },
  "scripts": {
    "start": "run-s build",
    "build:default": "cross-env NODE_ENV=default babel ./src --out-dir ./dist/default",
    "build:chrome52": "cross-env NODE_ENV=chrome52 babel ./src --out-dir ./dist/chrome52",
    "build:chrome52webpack_loose": "cross-env NODE_ENV=chrome52webpack_loose babel ./src --out-dir ./dist/chrome52webpack_loose",
    "build:browserslist": "cross-env NODE_ENV=browserslist babel ./src --out-dir ./dist/browserslist",
    "build:node_current": "cross-env NODE_ENV=node_current babel ./src --out-dir ./dist/node_current",
    "build:debug_output": "cross-env NODE_ENV=debug_output babel ./src --out-dir ./dist/debug_output",
    "build:include_exclude_plugins_buildin": "cross-env NODE_ENV=include_exclude_plugins_buildin babel ./src --out-dir ./dist/include_exclude_plugins_buildin",
    "build:electron": "cross-env NODE_ENV=electron babel ./src --out-dir ./dist/electron",
    "build": "run-s build:*"
  },
  "devDependencies": {
    "babel-cli": "^6.24.0",
    "babel-preset-env": "^1.2.2",
    "cross-env": "^3.2.4",
    "npm-run-all": "^4.0.2"
  }
}

Babel の設定は NODE_ENV 単位に定義できることを利用して cross-env により分岐している。npm start を実行すると dist/ 配下に設定ごとの変換結果が出力される。普通に指定するなら presets 配下の内容を Babel 設定のルートに記述すればよい。例えば .babelrc に Electron 用の設定を定義するなら以下のようにする。

{
  "presets": [
    ["env", {
      "targets": {
        "electron": 1.6
      }
    }]
  ]
}

サンプルの出力結果を比較すると、例えば default なら全変換され chrome52webpack_loose だと async/await 以外はそのままであることが分かる。また chrome52 では async/await 変換があり electron だと v1.6.0 から Chromium 56.0.2924.87 を採用しているため async/await はそのままに Modules は共通して変換されていた。

babel-preset-env の README によれば、変換の基準は前述の ECMAScript 6 compatibility table を基準として判断しているのだという。つまり機能と対象環境の組み合わせ管理を babel-preset-env に丸投げできるわけだ。本当に ECMAScript 6 compatibility table や Electron のバージョンをチェックしているのか?と targets にデタラメな値を設定してみたら実行時にエラーとなった。Electron に関しては 1.0 以降が対象で最新の 1.6 系まで指定可能。

ただし targets のバージョンは JSON の number になるため小数点第一位までしか対応しておらず 1.6.2 のように semver 形式は受け付けていない。また "node": "current" は可能だが "electron": "current" を指定したらエラーになった。Electron も browsers のように相対値で指定したいものだ。

とはいえ babel-preset-latest を使用しつつ「将来 ES2015 変換をやめたくなったらどうしよう?」などと考えていたので、そのような処理を動作環境の指定へ抽象化して変換してくれる babel-preset-env は実にありがたい。

babel-preset-env 設定により想定している動作環境が明示される点もメリット。これはアプリの仕様を外部へ説明するのに役立つ。また設定を Git リポジトリなどで管理しているなら動作環境の変遷も記録される。例えば IE サポートを打ち切るとして、その履歴をきちんと残せるのだ。

最後に動作確認で使用したサンプル プロジェクトを公開しておく。