Cookpad TechConf 2019 を開催します!

こんにちは! 広報部のとくなり餃子大好き( id:tokunarigyozadaisuki )です。

2019年2月27日(水)、クックパッドの技術カンファレンス「Cookpad TechConf 2019」が開催されます。

公式サイト : https://techconf.cookpad.com/2019

f:id:tokunarigyozadaisuki:20190131135452p:plain

Cookpad TechConf 2019 について

【「Cookpad TechConf 2019」開催概要】
開催日:2月27日(水)12:30開場、17:30終了予定
場所:恵比寿ガーデンプレイス ザ・ガーデンホール(東京都目黒区三田1-13-2)
参加費:無料

「Cookpad TechConf 2019」は、クックパッドのサービスづくりのノウハウを発信する技術カンファレンスです。 私たちクックパッドは「毎日の料理を楽しみにする」というミッションを掲げ、世界中における食と料理の課題をテクノロジーで解決するために、様々な新規プロジェクトに挑戦しています。今年は、「クックパッドの新規事業を支える技術」をテーマとし、クックパッドのエンジニアやデザイナーがどのようにサービス開発に取り組んでいるのか、またその過程で得た技術的知見について公開します。

執行役 CTO 成田一生 の基調講演のほか、前半は本カンファレンスのテーマである、クックパッドの新規事業を支える技術やデザイン開発の現場について、後半にはテクニカルセッションをご用意しております。詳しい内容については、公式サイトをご覧ください。

会場には、今回発表するセッションに関連するプロダクトを展示するブースもご用意いたしますので、ぜひお立ち寄りください。

自作キーボードキット Cookpad Pad をノベルティとして少量配布します

昨今、ブームになっている自作キーボードですが、この度「Cookpad Pad」という自作キーボードを設計し、作りました。ごくわずかではございますが、本カンファレンスにてノベルティとしてお渡ししたいと考えております! 6つのキーで、「C」「O」「K」「P」「A」「D」を打つことができます。オープンハードウェアとして公開もしています。

f:id:tokunarigyozadaisuki:20190131140559j:plain

みなさまのご参加お待ちしております! 

本カンファレンスへの応募締切は2/4(月)までとさせていただいております。まだ「カンファレンス+懇親会」のお申込みも受付中です! 当日は、登壇社員以外にも運営として関わる社員が多数おります。お見かけの際はぜひお声がけください。みなさまにお会いできますことを楽しみにしております。

cookpad.connpass.com

Dynamic Type

モバイル基盤部のヴァンサン(@vincentisambart)です。

使っているアプリのフォントサイズを変えたいと思ったことありますか?目があまり良くないから文字を大きくしたい。逆にもっと多くの情報を一目で見られるために文字を少し小さくしたい。

フォントサイズをアプリ内で調整できるアプリもありますが、iOSではシステム全体のフォントサイズを調整できる「Dynamic Type」と呼ばれる機能があります。iOS全体の設定に一般→アクセシビリティ→さらに大きな文字(英語だとSettingsにGeneral→Accessibility→Larger Text)で変えられます。対応しているアプリではフォントサイズがその設定に合わせられます。

標準より少し小さくできるとはいえ、「アクセシビリティ」設定に入っているのもあって文字を大きくする方がメインのユースケースの気がします。

Dynamic TypeはiOS 7から使えるようになりましたが、関連している様々な便利機能がその後のiOSバージョンに追加されました。

一応Dynamic Typeを使うにはAuto Layoutは必須ではないのですが、手動レイアウトと一緒に使うのはおすすめできません。

システム設定変更

設定を変える前に、簡単に戻せるようにまずは標準設定を覚えておきましょう。標準設定は以下のスクリーンショットのように「さらに大きな文字」が無効で、下部のスライダーがど真ん中です。

Dynamic Type標準設定

スライダーを動かすとフォントサイズがどれくらい変わるのかすぐ見られます。一番小さい設定(extra smallまたはXS)にすると、以下のようになります。

f:id:vincentisambart:20190104132028p:plain:w320

「さらに大きな文字」無効のまま一番大きい設定(extra extra extra largeまたはXXXL)にすると以下のようになります。

f:id:vincentisambart:20190104132100p:plain:w320

アクセシビリティサイズはXXXLより大きく、「さらに大きな文字」を有効にすると選択できるようになる5つの設定です。その一番大きい設定(accessibility extra extra extra large、またはAX5)にすると以下のようになります。

f:id:vincentisambart:20190104131951p:plain:w320

因みにDynamic Type対応しているアプリでも、アクセシビリティサイズをXXXLと同じ扱いにしているアプリもあります。文字がとても大きいのでレイアウトが崩れやすいからでしょう。

設定の値

ユーザーに選択されたDynamic Type設定はUIContentSizeCategoryという型で、UIApplication.shared.preferredContentSizeCategoryまたは(iOS 8以上では)traitCollection.preferredContentSizeCategoryで取得できます。現時点(iOS 12)で利用できるすべての値は以下のとおりです。

  • unspecified (iOS 10以上)
  • extraSmall (XS, xSmall)
  • small (S)
  • medium (M)
  • large (L) – 標準設定
  • extraLarge (XL, xLarge)
  • extraExtraLarge (XXL, xxLarge)
  • extraExtraExtraLarge (XXXL, xxxLarge)
  • accessibilityMedium (AccessibilityM, AX1)
  • accessibilityLarge (AccessibilityL, AX2)
  • accessibilityExtraLarge (AccessibilityXL, AX3)
  • accessibilityExtraExtraLarge (AccessibilityXXL, AX4)
  • accessibilityExtraExtraExtraLarge (AccessibilityXXXL, AX5)

