Python プロジェクトを構成して CI 連携 & PyPI 登録

Python 絡みのドキュメントは個人的に割とハードに感じることが多く、プロジェクト構成を作るところからよく理解できていなかった。

そのため、Python で何か作ろうと思って少し調べてみても、以前はこんな感じだった。

  • requirements.txt に依存モジュール書いても自動的にインストールされないし何なの……
  • 開発中のパッケージの動作確認時、ローカル環境への再インストールが面倒すぎる
  • 動作用の依存パッケージと開発用の依存パッケージの管理を分けたい

今回少し試行錯誤をしてみて、上記の謎を解いて CI 連携も行って、まあまあ満足できる構成ができた。 あまり公式的な作法に則っていないかもしれないがメモしておく。

以下では、基本的なファイルレイアウトから、開発中パッケージのローカル環境へのインストール方法、依存ライブラリの管理方法、PyPI への登録方法について説明する。 なお OSS として PyPI に公開しない場合であっても、同じ作法でプライベートな VCS 上にリポジトリを作っておけば pip でその URL を指定してインストールできるようになるので、インストール時の依存パッケージ管理を楽にする意味でもこのような構成で作っておくといい。

説明に使うリポジトリhttps://github.com/keik/subarg にある。

基本のファイルレイアウト

依存パッケージがない場合、PyPI に準拠した基本的なファイルレイアウトは以下のような感じ。

.
├── setup.py
├── subarg
│   └── __init__.py
└── tests
    └── test_subarg.py

これに、以降で説明するもろもろを追加していく。

依存パッケージがない場合の setup.py

PyPI にパッケージを登録するためのスクリプトsetup.py というファイル名で作成する。

Bundler における Gemfile や npm における package.json のように専用のフォーマットで情報を列挙する方式と違って、setup.py ファイルは setuptools.setup 関数を使って以下のように Python で実装する。

from setuptools import setup, find_packages

setup(name='subarg',
      version='1.0.0',
      description="""Parse sub-arguments in `[` and `]` recursively""",
      author='keik',
      author_email='k4t0.kei@gmail.com',
      url='https://github.com/keik/subarg',
      license='MIT',
      classifiers=[
          'License :: OSI Approved :: MIT License',
          'Programming Language :: Python',
          'Programming Language :: Python :: 2',
          'Programming Language :: Python :: 2.7',
          'Programming Language :: Python :: 3',
          'Programming Language :: Python :: 3.5',
      ],
      packages=find_packages())

setuptools.setup 関数の classifiers 引数に指定する値は https://pypi.python.org/pypi?%3Aaction=list_classifiers から選択して列挙する。

ただしこの classifiers 引数、かなりモヤモヤ感がある。指定する値のリストにある Development Status :: 4 - Beta といった値は version 引数のサフィックスでも表現できるし、License :: OSI Approved :: MIT License といった値は license 引数でも表現できるし、Programming Language :: Python といった値は「そりゃそうだろ」感しかない。

とはいえ PyPI にはここに書かれたラベル情報が登録されるし、バッジを生成する shields.io もここの情報を参照するものがあるので、面倒でも上記の例くらいは書いておいたほうがよさそう。

開発時のローカル環境へのインストール

開発中は実際に動作させながらソースを編集していきたいが、ソース変更の度にパッケージを再インストールするのは面倒。

そこで、pip で setup.py があるディレクトリを --editable オプション指定でインストールする (下の例は . でカレントを指定)。

pip install --editable .

すると /Library/Python/2.7/site-packages などの pip 管理下の場所に実際のリソースがコピーされることなく、そのかわりに開発中パッケージの場所情報を持つ subarg.egg-link といったファイルが作られる。開発中パッケージのソースを書き換えても再インストールする必要はない。

動作に依存するパッケージを管理する

setuptools.setupinstall_requires 引数に依存するパッケージ名を文字列のリストで指定する。

install_requires = [
    'numpy',
    'matplotlib'
]

setup(name='subarg',
    ...,
    install_requires=install_requires)

これで pip でパッケージをインストールする際に自動的に依存ライブラリもインストールされるようになる。

パッケージ名を列挙した requirements.txt というファイル (Requirements ファイル) を見たことあるかもしれないが、このファイル単独ではこのような自動的な依存管理システムに作用することはない。このファイルは、pip install -r requirements.txt で依存パッケージをバッチでインストールするために使用されるファイルに対して慣例的につけられる名前というだけであって、予約されたファイル名ではない。多分。

開発時に必要なパッケージを管理する

pytest など開発時のテストにのみ必要なライブラリは、配布したパッケージのインストール時についてきてほしくない。

setuptools.setup 関数にもテスト関連の tests_require 引数とかなんとかがあるが、テスティングフレームワークとの統合がめんどくさそうだったりオプション渡すのも工夫がいるよう なので、もはや setuptools のシステムを使わないで済ませたほうが楽。

そこで Makefile および上述した Requirements ファイルの出番となる。

requirements-dev.txt には開発時に必要なパッケージを列挙する。

flake8
pytest
pytest-cov

これを用いて Makefile にテストの手続きを定義する。

SELF="subarg"
DEV_DEPS="requirements-dev.txt"

test: init
    py.test tests --cov subarg --verbose

init: uninstall
    pip install --upgrade -r $(DEV_DEPS)
    pip install --upgrade --editable .

uninstall:
    - pip uninstall --yes -r $(DEV_DEPS) 2>/dev/null

これで make test でテスト時に必要な依存ライブラリがインストールされてテストが実行できる。

