cybozu.com 稼働状況 を React/Redux で作り直した話

こんにちは。Sales Systemチームの金子です。Sales Systemチームでは、cybozu.com Store や、販売管理システム等の開発をしています。

このエントリでは、cybozu.com 稼働状況のフロントエンドをReact/Reduxで作り直した話を書いていきます。「React/ReduxでWebアプリケーションを作ってみようと考えている人」を対象としています。

TOC

  • 「cybozu.com 稼働状況」とは?
  • 作り直した背景
  • 技術概要

    • React/Fluxについて
    • React/Redux
    • Routing
    • Resources
    • Async
    • Multilingualization/Localization
    • ES6
    • Utility
    • Lint
    • Testing
  • 取り組んでみた感想

  • まとめ

「cybozu.com 稼働状況」とは?

クラウドサービスはサービスの稼働状況をステータスダッシュボード形式で提供するのが一般的です。 cybozu.com 稼働状況は、弊社が提供しているcybozu.comで過去30日間で発生した障害内容を表示するサービスです。

記事公開時点ではReact/Reduxで置き換えたcybozu.com稼働状況の運用は始まっておらず、運用開始までに画面イメージを含む仕様変更を行う可能性があります。リリース日は記事公開時点では確定しておりません。

作り直した背景

当初、cybozu.com 稼働状況はインフラチームで開発していました。しかし、インフラチームにはWebアプリのUI開発が得意なメンバーは少なく、また本業であるクリティカルなインフラ運用業務も抱えているため、ビジネスサイドからのUI改善要求にはなかなか対応できませんでした。

そこで、インフラチームは障害情報を提供するサーバAPIのみを担当し、フロントエンド部分はUI開発に慣れているアプリ開発チームが担当するという分担でcybozu.com 稼働状況を再構築することになり、フロントエンド担当としてkintoneチームの天野(@ama_ch)、Sales Systemの私、の二人に話が来ました。

技術概要

弊社は、Google Closure Library/Compilerのイメージが強いと思われますが、今回のような規模のそれほど大きくならないと予想されるアプリケーションにおいては、Google Closure Toolsは少々大げさに感じていました。また、Closure Libraryよりも後発のデータバイディング機能をもつフレームワークのUI開発効率にも魅力を感じていました。

cybozu.com は3か国でサービスを提供しています。そのため、cybozu.com 稼働状況も同様に3か国で提供する必要があります。また、作り直した背景にも書いたように、UI変更に柔軟に対応できる作りにしたいというのがありました。まとめると、cybozu.com 稼働状況フロントエンド部分への要件は以下のようになります。

  • 同じサービスに3種類(3か国)のUIを作成
  • 低コストでUIの変更ができる
  • ルーティングやサーバからのデータ取得をフロントエンドで完結
  • Closure Libraryほどではないが、ある程度の堅さ

そこで、天野がkintoneチームで導入していたReactと、Fluxフレームワークをメインに、npmエコシステムを使って開発しようということになりました。

React/Fluxについて

React/Fluxについては、様々な記事が書かれているので詳細は書きませんが特徴をいくつか挙げます。

  • Viewに相当するComponentと呼ばれるUIパーツ
    • 親Componentから渡されたpropsをもとにrenderingを行う
    • 関心事は基本renderingのみ
    • stateless(であるべき)
    • JSXによる宣言的なDOMの木構造定義
  • Flux architectureに基づくone-way data flow
    • UIとユーザインタラクションの関心事を分離
    • 状態遷移をさせるロジックを局所化し、データの流れを追いやすくする
  • one-way data flowのシンプルさに伴うrenderingコストを改善するためのVirtual DOMアルゴリズム
  • 関数型プログラミングに強い影響を受けている

React/Redux

React/Reduxをメインのフレームワークに採用しました。React/Reduxを採用した理由は以下の通りです。

  • JSXによりViewレイヤーにロジックが入らず、UI変更コストがマークアップ並に抑えられる
  • 簡単にルーティングを含むシングルページアプリケーションが実装できる
  • Reactコンポーネントをうまく差し替えれば少ない工数でUIを大幅に変えることが可能
  • pure Fluxより手軽に乗れるフレームワークがほしかった
  • 各所で評判が良い
  • loggerなど周辺ツールが充実してる
"dependencies": {
  "react": "^0.13.3",
  "react-redux": "^3.0.0",
  "redux": "^3.0.0"
}

React 0.14からはライブラリ構成が変わっているので、少し注意が必要です。

Routing

作り直す前は、サーバで処理したテンプレートを返すものでしたが、SPA(シングルページアプリケーション)で作り直すことにしました。以下のライブラリを使いました。

