大きな Rails アプリケーションをなんとかしよう。まずは計測と可視化からはじめよう。

こんにちは、技術部開発基盤グループの id:hogelog です。

RubyKaigi 2018 楽しかったですね。僕はおそらく RubyKaigi 2010 以来の久しぶりの参加でした。ああいう場の楽しさを思い出し、また今回はスポンサーブースから RubyKaigi に参加するという学生の頃は知らなかった楽しみも新たに知り、RubyKaigi を満喫させていただきました。

さて今回はそんな RubyKaigi で取り戻した Ruby に対する感情と関係あるようなないような、最近自分が取り組んでいるお台場プロジェクトとプロジェクト内で実施している計測と可視化について紹介します。

お台場プロジェクトの発足

クックパッドの開発といえば数年前までは cookpad_all という一つのリポジトリの中に詰め込まれた巨大なモノリシック Rails アプリケーションを社内のエンジニアが寄ってたかって開発するというのが典型的な開発スタイルでした。世界でも類を見ない規模の巨大な Rails アプリケーションの開発であるため、もちろん多様な技術的困難が発生していましたが様々な技術を用いてアプリケーションをメンテナンスし Rails の良さを損なわず開発が進められるように努力していました。*1

しかしその後クックパッドでも徐々にモノリシックアプリケーション構成から Microservices 構成への移行が進んでいきました。 techlife.cookpad.com

そして気づけば cookpad_all は社内に数多く存在する他のアプリケーションと比較してずいぶんと古臭い、触ることが忌避されがちなアプリケーションの代表となっていました。 https://cookpad.com/ のバックエンドの大部分を支える重要なアプリケーションであるというのは変わっていないのに。そこで始まったのがお台場プロジェクトでした。お台場プロジェクトとはなんなのか。その全貌を語るのはまた別の機会としますが、実施することは端的に言えば cookpad_all というアプリケーションの実装の改善です。

お台場プロジェクトではレガシーなシステムの削除、未使用コードの削除、システム分割など様々なことをおこなっており、 id:riseshia が取り組んだ Ruby の lazy loading の仕組みを利用して未使用の gem を探す - クックパッド開発者ブログ や RubyKaigi 2018 LT で発表した Find out potential dead codes from diff もその一環です。

以下ではお台場プロジェクトを進めるにあたって取り組んだ cookpad_all 関連メトリクスの計測について紹介します。

cookpad_all 関連メトリクスの計測

cookpad_all の開発における困難を改善するといってもどう改善されているのか記録し、可視化しなければなにもわかりません。

そこでお台場プロジェクト開始初期にまず cookpad_all に関するメトリクスを計測し、社内で稼働させている InfluxDB に記録し、Grafana でダッシュボードを作成しメトリクスを可視化できている状態を作りました。

f:id:hogelog:20180607161915p:plain

具体的には現在 cookpad_all では以下のようなメトリクスを可視化し、改善を進めながら経過を観測し続けています。

  • CI Duration
  • App Load Time (development / production)
  • Loaded File Count
  • Code Statistics
  • GemCollector Up-to-date Point
  • Dependent Gem Count

CI Duration

これは Jenkins で実行している CI にかかった時間の計測です。気をつけることとしては失敗した時の実行時間は不安定になることが多いので、成功した時の時間のみ記録していることです。上記の図で示すように 2017/7 〜 2018/6 現在に至るまで、長いものでは 10 分程かかっていたものが 7分程度まで実行時間が削減されています。

App Load Time

開発者が手元で bin/rails s した時にアプリケーションが動き出すまでの遅さはわかりやすく辛い箇所です。cookpad_all の各アプリケーションでは定期的に以下のようなスクリプトを実行しアプリケーションのロードにかかった時間を計測しています。

def profile_app_load_time
  Benchmark.measure do
    system("./bin/rails r '1;'") or raise "error"
  end
end

# Warming disk cache, ...
puts profile_app_load_time

3.times do
  result = profile_app_load_time
  puts result
  influx.write_point("cookpad_ci_app_load_time", tags: { app: app }, values: { load_time: result.real })
end

Loaded File Count

