クックパッドのサービスメッシュ基盤を改善した話

こんにちは、技術部 SRE グループの ryojiro (@flyhigh_ro) です。今回はクックパッドでのサービスメッシュ基盤を改善した話を紹介します。クックパッドでのサービスメッシュの構成については以前の記事をご覧ください。

クックパッドでは多くのサービス間通信において Envoy を利用していますが、以下のような問題を抱えていました。

  • 改善前の Envoy のバージョンは v1.9.0 (2018/12 リリース) と古く、開発者はそれ以降に実装された機能を利用することが出来なかった。
  • CDS/RDS を cookpad/itacho によって生成しているため、 v1.9.0 で利用出来る機能であっても cookpad/itacho で実装されていなければその機能を利用できなかった。利用するためには cookpad/itacho にその設定を実装する必要があり、面倒だった。
  • cookpad/itacho で既に実装されている機能でも、ドキュメンテーションが不十分で目的の Envoy での設定に対応する itacho の設定が調べられず、cookpad/itacho の実装を調べることがあった。

上記の理由から、サービス開発者が Envoy v1.9.0 以降の機能や cookpad/itacho で実装されていない機能を利用したくても、すぐにその機能を利用することができずに、その機能を利用することを諦めることが何度かありました。SRE としては、サービス開発者にサービスメッシュを積極的に活用してもらいたいと考えていたので、サービスメッシュをもっと手軽に利用してもらうことを目的として、以下の内容でサービスメッシュ基盤を改善しました。

  • Envoy のアップデート
  • v1 xDS API の廃止
  • cookpad/itacho での itacho generate 廃止
  • xDS API の CI 整備

Envoy のアップデート

クックパッドで利用されている Envoy のバージョンは v1.9.0 と 2018/12 にリリースされた古いバージョンを利用していました。v1.9.0 でも機能としては十分でしたが、脆弱性が報告されていたり、古いバージョンを使い続けることでアップデートがどんどん大変になっていくことに懸念がありました。そのため、今回を機に最新のバージョンまで上げることにし、以降もバージョンアップしやすい環境を目指すことにしました。

段階的な移行

最初は Envoy を一気に v1.9.0 から v1.14.2 まで上げようと考えていましたが、以下の理由から一度 v1.12.0 にしてから v1.14.2 に上げることにしました。

v1.14.2 だと既に deprecated になっている設定があり、v1.9.0 と v1.14.2 で互換性のない設定があった

envoy.api.v2.route.HeaderMatcher.regex_match を例にすると、 v.1.14.2 では既に deprecated となっているため envoy.api.v2.route.HeaderMatcher.safe_regex_match へ移行する必要がありました。しかし envoy.api.v2.route.HeaderMatcher.safe_regex_match は v1.9.0 では実装されていません。一旦全ての Envoy を envoy.api.v2.route.HeaderMatcher.regex_match と envoy.api.v2.route.HeaderMatcher.safe_regex_match に対応しているバージョンへアップデートし、envoy.api.v2.route.HeaderMatcher.regex_match を envoy.api.v2.route.HeaderMatcher.safe_regex_match へと移行してから v1.14.2 にアップデートする必要がありました。

cookpad/itacho で利用しているライブラリの protobuf 定義が古く、v1.12.0 までの xDS リクエストにしか対応していなかった

cookpad/itacho で利用しているライブラリの protobuf 定義が古く、v1.13.0 以降の Envoy から送信される xDS request のデシリアライズに失敗していました。cookpad/itacho に原因があることはわかっていましたが、cookpad/itacho を開発した経験がなく、この対応にどの程度工数がかかるのか見積もることができませんでした。そこで、一旦 v1.12.0 へアップデートすることにして、その間に cookpad/itacho へ対応することにしました。

v1 xDS API の廃止

Envoy v1.10.0 で Bootstrap config の deprecated_v1 sds_config と command line config の –v2-config-only オプションが廃止、 v1.13.0 で v1 xDS API が廃止となりました。クックパッドではいくつかのアプリケーションで v1 xDS API を利用してたので、それらを全て v2 xDS API へと移行しました。Envoy 以外から v1 xDS API を利用しているアプリケーションもあったので、それらも v2 xDS API を利用するように変更しました。

cookpad/itacho での itacho generate 廃止

