クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

メンテ不能になったフロントエンド環境を立て直す話

エンジニアの @suusan2go です。2017年の10月まではクラウドワークスに社員として参画していましたが、現在はフリーランスのエンジニアとして、主にフロントエンド環境の改善・支援を行ったり、ちょっとだけRailsのアップグレードを手伝ったりしています。

Railsのアップグレードを手伝っている様子

今回は、わかるエンジニアがいなくなり無人化してしまいメンテナンス不能になったフロントエンド環境を立て直す話です。

クラウドワークスのフロントエンド事情

クラウドワークスは最初のコミットが2011年です。当然その頃はVue.jsやReactなんていうものは登場もしておらず、2015年ころまではSprocketsを使ったアセットパイプライン、CoffeeScriptjQuery によりフロントエンド環境は記述されていました。

f:id:suzan2go:20190527111914p:plain
当時のCTOによる記念すべき最初のコミット

しかしながら、2015年の頃にはよく言われるように、大きくなったアプリケーションでjQueryで複雑な画面を記述して動作を変更するのは大変に辛い状況になっており、そのタイミングでReactやVue.js、ES6、Babelといったキーワードがバズりはじめたということもあって、当時在籍していたエンジニアによりSprocketsとは別のフロントエンドビルド環境が誕生しました。

2015年夏 frontend ディレクトリの誕生

Railsアプリケーションと同じリポジトリの中に frontend/ というディレクトリを切る形で、ES6(ES2015)・Vue.jsを記述するためのフロントエンドビルド環境が誕生しました。技術構成としては以下のようになっています。

  • gulp
  • Browserify
  • Babel (Babelify)
  • Vue.js (0.12.10)
├── app # Railsのコード
│   ├── assets
│   │   ├── javascripts
├── package.json
├── gulpfile.coffee
├── frontend # 新フロントエンド環境
│   ├── javascripts
│   │   ├── src
│   │   └── build

frontend/ ディレクトリでビルドされたJSをSprocketsのアセットパイプラインに組み込んで、Railsから配信するという形をとっていました。当時のRailsプロジェクトとしては割とモダンな構成と言えるのではないでしょうか。 新しく作成されたfrontend/ ディレクトリにより、クラウドワークスの中でも重要な機能がいくつか書き換えられることとなりました。

しかしながら、ES2015やVue.jsを使用するもののみ frontend/ ディレクトリを使うというルールになっていたこと、また後述するクラウドワークス独自のRailsとのインテグレーション機構が作り込まれていたこともあり参入障壁が高くなってしまい、 frontend/ ディレクトリは導入に関わったエンジニア + 数名のみしか触れない環境になってしまいました。

独自のルーティング、Railsからのデータ渡し機構

特にエンジニアを遠ざける原因となってしまったのは Railsとの独自のインテグレーション機構です。Vue.jsを使用する画面はクラウドワークスの中でも10くらいだったのですが、全画面で全てのVue.jsのコンポーネントとルーティング機構(どの画面でどのコンポーネントをrenderするか)がbundleされたJSを読み込んでおり、またRailsからのデータの渡し方も独自の仕組みで行われていました。

//  疑似コード
import Base from './viewmodels/base';
import Dashboard from './viewmodels/dashboard/dashboard';

export default class Router {
  constructor() {
    this.routes = []
    this.route("/user-dashboard", Dashboard);

    this.route("*", Base);
  }

  route(path, vm) {
    this.routes.push(new Route(path, vm));
  }

  exec() {
    var pathName = location.pathname;
    var first_match_route = _.find(this.routes, (route) => { return route.isMatch(pathName); });
    if (first_match_route != undefined) {
      global.CWViewModel = new first_match_route.ViewModel();
    }
  }
}

Vue.jsのコンポーネントは以下のようになっており、RailsレンダリングするERBのbody配下を全てVue.jsのテンプレートとして扱っています。

// viewmodel/sample.vue
import Vue from 'vue';
import MessageThread from '../components/message/thread.vue';

