アメブロ2016 ~React/ReduxでつくるIsomorphic web app~

 

みなさんこんにちは、サイバーエージェントでフロントエンドを中心に開発しています原(@herablog)です。

アメブロでは、2016年9月にフロントエンドをJavaベースのアプリから、node.js・Reactベースのアプリへとシステムの移行をおこないました。本記事では、その移行へといたる経緯やゴール、システム設計、その結果についてお伝えします。

 

リリース直後に気づいているツワモノな方もいらっしゃいました。

システム移行へといたる経緯

2004年から始まり、日本国内で最大規模のブログサービスとなったアメブロは、システムの肥大化や多数の関係者が存在したことによるモジュール・導線の急増などの理由により、ページ表示スピードが遅くなり、ページビュー数にも明らかに影響を与えるようになっていました。そして、その表示スピードの多くの問題は、バックエンドではなくフロントエンドに関わるものでした。

そこで、ページ表示速度改善をメインの指標とし、フロントエンドシステムの改修をおこなうことにしました。また、バックエンドのシステム移行も並行しており、データがAPI化されていました。すべてJavaベースでオールインワンとしてできていたシステムを適切に分割するちょうどよいタイミングでもありました。

ゴール

今回のシステム移行では、以下の項目をゴールとしました。

表示速度改善 (とにかくはやく)

ユーザー体験の向上を測る指標には様々なものがありますが、一番のユーザーメリットは表示速度だと思います。ページの表示がはやくされるほど、目的のコンテンツにたどり着き、よりはやくそのタスクを完了することができます。今回のシステム移行ではブログ、そして表示したい内容が多くなりがちなアメブロに合った形で、できるだけ現状の価値・体験を崩さず表示・動作速度がはやくなることを目指しました。

システムのモダン化 (エコシステムに乗ること)

かつては、データをHTMLとして返却すれば問題のなかったWebアプリですが、コンテンツの増加、体験のリッチ化、デバイスの多様化などの理由によりフロントエンドの比重が増えていきました。本来、よいWebアプリを作るためにはサーバーサイド・フロントエンドともに隔たりなくパフォーマンスを気にする必要がありますが、当時の構成ではそれが難しい作りになっており、開始から10年以上たったシステム構成はエコシステムに乗らないものなっていました。

エコシステムに乗った上でシステムを構築することでは様々な恩恵を得ることができます。中心となるエコシステム上では開発が盛んであり、日々様々なアイデアが生まれているため、最新の技術・機能を取り入れやすく最善のパフォーマンスを比較的容易に実現することができます。また、若者にとって新しいということも重要です。イニシエからの仕様や、昔からある技術だけを知っているおじさんがエライチームには未来がありません (これは壮大なブーメランでもあります)。

デザイン・体験のアップデート (2016年版アメブロ)

アメブロのモバイル表示面は2010年にリニューアルして以降、大きな変化はありませんでした。その間に多くの利用者はネイティブアプリのデザインや体験に慣れ親しんでいました。今回のプロジェクトでは、ダサい使いにくいと思われないために、時代にあった2016年版のデザイン・体験へのアップデートを目指しました。

それでは、それぞれの項目についてもう少し詳しくみていきましょう。

表示速度改善

改善ポイント

システム移行前のページをSpeedCurveを使って分析したところ以下のことがわかりました。

  • サーバーの返却速度は、はやい
  • HTMLサイズが大きい (ページのすべての要素が入っている)
  • レンダリングブロックしているリソース(JavaScript・スタイルシート)が多数ある
  • リソースの読み込み本数が多い、サイズが大きいそこで、以下の項目を基本方針としました。
  • サーバーの返却速度は落とさないようコードチューニング、キャッシュの最適化をする
  • HTMLで返却する内容は最小限にする
  • JavaScriptを非同期で読み込み、実行できるようにする
  • 最初の表示時に必要なリソースのみ読み込むようにする

SSRかSPAか

近年ブログは、ブックマークでの登録よりも、検索結果やFacebook、Twitterといったソーシャルメディア上でシェアされたものから遷移することが多くなりました。GoogleやTwitterがAMP、FacebookがInstant Articlesを始めたことが示すように、1ページ目がいかにはやく表示されるかといったことが利用者の満足度に大きく影響します。