設定画面で「さらに大きな文字」が有効になっているときだけに選択できるアクセシビリティサイズは名前がaccessibilityから始まります。iOS 11以上では、アクセシビリティサイズかどうか確認するためにUIContentSizeCategoryisAccessibilityCategoryというメソッドがあります。また、同じくiOS 11以上でUIContentSizeCategory<, <=, >=, >で比較できるようになるのでtraitCollection.preferredContentSizeCategory > .extraExtraExtraLargeも使えます。

content size categoryの値によって、レイアウトを変えたりできます。例えばDynamic Typeの設定画面で大きいアクセシビリティサイズを選ぶ時、「さらに大きな文字」スイッチがラベルの右からその下に移ります。Dynamic Type設定によってUIStackViewの設定が変わるのでしょう。

f:id:vincentisambart:20190104131951p:plain:w320

自分のアプリ内、Dynamic Typeの設定に合わせてするレイアウト変更は作成時以外、どういうタイミングでやれば良いのでしょうか。

変更に反応

アプリがバックグラウンドにある間に、ユーザーがDynamic Typeの設定を変えるとアプリが終了されるわけではありません。ユーザーがアプリに戻ったら、アプリが変更を知らされる仕組みが2つあります:

  • UIContentSizeCategory.didChangeNotificationというnotificationがアプリに送られます。
  • iOS 8以上ではtraitCollectionDidChangeメソッドが呼ばれます。UITraitEnvironmentプロトコルのメソッドですが、UIViewUIViewControllerが実装しているので、自分のビューやビューコントローラに以下のコードでDynamic Typeの設定変更に反応できます。
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory {
        // 設定が変わったときにやりたいこと
    }
}

注意: ビューコントローラーが表示されていない場合(例えば別のタブが表示されている間)、traitCollectionDidChangeは呼ばれませんが、UIContentSizeCategory.didChangeNotificationは届きます。

Text Style (書式)

設定によってレイアウトを変えても、Dynamic Typeの「Type」は文字、フォントのことなので、肝心のフォントサイズはどうすれば良いのでしょうか。アプリに使われているすべてのスタイルをcontent size categoryごとに用意するのは手間が掛かりすぎます。

そのため、iOS 7からシステムにいくつかのText Styleが用意されています。ワードプロセッサーでいうと「書式」と同じ概念です(ただし自分の好みに合わせて変えることができません)。Human Interface Guidelines(HIG)のTypographyページにスタイルのリストと各content size categoryでのフォントサイズが見られます。アクセシビリティサイズも同じページの少し下にAX1, AX2, AX3, AX4, AX5として紹介されてあります。因みにフォント自体はシステムフォント(San Francisco)です。

そのスタイルはコード上ではUIFont.TextStyleです。iOS 7ではスタイルが少なかった(body, caption1, caption2, footnote, headline, subheadline)のですが、iOS 9(callout, title1, title2, title3)やiOS 11(largeTitle)で少し増えました。

スタイルによって変わるのはフォントサイズだけではなく、headlineスタイルの場合、フォントがボルドになります。

large設定では、iPadで各スタイルを表示すると以下のようになります。

f:id:vincentisambart:20190104132124p:plain:w500

extraExtraExtraLarge設定では、以下のようになります。

f:id:vincentisambart:20190104132150p:plain:w500

accessibilityExtraExtraExtraLarge設定では、以下のようになります。この最大サイズでは各スタイルの差があまりないですね。

f:id:vincentisambart:20190104132207p:plain:w500

コードでは、UIFont.preferredFont(forTextStyle:)がメインのAPIです。UIFont.preferredFont(forTextStyle: .body)が今のDynamic Type設定に合っているbodyスタイルのフォントを返してくれます。

Interface Builderでは、スタイルをlabelやtext viewのFont設定にText Stylesの中で選べます。

f:id:vincentisambart:20190104131624p:plain:w390

自動フォント調整

上記のセクションの使い方だけでは、アプリがバックグラウンドにある間にユーザーがDynamic Typeの設定を変えてアプリに戻ったら、新しい設定が自動的に反映されるわけではありません。自分でcontent size categoryの変更に反応して、labelやtext viewのフォントを指定し直す必要があります。テキストスタイルをコードで指定していればまだ良いのですが、Interface Builderで指定した場合、コードで再指定することになってしまいます。

その問題を避けるため、iOS 10以上ではUIContentSizeCategoryAdjustingプロトコルに準拠しているクラス(UILabel, UITextField, UITextView)にadjustsFontForContentSizeCategoryというプロパティがあります。Interface Builder上の「Automatically Adjusts Font」と同じです。

f:id:vincentisambart:20190104131820p:plain:w325

以下その機能を「自動フォント調整」と呼ぶことにします。

自動フォント調整が上記のプロパティで有効になっているビューは、Dynamic Type設定変更後、バックグラウンドにあったアプリに戻るとフォントサイズが自動的に更新されます。ただし、フォントサイズが更新されるのは上記に紹介したtext style、またはあとで紹介するfont metrics、が使われている場合のみに有効です。

有効にされたら自動フォント調整が効く 有効にされても自動フォント調整が効かない
label.font = UIFont.preferredFont(forTextStyle: .body) label.font = UIFont.systemFont(ofSize: 12)
Interface BuilderでフォントにText Stylesのどれかを選んだ Interface Builderでカスタムやシステムフォントを選んだ

実際Interface Builderでテキストスタイルでないフォントを指定して、「Automatically Adjusts Font」にチェックを入れた場合、ビルド時に警告が出ます。コードでそのビューのフォントに自動フォント調整が効くフォントを指定したら、ちゃんと動きますがビルド時の警告が残ります。なので、コードでフォントを指定する場合、自動フォント調整を有効にするのもコードでやる(view.adjustsFontForContentSizeCategory = true)のが良いでしょう。