export default Vue.extend({
  data: function() { return CW.getData(); },
  el: function() { return "body"; },
  components: {
    'cw-message-thread': MessageThread,
  },
});

ここで、 CW.getData() しているのが、独自のデータ渡し機構です。詳細は省きますが、以下のJSをERBに書くと、フロントエンド環境で定義されたモデルクラスに値を渡してインスタンスを生成するというような機構になっていました。

  <script>
    CW.assign('some_model', <%= raw @some_model.to_json()) %>);
  </script>

Rails側のテンプレートは以下のようになります。RailsのERBとVue.jsのテンプレートが混ざっており、読むのにかなり脳内メモリが必要になっています。

<input-text inline-template :ay-form-field="hoge.fugas[<%= field.index - 1 %>].piyo">
  <%= text_area_tag "some_textT_area",
    required: 'required',
    class: 'hoge',
    'v-model' => 'text',
    'rows' => '{{rows}}'
    ':rows' => 'rows'
  %>
  <cw-keywords-counter :keywords="hoge.keywords" :text="text"></cw-keywords-counter>
</input-text>

これらの機構はWebComponentsのようなものを志向して導入されたようなのですが、上記のコードを見てもわかるように以下のような問題があったと言えるでしょう。

  • Railsとフロントエンド環境との境目が曖昧でデータのフローがわかりにくい
  • ES2015、Vue.jsと合わせて、これらの独自機構を学ぶ必要があり不要に学習コストが高い

これによりVue.jsに苦手意識を持ってしまったエンジニアも多いです。在籍時はVue.jsは苦手と言っていたエンジニアが退職後にVue.jsいいじゃん!となっている事例を何度も見ました(ちなみに私もその一人です……)。またbody配下を全てVueのテンプレートとして扱ったせいか、あるChromeのバージョンから一時的に画面が真っ白になってしまうという現象が発生しており、chromeのみ画面ロード時に微妙にスクロールさせるという謎のハックが導入されたりしていました。また触る人間も少ないためライブラリのバージョンも古いままとなっており、Vue.jsは1.0のまま塩漬けにされているという状態でした。

エンジニアとしてフォローしておくと、2015年はまだWebpackerも存在しておらず、Vuex、vue-routerといった今ではデファクトとなったツール・ライブラリの登場も未だだったと記憶しています。そんな中、手探りでRailsとモダンフロントエンドの良い関係、Vue.jsでのアプリケーション開発にチャレンジしたことは称賛されるべきだろうと思います。

エンジニアの退職、そして無人化へ

2017年の冬から2018年の夏にかけてfrontend/ 環境を知るエンジニアが退職してしまい、とうとう frontend/ をメンテできるエンジニアはいなくなってしまいました。

私は2018年の春ごろから副業という形でクラウドワークスのMihalyという社内CSS / JSフレームワーク開発環境の整備・サービスへ適用するための技術的な支援を行っていましたが、作成したフレームワークを実サービスへnpmのパッケージとして配布するためには、既存の無人化した frontend/ をどうにかすることは避けられません。そこで社員時代には避けてきた frontend/ ディレクトリを何とかするという仕事を手が空いた時間を見つけては進めることにしたのでした。

f:id:suzan2go:20190527122344p:plain

フロントエンド環境をもう一度メンテ可能な状態にもっていくために

前述したとおり、誰も触れない状態ではあるものの、非常に重要な画面のインタラクションを担っているJSなので、そのまま捨てる事はできません。またメンテ可能な状態にするには少なくとも以下のようなことは実施する必要がありそうです。

  • gulp + browserifyをやめてWebpackに移行する
  • Vue1.0 => 2.0にアップグレードする
  • オレオレデータフローをやめる
  • オレオレルーティングをやめる
  • Linter / テストを導入する