また、記事一覧ページや前後の記事に遷移している利用者も多いということがGoogle Analytics等のログからわかっていました。これは、ブログは個人メディアでもあるため、一度遷移したページの満足度が高いと、そのブロガーに興味がわき、そのままブログ内の他のページも閲覧しているからと思われます。つまりブログというサービスにとっては、1ページ目が早く表示されること、そしてページ遷移時にも早く表示されることどちらもが重要であると言えます。

そこで、どちらも最善のパフォーマンスを発揮するために、1ページ目にはサーバーサイドレンダリング(SSR)の結果を表示し、2ページ目以降はシングルページアプリケーション(SPA)にしました。こうすることで、1ページ目の表示速度やマシンリーダビリティ(SEO含む)を担保したまま、SPAの表示速度の恩恵を得ることができます。

ちなみに、現在の構成では、サーバー・クライアント上どちらでも同一のコードで動作するため、すべてをSSR、もしくはSPAにすることも可能です。すでに、JavaScriptが使えない環境ではすべてがSSRのページとして動作します。将来的にService Workerが普及したあかつきには、初期表示のさらなる高速化やオフラインでの動作も視野に入れています。

SSR+SPA
以前のシステムではすべてSSRでしたが、今回のシステムから2ページ目以降はSPAで表示するようにしました。

 

spa-speed
SPAの魅力は何と言っても表示のはやさです。必要なデータのみAPI経由で取得するため、爆速です。

Lazy Load

ページ遷移という横移動への速度改善には、SSR+SPAという手法を使いましたが、1ページを早く表示という縦方向の速度改善にはLazy Loadを使いました。最初に表示されるコンテンツやナビゲーション・ブログ記事は最初から表示され、それより下に位置しているサブ的なコンテンツは利用者のスクロールに合わせて、表示されるようにしています。こうすることで、メインとなるコンテンツはページ下方のコンテンツに邪魔されることなくいち早く表示されます。ブログ記事を読みたいという利用者の体験にストレスを与えることなく、下部のコンテンツも提供することができます。

Lazyload
以前のシステムではページ全体の内容がHTML内に含まれていたためサイズが大きくなっていました。今回のシステムでは、メインコンテンツ(主に記事)のみをHTMLで返すことにより、HTMLのサイズやデータ取得リクエストが減りました。

HTMLキャッシュ

ブログ記事は静的なドキュメントであり、ひとつのURLに対して同じ内容が返却されるためキャッシュ効率がよいです。キャッシュをすると処理が減るので、返却速度があがり、サーバーの負荷を軽減させることもできます。そこで、変わらない部分(記事など)はキャッシュされたHTMLで返し、リクエスト・クライアントによって変わる情報はJavaScriptやスタイルシートで操作(表示・非表示)するようにしました。

newrelic-entrylist
これは2016年9月最終週のNew relic上で見るスタッツです。記事一覧ページのHTMLは大体50ms以下で返せています。

newrelic-entry
記事詳細ページのスタッツです。こちらもだいたい50ms以下で返せています。記事が長く容量が大きかったり、キャッシュに乗り切らない場合もあるので記事一覧ページと比べると遅いレスポンスもあります。

 

リクエスト・クライアントによって変わる部分は、HTMLのbody要素にclassを付与し、クライアント上のJavaScriptやスタイルシートで操作します。例えば、特定のOSでは非表示にしたい場合は、CSSに非表示の処理を記述します。スタイルシートは先に読み込まれ、レイアウトを確定されるので後述する「ガタンッ」問題を防ぐこともできます。

<!-- html -->

<body class="OsAndroid">
/ main.css /

body.OsAndroid .BannerForIos {
  dsplay: none;
}

システムのモダン化 (エコシステムに乗ること)

技術選定

今回のプロジェクトでは、技術選定の際に市場でできるだけ一般的に使われているものをチョイスするようにしました。合言葉は、まるでexampleアプリのようにでスタートしました。そうすることで誰でも情報を入手でき、また他のチームや会社からプロジェクトに参加することになった際にも比較的容易に加わることができるようになります。実装していくうちに諸般の事情により多少イレギュラーになってしまった箇所もありますが、できる限り各コンポーネントの役割をシンプルに保つようにしました。その結果、大枠のシステム構成は以下のようになりました。

