woshidan's blog

あいとゆうきとITと、とっておきの話。

Rackの概要、RackミドルウェアとRackアプリケーションの違いについて

この記事はRubyアドベントカレンダーの14日目の記事です。

最近Rackにさわることがあったのでこの記事では

についてまとめます。

Rack概要

RackはWebサーバとRuby及びRubyフレームワークとの間でやりとりをするためのインタフェースを提供するためのライブラリです。

具体的にはRackは

  1. Webサーバに来たリクエストを決まったキーを持つハッシュに加工し *1 、そのハッシュを引数にしてRubyで動いているアプリケーションのプログラムを呼び出す
  2. Rubyのアプリケーションから受け取る['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']] のような形式の配列を加工して、Webサーバが扱う変数に代入する

のはたらきをする Rack ハンドラー 、設定ファイルを読み込み、実行環境に合わせたWebサーバを起動する rackup コマンド、設定ファイルを書く際に利用する Rack::Builder DSL を提供し、

Rackハンドラーから呼び出されるRuby及びRubyフレームワークのアプリケーションプログラム側には

  • ハッシュを引数に call メソッドを呼ぶと ['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']] のような形式のレスポンスを返す

という規約( Rackプロトコル *2 )を守ることを要求します。

この規約に則ったRubyのオブジェクトを Rackアプリケーション と呼びます。

f:id:woshidan:20181214222452p:plain

RackアプリケーションとRackミドルウェア

Rackアプリケーションの中には、レスポンスを返すこと自体を目的とせず、Rackアプリケーションの call メソッドの前後に処理を挟むことを目的として実装される Rackミドルウェア と呼ばれるものがあります。

Rackアプリケーション/Rackミドルウェアと呼ばれるものは両方ともハッシュを引数に call メソッドを呼ぶと所定の形式の配列を返します。そういう意味ではまとめてRackアプリケーションといえますが、両者には以下のような違いがあります。

  • rackupコマンドに与える設定ファイル( config.ru )上で利用を指定するときのDSLが異なる
    • Rackアプリケーションは run Rack::SampleApp, Rackミドルウェアuse Rack::SampleMiddleware で指定
  • Rackミドルウェアには追加の規約があり、1番目の引数に app を受け取る initialize メソッドを定義する 必要がある

Rackミドルウェア(Rackアプリケーション)は自分のコンストラクタに引数として受け取ったアプリケーションに、さらにenvを渡す形で入れ子のように実行していくのですが、最後に実行されるRackアプリケーションを特別に エンドポイント と呼びます。

f:id:woshidan:20181214222517p:plain

参考

負荷試験のアプローチとWeb系でクラウドサービスを利用している場合のそれぞれのアプローチに対する感想について

この記事はソフトウェアテストアドベントカレンダーの8日目の記事です。

以前、担当しているサービスの負荷試験を行ったのですが、負荷試験やパフォーマンステストとふわっといった時、いくつかアプローチの種類があるようなので今回の記事ではそれらの簡単にまとめて実際Web系でAWSなどクラウドサービス使っててどう思ってるかについて書きます。

それぞれのアプローチの呼び方については、Oracleの定義*1であったり、ソフトウェアテスト標準用語集*2であったりに違いがあるようなので、この記事ではOracleが使っている名称に基づくことにします。

TL;DR

継続的な負荷の監視は実質的に負荷試験の一種だなぁ、と思いました。

負荷テストのアプローチについて

負荷テストを考える際のアプローチには大きく分けて

  • 性能テスト: 想定している負荷に対し、どの程度のスループットやピーク時の処理量が出せるか
  • 限界テスト: システムが想定している以上の負荷、あるいは限界以上の負荷を与えた場合何が起こるか
  • 耐久テスト: 高負荷で長時間運用時に何が起こるか

の3つがあると思います。実際は要件に合わせて、これらのアプローチを組み合わせて行っていることも多いですが、まずそれぞれのアプローチについてどういったことを計測するのかなどを確認します。

性能テスト

サービスや新機能のリリース前に、システムの結合試験や統合試験ではテスト用のユーザでのみ動作させていると思うのですが、ユーザの数が少ないと遅い処理でも問題なく動いてるように見えます。

性能テストでは、システムが想定している負荷に対し、どの程度のスループット応答時間を返すかを確認します。

限界テスト

システムが想定している以上の負荷を与えて、システムに何が起こるのかを確認します。

たとえば、リクエスト量が想定している負荷の2倍を与えた時のシステムの振る舞い、たとえば、

  • トップページが500エラーになってしまうか
  • 特定のデータストアにアクセスが集中してスループットが下がってしまわないか
  • クラッシュしないか