Python のエコシステムを脱して Makefile なの? と思うかもしれないが httpie で採っている方法 でもあるので、悪くない方法なのだと思う。

Travis CI と Coveralls 連携

あらかじめ Travis CI と Coveralls のウェブサイトからリポジトリを有効化しておく。あとは .travis.yml を用意してリポジトリにプッシュ。

language: python
python:
  - 2.7
  - 3.5
script:
  - make
after_success:
  - pip install python-coveralls && coveralls

Coveralls でカバレッジを計測するため、after_success で Coveralls 用のパッケージ coveralls のインストールと実行をする。 これでテスト時に生成されたカバレッジファイルが自動的に Coveralls に送信され、測定結果の記録やバッジ機能が有効になる。

PyPI への登録

パッケージを PyPI に登録する際は以下のコマンドを実行する。

python setup.py register
python setup.py sdist upload

事前に PyPI へのアカウント登録が必要となる。上の register 時にログイン情報が求められる。

README.md にバッジを貼る

一番楽しい時間。Coveralls でカバレッジを記録したし PyPI にも登録したら、あとはバッジを貼ってちゃんとしている感を演出する。

shields.io には PyPI 登録情報に基づくバッジがある。またフラットデザインのバッジを生成できるので、バッジのデザインを統一したいなら shields.io のバッジで統一して使うといい。

JavaScript バンドルツール Rollup を試した

ウェブアプリ向けの JavaScript のバンドル (ビルド) に、普段は Browserify を使っているが、最近 Rollup というバンドルツールを目にしたので試してみた。

Rollup の特徴や使用例、感想などをメモしておく。

基本は ES6 modules

Rollup においては、モジュールシステムは ES6 modules の記法 (import React from 'react' という感じのやつ) で書くことが基本となる。

とはいえ現状、npm にあるパッケージの多くは、モジュールシステムが CommonJS の記法 (module.exports とか require('react') という感じのやつ) で書かれている。Rollup でこれに対応するためには、プラグイン rollup-plugin-commonjs を使う必要がある。

プラグイン方式

Rollup はプラグイン方式を採っている。

例えば npm でインストールしたパッケージをバンドルするためには、上で述べた rollup-plugin-commonjs の他に、node_modules 以下からモジュールを探すためのプラグイン rollup-plugin-node-resolve を使う必要がある。

そしてこのようなプラグインを使ったビルドプロセスを、デフォルト名 rollup.config.js という設定ファイルに定義する。

import nodeResolve from 'rollup-plugin-node-resolve'
import commonjs from 'rollup-plugin-commonjs'

export default {
  entry: 'src/scripts/main.js',
  dest: 'dist/bundle.js',
  plugins: [
    nodeResolve({
      jsnext: true
    }),
    commonjs()
  ]
}

他の例として、ファイルの変更検知してインクリメンタルビルドするためには rollup-watch パッケージをインストールする必要がある (正確にはプラグインというカテゴリに入るものではないと思うけど)。これをインストールした上で Rollup の CLI オプション --watch を指定することで実際に機能させることができる。

React アプリをバンドルするプロジェクト構成例

Rollup + React オンリーの最小構成プロジェクトを作った。

https://github.com/keik/hello-rollup

package.json 内に npm-scripts として buildwatch のタスクを定義している。

この僅かなサンプルの中に、以下2つほどポイントがある。

  • React の JSX をトランスフォームするため Babel + babel-preset-react とかが必要なので、Rollup で Babel するためのプラグイン rollup-plugin-babel を使っている。これを使って、あとはいつもどおり .babelrc に presets の設定を書けばいい。
  • React がフレームワーク内で process.env を参照する際、未定義オブジェクトへの ReferenceError が起きる。これを回避するため、rollup-plugin-env を使ってバンドル時に process.env に定数を設定している。

ググってすぐ解決できるレベルだけど、これだけなのになんかいろいろあるなあって感じで面倒くさい感じになってた。

感想

  • 出力がきれい。
  • ビルド速度は Browserify とそんなに変わらない。
  • プラグイン方式なので流行次第でエコシステムが捗りそうだがセットアップが少々面倒。
  • 上記を踏まえた上で、自分の実力では有効なユースケースが思い浮かばなかった。しばらく Browserify 使い続けると思う。

OS X 上 Anaconda で Tk 使おうとしてエラーが出たときの対処法

OS X 上に pyenv を使って Anaconda 環境を作った。

git clone https://github.com/yyuu/pyenv.git ~/.pyenv
# 中略
pyenv install anaconda3-4.1.0

その環境で Tk を使おうとしたらこんなエラーが出た。

objc[15737]: Class TKApplication is implemented in both /System/Library/Frameworks/Tk.framework/Versions/8.5/Tk and /Users/keik/.pyenv/versions/anaconda3-4.1.0/lib/libtk8.5.dylib. One of the two will be used. Which one is undefined.
objc[15737]: Class TKMenu is implemented in both /System/Library/Frameworks/Tk.framework/Versions/8.5/Tk and /Users/keik/.pyenv/versions/anaconda3-4.1.0/lib/libtk8.5.dylib. One of the two will be used. Which one is undefined.
objc[15737]: Class TKContentView is implemented in both /System/Library/Frameworks/Tk.framework/Versions/8.5/Tk and /Users/keik/.pyenv/versions/anaconda3-4.1.0/lib/libtk8.5.dylib. One of the two will be used. Which one is undefined.
objc[15737]: Class TKWindow is implemented in both /System/Library/Frameworks/Tk.framework/Versions/8.5/Tk and /Users/keik/.pyenv/versions/anaconda3-4.1.0/lib/libtk8.5.dylib. One of the two will be used. Which one is undefined.
Exception in Tkinter callback
Traceback (most recent call last):
  File "/Users/keik/.pyenv/versions/anaconda3-4.1.0/lib/python3.5/site-packages/matplotlib/backends/tkagg.py", line 22, in blit
    id(data), colormode, id(bbox_array))