クックパッドでは CDS/RDS のレスポンスの生成に itacho generate を使用していました。itacho generate は指定された設定に沿って CDS/RDS を生成します。しかし、Envoy の設定名とそれを生成する itacho generate の設定名が一致していなかったり、ドキュメントが整備されていないことから、どのような記述をすればいいのかわからないとの声が上がっていました。実際に itacho generate の設定を確認するために直接実装を確認することもありました。また、新規の機能を利用する場合も cookpad/itacho へその機能を実装する必要があり、手軽に新規の機能を利用することが困難でした。これらの課題を解決するために、itacho generate で xDS API レスポンスを生成することをやめ、直接 xDS API レスポンスを記述するように変更しました。そのまま全てのレスポンスを記述すると冗長になってしまうので Jsonnet で記述するようにしました。共通の設定は関数化し、upstreams 毎に設定を libsonnet ファイルにまとめて、それらを import して利用することで簡潔に記述できるように工夫しています。以下は itacho generate での記述例とxDS API レスポンスをそのまま記述したときの例です。

itacho generate での記述例

https://gist.github.com/ryojiro/baac94ceb615949c7ea54e36ba94b70a

xDS API をそのまま記述した例

https://gist.github.com/ryojiro/cde4f0024cd29b6ed4ee10467519f1fb

このような記述にすることで、upstreams の設定を1箇所で管理しつつ、サービス毎に独自に upstream の設定を上書きすることも可能となっています。また、新しい設定を記述する時にも Jsonnet へ設定を追加するだけなので、手軽に Envoy の機能を利用できるようになりました。

xDS API の CI 整備

これまでは xDS API レスポンスを itacho generate によって生成していたので、正しい xDS API の形式となっていることが保証されていました。しかし Jsonnet で xDS API レスポンスを生成するように変更したことで、生成される xDS API レスポンスが正しいことが保証されなくなってしまいました。そこで、CI を整備して生成される xDS API レスポンスが正しい形式となっていることを事前に検証するようにしました。Envoy のドキュメントを読むと mode オプションvalidate を渡して起動することで、Envoy の設定が正しいかを検証できそうでしたが、ネットワーク通信が発生しないので xDS API サーバーを立てて生成した xDS API レスポンスを検証することはできませんでした (静的な設定ファイルのみ検証されます) 。検証したいのは CDS/RDS のレスポンスで、Envoy の static_resources との設定はほとんど同じだったので、CI では設定した xDS API レスポンスを静的な設定ファイルに変換し、その設定ファイルで Envoy を起動することで、設定した xDS API レスポンスが正しい形式で記述されているかを検証するようにしました。クックパッドでは現在 v1.12.0 と v1.14.2 の Envoy が混在しているので、どちらも valid な設定のみ追加できるように、それぞれのバージョンで検証するようにしています。

最後に

今回はサービスメッシュをサービス開発者により手軽に利用してもらうために、サービスメッシュ基盤を改善した話を紹介させていただきました。この改善によって、実際にサービス開発者が新しい Envoy の設定を追加して利用する事例も生まれています。Envoy は比較的新しいアプリケーションでまだ知見も少ないと思うので、これからサービスメッシュ基盤の改善を考えている方の参考になれば嬉しいです。

このエントリを読んで興味を持った方や、数千の規模で Envoy が利用されているサービスメッシュ基盤を改善したい方はぜひ以下のサイトよりご応募ください。

クックパッド採用サイト: https://cookpad.jobs

iOSでモダンなカスタムボタンを作ってみよう

お久しぶりです。モバイル基盤部のヴァンサン(@vincentisambart)です。

iOS標準のボタンクラスUIButtonが10年前に作られたものであって、当時存在していなかったAuto LayoutやDynamic Typeとの相性がよくありません。

Auto Layout、Dynamic Type、複数行表示、を活用するカスタムなボタンクラスを作ってみれば少し勉強になるかもしれません。

因みにDynamic Typeはあまり使われていない機能だと思われることがあるようですが、気になって調べてみたら、クックパッドのiOSアプリのユーザーの中で、3分の1がシステム標準でない文字サイズを使っていました。その半分が標準より小さい設定を使っていて、もう半分が標準より大きい設定を使っています。「さらに大きな文字」を有効にすると選べる「アクセシビリティサイズ」を使っているユーザーは全ユーザーの1%未満でした。

まずはシンプルに

ボタンを作るとき、適切な親クラスを考えるとき、UIButtonが最初に頭に浮かぶかもしれません。しかし、UIButtonの標準のサブビュー(titleLabelimageView)の配置はAuto LayoutやUIStackViewを活用できませんし、ボタンに別のUILabelを入れるとUIButton標準のtitleLabelも残っていて分かりにくいと思います。