これはアプリケーションのロードが終わった時点での $LOADED_FEATURES の数です。この数字は依存 gem の追加や削除、大規模なコード削除などで大きく数字が動き、アプリケーションになにか大きな変更があったことの観測に役立っています。

Code Statistics

bundle exec rake stats *2 の数字を記録するものです。この数字も時々誰かがどこか外部で「クックパッドの巨大 Rails アプリケーション」の発表をする時に計測する程度で、定点観測はおこなわれていませんでした。

f:id:hogelog:20180607172035p:plain

大きなシステム分割などにより時々グッと下がっている以外にも、日常的なコード掃除などで地道ながらもコード削減が進んでいることがダッシュボードを見るだけでわかるようになりました。

Dependent Gem Count

依存している gem の数の記録です。数が増えれば増えるほど gem の依存関係が深くなり、新規 gem の導入や既存 gem の更新などが難しくなっていきます。

f:id:hogelog:20180607161805p:plain

このメトリクスは git のログを遡り 2011 年頃からの値を計測してみましたが、依存する gem 数はお台場プロジェクトが始まるまでは増える一方でありアプリケーションを小さくしていこうという開発の流れはほぼ存在していなかったことがわかります。

ちなみに一瞬依存 gem 数 が400個を超えたところがあるのが目を引くかもしれないので説明しておくと、これは aws-sdk を v2 -> v3 にアップグレードし、その後で必要な aws-sdk-* のみに絞るよう修正したためです。

GemCollector Up-to-date Point

これは この gem を使っているアプリケーションを探す - クックパッド開発者ブログ で紹介した GemCollector で出している gem の最新度を記録しているものです。

f:id:hogelog:20180607161828p:plain

この値は相対的なものであるため、gem のバージョンアップに追従していかないとどんどんポイントが下がっていきます。対応をおこたっていくといどんどんアプリケーションがレガシーになっていく状況を把握するのに非常に便利なグラフになっています。

まとめ

クックパッドでは現在巨大モノリシック Rails アプリケーションに頼った開発から Microservices 構成のアプリケーション群を組み合わせて使ったサービス開発への急速な移行段階にあります。その中で最後に残されている巨大 Rails アプリケーションを改善していくためのメトリクス収集と可視化ダッシュボードについて紹介しました。

私達はそういうことを一緒にやっていく仲間をもっともっと求めています。定型文じゃなくて本当に求めています。採用への応募またはどんな会社なのか聞くために遊びに来たいみたいなお声がけ、お待ちしております。

*1:どんな技術を用いていたか詳しくは Ruby on Ales 2015 で @amatsuda が発表した The Recipe for the World's Largest Rails Monolith などで詳しく説明されています

*2:実際にはちょっと特殊なディレクトリ構成に対応するため cookpad:stats という独自タスクを定義しています

AlexaでE2Eテストを書けるようにした話

研究開発部の伊尾木です。

研究開発部では、Alexaのスキルを公開しています(Google Assistantも公開していますよ!)。

今回はAlexaスキルのテストを便利にするKuchimaneというツールを公開したので紹介したいと思います。

E2Eテストが難しい

音声UIの開発はまだまだ新しい分野で知見やツールがそろっているわけではありません。 特に E2E (End To End) テスト、RSpecでいうところの Feature spec に相当するようなテストを行うことがとても困難でした。

AlexaでのE2Eテスト

以下のような一連の会話があったとします。

あなた「クックパッドを開いて」
Alexa「クックパッドへようこそ」
あなた「大根のレシピを教えて」
Alexa「大根ですね。サラダ、ナムル、スープのどのレシピがいいですか」
あなた「スープ」
Alexa「大根のスープですね。レシピを送信しました」

Alexaでは、この一連の会話「クックパッドを開いてから、レシピを送信するまで」をローカルでテストする方法がありません。 (Alexaのデモ環境に都度リクエストを投げればテストはできますが、やっぱりローカルだけでやりたいですよね)

会話の一部だけ、一回のやりとりだけのテストなら可能です。

例えば、「クックパッドを開いて」->「クックパッドへようこそ」 の組み合わせのみテストするといったことは可能です。 が、全部通したテストを書くことはできません。