_tkinter.TclError: invalid command name "PyAggImagePhoto"

エラーメッセージ _tkinter.TclError: invalid command name "PyAggImagePhoto" でググッて出てきた これ の通り、matplotlib を再インストールしたら治った。

pip uninstall matplotlib
pip install matpotlib

検索結果から対処方法が微妙に見つけにくかったので一応メモ。

Python 隠れマルコフモデル用ライブラリ hmmlearn の使い方メモ

隠れマルコフモデル (HMM; Hidden Markov Model) を実装した Python のライブラリ hmmlearn の使い方を理解したのでメモしておく。

HMM で扱う問題は3種類あって、それを理解していないと「使ってみたけどよくわからない」状態になりかねないので、まずはそれらをおさらいして、その後にそれぞれの問題に対応させながら API の使い方を説明していく。

HMM が扱う問題の種類

HMM では次の3つの種類の問題を扱う。

  1. 評価: 既知のパラメータで構成された HMM の出力として観測系列 \boldsymbol{x} が得られる確率 (尤度) を評価する
  2. 復号: 既知のパラメータで構成された HMM の出力として観測系列 \boldsymbol{x} が得られる確率が最も高くなる状態系列 \boldsymbol{s} を復号する
  3. 推定: 未知のパラメータの HMM からの出力として観測系列 \boldsymbol{x} が得られたとき、未知のパラメータを推定する

インストール

$ pip install hmmlearn
$ python 
>>> from hmmlearn import hmm
>>> hmm.GaussianHMM
<class 'hmmlearn.hmm.GaussianHMM'>

モデルを構築する

HMM には次の3つのパラメータがある。

  • 初期状態確率 \boldsymbol{\pi}
  • 遷移確率 \boldsymbol{A}
  • 出力確率 \boldsymbol{B}

これらのパラメータは学習で推定して指定することもできるが、まずは明示的に指定してモデルを構築する。

# ライブラリのインポート
import numpy as np
from hmmlearn import hmm

# GaussianHMM クラスから出力確率が正規分布に従う隠れマルコフモデルを作る。
# n_components パラメータで隠れ状態が3つあることを指定している。
model = hmm.GaussianHMM(n_components=3, covariance_type="full")

# 初期状態確率 π を指定する。
model.startprob_ = np.array([0.6, 0.3, 0.1])

# 遷移確率 A を指定する。
model.transmat_ = np.array([
        [0.7, 0.2, 0.1],
        [0.3, 0.5, 0.2],
        [0.3, 0.3, 0.4]])

# 出力確率 B を指定する。
# ただし出力は正規分布に従うと仮定しているため、正規分布のパラメータの
# 平均値 μ (means_) と、共分散 σ^2 (covars_) を指定する。
model.means_ = np.array([
        [0.0, 0.0],
        [10.0, 10.0],
        [100.0, 100.0]])
model.covars_ = 2 * np.tile(np.identity(2), (3, 1, 1))

ここでちょっとわかりにくいのは出力関数の正規分布のパラメータとして指定する model.means_model.covars_ だと思う。上の例においては、model.means_ によって

  • 状態 s_0 からは [0.0, 0.0]
  • 状態 s_1 からは [10.0, 10.0]
  • 状態 s_2 からは [100.0, 100.0]

を平均値とし、さらに model.covars_ に指定した共分散行列に従う正規乱数を出力することを定義している。

出力信号に2次元ベクトル (配列) を指定しているので、共分散行列 model.covars_ に指定する値には np.identity(2)ℝ^{2 \times 2} の行列を作っている。ここのサイズが一致しないとエラーになる。また1次元の場合であっても [[0], [10], [100]] のように配列で指定しないとエラーになる。

観測系列のサンプルを出力する

上で構築したモデルから sample メソッドを用いて観測系列サンプルを出力する。

# サンプル信号を 10 個出力する。
X, Z = model.sample(10)

戻り値は (観測系列, 状態系列) のタプルで得られる。その中身はこんな感じ。

>>> X
array([[  0.75706838,  -0.1280334 ],
       [ 10.3137587 ,  10.59635189],
       [ 99.27435882,  99.63294557],
       [ 98.74324108,  99.25505532],
       [ -0.5194227 ,   2.59958429],
       [  2.66607548,  -0.41898544],
       [  1.49013197,  -1.51010137],
       [  9.394274  ,  11.27156992],
       [ 10.1477135 ,   9.76125003],
       [  9.88244708,   9.33941603]])
>>> Z
array([0, 1, 2, 2, 0, 0, 0, 1, 1, 1])

観測値と状態の組み合わせを確認して、それらしい値が出力されていることが分かる。

[評価] 既知のモデルから観測系列 \boldsymbol{x} が得られる尤度を評価する

構築したモデルの score メソッドに観測系列 \boldsymbol{x} を渡すことで、観測系列の対数尤度が得られる。

上で出力した観測系列サンプルの対数尤度を評価すると

>>> model.score(X)
-41.160325787201835

対数尤度を真数に変換し確率として評価すると

>>> np.exp(model.score(X))
1.3313665376872969e-18

まあ尤度だけ表示したところで「ふーん」って感じなので、次いきましょう。

[復号] 既知のモデルから観測系列 \boldsymbol{x} が得られた時の状態系列 \boldsymbol{s} を復号する