(端折ってます)
(ところどころ端折ってます)

React with Redux

ReactとReduxを使った開発では、多くの部分をピュアな関数で組み立てる事ができます。ピュアな関数は特定の引数に対して常に同じ結果を返し、関数外のスコープに影響がありません。ピュアな関数を組み立てて開発していくと、それぞれの処理を小さく保つことができたり、参照元のオブジェクトを意図せずに変更してしまうことを気にすることなく開発することができます。これは、大規模な開発や多数の状態を持つクライアント側の実装にメリットがあります。

表示を更新するフローは、Action (イベント) -> Reducer (新しいstate(状態)を返す) -> React (更新されたstore内のstateを元に表示内容を更新) です。

Redux Actionの例。Redux Action(Action Creator)は引数を元にプレーンオブジェクトを返すだけの関数です。非同期リクエストは公式ドキュメントを参考にし、リクエスト・成功・失敗それぞれのアクションを定義しています。データ取得にはredux-dataloaderを利用しています。

// actions/blogAction.js

export const FETCH_BLOG_REQUEST = 'blog/FETCH_BLOG/REQUEST';

export function fetchBlogRequest(blogId) {
  return load({
    type: FETCH_BLOG_REQUEST,
    payload: {
      blogId,
    },
  });
}

Redux ReducerはActionのデータを元に既存のstateをコピーして新しいstateを返却する関数です。

// reducers/blogReducer.js

import  as blogAction from '../actions/blogAction';

const initialState = {};

function createReducer(initialState, handlers) {
  return (state = initialState, action) => {
    const handler = (action && action.type) ? handlers[action.type] : undefined;
    if (!handler) {
      return state;
    }
    return handler(state, action);
  };
}

export default createReducer(initialState, {
  [blogAction.FETCH_BLOG_SUCCESS]: (state, action) => {
    const { blogId, data } = action.payload;
    return {
      ...state,
      [blogId]: data,
    };
  },
});

React/Reduxでは、更新されたstoreの情報を元に表示内容を更新します。各コンポーネントは、propsで与えた値に対し、常に同じ結果のHTMLを返すようにします。Reactでは、Viewコンポーネントを関数的に扱えます。

// main.js
<SpBlogTitle blogTitle="渋谷のブログ" />

// SpBlogTitle.js
import React from 'react';

export class SpBlogTitle extends React.Component {
  static propTypes = {
    blogTitle: React.PropTypes.string,
  };

  shouldComponentUpdate(nextProps) {
    return this.props.blogTitle !== nextProps.blogTitle;
  }

  render() {
    return (
      <h1>{this.props.blogTitle}</h1>
    );
  }
}

なお、Reduxに関しては公式ドキュメントでかなり詳しく解説されていますので、こちらを都度参照することをおすすめします。

Isomorphic web app

アメブロ2016年版のアプリは、ほぼJavaScriptのみで書かれており、nodeサーバー上でも、クライアント上でも同様のコード・フローで動作するいわゆるIsomorphic web appとなっています。大まかなディレクトリ構成は以下のようになっており、サーバー側はserver.js、クライアント側はclient.jsを起点に動き出します。

  • actions/ Reduxアクション (サーバー・クライアント専用)
  • api/ データアクセスを抽象化します (サーバー・クライアント専用)
  • components/ Reactコンポーネント (サーバー・クライアント専用)
  • reducers/ Redux Reducers (サーバー・クライアント専用)
  • services/ サービスモデル、 Fetchrを使いデータを適切な粒度にまとめます。node.js経由のAPIになります (サーバー専用)
  • server.js サーバーの入り口 (サーバー専用)
  • app.js nodeサーバーの設定・起動、server.jsから呼び出します (サーバー専用)
  • client.js クライアントの入り口 (クライアント専用)

Isomorphic web app 記述されたJavaScriptはサーバー・クライアント上どちらでも動き、画面にデータを表示するまでのフローも同じ流れをたどります。

 

