Galapagos Engineering Blog

株式会社ガラパゴス エンジニアチームによるブログです。

Gatlingで負荷試験してみる

ご機嫌よう、ガラパゴスのおとめです。

先日出社したら机に「Amazon Web Services 負荷試験入門 ークラドの性能の引き出し方がわかる」という本がおもむろに意味ありげに置かれていました。

さて、負荷試験と言ったらまず攻撃ツールに考えが及びますね? たとえばJMeterとかが有名どころかしらん? 先述の本には以下の攻撃ツールが掲載されています。

Erlangで書かれたTsungが掲載されているのが興味深いところですが、 ガラパゴスではwrkGatlingが使われたりしています。

そこで今日はGatlingについてお話してみようと思います。

なぜGatling?

例えばJMeterでシナリオテストを作成する時はGUIで操作したりするかと思います。保存するとXMLが吐き出されましたね?

でもシナリオが複雑になると、ちまちまGUIで設定するのが面倒だったり、引き継いだ場合など−−数年前に自分で書いたシナリオを再び引っ張り出してきた場合もそうですが−−前任者の真心が読み取りにくかったりしますね? さらに (これは個人的事情ですが) ガラパゴスのおとめにはXMLに対する脆弱性があって、ちょっとシナリオを編集しようと思ってもXMLを見ると気絶してしまいます。

そういうことがあって、できればシナリオもリーダブルかつライタブルであって欲しいというお気持ちが溢れ出てきます。そこで何がリーダブル/ライタブルなのかというお話になるのですが、簡単簡潔なDSLとかでコードにそのまま書いてあるといいな……でも一方で負荷はちゃんとかけて欲しいですね。

それGatlingでできます

使ったことはありませんがLocustもシナリオの記述がPythonでそのまま書くだけのようなのですが、Pythonかあ……。

実行環境の準備

GatlingはScalaで動いて、ScalaJVMで動きますので、環境に合わせてこれらをインストールしましょう。Gatlingそのものは公式からダウンロードしたzipを適当な場所に展開しておけば良いです。

えっScalaなの? と身構える必要はありません。

テストを書いてみる

簡単なシナリオを書いてみる

先ほど簡単簡潔なDSLでコードにそのまま書いて、と書きました。シナリオをちょっと見てみましょう。シナリオそのものの記述はこのようになります。

