HEARTBEATS

こんにちは。CTOの馬場です。

HTTP負荷テストツール Tsung の話の第2回です。

  1. Tsungで負荷テストしよう(1) - 基礎知識
  2. Tsungで負荷テストしよう(2) - 実践Tips
  3. Tsungで負荷テストしよう(3) - リアルな負荷のためのTips

今回は実際にガツガツ使ったうえで遭遇した課題と解決策を紹介します。 特にフォームまわりは大変で、 Tsungは "受け取ったフォームをSubmitする" ことが できない ので、 擬似的に実現するためにいろいろと工夫する必要があります。

なおこのエントリはTsung 1.6.0ベースで書いています。

N回繰り返す

forforeachrepeat で実現できます。

シナリオ作成時に回数が決まっているものは for 、 取得したHTML内の全imgタグを取得するような回数が動的に決定するものは foreach 、 ジョブをキューイングし結果が画面に反映されるまでポーリングするような特定条件を満たすまで繰り返すものは repeat を使うのがオススメです。

6.7.5. Loops, If, Foreach

リダイレクトをフォローする

会員制サイトのログイン・ログアウトなど、 リダイレクトを利用するシーンは多々あります。

リダイレクトをフォロー(追跡)するためには、以下の段取りが必要です。

  1. リクエストをサーバに送信する
  2. サーバからリダイレクトレスポンスを受け取る
    • リダイレクトレスポンスのLocationヘッダを読み取る
  3. Locationヘッダで指示されたURLにリクエストを送信する

1.はふつうの処理なのでよいのですが、 2と3は少し工夫が必要です。

この話、実はFAQに書いてあります。 せっかくなのでFAQのコードをもとに説明します。

10.5. Can I dynamically follow redirect with HTTP?

<request>
  <dyn_variable name="redirect" re="Location: (http://.*)\r"/>
  <http url="index.html" method="GET" ></http>
</request>

<request subst="true">
  <http url="%%_redirect%%" method="GET"></http>
</request>
  • 1つ目の request
    • dyn_variableredirect という名前の変数に値を格納します
    • re 属性を使い正規表現で値を抽出します
    • dyn_variable を除いたぶん (= http ) が1つ目のリクエスト自体の設定です
      • HTTPで GET index.html というリクエストを出す設定になっています
  • 2つ目の request
    • request タグの subst 属性を true にすることで、リクエスト内での変数利用を有効にします
    • http タグの url 属性で、1つ目の request で取得した redirect という名前の変数を展開します
      • ※Tsungでは %%_変数名%% で変数展開します
      • %%_ も必須です
      • ややこしい

実際に使うときは1つ目の request できちんと redirect に値が入らないことがあるので、 ifmatch でエラー処理しましょう。

6.7.4. Checking the server's response

この例では re 属性を使って正規表現で文字列を特定しています。 正規表現の他にXPathやJSONPathを使うこともできます。

csrf_tokenauthenticity_token を読み込んで次のPOSTで使う場合は大抵XPathを使います。

最近はAPIバックエンドなどJSONがインターフェイスになるシステムが多いので、 JSONPathも意外とよく使います(使いました)。

6.7.3. Dynamic variables

ID・パスワードを別ファイルから読む

これはドキュメントにありますが、 CSVファイルを読み込んで変数として利用することができます。

こちらもドキュメントのコードをもとに解説します。

6.7.2. Reading external file

<setdynvars sourcetype="file" fileid="userlist.csv" delimiter=";" order="iter">
 <var name="username" />
 <var name="user_password" />
</setdynvars>

<request subst="true">
  <http url='/login.cgi' version='1.0'
    contents='username=%%_username%%&amp;password=%%_user_password%%&amp;op=login'
  content_type='application/x-www-form-urlencoded' method='POST'>
  </http>
</request>
  • setdynvars
    • userlist.csv というファイルで、デリミタは ; と設定しています
    • ファイルの1列目の値が username 、2列目の値が user_password に設定されます
    • 順番ではなくランダムに利用する場合は order 属性を random にします
  • request
    • request タグの subst 属性を true にすることで、リクエスト内での変数利用を有効にします
    • http タグの method 属性に POST を設定します
      • POSTするときは content_type を忘れずに指定しましょう
      • contents 属性で実際に送信するデータを設定します
      • 変数指定のときに、変数名の先頭の _ を忘れずに
      • contents& 区切りで複数キー・値を設定できるものですが、XML内に記載するため &&amp; と書く必要があります
      • 控え目に言ってめんどくさいです
      • というわけで contents に設定する値はHTML encodeしたうえでXML用にエスケープしておく必要があります
      • 変数展開(置換)が実行されるのはXMLを読み取った後なので、1つの変数で複数キーをむりくり設定したい場合は & はXMLエスケープしません