UIButtonの代わりにその親クラスであるUIControlを使ってみましょう。実は、UIButtonに期待されている挙動の多くはUIControlがやってくれます。

カスタムボタンベースは以下のコードでいかがでしょうか。

public final class MyCustomButton: UIControl {
    private static let cornerRadius: CGFloat = 4

    private let titleLabel = UILabel()

    private func setUp() {
        // ユーザーの文字サイズの設定によってサイズの変わるフォントを使います
        // `UIFont.preferredFont(forTextStyle:)`の代わりに`UIFontMetrics.default.scaledFont(for:)`を使っても良いです
        titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
        // Dynamic Typeの設定が変わるたびに、上記のフォントのサイズを新しい設定に合わせてほしいです。
        // 自動調整を有効にするには、この指定だけでなくフォントを`UIFont.preferredFont(forTextStyle:)`または`UIFontMetrics.default.scaledFont(for:)`で作成する必要があります。
        titleLabel.adjustsFontForContentSizeCategory = true
        
        titleLabel.numberOfLines = 0 // 行数制限なし
        titleLabel.textAlignment = .center

        // titleLabelがボタン全体を覆うように
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(titleLabel)
        titleLabel.topAnchor.constraint(equalTo: topAnchor).isActive = true
        titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
        titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true

        // 角丸を忘れず
        layer.cornerRadius = Self.cornerRadius
        clipsToBounds = true

        // 色をつけておく
        backgroundColor = .orange
        titleLabel.textColor = .white
    }

    public override init(frame: CGRect) {
        super.init(frame: frame)
        setUp()
    }

    public required init?(coder: NSCoder) {
        super.init(coder: coder)
        setUp()
    }

    public var title: String {
        get {
            titleLabel.text ?? ""
        }
        set {
            titleLabel.text = newValue
        }
    }
}

実行してみると以下のようになります。

f:id:vincentisambart:20200616071827p:plain:w320

上記のコードだけでも、addTargetを使ってみればちゃんと動きます。ただし、ボタンを押すとタッチフィードバックがないので改善が少し必要です。

色変更

ボタンの色は押されているかどうかだけではなく、無効(disabled)になっているかどうかでも色が変わります。 色に影響ある状態を表現するためのenumを用意しておきましょう。

// `UIControl.State`と違って、この`enum`にはこのボタンの表示に影響ある状態しか入っていません。
private enum DisplayState {
    case disabled
    case enabled
    case enabledHighlighted
}

private var displayState: DisplayState {
    // `isEnabled`と`isHighlighted`は`UIControl`の標準のプロパティです。
    if isEnabled {
        if isHighlighted {
            return .enabledHighlighted
        } else {
            return .enabled
        }
    } else {
        return .disabled
    }
}

その状態によって色を変えたいので、色を変えてくれるメソッドを用意しておきましょう。 以下のコードは選んだ色がちょっと適当ですし、文字や背景の色だけではなく、ふちの色も変えても良いかもしれないので、見た目に関してデザイナーに相談しても良いかもしれません。

private func updateColors() {
    let textColor: UIColor
    let backgroundColor: UIColor

    switch displayState {
    case .disabled:
        textColor = .white
        backgroundColor = UIColor.white.darkened
    case .enabled:
        textColor = .white
        backgroundColor = .orange
    case .enabledHighlighted:
        textColor = UIColor.white.darkened
        backgroundColor = UIColor.orange.darkened
    }

    self.backgroundColor = backgroundColor
    titleLabel.textColor = textColor
}

因みに上記のdarkenedの定義は以下の通りです。もっと正しい計算があるかもしれませんが、ここはこれで十分でしょう。

private extension UIColor {
    var darkened: UIColor {
        let darkeningRatio: CGFloat = 0.9

        var hue: CGFloat = 0
        var saturation: CGFloat = 0
        var brightness: CGFloat = 0
        var alpha: CGFloat = 0

        if getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) {
            return UIColor(
                hue: hue,
                saturation: saturation,
                brightness: brightness * darkeningRatio,
                alpha: alpha
            )
        } else {
            return self
        }
    }
}

updateColors()を用意するだけではなく、正しいタイミングで呼ぶ必要もあります。 setUp()の最後で呼ぶのはもちろん、状態が変わるタイミングでも呼んでおきましょう。

public override var isHighlighted: Bool {
    didSet {
        updateColors()
    }
}

public override var isEnabled: Bool {
    didSet {
        updateColors()
    }
}

ボタンが押されている間に色が変わるようになりました。