val scn = scenario("シナリオの名前をお好きに ")
          .exec(http("リクエストの名前をお好きに")
                .get("/url")
                .check(status.is(200)))
          .pause(1) // 次のリクエストまでの待ち時間
          .exec(http("次のリクエストの名前をお好きに")
                .post("/other/url")
                .body(StringBody("""{"json":"body"}""").asJSON)
                .check(statsu.is(201)))
                .check(jsonPath($.something[0].json)))
          .pause(1)
          .exec(... // 以下続く

何やらGETして、その直後に何やらPUTして、結果を確認していることが読み取れます。しかもとても読みやすいですね!

テスト実行部分はこのようになります。

object TestA {
  val scn = scenario("...")
            .exec(... // (ry
            ...
}
val testA = scenario("scenario a").exec(TestA.scn)
val httpConf = http.baseURL("http://exapmle.com")
setUp(testA.inject(atOnceUsers(10))).protocols(httpConf)

いっぺんに10アクセスすることが読み取れますね。

ヘッダを指定してみる

もちろんリクエストヘッダも簡単に設定できます。例えば何かの認証ヘッダを設定してみましょう。

val header = Map("auth" -> "header",
                 "and" -> "something")
val scn = senario("なまえ")
          .exec(http("(ry")
                .get("/some/url")
                .headers(header))

レスポンスの値を利用してみる

ここではレスポンスボディがJSONの場合を例にしますが、JSONパスを指定してセッション変数にしまっておいて、後で使うことができます。

.exec(http("something post")
      .post("/url")
      .body(StringBody("""{"json":"body"}""").asJSON)
      .check(statsu.is(201)))
      .check(jsonPath($.something[0].id).saveAs("some_id"))
.exec(http("something get")
      .get("/url/" + "${some_id}")
      .check(status.is(200)))

セッション変数には${variable_name}のようにアクセスします。

変数を使ってみる

さて、ここまでお読みになられたら普通のScalaな変数も簡単に使えそうな気がしますね。例えばこんな風に。

val foo = "bar"
...
.exec(http("simething get")
      .get("/url/" + foo)
      .check(status.is(200)))

もちろんなんの問題もありません。また、独自の値をセッションに変数を入れて使うこともできます。

val my_session_vals = exec({_.setAll(("foo", "bar"))})
...
.exec(my_session_vals)
.exec(http("simething get")
      .get("/url/${foo}")
      .check(status.is(200)))

複数のシナリオを同時に実行する

もちろん複数シナリオを同時に実行することもできます。setUpに並べて書くだけというお手軽さです。

object TestA {
  val scn = exec(... // 省略
}
object TestB {
  val scn = exec(... // 省略
}
val testA = scenario("scenario a").exec(TestA.scn)
val testB = scenario("scenario b").exec(TestB.scn)
setUp(testA.inject(atOnceUsers(10)),
      testB.inject(atOnceUsers(10))).protocols(httpConf)

負荷のかけ具合を調整する

setUpに書いてあるtestA.inject(atOnceUsers(10))はいっぺんに10アクセスして、それだけでおしまいですが、これはもちろん調整できます。

例えば以下のような設定ができます。

  • atOnceUsers(n)n ユーザーが一度に。
  • rampUsers(n) over(t)t 秒かけて n ユーザーに線形で増加。
  • constantUsersPerSec(n) during(t)t 秒間 n ユーザーをキープ。
  • rampUsersPerSec(n) to(m) during(t)t 秒かけて n ユーザーから m ユーザーに線形で増加。
  • heavisideUsers(n) during(t):ヘヴィサイドの階段関数を使って t 秒間で n ユーザーになるまで段階的に増加。
  • splitUsers(n) into(s) separatedBy(t)t 秒ごとに n ユーザーが s を繰り返す。

Feederを使う

API1とAPI2の呼び出し間隔をランダムにしたり、ランダムな値を設定したりするには、Feederを使ってセッションに値を渡すと良いかもしれません。以下の例では、${key}というセッション変数で1から10の数値がランダムに取れます。

val feeder = (1 to 10)
             .toArray
             .map(n => Map("key" -> n))
             .random
val scn = // 中略
          .feed(feeder)
          .pause("${key}")
          ... // 以下略

もちろんこの簡単な例では、そんなの普通に乱数にすればいいような気もしますが、Feederが威力を発揮するのは、例えばCSVから読み込んで使いたい時です。

例えば、tokenという列に何かテスト用の認証情報が、idには対応するIDが書いてあるCSVを使って、IDのパスにアクセスするテスト、のようなことができます(CSVに列名は必須です)。

val feeder = csv("/path/to/csv.csv").random
val numOfFeeds = feeder.records.length
val scn = // 中略
          .feed(feeder)
          .exec(http("test")
                .headers(Map("auth_token": "${token}"))
                .get("/path/${id}")
                .check(status.is(200)))

ところでFeederを使ってテストしていると、以下のようなエラーが出ることがあります。

Exception in thread "main" java.lang.IllegalStateException: Feeder is now empty, stopping engine

Feederは単なるIterableで、内部的には.nextして次の値を取っているだけですので、全部を使い切ってしまうとこのエラーになります。つまり負荷に対して用意した値が少なすぎるということですね。Feederを使い切らないようにテストを見直しましょう。

テストしてみる

テストはコンソールから実行します(テストコードを/path/to/gatling/user-filesにコピーする必要があります)。gatlingを展開したパスに移動して、bin/gatling.shを実行します。コンパイルエラーがなければ、作成したテストが[6]あたりに出てきます(1〜5はデフォルトで入っているテストです)。

$ cd /path/to/gatling
$ bin/gatling.sh
GATLING_HOME is set to /*****/gatling/gatling-charts-highcharts-bundle-2.3.0
Choose a simulation number:
     [0] computerdatabase.BasicSimulation
     [1] computerdatabase.advanced.AdvancedSimulationStep01
     [2] computerdatabase.advanced.AdvancedSimulationStep02
     [3] computerdatabase.advanced.AdvancedSimulationStep03
     [4] computerdatabase.advanced.AdvancedSimulationStep04
     [5] computerdatabase.advanced.AdvancedSimulationStep05
     [6] **********.*********

数字を選ぶとテストが始まります。

テスト結果を見てみる

テストするとコンソールにも概要が表示されます(一部伏せさせていただきます)。

================================================================================
2017-11-22 17:04:47                                          17s elapsed
---- Requests ------------------------------------------------------------------
> Global                                                   (OK=40     KO=0     )
> **********                                              (OK=10     KO=0     )
> **********                                          (OK=10     KO=0     )
> **********                                                 (OK=10     KO=0     )
> **********                                          (OK=10     KO=0     )

---- ********** load test ----------------------------------------
[##########################################################################]100%
          waiting: 0      / active: 0      / done:10
================================================================================

Simulation **********.********** completed in 16 seconds
Parsing log file(s)...
Parsing log file(s) done
Generating reports...

================================================================================
---- Global Information --------------------------------------------------------
> request count                                         40 (OK=40     KO=0     )
> min response time                                     29 (OK=29     KO=-     )
> max response time                                    180 (OK=180    KO=-     )
> mean response time                                    70 (OK=70     KO=-     )
> std deviation                                         32 (OK=32     KO=-     )
> response time 50th percentile                         69 (OK=69     KO=-     )
> response time 75th percentile                         87 (OK=87     KO=-     )
> response time 95th percentile                        111 (OK=111    KO=-     )
> response time 99th percentile                        163 (OK=163    KO=-     )
> mean requests/sec                                  2.353 (OK=2.353  KO=-     )
---- Response Time Distribution ------------------------------------------------
> t < 800 ms                                            40 (100%)
> 800 ms < t < 1200 ms                                   0 (  0%)
> t > 1200 ms                                            0 (  0%)
> failed                                                 0 (  0%)
================================================================================

が、素敵なことに詳細な結果レポートがHTMLで出力されます。

例えば統計情報は綺麗なグラフで表示されます(この画像にはありませんが、NG分は目立つ赤で表示されます)。

f:id:glpgsinc:20171122183138p:plain

レスポンスタイムの推移などもみれますし、カーソルを合わせると詳細を見ることもできます。

f:id:glpgsinc:20171122183301p:plain

同様にユーザー数の推移やリクエスト・レスポンスの推移なども見ることができます。

これらの情報は、全リクエストの統計でも見ることができますし、execブロックごとに見ることもできます。

もちろん、この結果レポートでわかるのは全体のレイテンシだけですので、プロファイリングなどは適切に行なっていると良いですね。


突然ですがガラパゴスでは負荷試験にも覚えのあるエンジニアを絶賛募集中です。ご興味をお持ちの方はぜひ弊社の採用ページをご覧ください。

www.glpgs.com

では、ご機嫌よう。

--

この記事は業務の一環として業務時間中に書きました

Slackのボットに機能を追加してみた。

ガラパゴスのコードヒーヨアン(twitter: @luinily)です。

この夏、弊社で行われている三つの勉強会を一つのLT会にまとめることになりました。それまではiOS会、Android会、web会を別々で行なっていました。 そのついでに今までの勉強会の資料まとめについての問題をまとめました。

  • 資料は共有されているが、まとめたページがない
  • 後日また見たいときには見つけにくい
  • 手動だと誰も更新しなくなる

ということで、使いやすいまとめる方法が必要

それまでには資料の共有にメールとSlackがよく使われていたので、Slackに資料が共有されたら自動的にまとめてくれるようにできないか?

そこで、弊社のSlackで使われているボットに新機能を追加すれば、行けるかもしれないと考えました。社内の情報共有にQiita Teamも使っていますので、下記の結論に至りました。

Darwinを使ってSlackで共有された資料をQiitaにまとめてくれるボット機能を作りたい。

その機能を作るにあたって、三つのフェーズに分けました:

  1. ボットに提供された資料の情報を取得させる
  2. Qiitaの投稿に情報を追加する
  3. 1と2を繋げる
  4. ボナス:他のチームで使いやすくする

ボット編

社内で使われているボットはHubotの一種です。Hubotの機能開発はJavascriptCoffeeScriptで行えます。既存していた社内スクリプトCoffeeScriptで書いてありましたので、新機能もCoffeeScriptで書くことにしました。書いたスクリプトをボットに追加するには、「scripts」フォルダーに入れて、ボットを再起動するだけです。

スクリプトは下記のようになります:

module.exports = (robot) ->
    robot.hear /lt「(.*)」リンク:(.*)/i, (msg) ->
        title = msg.match[1]
        link = msg.match[2]
        name = msg.message.user.name
        addDocument(msg, title, link, name, () ->
            msg.send "[#{getNextFriday()}] #{name}さんの「#{title}」を登録しました。\n #{ARTICLE_URL}"
)

解説しましょう。

ボットに関数の追加

module.exports = (robot) ->

ここでボットに新しい関数を追加します。

ボットの反応トリガーの定義

次の行でボットが何に反応するかのを定義します。二つの設定を行います:ボットのモードとボットが反応する文字列のパターン。

robot.hear /lt「(.*)」リンク:(.*)/i, (msg) ->

可能なモードは二つあります:

  • robot.respond: ボットにダイレクトメッセージが送られたときに、そのメッセージが文字列のパターンとマッチしたときに反応する
  • robot.hear: 今回使うモードです。ダイレクトメッセージが送られてなくても、ボットがいるチャネルに文字列のパターンにマッチするメッセージが発信されたときに反応する。

そのあとは文字列のパターンの定義を書きます:lt「(.*)」リンク:(.*)

(.*)正規表現、どの文字列も入れますので、LTのタイトル、リンクの入力のために使います。

今回の例では、ボットが入っているチャネルで、lt「(ではじまる文字列に、」リンク:がある文字列が発信されれば、ボットが反応します。

メッセージから文字列の取得

ボットのマッチパターンに正規表現がある場合、msg.match正規表現に当たる文字列の取得ができます。

今回の例はこう使っています:

title = msg.match[1]
link = msg.match[2]

msg.match[1]で一つ目の正規表現の文字列を取得します、msg.match[2]で二つ目の正規表現の文字列を取得します。

lt「TITLE」リンク:LINKというメッセージが配信された場合、msg.match[1]TITLEを取得し、msg.match[2]LINKを取得します。

メッセージを送信したユーザーの名前の取得

msg.message.user.nameでスラックでメッセージを送った人のハンドルを取得します。

返信の送信

ボットの処理が完了すれば、スラックの方で返信を送りたい。そのためにmsg.sendを使います。msg.sendの後にある文字列がスラックの方に送信されます。

msg.send "[#{getNextFriday()}] #{name}さんの「#{title}」を登録しました。\n #{ARTICLE_URL}"

ボット編まとめ

CoffeeScriptを使って、Hubotの新しい機能を作る。今回はLTの資料をまとめたいので、LTのタイトル、資料へのリンク、作者の名前を取得できるようにしました。Slackで使ってみるとこのようになります:

f:id:glpgsinc:20170927125206j:plain

この情報でまとめ記事に追加する文書を作って、Qiitaの投稿を取得し、更新する必要があります。

Qiita編

始めた時はCoffeeScriptで通信する方法が見つからなかったので、とりあえずJavascriptAPIをいじって必要な処理を作ることにしました。Qiitaでは三つの作業を行いたい:

  • 記事の取得
  • 記事の編集
  • 記事の更新

使用するAPI

QiitaのAPIを調べて、今回使うことになるAPIを見つけました。

  • 記事の取得:GET /api/v2/items/:item_id
  • 記事の更新:PATCH /api/v2/items/:item_id

機能として新しく記事を作る必要性が低いので、予めに用意した記事を取得して編集するだけにしました。

APIを使ってみると・・

まずは上記の記事の取得APIを使ってみました。 実行したら、401エラーが発生してしまいました。エラー401はアクセス権限がないときのエラーですので、権限周りに問題があります。

Qiita APIの権限設定

複数のやり方があるようですが今回はアプリケショントークンを使うことにしました。Qiita Teamの設定で、アプリケーションの連携のためのトークンを作ることができますので、そのトークンをAPIのアクセスリクエストのヘッダーに設定します。

function getArticle(success, failure)  {
    $.ajax({
      url: API_END_POINT + '/api/v2/items/' + ITEM_ID,
      type: 'GET',
      dataType: 'json',
      success: success,
      error: failure,
      beforeSend: setHeader
    });
}

function setHeader(request) {
  request.setRequestHeader('Authorization', "Bearer " + TOKEN);
}

こうしてリクエストをすれば、記事の取得に成功できました。

Qiitaの記事の構造

APIで取得した記事は下記の構造になっています。

rendered_body: HTML形式の本文
body: Markdown形式の本文
coediting: この投稿が共同更新状態かどうか (Qiita:Teamでのみ有効)
comments_count: この投稿へのコメントの数
created_at: データが作成された日時
group: Qiita:Teamのグループを表します。
id: 投稿の一意なID
likes_count: この投稿への「いいね!」の数(Qiitaでのみ有効)
private: 限定共有状態かどうかを表すフラグ (Qiita:Teamでは無効)
reactions_count: 絵文字リアクションの数(Qiita:Teamでのみ有効)
tags: 投稿に付いたタグ一覧
title: 投稿のタイトル
updated_at: データが最後に更新された日時
url: 投稿のURL
user: Qiita上のユーザを表します。

今回はマークダウンの本文にLTの資料情報を追加したいので、bodyを使います。

記事の編集

bodyの中身は文字列になっています。今回の機能では、一つのLTの資料情報を一行にして、本文の中に挿入する場所を見つけて、挿入します。

LT会は毎週金曜日で行われていますので、下記のように挿入するようにしました:

  • 次の金曜日の日付を計算
  • 本文の中に、その日付のタイトルを探す
  • あった場合、日付のタイトルの下に新しいLT資料の情報を挿入
  • なかった場合、日付のタイトルを挿入し、その下新しいLT資料の情報を挿入

記事はこのような構造になります: f:id:glpgsinc:20170921191950j:plain

記事の更新

取得した記事オブジェクトのbodyに新しくできた文字列を代入して、PATCH APIで記事を更新します。

function applyEdit(success, failure)  {
    $.ajax({
headers: {'Content-Type' : 'application/json'},
      url: API_END_POINT + '/api/v2/items/' + ITEM_ID,
      type: 'PATCH',
      data: JSON.stringify(article),
      success: success,
      error: failure,
    complete: function() {
                console.log("patch complete!")
              }
      beforeSend: setHeader
    });
}

Qiita編まとめ

APIを使って記事を取得して、編集した文書を送信してQiitaの記事を更新します。

ただし、この時点ではCoffeeScriptのボット機能とJavascriptの記事編集機能をまとめる必要があります。

統合編

その時点ではJavascriptで書いたコードをそのままCoffeeScriptで使う方法を調べていましたが、他の社員が書いたスクリプトにヒントがあるかみていたら、新しくできていたスクリプトにhubotに通信機能があることがわかって、そのままCoffeeScriptで支えることがわかりましたので、Javascriptで書いてたコードをCoffeeScriptに書き換えました。

getArticle関数をCoffeeScriptで書き直すとこのようになります:

getArticle = (msg, callback) ->
  msg.http(URL)
    .header('Content-Type', 'application/json')
    .header('Accept', 'application/json')
    .header('Authorization', "Bearer #{QIITA_TOKEN}")
    .get() (err, res, body) ->
      if err != null
        msg.send err.message
      else
        callback(JSON.parse(body))

ちょっとみてみると、msgが引数に追加されました。msgはhubotが受信したメッセージのオブジェクトです、ボット編ですでに正規表現のところの文字列やユーザのハンドルの取得に使っていました。

記事の取得には、そのオブジェクトが入らないと思われるのですが、実際通信機能がそのオブジェクトについているので、ここでは使います。 .http()APIのURLを設定して、.header()で必要なヘッダーを一つずつ設定します。最後にget()でリクエストをして、リクエストが終わった時のコンプレションハンドラーを設定します。

リクエストが終了した時は、エラーの場合msg.send err.messageでスラックの方でエラーメッセージを返します、成功した場合、getArticle()に渡したコンプレションヘッダーに記事のbodyを渡します。

編集用のAPIで同じのように書きます、get()の代わりにpatch()を使います。

文字列処理のところも書き直して、記事まとめ機能が完了しました。

このようなメッセージをボットのいるチャネルで送ると f:id:glpgsinc:20170927125246j:plain

このようにまとめ記事に方法が追加されます。 f:id:glpgsinc:20170921193953p:plain

ボナス編

この機能ができたら、みんなに知っていただくためにLTにここと同じ内容の発表をしました。そうすれば、他の会のメンバーにこういう機能を使いたいと言われ、機能の拡張を行いました。

対応するカテゴリーを増やす

最初はlt「(.*)」リンク:(.*)というパターンを使って、「lt」を含むメッセージにしか対応していませんでした。拡張するために、「lt」を正規表現にしました:

(.*)「(.*)」リンク:(.*)

機能の設定に対応されているカテゴリーのリストを入れて、処理するかどうかのところでチェック入れました。 if isCategorySupported(settings, category)

記事のIDなどはカテゴリーで判別するように変えました

記事の日付フォーマットを増やす。

最初は日付をタイトルにして、その下に記事を追加していたが、もう一つの会では週に記事一つしかなくて、情報の行の頭に日付をつけるようにしました。 f:id:glpgsinc:20170921195054j:plain

最初は週の何曜日の日付になるようにしていましたが、曜日を設定しないとSlackで追加された日の日付になるようにしました。

Qiitaの記事を編集して設定ができるようにする

その時点で設定をコードの中にしていたが、別のところでこの機能を使いたい場合、コードをいじる必要があって、他の社員に使いづらい、毎回私が手を入れる必要があるので、設定しやすくするために、Qiitaに設定を書き込む記事を追加し、機能を使う場合、その設定を読み込んで適用していくようにしました。 カテゴリーの追加、日付のフォーマットなど簡単に設定できるようになりました。 f:id:glpgsinc:20170921195601p:plain

解説すると:

  • categoryでSlackのメッセージ判定の頭にくる「lt」を定義します。

他の設定は「設定名 カテゴリー名」という形で定義します。

  • token ltで編集する記事のIDを設定する。
  • group ltでその記事が普通の記事(items)なのか、プロジェクト(projects)なのかのを設定します
  • owner ltではその記事・プロジェクトのオナーのQiita名を設定します。
  • format ltは日付のフォーマットを設定します。「titleDate」は日付をタイトルにして、その下に情報を追加する。「inLine」では情報の行の頭に日付を書きます。
  • startline ltでは、記事の中に編集はどの行からのを設定します。設定された行の上は自由に説明文などかけます。
  • day ltでは日付は何曜日にするか設定できます。設定されていないと、Slackでメッセージが送られた日付になります。

最後に

このように資料をまとめやすくする機能を作りました。まだ導入して一ヶ月経っていませんが、今の所LT会では使われていてほとんど負担なく資料をまとめる記事が更新されています。 Web系開発にあまり経験ない私でも簡単にこういう機能の作成ができましたので、みんなさんもSlack使っていて、こういうボット機能があればいいなーと思った時に是非挑戦してみてください!

参考資料

Swift 4でJSONの扱い

ガラパゴスのコードヒーヨアン(twitter: @luinily)です。

先月WWDCApple社のOS、SDKなどの新バージョンか公開されました。その中にSwift 4も発表されて、その中にJSONの扱いに関するツールが追加されたことがわかりました。

弊社では、サーバーとのやりとりを行うアプリの開発が珍しくありませんが、その際JSONを扱うことが多く、扱いやすくなると仕事がしやすくなります。 今回の記事ではSwift 4で導入される新しいJSON用のツールを紹介したいと思います。

JSONとは

JSONは軽量なデータ記述言語の1つです。JavaScriptから生まれ、サーバーとアプリの情報交換などによく使われるようになり、多くの言語で扱うツールが存在します。 下記のようなJSONの形式は人間に可読なテキストファイルです。

{
    "firstName": "オルシュファン",
    "lastName": "グレイストーン",
    "age": 28,
    "address": {
        "place": "ドラゴンヘッドt",
        "region": "クルザス中央地",
        "state": "イシュガルド",
    }
}

フォーマットの詳細は「JSON - Wikipedia」を参考してください。

今までの扱い

今までJSONを扱うときは下記のような扱いになっていた:

  1. JSONデータをData形式で取得します。
  2. そのデータを[String: Any]に変換します。
  3. ディクショナリーの各キーにあるデータを正しく変換してデータを取り出す。

難しいことはしていませんが、任意なキーがないときや、オブジェクトがあるときなど、いろんな場合に対しての処理を、変換処理に加えて対応する必要がありますので、実装が時間をかかってしまって、馬具しやすいコードになります。そのために現在いろんなライブラリーが存在していますが、SwiftやiOS SDKで正式なライブラリーがないため、プロジェクトによって違うライブラリーを使ったり、使用していたライブラリーの開発が止まったりますし、ライブラリーを使うことでうまく変換できないときデバッグが難しくなったりします。

Swift 4で追加されるCodable

Swift 4ではCodableというプロトコールとJSONDecoderというクラスが追加されます。それを使えば簡単いJSONの扱いができるようになります。 紹介するためにCodableとJSONDecoderを使う例を実装しようと思います。

JSON Feed

JSON Feedは5月に公開されたRSSのようなブログの更新情報を持つフォーマット。RSSXMLに比べて、JSONで定義されていて扱いやすい、データ量の軽いフォーマットだと思われます。 5月に発表されていて、すでに複数のブログまとめサービスなどで対応されています。実装は結構簡単なので今回の例にちょうどいいと思います。詳しいことは公式サイトで確認できます。

JSON FeedのJSONファイルの簡単な例を見ましょう

{
    "version": "https://jsonfeed.org/version/1",
    "title": "My Example Feed",
    "home_page_url": "https://example.org/",
    "feed_url": "https://example.org/feed.json",
    "items": [
        {
            "id": "2",
            "content_text": "This is a second item.",
            "url": "https://example.org/second-item"
        },
        {
            "id": "1",
            "content_html": "<p>Hello, world!</p>",
            "url": "https://example.org/initial-post"
        }
    ]
}

フェードの中にはversion、title、home_page_url、feed_urlのフィードの情報があります。 最後にitemsという配列の中には各記事の情報があります:id、url、テキストフォーマットでのコンテンツ、またはHTMLフォーマットでのコンテンツ このファイルを取得してブログのフィードの情報と、各記事の方法を取得できます。

アプリの中に、このJSONをどうすれば、Swiftの情報に変換できるか見て見ましょう。

データを入れるための構造体を作成します

まずはこのデータを入れるための構造体を書きます。

struct JsonFeed {
    var version: URL
    var title: String
    var homePage: URL?
    var feed: URL?
    var items: [JsonFeedItem]
}

version、homePage、feedはURLで定義します。JSON Feedの定義にはhomePageとfeedが任意だと書いてありますので、入ってない場合に備えてオプショナルの「URL?」として定義します。titleはそのままStringとして定義して、itemsは記事の情報の配列なので、配列として定義します。 記事の情報を持つために同じく構造体を作成します。

struct JsonFeedItem {
    var id: String
    var url: URL?
    var contentHTML: String?
    var contentText: String?
}

ここはフィードの方と同じく、任意の項目をオプショナルとして定義します。

JSONのデータを構造体に入れる

ここが関心のところです、CodableとJSONDecoderを使用し、作った構造体にJSONが持っているデータを入れます。 手順は簡単です。 まずは構造体にCodableを追加します。

struct JsonFeed: Codable {
    var version: URL
    var title: String
    var homePage: URL?
    var feed: URL?
    var items: [JsonFeedItem]
}

struct JsonFeedItem: Codable {
    var id: String
    var url: URL?
    var contentHTML: String?
    var contentText: String?
}

そうしたら、JSONDecoderを使用し、変換をします。

let decoder = JSONDecoder()
let feed = try? decoder.decode(JsonFeed.self, from: data)

変換が成功した場合、JsonFeed型のfeedにデータが入っています。それだけです!

キーのマッチング

ただし、このまま変換すると変換が失敗してしまいます。 理由は構造体のメンバーにJSONの定義と違う名称をつけたからです。 JSONで「home_page_url」となっているところを「homePage」とSwiftっぽい定義しました。JSONDecoderはメンバーとJSONのキーの名称が同じだと自動的に入れてくれますが、名称が違う場合、どのキーがどのメンバーになるのか教える必要があります。

実はCodableプロトコルを適用すると構造体の中に暗黙的にCodingKeysという列挙型が作成されます。この列挙型を上書きすることで、構造体のメンバーにどのキーを適用させるかのを定義できます。 JsonFeedとJsonFeedItemでCondingKeysを定義しましょう。

struct JsonFeed: Codable {
    var version: URL
    var title: String
    var homePage: URL?
    var feed: URL?
    var items: [JsonFeedItem]

    enum CodingKeys: String, CodingKey {
        case version
        case title
        case homePage = "home_page_url"
        case feed = "feed_url"
        case items
    }
}

struct JsonFeedItem: Codable {
    var id: String
    var url: URL?
    var contentHTML: String?
    var contentText: String?

    enum CodingKeys: String, CodingKey {
        case id
        case url
        case contentHTML = "content_html"
        case contentText = "content_text"
    }
}

列挙型は「enum CodingKeys: String, CodingKey」として定義をします。構造体の全てのメンバーのcaseを追加する必要があります。 メンバーとキーの名称が同じであれば、メンバーと同じ名称のcaseだけを定義します メンバーとキーの名称が異なる場合は、メンバーと同じ名称のcaseを定義して、そのcaseにキーの名称をStringとして定義します。 この修正を適用するとJSONデータの変換が成功します。

まとめ

CodableとJSONDecoderを使う場合、JSONの変換に必要な手順は

  1. JSONの情報を持つための構造体を作成する。
  2. その構造体にCodableプロトコルを定義する。
  3. 構造体のメンバーの名称がJSONのキーの名称と異なる場合。CodingKeysの列挙型を定義する
  4. JSONDecodeのインスターンスを作成する
  5. Dataとしてもつ JSONデータをJSONDecode.decodeで変換させる

構造体の定義は以前でも必要がありましたが、CodableとJSONDecoderのおかげで変換コードが2行だけになりました。自作のアプリにCodableを適用して見たら、80行ぐらいあった変換コードがたったの2行になりました。

Codable関連はWWDC「What’s new in Foundation」で発表されました。 9月からSwift 4がリリースされたら、業務で使うのが楽しみです。