カスタム

テキストスタイルAPIはシステムフォントしか使えませんし、システムフォントを使うとしても、スタイルの種類が多くありません。

テキストスタイルAPIが用意されたスタイルを使う前提で作られたとしても、コードで自分の定義したサイズやフォントを使う方法がなかったわけではありません。少し考えるだけで以下の2つの方法がすぐ思いつくのでしょう。

  • switch preferredContentSizeCategory { ... } – すべてのcontent size categoryの分のフォントとサイズを用意します。かなり手間が掛かりますし、スタイルを変えたいときも大変です。
  • ((UIFont.preferredFont(forTextStyle: .body).pointSize / 17.0) * myFontSize).round() (17.0がbodyスタイルの標準のポイントサイズです)で自分のフォントのサイズをcontent size categoryに合わせて調整します。

実は、現時点でクックパッドiOSアプリがDynamic Typeに対応している唯一の画面、レシピ詳細では長い間後者を使っています。

もちろんこの2つの方法では、自動フォント調整が使えません。Dynamic Typeの設定に変更があったときにフォントサイズを調整したければ、「変更に反応」セクションで紹介した方法で変更に反応して各ビューのフォントを再指定します。

Deployment TargetがiOS 10以前のアプリはカスタムなスタイルを使いたかったら、上記の方法しかありませんが、iOS 11以上だと、もう少し楽なAPIが提供されています。

UIFontMetrics

iOS 11から誕生したUIFontMetricsクラスを使うと、好きなフォントやフォントサイズを選んで、そのカスタムなスタイルのサイズをDynamic Typeの設定に合わせられるだけではなく、自動フォント調整も使えます。

使い方は以下のようです。

let customFont = UIFont(name: "AmericanTypewriter", size: 17)! // 好きなサイズのシステムフォント`UIFont.systemFont(ofSize: myCustomSize)`でも大丈夫です
let scaledFont = UIFontMetrics.default.scaledFont(for: customFont)
label.font = scaledFont
label.adjustsImageSizeForAccessibilityContentSizeCategory = true

もしフォントサイズが大きすぎるとレイアウトが崩れてしまう場合、最大フォントサイズを指定できます。

let scaledFont = UIFontMetrics.default.scaledFont(
    for: UIFont.systemFont(ofSize: customFont),
    maximumPointSize: 50.0
)

UIFontMetricsにはフォントだけではなく、画像のサイズを文字の大きさに合わせるためのメソッドもあります。

let scaledSize = UIFontMetrics.default.scaledValue(for: sizeToScale)

シンプルですね。懸念点が1つあります:Interface Builderでその機能が使えません。ビューをInterface Builderで配置しても良いのですが、フォントはコードで指定する必要があります。

メトリックスはテキストスタイル次第

上記のコードでUIFontMetricsのインスタンスはUIFontMetrics.defaultを使っていましたが、UIFontMetricsのドキュメントを見ると、init(forTextStyle textStyle: UIFont.TextStyle)もあります。実はUIFontMetrics.defaultUIFontMetrics(forTextStyle: .body)に初期化されたものです。UIFontMetrics(forTextStyle: .title1)UIFontMetrics(forTextStyle: .caption2)なども使えます。

どうしてテキストスタイルを指定できるのかと言いますと、Dynamic Type設定によってフォントのサイズがどう変わるのかはテキストスタイルごとに少し違うためです。以下の図を見ればもう少し分かるかと思います。図に使われた数値をどう計算したのかあとで説明します。

f:id:vincentisambart:20190104131516p:plain

カスタムスタイルごとにサイズの推移をどのシステムスタイルに合わせたいのか決めるのが大変だと思うので、分からない時はUIFontMetrics.defaultを使って良い気がします。

UIFontMetricsの計算

上記は説明せずに図を出しましたが、実際サイズがどう計算されるのでしょうか?UIFontMetrics.scaledFont(for:)の返しているフォントの自動フォント調整対応は自分で実装できないかもしれませんが、計算くらいはできるのではないでしょうか。

実はUIFontMetrics.scaledFont(for:)に使われる計算が上記の「カスタム」セクションで紹介したすぐ思いつきそうな((UIFont.preferredFont(forTextStyle: .body).pointSize / 17.0) * myFontSize).round()に近いです。ただし、割合はpoint sizeからではなく、leadingから計算されています。

数字は既に紹介しましたHIGのTypographyページのDynamic Type Sizesにある「Leading」にあります。図に使った数字は対象のcontent size categoryのleadingを標準(large)のleadingで割っただけです。

因みにフォントに関して「Leading」は「リーディング」ではなく、「レディング」と読みます。

TypographyページにあるLeadingというのは日本語で「行送り」のことです。1行のベースラインから次の行のベースラインまでの距離です。

UIFontにあるleadingは違っていて、こっちは「行間」のことです。TypographyページにあるLeadingがUIFontでいうとleading + lineHeightです。

いろんな説明よりコードの方が分かりやすいかもしれません。下記のコードに使われているUIFont.preferredFont(forTextStyle:compatibleWith:)がiOS 10以上でしか使えませんが、もっと古いiOSバージョンではleadingの値のテーブルをコードに埋め込めば簡単に実装できるしょう。

因みに下記のコードに使われているUITraitCollectionが表示に影響ありそうな環境の状態(サイズクラス、画面スケール)や設定(Dynamic Type)をまとめるものです。UIFontMetricsの場合、サイズがどう変わるのか見たいとき、Dynamic Type設定をいちいち変えるのが大変なので、UITraitCollectionを渡すのが良いのですが、ビューのフォントを指定する場合は基本的にUITraitCollectionを渡さず、ユーザーの設定に合うものを求めます。