通常のWebアプリのテストでいえば、 「一回のHTTPリクエストごとのテストは可能だけど、複数HTTPリクエスト、あるいは複数画面にまたがるテストが書けない」 という状況と同じです。

とても不便ですよね。

なぜできないのか

Alexaでは、ユーザの生の発話を、開発者が直接操作することはありません。 一旦、内部的なインテントとよばれるユーザの意図を表している処理に変換します。

例えば「クックパッドを開いて」という発話は、内部的に LaunchRequest というインテントに変換されて処理を実行します。 Webアプリとの対比でいえば、URLからコントローラ・アクション名に変換するルーティング処理と同じような感じです。

このルーティング処理が、Alexa内部に隠蔽されているため、ローカルでテストすることができないのです。 どうしてもローカルで一連の会話をテストしたい場合、ルーティング処理を自前で処理する必要があります。

Kuchimane

私達も当初、インテント単位のテストだけで乗り切ろうとしていましたが、複数インテントが絡む処理はテストできないため、エラーが起きやすい状況でした。

そこで、一連の会話をテストするための Kuchimane を開発しました!

じゃぁ Kuchimane では、さきほどのルーティング処理をどうしているのかというと、これまた自前で実装しています。

より正確にはsatori-flow というルーティング処理用のライブラリを開発しています。 KuchimaneがAlexaの会話モデル定義を解析し、このsatori-flowに「どんな発話がどのインテントになるのか」を登録します。

というわけで、以下のようなコードが書けるようになります!

const intents = { LaunchRequest, SearchDishIntent, SearchRecipeIntent };
const kuchimaneRunner = Kuchimane.runner(intents, __dirname + '/kuchimane_config.json');

it('searchRecipe', () => {
  return kuchimaneRunner.talkCheck('クックパッドを開いて', (message) => {
      expect(message).to.include('クックパッドですね')
    })()
    .then(kuchimaneRunner.talkCheck('大根のレシピを教えて', (message) => {
      expect(message).to.include('大根ですね。サラダ、ナムル、スープのどのレシピがいいですか');
    }))
    .then(kuchimaneRunner.talkCheck('スープ', (message) => {
      expect(message).to.include('大根のスープですね。レシピを送信しました');
    }))
  }
);

最初の行でintentsというオブジェクトを生成していますが、ここのLaunchRequestSearchDishIntentSearchRecipeIntent がインテント関数になります。 次にkuchimaneRunnerというインスタンスを、さきほどのintentsとKuchimane用の設定ファイル(Alexaのモデルへのパスなどを書く)から生成しています。

kuchimaneRunnertalkCheckというメソッドがE2Eテスト用のメソッドになります。第1引数がユーザの発話、第2引数がチェック用の関数になります。

talkCheckメソッドはユーザの発話を受け取ると、それを satori-flow に渡してインテント名に変換してもらいます。 そして、kuchimaneRunnerの生成時にもらったintentsの中から、インテント名にマッチする関数を取り出して実行し、Alexaのレスポンスをチェック用の関数に渡してテストを実行します。 最後にtalkCheckメソッドは、Promiseを返しますので、thenで会話を繋げていきます。

一連の会話をテストで書けることがわかりますね! 便利ですね!!

おわりに

AlexaのE2Eテストのための Kuchimane の紹介でした。

バグの多くは機能の組み合わせ部分に潜むと言われますが、実際私達も複数の会話、複数のインテントが絡む部分でよくエラーが起きていました。 Kuchimane以前は、このような部分をテストすることができなかったのですが、Kuchimaneのおかげで複数の会話が絡む部分をテストできるようになり 品質向上に一定の効果があるなと感じています。

ちなみに、まだまだKuchimaneの完成度は高くありません。例えばASK SDK v2 にも対応できていませんし、私達にとって必要な部分を優先的に実装しているため、フォローできていないケースもあります。これらの点については今後拡充していく予定です。

また現状ではGoogle Assistantに対応していませんが、こちらも今後対応する予定です!

iOSアプリの大規模なCustom URL Schemeを支える技術

こんにちは。技術部モバイル基盤グループの@です。

今回は、iOSアプリでCustom URL Schemeを簡単に処理するライブラリを公開しましたので紹介します。