構築したモデルの predict メソッドに観測系列 \boldsymbol{x} を渡すことで、その観測系列が得られる確率が最も高い状態系列が復号される。

上で出力した観測系列サンプルから状態系列を復号してみると

>>> model.predict(X)
array([0, 1, 2, 2, 0, 0, 0, 1, 1, 1])

この結果は上で出力した状態系列と同じなので、尤もらしい状態系列を復号できていることが分かる。

[推定] 未知のモデルから観測結果 \boldsymbol{x} が得られた時のモデルのパラメータを推定する

いよいよパラメータの推定。

新しくパラメータ未設定のモデルを初期化し、fit メソッドに観測系列を与えることでパラメータを推定する。 モデル初期化時の n_iter パラメータによって、推定に使う Baum-Welch アルゴリズムイテレーション回数を指定できる、大きめの値にしておくことで、計算量は増えるが収束しやすくなる。

上で出力した長さ10の観測系列サンプルからパラメータを推定してみると

remodel = hmm.GaussianHMM(n_components=3, covariance_type="full", n_iter=100)
model.fit(X)

結果は以下のとおり (遷移確率と出力値の平均のみ表示)。まあ教師データのサンプル数が10しかないので、まともに推定できていない。

>>> remodel.transmat_  # 推定された遷移確率
array([[  0.00000000e+00,   0.00000000e+00,   1.00000000e+00],
       [  5.00000000e-01,   5.00000000e-01,   5.63659518e-81],
       [  1.99850496e-01,   1.99975083e-01,   6.00174422e-01]])

>>> remodel.means_  # 推定された出力確率 (正規乱数の平均)
array([[  4.43588108,   6.934226  ],
       [ 99.00879995,  99.44400045],
       [  5.87656445,   4.60734161]])

今度は元のモデルからサンプルを10000個出力し、それを用いて推定してみる。

X, Z = model.sample(10000)
remodel = hmm.GaussianHMM(n_components=3, covariance_type="full", n_iter=100)
remodel.fit(X)

今度は、以下のとおり正解に近いパラメータが推定できていることが確認できる。パラメータ内のベクトルの並び順はランダムなので注意。

>>> remodel.transmat_  # 推測された遷移確率
array([[ 0.70077029,  0.1060636 ,  0.19316611],
       [ 0.31953291,  0.39437367,  0.28609342],
       [ 0.2991481 ,  0.19790302,  0.50294888]])

>>> remodel.means_  # 推定された出力確率 (正規乱数の平均)
array([[  2.95450793e-02,   9.44972133e-03],
       [  9.99727677e+01,   1.00005236e+02],
       [  9.99470491e+00,   9.98823474e+00]])

パラメータの初期値を指定して推定する

上の例では初期状態確率 \boldsymbol{\pi}、遷移確率 \boldsymbol{A}、出力確率 \boldsymbol{B} の初期パラメータを指定していないため、すべてのパラメータを乱数で初期化して推定を開始していた。

一方で、パラメータをおおまかに推測できる場合は、パラメータを乱数で初期化する代わりに推定値で初期化することで、収束を早めることができる。

GaussianHMM クラスの init_params パラメータを用いて乱数で初期化するパラメータを頭文字で指定できる。初期値は stmc つまり startprob_, transmat_, means, covars_ のすべてのパラメータを乱数で初期化する設定になっている。よって、例えば大まかな出力の平均値が分かっているというのであれば、次のように init_params パラメータに m を指定せず、means_ プロパティに明示的に初期値を与える。

remodel = hmm.GaussianHMM(n_components=3, covariance_type="full", init_params='stc', n_iter=100)
remodel.means_ = np.array([
        [0.0, 0.0],
        [10.0, 10.0],
        [100.0, 100.0]])
remodel.fit(X)

特定のパラメータを固定し、残りのパラメータを推定する

上の例ではすべてのパラメータを学習プロセスで更新していた。

一方で、一部のパラメータについて正解値が分かっていたり固定値で仮定したい場合には、学習プロセスで更新しないように固定化できる。

GuassianHMM クラスの params パラメータを用いて学習プロセスで更新するパラメータを頭文字で指定できる。初期値は stmc つまりすべてのパラメータを更新する設定になっている。よって、例えば出力の平均値が確定的に分かっているというのであれば、params パラメータおよび init_params パラメータに m を指定せず、かつ means_ プロパティに明示的に初期値を与える。

remodel = hmm.GaussianHMM(n_components=3, covariance_type="full", init_params='stc', params='stc', n_iter=100)
remodel.means_ = np.array([
        [0.0, 0.0],
        [10.0, 10.0],
        [100.0, 100.0]])
remodel.fit(X)

半教師あり NMF による音源分離を Python で実装した

非負値行列因子分解 (NMF; Non-negative Matrix Factorization) は、非負値からなる行列 \boldsymbol{Y} \in ℝ^{m \times n} を、\boldsymbol{H} \in ℝ^{m \times k}\boldsymbol{U} \in ℝ^{k \times n} の積で近似する数学的な操作で、\boldsymbol{H} の列は基底パターンを、\boldsymbol{U} の行は基底パターンの重みを表す。

音響学においては、スペクトログラムを非負値からなる行列とみなして NMF で分解すれば、\boldsymbol{H} は周波数基底を、\boldsymbol{U} は時系列ごとの周波数成分基底の強さを表すことになる。

ここで、分離したい楽器の音から求めた周波数基底をあらかじめ与えて固定化し NMF を適用すれば、高精度で目的の楽器音が分離できる。このように固定的な基底を教師データとして与えられるように NMF を拡張したものを半教師あり NMF (SSMNF; Semi-Supervised NMF) という。