f:id:vincentisambart:20200616071836p:plain:w320

ボタンが無効のときも色がちゃんと変わります。

f:id:vincentisambart:20200616071841p:plain:w320

サブタイトルと余白

タイトルだけではなく、サブタイトルも追加しておきましょう。そしてその周りに余白を入れておきましょう。

private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
// シンプルさのためにinsetsを固定にしてあるが、変えられるようにした方が良さそう
private static let insets = NSDirectionalEdgeInsets(
    top: 5,
    leading: 5,
    bottom: 5,
    trailing: 5
)

private func setUp() {
    // ユーザーの文字サイズの設定によってサイズの変わるフォントを使います
    // `UIFont.preferredFont(forTextStyle:)`の代わりに`UIFontMetrics.default.scaledFont(for:)`を使っても良いです
    titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
    titleLabel.adjustsFontForContentSizeCategory = true
    subtitleLabel.font = UIFont.preferredFont(forTextStyle: .subheadline)
    subtitleLabel.adjustsFontForContentSizeCategory = true

    titleLabel.numberOfLines = 0 // 行数制限なし
    titleLabel.textAlignment = .center
    subtitleLabel.numberOfLines = 0 // 行数制限なし
    subtitleLabel.textAlignment = .center

    let verticalStackView = UIStackView()
    verticalStackView.axis = .vertical
    verticalStackView.alignment = .center
    verticalStackView.translatesAutoresizingMaskIntoConstraints = false
    addSubview(verticalStackView)
    // 左右上下の制約にinsetsの値を活用しても良いのですが、今回はUIStackView.directionalLayoutMarginsを使ってみました
    verticalStackView.topAnchor.constraint(equalTo: topAnchor).isActive = true
    verticalStackView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
    verticalStackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
    verticalStackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true

    // stack view内に余白を少し入れておきます
    verticalStackView.isLayoutMarginsRelativeArrangement = true
    verticalStackView.directionalLayoutMargins = Self.insets

    verticalStackView.addArrangedSubview(titleLabel)
    verticalStackView.addArrangedSubview(subtitleLabel)

    // stack viewのおかげで隠れたビューがスペースをとりません
    subtitleLabel.isHidden = true

    layer.cornerRadius = Self.cornerRadius
    clipsToBounds = true

    updateColors()
}

public var subtitle: String {
    get {
        subtitleLabel.text ?? ""
    }
    set {
        subtitleLabel.text = newValue
        subtitleLabel.isHidden = newValue.isEmpty
    }
}

もちろんupdateColors()の最後にsubtitleLabelの色の更新も必要ですね。

subtitleLabel.textColor = textColor

f:id:vincentisambart:20200616071851p:plain:w320

タップ反応

見た目は大丈夫そうに見えるが、試してみたら、なぜかタップするとき反応しなくなりました…

実は、タップはverticalStackViewが全部受け取るようになりました。タップがボタン自体にたどり着きません。 以前動いていたのはUILabelisUserInteractionEnabledが標準でfalseだからです。UIStackViewはシンプルなUIViewのようにisUserInteractionEnabledが標準でtrueです。

setUp()の中で以下の1行を入れておけば上手く動くようになります。

verticalStackView.isUserInteractionEnabled = false // タッチイベントはこのボタンまで来てほしい

このボタンの中のタップが全部ボタンにたどり着いてほしいので、stackView.isUserInteractionEnabled = falseが良いのですが、UIStackViewの中のものにたどり着いてほしければ使えません。

これでボタンがちゃんと動くはずです。あとはレイアウトは自分のニーズに合わせて色々できます。

UIButtonを使わないおかげで、不要なサブビューが作られることはないが、UIButtonがやってくれて、UIControlがやってくれない機能を失ってしまう。その機能の1つがアクセシビリティです。

アクセシビリティ

アクセシビリティとは利用しやすさ、もっと多くの人がもっと多くの状況でアプリを使えるのを目指すことだと言っても良いのかな。今の自分がアプリを問題なく使えたとしても、メガネのない時の自分、30年後の自分、自分の親戚、にはアクセシビリティ機能が必要かもしれません。

上記のコードにadjustsFontForContentSizeCategory = trueが入っていて、Dynamic Typeというアクセシビリティ機能の一つを既に活用しています。

でもVoice Overなど、画面の中身を見て操作できるアクセシビリティ機能にとって、各ビューがどういうものなのか、どういう風に使えるのか、知るすべが必要です。

上記のコードのままだと一応Voice Overで操作はできるけど、「ボタン」として認識されていないので、操作できることに気づかれないかもしれません。