Custom URL Schemeは、アプリの特定の画面に遷移させることができるリンク(ディープリンク)を提供する機能です。

f:id:gigi-net:20180530205333g:plain

アプリ開発をしていると、Custom URL Schemeを用いたディープリンクを実装したい需要は多いでしょう。 特にクックパッドのような、ブラウザ版を提供するWebサービスですと、アプリとWebページの行き来のため非常に多くのCustom URL Schemeを処理する必要が出てきます。

現に、クックパッドアプリでは、30以上のパターンが遷移先として実装されています。

渡ってきたURLのパーサーを愚直に書いていくのは、コードの記述量も増えますし、どのようなURL Schemeが有効なのか簡単に見通すことは難しいです。

Crossroad

そこで、複雑なCustom URL Schemeのルーティングを簡単に実現するライブラリをOSSとして公開しました。

例えば、あなたがiOS上で「ポケモンずかん」を実装する仕事を請け負ったとしましょう。

Crossroadを用いると、以下のような記述でCustom URL Schemeのルーティングが行えます。

let router = DefaultRouter(scheme: "pokedex")
router.register([
    ("pokedex://pokemons", { context in 
        let type: Type? = context.parameter(for: "type")
        presentPokedexListViewController(for: type)
        return true 
    }),
    ("pokedex://pokemons/:pokedexID", { context in 
        guard let pokedexID: Int = try? context.arguments(for: "pokedexID") else {
            return false
        }

        guard let pokemon = Pokedex.find(by: pokedexID) else {
            return false
        }

        presentPokemonDetailViewController(of: pokemon)
        return true
    }),
])
router.openIfPossible(url)

このように、Ruby on Railsのroutes.rbのようなルーティングを記述することができます。

この仕組みをクックパッドアプリでは、1年以上前から運用していたのですが、今回、別のアプリでも使いやすい形で提供するためにOSS化しました。

同様のライブラリはいくつか公開されていますが、Crossroadはこれらに比べ、パラメータをType-Safeに、そして簡単に取り扱うことができます。

使い方

Crossroadの基本的な使い方を見ていきましょう。

URLのルーティング

iOSでは、Custom URL Schemeからアプリが起動されると、UIApplicationDelegateapplication(_:open:options:) が呼び出されます。

基本的な使い方は、AppDelegateで、ルーティングを定義した Router を生成し、そこでopenIfPossibleを呼び出すだけです。

import Crossroad

class AppDelegate: UIApplicationDelegate {
    let router: DefaultRouter = {
        let router = DefaultRouter(scheme: "scheme")
        router.register([
            ("scheme://search", { _ in return true }),
            // ...
        ])
        return router
    }()

    func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey: Any]) -> Bool {
        return router.openIfPossible(url, options: options)
    }
}

:から始まるパスは任意の文字列にマッチし、あとでマッチした値を参照できます。 URLパターンは定義順に上からマッチするかどうかが判定され、ブロックから true を返された時点で探索を終了します。 複数のURLパターンにマッチしうる場合も、最初にtrueを返した物のみが実行されます。

パラメータの取得

:から始まるパスにマッチした文字列は Argument として扱われ、ブロックから取得することができます。

("pokedex://pokemons/:pokedexID", { context in
    // URLからポケモンずかん番号を取得
    guard let pokedexID: Int = try? context.arguments(for: "pokedexID") else {
        return false
    }

    // 該当するポケモンを取得する
    guard let pokemon = Pokedex.find(by: pokedexID) else {
        return false
    }

    // ポケモン詳細画面を表示する
    presentPokemonDetailViewController(of: pokemon)
    return true
})

Argument はGenericsを利用しているので、任意の型として受け取ることができます。

例えば、pokedex://pokemons/25のURL Schemeからアプリを起動した場合、ずかん番号25番のポケモンが表示されます。

enumの値を取得する

Argumentを利用することで、それぞれのポケモンの詳細画面へ遷移するURL Schemeを実装することができました。

今度はポケモンを検索する画面を作ってみましょう。

URLのクエリとして渡された値は Parameter として扱われ、Argumentと同様にContextから取得することができます。

