2019.10.02

負荷試験でgatlingを使ってみた(その2)

こんにちは。次世代システム研究室のM.Mです。
3年以上前になりますが、あるサービスの負荷試験をgatlingを使って実施することになり、「負荷試験でgatlingを使ってみた」というブログを書いたのですが、今月、また負荷試験をする機会があったので、第2段としてその際に実施した内容を共有したいと思います。
以前書いたブログは「負荷試験でgatlingを使ってみた」になります。

今回の負荷試験は前回のWeb画面に対するものではなく、WebAPIへの負荷試験でした。
gatlingを使い始めてから3年以上経過していることもあり、すでにAPIの負荷試験を実施するSimulationは多く存在していました。
それをコピーしてWebAPIのエンドポイントとパラメータだけ変えるだけかなと思っていましたが、残念ながらそれは通用しませんでした。

目次

1. APIの実行に必要なアクセストークンの取得方法が気になった
2. ファイルからAPIに送信するパラメータを取得するだけでは通用しなかった
3. 複数のAPIを同時に実行するSimulationを作りたかった

1. APIの実行に必要なアクセストークンの取得方法が気になった

すでに存在するAPIのSimulationもアクセストークンを取得するようになっていましたが、以下のようにアクセストークンを取得する処理も負荷試験のシナリオとして組み込まれていました。
pauseを入れるなど上手く考えてやっているなと思いましたが、負荷試験の結果にアクセストークン取得部分も含まれるしどうにかしたいなと思いました。
    // アクセストークンを取得するシナリオ
    val getTokenScn = scenario("GetTokenSimulation")
      .exec(http("Token")
        .post(token_api_endpoint)
        .headers(headers_0)
        .body(StringBody(
            """{"client_id": "xxxxx","password": "xxxxx"}""")
        ).asJSON
        .check(jsonPath("$..token").saveAs("_token"))
        .check(status.is(200)))
      .exec(session => {
          _token = session("_token").as[String]
          session
      })

    // 実際にテストをしたいAPIのシナリオ
    val xxxApiScn = scenario("XxxApiSimulation")
      .pause(3)
      .feed(csv("user_id.csv").random)
      .exec(session => {
          session.set("token", _token)
      })
      .exec(http("Xxx Api")
        .post("/api/xxx")
        .headers(headers_0)
        .header("Content-Type", "application/json")
        .header("api-token", "${token}")
        .body(StringBody("""{"user_id":"${USER_ID}"}""")).asJSON
        .check(status.is(200)))

    // トークンを取得するシナリオと実際にテストをしたいシナリオを設定
    setUp(
      getTokenScn.inject(constantUsersPerSec(1) during (1))
         .protocols(httpProtocol),
      xxxApi.inject(constantUsersPerSec(qps) during (during))
         .protocols(httpProtocol))

2行目でアクセストークンを取得するシナリオを用意して、33行目で実行するシナリオとして設定しています。
そこで、アクセストークンの取得は負荷試験のシナリオとは別に事前に取得するように変更しました。
Jsonをパースするとかその辺りまでは頑張っていないですが、以下のようにアクセストークンを取得するメソッドを用意して対応しました。
    // 新たに作成したトークンを取得するメソッドを使ってトークンを取得
    val token = this.getToken()

    // 実際にテストをしたいAPIのシナリオ
    val xxxApiScn = scenario("XxxApiSimulation")
      .feed(csv("user_id.csv").random)
      .exec(http("Xxx Api")
        .post("/api/xxx")
        .headers(headers_0)
        .header("Content-Type", "application/json")
        .header("api-token", "${token}")
        .body(StringBody("""{"user_id":"${USER_ID}"}""")).asJSON
        .check(status.is(200)))

    // 実際にテストをしたいシナリオのみを設定
    setUp(
      xxxApi.inject(constantUsersPerSec(qps) during (during))
          .protocols(httpProtocol))

    // トークンを取得するメソッド定義
    def getToken():String = {
        val url = new URL(token_api_endpoint)
        val con = url.openConnection().asInstanceOf[HttpURLConnection]
        HttpURLConnection.setFollowRedirects(false)
        con.setRequestMethod("POST")
        con.setRequestProperty("Content-Type", "application/json")
        con.setDoOutput(true)
        
        val out = new OutputStreamWriter(con.getOutputStream(), "UTF-8")
        out.write("""{"client_id": "xxxxx","password": "xxxxx"}""")
        out.flush()
        out.close()
        con.connect()
        
        val in = con.getInputStream
        
        val br = new BufferedReader(new InputStreamReader(in, "UTF-8"));
        val json = br.readLine()
        
        in.close()
        con.disconnect()
        
        val regex = "token\":\"(.+)\"".r
        return regex.findFirstMatchIn(json).get.group(1)
    }
