ScalaとGatlingによるパフォーマンステスト 虎の巻

こんにちは、こんにちは。
これは「FOLIO Advent Calendar 2023」3日目の記事です。
なので、いつもより気持ち真面目に書きますよ!

さて、今回は「これさえ覚えればGatlingとScalaで最低限はパフォーマンスが書ける!」を目指して虎の巻を書いてみました。
「パフォーマンステストどこから手をつけたらいいかわかんないよ〜(主に私の話)」とか「シナリオを書くのが面倒くさいよ〜(主に私の話)」という人の重い腰を上げる一助となれば幸いです。

「Gatling」ってなに?

JavaベースのWebアプリケーション向けのパフォーマンステストのフレームワークです。
DSLが提供されていてシュッとシナリオが書けるところ、きれいめなレポートが出力できるところがGood Pointです。
(説明するまでもなく有名なツールですが、念のため...)

sbtプラグインの導入と依存ライブラリの追加

GatlingはJava、Kotlin、Scalaでテストを記述することができるようになっています。 Maven、Gradleのほか、sbt向けのプラグインも存在します。
こちらのプラグインを導入することでscalatestの要領でGatlingでテストを行うことができます。

plugins.sbt 設定例

addSbtPlugin("io.gatling" % "gatling-sbt" % "4.6.0")

build.sbt 設定例

// ...省略

lazy val gatlingVersion = "3.9.5"

lazy val gatling = (project in file("gatling"))
  .enablePlugins(GatlingPlugin)
  .settings(
    name := "subProject",
    libraryDependencies ++= Seq(
      "io.gatling.highcharts" % "gatling-charts-highcharts" % gatlingVersion % "test",
      "io.gatling" % "gatling-test-framework" % gatlingVersion % "test"
    )
  )

指定しているバージョンは記事執筆時点でのものです。
最新のバージョンはMavenリポジトリを参照してください。

テストプロジェクトの構成について

Gatlingのプラグインのデフォルトの設定では、プロジェクトの src/test/scala 以下を読みに行きます。
今回の場合は、 gatling/src/test/scala ディレクトリを掘ります。

% mkdir -p gatling/src/test/scala

% tree -I target
# .
# ├── README.md
# ├── build.sbt
# ├── gatling
# │   └── src
# │       └── test
# │           └── scala
# └── project
#     ├── build.properties
#     ├── plugins.sbt
#     └── project

変更することもできますがこの記事では説明を割愛します。

よくあるテストクラスの形

詳しい説明は後述しますが、Gatlingのよくあるテストクラスの形を示します。

// DSLのインポート
import io.gatling.core.Predef._
import io.gatling.http.Predef._

// 負荷をかける秒数の指定をする際に便利、予めインポートしておくと良い
import scala.concurrent.duration._

class Example1 extends Simulation {
  // 1. HTTPの設定
  val httpProtocol = http.baseUrl("http://localhost:9000").userAgentHeader("UserAgentHere")

  // 2. シナリオの記述
  val scn = scenario("シナリオ名").exec(
    http("リクエスト名").get("/path").header("key", "value")
  )

  // 3. シナリオの実行方法(負荷のかけ方)
  setUp(
    scn
      .inject(
        // 10ユーザーが30秒間、バラバラのタイミングでシナリオを実行
        rampUsers(10).during(30.seconds)
      )
      .protocols(httpProtocol)
  )
}

これを覚えておけばOK! Gatling チートシート

Scenario

アプリケーション上でHTTPリクエストなどを伴う何らかの操作(シナリオ)を定義する仕組みです。

// 最もシンプルな何もしないシナリオの例
val scn1 = scenario("シナリオ例1")

複数の操作を繋げて書くこともできます。

// ホーム画面にアクセス、10秒待ってから、概要画面に遷移
val scn2 = scenario("シナリオ例2")
  .exec(http("ホーム").get("/home"))
  .exec(pause(10.seconds))
  .exec(http("概要").get("/about"))

同じクラス内に複数のシナリオを定義することができますが、シナリオ名はユニークにする必要があります。

Session

シナリオ中の状態を保持する仕組みです。
前のリクエストのレスポンスを次のリクエストで使用したいシナリオを書きたいときに役立ちます。

Session オブジェクトは Map<String, Object> のようなデータ構造をしています。
execSession を引数として取り Session を返す関数を引数に取ります。(説明が難しい)
オブジェクトはイミュータブルであるため、値を追加、変更、削除した場合には新しく生成された Session を返してください。

説明よりもコードがわかりやすいと思います。

val scn = scenario("シナリオ例 3")
  .exec { session =>
    // セッションへ値をセットする
    val newSession1 = session.set("key1", "value")
    val newSession2 = newSession1.setAll(
      ("key2", "value"),
      ("key3", "value")
    )

    newSession2
  }
  .exec { session =>
    // セッションの値を削除する
    val newSession = session.remove("key")

    newSession
  }
  .exec { session =>
    // セッションの値を取得する
    // 値が存在しない場合、型が合わない場合に例外が発生する
    val as = session("key1").as[String]

    // 型が合わない場合に例外が発生する
    val asOption = session("key1").asOption[String]

    // 安全にアクセスできる
    val validated = session("key1").validate[String]
  }

Checks

リクエストに対するレスポンスの結果をチェックする仕組みです。
XMLJSON形式のレスポンスから値を展開、また展開した値をSessionに保持する機能も含みます。
Sessionのメソッドを呼び出すよりもこの機能を使用してSessionに値を格納するのが楽です。

簡単な例は以下のとおりです。