import UIKit

private extension UIFont {
    // https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/typography/#dynamic-type-sizes にある"leading"に相当します
    // 実はUIFontには`_bodyLeading`というメソッドがありますが、プライベートなので再実装が必要です
    var bodyLeading: CGFloat {
        return lineHeight + leading
    }
}

// `UIFontMetrics`の機能の一部を再実装するクラス
// 注意:自動フォント調整対応のフォントを作るには本物の`UIFontMetrics`を使うしかありません
class SimpleFontMetrics {
    private var textStyle: UIFont.TextStyle
    init(forTextStyle textStyle: UIFont.TextStyle) {
        self.textStyle = textStyle
    }

    static let `default`: SimpleFontMetrics = .init(forTextStyle: .body)

    private static let defaultContentSizeCategoryTraitCollection = UITraitCollection(preferredContentSizeCategory: .large)

    private func unroundedScaledValue(for value: CGFloat, compatibleWith traitCollection: UITraitCollection?) -> CGFloat {
        let defaultFont = UIFont.preferredFont(forTextStyle: textStyle, compatibleWith: SimpleFontMetrics.defaultContentSizeCategoryTraitCollection)
        let currentFont = UIFont.preferredFont(forTextStyle: textStyle, compatibleWith: traitCollection)

        return (value * currentFont.bodyLeading) / defaultFont.bodyLeading
    }

    // `UIFontMetrics.scaledValue(for:compatibleWith:)`と同じです
    func scaledValue(for value: CGFloat, compatibleWith traitCollection: UITraitCollection? = nil) -> CGFloat {
        // 表示に使われている画面のスケール(ポイントごとのピクセル数)
        let displayScale: CGFloat
        // `traitCollection.displayScale`が0だったら未定だということなので`traitCollection`が指定されていないと同じ扱いです
        if let traitCollection = traitCollection, traitCollection.displayScale != 0 {
            displayScale = traitCollection.displayScale
        } else {
            displayScale = UIScreen.main.scale
        }
        // ピクセル単位で四捨五入
        return (unroundedScaledValue(for: value, compatibleWith: traitCollection) * displayScale).rounded() / displayScale
    }

    // `UIFontMetrics.scaledFont(for:maximumPointSize:compatibleWith:).pointSize`に相当します
    func scaledFontPointSize(for pointSize: CGFloat, maximumPointSize: CGFloat = 0, compatibleWith traitCollection: UITraitCollection? = nil) -> CGFloat {
        assert(pointSize >= 0, "You cannot create a font of negative size.")
        // フォントサイズの四捨五入はピクセル単位ではなくポイント単位です
        let scaledPointSize = unroundedScaledValue(for: pointSize, compatibleWith: traitCollection).rounded()
        if maximumPointSize == 0 {
            return scaledPointSize
        } else {
            return min(scaledPointSize, maximumPointSize)
        }
    }
}

UIFontMetricsの少し不自然なところ

上記にUIFontMetricsがサイズ計算に使っている比率がフォントのサイズ(pointSize)のではなく、行送り(leading + lineHeight)のだと書きましたが、フォントサイズの計算にその比率が使われるから少し不自然な結果になることがあります。

let largeTraitCollection = UITraitCollection(preferredContentSizeCategory: .large)
let axxxlTraitCollection = UITraitCollection(preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge)
let baseFont = UIFont.preferredFont(forTextStyle: .body, compatibleWith: largeTraitCollection)
UIFont.preferredFont(forTextStyle: .body, compatibleWith: axxxlTraitCollection).pointSize // => 53
UIFontMetrics.default.scaledFont(for: baseFont, compatibleWith: axxxlTraitCollection).pointSize // => 48

標準文字サイズ(large)のbodyスタイルのフォントをUIFontMetricsで一番大きいDynamic Type設定に拡大されたフォントと、一番大きいDynamic Type設定でのbodyのフォントはサイズが少し異なります。そのため、UIFont.preferredFontUIFontMetrics.scaledFontを同じ画面で混在させない方が良いかもしれません。

因みに、今回はその方が分かりやすかったのですが、普段はUIFont.preferredFontの戻り値をUIFontMetrics.scaledFontを渡さないでおきましょう。一応動くのですが、UIFontMetrics.scaledFontの戻り値をまたUIFontMetrics.scaledFontに渡すとObjective-Cの例外が発生します。

画像

テキストの横に画像があると、その画像をある程度テキストのサイズに合わせたいことがあるかもしれません。手動でUIFontMetrics.scaledValue(for:)を使ってもできますが、iOS 11からUIButtonUIImageViewが準拠しているUIAccessibilityContentSizeCategoryImageAdjustingプロトコルにadjustsImageSizeForAccessibilityContentSizeCategoryというプロパティが登場しました。Interface Builderにある「Adjust Image Size」と同じです。

f:id:vincentisambart:20190104132302p:plain:w325

そのプロパティがtrueの場合、Dynamic Type設定によってビューの画像が拡大されますが、アクセシビリティサイズが選ばれている場合のみです。他の設定ではサイズが変わりません。

サイズ調整で画像が大きくなると荒くなってしまう可能性があります。もとの画像がPDFでしたら、Asset Catalogの設定で「Preserve Vector Data」にチェックを入れたら綺麗に拡大されます。

f:id:vincentisambart:20190104132325p:plain:w325