code-stats
GitHubの言語スタッツによるとJavaScriptは94.0%。ほとんどJavaScriptで作られていることがわかります。

Atomic Design

コンポーネントの構成にはAtomic Designを採用しました。プロジェクトの途中までは、Reduxのページにも記載されているPresentational and Container Componentsを元に、container, compornent の2階層でやっていたのですが、アメブロでは管理するコンポーネント数があまりにも多く、役割が曖昧になってしまうためAtomic Designの考え方を取り入れています。実際には以下のようなルールで運用しています。

atomic-design

Atoms

最小限単位のコンポーネントで、IconやButtonなどが該当します。基本的にstateは持たず、親コンポーネントから渡されたpropsを元にHTMLを返却します。

Molecules

再利用前提のコンポーネントで、List, Modal, User thumbnailなどが該当します。基本的にstateは持たず、親コンポーネントから渡されたpropsを元にHTMLを返却します。

Organisms

画面上の大きな一部のコンポーネントです。Header, Entry, Naviなどが該当します。この階層のコンポーネントでは、データ取得処理を記述したり、Redux Stateとconnectし、状態を保持することができます。ここで取得した状態はpropsとして、Molecules,Atoms のコンポーネントにも引き継がれます。

// components/organisms/SpProfile.js

import React from 'react';
import { connect } from 'react-redux';
import { routerHooks } from 'react-router-hook';

import { fetchBloggerRequest } from '../../../actions/bloggerAction';

// データ取得処理 (react-router-hookを利用)
const defer = async ({ dispatch }) => {
  await dispatch(fetchBloggerRequest());
};

// Redu storeのstateをpropsとして利用
const mapStateToProps = (state, owndProps) => {
  const amebaId = owndProps.params.amebaId;
  const bloggerMap = state.bloggerMap;
  const blogger = bloggerMap[amebaId];
  const nickName = blogger.nickName;

  return {
    nickName,
  };
};

@connect(mapStateToProps)
@routerHooks({ done })
export class SpProfileInfo extends React.Component {
  static propTypes = {
    nickName: React.PropTypes.string.isRequired,
  };

  render() {
    return (
      <div>{this.props.nickName}</div>
    );
  }
}

Templates

各path(URL)ごとのコンポーネントです。雛形なので、必要なパーツをOrganismsからimportし、リスト化するだけの役割です。

Pages

ベースとなるページのコンポーネントです。基本的には渡されたthis.props.childrenをそのまま表示します。アメブロではSPAのため1ページのみ存在しています。

CSS Modules

スタイルシートの記述には、CSS Modules を利用してCSSルールセットのスコープを各コンポーネント内に閉じています。各ルールセットの影響範囲が絞られているので、変更・削除が容易になります。アメブロでは、大人数での開発で、必ずしも全員がCSSに詳しくなかったり、誰がいつ書いたかわからないソースを変更するということが多く、影響範囲が絞られコンポーネント単位でスタイルを定義できるCSS Modulesは機能しています。

/ components/organisms/SpNavigationBar.css /

.Nav {
  background: #fff;
  border-bottom: 1px solid #e3e5e4;
  display: flex;
  height: 40px;
  width: 100%;
}

.Logo {
  text-align: center;
}
// components/organisms/SpNavigationBar.js

import React from 'react';
import style from './SpNavigationBar.css'

export class SpBlogInfo extends React.Component {
  render() {
    return (
      <nav className={style.Nav}>
        <div className={style.Logo}>
          <img
            alt="Ameba"
            height="24"
            src="logo.svg"
            width="71"
           />
        </div>
        <div ...>
      </nav>
    );
  }
}

各クラス名はwebpackでのビルド後、SpNavigationBar__Nav___3g5MHのようにhash値が付き一意になります。

ESLint, stylelint

今回のプロジェクトでは、ESLint, stylelintによるテストを必須にしていて、一文字でも正しくないとプロジェクトに取り込まないようにしています。これは書き方に一貫性をもたせるのと、レビュー時の手間を省くのが目的です。ルールはそれぞれeslint-config-airbnbstylelint-config-standardを継承していて、必要な箇所以外カスタマイズしていません。比較的厳し目なので、最初のうちは大変かもしれません。プロジェクトに新しいメンバーがジョインした際には、Lintによるチェックを通すことが最初の関門となっています?

