去年(2016年8月頃)、社内で使う電話帳blacklistアプリをIsomorphic Javascriptで作りながらWikiにまとめていた開発話が面白い感じになっていたのでその転載です。
GitLab上で開発していたのでJavascript以外にもGitLab CIの話もあります。
開発話
Reactを使用して楽をしたいので、開発言語はNode.jsを選択。
versionは新しいものを使っていこうと思うので、v6.3.0を使っていきます。
$ mkdir blacklist
$ cd blacklist
$ npm init
$ git init
$ vim .gitignore # https://gist.github.com/pmq20/2887714
全てESで書けるのでisomorphicということになる。
Node.jsの問題はここから使用するツールをいろいろ選択肢ないとなところ...
まずは、定番ぽいものを選択してboilerplateを作成していく。
- サーバサイド => Koa
- フロントエンド => React
- コンパイラ => Babel
- モジュール管理 => Webpack
- ビルド => Gulp(この手のbuildツール使いたくない...)
- テスト => Mocha
ウッ。。。。オオスギ。。。。。
Boilerplate
ES6で書くIsomorphicアプリ入門 - Part1: リソース - Qiita
このリンクからわかるように様々な人が色んなboilerplateを作成していて、しかも割りと複雑で控えめに言って地獄。
今回は必要ないもの多すぎる気がするけど参考に眺めてみると...
- npmコマンドで諸々のことができるようなのでgulpは死亡。
- webpackが開発サーバ建てれるようなのでwebpackの調査
- .babelrcという設定ファイルがあるようなのでbabelの調査
- eslint使うの良さそう
いろいろ大変そうだが以下の記事が最高だと感じたので全面的に参考にしていく。
Building a boilerplate for a Koa, Redux, React application including Webpack, Mocha and SASS
setup server-side (koa)
$ npm install --save koa
とりあえず一番シンプルなKoaを書いてみます。
./server.js
var koa = require('koa');
var app = koa();
app.use(function *(){
this.body = 'Hello from koajs';
});
app.listen(3000);
Koaはシンプルな機能のみを提供していて、必要な機能はmiddlewareを追加していくアーキテクチャになっているので、APIサーバを書く予定なのでまずはrouterを追加する。
$ npm install --save koa-route
($ npm install --save koa-cors)
Koa単体だと、Cross Origin Resource Sharing (CORS)の対策がないのでAPIサーバを実装する場合には入れたほうが良い。
テンプレートエンジンと静的ファイルも使いたいので、必要なパッケージを追加します。
(参考: koa入門 - from scratch)
$ npm install --save koa-static co-views
これらのmiddle wareを使ってserver.jsを書きなおします。
var koa = require('koa');
var route = require('koa-route');
var serve = require('koa-static');
var views = require('co-views');
var app = koa();
// View
var render = views(__dirname + '/views');
app.use(route.get('/', function *(){
this.body = yield render('index.html');
}));
// API
// Static
app.use(serve(__dirname + '/app/dist'));
app.listen(3000);
setup front-end (babel/webpack/react)
ディレクトリ構成は以下のようにする方針で行きます。
.
├── app
│ ├── dist
│ │ └── bundle.js
│ └── src
│ └── main.js
├── package.json
├── server.js
├── views
│ └── index.html
└── webpack.config.js
まずWebpackの準備をします。
$ npm install --save webpack
$ npm install --save-dev webpack-dev-server
最初のwebpack.config.jsを書いてみます。
module.exports = {
entry: [
'./app/src/main.js'
],
output: {
path: __dirname + '/app/dist',
publicPath: '/',
filename: 'bundle.js'
},
devServer: {
contentBase: './app/dist'
}
};
publicPathは、この記事によると、
publicPath は webpack-dev-server で自動コンパイルするために必要(URLにおけるJSファイルへのパスを書く)
ということなので、index.htmlから参照するbundle.jsのディレクトリを指定します。
Webpackを実行してみます。
$ ./node_modules/.bin/webpack
Hash: 448a36d28f029188e4ee
Version: webpack 1.13.1
Time: 51ms
Asset Size Chunks Chunk Names
bundle.js 1.51 kB 0 [emitted] main
[0] multi main 28 bytes {0} [built]
[1] ./app/src/main.js 0 bytes {0} [built]
bundle.jsが誕生しました。package.jsonのscriptsにwebpackビルドのコマンドを追加します。
"scripts": {
"webserver": "node server.js",
"build": "./node_modules/.bin/webpack",
"dev": "./node_modules/.bin/webpack-dev-server"
},
これで、"npm run build"や"npm run dev"でwebpackビルドやdev-serverの起動ができます。
さぁ、ここからが本番です。Let's yak shaving!
Webpackにbabelビルドを追加します。ついでにdevServerにHotRoadも追加します。
var webpack = require('webpack');
module.exports = {
entry: [
'./app/src/main.js'
],
module: {
loaders: [{
test: /\.jsx?$/,
exclude: /node_modules/,
loader: 'babel'
}]
},
resolve: {
extensions: ['', '.js', '.jsx']
},
output: {
path: __dirname + '/app/dist',
publicPath: '/',
filename: 'bundle.js'
},
devServer: {
contentBase: './app/dist',
historyApiFallback: true,
hot: true,
inline: true,
progress: true
},
plugins: [
new webpack.HotModuleReplacementPlugin()
]
};
babelビルドのためにbabel-loaderをインストールします。
$ npm install --save babel-loader
Webpack固有の記法がじゃんじゃんでてきてウッってなりますが、webpack.config.jsの読み方、書き方 - dackdive's blogなどの有用な日本語記事を呼んで気を安らげると良いです。
dev-serverでhotリロードするにはdistにhtmlが配置されてる必要があるようです(参考: webpack-dev-serverの基本的な使い方とポイント - dackdive's blog)
Koaのテンプレートとしてhtmlを扱っていたので、Koa+Webpack+Hotという構成を取る...?という流れになります。
そしてさっと探すだけでそういうものが見つかります。
glenjamin/webpack-hot-middleware: Webpack hot reloading you can attach to your own server
見てみると、serverサイドのコードにhotリロードのためだけに手を入れる必要がでてきます。
デプロイするときは無効にするようにwebpackでやったりするのかなぁ...
ウッ...オ、、、オッ、、、オゥエェェェェ
危険を察知したなので使うのをやめます。
KoaとWebpackを組み合わせるのはisomorphicとは言え面倒さがある気がします。
サーバサイドとフロントエンドで同じ言語を使えますが、その壁を綺麗さっぱりぶち壊すことはできません。
あらためて方針を決めていきます。
- hotリロードは便利なので使う
- htmlはSPA的(KoaのAPIを叩く)に使うことにして開発時はwebpack-dev-serverを使う
- Koaで作るAPIも動かす必要があるので、開発時はKoaサーバとwebpack-dev-serverの両方を起動した状態で開発
- デプロイ時は、Koaでapp/dist/index.htmlをViewとして使うがテンプレートとしては全く使用しない(ただ表示するのみ)
開発中に2つサーバを動かすことを妥協点としてやっていくことにします。
(正直この辺の選択は難しいのと個人差がありそうなので、皆さんがどうしてるか知りたいところ...)
ここで今までのことの60,70%くらいが水の泡と化したので改めて検討し直します。
方針を決める
フロントエンドのほうはそのままwebpackで行きます。
サーバサイドはAPIサーバとindex.htmlを単に表示して、jsとcssをstaticファイルとして配信することができればokです。
本番環境ではフロントエンドをWebpackでビルドして通ったらKoaサーバを動かして完了。
開発環境ではwebpack-dev-serverでフロントエンド開発、KoaサーバでAPI開発をします。(一応Koaサーバのindex.htmlで本番環境と同等の状態で動かせますがHotリロードはない)
setup server-side (Koa)
koa-staticとkoa-routeを使ってSPAとAPIを同じプロジェクト内に作ります。
var koa = require('koa');
var route = require('koa-route');
var serve = require('koa-static');
var app = koa();
// View
app.use(serve(__dirname + '/app/dist'));
// API
// app.use(route.get('/api/xxx', function *(next) {
// }));
app.listen(3000);
setup front-end (webpack, babel, react)
reactを使う方向でディレクトリ構成を改めて決めていきます。
.
├── app
│ ├── dist
│ │ ├── bundle.js
│ │ └── index.html
│ └── src
│ ├── components
│ │ └── App.jsx
│ └── index.jsx
├── package.json
├── server.js
└── webpack.config.js
webpackを改めて書き直します。
var webpack = require('webpack');
module.exports = {
entry: [
'./app/src/index'
],
module: {
loaders: [{
test: /\.jsx?$/,
exclude: /node_modules/,
loaders: ['babel']
}]
},
resolve: {
extensions: ['', '.js', '.jsx']
},
output: {
path: __dirname + '/app/dist',
publicPath: '/',
filename: 'bundle.js'
},
devServer: {
contentBase: './app/dist',
historyApiFallback: true,
hot: true,
inline: true,
progress: true
},
plugins: [
new webpack.HotModuleReplacementPlugin()
]
};
reactを使っていくので必要なものをインストールします。
$ npm install --save react react-dom
babelでコンパイルするのですが、babelをreactとimportに対応させるためにパッケージと.babelrcが必要です(しばらくハマった...)
$ npm install --save-dev babel-preset-es2015 babel-preset-react
.babelrc
{
"presets": ["react", "es2015"]
}
これで"npm run dev"でhot-reloadなwebpack-dev-server(:8080)でフロントエンド開発ができます。
最後に、ブラウザでのdebug用にsource-mapの追加と、pathが相対パスなのを修正します。
var path = require('path');
var webpack = require('webpack');
module.exports = {
devtool: 'source-map',
entry: [
path.resolve(__dirname, 'app/src/index')
],
module: {
loaders: [{
test: /\.jsx?$/,
exclude: /node_modules/,
loaders: ['babel']
}]
},
resolve: {
extensions: ['', '.js', '.jsx']
},
output: {
path: path.resolve(__dirname, 'app/dist'),
publicPath: '/',
filename: 'bundle.js'
},
devServer: {
contentBase: path.resolve(__dirname, 'app/dist'),
historyApiFallback: true,
hot: true,
inline: true,
progress: true
},
plugins: [
new webpack.HotModuleReplacementPlugin()
]
};
setup test/lint
mochaとeslintのセットアップをしていきます。
eslint
eslintをdevにインストールして、initします。
$ npm install --save-dev eslint
$ ./node_modules/.bin/eslint --init
? How would you like to configure ESLint? Answer questions about your style
? Are you using ECMAScript 6 features? Yes
? Are you using ES6 modules? Yes
? Where will your code run? Browser
? Do you use CommonJS? No
? Do you use JSX? Yes
? Do you use React Yes
? What style of indentation do you use? Spaces
? What quotes do you use for strings? Single
? What line endings do you use? Unix
? Do you require semicolons? Yes
? What format do you want your config file to be in? JavaScript
Installing eslint-plugin-react
blacklist@1.0.0
└─┬ eslint-plugin-react@6.1.2
└── jsx-ast-utils@1.3.1
Vimのsyntasticにeslintを使うように書いておくと、
let g:syntastic_javascript_checkers = ['eslint']
今いるディレクトリのeslint.jsを読み込んでくれます。
実はinitで吐いたeslint設定では、jsx内で使ってるはずの変数に対して
error 'App' is defined but never used (no-unused-vars)
になります、これに困ってる方がたくさんいます
- es6jsx: no-unused-vars misses JSX references · Issue #1534 · eslint/eslint
- JSX no-unused-vars · Issue #2054 · eslint/eslint
というわけで、initで生成したeslintの設定を少し変えてあげる必要があります。
eslint-plugin-reactのREADMEにあるように、rulesを追加します。
yannickcr/eslint-plugin-react: React specific linting rules for ESLint
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
}
おすすめルールを全て有効にするようにしてもいいなら
"extends": ["eslint:recommended", "plugin:react/recommended"],
のようにしても良さそうです。なんとなくこっちのほうが良さそうなのでextendsのほうを採用します。
npm run lintで実行できるようにします。
"lint": "./node_modules/.bin/eslint server.js app/src/* --ext .js --ext .jsx",
mocha
babelを噛ましてテストをするのでbabel-coreもインストールします。
$ npm install --save-dev babel-core mocha
assertにはchaiを使っているケースが多いですが、せっかく日本人なのでpower-assertを使おうと思います。
mocha + power-assert環境の構築 - Qiitaを参考にインストール。
npm install --save-dev intelli-espower-loader power-assert
npm run testでテストをするためにpackage.jsonのscriptsにコマンドを作成します。
test: "./node_modules/mocha/bin/mocha --require intelli-espower-loader --recursive"
testディレクトリにserver-test.jsを作成してテストを書いてみます。
mochaはざっと調べると、describeで大枠を作ってその中にitで実際に行うテストを複数定義していく流れぽいです。
mochaでKoaのテストをする方法を調べてみると、APIのテストはsupertestを使うのが良さそうです。
今回はViewは最低限のものを返しているだけなのでテストの必要はない感じです。しかもkoaでのテスト - QiitaによるとViewのテストが大変そうなのでやりたくない感じもあります。
Koajsのexmapleプロジェクトを参考にすると良い感じあります。
examples/test.js at master · koajs/examples
var assert = require('power-assert');
var app = require('../server');
var request = require('supertest').agent(app.listen());
describe('api', () => {
it('hello', (done) => {
request
.get('/api/hello')
.expect(200)
.expect('hello', done);
});
});
サーバサイドのテストはこれでokです。
フロントエンドのテストはbabelでコンパイルする必要があるのでちょっと変わってきます。しかもサーバサイド(nodejs)のコードをbabelコンパイルするとKoaのGenerator周りのコードが盛大にエラーを吐くので別々のテストコマンドでテストするほうが良さそうです。 => Node.js & webpack & babel で「 regeneratorRuntime is not defined」が発生する場合の対処 - Qiita
...
......
.........
いよいよisomorphicが幻想だとわかってきました。というより自分の想像していたisomorphicが大きすぎる解釈だったと気付きました。サーバサイドとフロントエンドは別物です、全てESで書けるとはいえjavascriptとnodejsは違います、しっかり線引して扱うと良いということがわかりました。
boilerplate完成
ゼェ...ゼェ...
やっと記念すべき最初のコミットが完成しました。
最終的なディレクトリ構成
.
├── .babelrc
├── .eslintrc.js
├── .gitignore
├── app
│ ├── dist
│ │ ├── bundle.js
│ │ └── index.html
│ ├── src
│ │ ├── components
│ │ └── index.jsx
│ ├── test
│ │ └── model-test.js
│ └── webpack.config.js
├── package.json
├── server.js
└── test
└── server-test.js
- webpackはフロントエンドしか関係していないのでappに配置
- app内はbabelでコンパイルされるのでimportで依存解決
- serverはnodejsなのでrequireで依存解決
npm runコマンド
"scripts": {
"lint": "./node_modules/.bin/eslint server.js app/src/* --ext .js --ext .jsx",
"build:front": "cd app && ../node_modules/.bin/webpack",
"dev:front": "cd app && ../node_modules/.bin/webpack-dev-server",
"dev:server": "node server.js",
"test:front": "./node_modules/mocha/bin/mocha --compilers js:babel-core/register --require intelli-espower-loader app/test/*.js",
"test:server": "./node_modules/mocha/bin/mocha --require intelli-espower-loader test/*.js"
},
というわけで、本番のアプリ開発にとりかかります。
GitLab CI
dockerビルドを試してみます。
まずはRunnerの設置です。
docs/install/docker.md · master · GitLab.org / gitlab-ci-multi-runner · GitLab
dockerビルドするためにはdockerコマンドを叩く必要があるのでhostの/var/run/docker.sockをマウントする必要があります。
$ docker run -d \
--name blacklist-runner \
--restart always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /srv/blacklist-runner/config:/etc/gitlab-runner \
gitlab/gitlab-runner:latest
$ docker exec -it blacklist-runner gitlab-runner register
executerにdockerを選択して、imageにnode:6.3を指定します。
プロジェクトのほうに.gitlab-ci.ymlを追加します。
image: node:6.3
before_script:
- npm install
stages:
- test
eslint:
stage: test
script:
- npm run lint
mocha-test:
stage: test
script:
- npm run test:server
- npm run test:front
将来的にdokkuへの自動デプロイをしたいのですが、なかなか大変そうです。とりあえずアイデアを書いておきます。
- dokkuへのデプロイのためにはSSH鍵ペアが必要なのでどうにかしないとな感じ
- ssh鍵ペアをrunnerにマウントしてpush
- deployタスクだけimage使わないので工夫必要そう
- masterへのpushのときだけdeploy(only: master)
- stagingもあるとよさ(only: staging)
Koaサーバをコード変更でリロード
node.js - automatically reloading Koa server - Stack Overflow
nodemonで動かす。
server-side (redismongo)
stagingブランチを作成して作っていきます。
redisかなと思っていましたが、やっぱりmongodbを使ってみます。mongo採用の理由は
- 使ったことないから気になっていた
- mongooseを使えばschemaを使えるので可読性良さそう&バリデーションを自分で書かなくて良い
- nodeと組み合わせる情報が多い
- redisのset/getを同期処理させたいけど勉強不足で辛い
- 同期処理(generator)のmongo+koaの例があった => Write "Synchronous" Node.js Code with ES6 Generators
早速書いてみます。
const mongoose = require('mongoose');
const mongoUrl = process.env.MONGO_URL ? process.env.MONGO_URL
: 'localhost/blacklist';
mongoose.connect(mongoUrl);
mongoose.connection.on('error', (err) => {
console.log(err);
});
const Blacklist = mongoose.model( 'blacklist', new mongoose.Schema({
company: { type: String, required: true },
sender: String,
target: String,
date: { type: Date, default: Date.now() },
comment: String,
black: { type: Boolean, default: true }
}));
良い感じします。server.jsにそのまま追記していく方針で行きますので、このままapiも書いてみます。
app.use(route.get('/api/set', function *() {
const params = querystring.parse(this.request.querystring);
let blacklist = new Blacklist();
blacklist.company = params.company ? params.company : null;
blacklist.sender = params.sender ? params.sender : null;
blacklist.target = params.target ? params.target : null;
blacklist.comment = params.comment ? params.comment : null;
blacklist.black = params.black ? params.black : true;
try {
yield blacklist.save();
} catch (e) {
this.body = { ok: false, response: e.message };
return;
}
this.body = { ok: true, response: JSON.stringify(blacklist) };
}));
app.use(route.get('/api/get', function *() {
const params = querystring.parse(this.request.querystring);
this.body = yield Blacklist.find(params);
}));
app.use(route.get('/api/update', function *() {
let params = querystring.parse(this.request.querystring);
if (!('_id' in params)) {
this.body = { ok: false };
return;
}
const query = { _id: params._id };
delete params._id;
params.date = Date.now();
let blacklist = null;
try {
blacklist = yield Blacklist.findOneAndUpdate(query, params);
} catch (e) {
this.body = { ok: false, response: e.message };
return;
}
this.body = { ok: true, response: JSON.stringify(blacklist) };
}));
app.use(route.get('/api/delete', function *() {
const params = querystring.parse(this.request.querystring);
const query = { _id: params._id};
const res = yield Blacklist.remove(query);
this.body = { ok: Boolean(res.result.ok), response: res.result.n }; // res.result.nは消えた数
}));
- APIはgetでAPIを生やすことにしました(openなweb apiで良いと思っているので外から叩きやすいから)
- Koaのresquestパラメータについてはkoa/request.md at master · koajs/koaを参考にしました
- Koaはobjectをthis.bodyに渡すと普通にjsonを返します(koaでJSON返させるシンプルで唯一の記述 - Qiita)
- paramsのparseはQuery String | Node.js v6.4.0 Documentationを使いました
- save()するとバリデーションエラーで例外が発生する可能性があるのでtry-catchしました
- update/deleteは基本的にblacklistのjsonをquerystringとして受け取って処理するように書いています
- update/deleteは_idを元にして削除することにしました
- updateはMongoose API v4.5.9の関数を使用
あと普通にmongooseを使っていると
(node:52888) DeprecationWarning: Mongoose: mpromise (mongoose's default promise library) is deprecated, plug in your own promise library instead: http://mongoosejs.com/docs/promises.html
というエラーが出ます。mongoose標準のPromiseが古いので比較的新しいNodejsを使っている場合はNodejsのPromiseに置き換えてやります。(Mongoose Promises v4.5.9)
mongoose.Promise = global.Promise;
そんなこんなで、テストも一通り書いて実装が完成しました。
.gitlab-ciの改善
サーバサイド実装のコミットでCIが死にました。mongoがないのが原因です。
デプロイをしたいのもあるのでもう少し凝ったCIに書き直します。
GitLab Documentationによると、docker image/serviceはjobごとに設定できます。デプロイはdocker containerではなくrunner上でやるほうが良さそうなので明示的にcontainerとrunnerでのjobを分けます。
.........
試してみましたが、imageを一度指定してしまうと全てのjobに対してimageが適用される模様...
front-end開発
Getting Startsする。
$ npm install --save react-tap-event-plugin material-ui
そろそろまとめるのが面倒になってきたのでリンクと何を勉強したかとまとめていきます。
Material UI関連
ここにはComponentの全てが詰まっています。
iconはこちら。namespaceは小文字、空白は"-"で繋いでsvg形式でimportできます。
(例)
import PhoneMissed from 'material-ui/svg-icons/notification/phone-missed';
- [FlatButton] File input not working with button. · Issue #647 · callemall/material-ui
- の話
- IE10で動くHTML5アプリ実装例 「File APIを利用したアプリ」 (3/3):CodeZine(コードジン)
type=inputをMaterial UIで扱う方法が謎なのと、そもそもFile APIなんて使ったことなかったのでその情報です。
結果的に以下のようなコードになりました。
handleCsvUpload() {
let fileUploadDom = ReactDOM.findDOMNode(this.refs.fileUpload);
fileUploadDom.click();
}
handleVirtualClickCsvUpload(e) {
var reader = new FileReader();
reader.onload = () => {
const csv = reader.result;
// TODO: Server: web api未実装
};
reader.readAsText(e.target.files[0], 'UTF-8');
}
// ...
<FlatButton
label='CSV import'
labelPosition='after'
icon={<ImportExport />}
onTouchTap={this.handleCsvUpload}
/>
<input
ref='fileUpload'
type='file'
style={{'display' : 'none'}}
accept={'text/csv'}
onChange={this.handleChange}
/>
GUI設計
- Redux入門 6日目 ReduxとReactの連携(公式ドキュメント和訳) - Qiita
- 10分で実装するFlux
- Flux | Application Architecture for Building User Interfaces
フレームワークは使いませんが参考にはしました。
Show comments