今回、アクセシビリティ対応は難しいことではありません:

  • 標準のUIControlが「accessibility element」ではないので、アクセシビリティ機能に無視されてしまいます。isAccessibilityElement = trueで認識されるようになります。
  • このビューがボタンであることをaccessibilityTraits = .buttonでシステムに伝えましょう。
  • isAccessibilityElement = trueをやったことで、Voice Overが中に入っているUILabelを音読しなくなるので、accessibilityLabelでボタンの中身を伝えましょう。\ 因みにUIButtonがaccessibility elementなので、UIButtonの中にUILabelを入れるときも同じ問題が起きます。
  • ボタンに画像しか入っていないときでも、何をやるボタンなのか分かるすべがないのでaccessibilityLabelにひとことを入れておきましょう。

以下のようになります。

isAccessibilityElement = true
var accessibilityTraits: UIAccessibilityTraits = .button
if !isEnabled {
    accessibilityTraits.insert(.notEnabled)
}
self.accessibilityTraits = accessibilityTraits
accessibilityLabel = [title, subtitle].filter { !$0.isEmpty }.joined(separator: "\n")

もちろん上記のコードはtitlesubtitleisEnabledの変更時に呼んで情報を更新する必要がありますね。

最後に

iOSクックパッドアプリでは、このボタンの拡張したバージョンが一部の画面で使われています。 作った時、細かいところいくつかに引っかかったので、この記事が少しでも役に立っていただければと思って書いてみました。

iOSクックパッドアプリのDynamic Type対応はまだ対応していない画面がまだありますが、少しずつ改善していこうとしています。

すべてのコードを以下にまとめておきました。このコードをご自由に自分のアプリにお使いください。\ 必要であれば、ライセンスがないと困る人のためにちゃんとしたライセンスも入れておきました。

// This project is licensed under the MIT license.
// 
// Copyright (c) 2020 Cookpad Inc.
// 
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// 
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// 
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
public final class MyCustomButton: UIControl {
    private static let cornerRadius: CGFloat = 4

    private let titleLabel = UILabel()
    private let subtitleLabel = UILabel()
    private static let insets = NSDirectionalEdgeInsets(
        top: 5,
        leading: 5,
        bottom: 5,
        trailing: 5
    )

    private func setUp() {
        // ユーザーの文字サイズの設定によってサイズの変わるフォントを使う
        // `UIFont.preferredFont(forTextStyle:)`の代わりに`UIFontMetrics.default.scaledFont(for:)`を使っても良いです
        titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
        titleLabel.adjustsFontForContentSizeCategory = true
        subtitleLabel.font = UIFont.preferredFont(forTextStyle: .subheadline)
        subtitleLabel.adjustsFontForContentSizeCategory = true

        titleLabel.numberOfLines = 0 // 行数制限なし
        titleLabel.textAlignment = .center
        subtitleLabel.numberOfLines = 0 // 行数制限なし
        subtitleLabel.textAlignment = .center

        let verticalStackView = UIStackView()
        verticalStackView.axis = .vertical
        verticalStackView.alignment = .center
        verticalStackView.isUserInteractionEnabled = false // タッチイベントはこのボタンまで来てほしい
        verticalStackView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(verticalStackView)
        // 左右上下の制約にinsetsの値を活用しても良いのですが、今回はUIStackView.directionalLayoutMarginsを使ってみました
        verticalStackView.topAnchor.constraint(equalTo: topAnchor).isActive = true
        verticalStackView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
        verticalStackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        verticalStackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        // stack view内に余白を少し入れておきます
        verticalStackView.isLayoutMarginsRelativeArrangement = true
        verticalStackView.directionalLayoutMargins = Self.insets

        verticalStackView.addArrangedSubview(titleLabel)
        verticalStackView.addArrangedSubview(subtitleLabel)

        // stack viewのおかげで隠れたビューがスペースをとりません
        subtitleLabel.isHidden = true

        layer.cornerRadius = Self.cornerRadius
        clipsToBounds = true

        updateColors()
        updateAccessibility()
    }

    private enum DisplayState {
        case disabled
        case enabled
        case enabledHighlighted
    }

    private var displayState: DisplayState {
        if isEnabled {
            if isHighlighted {
                return .enabledHighlighted
            } else {
                return .enabled
            }
        } else {
            return .disabled
        }
    }