"dependencies": {
  "history": "^1.11.1",
  "react-router": "^1.0.0-rc1"
}

これらを使うと、React component等を用いて、数行でBrowser history APIを操作することができます。 こんなイメージになります。

index.js

const history = createBrowserHistory();
const store = createStore(dashboard);

React.render(
  <Provider store={store}>
    {() =>
      <Router history={history}>
          <Route component={App} path="/">
          <Route component={App} path="/status/" />
          <Route component={App} path="/status/:subdomain" />
        </Route>
      </Router>
    }
  </Provider>,
  document.body
);

App.js

class App extends Component {
  handleSubmit(evt) {
    evt.preventDefault();
    const { subdomain } = this.props;
    this.context.history.pushState(null, `/status/${subdomain}`);
  }
  
  render() {
    return (
      // blah
    );
  }
}

App.contextTypes = {
  history: RouterPropTypes.history
};

Resources

cybozu.com 稼働状況では、ユーザが入力したサブドメインをBrowserに記憶させておき、次回アクセス時にはその値を使用してステータス状況を表示するという仕組みになっています。作り直す前はCookieを使用していましたが、localStorageを使うように変更しました。使ったライブラリは次のようになります。

"dependencies": {
  "redux-localstorage": "^1.0.0-rc4",
  "redux-localstorage-filter": "^0.1.1"
}

redux-localstorageを使うと、StoreのstateとlocalStorageのデータを同期することができます。localStorageによらず、redux-localstorageが定義するstorageのインタフェースを満たしていれば、理論上はなんでも同期できます。詳細は、adaptersのsrcを参照してみてください。

stateのすべてのプロパティをlocalStorageと同期したいわけではないので、redux-localstorage-filterを用いて、同期するプロパティをフィルタリングしています。

// state: dashboardのうち、subdomainプロパティだけlocal storageと同期する
const storage = compose(
  filter(['dashboard.subdomain'])
)(adapter(window.localStorage));

const createPersistentStore = compose(
  persistState(storage, "someKey")
)(createStore);

const store = createPersistentStore(rootReducer, { dashborad: initialState });

これらのライブラリを使用するうえでの注意点は、redux-localstorageのversionです。1.0.0-rc4を採用しました。これは、redux-localstorage-filterを使いたかったからです。

Async

XHRでAPIサーバから稼働状況やお知らせを取得します。Fetch APIでXHRを実現したかったので、以下のライブラリを採用しました。

"dependencies": {
  "babel-core": "^5.8.25",
  "isomorphic-fetch": "^2.1.1",
  "redux-thunk": "^1.0.0"
}

isomorphic-fetchはFetch APIのpolyfillです。

FacebookのFluxでは、action creatorsがdispatchまでやります。

対して、Reduxではaction creatorsはpure functionsで単純にactionを返すものです。これをstore.dispatchでstoreにdispatchするのがReduxのお作法になります。

Actions | Redux

Reduxでは、middlewareという、store.dispatchをWrapした関数を作れる仕組みがあります。 redux-thunkではこの仕組みを用いて、「非同期処理を行い、結果を元にaction creators関数を呼び出す」関数をstore.dispatchでWrapした関数を作ることができます。 あとは、アプリケーションの初期化時のReact Lifecycle メソッドや、ユーザインタラクションイベントハンドラ等で、作った関数を呼び出します。 実装は、Redux documents/Async Actionsを参考に、Promiseのエラー処理を少し追加しました。

Multilingualization/Localization

cybozu.com 稼働状況には、

  • 日本以外にも米国で同様のcybozu.com 稼働状況サービスを提供(今後は中国も対応予定)
  • 米国のcybozu.com 稼働状況はUIがかなり違う
  • 文言を切り替えるだけじゃなくてコンポーネントレベルで切り替える必要がある

といった要件があります。

f:id:cybozuinsideout:20151028114947p:plain
日本リージョンにおける日本語表示の cybozu.com 稼働状況
f:id:cybozuinsideout:20151028114957p:plain
米国リージョンにおける日本語表示の kintone.com 稼働状況

多言語化対応とロケールを考慮したdatetime操作のために以下のライブラリを導入しました。

"dependencies": {
  "i18next-client": "^1.10.2",
  "moment": "^2.10.6"
}

社内事情ですが、cybozu.com 稼働状況では、日米でステークホルダーが異なります。例えば、米国の担当者から 「フッターのこの部分を米国では変えたいんだけど。」 といった要望が来ることがあります。これに柔軟に対応するために、日米間で異なるComponentを提供し、それぞれのComponentをWrapしたComponentにリージョン情報を渡すことでレンダリングするComponentを変えるように実装しました。米国の画面修正対応が日本の画面に影響を与えないようになるので、Component単位でのTestが楽になります。