その他

  • 広い画面でもコンテンツが広がりすぎないためにある読みやすい幅機能はDynamic Typeと一緒に使われるように設計されているので、ぜひ紹介記事をご覧ください。
  • 既存のアプリは全画面を一気に対応するのは難しいでしょう。1つの画面で対応しているビューとしていないビューが混在すると不自然な表示になりますので、画面単位で対応した方が良い気がします。
  • テーブルのセルの高さはできるだけシステムが自動計算するようにしましょう:estimatedRowHeightの指定を忘れず(指定されないと自動計算が動かないので)、高さ(rowHeight)をUITableView.automaticDimensionにします。rowHeightestimatedRowHeightUITableViewのプロパティで指定しても、UITableViewDelegateのメソッドで返しても、どっちでも大丈夫です。
  • Auto Layoutを使ってビューを配置するとき、ベースラインアンカー(firstBaselineAnchorまたはlastBaselineAnchor)に対する縦制約はconstraint(equalToSystemSpacingBelow:multiplier:)(またはlessThanバージョンgreaterThanバージョン)を使うと、制約の高さがフォントのサイズに合わせて変わります。Interface Builder上では「Constant」の値に「Use Standard Value」を選ぶのと同じです。

まとめ

Dynamic Type設定でユーザーが自分の視力や好みに合わせてフォントのサイズを選べますが、アプリ側での対応が必要です。

iOS 7でその機能が導入されてから、対応がやりやすくなる機能が少しずつ増えて、iOS 11以上ではだいぶ楽になったのではないでしょうか。

もっと多くのユーザーの使いやすさのために対応しているアプリを増やしておきましょう。

クックパッド基幹システムのmicroservices化戦略 〜お台場プロジェクト1年半の軌跡〜

インフラストラクチャー部の青木峰郎です。 最近はDWH運用の傍ら、所属とまったく関係のないサービス開発のためのデザインスプリントをしつつ、 Java 10でgRPCサーバーを書きつつ、 リアクティブプログラミングを使った非同期オーケストレーション層を勢いだけで導入したりしています。

ですが今日はそれとはあまり関係なく、クックパッドの中核サービスであるレシピサービスの アーキテクチャ改善プロジェクト、「お台場プロジェクト」の戦略について話します。

これまで、お台場プロジェクトで行った施策について対外的に発表したことはあっても、 全体戦略について話したことはありませんでした。 その一番の理由は、正直に言って、プロジェクトオーナーであるわたしにもプロジェクト全体の姿が見えていなかったからです。 しかし現在プロジェクト開始から1年半が経過してようやく全貌が見えてきたので、すべてをお話ししようと思います。

クックパッドの「本体」システム

クックパッドは現在では大小様々なサービスをリリースしています。 しかしその中でも最初期から存在し、 現在でもあらゆる意味で中核にあるサービスがいわゆる「クックパッド」、 社内では「本体」や「cookpad_all」さらに略して「all」などと呼ばれているレシピサービスです。

このレシピサービスは世界最大のモノリシックなRuby on Railsサービスであり、 いま手元で適当に数えただけでRubyのコードが27万行(テストを除く)、 テストが51万行、HTMLテンプレートが14万行あります。 このコード量でウェブサービスのcookpad、APIサーバーのpantry、 バッチのkuroko、管理画面のpapaの4アプリケーションを主に実装しています。

お台場プロジェクトとは

そして、この巨大な「本体」システムのアーキテクチャを根本から刷新し、 改善するプロジェクトが「お台場プロジェクト」です。 わたしがこのお台場プロジェクトを開始したのは去年(2017年)のバレンタインデー、2月14日のことでした。 そのときやったことは「とりあえず改善したいことをリストアップする場所」としてGitHubにレポジトリを作っただけでしたが、 その後にお台場プロジェクトは技術部の注力課題に昇格してメンバーも大幅に増員され、1年半が経過しました。

約30万行というコードサイズは世間一般で言えば超巨大とは言えないでしょうが、 少なくとも容易なプロジェクトではないことは確かです。 2017年2月の時点では、このプロジェクトを達成できると言う人も、俺がやると言う人も社内にはいませんでした。 むしろ、本体には関わりたくない、できるだけ触りたくない、 最低限の機能追加以外の余計なことはしたくないという雰囲気が充満していたように思います。

お台場プロジェクトが目指すもの

お台場プロジェクトの目的は、「本体」にも大規模な機能追加・変更ができるようにすること、 さらにその大規模な変更をできるだけ少ない時間でできるようにすることです。 逆に言うと、プロジェクト開始時点では、思い切った機能追加はできなかったということになります。

大規模な変更を行えない技術的な理由としては以下のような点が挙げられます。

  • コードを変更すると意図しないところが壊れる。例えばウェブサービスをいじるとガラケーの認証が壊れる。
  • ライブラリが古かったとしても依存が多すぎて気軽に更新できない。
  • 実行環境が非常に複雑かつ特殊で、迂闊にデータベースを追加したりできない。
  • 普通のツールが動かない。例えばコードカバレージが取れない、並列テストが動かない。
  • ObjectクラスやStringクラスのような非常に基本的なクラスが改変されており、普通の動きをしない。

また、組織的・プロセス的な理由もあります。

  • あるコードのオーナーが誰かわからない。例えばuserリソースのAPIを変更したくても誰にも相談できない。
  • GitHubのissue・pull requestが多すぎてとても全部は見ていられない。通知も何も機能しない。
  • 「本体」をいじる開発者が多すぎて、改善系のpull requestを作ると頻繁にコンフリクトする。