    private func updateColors() {
        let textColor: UIColor
        let backgroundColor: UIColor

        switch displayState {
        case .disabled:
            textColor = .white
            backgroundColor = .lightGray
        case .enabled:
            textColor = .white
            backgroundColor = .orange
        case .enabledHighlighted:
            textColor = UIColor.white.darkened
            backgroundColor = UIColor.orange.darkened
        }

        self.backgroundColor = backgroundColor
        titleLabel.textColor = textColor
        subtitleLabel.textColor = textColor
    }

    public override var isHighlighted: Bool {
        didSet {
            updateColors()
        }
    }

    public override var isEnabled: Bool {
        didSet {
            updateColors()
            updateAccessibility()
        }
    }

    public override init(frame: CGRect) {
        super.init(frame: frame)
        setUp()
    }

    public required init?(coder: NSCoder) {
        super.init(coder: coder)
        setUp()
    }

    public var title: String {
        get {
            titleLabel.text ?? ""
        }
        set {
            titleLabel.text = newValue
            updateAccessibility()
        }
    }

    public var subtitle: String {
        get {
            subtitleLabel.text ?? ""
        }
        set {
            subtitleLabel.text = newValue
            subtitleLabel.isHidden = newValue.isEmpty
            updateAccessibility()
        }
    }

    private func updateAccessibility() {
        isAccessibilityElement = true
        var accessibilityTraits: UIAccessibilityTraits = .button
        if !isEnabled {
            accessibilityTraits.insert(.notEnabled)
        }
        self.accessibilityTraits = accessibilityTraits
        accessibilityLabel = [title, subtitle].filter { !$0.isEmpty }.joined(separator: "\n")
    }
}

KPI 設定の難しさについての思索とそれに付随した細かな考察

こんにちは、事業開発部でプロジェクトマネージャー兼エンジニアをやっている新井(@SpicyCoffee)です。10 万円の申請書を書く前に 20 万円のパソコンを買いました。

クックパッドでは、毎日の料理を楽しみにするべく日々サービス開発がおこなわれています。本稿では、サービス開発の中でも重要かつ難解な「KPI の設定」について、私がプロジェクトマネージャーとして普段考えていることや注意している点を紹介します。

KPI を決めるのは難しい

サービス開発において KPI を設定し、それを改善するような施策や検証を繰り返していくことは基本中の基本です。しかしながら、現実には「KPI を設定する」という行為自体の難易度が非常に高く、日夜頭を悩ませている開発者のみなさんも多いのではないでしょうか。 以下では、その要因の一つである「KPI は複数の要件を満たす必要がある」ことについて考えます。

満たすべき要件

具体的にどういった要件を満たす必要があるかはケースによって変わることもあると思いますが、私は普段以下の3点を KPI が満たすべき要件として考えています。

  • ユーザー体験 を表現する指標であること
  • 事業の収益 に繋がる指標であること
  • 自分たちの施策で 動かすことが可能 な指標であること

ユーザー体験を表現する指標であること

KPI を設定する行為は取り組むべき課題の設定であり、すなわちサービスの中で自分たちが次に改善するべきポイントを定義する行為であるとも言えます。このことから KPI は、その指標を改善することで なぜサービスを利用するユーザーの体験が向上するのか を説明できるものでなくてはなりません。

KPI を改善する方法は複数ある中で、この部分の論理立てが不十分なままプロジェクトを進めると「KPI はよくなるがユーザー体験は悪くなっている」というような方法を選んでしまうことにも繋がります。たとえば EC サイトの検索結果において「SEO に効果がある → "ページ閲覧数/セッション" を KPI に据えましょう」という意思決定をするのと、「複数の商品を見比べてもらうことで、ユーザーは真に満足する買い物をすることができるという仮説がある → "ページ閲覧数/セッション" を KPI に据えましょう」という意思決定をするのとでは、結果として設定した KPI が同じであっても意味合いが大きく異なります。

極端な例ではありますが、前者では "ページ閲覧数/セッション" を伸ばすために「1ページ辺りに表示される商品数を少なくする」といったような「指標しか見ていない施策」が実施される可能性が高くなります。一方、後者のようにユーザー体験と KPI を紐付けておくことで、例に上げたような施策はメンバーから異議が唱えられる可能性が高くなり、実施されづらくなります。このことは、ユーザー体験の担保が施策の実行に置いて一種の制約条件のように働いていると捉えることができるかもしれません。

事業の収益に繋がる指標であること

ユーザー体験と同様に重要になってくるのが、その指標を改善することで 事業の収益にいい影響を与えることができるか、そしてその規模は十分か という観点です。