2行目でトークンを取得するメソッドを利用してトークンを取得、21行目にてトークンを取得するメソッドを定義して、そのメソッド内でHttpURLConnectionを利用してトークンを取得しています。
負荷試験対象のAPIを実行する前に、そのAPIに必要な情報をWebAPIを通じて取得することができました。

2. ファイルからAPIに送信するパラメータを取得するだけでは通用しなかった

ファイルからAPIに送信するパラメータを取得するには、以下のように、feedを使うことで実現できます。
    val xxxApiScn = scenario("XxxApiSimulation")
      .feed(csv("user_id.csv").random)
      .exec(http("Xxx Api")
        .post("/api/xxx")
        .headers(headers_0)
        .header("Content-Type", "application/json")
        .header("api-token", "${token}")
        .body(StringBody("""{"user_id":"${USER_ID}"}""")).asJSON
        .check(status.is(200)))
2行目のfeedにて読み込むファイルを指定して、8行目で読み込んだ値を設定しています。
今回の負荷試験でそれが通用しなかったのは、そのAPIのパラメータには、送信するパラメータの値に応じて動的に変わるパラメータがあったからです。
例えば、XxxAPIのパラメータにはuser_id, user_name, checkkeyという3つのパラメータがあり、checkkeyはuser_idの値とuser_nameの値と秘密の文字列を特定のルールで連携してmd5した値といった具合です。
API側は受け取ったuser_id, user_nameから同じルールでcheckkeyを生成して、受け取ったcheckkeyと同じ値になるかチェックして、正しい利用方法であるか確認しています。
このcheckkeyも含めて、読み込むファイルに記載すればよいのですが、読み込むファイル生成時にcheckkeyの生成が必要になり、ファイルを作ること自体が困難になってしまします。
そこで今回はfeed(csv(“user_id.csv”).random)ではなく、一度普通にファイルを読み込み、ファイルから読み込んだパラメータの値をもとにcheckkeyを生成した形で配列を作ってfeedに設定しました。
      var feeder: Array[Map[String,String]] = Array.empty
      
      val file = Source.fromURL(
                    getClass.getClassLoader.getResource("data/user_id.csv")
                 )
      try {
        for (line <- file.getLines) {
          val user_id = line.trim()
          if (user_id.length != 0) {

            // user_idや秘密の文字列を使ってcheckkeyの生成する

            val mapdata = Map("user_id" -> user_id, "checkkey" -> checkkey)
            feeder = feeder :+ mapdata
          }
        }
      } finally {
        file.close
      }

      var scn = scenario("XxxApi")
        .feed(feeder.random)  // ここでファイルを読み込んで設定した配列を指定する
        .exec(http("Xxx Api")
          .post("/api/xxx")
          .headers(headers_0)
          .header("Content-Type", "application/json")
          .header("api-token", "${token}")
          .body(
              StringBody(
                  """{"user_id":"${user_id}", "checkkey":"${checkkey}"}"""
              )
          ).asJSON
          .check(regex("\"status\":\"SUCCESS\"")))
