APIファーストでバックエンドとフロントエンドを別々に開発する時にハマるクロスドメインアクセス

いま個人で開発を行っているWebサービスがありまして、そこではバックエンドをRubyonRails、フロントエンドをClojure & ClojureScriptで開発し、APIでお互いやりとりするように設計しています。(その設計した理由は色々ありますが、単純に好きな2つの言語を使いたかったのが大きな理由の一つですw)

おそらくこれからのアプリケーション開発において、このようにフロントエンドとバックエンドをサーバーも言語も分けて設計・開発することが多くなると思いますので、最近ハマった問題をシェアしておきます。

同一生成元ポリシー(Same Origin Policy)

そのハマった問題というのが同一生成元ポリシーによるAjaxの規制です。

同一生成元ポリシーとは、ドメインAに配置されているHTMLファイルから別ドメインBのサーバーのAPIAjaxで通信することが出来ないという制約です。

この制約があることで、例えば開発中にフロントエンドをlocalhost:8000で起動し、バックエンドをlocalhost:8001で起動してAjaxAPI通信しようとするとエラーが発生してしまいます。

では、フロントエンドから別サーバーのAPIと通信するにはどうすればいいのか・・

CORS(Cross-Origin Resource Sharing)

実はこういう希望に対処するためにCross-Origin Resource Sharingという、XMLHttpRequest(Ajaxのことです)でクロスドメインアクセスを実現する仕様をW3C勧告で各ブラウザが実装しています。

CORSではクロスドメインアクセスを行うクライアント側とアクセスされるサーバー側の振る舞いが仕様で規定されています。

その概要はブラウザとサーバーがHTTPヘッダを使ってアクセス制御に関する情報をやりとりしてアクセスを行うものです。

CORSを実際に行う場合にクライアント側のJSで特別な制御を行う必要はあまりないのですが、サーバー側で以下のようなアクセス制限に関するルールを設定する必要があります。

では、これからどのようにCORSを行っているかを説明していきます。

CORSの動作 - クライアント側

クライアント側がクロスドメインアクセスを行うときに行う通信手段には2パターンあります。

  • 直接クロスドメインのリソースへリクエストを送る
  • クロスドメインアクセスが出来るか確認するリクエスト(preflightリクエスト)を送り、そのレスポンスを受けたあとに改めてリクエストを送信する。

以下に記載する条件にすべて該当する場合はpreflightリクエストを送る必要がないと判断して、直接サーバーにリクエストを送信します。逆にいうと、この条件に合わなければ必ずpreflightリクエストを送るということです。(これらの判断はすべてブラウザが行ってくれるのでクライアント側で特別にコードを書く必要はありません。)

preflightリクエストを送信しない条件

  • HTTPメソッドがGET, POST, HEADのどれか
  • HTTPヘッダにAccept, Accept-Language, Content-Language, Content-Type以外のフィールドが含まれていない
  • Content-Typeの値がapplication/x-www-form-urlencoded, multipart/form-data, text/plainのいずれか

上記の条件をみて頂けると気づくと思いますが、APIでデータをやりとりするときはjsonxmlをフォーマットとして使うことが多いので、大半はまず間違いなくpreflightリクエストが送られるということです。^^;

CORSの動作 - サーバー側

Originのチェック

サーバー側では、まず受け取ったリクエストがクロスドメインから受け取ったリクエストか判断するためにリクエストヘッダに含まれるOriginフィールドの値が入っているかチェックします。

Originフィールドに値が入っていれば、そのドメインがCORSを許可したドメインかをチェックして受け入れるか否かを判断します。

preflightリクエストかどうかを判断する

次にサーバーはこのリクエストが通常のリクエストかpreflightリクエストかを判断しないといけません。実際には以下の項目をチェックしてpreflightかどうかを判断します。

  • HTTPメソッドがOPTIONSであるか
  • リクエストヘッダにAccess-Control-Request-Methodフィールドが付与されているか

ちなみに、僕はクロスドメインでエラーが発生したリクエストを調査しているときに、POSTで送ったはずのHTTPメソッドがOPTIONSになっていて思わずライブラリのバグかなと中のコードを30分程探ってしまいました(- -;)

アクセス許可メソッドのチェック

続いて、HTTPメソッドがアクセスを許可しているものかどうかをチェックします。

preflightリクエストが来ていた場合、Access-Control-Request-Methodに本来送るはずのHTTPメソッドがここに記載されているので、この中身もみてアクセスを許可するかどうかを判断します。

レスポンスヘッダの作成

Access-Control-Allow-Origin

CORSが成立して、リクエストに対してレスポンスを返す時にレスポンスヘッダのAccess-Control-Allow-Originフィールドにクロスドメインアクセスが許可されるドメイン名を付加します。

もしAccess-Control-Allow-Originのフィールドがない場合はブラウザはクロスドメインアクセスに失敗したと判断してエラーを発生させます。

リクエストがpreflightリクエストだった場合は他にもレスポンスヘッダを追加します。

Access-Control-Allow-Methods

preflight後の実際のリクエストで利用を許可するHTTPメソッドの情報を付加します。

Access-Control-Allow-Headers

preflights後の実際のリクエストで利用を許可するリクエストヘッダの情報を付加します。

Access-Control-Max-Age

preflightの結果をキャッシュする時間を付加します。毎回毎回クロスドメインアクセスを行うたびにpreflightリクエストを送ると送受信コストが無駄にかかってしまうため、こちらで指定した時間はその結果をキャッシュしブラウザ側はpreflightリクエストを送らずに直接リクエストを送信します。単位は秒で指定します。

理屈は分かった。で、どうすればいいんだ!?

上の説明でCORSを行う仕組みはなんとなく把握できたと思うのですが、じゃあ具体的にどういう対応すればいいのでしょうか?

自分の場合はバックエンド側にはRubyonRailsを使っており、幸いCORS対応を行うためのgemでrack-corsというのがすでにあるのでそちらを使いました。

rack-corsのREADMEを見れば良いのですが、Gemfileにrack-corsを追記してbundle installしたあとに、config/application.rbに以下のように記載します。

module YourApp
  class Application < Rails::Application

    # ...

    config.middleware.insert_before 0, "Rack::Cors" do
      allow do
        origins 'your_app_domain'
        resource '*', :headers => :any, :methods => [:get, :post, :put, :delete, :options]
      end
    end

  end
end