「本体」で大きな変更を行おうと思うと、これらの問題がすべて同時に襲いかかってきます。 例えばI/Oの激しいシステムを追加するためにDynamoDBを使いたいと思ったとしても、 AWS-SDKのバージョンが古いのでまずはSDKのバージョンを更新するのに1ヶ月かかる、 テストが遅いので検証にも時間がかかり、そのあいだに別のpullreqがマージされてコンフリクト、 実装を進めていくと既存のクラスに変更が必要そうなことがわかってきたがオーナーが誰かはわからない、 がんばって実装してみたが触ってもいないバッチのCIが通らない、 ようやく理由がわかって直してデプロイしたらなぜかガラケーサイトが落ちた……という具合です。

これはさすがに誇張だろうと思われるかもしれませんが、残念ながらすべて事実を元にした話です。 こんな開発を続けていれば、大きな機能を追加しようという気がなくなって当然でしょう。

タスクの優先順位の決定

こんな状態ですから、なんらかの改善をしなければいけないことはわかります。 しかし、はたして何から手を付けたらよいものでしょうか。 まずは問題をリストアップしてはみたのですが、あまりにも問題が多すぎ粒度もバラバラ、しかもどれも大変そうで腰が引けます。 タスクを絞るところが肝であることはどう考えても明らかでした。 そこで、ビジネス上の価値と政治的判断を加味しつつ、「やること」「やらないこと」を次のようにエイヤと決めました。

やること:

  • APIサーバー(pantry)のアーキテクチャ改善
  • 不要なサービスの廃止
  • デッドコードの自動検出と削除
  • ストレージ数の削減
  • 特殊な実行環境・開発環境の廃止

やらないこと:

  • ウェブサービス(cookpad)のビュー改善
  • Railsのメジャーバージョンアップ
  • 細かい実装レベルの改善

最初に決めなかったこと:

  • 全面的にmicroservice化するかどうか

まず、ユーザー数と有料会員数の分布、将来向かう方向を考えると、 ウェブやガラケーよりもスマホアプリのAPIサーバー(pantry)が重要であることは明らかなので、 そのアーキテクチャ改善につながらないタスクは原則捨てると決めました。 そうすると例えばウェブのビューの実装改善などは自動的に「やらないこと」になります。

microservices化はギリギリまで先送り

一方で、先に述べたように、本体のコードはオーナーがよくわかりませんし、 最初からmicroservices化するとは決断できなかったので、 いきなりそこに大々的に踏み込んでいくことはしませんでした。 例外は検索システム1つだけです。

最終的にコードをなにかしら分割することは避けられないとは思っていましたが、 サービスとして分割する以外にも、たとえばRails engineとして分割するなど代替案はいくつかあります。 とにかく戦略的に、大々的に分割するぞーと宣言して分割はしたけど失敗でしたという事態だけは絶対に避けたかったので、 どうしても分割を避けられない時が来るまで決断を遅らせることにしたわけです。

これほど慎重になった背景にはもちろん理由があります。 実は、クックパッドでは2015年から2016年くらいにすでに一度microservices化を試して失敗した経験があるのです。

当時はmicroservices化の機運が社内で盛り上がっており、 今後「本体」に機能を追加するときは必ずmicroservicesに分けようということになりました。 しかしこのときの経過が非常にまずくて、業務プロセスを複数のアプリに分断してしまって作業が増えたり、 管理アプリだけ別サービスに分割したことによってひたすら新規APIを作るはめになったりと、 microservices化全般に悪い印象だけが残ってしまったのです。 後者の管理アプリに至っては最終的に「本体」の管理アプリに統合され、microservicesではなくなってしまいました。

この時の経験があったため、最初からシステム分割を行うのはできるだけ避けることにしました。 最初に社内のエンジニアにお台場プロジェクトの話をしたときも、 microservices化に反対する意見が出た場合に備えて想定問答集を作ったほどです。 もっとも実際に話してみると、当時とは社内外の状況が変わったこともあって、反発はほとんどありませんでした。

f:id:mineroaoki:20181228000116j:plain
お台場プロジェクト発表時の社内の様子

ちなみにそのときの写真がこれで、わたしが用意した大変わかりやすいスライドで成田CTOが喋っているの図です。 お台場プロジェクトが終わってからプレスリリースを打つときに使おうと思っていた秘蔵写真ですが、 いい機会なので公開します。

最初は確実に成果を出せるコード削除を実施

いきなりサービス分割をしない代わりにまずやったタスクが、古いサービスの廃止と、古いAPIサーバーの廃止です。 ほぼ誰も使っていない機能の廃止はトレードオフがほぼないので、どう考えてもやったほうが得であり、 しかも誰も反対しないからです。まず最初にわかりやすい成果を作るために最適のタスクでした。 社内に効果を示すためにも、チームが自信を得るためにも、 比較的簡単にできて結果のわかりやすいタスクから始めるのは妥当でしょう。 時期的にもちょうど大きな機能が分社されて消せるコードがたくさんありました。

また、今後コードを消していくうえで、 本番で使われていないコード(デッドコード)がツールで自動判定できたら非常に楽ができるので、 その方法を少し調べて実施することにしました。 その結果できあがったのがRubyのLazy Loadingを使って実行されないコードを探す手法です。 現在ではこのシステムによって自動的に不要コードを検知できるようになっています。 さらに、このデッドコード検出機能はブラッシュアップされてRuby 2.6にも取り込まれました

「Railsのメジャーバージョンアップはしない」

Railsのメジャーバージョンアップもお台場プロジェクトではやらないと最初に決めました。 これは単純に「Railsをバージョンアップしたところでアーキテクチャの根本改善にはつながらない」 という理由もありますが、その他に一種のシンボル的な意味もあります。