長期間に渡ってユーザー体験をよくし続けるためには、その源泉となる収益を得ることについても必ず考えなければなりません*1。どれほど質の高いサービスや機能を提供できたとしても、それが収益につながらなければ継続的に改善を続けることは難しく、結果として機能を落としたりその領域から撤退したりすることになってしまいます。

したがって KPI は、現状存在しているマネタイズ方法に繋がるか、新しくマネタイズ方法を定義しそこに繋がるものである必要があります。

自分たちの施策で動かすことが可能な指標であること

当然のことではありますが、KPI は自分たちで 観測・改善ができるもの でなくてはなりません。それを担保するためのポイントとして「実装・集計の容易さ」と「外部要因の少なさ」があげられます。

前者については、実際にログを仕込んだり集計をする作業が自分たちが持っているリソースで可能かどうかを考える必要があります。たとえば、サービスのログ基盤が大量のログを収集・加工するのに十分なほど整っていないのであれば、複数の画面操作を組み合わせたような複雑な指標は避けるべきでしょう*2。プロジェクトに与えられた時間が3ヶ月なのであれば「一ヶ月後の再利用率」のような観測に時間のかかる指標は避けた方が無難です。

後者については、KPI の変動要因が多すぎないかどうかを考える必要があります。たとえば、施策を打っていない状態で A/A テストをした結果に差があるような指標は、平時からの変動が激しすぎる(≒多くの外部要因がある)と考えて避けるべきです。また、一般的には指標を実現するために必要な操作が多くなるほど、ノイズに近い離脱・誤操作が発生しやすくなり、施策を打ったときの効果が見えづらくなってしまう傾向にあります。

どう考えるべきか

以上に挙げたポイントを満たすような KPI を設定するのは非常に難しく、どこから手を付ければいいかわからなくなることも多々あります。しかし、一段抽象化して考えれば、これは複数の制約条件を持つ問題をいかに解くかという話になります。一般に複数の制約を満たす必要がある問題を考える際には、制約の最も厳しい条件から考えた方が後の手戻りが少なくなります。したがって、まず最初に手を付けるべきは、自分たちの環境において どの条件が最も厳しい制約か を考えることです。

「複数の制約を満たす」と聞くといわゆるベン図が頭に浮かぶ人も多いかもしれません。たとえば『ビジョナリー・カンパニー2』には、「情熱をもって取り組めるもの」「自社が世界一になれるもの」「経済的原動力になるもの」のすべてを満たすコトを対象にビジネスをしなさいと書かれており、その図解として以下のベン図が掲載されています。

f:id:spicycoffee:20200612162408p:plain
『ビジョナリー・カンパニー2 飛躍の法則』より作成

私自身この考え方は好きで似たような図もよく頭に思い浮かべますが、一つだけ疑問があるのは「この円のサイズは果たして本当におなじなのだろうか?」という点です。そして上記の主張をこの疑問に沿って捉え直すと、「まずは 最も小さな円について 考えよう」という話になります。

たとえば、すでに多くの機能を持ち、提供できるユーザー体験やそれを表現するための指標は大量に考えつくが、マネタイズの方法自体はそれほど多くないような大規模サービスでは下図左側のような図になります。この場合はまず事業の収益に繋がる指標をいくつか思い浮かべ、 その指標をよくしながらユーザー体験を向上させるにはどうすればいいか? という考え方をした方がよいでしょう。
逆に立ち上げたばかりのサービスでは、実現するべきユーザー体験はこれと決まっているが、マネタイズの方法については模索中で多くの可能性があり、ベン図は右側のような図になるかもしれません。この場合は、ユーザー体験を表現する指標をまず設定し、 その指標をよくすることで収益を上げるにはどうすればいいか? という考え方をするのがよさそうです。

f:id:spicycoffee:20200612162400p:plain

このように、組織や置かれている状況や個人の知識・経験によってそれぞれの円の大きさが変化する中で、最も小さな円=取りうる選択肢の少ない円についての要件から満たすように KPI を考えることで、すべての制約を満たした指標を設定しやすくなるのではないでしょうか。

またこれは、裏を返すと KPI や取り組むべき課題を設定する際には 円の一番小さいところから考えざるを得ない ということでもあります。つまり、たとえば先にあげた大規模サービスの例においてよりよいユーザー体験を作りたいなら、逆説的に一番小さな収益性の円を大きくする必要がある のです。これは個人の行動にするとたとえば書籍等からビジネスに関する知識を得たり、組織の置かれている状況について情報を収集したりして、収益に繋げる方法を新たに発見するといった行為になります。ログ基盤が整っていないせいで実現可能性の円が小さいのであれば、ログ基盤を整えることで取りうるユーザー体験の選択肢が広がるということです。これは、サービス開発に技術力が必要になる証左でもあります。