といったようなことを確認します。

耐久テスト

高負荷で長時間動かした場合にどう動かすかを確認します。

たとえば、

  • 微量のメモリリークがあるプロセスのせいで、メモリ使用量が時間とともに増加する
  • キャッシュの保持期限設定を間違えてキャッシュのメモリが不足する
  • ログの出力が多すぎて、ログファイルがディスクを圧迫
  • 処理にメモリを使いすぎて、GCなどが起こりすぎて処理速度に影響

といったようなことが起きないか確認します。

それぞれのアプローチに対する個人的な感想

個人的な感想ですが、本題です。

性能テストについて

自分がWeb系の自社サービスでAWSなどのクラウドサービスを使っているという前提で話すと、かなり大規模なサービスを持っている会社がそれに付随するようなサービスを新しく展開する、といったような場合以外は、あまり性能テストという形でやらないのかな、という感じです。

  • 小さいリリースを小刻みに行っていく場合リリースごとに想定される負荷があまり変化しない
  • クラウドサービスを利用している場合、サービスを継続的に監視してCPU使用率などが高くなったタイミングで一つ上のクラスのサーバにすぐ変更することが容易

なので、リリース後の継続的な監視で済ますことが多そうです。

弊社の場合、機能のリリース前にステージング環境などある程度用意しているデータの規模が大きい環境でしばらくサーバの負荷に変化がないかなどを監視するといった方法を取ることが多いです。

限界テストについて

テレビCMなどでのアクセス増予測や、新機能リリースのために試験している印象が実はそこまでないです。

  • 新機能がそこまでヒットする予測が毎回ない*3

というのと、

  • 高負荷で障害が発生してからの負荷に対応できるインフラの調達が30分以内とかで可能
    • オートスケールの設定台数を変える(数分くらい)、で済むことが多い
  • 小さいサービスが急にバズって高負荷で止まっても仕方ないよね、頑張ってねという業界の空気がある(気がする)

ので、決済とかクリティカルなサービス以外では、リリース後にその場で対応することが多い印象があります*4

来年ユーザー数を2倍に増やす事業計画とかがあって、その前提にあわせて試験したりすることもあるかな、という感じです。ぶっちゃけると新規リリース以外の場合、リリース直前の試験の段階でパフォーマンスの問題が見つかっても、負荷に対応するためのアーキテクチャの構築、ならびに新しいアーキテクチャへのデータ移行が間に合わないので、先手を打って試験をすることもあります。

テレビで急に取り上げられた場合は違いますが、テレビCMを打つ場合、Web系のベンチャーあたりだと「ここが社運をかけた投資・拡大のタイミング」と踏まえた事業計画に基づいて行なっているので、上記のような事業計画がありそれに沿って動くのかな、という気がしています。気だけですが。

耐久テスト

これもサーバ側だと監視ですます印象です。

  • 監視していると数日に一回メモリ使用量が80%などを超えるサーバがある
    • だいたい再起動など比較的簡単な対応をすればその場はしのげるので暫時対応で時間を稼ぎながら根本対応をしていく

みたいな。リリースしたらお客さんが運用する受託であったり、リリースしたらユーザの手元で動くアプリの場合はまた違うのかな、と思います。

締め切りギリギリのポエムですが、監視は試験である、とはこのことなんだなぁ、ということで現場からは以上です。

参考

*1:https://www.oracle.com/technetwork/jp/ats-tech/tech/useful-class-8-520782-ja.html

*2:http://jstqb.jp/dl/JSTQB-glossary.V2.3.J01.pdf

*3:このお話は一般論です

*4:それでなんとかならないソシャゲとかあった気がしますがどういう修羅なんでしょうか

いまさらRSpecに少し慣れてテストをすっきり書く方法について少し覚えた話

いまさらRSpecをさわってきて慣れて覚えた話もメモしておきます。

TL;DR

  • letで宣言した変数の初期化用パラメータで置き換えたくなったパラメータもletで宣言しておくとよさそう
  • shared_context や shared_examplesでまとめるのがちょうどいいくらいの事例としてちょうどいいのは、外部公開するAPIの異常系のレスポンスのテストくらいでは

慣れてきて覚えた話

letで宣言した変数の初期化用パラメータで置き換えたくなったパラメータもletで宣言しておく

これは同僚の方から教えていただいていいな、と思ったんですが、