ここで、ポケモンのタイプを示すenum Typeを定義してみましょう。 Crossroadでは、Extractableというプロトコルに準拠させることで、任意の型をContextから返却することができます。

enum Type: String, Extractable {
    case normal // ノーマルタイプ
    case fire // ほのおタイプ
    case water // みずタイプ
    case grass // くさタイプ
    // ...
}

enumを表す型であるRawRepresentableは、すでにExtractableに準拠しているため、これだけで文字列をenumにマッピングすることができます。

("pokedex://pokemons", { context in
    let type: Type? = context.parameters(for: "type")

    // ポケモン一覧画面を表示する
    presentPokemonListViewController(of: type)
    return true
})

これで、pokedex://pokemons?type=fire というURL Schemeからアプリを起動すると、ほのおポケモンのみを表示する画面へ遷移することができます。

一般的な検索画面を実装する場合は、キーワードや並び順などをパラメータで受け取る実装が考えられるでしょう。

pokedex://search?keyword=ピカチュウ&order=asc

複数の値を取得する

ポケモンずかんを実装するに当たって、今度は複合タイプのポケモンをURL Schemeから検索したいという需要が出てくるでしょう。

Crossroadは、パラメータに渡されたカンマ区切りの文字列を配列としてマッピングする機能も提供しています。

// pokedex://pokemons?types=water,grass
let types: [Type]? = context.parameters(for: "types") // [.water, .grass]

これは、Swift 4.1から利用可能になった、Conditional Conformanceを用いて、[Extractable]Extractableに準拠させることで実現しています。

extension Array: Extractable where Array.Element: Extractable {
    static func extract(from string: String) -> [Element]? {
        let components = string.split(separator: ",")
        return components
            .map { String($0) }
            .compactMap(Element.extract(from:))
    }
}

独自の型を取得する

もちろん、独自の型を取得することもできます。Contextから取得したい型をExtractableに準拠させましょう。

struct Pokemon: Extractable {
    let name: String

    static func extract(from string: String) -> Pokemon? {
        return Pokemon(name: string)
    }
}
// pokedex://pokemons/:name
let pokemon: Pokemon = try? context.arguments(for: "name")

このように、Crossroadでは、柔軟にパスやクエリパラメータの取得を行うことができます。

Dynamic Member Lookupを使ったインターフェイス

最後に、Swift 4.2から実装される新たな言語機能であるDynamic Member Lookupを使ったインターフェイスの構想を紹介します。

Dynamic Member Lookupは、動的なプロパティ生成を提供するシンタックスシュガーです。 クラスや構造体に@dynamicMemberLookupを宣言することで、ランタイムで評価されるプロパティを生成することができます。

Dynamic Member Lookupを宣言すると、subscript(dynamicMember:) の実装が要求され、プロパティアクセスを行ったときに、プロパティ名が引数に渡され実行されます。

@dynamicMemberLookup
struct Container {
    let values: [String: Any]

    subscript<T>(dynamicMember member: String) -> T? {
        if let value = values[member] {
            return value as? T
        }
    }
}

let container = Container(values: ["name": "Pikachu"])
let name: String = container.name // Pikachu

本稿執筆時点では、Swift 4.2の正式版はまだリリースされていませんが、Swift.orgからdevelopmentのToolchainをダウンロードすることで、Xcode 9.3でも利用することができました *1

この機能をCrossroad.Contextに適用してみると、以下のように Argument を取得できるようになりました。

// match pokedex://pokemons/:pokedexID
let pokedexID: Int? = context.arguments.pokedexID

この実装はまだmasterへマージしていませんが、別ブランチで公開しているので、興味のある方は見てみてください。

まとめ

今回はCustom URL Schemeを簡単にルーティングするライブラリを紹介しました。 ぜひ利用を検討してみてください。もちろんPull Requestもお待ちしております。

技術部モバイル基盤グループでは、OSSを通して問題解決をしていきたいエンジニアを募集しています。

iOS アプリケーションエンジニア(開発基盤)

Android アプリケーションエンジニア(開発基盤)

*1:普通にビルドすることはできますが、静的解析の時点ではシンタックスエラーが発生します