Backbone.js + Rails + AWSでWebサービスを開発したお話(その3:SEO/OGP)

弊社では、新サービス「otacco – おたっこ|オトナになったオタク女子のための情報サイト」を先日ローンチいたしました。

otacco

その1では主に弊社のRailsでの開発手法を、その2ではotaccoのAWSおよびミドルウェアの構成に関してご紹介させて頂きましたが、今回はシングルページアプリケーションを開発する際に問題になりやすい、SEOとOGPのノウハウに関して解説させて頂きたいと思います。

SEO

結論から申し上げますと、一般的なAjaxページであれば、ここ数年間のGoogle botの機能向上により、URLにHashbang(#!)を使用してHTMLスナップショットを返すという、Googleが公式ドキュメントで推奨している方式もはや不要になったと考えて良いのではないかと思われます。詳細は下記の記事等をご参照ください。

AjaxやSPAのHTMLスナップショットをSEO向けに作る必要はなし

ただし、あらゆる種類のAjaxページでGoogle botがコンテンツを正しく認識出来るのかどうかは分かりません。Google Search ConsoleのFetch as Google機能を使用した際にコンテンツが正確にレンダリングされるかどうかが一つの目安になりそうな気もしますが、確証はありませんので、こちらはあくまで参考情報であると考えて頂ければと思います。

以下、hash fragment方式、Hashbang方式、そしてpushState(History API)を使用した方式に関して解説いたします。

(なお、この記事では「hash fragment」は「#だけで区切られたURLの形式」、「Hashbang」は「#!で区切られたURLの形式」という前提で説明させて頂きます。SEO用語に関してはこちらの記事が詳しいです)

hash fragment

Backbone.jsのデフォルトである「#だけで区切られたURL」は、Googleのインデックス対象外となります。

ネット上では「hash fragment形式でもGoogleはインデックス化することがある」というような情報も見受けられましたが、これはかなり特殊なケース(もしくはHashbangとの混同?)なようで、少なくともotaccoの場合、#だけで区切られたURLではGoogleはインデックス化してくれませんでした。

よって、Backbone.jsでシングルページアプリケーションを開発する場合、SEOを考慮するのであれば、少なくともhash fragment方式は不可ということになると思われます。

Hashbang

「#!」でURLを区切り、「?escaped_fragment=」というクエリ文字列の付加されたGoogle botからのリクエストに対してHTMLスナップショットを返すという方式は、Googleが公式に推奨している方式ではありますが、過去にこの手法を採用していたFacebookやTwitterが現在ではもうHashbangを使用していないこと、およびこのURL形式がかなり評判が悪い&将来的に廃止される可能性が高そうということもあり、otaccoではこの手法は採用しませんでした。

どうしてもHashbang&HTMLスナップショットで対応したいという場合、Railsでは下記のようなgemを使用する方法もあるようです。(内部でヘッドレスブラウザを使用してAjaxページをレンダリングし、それをGoogle botに返すというような方式になるようです)
google-ajax-crawler

pushState

pushState/popStateは、HTML5でHistory APIに追加された機能ですが、otaccoではこの機能を使用して、URLは全て「/」のみで区切る方式を採用いたしました。

Backbone.jsでのpushStateの使用方法に関しては下記をご参照ください。
試して学ぶ Backbone.js入門5

また、otaccoでは、アンカーがクリックされた際の挙動に関しては下記の記事を参考にいたしました。そのため、URLは全て「/」から開始する方式で統一しています。
Proper Link Handling With PushState

pushStateを使用する場合の大きな問題点は、IE8とIE9、および一部のAndroid標準ブラウザ(version3〜4.1)では動作しないということです。
Can I use history?

otaccoの場合、対象となるユーザー様でIE8やIE9を使用されている方はほぼいらっしゃらないと想定されること、および各ブラウザのシェアや工数等を考慮して上記は動作対象外とさせて頂きましたが、IE8とIE9に関しては、現時点(2015年9月時点)でもまだそれなりのシェアを確保しているため、pushState方式を選択される方はこの点をよくご確認された方がよろしいかと思われます。
Desktop Browser Version Market Share

蛇足ながら、現在IE8もしくはIE9を使用しているユーザーのOSは、既にMicrosoftのサポート対象外となっているXP、もしくは2017年4月でサポートが終了するVistaのいずれかと思われますが、ハードウェアの耐久年数等を考えれば、おそらく順次Windows 10やその他のOSに移行していくものと思われますので、IE8やIE9への対応に大きな工数を割くのは、一般的なWebサービスの場合あまり得策ではないのでは、という気はいたします。
各Windows OSで利用できるIEのバージョンを知る

OGP

FacebookまたはTwitterのbotは、残念ながらAjaxコンテンツを解釈してくれません。つまり、OGP用のタグをJavaScriptで動的に変更しても、Facebook/Twitterのbotはその変更を認識してくれないということになります。

よって、Facebook/Twitterのbotからアクセスされた場合、何らかの手段で「OGPタグがそのページ用にレンダリングされた状態の静的HTML」を返す必要があるということになりますが、otaccoではRails側に「ogpタグ専用のHTMLページ」を出力するためのコントローラを作成し、Facbook/Twitterのbotからアクセスされた場合はそのページ用のHTMLを動的に生成して返す、という方式を採用いたしました。

ちなみに、以下で説明いたしますが、このHTMLページに関しては、最低限のHTMLタグとOGPタグが記述されていれば、コンテンツは一切不要のようです。

titleタグやmeta descriptionタグは一応設定しておりますが、固定の文字列を出力しております。(もしかするとこのタグも不要かもしれません)

HTMLテンプレート

FacebookもしくはTwitterのbotからアクセスされた際に出力するHTMLのテンプレートは下記のようになります。(Twitter Cards用のOGPはページ毎に出力するタグを切り替えています)

# index.html.erb
<!DOCTYPE html>
<html>
<head>
<title>otacco</title>
<meta name="description" content="otacco">
<%= render 記事ページ?(@origin) ? {partial: "twitter_entry", locals: {entry: 記事を取得()}} : {partial: "twitter"}   %>
<meta property="og:locale" content="ja_JP">
<meta property="og:site_name" content="otacco">
<meta property="og:url" content="<%= get_ogp_url @origin %>">
<meta property="og:type" content="<%= get_ogp_type @origin %>">
<meta property="og:title" content="<%= get_ogp_title @origin %>">
<meta property="og:description" content="<%= get_ogp_description @origin %>">
<meta property="og:image" content="<%= get_ogp_image @origin %>">
</head>
<body>
</body>
</html>
# _twitter.html.erb
<meta name="twitter:card" content="summary">
<meta name="twitter:site" content="<%= Settings.twitter.account %>">
# _twitter_entry.html.erb
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="<%= Settings.twitter.account %>">
<meta name="twitter:title" content="<%= entry.title %>" />
<meta name="twitter:description" content="<%= entry.description %>" />
<meta name="twitter:image" content="<%= entry.image.url %>" />

nginxの設定

Facebook/TwitterのUser Agentを判定するために下記のような設定を行っています。(「Facebook/Twitter botからのアクセス」かつ「imagesディレクトリへのアクセスではない」場合に、Railsのbot用コントローラにURLをRewriteするという処理になります)

if ($http_user_agent ~* (facebookexternalhit|Twitterbot)) {
  set $bot A;
}
if ($uri ~* "^/(?!images)") {
  set $bot "${bot}B";
}
if ($bot = AB) {
  rewrite .* /bot?target=$uri break;
}

ご参考までに、フロントエンドが担当するURLと、バックエンドが担当するURLを切り分けるためのnginx設定の一部をご紹介いたします。下記のような設定を行わずに、全てのリクエストが常にunicorn等のアプリケーションサーバに渡されてしまうと、負荷の面で問題がございますのでご注意ください。

location ~* ^/(?!api|bot|index.html) {
  try_files $uri /index.html;
  break;
}

try_files $uri @unicorn;

location @unicorn {
  include conf.d/proxy.conf;
  proxy_pass http://unicorn;
}
正規表現に関する補足

上記の補足になりますが、nginxはPCRE(Perl Compatible Regular Expressions)をサポートしていますので、正規表現においてPerlと同様な構文で「否定先読み」が使用できます。
4. 正規表現

シングルページアプリケーションの場合、「フロントエンド側独自のURL」はどんどん増えていく可能性がありますが、それに合わせてnginxの設定を毎回変更するのは大変です。

これに対して、バックエンド側が担当するURLは滅多に増えませんので、「バックエンド側で担当するURLでない場合」という判定を行っておく方が作業工数を減らせるということになりますが、こういった判定は上記のような「否定先読み構文」を使うことでシンプルに対応出来ます。

以上になります。


「世界NO1のotakuマーケティングカンパニー」を目指す株式会社アリスマティックでは、インフラ・サーバーサイド・ネイティブ・フロントエンドなど、各職種でエンジニアを絶賛募集中です!

採用ページ



Backbone.js + Rails + AWSでWebサービスを開発したお話(その2:AWS/ミドルウェア)

弊社では、新サービス「otacco – おたっこ|オトナになったオタク女子のための情報サイト」を先日ローンチいたしました。

otacco

前回の記事では主に弊社のRailsでの開発手法に関してご紹介させて頂きましたが、今回はotaccoのインフラを構成しているAWSおよびミドルウェア周りの環境について解説させて頂きたいと思います。

AWS

まずは構成図をご覧ください。

otacco7

専用のVPCを構築し、Multi-AZ構成で冗長化、サブネットは「フロント層/アプリケーション層/データ層」に分割し、セキュリティグループもこの単位で分けています。

Webサーバーおよび全文検索用サーバーはELBでそれぞれ冗長化および負荷分散しています。踏み台(bastion)サーバー以外へのSSHアクセスは許可していません。

Webサービス用のAWS環境としては標準的な構成だと思います。

ここら辺の構成に関しては、AmazonのAWS構成事例のページや、下記のDevelopers.ioさんの記事等、色々な情報を参考にさせて頂きました。
【AWS】VPC環境構築ノウハウ社内資料 2014年4月版

サーバー監視は別会社様に外注させて頂いておりますが、VPCごとに監視元サーバーを用意するとコスト面で問題があるため、今後VPCの数が増えていっても監視元サーバーの数を増やさないですむように、VPC peering機能を活用した監視方式を採用しております。

EC2

EC2のAMIは標準のAmazon Linux AMIを使用しております。

特殊な設定は行っておりませんが、Capistranoでのデプロイ時に、AWS SDKを使用してデプロイ先のサーバーを環境に応じて動的に識別出来るように、Webサーバーには「Project: production」のようなタグを設定しています。

S3/CloudFront

本番環境用の静的アセットファイル、記事等の画像ファイル、ログファイル等は全てS3にアップロードしています。

アセットファイルや画像ファイルに関しては、デプロイ時や更新時に古いファイルは自動的に削除されますが、ログファイルはどんどん蓄積されていってしまうため、専用のRakeタスクを作成し、それをwheneverでcronに登録して、古いログを定期的に削除するようにしています。

RDS

弊社ではMySQL5.6を使用しています。

文字コードにutf8mb4を採用する場合は、新しいParameter Gropを作成し、下記のように設定を変更してRDSをReboot必要があります。

character_set_client = utf8mb4
character_set_connection = utf8mb4
character_set_results = utf8mb4
character_set_server = utf8mb4
character_set_database = utf8mb4
innodb_file_per_table = 1
innodb_file_format = barracuda
innodb_large_prefix = 1
skip-character-set-client-handshake = 1

セッションストレージおよびキャッシュとして使用しているInnoDB memcached plugin用のOption Groupsの設定に関しては下記の記事が参考になると思われます。
Amazon RDS MySQL 5.6と新機能を試してみた

Route53

「otacco.com」というzone apexをELBのエンドポイントにマッピングするために使用しています。詳しくは下記の記事等をご参照ください。
Zone apexとCNAME

ミドルウェア

nginx

nginxに関しては、要件ごとに下記のような設定を行っております。

常時SSL対応

otaccoでは全てのアクセスをhttpsで行う(常時SSL対応)というルールを採用しておりますため、下記のような設定を行い、httpでアクセスされた場合はhttpsにリダイレクトするようにしています。
(ELB経由の場合は、HTTP_X_FORWARDED_PROTOというヘッダで判定を行います)

if ($http_x_forwarded_proto != "https") {
  rewrite ^ https://$host$request_uri? permanent;
}
ヘルスチェック

ELBからのヘルスチェックは下記のような設定で対応しています。

server {
  listen 80;
  location = /healthcheck {
    empty_gif;
    access_log off;
    break;
  }
}
IPフィルタリング

特定のURLへのアクセスを特定のIPアドレス(オフィスの固定IP等)に限定するために、nginxのgeoモジュールを使用しています。

geo $geo {
    proxy VPCのネットワークアドレス;
    オフィスのIPアドレス OK;
    default NG;
}

server {
    ...
    location ~ ^/hoge/ {
      if ($geo = 'NG') {
        return 403;
      }
      ...
    }
    ...
}

unicorn

unicornの設定に関してはネット上に色々な情報が散見されますが、弊社では下記のような設定に落ち着きました。

# -*- coding: utf-8 -*-
worker_processes Integer(ENV["WEB_CONCURRENCY"] || 1)
timeout 15
preload_app true

listen "/tmp/hoge.sock"
pid "/tmp/hoge.pid"

current_path = ENV["RAILS_ENV"] == "development" ? "/hoge" : "/hoge/current"
working_directory current_path

before_exec do |server|
  ENV["BUNDLE_GEMFILE"] = File.expand_path("Gemfile", current_path)
end

before_fork do |server, worker|
  defined?(ActiveRecord::Base) and
    ActiveRecord::Base.connection.disconnect!

  old_pid = "#{server.config[:pid]}.oldbin"
  if File.exists?(old_pid) && server.pid != old_pid
    begin
      sig = (worker.nr + 1) >= server.worker_processes ? :QUIT : :TTOU
      Process.kill(sig, File.read(old_pid).to_i)
    rescue Errno::ENOENT, Errno::ESRCH
    end
  end

  sleep 1
end

after_fork do |server, worker|
  defined?(ActiveRecord::Base) and
    ActiveRecord::Base.establish_connection
end

before_execBUNDLE_GEMFILE環境変数を設定する箇所は非常に重要で、これが記載されていないと、RailsのGemfileを更新した場合にunicornのreloadが失敗する場合がありますのでご注意ください。この問題に関しては下記の記事で解説されています。
capistrano + unicornではまった。

WEB_CONCURRENCY環境変数に関しては、弊社では/etc/environmentに設定する方式を採用しました。

# /etc/environment
WEB_CONCURRENCY=任意の値

fluentd

nginxとRailsのログを集約し、S3にアップしています。設定が重複しないようにfluent-plugin-forestプラグインを使用しています。

<match hogelog.**>
  type forest
  subtype copy
  remove_prefix hogelog
  <template>
    <store>
      type s3
      aws_key_id "hoge"
      aws_sec_key "secret"
      path ${tag}
      buffer_path /var/log/td-agent/buffer/${tag}.s3
      s3_bucket "hogelog"
      s3_region "ap-northeast-1"
      s3_object_key_format %{path}.%{time_slice}_%{index}.%{file_extension}
      time_slice_format %Y%m%d
      time_slice_wait 10m
    </store>
  </template>
</match>

NewRelic

パフォーマンス監視用にNewRelicを導入しています。ほぼ標準設定のまま使用しています。

ElasticSearch

全文検索用にElasticSearchを導入しています。AWS環境ではマルチキャストが使用できませんので、ノードのディスカバリ用に、下記のような設定を行う必要があります。
AWS Cloud Plugin for ElasticSearchを使う

RailsとElastiSearchの連携、特に「Modelに親子関係がある場合」の連携方法に関しては下記の記事がとても参考になりました。
既存のRailsアプリの検索にElasticSearchを導入してみる

Wercker

CIツールにはWerckerを採用しました。rubyおよびmysqlのdockerを使用する場合の設定ファイルは下記のようになります。ご参考までに。
(otaccoではDBのマイグレーションにridgepoleを使用しています。メッセージの通知先はChatworkです)

box: ruby
services:
  - id: mysql:5.6
    env:
      MYSQL_ROOT_PASSWORD: $MYSQL_ROOT_PASSWORD
      MYSQL_DATABASE: $MYSQL_DATABASE
      MYSQL_USER: $MYSQL_USER
      MYSQL_PASSWORD: $MYSQL_PASSWORD
build:
    steps:
        - bundle-install
        - rails-database-yml:
            service: mysql-docker
        - script:
            name: ridgepole
            code: (ridgepoleコマンド。省略)
        - script:
            name: rspec
            code: bundle exec rspec
    after-steps:
        - heathrow/chatwork-notify:
            (省略)
deploy:
    steps:
        - script:
            name: make .ssh directory
            code: mkdir -p "$HOME/.ssh"
        (以下、秘密鍵の生成用コード。省略)
        - script: 
            name: execute deploy
            code: (capistranoを起動するコード。省略)
    after-steps:
        - heathrow/chatwork-notify:
            (省略)

Capistrano

デプロイにはCapistranoを使用しています。かなり設定が多くて書ききれないので省略させて頂きますが、アセットのプリコンパイルに関しては下記の記事を参考にさせて頂きました。
Capistrano3 で asset_sync をローカル環境で実行してからデプロイする

次回は、シングルページアプリケーションを開発する際にハマりやすい、SEOとOGP関係の設定方法に関して紹介させて頂きたいと思います。


「世界NO1のotakuマーケティングカンパニー」を目指す株式会社アリスマティックでは、インフラ・サーバーサイド・ネイティブ・フロントエンドなど、各職種でエンジニアを絶賛募集中です!

採用ページ