describe "post comment" do
  let(:params) { { user_name: "HOGE", comment: "...", "book_id": 3 } }

  context "valid request" do
    it "can be posted" do
      ...
    end
  end

  context "invalid user" do
    let(:params) { { user_name: "", comment: "...", "book_id": 3 } }

    it "cannot be posted" do
      ...
    end
  end

  context "invalid comment" do
    let(:params) { { user_name: "HOGE", comment: "INVALID_FORMAT", "book_id": 3 } }

    it "cannot be posted" do
      ...
    end
  end

  context "invalid book" do
    let(:params) { { user_name: "HOGE", comment: "...", "book_id": nil } }

    it "cannot be posted" do
      ...
    end
  end
end

のような場合、 params の値の一部がそれぞれの context で異なるので、それぞれの context ブロックでパラメータを宣言しなおしていますが、

describe "post comment" do
  let(:user_name) { "HOGE" }
  let(:comment) { "..." }
  let(:book_id) { 3 }
  let(:params) { { user_name: "HOGE", comment: "...", "book_id": 3 } }

  context "valid request" do
    it "can be posted" do
      ...
    end
  end

  context "invalid user" do
    let(:user_name) { "" }

    it "cannot be posted" do
      ...
    end
  end

  context "invalid comment" do
    let(:comment) { "INVALID_FORMAT" }

    it "cannot be posted" do
      ...
    end
  end

  context "invalid book" do
    let(:book_id) { nil }

    it "cannot be posted" do
      ...
    end
  end
end

のように、example内で触る変数はparamsだけかもしれないんですが、それぞれのcontextで注目しているパラメータだけあとで置き換えられるように、宣言の時に工夫しておくとすっきりするなと思いました。

shared_context や shared_examplesでまとめるのがちょうどいいくらいの事例としての外部公開するAPIの異常系のレスポンスのテスト

この話がしたくてこの記事を書きはじめたのに随分長くなってしまったのですが、RSpecでは、shared_contextを使ってspecの中で繰り返し登場するテストケースを

describe "post comment" do
  let(:user_name) { "HOGE" }
  let(:comment) { "..." }
  let(:book_id) { 3 }
  let(:params) { { user_name: "HOGE", comment: "...", "book_id": 3 } }

  context "valid request" do
    it "can be posted" do
      ...
    end
  end

  context "invalid user" do
    let(:user_name) { "" }

    it "cannot be posted" do
      ...
      expect(:status).to eq 400
      expect(:body).to eq "Invalid Request" # このケース自体がいいかは微妙ですが...
    end
  end

  context "invalid comment" do
    let(:comment) { "INVALID_FORMAT" }

    it "cannot be posted" do
      ...
      expect(:status).to eq 400
      expect(:body).to eq "Invalid Request"
    end
  end

  context "invalid book" do
    let(:book_id) { nil }

    it "cannot be posted" do
      ...
      expect(:status).to eq 400
      expect(:body).to eq "Invalid Request"
    end
  end
end

のような場合、

describe "post comment" do
  let(:user_name) { "HOGE" }
  let(:comment) { "..." }
  let(:book_id) { 3 }
  let(:params) { { user_name: "HOGE", comment: "...", "book_id": 3 } }

  context "valid request" do
    it "can be posted" do
      ...
    end
  end

  shared_examples "invalid request" do
    it "returns 400 response" do 
      expect(:status).to eq 400
      expect(:body).to eq "Invalid Request"
    end
  end

  context "invalid user" do
    let(:user_name) { "" }

    it "cannot be posted" do
      ...
      it_behaves_like "invalid request"
    end
  end

  context "invalid comment" do
    let(:comment) { "INVALID_FORMAT" }

    it "cannot be posted" do
      ...
      it_behaves_like "invalid request"
    end
  end

  context "invalid book" do
    let(:book_id) { nil }

    it "cannot be posted" do
      ...
      it_behaves_like "invalid request"
    end
  end
end

のように、同じテストコードを実行している部分を shared_examples に定義して it_behaves_like "SHARED_EXAMPLE_NAME" で呼び出すことができます。

こうするとテストケースはDRYになりますが、

  • 一方でテストが読みにくくなったり
  • バリデーションエラーなどは案外全く同じコードにならなかったり
    • たとえば、上記のサンプルコードは実際はパラメータごとに invalid user などのレスポンスを返したほうが親切でしょう

して、使いどころが難しいんですが、外部公開しているAPIの場合、

  • エラーの内容からAPI内部の処理を推測できないようにわざと詳細なレスポンスを返さないケースがある
  • 外部公開しているAPIが複数あるとしても、機能ごとにエラー時のレスポンスを変えない場合が多い
    • たとえば、一覧取得用API投稿用APIで認証エラー時のレスポンス内容は変えない方が親切でしょう

という感じで使ってみるとよいかもしれないな、と思ったのでメモです。

参考

https://qiita.com/jnchito/items/42193d066bd61c740612 https://qiita.com/etet-etet/items/7babe4856a1cd62b9ecb