val scn = scenario("シナリオ例 4")
  .exec(
    http("リクエスト")
      .get("/me")
      .check(status.is(200)) // ステータスコードが200であるか?
  )

レスポンスボディを想定した場合

{
  "username": "taro",
  "age": 10
}

レスポンスボディの username をキー名 username として Session に保存する例は以下のとおりです。
取得したい値はJsonPathで指定します。
厳密さを求める場合は型や存在チェックを行うと良いでしょう。

val scn = scenario("シナリオ例 4")
  .exec(
    http("リクエスト")
      .get("/me")
      .check(status.is(200))
      .check(jsonPath("$.username").ofType[String])
      .check(jsonPath("$.username").saveAs("username"))
  )

(が、意識が低いためあまり厳密に書いたことはない...。)

HTTP

HTTPリクエストを組み立てる仕組みです。
基本的なメソッドは次のとおりです。

val scn = scenario("シナリオ例 5")
  .exec(
    http("GETの例")
      .get("/path")
      .queryParam("key", "value") // クエリパラメーターの指定
      .header("key", "value")
  )
  .exec(
    http("POSTの例1")
      .post("/path")
      .body(StringBody("""{"name": "taro", "age": 10}""")) // リクエストボディの指定
      .asJson // accept,content-type ヘッダーに application/json を付与
  )
  .exec(
    http("POSTの例2")
      .post("/path")
      .formParam("key", "value") // フォームデータの指定
      .asFormUrlEncoded // content-type ヘッダーに application/x-www-form-urlencoded を付与
  )

また、Gatlingには Expression Language という機能があり、文字列中に #{Sessionのキー名} のような形で値を指定すると、動的に値を展開することができます。

"#{key}"

// 配列へのアクセス
"#{key(n)}"

// オブジェクトへのアクセス
"#{foo.bar}"

formParam , body などは以下のように使用することもできます。
Expression LanguageはGatlingのDSLでのみ有効で、自前で宣言した関数内では機能しないため注意が必要です。
例として「sessionの値を展開する例2」ではパラメーターを展開することはできません。

// ... 省略
  .exec(
    http("sessionの値を展開する例1")
      .post("/path")
      .formParam("key", "#{session-key-here}")
      .asFormUrlEncoded
  )
  .exec(
    http("sessionの値を展開する例2")
      .post("/path")
      .formParam("key", session => { s"${session("session-key-here")}" })
      .asFormUrlEncoded
  )

Injection

シナリオの同時実行数や実行時間などを定義する仕組みです。
以下に個人的によく使用する関数を指定します。
(duration は実行時間です。)

関数 説明
nothingFor(duration) 一時停止
atOnceUsers(n) 同時にn個 シナリオを実行
rampUsers(n).during(duration) n回 シナリオを分散して実行
constantUsersPerSec(n).during(duration) 1秒ごとにのシナリオ実行数をn個ずつ増やす
rampUsersPerSec(n1).to.(n2).during(duration) 1秒ごとのシナリオ実行数をn1からn2に増やす

負荷をかけるシナリオの例は以下のとおりです。

setUp(
  scn1
    .inject(
      rampUsers(10).during(30.seconds), // 10ユーザーが30秒間、分散してシナリオを実行
      nothingFor(10.seconds), // 10秒一時停止
      rampUsersPerSec(10).to(100).during(60.seconds) // 60秒間で同時シナリオ実行数を10回から100回まで増やす
    )
    .protocols(httpProtocol),
  // シナリオを同時実行することも可能です
  scn2
    .inject(
      rampUsers(10).during(30.seconds), // 10ユーザーが30秒間、分散してシナリオを実行
      nothingFor(10.seconds), // 10秒一時停止
      rampUsersPerSec(10).to(100).during(60.seconds) // 60秒間で同時シナリオ実行数を10回から100回まで増やす
    )
    .protocols(httpProtocol)
)

負荷をかける側のネットワーク帯域が足りずに十分なテストが行えないことがあるため注意してください。

どうでもいい話

Injectionとか注入とか聞くとなんとなく依存性の注入(Dependency Injection)とか想像してしまうのですが全く関係ないです。
Gatlingではシナリオにユーザーを注入するみたいな世界観なんですかね?

テストの実行

テストシナリオを作成したプロジェクトで Gatling/test もしくは Gatling/testOnly テストクラス名 でテストを実行することができます。

sbt:gatling-template> projects
[info] In file:
[info]     gatling
[info]   * root
sbt:gatling-template> project gatling
[info] set current project to gatling-template (in build file: )
sbt:gatling-template> Gatling/testOnly クラス名

... 実行ログが出力 ...

Reports generated in 0s.
Please open the following file: file:///...hoge/fuga/gatling-playground/gatling/target/gatling/example6-20231109095951995/index.html
[info] Simulation Example6 successful.
[info] Simulation(s) execution ended.
[success] Total time: 114 s (01:54), completed 2023/11/09 19:01:43
sbt:subProject>

テストが完了するとレポートがHTML形式で出力され、 Please open the following file:... の行に表示されたパスに保存されています。
(デフォルトでは target/gatling 以下に出力されます。)

その他

  • アプリケーションレイヤー、インフラレイヤーごとにログやメトリクスを収集できるようにしておくと問題やボトルネックの解消の糸口になるため収集することをおすすめします。
    • JVMで動作するアプリケーションの場合はJMXやJFRなどが存在します。
  • パフォーマンステストを行う文脈を踏まえてシナリオを検討しましょう。
    • 現実には起こり得ない負荷や頻度が少ないため問題になることは少ないなどといった無視しても良い結果が含まれることがあります。
  • この記事ではテストの実行方法にかなりフォーカスしたものとなっています