code-review
レビュー時に細かな書き方を指摘する手間を防ぎます。機械に言ってもらった方が心理的に楽でもあります。

ci-error
プロジェクトジョイン後、最初のうちはLintエラーになることがよくあります。

CI, Build, Testing

プロジェクトのビルドテストデプロイはCI(社内ではCircleCIを利用)に集約するようにしています。各ブランチをGHEにプッシュすると、それぞれ適切な処理が動き出します。このフローの良いところは、ビルドに関する処理が職人化せず、circle.ymlpackage.json(node環境では)にまとまるところです。

現時点のブランチの運用は以下のようになっています。

  • develop 開発(次回リリース)用ブランチ。ビルド・テスト後、staging環境にデプロイされる。
  • release/vX.X.X リリースブランチ。developブランチから派生し、ビルド・テスト後、semi環境にデプロイされる。
  • hotfix/vX.X.X hotfixブランチ。masterブランチから派生し、ビルド・テスト後、semi環境にデプロイされる。
  • deploy/${SERVER_NAME} ブランチの内容が指定されたサーバーにデプロイされる。主に開発時に利用。
  • master このブランチのビルドで作られたdockerイメージからproduction環境にデプロイする。
  • それ以外 開発用ブランチ。ビルド・テストが行われる。

Docker

今回のシステム移行では、node.jsアプリケーションをdocker化しました。今回移行したシステムはフロントエンドであり、細かな修正をできるだけさらっとデプロイできることが好ましいです。docker化し、一度イメージを作ってしまえば、nodeモジュールのバージョンに左右されることなくデプロイができ、切り戻しも容易です。

また、node.js自体のリリース頻度も高いので、放置しているとあっという間に古いシステムになってしまいます。docker化しておくことで各ホストの環境に影響されずアップデートをしていくことができます。

さらに、コンテナ数の上限も比較的容易にできるため、スケールアウトしたいときや、サーバーのスペックをチューニングして使い切りたいときにも有効です。

デザイン・体験のアップデート

No more ガタンッ

システム移行前のアメブロでは、高さが固定されていないモジュールがいくつかあり、「ガタンッ」としていました。ガタンッは、誤タップを誘発するほか、描画の再計算も行われてしまうので非常に好ましくありません。そこで、今回のシステム移行では、モジュールの高さを固定することを前提にデザインをしました。特に、ナビゲーションは重要な要素であり、ページング時に同じ位置で押せるようにもしました。

gatan
「ガタンッ」の一例です。[次のページ]を押そうとした時に、別の要素が遅れて表示され、誤タップの原因となってしまいました。

 

paging-fixed
システム移行後では、要素の位置が固定されており、ページ遷移するための心理的ストレスが少なくなっています。

 

スマートフォン時代のユーザーインターフェース

2016年のモバイル環境ではほとんどの利用者がスマートフォンを使っています。スマートフォンではプラットフォーム事業者がそれぞれのユーザーインターフェースガイドラインを策定しており、利用者は日々そのユーザーインターフェースに慣れ親しんでいます。ブラウザ上での制約は少ないとはいえ、あまりにもそれらのお作法と異なるユーザーインターフェースでは、使いにくいものになってしまいます。

アメブロのモバイル表示は2010年にリニューアルしてから、もちろん細かな改善は行っていたものの、大きな変更は加えておらず少し古いなと印象づける箇所がありました。利用者からすると、その画面がネイティブアプリなのか、ブラウザ上なのか区別して閲覧することはほとんどないため、その時代のプラットフォームに合ったユーザーインターフェースを作ることが重要です。そこで今回のシステム移行では、いくつかのアップデートを加えました。

update-design
コンテンツを画面幅いっぱい使って表示するようにしました。2010年当時はTwitterを筆頭に「モジュールごとに囲むデザイン」が一般的でした。

 

 

navibar
ナビゲーションバーを設置し、ナビゲーションとなる操作をまとめました。