ペアワイズ法について簡単にまとめて手を動かしてテストケースが削れるのを眺めてみた

この記事はソフトウェアテストの小ネタアドベントカレンダーの2日目の記事です。

この記事では、大量にあるテストケースをある程度不具合検出率を担保しつつ効率よく削るための手法であるペアワイズ法についてその概要をまとめ、組み合わせが小さい場合について手で実際に行ってみて、なぜテストケースが減らせるのか確認します。

TL;DR

  • テスト対象のパラメータの値を単純に組み合わせていくと、テストケースがあっという間に増えすぎてしまう
  • 実際に影響があるのは2つくらいのパラメータの値の組み合わせであることが多いので、パラメータでペアを作って、そのペアごとにすべての値をテストできるようにテストケースを作成するようにすると効率がいいのでは、というのが ペアワイズ法 の基本の考え方
  • ペアごとにすべての値をテストすればよい、としたことで1つのペアの条件をテストしながら他のペアの条件もテストしていくことでテストケースが削減される様子を眺めてみた

単純に条件の組み合わせを増やしていくとテストケースがあっという間に増えてしまう

テストを作成する際、テスト対象に入力されるパラメータの組み合わせからテストケースを作成することが多いと思います。

たとえば、以下のような入力欄があるログインフォームがあるとして

  • 会員IDの欄
  • パスワードの欄

がありそうですが、これらに

  • 未入力か
  • 正常に入力されているか
  • 異常な値が入力されているか

などの状態があると思うんですが、律儀にテストしていると

  • 3 * 3 = 9

パターンになります。これだけのログインフォームならまだいいですが、例えば会員登録時のフォームで、さらに

  • 性別
  • 都道府県
  • メールアドレス

などの項目がある場合、律儀に同様の組み合わせを試験に追加しようとすると

  • 9 * 27 = 243

とちょっと真面目に組み合わせの試験をする気がなくなってきますね。

ペアワイズ法とは

ところで、上の例では、すべてのパラメータの組み合わせが同等に結果に影響をあたえる、つまり、

  • 会員IDに関する結果を見たい時、他の4つのパラメータ(パスワード/性別/都道府県/メールアドレス)のすべての組み合わせを考える必要がある

としているので、

  • 会員IDが未入力
  • 会員IDが正常に入力されている
  • 会員IDに異常な値が入力されている

の3つのケースそれぞれに対して、他の4つのパラメータの組み合わせ(81通り)をテストするということで243通りになっているのですが、実際、会員IDに利用したい値が正常かどうかについて都道府県のフォームの入力値はあまり関係なさそうですよね。

つまり、

  • 複数のパラメータの組み合わせがテスト結果に影響をあたえるとしても、すべてのパラメータの組み合わせを使う必要はない
  • 実際テスト結果に影響をあたえるパラメータの組み合わせは2つくらいのパラメータからなることが多い

ので、すべてのパラメータから2個ずつ*1のペアを作って、そのペアについてはすべての結果を試験するようにテストケースを作成していこう、というのがペアワイズ法の考え方みたいです。

組み合わせの数が小さい場合について、実際に手作業でやってみる

ペアワイズ法を使ってみるとどういう風になるか、具体例を使って考えてみます。今回は、

  • 会員ID
  • パスワード
  • 性別

のパラメータについて、パラメータごとの値を、正常に入力されている(o)/未入力()/異常な値が入力されている(-)として、3つのパラメータのすべての値の組み合わせで表を作ると

会員ID パスワード 性別
o o o
o o -
o o
o - o
o - -
o -
o o
o -
o
- o o
- o -
- o
- - o
- - -
- -
- o
- -
-
o o
o -
o
- o
- -
-
o
-

と27パターンありますが、特に禁則などを指定せず、組み合わせの強さ2でペアワイズ法を利用した場合、

会員ID パスワード 性別
o o o
o - o
o o
- o -
- - -
- -
o
-
o - x
o -
- o o
- - x
o o
- x

の15パターンに減ります。

なぜ減るかというと、ペアワイズ法でテストケースを作成していく時、個々のテストケースにおいてペアにしていないパラメータの値の影響は気にしないことにしているので、複数のペアについて1つのテストケースである程度同時にテストしていくことが可能になるからです*2

たとえば、上の例だと、最初の9行で

会員ID パスワード
o o
o -
o
- o
- -
-
o
-

のように、会員IDとパスワードのペアについてすべての組み合わせ(9通り)をテストしていますが、この時、

会員ID パスワード 性別
o o o
o - o
o o
- o -
- - -
- -
o
-