XMLがSyntax Error になる

いろいろやった結果XMLが壊れてしまうことが稀によくあります。 そんなときは xmllint を使うとエラー箇所をわかりやすく教えてくれます。

$ xmllint example.xml
example.xml:5: parser error : Opening and ending tag mismatch: tsung line 3 and clients
  
            ^
example.xml:6: parser error : Extra content at the end of the document
  
  ^

なお xmllint ではXPathを試すこともできます。 試行錯誤の段階で重宝するのでぜひご活用ください。

ちなみに Google Chrome のDeveloper Tool で特定したいElementsを選択して右クリックすると 「Copy XPath」 というメニューがあるので、 特定の要素を取得するためのXPathを書く参考になります。

cat example.xml | xmllint --xpath "//form//input" --html - 2>/dev/null

ファイルアップロード

画像などのバイナリファイルアップロードするのはちょっと手間です。

Tsung自体には機能がないようなので、 独自モジュールを作成します。

今回は hb という名前のモジュールを作成し、 シナリオ中のhttpのcontentsで %%hb:image_file と書くことでデータを送信できるようにします。

まず以下のようなファイルを hb.erl という名前で作成します。 なんのことはない、ブラウザが送るデータと同じものを合成して送信するだけです。

-module(hb).
-export([image_file/1]).

image_file({_,DynVars})->
  Filename = "/home/centos/scenario/img/kuma.jpg",
  {_,ImageData,_Size}=ts_utils:read_file_raw(Filename),
  lists:concat([
              "------WebKitFormBoundaryF6QBOK9ESy7Gy7O8\r\n",
              "Content-Disposition: form-data; name=\"files[]\"; filename=\"", Filename, "\"\r\n",
              "Content-Type: image/jpeg\r\n",
              "\r\n",
              binary_to_list(ImageData),
              "\r\n",
              "\r\n",
              "------WebKitFormBoundaryF6QBOK9ESy7Gy7O8--"
              ]).

次に上記ファイルをコンパイルしTsungのディレクトリに配置します。

erlc -o /usr/lib64/erlang/lib/tsung-1.6.0/ebin/ hb.erl

コンパイルが成功すると /usr/lib64/erlang/lib/tsung-1.6.0/ebin/hb.beam ができるはずです。

この状態で以下のようなシナリオを書くことでファイルをアップロードできます。

<request subst="true">
  <http url='http://localhost/file_upload' version='1.1' contents='%%hb:image_file%%' content_type="multipart/form-data; boundary=----WebKitFormBoundaryF6QBOK9ESy7Gy7O8" method='POST'>
  </http>
</request>
  • request
    • 今回のようにモジュール呼び出しの場合は %%モジュール名:関数名%% と書きます。 _ は不要です
    • 呼び出す関数はエクスポートされている必要があります

XML用に取得した値をエスケープする

特に csrf_tokenauthenticity_token など記号を含む場合がありまして、 そのような場合はGET/POSTする際にURL encodeしておく必要があります。

Tsung標準ではそのような処理を見つけられず、 やむなく自分で書きました。ということがありました。 ( http_uri モジュールが意図通りに動かずやむなく... )

シナリオ中で authenticity_tokenraw_authenticity_token という名前で取得し、 それを %%hb:authenticity_token という形で呼びたすためのモジュール例は以下の通りです。

対象文字が足りないなど不完全なのであくまで参考として見てください。

-module(hb).
-export([authenticity_token/1, escape/1]).

escape([H|T], Val) when H =:= equal ->
    Replaced = re:replace(Val, "=", "%3D", [global, {return, list}]),
    escape(T, Replaced);
escape([H|T], Val) when H =:= slash ->
    Replaced = re:replace(Val, "/", "%2F", [global, {return, list}]),
    escape(T, Replaced);
escape([H|T], Val) when H =:= plus ->
    Replaced = re:replace(Val, "\\+", "%2B", [global, {return, list}]),
    escape(T, Replaced);
escape([], Val) ->
    Val.

escape(Val) ->
    escape([equal, slash, plus], Val).

authenticity_token({_, DynVars}) ->
    {_,Val}=ts_dynvars:lookup(raw_authenticity_token,DynVars),
    escape(Val).

フォームをSubmitする

冒頭にも書きましたが、 Tsungは "受け取ったフォームをSubmitする" ことが できない です。

なので、自分で必要な要素を取り出してがんばってなんとかしましょう。

具体的にはページ内の input タグと textarea タグを取り出して、 値をうまいこと取り出したり設定したりしてなんとかします。

受け取ったHTMLからタグを取り出すためのXPathの例は以下の通りです。