JP,US,CNでFooterを出しわけるコードはこのような感じになります。

class RegionalComponent extends Component {
  render() {
    const { regions } = this.props;
    const region = getRegion();
    const el = regions[region];
    invariant(el, 'Component for %s is required.', region);
    return el;
  }
}

RegionalComponent.propTypes = {
  // regionsに各リージョンごとのComponentを受け取る
  regions: PropTypes.shape({
    JP: PropTypes.element.isRequired,
    US: PropTypes.element.isRequired,
    CN: PropTypes.element.isRequired
  }).isRequired
};

class JPFooter extends Component {
  render() {
    return (
      // blah
    );
  }
}

// US,CNも同様

export default class Footer extends Component {
  render() {
    const regions = {
      JP: <JPFooter/>,
      US: <USFooter/>,
      CN: <CNFooter/>
    };
    return (
      <RegionalComponent regions={regions} />
    );
  }
}

ES6

Reactでは、babelを使ったES6開発が当たり前になっていて、Reactを勉強すると、ES6の勉強にもなります。 今回のプロジェクトでよく使ったES6 syntaxは以下のようになります。

  • アロー関数(arrow function)
  • クラスと継承(class, extends)
  • let/const
  • 関数のデフォルトパラメータ(default parameter)
  • スプレッドオペレータ(spread operator)
  • 分割代入(destructuring)

特に、propsや(store.dispatchでWrapした)actionをRoot Componentから下位Componentに渡していく際に、少ない記述で表現できる、スプレッドオペレータ・分割代入にはとてもお世話になりました。

class App extends Component {
  render() {
    // destructuring
    const { announcements, inputSubdomain, status } = this.props.dashboard;
    const props = {
      announcements,
      inputSubdomain,
      status
    };
    return (
      {/* spread operator */}
      <Contents {...props} />
    );
  }
}

Utility

React/Redux開発で、stateやactionのpayload情報を追いかけたいときは、redux-loggerが便利です。開発モードフラグを設定すると、 actionの前後のstateやactionに載っているpayloadの情報をBrowser consoleに出力してくれます。

f:id:cybozuinsideout:20151028215326p:plain
redux-logger

また、Reactでは関数型プログラミングが推奨されています。Actionによるstateの変更時には、

をオブジェクトのコピーに使いました。

export default function dashboardReducer(state = {}, action) {
  switch (action.type) {
  case ActionTypes.CHANGE_INPUT_SUBDOMAIN:
    const inputSubdomain = action.payload.subdomain.trim();
    return Object.assign({}, state, {
      inputSubdomain
    });
  default:
    return state;
  }
}

Lint

チーム開発をやる上で、主にレビュー工数削減のために、

  • coding styleを統一したい
  • 静的解析で見つけられるBugやcode smellは取り除きたい

ということがありました。また、天野の経験からもあとから静的解析ツールを入れることは初期時の導入に比べてコストがかかるということがあったため、開発開始段階からESLintを導入しました。

"devDependencies": {
  "eslint": "^1.5.1",
  "eslint-plugin-react": "^3.4.2"
}

最初から最もきついRuleで運用し、Ruleの適用方法を変えたくなったら都度相談するという運用でやりました。

ESLintは先人の知恵がRuleに詰まっていて、

  • JavaScriptになれない開発者がつまづきそうなポイントが抑えられている
  • 関数で受け取った変数に副作用がないような書き方もある程度強制できる
  • ES6の書き方も強制できる

といった部分が魅力的でした。

例えば、reducerで前のstateを変更するようなコードがあるとします。

function someReducer(state = initialState, action) {
  switch (action.type) {
  case ActionTypes.INIT:
    // 引数のstateを変更
    state.foo = 'reassigned';
    return state;
  default:
    return state;
  }
}

.eslintrc に次のような設定を書きます。

{
  "rules": {
    "no-param-reassign": 2
  }
}

対象ソースにESLintにかけるとRule Disallow Reassignment of Function Parameters (no-param-reassign) に、次のように怒られます。

$ eslint path/to/src --config path/to/.eslintrc
/path/to/somereducer.js
  10:5  error  Assignment to function parameter 'state'  no-param-reassign

✖ 1 problem (1 error, 0 warnings)

ESLint Rules

eslint-plugin-reactを入れるとReact関連のLintもできます。

nodejsのLintにも使われているようです。