アクセシビリティ

今回のシステム移行のタイミングでアクセシビリティに関しても気を配っていくことにしました。HTMLはきちんとマークアップしていれば十分にアクセシブルであるため、まずはきちんとマークアップすることを心がけます。見出しや、imgに付与するalt属性を適切にしたり、クリッカブルな要素にはきちんとabuttonを使っていくようにします。チェックを自動化できると良いので、ESLintの jsx-a11y プラグインも利用します。

また、時を同じくして社内でもアクセシビリティの勉強会が開催され(なんとデザイニングWebアクセシビリティの著者でもある太田さん、伊原さんにもお越しいただきました)、アメブロでも今まであまり気にしていなかったボイスリーダーへの対応も試みてみました。その際にはボイスリーダーが読み上げる際に特に障壁となりそうな箇所から WAI-ARIA を利用しました(JSXではdata-*と同様にaria-*サポートしています)。

詳しくは、こちらのスライドにも記載していますので、ご覧いただければと思います。

結果

さて、上記のような様々な変更を加えた結果、当初の目標は達成できたのでしょうか。

まずは、パフォーマンス関連の指標です(なお、測定したURLは、アメブロの中でも表示リソースが多く、表示速度が遅めのページです)。

Critical Blockig Resources

speed-blocking

レンダリングをブロックするリソースの読み込み本数は75%向上しました。JavaScriptはすべて非同期で読み込み・実行するようにしました。スタイルシートは運用の都合上移行前のままにしています。

Content Requests

speed-requests

リソースの取得数は58.04%向上しました。Lazy Loadで初期表示に必要のないリソースを読み込まないようにしたり、適切にファイルをまとめたり、不必要なモジュールを削除したりした効果がでました。

Rendering

speed-rendering

主にクライアント側の指標であるレンダリングスピードは44.68%向上しました。

Page Load Time

speed-pageload

ページロードタイムは40.5%向上しました。また、Backendの時間も0.2ms~0.3ms台を維持しています。

 

続いて、メディア指標はこのようになりました。

Pageviews

ga-pv

2016年9月には有名ブロガーさんのトピックスがあったため、例外的な値ではありますが、ページビュー数は57.15%向上しました。トピックスを除いたシステム移行だけの値としては10~20%向上とみています。

Page / Session

ga-pps

各セッション中に閲覧したページ数である Page / Session は35.54%向上しました。SPAによるページ遷移速度の改善の効果が大きくみられました。

直帰率

ga-bounce-rate

各セッション中に1ページしか閲覧しなかった割合である直帰率は44.44%向上しました。初期表示時・ページ遷移時の速度改善、ユーザーインターフェースのアップデート(わかりやすいページング)、「ガタンッ」の排除などの効果が出ていると思われます。

 

まだまだ改善の余地があるものの、どの指標も向上させることができました。サイトパフォーマンスが上がれば、メディア指標も向上する?ということが示せたと思います。

なお、上記のデータは以下の条件で取得しています。

  • ページパフォーマンス
  • メディア指標
    • Google Analyticsを利用
    • s.ameblo.jp内の全データから取得
    • 2016年8月と2016年9月の数値を比較

さいごに

今回のシステム移行では、技術チャレンジからスタートし、その結果としてよいユーザーの反応を得られ、ビジネスに貢献できたということで非常にやりがい・達成感のあるものでした。このようにしてその時代に合わせた技術を取り入れて、サービスのクオリティをアップデートさせていくことを当たり前として、文化を根付かせていけたらと思っています。また、いち早くIsomorphic JavaScriptを導入し、日本に広めてくれた同僚 @ahomu 氏には大変感謝をしております ? ?

サイバーエージェントではエンジニアを募集しています。以下のキーワードに興味のある方はぜひお話しましょう。 React・Redux・node.js・webpack・HTML・CSS・a11y・AMP・Docker・CI・Service Worker・PWA・Sushi…

 


アメブロに関するその他の記事・スライドは下記をご覧ください!

◆アメブロ2016 ~ Isomorphic JavaScriptの詳しい話

 

◆アメブロの大規模システム刷新と それを支えるSpring