Python機械学習用ライブラリ scikit-learn には NMF の実装がある が、SSNMF の実装がなかったので、ギリギリの数学力で独自に実装して試してみた。

実装コードと実験結果のデモ

一応分離できているけど音がガビガビする。参考にした文献「非負値行列因子分解 NMF の基礎と データ/信号解析への応用」には

基底 T に時間方向の連続性を持たせた convolutive NMF

というものを用いているそうで、これを試した結果も気になるけど、有料の論文だったので実装実験はここまでにした。

それと、Python のパッケージシステムの理解のために SSNMF の実装を nmftools というライブラリにしてみた。パッケージのレイアウトができていれば、PyPI に登録してなくても pip でリポジトリURI を指定することでインストールできるんですね。

pip install git+https://github.com/keik/nmftools.git

独立成分分析による音源分離を Python で実装した

複数の音源が交じり合った混合信号から元の音源を推測して再合成することを音源分離といい、各音源についての事前情報を持たない場合を特にブラインド音源分離という。

音声情報を用いた応用アプリケーションを考えてみれば、声でコンピュータを操作したり、鼻歌で楽曲を検索したり、演奏から楽譜を自動生成したり、演奏の自動伴奏をしたりと様々なものが存在する。これらは、音声信号から分析した音高・発音時刻・音素に含まれる振幅伝達特性などの時系列的な変化を特徴量として利用することで実現されている。

入力音声の特徴量抽出の精度を向上させるためにも、入力音声からは予めノイズとなる雑音や特定の音源からの信号を取り除きたい。そこで音源分離の技術が利用される。

音源分離にはいくつかの手法があるが、ここでは独立成分分析 (ICA; Independent Component Analysis) による手法を Python で実装して試した。

実装コードと実験結果のデモ

ICA がなぜ音源分離に有効か、また ICA はどのようなアルゴリズムで実行されるかは、研究者の方がアップロードしていたスライド「音響メディア信号処理における独立成分分析の発展と応用」がとても分かりやすい。

なお ICA の適用条件における注意点として、ICA への入力には分離したい音源数以上の混合信号が必要になる。つまり分離したい音源数以上のマイク本数で録音する必要がある。

Jupyter Notebook ええぞ

もともと Python には不慣れだったが、Jupyter Notebook という Web ベースのインタラクティブなコード実行環境と、NumPy をはじめとする科学技術計算用ライブラリを使うことで、意外となんとかなった。

特に Jupyter Notebook はインタラクティブにコードを実行できるだけでなく、グラフ画像出力によるデータ可視化や音声データ再生ウィジェットが配置できるので、結果確認からの微調整という試行錯誤のイテレーションが極めてスムーズに行える。さらにドキュメンテーション機能を見れば Markdown による文章構造表現や MathJax による数式表現がサポートされているし、最終的には単一の HTML ファイルとして出力できる (画像や音声は Base64 エンコードされて埋め込まれる) ので、この手の実装実験レポートを作る場合には最高に便利。パワポとエクセルよりいいですよ。

Java とフロントエンドの付き合い方

ウェブアプリにおける JavaScriptCSS の役割・規模・複雑度が年々ヤバいことになってきているのは今更言うまでもない。今や JavaScript は、モジュールごとに分割して TypeScript や次期仕様の ECMAscript といったいわゆる altJS で記述されたのち、変換 (Transpile)・結合 (Concatinate)・最小化 (Minify) されてリリースされる。

ウェブアプリ開発において今時 (?) の言語を使う職場やコミュニティでは、フロントエンド絡みのビルドはある程度定着していることだろう。それには、Ruby on Rails における Sprockets のような、フロントエンド開発をサポートする優れたライブラリの存在によって、手軽に方法論を取り入れることができたり、コミュニティ内に情報が流通する機会がもたらされていることにも関係があると思う。

Java のフロントエンド事情

Java ウェブアプリ開発とフロントエンド開発を統合するためのツールや情報は少ない。

あくまで自分のケースだが、周囲で見かける Java ウェブアプリのフロントエンド開発は、依然旧世代的だ。JavaScript にビルドという考えがあることを知らない開発者も多い。そんな現場での典型的なフロントエンド開発の運用は次のような感じになる。

  • <script src="common.js"></script> から始まる script タグが10行以上並ぶ
  • common.js からはグローバルネームスペースにいくつもの変数・関数がエクスポートされる
  • common.js 以外に読み込んだページ固有スクリプトからもグローバルネームスペースへのエクスポートがある
  • 一つの巨大な JavaScriptCSS ファイルができる

こうした運用は、コードの見通しを悪化させ、グローバル依存のコードを増やし、テストが放棄される。その結果、バグを生んでメンテナビリティを失う。

もちろん Java に限ってのことではないのだが、Ruby などと比べて情報が少ない言語であるのは事実だ。

シンプルなフロントエンド開発方法からの統合

じゃあ、と意気込んでフロントエンドに馴染みのないエンジニアがフロントエンドの開発事情を調べてみれば、それはそれで混沌としていることが分かるだろう。Node、npm、Bower、Grunt、gulp、Browserify、webpack、React、AngularJS……。何かいろいろあってよくわからないが効率的な開発ができるようになるらしい。だが、まずはシンプルに始めたい。それなのにシンプルに始める方法を選ぶのが難しい。

ずばり、シンプルさを重視すれば、npmBrowserify だけで始めればいいと思う。