のように、性別の値も入れ替えておくことで、

  • 会員IDとパスワードの組み合わせのテストをしながら
  • パスワードと性別の値のペア(9通り)のテストが同時に実行でき
  • 会員IDと性別のペアのテストの一部(3通り)も進められる

ので、ペアワイズ法を用いた場合のテストケースは、もとの3つのパラメータのすべての値の組み合わせをテストしていた場合と比べて 27 パターンから 27 - 9 - 3 = 15 パターンに減ります。

また、これまでパラメータと書いてきた入力条件にあたるものを 因子、因子ごとに取りうる値のことを 水準 と呼びます。

参考

ペアワイズ法を利用する際に使うPICTのツールや、ペアワイズ法そのものについてもっと詳しい解説記事はたくさんありますが、この記事を書く際に参考にしたものをリンクして、この記事はおしまいにします。

http://www.atmarkit.co.jp/ait/articles/1503/16/news149.html https://qiita.com/mitch0807/items/2d8af40fc58d359232ba

*1:これは実際のテストケースの状態によって調整する。ペアを作る時のパラメータの個数を「組み合わせの強さ」といって、2個ずつのパラメータでペアを作る時の組み合わせの強さは2.

*2:実際にその組み合わせはありえない、など、結果に影響があるパラメータについては指定することも可能です http://www.atmarkit.co.jp/ait/articles/1503/16/news149.html

いまさらRSpecを触ってみて最初のうち戸惑っていた部分の話

この記事はRuby on Rails Advent Calendarの1日目の記事です。

実は、この夏からはじめて仕事で本格的に RSpec を触ることになり、少し慣れてきたところで最初に感じたことをメモしておきます。

RSpecの構文は慣れない人にはこんな風に映ることもあるんだーと笑っていただけますと幸いです。

TL;DR

  • subjectlet でテストで利用する変数の宣言と初期化をする記法に慣れるまで時間がかかった
  • it .. do ... end のブロックを example ということ、 example の中にテストコードを書いていくことがわからなかった
  • describecontext に文法上の違いがあるかと思って身構えていた

慣れないうちに戸惑った話

テストコードやテスト対象をどこにどうやって記述したらいいかわからない

なかなか斬新な話なんですが、どういうことかというと、たとえば、自分がこれまで一番テストを書いてきたのはAndroidなので、Java用のテストフレームワーク JUnit5を例に挙げると

// https://junit.org/junit5/docs/current/user-guide/#writing-tests-nested より
@DisplayName("A stack")
class TestingAStackDemo {

    Stack<Object> stack;

    @BeforeEach
      void createNewStack() {
      stack = new Stack<>();
    }

    @Test
    @DisplayName("is instantiated with new Stack()")
    void isInstantiatedWithNew() {
        new Stack<>();
    }

    @Nested
    @DisplayName("when new")
    class WhenNew {

        @BeforeEach
        void createNewStack() {
            stack = new Stack<>();
        }

        @Test
        @DisplayName("is empty")
        void isEmpty() {
            assertTrue(stack.isEmpty());
        }

        // ...

        @Nested
        @DisplayName("after pushing an element")
        class AfterPushing {

            String anElement = "an element";

            @BeforeEach
            void pushAnElement() {
                stack.push(anElement);
            }

            @Test
            @DisplayName("it is no longer empty")
            void isNotEmpty() {
                assertFalse(stack.isEmpty());
            }

            // ...
        }
    }
}

のような感じなんですが、

  • 基本的にテストはクラスの単位でまとめられていて
  • インスタンス変数などの定義は通常のクラスと同様に行ってよく
  • テストの各ケースも通常のメソッドと補足情報をアノテーションで指定したりするが、同様に書いていけばよい
  • 各テストケース実行前の変数の初期化は BeforeEach などのアノテーションがついた箇所で宣言とは別の箇所で行う

んですね。一方同じ内容をRSpecで書くと以下のようになります。

describe "A stack" do
  let(:stack) { Stack.new }

  describe "when new" do
    it "is empty" do
      expect(stack.empty?).to eq true
    end

    # ...

    describe "after pushing an element" do
      let(:an_element) { "an element" }

      it "is no longer empty" do
        expect(stack.empty?).to eq false
      end

      # ...
    end
  end
end
  • まず、テストのためにクラスを定義しない
    • 内部実装をよくわかってないので違うのかもしれませんが、少なくともそういう書き方をしない
  • let で変数の宣言と初期化の方法を一緒に記載してしまう。変数の初期化だけのために前処理のブロックを用意していない
  • テストの内容を書いていく箇所もメソッドではなくてブロックの中

という具合で、かなり戸惑いが大きかったです。