f:id:spicycoffee:20200612162404p:plain

施策を実行する際の注意点

ここまでの話は KPI の設定について述べたものでした。ここからは、私が実際に実施する施策の中で指標に関して注意している以下の 3 点について述べます*3

  • KPI そのものも改善サイクルの中で変化しうる
  • 施策で追う指標は3点セットで設定する
  • 施策の採用ラインは必ず事前に設定する

KPI そのものも改善サイクルの中で変化しうる

KPI そのものも絶対に不変のものであるわけではありません。KPI の設定が課題の設定と密になっている以上、事業を取り巻く環境や自分たちのサービスに対する理解が変化する中で取り組むべき課題そのものが変化し、KPI を変更した方がよい可能性があることは頭に入れておくべきです。
もちろん中長期で改善を進めていく指標として設定する以上、あまりにコロコロ変化するのは好ましくありませんが、時には「この KPI は本当に追うべきなんだろうか(=この課題を本当に解決すべきなのだろうか)」という思考を持つことも重要です。特にプロジェクトが発足してすぐのタイミングでは、先にあげた3条件に対する理解がチームの中でも不十分な可能性が高く、施策を重ねる中でその精度を上げた結果 KPI が変化することはよくあることかと思います。

施策で追う指標は3点セットで設定する

実際に KPI を改善するために施策を実施する際には、観測する指標を以下の3点セットで設定するようにしています。

  • KPI
  • 機能利用率
  • 副作用指標

KPI

設定した KPI です。

機能利用率

施策の意図が実現できているかを確認するために、実装した 機能が実際に利用されているか が確認できる指標を設定します。たとえば「直帰率」という KPI を設定し、その改善のために LP に新しいコンテンツを設定した場合、そのコンテンツのタップ率等を設定することになります。この指標を確認しなかった場合、KPI が動いたとしてもそれが意図したユーザー体験の変化によるものであるということが担保できなくなってしまいます。

副作用指標

実施した施策によって 既存コンテンツに影響を与える 可能性がある場合、その影響も観測する必要があります。先にあげた直帰率を改善するためのコンテンツの例であれば、その LP にもともと存在していた別導線のタップ率等を設定することになります。この指標を設定しなかった場合、意図したとおりに KPI が改善できたとしても「他の指標が悪化してしまい事業全体としてはマイナスになってしまっていた」というケースに気がつけなくなってしまいます。

施策の採用ラインは必ず事前に設定する

それぞれの指標がどの程度の数値になったときに 施策を採用するのかという目安の数字は必ず事前に設定 します。事後になってから議論しようとすると、せっかく作ったのだから採用したい気持ちが勝ってしまったり、最悪の場合ロクな議論もなく施策が採用されたりすることになりかねません。むやみやたらに機能を増やしてもユーザーの混乱を招くことに繋がるため、施策の採用については慎重になるべきであり、そのためにも事前に期待される効果等から採用ラインを設定することには大きな意味があります。
加えて言うと PM の立場であれば、施策が成功したときと失敗したときのそれぞれで次にどういった手を打つのかということも事前に想定しておく必要があります。

終わりに

冒頭にも述べましたように、この記事は私が実際に KPI を設定したり、それに基づいて施策を実施したりする際に注意している点をまとめたものです。サービス開発についての知見はその性質上「絶対の正解」が存在せず、また、それゆえに明文化されることが少ないものでもあると思います。私自身にとっても、この記事は「言語化しづらい思考を明文化して残す」という挑戦の一つであったのですが、これがみなさんのサービス開発の参考になればうれしいです。

クックパッドでは、このようにサービス開発について考えを巡らせながら、自分で手を動かして実際に開発を進めることのできるエンジニアを大募集しております。興味のわいた方や、この記事の内容について話がしたい!と感じた方はぜひ気軽に声をかけていただければと思います。

採用ページ: https://info.cookpad.com/careers/

*1:事業が投資フェーズであり、会社全体としては別事業の収益でバランスを取っているケースなどは別です

*2:あなたがエンジニアであれば自ら基盤を整える選択肢を取ることもできます

*3:後半2つについては以前書いた記事でも触れているのでよければ合わせてお読みください → https://techlife.cookpad.com/entry/2018/02/10/150709

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/