その後は適宜ツールを追加していけばいい。どのツールも UNIX 哲学的な疎結合で単機能なツールばかりだから、適宜追加すればいい。

そんな開発ワークフローを体験するためのチュートリアルを作った。とにかく一度、フロントエンドのモダンな開発ワークフローを体験してもらいたい。

Java + フロントエンド開発統合チュートリアル

チュートリアルでは次のサンプル Todo アプリを使用する。 * https://github.com/keik/spring-frontend-integration-example

サンプルの動作・開発には次の環境が必要になる。 * JDK 8+ * Apache Maven 3+ * Node 4+ * Gnu Make * Unix-like シェル環境 (Windows の場合 MSYS や Cygwin で可能)

このチュートリアルは Node をインストールしたことがないレベルの初心者でも始められる。逆に説明が冗長だと感じたら飛ばしながら進むといい。

0. 段取り

チュートリアルは次の5つのステップに分かれており、サンプルアプリの各コミットに対応する。

  1. ベースとなるサーバアプリを用意する (ab4b918)
  2. フロントエンド開発用のファイルレイアウトとビルドタスクを作る (f985e88)
  3. 自動ビルドタスクを追加して効率化する (948cd78)
  4. 自動ビルドを活用して JavaScript を実装する (132f9a5)
  5. CSS の自動ビルドタスクを作り、CSS を実装する (1711f58)

1. ベースとなるサーバアプリを用意する

対応するコミット: ab4b918

Spring Boot を使った簡単な Todo アプリを用意した。なお、Spring Boot でサーバアプリを実装するところについてはチュートリアルの本目的ではないので省略する。この段階のファイルレイアウトは以下のようになっている。

.
├── README.md
└── todo-app                         ... for sources of a Spring Boot server app
    ├── pom.xml
    └── src
        └── main
            ├── java
            └── resources
                ├── application.yml
                ├── static
                └── templates

Spring Boot は、組み込みサーバを起動してデプロイできる Maven ゴールが設定されている。次のコマンドで http://localhost:8080/ にデプロイされる。

% mvn spring-boot:run

まだ JavaScript は一切使用していない。以降の作業ではこれに JavaScriptCSS を加えてインタラクションと見栄えを整えていく。この段階での動作を確認しておくと以降のイメージがつかみやすいと思う。

2. フロントエンド開発用のファイルレイアウトとビルドタスクを作る

対応するコミット: f985e88

このステップでは、以下のファイルレイアウトになることを目指して作業を進めていく。

.
├── README.md
├── todo-app                         ... for sources of a Spring Boot server app
│  ├── pom.xml
│  └── src
│      └── main
│          ├── java
│          └── resources
│              ├── application.yml
│              ├── static
│              │  └── bundle.js      ... built from todo-client/scripts/main.js
│              └── templates
└── todo-client                      ... for sources of client resources
    ├── Makefile
    ├── package.json
    └── scripts                      ... for sources of a JavaScript to bundle
        └── main.js                  ... entry point of JavaScript

フロントエンド開発に使用するモジュールは、Node パッケージマネージャ npm を使用してインストールしていくのが基本になる。npm は Node に同梱されている。まずサーバアプリ開発用の todo-app ディレクトリと同じ階層に、フロントエンド開発用の todo-client ディレクトリを作成し、そこで npm を初期化しよう。

% mkdir todo-client
% cd todo-client
% npm init

すると対話形式でプロジェクト情報の入力を求められるが、さほど重要ではないし後で修正もできるため、適当に入力する。これが済むと package.json ファイルが作られる。このファイルは、Maven における pom.xml のような、npm にとっての依存パッケージ情報などを管理するためのものだ。

次にこのプロジェクトで必要な npm パッケージをインストールする。モジュールごとに分割した JavaScript をビルドするために使用するツール Browserify は次のコマンドでインストールできる。

% npm install browserify --save-dev

--save-dev オプションをつけると、今インストールしたパッケージ名とバージョンが package.json 内に追記される。類似するオプションとして --save というものもあり、package.json に追記されるフィールドが異なる。使い分け方としては、--save-dev は開発時にのみ必要なツール (Browserify など) を、--save はランタイムに必要なライブラリ (jQuery や Bootstrap など) を指定する。この package.json ファイルがある場所で npm install コマンドを実行すると、package.json にある依存パッケージが自動的にまとめてインストールされるので、他のマシン環境でもコマンドを実行するだけで同じ依存パッケージを簡単に揃えることができる。

npm でインストールしたパッケージは node_moduels ディレクトリ内に格納される。Browserify のような、コマンドラインツールを提供しているパッケージの場合、node_modules/.bin 以下に実行ファイルが配置されるので、次のように Browserify を起動してみよう。

% node_modules/.bin/browserify -h

ヘルプが表示されるはずだ。さっそく JavaScript ファイルを作って Browserify で変換してみよう。

% mkdir scripts
% echo 'console.log("hi")' > scripts/main.js
% node_modules/.bin/browserify scripts/main.js

標準出力に表示されたものが変換後の JavaScript だ。

今度は npm で jQuery をインストールし、これを読み込むコードを書いてみよう。それを変換し、サーバアプリの静的ファイル置き場に出力してみよう。

% npm install jquery --save
% echo '
       var $ = require("jquery") // (1)
       console.log($().jquery)   // (2)
       ' > scripts/main.js
% mkdir ../todo-app/src/main/resources/static
% node_modules/.bin/browserify scripts/main.js -o ../todo-app/src/main/resources/static/bundle.js