特に3番目について、RSpecでは it ... do ... end のブロックで囲まれた部分にテストのコードを書いていき、その単位を example といって、その中にexampleなどを書いてくのですが、最初は

describe ブロックや context ブロックの中にどうして処理を書いていけないのか( example のそれと同じブロックやんけ)」

みたいな感じであわあわしてました。

また、テスト対象が1つに決まっている場合は subject という機能を使うと

  • テスト対象が1つに決まっていることを明確化され
  • expect(Hogehoge).to みたいにテスト対象の指定をしない is_expected.to という書き方が可能となる

のですが、これも最初「テスト対象どこに行った??」という感じでした。

# subject を使わない場合
describe "A stack" do
  let(:stack) { Stack.new }

  describe "when new" do
    it "is empty" do
      expect(stack.empty?).to eq true
    end
# subject を使う場合
describe "A stack" do
  let(:stack) { Stack.new }
  subject { stack.empty? }
  # 特にテスト対象がインスタンスそのものでなく、
  # インスタンスのメソッドの結果だったりする場合は
  # 直感的に理解できるようになるまで時間がかかった

  describe "when new" do
    it "is empty" do
      is_expected.to eq true
    end

エイリアスがたくさんある

また、テストコードを読んでいると

describe "..." do
  describe "..." do
    it "..." do
      ...
    end
  end

  context "..." do
    it "..." do
    end
  end
end

みたいな同じ文法の位置に違う単語があって、最初は文法的に何か意味があるんだろうか、と思ったんですが、

  • it / specify/ example は同じ意味で1つのテストケースのコードをグループ化するために用いられる
  • describe / context は同じ意味で example をグループ化するために用いられる
  • これらのエイリアスは自然な英語でテストが記述できるように用意されている

そうです。

まとめ

黙って一回チュートリアル。謙虚な気持ち、大事。

現場からは以上です。

慣れてきて覚えたことの話も書こうかと思ったのですが、長くなったので別記事にします。

参考

JUnit 5 User Guide

qiita.com

OPTIONSメソッドが気になったのでCORSとプリフライトリクエストについて少し調べました

Amazon API GatewayでCORSを有効にするとOPTIONSメソッドが追加されるのが気になったので、CORSについて少し調べました。

CORSとは

オリジン間リソース共有Cross-Origin Resource Sharing (CORS) は、追加の HTTP ヘッダーを使用して、あるオリジン (ドメイン) で動作しているウェブアプリケーションに、異なるオリジンのサーバーにある選択されたリソースへのアクセスを許可することができる仕組みです。ウェブアプリケーションは、自分のオリジンとは異なるオリジン (ドメインプロトコル、ポート番号) からリソースをリクエストするとき、オリジン間 HTTP リクエストを発行します。*1

クライアントサイドの実装は、最近ではIEのごく一部のブラウザ以外で実装されていて、JavaScriptで異なるドメインのサーバへアクセスしようとした時によくエラーになっているあれです。

CORSが必要な理由

CORSのことは一旦置いておいて、Webサーバにリクエストを送った時のことを考えましょう。基本的に、Webサーバはリクエストを送ってきたクライアントのIPにレスポンスを返します。

f:id:woshidan:20181126210159p:plain

たとえば自社でAPIとWebページを開発しているときは以下のような感じになりますね。

f:id:woshidan:20181126210321p:plain

ここでもし、自社API(図中の api.example.com)に他者のホームページからリクエストが

f:id:woshidan:20181126210754p:plain

のように送信されていたらどうなるでしょうか。不正なリクエストを送ってサーバに不具合を起こされたり、顧客に本物のWebサイトと偽って表示して顧客に表示するためのコンテンツを盗まれたり、といったことは起こるかもしれません。

CORSはこのような場合に役に立つもので、CORSを用いると、Webサイトのものとは異なるドメインにリクエストを送る場合、そのリクエストが許可されていることをサーバに一度確認してからでないとPOSTなどのHTTPリクエストが送ることができなくなります*2

f:id:woshidan:20181126211356p:plain

f:id:woshidan:20181126211528p:plain

CORSの仕組み

CORSを用いると、Webサイトのものとは異なるドメインにリクエストを送る場合、そのリクエストが許可されていることをサーバに一度確認してからでないとPOSTなどのHTTPリクエストが送ることができなくなります と書きましたが、具体的にどのようにやっているのか、基本的なパターンについてさらっておきます*3

プリフライトリクエス

Content-Typetext/plain でそれ以外にめぼしいリクエストヘッダを含まないGETリクエストなど一部の単純なもの以外*4、 Webページ上のJavaScriptが外部ドメインにリクエストを送ろうとした際、Webブラウザは元のHTTPリクエストを送るのではなく、 プリフライトリクエスト というものを送信します。