<dyn_variable name="all_input_tags" xpath='//input[@type="text" or @type="password" or @type="hidden" or @type="file"]|//input[@type="checkbox" and @checked="checked"]|//input[@type="radio" and @checked="checked"]|//textarea|//select/option/parent::*'/>

このXPathから以下のようなデータが取得できるはずです。

Val =
  [
   {<<"input">>,
    [ {<<"type">>, <<"radio">>}, {<<"value">>, <<"false">>}, ... ],
    []},
    ...,
   {<<"select">>,
     [ {<<"class">>, <<"select_sel_item">>}, {<<"name">>, <<"select_name">>}, ... ],
     [
      {<<"option">>,[{<<"selected">>,<<"selected">>},{<<"value">>,<<"0">>}],[<<"x">>] },
      {<<"option">>,[{<<"value">>,<<"1">>}],[<<"a">>]},
      {<<"option">>,[{<<"value">>,<<"2">>}],[<<"b">>]},
      {<<"option">>,[{<<"value">>,<<"7">>}],[<<"c">>]}
     ]
   },
   {<<"textarea">>,
     [{<<"class">>, <<"xxx_text_field">>}, {<<"maxlength">>, <<"400">>}, {<<"name">>, <<"foo_name">>}, ... ],
     [<<"\n">>]
   },
    ...
  ]

あとはこのデータを処理するモジュールを作成します。


-module(hb).
-export([all_input_tags_to_contents/1, tag_to_contents/1]).

find_selected_option_value([{<<"option">>, Attrs, _}|T]) ->
    case lists:keymember(<<"selected">>, 1, Attrs) of
        false -> find_selected_option_value(T);
        _ ->
          {_,Value}=lists:keyfind(<<"value">>, 1, Attrs),
          Value
    end;
find_selected_option_value([_|T]) ->
    find_selected_option_value(T);
find_selected_option_value([{<<"option">>, Attrs, _}]) ->
    {_,Value}=lists:keyfind(<<"value">>, 1, Attrs), Value;
find_selected_option_value([]) ->
    false.

select_to_kv(Attrs, Subs) ->
    {_,Name}=lists:keyfind(<<"name">>, 1, Attrs),
    Value = find_selected_option_value(lists:reverse(Subs)),
    {Name, Value}.

input_to_kv(Attrs) ->
    {_,Name}=lists:keyfind(<<"name">>, 1, Attrs),
    case lists:keyfind(<<"value">>, 1, Attrs) of
        {K,V} ->
            {Name,V};
        false ->
            {Name,<<"">>}
    end.

tag_to_contents({<<"select">>, Attrs, Subs}) ->
  case Result = select_to_kv(Attrs, Subs) of
      {Name,Value} = Result when Value =:= false -> "";
      _ ->
          {Name,Value} = Result,
          lists:concat([http_uri:encode(binary_to_list(Name)),
                        "=",
                        encode_uri_rfc3986:encode(re:replace(Value,"\n","",[global,{return,list}]))])
  end;
tag_to_contents({<<"textarea">>, Attrs, [Text]}) ->
    {_,Name}=lists:keyfind(<<"name">>, 1, Attrs),
    lists:concat([http_uri:encode(binary_to_list(Name)),
                  "=",
                  encode_uri_rfc3986:encode(re:replace(binary_to_list(Text),"\n","",[global,{return,list}]))]);
tag_to_contents({<<"input">>, Attrs, _}) ->
    {Name,Value} = input_to_kv(Attrs),
    lists:concat([http_uri:encode(binary_to_list(Name)),
                  "=",
                  encode_uri_rfc3986:encode(binary_to_list(Value))]).


tag_to_contents("", [Tag|T]) ->
    tag_to_contents(tag_to_contents(Tag), T);
tag_to_contents(Contents, [Tag|T]) ->
    NewContents = lists:concat([Contents, "&", tag_to_contents(Tag)]),
    tag_to_contents(NewContents, T);
tag_to_contents(Contents, []) ->
    Contents.


all_input_tags_to_contents({_,DynVars}) ->
    {_,Val} = ts_dynvars:lookup(all_input_tags, DynVars),
    re:replace(tag_to_contents("", Val), "&+", "\\&", [global,{return,list}]).

実際にはパースして値を再利用するだけではなくて、 入力するところは入力するなどの操作が必要だと思うので、 なんというかがんばってください。

値の再利用をしなくていいよう、 シナリオとデータを工夫することで全体の工数が下がるのでそちらをオススメします。

なんかすごい謎のコードが登場しましたが、 Erlangは書いていてとても楽しいのでぜひチャレンジしてみてください!!!!!

→もひとつ書きましたTsungで負荷テストしよう(3) - リアルな負荷のためのTips

株式会社ハートビーツの技術情報やイベント情報などをお届けする公式ブログです。