こうしてビルドした bundle.js は、jQuery 本体のコード (1) と console.logjQuery のバージョンを出力するコード (2) が結合された状態で出力される。これをサーバアプリの HTML テンプレート (todo-app/src/main/resources/templates/todos.html) で読み込むよう script タグを追加し、ページをリロードしてみよう。開発者ツールのコンソールに jQuery のバージョンが出力されるはずだ。

これが npm と Browserify による、最も基本的な JavaScript のビルド方法だ。

あとは楽をするために工夫をするフェーズだ。これまで npmmkdirbrowserify などのコマンドを、都度オプションを指定しながら直接実行してきた。これらのコマンド実行の流れを自動化する仕組みを Makefile で作っておくと便利だ。

STATIC_DIR = ../todo-app/src/main/resources/static

all: clean $(STATIC_DIR)/bundle.js

$(STATIC_DIR)/bundle.js: node_modules $(STATIC_DIR)
    @node_modules/.bin/browserify scripts/main.js -o $@

$(STATIC_DIR):
    @mkdir -p $@

clean:
    @rm -rf $(STATIC_DIR)

node_modules: package.json
    @npm install

.PHONY: all clean

開発に途中参加する開発者は、ソースコードと package.json がコミットされたリポジトリをクローンして make さえすれば、他のコマンドを逐一実行しなくても、ビルドが完了するようになる。

なお Make でなくとも、シェルスクリプトやバッチファイル、Java のエコシステムを使いたいというのなら Gradle や Ant でもいい。だが Maven ですべてを統合するというのはやめておこう。Maven との格闘が始まり、決して効率的にならないだろう。frontend-maven-plugin というのもあるが、タスクのカスタマイズが不自由になるのでオススメしない。

3. 自動ビルドタスクを追加して効率化する

対応するコミット: 948cd78

Browserify によって JavaScript をビルドすることはできたが、JavaScript ファイルを編集するたびに browserify を手動で実行し再ビルドするのは面倒だ。そこで今度は、ビルド対象およびビルド対象が読み込んでいるファイルが変更されたら、自動的にビルドしてくれるツール watchify を導入しよう。

% npm install watchify --save-dev

watchify の使い方は Browserify とほぼ同じだ。基本的には自動ビルドするかしないかだけの違いしかない。

% node_modules/.bin/watchify scripts/main.js -o ../todo-app/src/main/resources/static/bundle.js -v -m

追加した -v オプションは、再ビルドの実行結果が標準出力するためのもので、動作状況を確認するために付けておいたほうが便利だ。-d オプションは、変換後のファイルに Source Map をバンドルするためのもので、これによって Chrome デベロッパーツールなどで結合前のファイルが表示できるようになるためデバッグに便利だ。

上のコマンドを実行した状態で、main.js に適当な編集を加えてみよう。自動的に再ビルドされ bundle.js が更新されるはずだ。再ビルドは思ったより早く終わることだろう。Node のストリームのパフォーマンスの素晴らしさに感動しよう。

このコマンドのエイリアスも、Makefile に追加しておこう。

watch-js: node_modules $(STATIC_DIR)
    @node_modules/.bin/watchify scripts/main.js -o $(STATIC_DIR)/bundle.js -v -m

これで make watch-js を実行するだけで、インクリメンタルビルドが開始されるようになった。あとはガシガシ JavaScript を実装しよう。

4. 自動ビルドを活用して JavaScript を実装する

対応するコミット: 132f9a5

このステップでは、以下のファイルレイアウトになることを目指して作業を進めていく。

.
├── README.md
├── todo-app                         ... for sources of a Spring Boot server app
│  ├── pom.xml
│  └── src
│      └── main
│          ├── java
│          └── resources
│              ├── application.yml
│              ├── static
│              │  └── bundle.js      ... built from todo-client/scripts/main.js
│              └── templates
└── todo-client                      ... for sources of client resources
    ├── Makefile
    ├── package.json
    └── scripts                      ... for sources of a JavaScript to bundle
        ├── main.js                  ... entry point of JavaScript
        └── page-specifics           ... for store page-specific scripts

ステップ 3 で効率的にアプリを実装する準備ができたので、あとはひたすら実装する。Todo アプリの、Todo 追加・削除・完了を、AJax でサーバにリクエストするようにしよう。

このステップでのポイントは、ページ固有のスクリプトの実装・管理方法だ。

ページ固有スクリプトは、ページごとに分割して管理したい。最終的なリリースの仕方にはいくつかのパターンがあるが、今回はページ固有スクリプトも含めすべての JavaScript を一つのファイルにバンドルしてリリースすることにする。この場合、表示しているページ固有スクリプトだけが呼び出されるようにし、関係のないページ用スクリプトは呼び出されないようにする必要がある。これをうまくコントロールするためには、ページの body 要素の ID を参照するというアイデアが有効だ。

Todo 一覧ページ用の HTML テンプレート (todos.html) の body 要素には page-todos を付与しよう。

<body id="page-todos">

そして Todo 一覧ページ用スクリプトは scripts/page-specifics/todos.js に作成し、その中で body の ID を参照して処理を呼び出すかどうかを判断するようにしよう。

// todo-client/scripts/page-specifics/todos.js

if (document.body.id === 'page-todos')
  $(init)

function init() {
  // snips
}

これを main.js から読み込むようにする。Todo 一覧ページの他に新たなページが増えても、このように body の ID を参照させる方法で追加していけばいい。なお、ローカルのファイルを require する場合は、引数にファイルの相対パスを書き、.js は省略可能だ。

// todo-client/scripts/main.js

require('./page-specifics/todos')

