Isomorphic Javascript 開発話 in 2016 (Koa + React + Material-UI)

去年(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を作成していて、しかも割りと複雑で控えめに言って地獄。

dozoisch/koa-react-full-example: Full example using Koa, React, Passport, Mongoose, Webpack, Mocha, Babel

今回は必要ないもの多すぎる気がするけど参考に眺めてみると...

  • 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)

になります、これに困ってる方がたくさんいます

というわけで、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は消えた数
}));

あと普通に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開発

Prerequisites - Material-UI

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';

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設計

フレームワークは使いませんが参考にはしました。

Show comments

Adsense

Share

  • このエントリーをはてなブックマークに追加

About

どこにでもいる平凡なプログラムを書く人間のログ。

Tags

-->