これらを一気に変更するのはリスクも高く、QAコストも高くつきます。またfrontendディレクトリ配下で徐々に新しいライブラリに移行していくことも以下のように難しい状況に見えました。

  • Vue.js 1.0と2.0を混在させた状態でビルド環境を作ることは vue-template-compilerwebpack 等の依存関係により、不可能に見えた*1
  • 後述する通りVue.js 2.0非互換なAPIを使っている箇所が大量にあった

そこで frontend/ ディレクトリの中身を一気に改善するのではなく、別のビルド環境を作って少しずつ移行していく方針をとりました。

frontendディレクトリからの 漸進的 な脱却

まずは全画面で読まれていたfrontendディレクトリのJSを、必要な画面だけで読み込む形にし、独自のルーティング機構が不要な状態に変更しました。

f:id:suzan2go:20190527124548p:plain

またアプリルートから package.jsongulpfileといったものを frontend ディレクトリに移して frontend ディレクトリでビルドが完結するようにし、完全別のパッケージの管理とビルドを導入できる状態にもっていきました。これにより、画面ごとに別のビルド環境で生成されたJSを読み込ませる事が可能になり、安全にフロントエンド環境の移行が行える準備が整いました。

Webpackerの導入

そしてfrontend/ディレクトリとは別のビルド環境として選んだのはWebpackerです。

これを読んでいる人の中にはWebpackerではなく素のWebpackを使ったほうが良いのでは???と思ってる人も多いと思います。自分自身、過去に「webpackで作るSprockets無しのフロントエンド開発 - クラウドワークス エンジニアブログ」というブログを書いたり、プロダクションでSprocketsではなくWebpackを使ってフロントエンドビルド環境を作ったこともあるので、自分がフルタイムで関わるプロジェクトなら素のWebpackを選んだでしょう。しかしながらクラウドワークスにはフロントエンド専任のエンジニアがおらず、自分がいきなり1からビルド環境を作ってもまた無人化してしまうのではという懸念があったので、今回はWebpackerを選んだのでした。

Webpacker2系までは大量のconfigファイルがアプリ上に生成される形式だったため、かなりアップグレードが難しい状況であったのは間違いないですが*2、3系からはconfigファイルがアプリには殆ど入らない形式になっているため、Webpackのコンフィグをゴリゴリいじる必要がなく、Railsエンジニアが片手間に触るという環境なら良い妥協点なのではと考えています*3。Webpacker脱却記事も多いので、フロントエンド人材が育ったら引き剥がすことがそこまで難しくなさそうなのもポイントです。

Webpackerをフロントエンド環境の補助輪と考えて、将来存在が足かせになったタイミングでWebpackerのレイヤは剥がしてもらえばよいと考えています。

この変更により、ローカル開発環境では Sprocketsgulp(frontendディレクトリ)Webpacker と3つのフロントエンドビルド環境が一時的に生まれてしまったわけですが、幸いクラウドワークスの開発環境は docker-compose up で全てが立ち上がるように標準化されていたこともあり、他のエンジニアの開発環境に大きな迷惑はかけずに docker-compsoe.yml の更新をするだけで済んだのでした。

Webpackerの導入にあたっては、本番環境のNodeのバージョンアップ、デプロイツールと関連するGemのアップデートなどフロントエンドに完結しないタスクも多くありましたが、話の本筋からは離れるので割愛します。

導入後はVue.jsを使っている画面ごとに以下のような変更を行っていき、少しずつ移行を実施していきました。

- <%= javascript_include_tag bundle.js # 旧フロントエンド環境のJS %>
+ <%= javascript_pack_tag 'hogefuga.js' # Webpackerによりビルドした新JS %>

Vue.js 1.0から2.0へのアップグレード

Vue.jsの公式ドキュメントには以下のように、Vue.js 1.x 系からの移行に関するドキュメントがあるのでこちらを参考にしつつ、公式が vue-migration-helper という2.0非互換なAPIを使っている箇所を検出してくれるCLIを提供してくれていたので、それを元に少しずつ移行作業を進めていきました。 jp.vuejs.org