JavaScript のビルド環境・開発ワークフローについてはひとまずこれで完成だ。Ajax による Todo 操作の実装についてはただの jQuery を使った実装なので、サンプルのソースコードを確認してほしい。

ところで今回のサンプルでは、ページ固有スクリプトしか作成しておらず、共通関数モジュールを定義していない。もし共通関数などをモジュール化する場合には、エクスポートしたい関数・オブジェクトなどを module.exports に代入するようにする。例えば、greet という関数を持つオブジェクトをエクスポートする場合は、次のように記述する。

// utils.js

module.exports = {
  greet: function() {
    console.log('hi')
  }
}

このような module.exports に代入のあるファイルを require で読み込むと、戻り値として代入されたオブジェクトや関数が得られる。よって次のように読み込んで使うことができる。

// main.js

var utils = require('./utils')
utils.greet()

上で作成したページ固有スクリプトのように、module.exports に代入していないファイルを読み込んだ場合は、ファイルの頭から処理が呼び出される動作になる。

この module 変数や require 関数によるモジュール管理は Node で使われている方式であり、CommonJS と呼ばれる。

5. CSS の自動ビルドタスクを作り、CSS を実装する

対応するコミット: 1711f58

JavaScript と同じように、CSS の実装も効率化しよう。

このステップでは、以下のファイルレイアウトになることを目指して作業を進めていく。

.
├── README.md
├── todo-app                         ... for sources of a Spring Boot server app
│  ├── pom.xml
│  └── src
│      └── main
│          ├── java
│          └── resources
│              ├── application.yml
│              ├── static
│              │  ├── bundle.js      ... built from todo-client/scripts/main.js
│              │  └── style.css      ... built from todo-client/styles/main.less
│              └── templates
└── todo-client                      ... for sources of client resources
    ├── Makefile
    ├── package.json
    ├── scripts                      ... for sources of a JavaScript to bundle
    │  ├── main.js                   ... entry point of JavaScript
    │  └── page-specifics            ... for store page-specific scripts
    └── styles                       ... for sources of a CSS to bundle
        ├── main.less                ... entry point of CSS
        └── page-specifics           ... for store page-specific styles

まず CSS をより便利に記述するための Less を導入する。公式ウェブサイトの紹介にあるように、Less は CSS 上で変数や演算、入れ子による記述を可能にしたようなものだ。Less は npm からインストール可能だ。

% npm install less --save-dev

Less は lessc というコマンドラインツールを提供している。lessc に *.less ファイルを指定すると CSS が生成される。

そして Less のビルドも自動ビルド化したい。指定したファイルの変更を検知して、任意のコマンドを実行するパッケージ chokidar-cli あるいは watchf を導入しよう。chokidar-cli は有名だ。一方 watchf は変更されたファイル名をコマンドパートに埋め込むことができる。

% npm install watchf --save-dev

Makefile に Less の手動ビルド・自動ビルドのタスクを追加しよう。

STATIC_DIR = ../todo-app/src/main/resources/static

# 手動ビルド
$(STATIC_DIR)/style.css: node_modules $(STATIC_DIR)
    @node_modules/.bin/lessc styles/main.less $@

# 自動ビルド
watch-less: node_modules $(STATIC_DIR)
    @node_modules/.bin/watchf "styles/**/*.less" -c "node_modules/.bin/lessc styles/main.less $(STATIC_DIR)/style.css"

これで make watch-less を実行すれば、Less が自動ビルドされるようになった。だが、make watch-js も同じような自動ビルドタスクだ。これを別の端末で起動させておくのは面倒だ。

よって、複数のタスクを並列実行する watch タスクも追加しよう。

watch:
    $(MAKE) -j watch-js watch-less

これで make watch すれば JavaScript も Less も自動ビルドされるようになる。他に並列実行したいプロセスが追加されたら、Makefilewatch ターゲットに追加していけばいい。

他に何ができるか

Java アプリとビルドを統合する際は、todo-app および todo-client ディレクトリのある階層に、mvn -f todo-app/pom.xmlmake -C todo-client を実行するタスクを持った親 Makefile を配置すればいい。

より効率的な開発のために、npm でインストールできる他のツールも活用していってほしい。カテゴリごとに便利なライブラリを紹介して終わりにしたい。

ミニファイ

リリースビルド時はミニファイをしたいので、JavaScript については UglifyJS2 を、CSS については less-plugin-clean-css を使うといい。

コード変換

JavaScript のソースに altJS などを使いたければ Browserify 時に追加の変換処理を加えるトランスフォーム (Browserify プラグイン) を使うといい。TypeScript や ECMAScriptCoffeeScript などから JavaScript に変換するものをはじめ、主要なトランスフォームは https://github.com/substack/node-browserify/wiki/list-of-transforms にリストされている。

コードチェック

Makefile にコードチェック (Lint) タスクを設定し、all ターゲットの依存に加えることで、コードチェックエラーがある場合にビルドを失敗させることができる。

コードチェックには ESLint が機能性・使い勝手の両面からオススメだ。

テスト

Makefile にテストタスクを設定し all ターゲットの依存に加えることで、テスト失敗時にビルドを失敗させることができる。

テストランナーは mochatape がシンプルでオススメだ。アサーションライブラリは power-assert が便利だが、セットアップに手こずるようなら Chai がシンプルだ。

次のステップ

クライアント MVC フレームワークや仮想 DOM やサーバサイドレンダリングが待ち受けている。そう考えるとフロントエンドやっぱり結構難しい。ただ、このような技術がどんな場合にも求められるようになるのは当分先だと思うので、それまでのレベルの底上げとしてメンテナブルなフロントエンド開発を浸透させていきたい。