この プリフライトリクエスト には

  • パスは元のリクエストのまま
  • メソッドは OPTIONS
  • リクエストヘッダには
    • リクエスト元のドメインを示す Origin
    • 元々送ろうとしていたリクエストが入る Access-Control-Request-Method
    • 元々送ろうとしていたリクエストのリクエストヘッダが入る Access-Control-Request-Headers

が含まれ、この プリフライトリクエスト を受け取ったサーバは、

  • サーバがリクエストを許可する外部サーバのドメイン一覧である Access-Control-Allow-Origin
  • サーバがリクエストを許可するリクエストメソッドの一覧である Access-Control-Allow-Methods
  • サーバがリクエストを許可するリクエストヘッダの一覧である Access-Control-Allow-Headers
  • プリフライトリクエストをしばらく有効にしておく期限を表す Access-Control-Max-Age

が含まれるレスポンスを返します。Webブラウザはこのレスポンスを見て、自分が送ろうとしていたリクエストが外部サーバから許可されているかを確認し、許可されているのを確認できたら元々送信したかったリクエストを送信します。

サーバから通信を許可されていないドメインであったり、許可されていないメソッドでリクエストしようとしている場合は、Webブラウザはリクエストの送信を断念します。

参考

*1:https://developer.mozilla.org/ja/docs/Web/HTTP/CORS

*2:GETのみなど一部確認用のリクエスト(プリフライトリクエスト)なしに送ることも可能です(単純なリクエスト)。

*3:リクエストそのものだけでなくクッキーを扱えるかどうかなどもCORSを用いて制御することができます https://qiita.com/tomoyukilabs/items/81698edd5812ff6acb34#cookie%E3%82%82%E8%A8%B1%E5%8F%AF%E3%81%97%E3%81%9F%E3%81%84%E5%A0%B4%E5%90%88

*4:詳しくは https://developer.mozilla.org/ja/docs/Web/HTTP/CORS#Simple_requests

AWSによるサーバーレスアーキテクチャを読みました

3連休はサーバレスをやるぞ! と決めていたので、2ヶ月くらい積ん読してた気がする本をようやく読みました。

AWSによるサーバーレスアーキテクチャ

AWSによるサーバーレスアーキテクチャ

最初の方、思ったよりLambda関数を作成してはロールを付与して確認して、の流れが慣れなくて思ったより時間がかかりました。。

また感想がてらいくつかの話題についてまとめておきます。

サンプルのLambda関数を動かしてみて

2年以上前の本だったのでAuth0などのライブラリが古くて動かないところもあったのですが、そもそもLambda関数を作ってイベントから起動させたり、API Gatewayから呼び出したり、というのに不慣れだったため、7章くらいまでは一部妥協しながらも手を動かしながら読み進めていました。

Lambdaで実装しようとするときの一連の流れは

  • Lambda関数用のロールとロール用ポリシー作る
  • 上を関数のロールに指定してLambda関数を作る
  • Nodeなど関数を書く
  • 書いた関数をzipでまとめてデプロイ
  • その後、S3などイベントを発行するサービスから連携の設定をする

という感じです。

ポリシーの作り方について、Lambda関数の実行に必要最低限のポリシーを用意するのに管理ポリシーの AWSLambdaBasicExecutionRole (CloudWatchへのログ書き込みに必要な権限のみ)というのがあり、これに対し関数で行う内容によってさらに必要な権限を付与していきました。

特に参考になったのが、テスト手法の紹介で、lamdbaと同様の処理*1がローカルで実行できる run-local-lambda の紹介や、Lambda関数のコンソールにあるテストの設定を作る際に利用出来るイベントテンプレートがすごくよかったです。

イベントテンプレートはAWSの各種サービスから受け取るイベントの内容を下のgifのように1クリックで確認できて、すごい楽になりました。

f:id:woshidan:20181125135514g:plain

あと、地味にLambda関数のデフォルト実行時間の3秒が短くて、サンプルの動画をダウンロードしてメタデータを読み取る関数がエラーになりまくるなどしました。

バックエンドで実行すべき処理の例

サーバレスアプリケーションでは、基本的にフロントエンド*2から直接リクエストできる処理はなるべく間にバックエンドのサーバーなどを挟まない方がよいとされているのですが、それでもバックエンドから呼び出した方がよい処理はあります。

たとえば、

  • フロントエンドのコードというのはユーザーのブラウザにロードされる情報なので、ユーザーのブラウザに読み込まれてはいけないような 機密情報 を利用する処理であったり。
  • ブラウザというのはいつ閉じられてもおかしくないのに、中断されたら困るような処理をブラウザ上で実行したり。