1行目でfeederに設定する配列を定義して、7行目のループでファイル内容を1行づつ読み込んでcheckkeyの生成、13-14行目でfeederの配列に追加していく。
22行目でそのfeederを設定する。
これで、ファイルから読み込んだパラメータを利用して生成されるパラメータにも対応することができました。

3. 複数のAPIを同時に実行するSimulationを作りたかった

API単体の負荷試験をするSimulationは多くありました。
複数のAPIを同時に実行するSimulationもあるにはありましたが、その内容は、API単体のSimulationに記載されているシナリオを、コピーして並べているだけといった状態でした。
API単体のSimulationに記載されているシナリオが修正されたら、その複数実行のSimulationも修正しないといけないのかと思うと効率が悪いなと思えました。
そこでシナリオを返すObjectを作っておいて、SimulationはそれらObjectを利用して実装するようにしました。

API単体のSimulation

class XxxApiSimulation extends Simulation {
    val conf = ConfigFactory.load("conf/api.conf")

    val url = conf.getString("url")
    var qps = conf.getInt("qps")
    var during = conf.getInt("during")
  
    //...
      
    val xxxApiScn = XxxApiObject.getScenario()
    setUp(
      xxxApiScn.inject(constantUsersPerSec(qps) during (during))
         .protocols(httpProtocol)
    )
}
10行目で利用するシナリオを取得しています。

複数のAPIを実行するSimulation

class SomeApiSimulation extends Simulation {
    val conf = ConfigFactory.load("conf/api.conf")

    val url = conf.getString("url")
    var qps = conf.getInt("qps")
    var during = conf.getInt("during")

    //...  
      
    val xxxApiScn = XxxApiObject.getScenario()
    val yyyApiScn = YyyApiObject.getScenario()
    setUp(
      xxxApiScn.inject(constantUsersPerSec(qps) during (during))
          .protocols(httpProtocol),
      yyyApi.inject(constantUsersPerSec(qps) during (during))
          .protocols(httpProtocol)
    )
}
10行目でXxxApiのシナリオ、11行目でYyyApiのシナリオを取得しています。

API単体のSimulationでも、複数のAPIを実行するSimulationでも、10行目で同じXxxApiのシナリオを取得しています。
Objectは以下のように定義して、シナリオを返すメソッドを定義しているだけとなります。
object XxxApiObject {
  
  def getScenario():io.gatling.core.structure.ScenarioBuilder = {
    try {
      //...
      var scn = scenario("XxxApi")
        .feed(csv("user_id.csv").random)
        .exec(http("XxxApi")
          .post("/api/xxx")
          .headers(headers_0)
          .header("api-token", "${token}")
          .body(StringBody("""{"user_id":"${USER_ID}"}""")).asJSON
          .check(status.is(200)))
      return scn
    } catch {
      case e: Exception =>
        e.printStackTrace()
        sys.exit(1)
    }
  }
}
これで複数のAPIを実行したい場合でも、シナリオの内容をコピーしなくても、使いたいシナリオを呼び出すだけにできました。

まとめ

テストスクリプトはどうしてもその場しのぎのものが多く、メンテナンスされない傾向が強いように思えます。
scalaをやったことがなくても、Javaを知っていれば何となく作れてしまうが、本当はどうするべきなのかもよく分からずスッキリしない。
負荷試験が完了すればスッキリしないまま終了という感じです。
テストについても重視して工数をとってスキルアップしていければなと思いました。

最後に、次世代システム研究室では、グループ全体のインテグレーションを支援してくれるアーキテクトを募集しています。アプリケーション開発の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ募集職種一覧からご応募をお願いします。

皆さんのご応募をお待ちしています。

  • Twitter
  • Facebook
  • はてなブックマークに追加

グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。

 
  • AI研究開発室
  • 大阪研究開発グループ

関連記事