github.com

実際に vue-migration-helper をかけてみると大体150件ほど警告が出る状態でしたが、殆どが name="{{ name }}" to v-bind:name="name" のような警告で機械的に修正が可能なものでした。 以下のように1画面ずつプルリクを出してリリースしていき、何か問題があっても複数の画面に影響がでないよう進めていったので、大きな問題を出さずに移行を進めていくことが出来ました。

f:id:suzan2go:20190527133239p:plain

Railsから素直にデータをフロントエンド環境にわたす

前述したとおり、従来のフロントエンド環境はRailsからデータを渡すのに不必要に複雑な機構をもってしまっていました。しかしながらJSで扱うデータは全てAPIを作って対応するというのも、初期データしか必要のない場合には少し実装のオーバーヘッドが大きいように感じます。

そこで Webpackerにもドキュメントとして公開されており(webpacker/props.md at master · rails/webpacker · GitHub)、 react-rails などのライブラリが内部でやっているように、テンプレートにJSONを埋め込んでJSから読み出すという方法を取りました。

<%= content_tag :div,
  id: "hello-vue",
  data: {
    message: "Hello!",
    name: "David"
  }.to_json do %>
<% end %>
document.addEventListener('DOMContentLoaded', () => {
  // Get the properties BEFORE the app is instantiated
  const node = document.getElementById('hello-vue')
  const props = JSON.parse(node.getAttribute('data'))

  // Render component with props
  new Vue({
    render: h => h(App, { props })
  }).$mount('#hello-vue');
})

これがベストなやり方かと言われると微妙かもしれませんが、globalにデータをセットしてJSから読み出すよりも、「データをどこでセットしているか」、「データをどこから読み出しているか」が明確になったので以前よりはデータフローが追いやすくなったと思います。

Vue2.0へのアップグレードと合わせて、このデータフローについても上記のような変更を行いました。

Linter / Formatter 及びテストの導入

当たり前の話ではありますが、ライブラリを現時点で最新までもっていっても、追随できなければまたメンテナンスは難しくなってしまいます。またライブラリを上げたときに全画面で手動で確認しなければいけないとなると、アップグレードしていくのはかなり辛い作業になってしまうでしょう。というわけで普通の話ではありますが、アップグレードと合わせてLintとテストをするようにしました。

特に特殊なことはやっておらず、以下のライブラリを使用しています。

  • Jest + vue-test-utils
  • ESLint / Prettier

既存のコンポーネントは設計もあまりよくなく、テストが書きにくいものが多かったのですが、スナップショットテストがあるだけでも、ライブラリのアップデート・変更の心理的ハードルは下がるなーと実感しています。

そして現状

無事、frontendディレクトリは役目を終え、全てをWebpackerに載せることが完了しました。

f:id:suzan2go:20190527141125p:plain

また自分がプッシュしたわけでもなく、Vue.jsを使った施策を出すチームも現れてきています。もともとやっていた mihaly という社内CSSフレームワークについても、インタラクションは自分が素のJS(TypeScript)で書いていたのですが、Vue.jsからも使いたいという声が出てきており、 vue-mihaly 爆誕の機運が高まっています。

まだまだフロントエンドはCoffeeScriptが支配的ではありますが、継続的にフロントエンドを改善していく下地は出来てきたのかなと感じています。今後はCoffeeScriptの脱却、mihalyの導入などが進むといいな〜

おわりに

クラウドワークスではフロントエンドエンジニアを募集していますし、私への仕事の依頼もお待ちしております!(DMください)

twitter.com

www.wantedly.com

*1:SFCを使っていなければできるのかもしれない 心理的負担を抑えつつVue.jsを0.12→2.4にアップグレードした話 - Misoca開発者ブログ

*2:実際2系からアップグレードできず、素のWebpackを使う変更をしたこともあります

*3:実際3=>4のアップグレードはかなり楽に出来ました

© 2016 CrowdWorks, Inc., All rights reserved.