開発当初(9月下旬)は、npm@3で運用していたのですが、ESLint関連の依存解決がうまくいかなかったので、npm@2で運用しています。

Testing

テストにも、ES6を採用しました。主なライブラリの構成は以下のようになりました。

"devDependencies": {
  "espower-babel": "^3.3.0",
  "jsdom": "^6.5.1",
  "mocha": "^2.3.3",
  "mocha-jsdom": "^1.0.0",
  "power-assert": "^1.0.1"
}

reducerのTestはこんな感じで書きます。

describe('dashboard reducer', () => {
  jsdomReact();
  
  it('サブドメインが変更されたら、変更されたサブドメインを返す', () => {
    const subdomain = 'yusya';
    const action = {
      type: ActionTypes.CHANGED_INPUT_SUBDOMAIN,
      payload: { subdomain }
    };
    
    const actual = someReducer(initialState, action);
    
    assert(actual.inputSubdomain === 'not-yusya');
  });
});

これをmochaで動かすと次のようなテスト失敗結果が得られます。

$ mocha --compilers js:espower-babel/guess test/**/*.js

  1) dashboard reducer サブドメインが変更されたら、変更されたサブドメインを返す:

      AssertionError:   # test/reducers/dashboard.spec.js:24

  assert(actual.inputSubdomain === 'not-yusya')
         |      |              |
         |      "yusya"        false
         Object{announcements:#Array#,changed:true,inputSubdomain:"yusya",notFound:false,status:#Object#,subdomain:""}

  --- [string] 'not-yusya'
  +++ [string] actual.inputSubdomain
  @@ -1,8 +1,4 @@
  -not-
   yusy


      + expected - actual

      -false
      +true

      at decoratedAssert (node_modules/power-assert/node_modules/empower/lib/decorate.js:42:30)
      at powerAssert (node_modules/power-assert/node_modules/empower/index.js:58:32)
      at Context.<anonymous> (test/reducers/dashboard.spec.js:24:5)

Sales Systemチームでは、サーバサイドのJavaのTestで、Spockを使っているため、power-assert

  • "No API is the best API." の思想
  • わかりやすいレポート

などexpectにはないメリットが個人的には良かったです。

また、power-assert + ES6 でTestを書く上で、espower-babel のおかげで動かすまでが楽でした。

取り組んでみた感想

今回は作り直しということで技術選定に特にしがらみがなく、解決したい問題に対して使いたいものを使えたことは良かったです。

kintoneチームでも天野が主導して一部にReactを導入していますが、Google Closure Libraryとどのように組み合わせると効果的かという部分に試行錯誤しているようです。

そんな中で、小さいプロジェクトではありますが、React/Reduxの導入事例を社内に作れたのは良かったと思っています。 React/Fluxで実装すると、DOM操作をする部分が全くなくなって、どこに何を書いているのかが見通しがよくなるなということを実感できました。 また、React/Fluxではドキュメント等に、「どこに何を書くべきか?」といった指針が豊富に示されていて、チームでコードレビューをする際も議論がしやすかったです。

しいて不安な部分を挙げるとすれば、React/Reduxをもっと大きなプロジェクトに採用する際は、Storeのstateの構成がかなり大きい一枚のJSONになることが予想され、うまくそれを管理できるかが課題になりそうです。

まとめ

長くなってしまいましたが、cybozu.com 稼働状況を React/Redux で作り直した話を技術要素を中心に述べました。

また、課題となっていた、「インフラチームがクリティカルなインフラ業務と並行して、ビジネスサイドからのUI改善要望に対応できない問題」に関しては、UIとAPIサーバに分け、文言等をフロントで持つように実装することで、画面変更への対応にインフラ側への影響なく、デプロイできるようになりました。また、インフラチームの負担が減った分アプリチームの負担は増えましたが、適切なチームが適切な部分を担当することにより、会社全体としての対応コストは下がるのではないかと期待しています。

おわりに

Cybozu, Incでは、Reactでフロントエンド開発やりたいひとを募集しています。

cybozu.co.jp

#     #                                                                      ### 
#  #  # ######      ##   #####  ######    #    # # #####  # #    #  ####     ### 
#  #  # #          #  #  #    # #         #    # # #    # # ##   # #    #    ### 
#  #  # #####     #    # #    # #####     ###### # #    # # # #  # #          #  
#  #  # #         ###### #####  #         #    # # #####  # #  # # #  ###        
#  #  # #         #    # #   #  #         #    # # #   #  # #   ## #    #    ### 
 ## ##  ######    #    # #    # ######    #    # # #    # # #    #  ####     ###