この点を説明するには、まず少しだけクックパッドの組織構造の話をする必要があります。 クックパッドでは永らく、 「本体」のソフトウェアアーキテクチャ(主にRails)については技術部の開発基盤というグループが責任を持ち、 それより上の機能については各事業部が分割して持つという責任分担が行われてきました。 結果として2016年までの数年は、開発基盤グループに新しい人が入るととりあえず 「本体」のRailsバージョンアップをするというのが洗礼の儀式のように行われていたのです。 しかしこのタスクが技術的にも政治的にも非常につらく、 結果として若者が「本体」に対するヘイトをためていく構造になっていました。

そういった歴史の結果として、クックパッドにおいては「Railsのバージョンアップ」というタスクが 「これまでの開発基盤の役割」とほぼ同じ意味を持っています。 そんな状況で、開発基盤で新しいプロジェクトを始めます、 それじゃあまずRailsバージョンアップをやりますと言ったら、いままでと何も変わりません。 中身は同じで名前だけ変えたんですねということになりかねないからです。

お台場プロジェクトはシステムアーキテクチャの改善プロジェクトであると同時に、 組織アーキテクチャの改善プロジェクトでもあります。 巨大な1つのシステムをメンテするのはもはや手に余るので分割統治しよう、 というのがお台場プロジェクトの目的ですから、組織もいまのままであるわけがありません。 具体的には、開発基盤グループが不要にならなければいけません。

つまりある意味で開発基盤を解散させるプロジェクトでもあるお台場プロジェクトで、 これまでの開発基盤と同じことをやるというのはどう考えても筋が通らないわけです。 ですから、お台場プロジェクトでは絶対にRailsのバージョンアップはしないと決めました。

アプリケーション構造の整理

コード削除とほぼ同時にやったのが、アプリケーション構造の整理でした。

これについてはそもそも問題自体の説明が必要でしょう。 「本体」システムはウェブサービスやAPIサーバー、非同期ジョブ、 バッチなど複数のRailsアプリケーションからなるのですが、 そのうちAPIサーバーと非同期ジョブはウェブサービスの「モード」として実装されていました。 ウェブサービスを起動したとき、特定の名前のファイルが存在したら APIサーバーのエントリポイントが生えてAPIサーバーとして動くという、凄まじい実装がされていたのです。

プロジェクト開始から2017年いっぱいくらいにわたって、このものすごい実装を排除しました。 APIサーバー(pantry)は独立したアプリケーションとし、非同期ジョブ(background-worker)はバッチ(kuroko)に置き換えるなどして廃止。 同時に古いAPIサーバー(api, api2)を消したこともあり、アプリケーション構造はだいぶシンプルにすることができました。

f:id:mineroaoki:20181228000249p:plain
アプリケーション構造の整理

全面的なmicroservices化を決断

当初はmicroservices化を前面に出すかどうかはまだ決めかねていたのですが、 現在はすでに全面的にmicroservices化することを決めています。 その決め手となったのは、最初に小さな機能を分割してみて、その効果が明白に感じられたことでした。

具体的には、スマホアプリのA/Bテストなどに使っているuser_featuresという機能を分割した時点です。 この機能はもともと技術部がオーナーでしたし、専用Redis 1つだけにアクセスする構造になっていたため、 政治的にも技術的にも都合がよかったのです。 そこでこの機能を分割してみたところ、分割したあとのほうが明らかにつくりがわかりやすく、 改善しやすくなりましたし、実際に改善が進みました。 誰が実装すべきかも明確で、それでいて他の部署の人間も逆に手を出しやすくなったと感じています。 やはりコード共有というのは「誰も持っていない」のではだめで、オーナーありきのほうがうまくいくなと感じます。

わたし個人としても最近、本体のAPIサーバー(pantry)にとある機能を追加するためにmicroserviceを1つ実装したのですが、 DynamoDBを中心としたアーキテクチャ設計からstaging環境・production環境の構築に最小の実装までを、わたし1人で1週間弱で終えることができました。 これはお台場プロジェクトをやっていなければとてもできなかったことです。 もし2016年時点のpantryでこれをやれと言われたら何ヶ月必要になっていたか予想できません。

すべてがHakoになる

もともとクックパッドではmicroservicesのためのインフラは整備されつつありました。 例えば次のようなアプリケーションやミドルウェアが稼働しています。

さらに直近ではRubyのgRPCライブラリの置き換えなども行われています。 すでに新規のサービスはすべてこれらのインフラに乗っていますが、「本体」をどうするかだけはずっと宙に浮いた状態だったわけです。

2018年になって、「本体」もこの共通インフラに乗せると決めた時点で、話は非常にシンプルになりました。 現在では徐々にではありますが「本体」がHako化(コンテナ化)されつつあり、来年内の完了を見込んでいます。 社内のすべてのシステムがmicroservices構成になり、コンテナで動く状態が視界に入ったと言えるでしょう。

microservicesへの分割戦略

さて、microservices化を決断した場合、次に問題になってくるのが、「どこでサービスを切るか」です。 正直、これはシステム設計の話なので、パッケージをどう分けるか、クラスをどう分けるかと同じようなものであり、 決定的な基準がありません。

しかし、特に「本体」システムに限って言えば分割する場合の成功パターンがわかってきました。

そのパターンとは、データベースがすでに分かれている機能については、データベースを中心としてそれに紐付く部分を分割することです。 「本体」はRailsアプリケーションにしては珍しいことに、非常に大量のデータベースにアクセスしています。 database.ymlを見る限りだと、実に20以上のデータベースが接続されているようです。 これらのデータベースを、データベースとそれに紐付くコードをまとめて分割すると、 意味的にもデータフロー的にも無理がなく分割できることがわかりました。 これは冷静に考えてみれば当然と言えば当然なのですが、 このことに気付いてからは、「データベースが切れているならシステムも切れる」というわかりやすい基準ができました。