というのはバックエンドで行った方がよい処理です。

フロントエンドから直接サービスを呼ぶために、HTTPリクエストをAWSのサービスに合わせたクエリに変換する

上の節にもサーバレスアプリケーションでは、基本的にフロントエンドから直接リクエストできる処理はなるべく間にバックエンドのサーバーなどを挟まない方がよい、と書いたのですが、たとえば、

  • Firebaseをフロントエンドから利用する
  • Auth0などの認証・認可のサービスをフロントエンドから利用する

といったサーバレスを意識して作られているだろうサービスを利用するのは、これらのサービスのSDKやサンプルコードも公開されているしイメージがつきやすいです。

しかし、それ以外のサービス、たとえばAWSに用意したRDSへ接続して SELECT 文を発行するような場合、単純なHTTPリクエストでもHTTPリクエストの内容をクエリに変換するようなコードを動かすバックエンド(LambdaやEC2上で動くWebアプリケーション)を用意する必要があるのではないでしょうか?

たとえば GET /users/2 というHTTPリクエストを受け取ったとき、それを SELECT * FROM users where id = 2; のクエリとその発行に変換するためにLambda関数を用意するといった具合に。

そういう疑問があったのですが、単純なクエリであればAmazon API GatewayAWSサービス統合を利用すれば、バックエンドを用意せずに実装することが可能です。

リクエストのパラメータとクエリに使う値、レスポンスの形式をテンプレートを書いて指定するのですが、DynamoDBやKinesis Firehoseの例は以下の記事が参考になります。

dev.classmethod.jp

dev.classmethod.jp

Lambda関数を書いていく上で重要な要素について

Lambdaの関数を書いてく上で重要な要素

  • 関数ハンドラ
  • イベントオブジェクト
  • コンテキストオブジェクト
  • コールバック関数
  • ログ

についてメモします。

まず、関数ハンドラは、Lambdaランタイムが書いた関数を実行するために呼び出しているもの(=エントリポイント)です。関数ハンドラの外に書いたコードはウォームスタート時は実行されないので、変数の初期化などは関数ハンドラの外で行うとよさそうです。

関数ハンドラの構文は

exports.handler = fucntion(event, context, callback) { // コード }

となっていますが、それぞれの引数は

  • 第1引数 ... イベントデータ
  • 第2引数 ... コンテキストオブジェクト
  • 第3引数 ... コールバックオブジェクト

と呼びます。コールバックオブジェクトは、関数の呼び出し元に情報を返したい時やエラーのログを残したい時に使います。

ちなみに、LambdaではS3へのファイルアップロードなどのイベント駆動で動作する関数は呼び出し元に情報を返す必要はなく、これらの実装(情報を返したりログを残す)はLambda関数を作成する上ではオプションです(やった方がいいです)。

イベントオブジェクトは、Lambda関数を呼び出した側がLambda関数に渡す情報が入ったオブジェクトで、S3のファイルアップロードによるイベントで起動される関数なら、ファイル名やバケット名が入ったイベントオブジェクトを受け取ります。

単純にリクエストパラメータとイベントオブジェクトの値をJSONとして対応させたいならAPI GatewayでLambda統合を利用すればよく、認証情報などがリクエストヘッダに含まれるのをイベントオブジェクトから取り出せるようにしたい、などカスタマイズする場合はマッピング*3を行います。

コンテキストオブジェクトはLambdaランタイムについての情報がわかる便利なプロパティやメソッドが生えています。たとえば、getRemaininTimeInMillis() メソッドで実行中のLambda関数のタイムアウトまでの残り時間が分かります*4

コールバック関数は、ハンドラ関数のオプションの第3引数で、Amazon API Gateway経由で呼び出される関数のように、関数の呼び出し元に情報を返すためのものです。

callback(Error error, Object result) のように第1引数がエラーを表すオブジェクトとなっていて、処理に成功した場合の第一引数はnullとします。

ログを出力したい場合は consle.log("message") でCloudWatchにログを書き込めます。 console.warn()console.error() なども利用できますが、CloudWatch側としては実質的な処理に違いはありません*5

もう一冊読みたいな、と思ってたんだけど一冊目だけで思ったよりわたわたしてしまいました。

現場からは以上です。

*1:イベントのJSONが渡されてハンドラ関数が実行されるという意味で

*2:具体的にはWebブラウザで動作するJavaScriptですね

*3:https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html など

*4:詳しくは http://amzn.to/1UK9eib

*5:ただ、運用としてはアラートレベルやログオブジェクトはしっかり管理した方がよいでしょう。