具体的には、Solrを核として分離したレシピ検索のシステム(voyager)、 専用Redisを中心として分離したA/Bテスト機能(user_features)、 専用Auroraをベースとして分離した「料理きろく」機能などがこのパターンでうまく分割できた例です。 今後もこのパターンに沿って、専用MySQLを持つブックマーク機能(MYフォルダ)や、 投稿者向けの統計機能(キッチンレポート)を分割していく予定です。 逆に、メインの一番巨大なMySQLに紐付いた機能群は最後に分割することになるでしょう。

microservicesに分割するという話になった当初は分割の基準がよくわかっていなかったので、 例えば「レシピのようによく使うリソースを最初に切り出すのがよいのではないか」 「事業部3つに合わせていきなり3つに分割しよう」などなど、様々な考えが錯綜していました。 その根底には、ひとまず切り出せそうな部分はいくらか見えているのだが、 それを順番に地道に分割していくくらいではいつまでたっても本丸のコア部分の分割まで至らないのではないか……という焦りがあったと思います。

しかし実際にやってみて最もうまくいった分割方法はやはり「データフローが明確に切れるところで切る」ことです。 慌てず騒がずデータフローを分析して、端から削り切るのが結局は最短の道だと思います。

大きな静的データの共有問題

microservicesへ分割していくうえで他に困ることの1つが「大きな静的データの共有」の問題です。 例えばクックパッドだとテキストの分析に使われている専用辞書がこれにあたります。 この辞書は検索サービスからも検索バッチからもレシピサービスからも、 その他ありとあらゆるところから頻繁にアクセスされており、 これを果たして単純に単体サービスとして分割してしまっていいものか難しいところでした。

『マイクロサービスアーキテクチャ』 などによると、 このようなタイプのデータは原則としてはサービスにするよりデータとして配布してしまったほうがよいようです。 クックパッドでは偶然にも、この辞書をGDBM化してメモリに乗せる仕組みが少し前に入っていました。 そこでこの仕組みを利用して、GDBMファイルを各アプリケーションに配布することでひとまず解決をみました。

その後、GDBMファイルのバージョン問題にぶちあたって少し方式を変更したりもしましたが、 いまのところうまく動いています。

APIオーケストレーション層の導入: Orcha

microservices化に関する直近の試みはAPIオーケストレーション層「Orcha(オルカ)」の導入です。 オチャではありません。 これはわたしが勢いだけで入れてみたものなのですが、思ったより便利で驚いています。

OrchaはJavaで実装されており、 Spring ReactorとSpring Fluxをベースとしたリアクティブプログラミングを活用しています。 下図のようにリバースプロクシ(rproxy)とAPIサーバー(pantry)の間に入り、 pantryを含めたmicroservices群のAPIを統合して、スマホアプリ用のAPIを提供します。

f:id:mineroaoki:20181228000351p:plain
APIオーケストレーション層Orcha

オーケストレーション層を入れようと思った最初の動機は、 スマホアプリから複数のAPIを呼ぶレイテンシーを削減することでした。 しかし実際に入れてみていま感じている最大の利点は、 「本体」にさわらずに既存のAPIを拡張できるという点です。 アーキテクチャを改善していくうえで非常に便利な道具が一つ加わったと感じています。

例えばレシピを取得するAPIに新しい情報を差し込みたい場合であれば、 本体のAPIサーバーが返したJSONを加工して情報を追加することで達成できます。 ようするに、「高機能なJSON用sed」のような動きをしているわけですね。

今後の展開

お台場プロジェクトは来年2019年から、最終の第4期に突入します。 これから1年強は、本体を片っ端から分離しHako化するという正面対決になるでしょう。 最初はデータベースが分かれている機能から分離を始め、徐々にメインDBを切り崩します。

また、せっかくOrchaという新しい自由な遊び場ができたので、 それを活用してGraphQLの導入を試してみようと思っています。 ちょうどスマホアプリの側でもiOS, Androidともにアーキテクチャが刷新されつつあるので、 新しい仕組みを導入するにはいいタイミングです。

まとめ

本稿では、クックパッドの中核たるレシピサービスのアーキテクチャを改善する 「お台場プロジェクト」について、その戦略のすべてをお話ししました。 特に意識して行ってきたことは次のような点です。

  • 意図的にこれまでとの違いを出すタスク選択
  • 最初は成果の出しやすいコード削除から
  • microservices化は小さく試して全面展開
  • オーケストレーション層で展開の自由度を高める

現在のところプロジェクトの最大の問題は、とにかく人が足りないということです。 エンジニアは全分野で足りていないのですが、サーバー側は特に足りません。 Railsならまかせろ!な方にも、Railsブッ殺す!な方にも、 やりごたえのある楽しいタスクがありますので、 ぜひ以下のフォームから応募をお願いいたします。

https://info.cookpad.com/careers

Special Thanks 〜またの名を戦績リスト〜

わたしはお台場プロジェクトについてはあくまで戦略レベルしか関与しておらず、 実装レベルの判断は特に聞かれない限り担当者にすべて任せています。 その点で、お台場プロジェクトは個々のエンジニアの力量によるところが大きいプロジェクトであり、 ここまで来られたのはすべてメンバーのおかげと言ってよいでしょう。 この記事の最後に、各自が撃破したタスクを記して、終わりにしたいと思います。 なお、プロジェクト開始から2018年内までに完了したものだけを、だいたい時間順に列挙しています。

※書き忘れてるやつあったらすまん……