Quantcast
Channel: クックパッド開発者ブログ
Viewing all 734 articles
Browse latest View live

UICollectionViewでページングスクロールを実装する

$
0
0

 こんにちは。新規サービス開発部の中村です。普段は「たべドリ」アプリの開発をしています。「たべドリ」は料理の学習アプリです。詳細はこちらの記事をご覧ください。本記事では UICollectionView でページングスクロールを実装する方法について解説します。

概要

f:id:nkmrh:20190807175935p:plainf:id:nkmrh:20190807175941p:plainf:id:nkmrh:20190807175952p:plain

 上記画像が今回解説する iOS アプリのUIです。左右のコンテンツが少し見えているカルーセルUIで、以下の要件を満たすものです。

  • 先頭にヘッダーを表示する
  • セルが水平方向にページングスクロールする

色々な実装方法があると思いますが、今回はヘッダーがあるため複数の異なる幅のViewを表示させながら、ページングスクロールを実現する方法を解説します。実装のポイントは以下の2点です。

  • UICollectionViewFlowLayoutのサブクラスを作成しtargetContentOffset(forProposedContentOffset:withScrollingVelocity:)メソッドをオーバーライドしてUICollectionViewcontentOffsetを計算する
  • UICollectionViewdecelerationRateプロパティに.fastを指定する

以降、実装方法の詳細を解説していきます。

ベースとなる画面の作成

 まずベースとなる画面を作成します。UICollectionViewControllerUICollectionViewFlowLayoutを使いヘッダーとセルを表示します。UICollectionViewFlowLayoutscrollDirectionプロパティは.horizontalを指定し横スクロールさせます。ヘッダーとセルのサイズ、セクションとセルのマージンは任意の値を指定します。

ViewController.swift

overridefuncviewDidLoad() {
    ... (省略)
    flowLayout.scrollDirection = .horizontal
    flowLayout.itemSize = cellSize
    flowLayout.minimumInteritemSpacing = collectionView.bounds.height
    flowLayout.minimumLineSpacing =20
    flowLayout.sectionInset = UIEdgeInsets(top:0, left:40, bottom:0, right:40)
}
    
... (以下 UICollectionViewDataSource は省略)

f:id:nkmrh:20190807174701g:plain

ここまではUICollectionViewControllerの基本的な実装です。

isPagingEnabled プロパティ

 ページングスクロールさせたい場合、最初に試したくなるのがUIScrollViewisPagingEnabledプロパティをtrueに指定することですが、この方法ではセルが画面の中途半端な位置に表示されてしまいます。これはcollectionViewの横幅の単位でスクロールされるためです。この方法でもセルの幅をcollectionViewの横幅と同じ値に設定し、セルのマージンを0に指定することで画面中央にセルを表示させることが可能です。しかし、今回はセルの幅と異なる幅のヘッダーも表示させる必要があるためこの方法では実現できません。

f:id:nkmrh:20190807174812g:plain

セルを画面中央に表示する

 そこでUICollectionViewFlowLayoutのサブクラスを作成しtargetContentOffset(forProposedContentOffset:withScrollingVelocity:)メソッドをオーバーライドします。このメソッドはユーザーが画面をスクロールして指を離した後に呼ばれます。メソッドの戻り値はスクロールアニメーションが終わった後のcollectionViewcontentOffsetの値となります。このメソッドを以下のように実装します。

FlowLayout.swift

overridefunctargetContentOffset(forProposedContentOffset proposedContentOffset:CGPoint, withScrollingVelocity velocity:CGPoint) ->CGPoint {
    guardletcollectionView= collectionView else { return proposedContentOffset }

    // sectionInset を考慮して表示領域を拡大するletexpansionMargin= sectionInset.left + sectionInset.right
    letexpandedVisibleRect= CGRect(x:collectionView.contentOffset.x- expansionMargin,
                                      y:0,
                                      width:collectionView.bounds.width+ (expansionMargin *2),
                                      height:collectionView.bounds.height)

    // 表示領域の layoutAttributes を取得し、X座標でソートするguardlettargetAttributes= layoutAttributesForElements(in:expandedVisibleRect)?
        .sorted(by: { $0.frame.minX <$1.frame.minX }) else { return proposedContentOffset }

    letnextAttributes:UICollectionViewLayoutAttributes?
    if velocity.x ==0 {
        // スワイプせずに指を離した場合は、画面中央から一番近い要素を取得する
        nextAttributes = layoutAttributesForNearbyCenterX(in:targetAttributes, collectionView:collectionView)
    } elseif velocity.x >0 {
        // 左スワイプの場合は、最後の要素を取得する
        nextAttributes = targetAttributes.last
    } else {
        // 右スワイプの場合は、先頭の要素を取得する
        nextAttributes = targetAttributes.first
    }
    guardletattributes= nextAttributes else { return proposedContentOffset }

    if attributes.representedElementKind == UICollectionView.elementKindSectionHeader {
        // ヘッダーの場合は先頭の座標を返すreturn CGPoint(x:0, y:collectionView.contentOffset.y)
    } else {
        // 画面左端からセルのマージンを引いた座標を返して画面中央に表示されるようにするletcellLeftMargin= (collectionView.bounds.width - attributes.bounds.width) *0.5return CGPoint(x:attributes.frame.minX- cellLeftMargin, y:collectionView.contentOffset.y)
    }
}

// 画面中央に一番近いセルの attributes を取得するprivatefunclayoutAttributesForNearbyCenterX(in attributes:[UICollectionViewLayoutAttributes], collectionView:UICollectionView) ->UICollectionViewLayoutAttributes? {
    ... (省略)
}

velocity引数をもとにユーザーが左右どちらにスワイプしたか、またはスワイプせずに指を離したかの判定をしています。スワイプしていない場合は画面中央に近いUICollectionViewLyaoutAttributesをもとに座標を計算します。ユーザーが左にスワイプした場合は取得したUICollectionViewLayoutAttributes配列の最後の要素、右スワイプの場合は最初の要素をもとに座標を計算します。これでセルを画面中央に表示できます。

f:id:nkmrh:20190807174938g:plain

 セルの位置は期待通りになりましたが、スクロールの速度が緩やかなのでスナップが効いた動きにします。UIScrollViewdecelerationRateプロパティを.fastに指定するとスクロールの減速が通常より速くなりスナップの効いた動作となります。

collectionView.decelerationRate = .fast

f:id:nkmrh:20190807175325g:plain

1ページずつのページング

 これで完成したように見えますが、スクロールの仕方によっては1ページずつではなくページを飛ばしてスクロールしてしまうことがあります。これを防ぎたい場合targetContentOffset(forProposedContentOffset:withScrollingVelocity:)メソッドで取得しているUICollectionViewLayoutAttributes配列の取得タイミングを、スクロールする直前に変更し、それをもとに座標を計算することで解決できます。以下のように実装を追加・変更します。

FlowLayout.swift

... (省略)

privatevarlayoutAttributesForPaging:[UICollectionViewLayoutAttributes]?

overridefunctargetContentOffset(forProposedContentOffset proposedContentOffset:CGPoint, withScrollingVelocity velocity:CGPoint) ->CGPoint {
    guardletcollectionView= collectionView else { return proposedContentOffset }
    guardlettargetAttributes= layoutAttributesForPaging else { return proposedContentOffset }

    ... (省略)
}

... (省略)

// UIScrollViewDelegate scrollViewWillBeginDragging から呼ぶfuncprepareForPaging() {
    // 1ページずつページングさせるために、あらかじめ表示されている attributes の配列を取得しておくguardletcollectionView= collectionView else { return }
    letexpansionMargin= sectionInset.left + sectionInset.right
    letexpandedVisibleRect= CGRect(x:collectionView.contentOffset.x- expansionMargin,
                                      y:0,
                                      width:collectionView.bounds.width+ (expansionMargin *2),
                                      height:collectionView.bounds.height)
    layoutAttributesForPaging = layoutAttributesForElements(in:expandedVisibleRect)?.sorted { $0.frame.minX <$1.frame.minX }
}
ViewController.swift

... (省略)

extensionViewController:UICollectionViewDelegateFlowLayout {
    overridefuncscrollViewWillBeginDragging(_ scrollView:UIScrollView) {
        letcollectionView= scrollView as! UICollectionView
        (collectionView.collectionViewLayout as! FlowLayout).prepareForPaging()
    }
    ... (省略)
}

UIScrollViewDelegatescrollViewWillBeginDragging(_:)が呼ばれたタイミングでprepareForPaging()メソッドを呼びます。このメソッドでスクロール直前のUICollectionViewLayoutAttributes配列をlayoutAttributesForPagingプロパティに保存しておき、targetContentOffset(forProposedContentOffset:withScrollingVelocity:)メソッドの中で保存した配列をもとに座標を計算するように変更します。これで1ページずつページングできるようになりました。

おわりに

 本記事では UICollectionView でページングスクロールを実装する方法を解説しました。このようなUIを実装することは稀だとは思いますが、何かの参考になれば幸いです。

サンプルプロジェクトはこちらhttps://github.com/nkmrh/PagingCollectionViewです。

料理のやり方を1から学んでみたいという方は、ぜひ「たべドリ」を使ってみて下さい!!

apps.apple.com

クックパッドでは新規サービス開発もやりたい、UI・UXにこだわりたいエンジニア・UXエンジニアを募集しています!!!

info.cookpad.com


Android cookpadLiveで採用してる技術 2019夏

$
0
0

メディアプロダクト開発部の安部(@STAR_ZERO)です。

Android cookpadLiveで採用してる技術について紹介したいと思います。

cookpadLiveとは

cookpadLiveは、料理上手な有名人や料理家がクッキングLiveを生配信しています。一緒に、Live配信でリアルタイムに料理が楽しめるアプリです。

ダウンロード: Android アプリiOS アプリ

ぜひ、ダウンロードしてLive配信を見てください!

基本環境

基本となる環境です

  • Kotlin
  • minSdkVersion 21
  • targetSdkVersion 28
  • AndroidX

特別な箇所はないですが、最新に追随するように努めています。

比較的新しいアプリなので、最初からすべてKotlinで記述されています。

targetSdkVersionについてはそろそろ29に対応する予定です。29にすることでの影響を調査している状況です。

Android Studio 3.5

Android Studio 3.5はbetaの段階から導入しています。理由としてはIncremental annotation processingを使いたかったためです。

cookpadLiveでは全面的にDataBindingを採用しているため、これの恩恵は非常に大きいものになります。 これまでは、レイアウトファイルを変更してビルドし直さなければコードが生成されず効率がよくありませんでした。3.5からはレイアウトファイルを変更すると同時にコード生成も行われるのでビルドによる待ち時間が減り効率よく開発できるようになりました。

Jetpack

現在、cookpadLiveではJetpackを積極的に採用しています。

DataBinding、LiveData、ViewModelについては、最初は使用されていなかったのですが、徐々に導入を進めて今では全面的に使用しています。

意外と便利だったのが、ViewModelをActivityに関連付けることでActivityとFragment間、それぞれのFragment間でのデータやイベントのやりとりが可能になる機能です。この機能のおかげ実装がだいぶ楽になったこともありました。 例えば、以下のようにActivityのイベントをFragmentでも受け取ることが簡単にできます。

class HogeActivity: AppCompatActivity() {
    privateval viewModel by lazy {
        ViewModelProviders.of(this).get(HogeViewModel::class.java)
    }
    fun someEvent() {
        // イベント発行
        viewModel.somaEvent.value = someValue // someEventはLiveData
    }
}

class HogeFragment: Fragment() {
    privateval activityViewModel by lazy {
        // thisではなく、Activityを指定することで共通のViewModelを使用できる
       ViewModelProviders.of(requireActivity()).get(HogeViewModel::class.java)
    }
    overridefun onActivityCreated(savedInstanceState: Bundle?) {
        activityViewModel.somaEvent.observe(viewLifecycleOwner) {
            // Activityのイベントを受信
        }
    }
}

Pagingはだいぶ癖があるライブラリですが、ちゃんと理解して使う非常に便利です。これも早い段階から導入しています。現状ではネットワークにはライブラリ側で対応はされてないですが、今後される予定らしいので、楽しみにしています。

Navigationについてはそこまで活用できないですが、部分的に使っています。全面的にSingle Activityにすることは考えてないですが、出来る箇所はFragmentに移行していきたいと考えています。また、SafeArgsは画面間の値の受け渡しが便利になるので積極的に使っていこうと思います。

RoomはDBまわりの処理には欠かせないくらい便利です。RoomはLivaData、RxJavaと簡単に連携することができるため、既存の実装に組み合わせることが簡単にできました。SQLも補完とシンタックスハイライトが効くので非常に助かります。

DI

DIについて Daggerを使用しています。こちらも最初は使用されてなかったのですが、徐々に導入をすすめました。

Daggerについては非常に難しい印象がある人が多いと思いますが、一度使うと便利すぎて手放せません。 DaggerでRepositoryクラスなど生成するようにして、あとは使いた箇所でInjectするだけです。ViewModelなどで必要なRepositoryが増えた場合も、生成するコードを意識せずパラメータに追加するだけ済みます。 最初の設定さえうまくやってしまえば、あとは楽になるはずです。

以前、部内でやったDagger勉強会のチュートリアルコードのリンクを貼っておきます。(まだ@Component.Factoryには対応してないです…)

STAR-ZERO/dagger-tutorial

AppSync

cookpadLiveではライブ中のコメントやハートなどのリアルタイム通信にAWSの AppSyncを使用しています。

f:id:STAR_ZERO:20190826153800p:plain:w300

この部分が一番特徴的かもしれません。

AppSyncはAWSのマネージド型GraphQLサービスです。 ユーザーが送信したコメントやハートをAppSync(GraphQL)のSubscriptionの機能を使い受信するようにしています。

ライブによって非常に多くのコメントやハートを受信することになりますが、受信のたびに画面に描画するのではなくRxJavaのbufferを使ってある程度まとめて画面に描画するようにしています。このあたりはうまくRxJavaと組み合わせて実装しています。

AppSyncの話は以下の記事の後半部分を見ていただければと思います。

CookpadTVのCTOが語る、料理動画サービス開発の課題と実装 - ログミーTech

その他ライブラリ

このあたりよく使われてるライブラリですね。これらももちろん活用しています。

設計

MVVM + Repositoryパターンを採用しています。Googleが公開してる Guide to app architectureとほぼ同じです。

元々はVIPERだったのですが、DataBindingやLiveDataとViewModelを導入していくと同時にMVVMに移行していきました。今ではすべてMVVMで実装されています。 私個人の経験からもJetpackを導入することで、開発効率と品質に大きく貢献することは明確だったので、これらを導入しました。

VIPERはAndroidではあまりの馴染みがないかもしれませんが、MVPパターンのようにInterfaceを使って各レイヤー間の処理を呼び出します。 すごく簡単な例ですが、以下のような感じです。(例ではViewとPresenterしか登場してないです)

// HogeView.ktinterface HogeView {
    fun show()
    fun hide()
}

// HogeFragment.ktclass HogeFragment: Fragment(), HogeView {

    privateval presenter by lazy { HogePresenter(this) }

    overridefun onCreateView(/** */): View? {
        // ...
    }
    overridefun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        presenter.fetch()
    }
    overridefun show() {
        // View.VISIBLEにする
    }
    overridefun hide() {
        // View.GONEにする
    }
}

// HogePresenter.ktclass HogePresenter(privateval view: HogeView) {
    fun fetch() {
        valdata == // ...if (data != null) {
            view.show()
        } else {
            view.hide()
        }
    }
}

このようにViewへの処理を呼び出すのにInterfaceを使ってPresenterからViewへの処理を呼び出しています。

一見、問題がなさそうですが、この時点で既に問題があります。例えば、Presenterでデータ取得中にActiivty/Fragmentが破棄された場合はどうなるでしょう。破棄されてるオブジェクトにアクセスすることになり、場合によってはクラッシュします。これはPresenterがActivity/FragmentのLifecycleについて何も知らないからです。これを解決するにはPresenter側に破棄されたことを教えてあげる必要があります。

では、これを今の実装で書き換えた場合です。

// HogeFragment.ktclass HogeFragment: Fragment() {

    privateval viewModel by lazy {
        ViewModelProviders.of(this).get(HogeViewModel::class.java)
    }

    privatelateinitvar binding: FragmentHogeBinding

    overridefun onCreateView(/** */): View? {
        // ...
    }

    overridefun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        binding.viewModel = viewModel
        binding.lifecycleOwner = viewLifecycleOwner
        viewModel.fetch()
    }
}

// HogeViewModel.ktclass HogeViewModel: ViewModel() {
    val isShow = MutableLiveData<Boolean>()

    fun fetch() {
        valdata == // ...
        isShow.value = data != null
    }
}
<!-- fragment_hoge.xml --><layout><data><variable name="viewModel"type="...HogeViewModel" /><import type="android.view.View" /></data><TextViewandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:text="..."android:visibility="@{viewModel.isShow ? View.VISIBLE : View.GONE}" /></layout>

ここではDataBindingを使用していて、ViewModelが保持しているLiveDataとバインドしています。このLiveDataの値を変更すると自動的にView側にも反映される仕組みになっています。 ここで重要なのは、LiveDataはLifecycleのことを知っているので、Activity/Fragmentがアクティブな状態のときしかデータを流しません。そのため、さきほど説明したActivity/Fragmentが破棄されたときの対応を特別にせずとも問題が起こることがありません。またViewModelおいては回転でActivityが再生成されたときもViewModelは状態をもっているため、データ取得を中断することなく処理を継続することができます。

この設計にすることでActivity/FragmentはViewModelの状態を反映すれば良いだけになり、責務もしっかり分かれて見通しが良くなりました。また単純にVIPERはファイル数が多くなるため、コードを追う時にコードジャンプであちこち飛ばなければならず、個人的にはコードが追いにくい感じでした。

他にも様々な面で効率・品質を向上させるのに貢献してくれています。その他便利なJetpackライブラリも簡単に導入できるようになっています。

今ではすべての画面が同じような感じになってるので、どういう処理をしてるのかを理解しやすくなっています。

この設計変更ですが、新規画面については新しい設計でやり、既存については少しずつ進めていました。またUIを大きく変更するタイミングもあったので、その時に一気に直した箇所もありました。出来ることからコツコツやってこともあり、大きくコストをかけることなく変更できました。

CI環境

CIに関しては、Jenkinsを使っています。やってることは以下になります。

  • Pull Request
    • テスト、lintを実行
    • 社内テスト環境にAPKをアップロード
  • masterマージ後
    • 社内テスト環境にAPKをアップロード
    • Google Play内部テストへアップロード

可能な限り早い段階でリリース版をビルドして触ることで、問題があったときに早めに気づくことができるようにしています。特にProguardまわりは見落としがちになるを防げます。

リリースするときは内部テスト版を製品版へ昇格するだけになっています。今ここは手作業でやってるのですが、ChatOps等で出来るようにしたいと考えています。

マルチモジュール

現状では、スマホ、AndroidTV、FireTVで共有するようなモジュールと、featureモジュールをいくつか分割しています。

図にすると以下のような感じです。

f:id:STAR_ZERO:20190826154302p:plain:w300

  • core
    • 全モジュールで共通処理
    • APIアクセス、Repository、データモデルなど
  • appcore
    • スマホアプリ共通処理
    • 共通View、ログ、リソースなど
  • feature
    • 各機能を分割したモジュール
  • app
    • スマホアプリメイン
  • smarttv
    • TVアプリ共通処理
    • 共通View、ログ、リソースなど
  • androidtv
    • AndroidTVメイン
  • firetv
    • FireTVメイン

まだfetureモジュールは分割できる箇所があるので、少しずつでも進めていきたいと思います。

課題と今後

テスト

正直、まだそこまでうまく書けてる状況ではないので、なんとかしていきたいと思っています。 せっかくなので、ライブラリの選定から考えようとも思っています。Truth良さそうですね。

StyleとTheme

StyleとThemeについては結構ちらかってる状態なので、整理したい思っています。画面数もそこそこあるので、だいぶ大変な作業になる気配がしています。まずは、どういうふうに整理するかを検討してから少しずつやっていく感じになりそうです。

Navigation

前に書きましたが、まだまだ活用できる箇所があります。すべてSingleActivityとは考えてないですが、Fragmentでの遷移で良い箇所もあるので、そういった箇所に対応していきたいと考えています。

Coroutines

Coroutinesについては、どうするかを検討している段階です。現状でCoroutinesじゃないと困るような場面は出てきていませんが、JetpackもCoroutinesの対応が進んでいて実装するのに困ることはないと考えています。また、今後Coroutinesによって実装コストが下がるような機能なんかも出てくる可能性ありそうです。 メンバーと会話して、導入する気持ちはありますが、進め方やどこから導入するのかを考えています。

まとめ

cookpadLiveでは積極的にJetpackを使っていき、Googleが推奨しているやり方にどんどん乗っかっていっています。 今後もJetpackも改善されていくと思うので、それにいつでも追随できるような状態を保つようにしています。

これからもcookpadLiveでは新しい技術を積極的に取り入れていきますし、やりたいこともまだまだたくさんあります。

興味がある方いらっしゃいましたら、気軽にお声がけください。一緒に色々チャレンジしていきましょう。

info.cookpad.com

iOSDC Japan 2019 に社員2名がLT枠で登壇&ブース企画のご案内

$
0
0

こんにちは!広報部のとくなり餃子大好き( id:tokunarigyozadaisuki)です。
「今年は梅雨が長いな」なんて思っていたらあっという間に暑くなり、気づけば本当の夏も過ぎ……。夏の終りの気配が見えてきましたね。

さて、iOSと周辺技術を題材としたカンファレンス、iOSDC Japan 2019が今年も9月5日(木)〜9月7日(土)に開催されますね!

クックパッドは、プラチナスポンサーをさせていただいており、ブースを出展いたします。今年は、@giginet@hiragramがLT枠で登壇し、@_sgr_ksmt@natmarkがiOSDCのスタッフとして関わってくれます。カンファレンスには、他にも多くの社員が参加いたしますので、会場でクックパッド社員をお見かけの際には、お声がけいただけますと嬉しいです。

参加予定社員一覧

@kanny, @slightair, @giginet, @hiragram, @_sgr_ksmt@natmark, @ichiko_revjune, @iceman5499, @sagaraya, @to9nariyui

登壇スケジュール

クックパッドの社員2名は、day2 - 9/7(土)に登壇いたします。 以下、スケジュールと登壇内容のご紹介です。

day2 - 9/7(土)

15:45〜 Track A @hiragram俺たちのARKitでめちゃめちゃ表情豊かなVTuber向け表情トラッカーを作るぞ

Animojiにも使われているTrueDepthカメラを使って3Dモデルの表情を動かす表情トラッカーを作りました。webカメラを用いて顔認識する他のシステムよりも精度高く、細かく、感情表現に必要な顔のパラメータを取得できるTrueDepthカメラの本気をお見せします。 表情トラッキングの精度以外にも、ARKitのおかげでバーチャルYouTuberを運用するにあたって地味に嬉しい機能をたくさん獲得しているので、プロデュースの現場の目線から面白おかしく紹介できればと思います。

16:55〜 Track A @giginet令和時代のゲームボーイ開発 👾

1989年に発売したゲームボーイは、今年30周年を迎えました。 そんな今だからこそ、実機で動くゲームボーイ開発をしてみましょう! 30年の時を経て、ゲームボーイが最新の技術で蘇ります。

ブース

今年のクックパッドブースではグッズの配布はもちろんですが、2つの企画を行います! 

f:id:tokunarigyozadaisuki:20190827145810j:plain
昨年のブースの様子

クックパッドのコード全部見せます大質問会

day2 - 9/7(土)の11:00-13:00にアンカンファレンストラックにて「iOS版クックパッドアプリのコード全部見せます大質問会」を開催します!

この会は、実際のプロダクトコードを見せながら、ディスカッション形式で、クックパッドのiOS開発について紹介します。 例えば以下のような質問を歓迎しています。

  • どういう開発体制でやってるの?
  • リリースフローやQAについて見せて
  • アーキテクチャはどうなってるの?
  • Podfile見せてください

なにか見たい部分や、開発上での質問がある方は、day1 - 9/6(金)にクックパッドブースへお越しください!当日はその質問を中心にご紹介します。飛び入りでの質問も歓迎です。

クックパッドエンジニアとの「カジュアルトーク」

エンジニアリングマネージャーや、現場で活躍するエンジニアと直接話せるカジュアルトーク企画を実施します。開発に関する話からオープンな場で聞きにくいキャリアについての話などを1on1のようなスタイルでお話しましょう! あんなことやこんなことまで……どんなことでもお気軽に。 参加メンバーとスケジュールは以下のとおりです。

エンジニアリングマネージャーと話せる枠

クックパッドブースにお越しいただき、希望する時間を選んでいただくと、その時間に VP of Tech 星 北斗 @kani_b、モバイル基盤部 部長 茂呂 智大 @slightairと話せます。

  • day1 - 9/6(金)11:00-18:00:星・茂呂
  • day2 - 9/7(土)11:00-18:00:茂呂

▼ RubyKaigi 2019 実施時の様子

特定の技術に詳しいエンジニアと話せる枠

以下の時間は、特定の技術について豊富な知見を持っている社員がブースにおりますのでぜひ情報交換しましょう!

day1 - 9/6(金)12:50-14:20 - fastlane / Carthage:@giginet
fastlaneとCarthageのコミッターやってるのでチョットできます。その他にもSwiftPMやCocoaPods, XcodeGenなどの開発ツール全般についても知見あります。 上記の話題にかかわらず、興味のある技術トピックがあればわいわいしましょう 🙌

day1 - 9/6(金)14:20-15:10 - モバイルテスト自動化/QA @ichiko_revjune
参考:https://techlife.cookpad.com/entry/2018/12/12/120000

day1 - 9/6(金)14:20-15:10 - SwiftUI @iceman5499
参考:https://techlife.cookpad.com/entry/2019/06/25/120000

おわりに

クックパッドはiOSエンジニアを募集しています。クックパッドで働くことに少しでもご興味をお持ちの方は、お気軽にブースまでお越しください! みなさまにお会いできることを楽しみにしております。

Cookpad Summer Internship 2019 10 Day Techコースを開催しました

$
0
0

こんにちは、サマーインターンシップ実行委員長の赤松( @ukstudio )です。

クックパッドでは毎年恒例となっているサマーインターンシップのうち「10 Day Tech コース」を 8月19日から8月30日にかけて開催しました。今年もたくさんの優秀な学生の方が参加し、10日間毎日真剣に取り組んでくれました。本当にありがとうございます。

前半

10 Day Techコースでは前半5日間の講義・実習を行い、後半5日間では更にOJT(実務体験)とPBL(サービス開発実習)の2つに分かれるという構成でした。

まずは前半の講義について簡単にご紹介致します。

1日目: オリエン・Web開発基礎

初日はオリエンテーションとしてクックパッドの取り組みの紹介や自己紹介、貸与PCのセットアップなどを行い、午後から講義に入りました。

講義ではインターンシップ全体を通して必要となるGit/GitHubの入門を行い、Webアプリケーション開発の基礎としてRackアプリケーションの実装、TypeScriptの入門を行いました。

f:id:ukstudio:20190819152258j:plain

speakerdeck.com

speakerdeck.com

ハンズオン資料

2日目: サービス開発講義

2日目はサービス開発講義として、午前中にクックパッドのサービス開発に対する考え方や開発プロセスについて学びました。午後は午前で学んだことをベースにインターン生同士でチームを組み、与えられたテーマを元にユーザーインタビューや価値仮説、アイデア出しからプロトタイプを作るというところまで実践し、最後に講師からの講評を行いました。

2日目は前半の中で唯一コードを書かない日ですが、みなさんかなり頭を使ったようで夜にはへとへとになっているようでした。

f:id:ukstudio:20190820181326j:plain

speakerdeck.com

3日目: API

今年の10 Day Techコースの技術面でのテーマは「クックパッドらしさ」というのが実はあるのですが、3日目から5日目にかけてMicroservices、適材適所な技術選定、クラウド技術などを意識した講義になっています。

3日目〜5日目の講義の中でもこの3日目がテーマが一番色濃くでたのではないかと思います。3日目ではNode.js(TypeScript)を用いてBFF層(Backend-For Frontend)となるGraphQLサーバーの実装を行いました。ボリュームも密度も濃い講義でしたが、みなさん無事に乗り切ることができました。

f:id:ukstudio:20190821140252j:plain

speakerdeck.com

4日目: モバイル

4日目では3日目に実装したBFFのGraphQLサーバーにクエリを投げ、受け取ったデータを表示するためのクライアントアプリを実装しました。今年はiOSとAndroidの二手に分かれ(希望制)、SwiftとKotolinで実装をしてもらいました。

iOSとAndroidの希望を聞いた時に、「経験したことのない方に挑戦してみよう」と伝えていたので未経験の方も多いようでしたが、スムーズに実装を終えることができていました。

f:id:ukstudio:20190822104350j:plain

speakerdeck.com

ハンズオン資料

speakerdeck.com

ハンズオン資料

5日目: インフラ

最終日となる5日目ではクックパッドにおいてSREがどうインフラの問題を解決してきたのか、また3日目ではあまり説明のなかったAPIのインフラレイヤーについて講義を行いました。講義の後は3日目に実装したAPIサーバーにパフォーマンスや可用性において問題が含まれていたので、その問題の解決に取り組みました。

ちょうどこの日にAWSに障害があり、少々ざわついたのですが幸い講義にはあまり影響なく無事終えることができました。講義では簡単に障害の内容についても説明されました。

f:id:ukstudio:20190823101042j:plain

speakerdeck.com

以上が前半に行なった講義・実習となります。今年の講義はどれも密度が濃くかなり大変だったと思いますが、全員無事に乗り切ってくれました。本当におつかれさまでした!!

後半

PBLでは6 Day Design Productコースのデザイナーとペアを組み、サービス開発の実習を行いました。PBLについてはふじけんの Cookpad Summer Internship 2019, 6 Day Product Designコースを開催しました|Cookpad|noteをご覧ください。

OJTではクックパッドの様々な部署に配属され、メンターの指導の元サービス開発を実践してもらいました。配属される部署はクックパッドマートを運営・開発する買物事業部、Komercoを運営・開発するKomerco事業部などのサービス系から、モバイル基盤や技術部のSREグループ、クックパッドサービス基盤グループなどの基盤系など様々でした。

最終日には5日間の各自の偉業をメンターやOJTに配属されたインターン生全員の前で発表してもらいました。5日間という短い中で進捗を出すのは大変だったと思いますが、全員無事にやり遂げてくれました。ありがとうございました!

f:id:ukstudio:20190830141025j:plain

まとめ

簡単にですが2019年のサマーインターンシップの10 Day Techコースについて簡単に紹介させて頂きました。今年のインターンシップは実行委員長の自分から見てもかなりキツいインターンシップだったと思います。参加して頂いたみなさん、本当におつかれさまでした!!

f:id:ukstudio:20190830203305j:plain

今年の10 Day Techコースはかなりクックパッドの現場に近い内容となっています。クックパッドの現場に少しでも興味がでた方は新卒・中途問わず、ぜひ遊びに来てください!!

info.cookpad.com

2019 年度版:クックパッド x 広告領域の紹介

$
0
0

こんにちは。メディアプロダクト開発部の我妻謙樹です。サーバーサイドエンジニアとして、広告配信システムの開発・運用を担当しています。入社以来広告領域を担当するグループに所属しています。

クックパッドと広告

クックパッドでは、PS に次ぐ売上高を占める主力事業として、広告事業があります。

過去にも、"クックパッドの広告エンジニアは何をやっているのか"(公開日:2015-11-26)という記事が公開されたことがありますが、当時とは技術要素やチーム構成はもちろん、事業をめぐる環境が大きく変わっています。

しかし、上記記事でも述べられている、以下の原則は変わっていません。

クックパッドの広告は、昔から、ユーザさんと広告出稿企業さん、そして私たちクックパッドの3者ともが幸せになる形を模索し続けてきています。 クックパッドを通して、最終的には広告も「価値ある情報」としてユーザさんに届けば、それは広告単価の上昇にも繋がるからです。

広告は、広告を出稿してくださる企業の課題を解決するために存在し、ユーザーにその価値を届けるために存在しています。いわゆるアドネットワークを支える DSP/SSP を開発するのではなく、自社で配信システムを内製し、貴重なユーザーのデータを保持するからこそできる独自の広告事業の面白みが、クックパッドにはあります。

本記事を通して、クックパッドにおける広告事業、及びそれを支える私達の部・グループについて少しでも理解の一助となれば幸いです。

運用保守対象のサービス一覧

私達のグループでは、広告が入稿されてから配信されるまで一貫した自社システムを保守・運用しています。細かいシステムは多数ありますが、主要なコンポーネントのみを表示させたシステムアーキテクチャ概観は以下の図の通りです。

f:id:itiskj:20190913125033j:plain
msdev-system-overall
各システムについて、簡潔に紹介します。

Ad creative admin service

  • いわゆる「入稿」システム
  • 弊社の業務推進チームが、受注に従ってクリエイティブを入稿
  • 純広告とネットワーク広告の配信比率の調整、ターゲティングの設定、広告枠のリアルタイムレポートなど多機能

New ads delivery service

  • いわゆる「配信」システム
  • 新世代。クックパッド アプリケーション自体に内在されていた配信ロジックを別アプリケーションとして切り出したシステム
  • 初期は Rails アプリケーションだったが、機能の肥大化及びインフラコストの削減&開発速度の向上を目指し、一部の機能を Go に Microservices として切り出し

Legacy ads delivery service

  • いわゆる「配信」システム
  • 旧世代。初期は Cookpad アプリケーション自体に配信ロジックが内在されていた
  • 現在はほとんどの機能が新世代に移行済み、新規開発することは殆どない。旧バージョンアプリの互換性のために残していたが、物理削除含めて近日中に完全廃止予定。

Machine Learning services

  • いわゆる「最適化」システム
  • Ruby/Rails で実装された入稿システムにおいて、別言語ランタイムである Python と機械学習ライブラリを利用し、配信比率の最適化や在庫予測を行うため、AWS APIGateway + Lambda を利用した Microservices として実装されている

Logging (Lambda Architecture)

batch layer

  • いわゆる「DWH(Data Warehouse)」
  • すべてのログが格納されており、配信比率の最適化、レポーティング用集計など、入稿システムのあらゆる箇所で利用されている
  • サービスオーナーは DWH チーム
  • 業務推進チームやディレクターは、Tableau を利用しダッシュボードで可視化して業務に利用している

speed layer

  • サービスオーナーは我々のグループ
  • ストリーミングパイプラインによるリアルタイムログ基盤
  • 入稿システムにおけるリアルタイムレポートや、配信比率の最適化処理における精度向上に利用されている

Tracking service

  • いわゆる「DMP(Data Management Platform)」
  • サービスオーナーは DMP チーム
  • EAT(Extreme Audience Targeting)を始めとし、エリアターゲティングや検索キーワードターゲティングといった機能も提供している

技術スタック

先ほど紹介した各サービスで利用されている技術スタックは、以下の通りです。

f:id:itiskj:20190913125210j:plain
msdev-tech-stack

技術選定

前節で技術スタックについてご紹介しました。私達のチームでは、以下の図に表される評価軸に沿って、プロジェクトや事業の成果を達成するために最適な技術スタックを選択することを心がけています。

f:id:itiskj:20190913125235j:plain
msdev-tech-selection

「会社がこの技術を押すから」といった会社目線での観点や、「チームでこの技術を使っている人が多いから何となく・・・」といったチーム目線での観点、「この技術を使いたいから」といった技術的成長目線の観点だけで技術を選択することは有りません。以下の三点を総合的に判断することを心がけています。もちろん、技術選定に失敗したこともありますし、この評価軸が完璧ではありませんが、考える軸にはなります。

Tech - 技術的観点

技術自体の正しさを評価する軸です。例えば、ある課題に対して奇想天外な技術を選択することは、どれだけその技術を導入する難易度が高かったとしても、優れた設計では有りません。適切な「課題」に対して適切な技術を「解決」策として適用することこそが求められています。

そのために、数多くのミドルウェア、クラウドサービスに対しての知識を深め、少しでも引き出しを増やし、純粋に比較検討できるスキルが、技術選定に責任を持つテックリードに求められています。

その他にも、以下の観点を評価します。

  • 技術が開発されているエコシステムの発展性
  • 開発をサポートするツールの充実生
  • グローバル及び日本における技術トレンド
  • その技術を支える日本でのコミュニティ

Company - 会社的観点

次に、会社全体のその技術への関わり方を評価します。

例えば、クックパッドは Ruby/Rails をヘビーに利用する会社です。Ruby コミッターの方々も働いており、サポートも手厚いです。しかし、だからといって全てのサービスが Ruby であるべきか、Rails であるべきかというと違います。「技術的観点」および「チーム的観点」の比重を優先し、Ruby/Rails 以外の技術を選択することは往々にしてあります。

その他にも、以下の観点を評価します。

  • 会社のミッションに対する適合性
  • その技術を選択すると事業の成功にどれだけ貢献できる可能性が高いのか
  • 会社全体(他のチーム)で利用されている技術とは親和性が高いか
  • 会社のエンジニアカルチャーと適合するか
  • 採用の観点から、その技術を選択する優位性はあるか

Team - チーム的観点

最後に、チーム的観点から評価します。具体的には、部およびチームがどのような技術方針を持っているかという観点に加え、新卒からシニアメンバーまでそれぞれのメンバーの現在のスキルセットや目指すキャリアパスを考慮して総合的に判断します。

具体的には、以下の観点で評価します。

  • チームメンバーのその技術に対する成熟度
  • チームメンバの現在のスキルセットから想定できるその技術の吸収力
  • 各メンバーが目指すキャリアパスへの貢献具合
  • チームがスケールしたときに対応できる学習コストやサポート体制
  • その技術を選択することへのモチベーション

チーム体制

「領域:マーケティングサービス領域」を担当する部署が 4 つ存在しています。

f:id:itiskj:20190913125258j:plain
msdev-team

  • マーケティングサポート事業部
    • 国内におけるマーケティングサービスの企画、開発、運用及び営業に関する業務を担当する。営業グループの他、社内のデータを検証して新商品開発や営業提案資料を作成するデータチーム、日々の入稿作業やレポーティングを支える業務推進チームが所属
  • マーケティング企画制作部
    • マーケティングサービスの企画立案・制作・進行に関する業務を担当する。タイアップ広告を企業とともに作り上げる制作グループが所属している。
  • マーケティングプロダクト開発部
    • 国内に置ける広告事業及び企業向け事業に関する企画、商品開発に関する業務を担当する。主にディレクターが所属。
  • メディアプロダクト開発部
    • 国内の行ける企業向けプロダクト開発に関する業務を担当する。私達が所属している部署。

メディアプロダクト開発部

メディアプロダクト開発部では、以下の 3 つのグループから成立しています。9 割がエンジニアの組織です。広告領域を担当する私が所属しているのが、「マーケティングサービス開発グループ」通称 msdevです。

  • マーケティングサービス開発グループ(通称:msdev)
    • 広告領域を担当しています
  • プロダクト開発グループ(通称:pdev)
    • 動画領域を担当しています
  • プロダクトデザイングループ
    • デザイナーとディレクターが所属しています

プロダクト開発グループとの協同体制

プロダクト開発グループでは、動画領域を中心とし、数多くのサービスを開発しています。最近の開発については、以下の発表やブログが参考になるでしょう。

グループは違いますが、msdev と pdev は席も近く、部署も同じですので、頻繁にコミュニケーションがあります。プロジェクトによっては、片方のグループメンバーが片方のグループのプロジェクトを手伝う、といったこともあります。

これによって、広告領域に携わりながらも動画領域で利用されている技術に触れることができる、という大きなメリットがあります。例えば、私も広告領域のプロジェクトを担当する傍ら、過去に以下のプロジェクトに携わらせていただいたことがあります。

また、msdev/pdev それぞれのグループで勉強会を開催しています。もちろん横断して参加が可能です。過去には、以下のような内容で勉強会が開催されています。

  • 基礎技術詳解
  • AWS 各サービス詳解
    • DynamoDB. Parameter Store, API Gateway+Lambda, AWS IoT, CloudFront, etc.
  • 利用サービス詳解
    • terraform, Stripe, Tableau Desktop, etc.
  • カンファレンス参加報告
    • RubyKaigi 2019
  • 自分たちの保守運用するシステムのアーキテクチャ詳解
    • 動画配信サーバー, 広告配信サーバー, etc.

まとめ

広告領域は、技術的にチャレンジングな課題も多く、かつ事業の売上貢献に直結することが多い、非常にエキサイティングな領域です。また、アドネットワークではなく、自社の事業で専用の配信サーバーとユーザーデータを保持するからこその事業の面白さもあるため、事業開発に興味・関心が高い人にとっても活躍の可能性が大いにある場です。

メディアプロダクト開発部では、一緒に働いてくれるメンバーを募集しています。少しでも興味を持っていただけたら、以下からエントリーをしてください。

Ruby中間表現のバイナリ出力を改善する

$
0
0

Ruby 開発チームに4週間インターン生として参加いたしました、永山 (GitHub: NagayamaRyoga) です。 私は「Ruby中間表現のバイナリ出力の改善」という課題に取り組み、Railsアプリケーションのコンパイルキャッシュのサイズを70%以上削減することに成功しました。以下ではこの課題の概要とその成果について述べたいと思います。

InstructionSequenceの概要

まず、RubyVM 内で実行される命令の中間表現、InstructionSequence (以下 ISeqと省略) について簡単に説明します。

通常の Ruby プログラムは、以下のような手順で実行されます。

  1. ソースコードを構文解析し、抽象構文木を作る。
  2. 抽象構文木をコンパイルして、ISeqを作る。
  3. RubyVM (YARV) で ISeqを解釈し、実行する。

ISeqは、このように RubyVM で解釈される命令列に関する情報を含んだ一種の中間表現です。

ISeqに関する API は RubyVM::InstructionSequenceとしてその一部が外部に公開されているため、Ruby プログラムからも (ごく簡単な操作に限ってですが) 取り扱うことが可能です。

# 文字列をコンパイルして ISeq を得る
iseq = RubyVM::InstructionSequence.compile("p 42")

# 得られた ISeq を RubyVM で実行する
iseq.eval
# => 42# ISeq に含まれている命令列を出力する
puts iseq.disasm
# => == disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,4)> (catch: FALSE)#    0000 putself                                                          (   1)[Li]#    0001 putobject                    42#    0003 opt_send_without_block       <callinfo!mid:p, argc:1, FCALL|ARGS_SIMPLE>, <callcache>#    0006 leave

また、#to_binaryメソッドを呼び出すことで ISeqをバイナリデータにシリアライズすることができます。

bin = iseq.to_binary
p bin
# => "YARB\x02\x00\x00\x00\a\x00\x00\x00D......"

もちろん、シリアライズされたバイナリデータから ISeqに戻すことも可能です。

iseq2 = RubyVM::InstructionSequence.load_from_binary(bin)
iseq2.eval
# => 42

コンパイルキャッシュとBootsnap

では、上の機能がどのように活用できるのかについて説明したいと思います。

先程も述べた通り、Ruby プログラムは実行されるたびにスクリプトファイルの構文解析が行われます。

  1. ソースコードを構文解析し、抽象構文木を作る。
  2. 抽象構文木をコンパイルして、ISeqを作る。
  3. RubyVM (YARV) で ISeqを解釈し、実行する。

しかし、スクリプトファイルが変更されていなけば、コンパイル結果として得られる ISeqが実行ごとに変化するようなことはありません。 同じ ISeqが得られるにも関わらず、構文解析やコンパイルが行われるのは冗長です。

特に、短時間に何回も実行されるようなプログラムや、多数のスクリプトファイルで構成される巨大なアプリケーションではコンパイル結果 (ISeq) をバイナリデータとしてキャッシュしておくとその起動速度を向上できるかもしれません。

Rails5.2以降ではデフォルトでプロジェクトにインストールされる Bootsnapという gem は、前項で説明した #to_binaryメソッドを使って、スクリプトファイルのコンパイル結果を自動的に ./tmp/以下のディレクトリにキャッシュしてくれます。 Bootsnapはこの他にもautoloadしたファイルのパスなどをキャッシュすることでRailsプロジェクトの起動時間を50%〜70%程度縮めることに成功しています。 例えば、$ rails newによって生成されただけの空のRailsプロジェクトでは、Bootsnapによって起動時間が約65%短くなるのを確認できました。

課題

さて、この #to_binaryですが、その出力にはかなりの無駄があります。

iseq = RubyVM::InstructionSequence.compile("p 42")
p iseq.to_binary.length
# => 580

p 42というごく小さいコードから生成されたバイナリにも関わらず、その出力は 580byte という大きさになってしまいました (出力のサイズは環境によって異なります)。

当然、より大きいコードからは大きいバイナリが生成されます。 さきほどの空Railsプロジェクトであれば、Bootsnapが1632個の.rbファイルをキャッシュしており、そのキャッシュファイルの合計サイズは 32MB ほどになりました。

というわけで、本課題の目的はこの #to_binaryの出力するバイナリのサイズを小さくすることです。 #to_binaryの出力が小さくなると単純にストレージや転送時間の節約になるほか、Bootsnapがコンパイルキャッシュにアクセスする際のディスクアクセスが少なくなるため、Railsアプリケーションの起動時間が短くなることが期待されます。

方法

#to_binaryの実装の大部分は Rubycompile.cに書かれています。

今回のインターンシップではこの実装を読みつつ、部分を部分を書き換えていくことで徐々に出力のサイズを小さくしていきました。

特にバイナリサイズの削減に寄与した変更は主に以下の2つです。

1. 不要な構造体フィールドの出力の削除

従来の実装では ISeq の情報を格納した構造体の、本来出力する必要のないものや、常に同じ定数が出力されているフィールドなどが存在していました。 コードを読み解いて、それらを出力に含めないようにすることでバイナリのサイズを削減しました。

2. 整数値の符号化方法を変更

また、出力に含まれていたあらゆる整数値はほぼすべてが固定長で符号化され、4byteや8byteのデータ長で出力されていました。 しかし、出力される整数値はその出現頻度に大きな偏りがあり、多くが 01などの少ないbit数で表現できる値です。 そこで、UTF-8を参考に可変長な整数の符号化方法を考え、導入することにしました。

0x0000000000000000 - 0x000000000000007f: 1byte | XXXXXXX1 |
0x0000000000000080 - 0x0000000000003fff: 2byte | XXXXXX10 | XXXXXXXX |
0x0000000000004000 - 0x00000000001fffff: 3byte | XXXXX100 | XXXXXXXX | XXXXXXXX |
0x0000000000020000 - 0x000000000fffffff: 4byte | XXXX1000 | XXXXXXXX | XXXXXXXX | XXXXXXXX |
...
0x0001000000000000 - 0x00ffffffffffffff: 8byte | 10000000 | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX |
0x0100000000000000 - 0xffffffffffffffff: 9byte | 00000000 | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX |

この方法では、7bitで十分に表現できる値は1byteに、14bitで表現できる値は2byteに、というように符号化する整数の大きさによって必要なバイト長を変化させています。

UTF-8では1byte目の上位bitを使って後続のバイト数を表しているのに対して、この符号化方法では下位bitの連続する0bitの個数でバイト数を表現しています。 このような形式を採用した理由は、x86_64などの命令セットではtzcntを用いることで後続のバイト数が1命令で数えられるためです。

評価

これらの変更によって、バイナリの読み込み速度を損なうことなく #to_binaryの出力のサイズを平均して 70%から75% 程度小さくすることに成功しました。 上記の空Railsプロジェクトでは、キャッシュファイルのサイズは合計 9.4MB (元の約70%) になりました。

f:id:NagayamaRyoga:20190926141101p:plain

その他の詳細なデータに関しては以下のチケットにまとめてあります。

https://bugs.ruby-lang.org/issues/16163

苦労した点

以下、今回の課題に取り組むにあたって苦労した点です。

プロジェクトの規模が大きいこと

Rubyは20年以上も継続して開発が続けられているプロジェクトであり、1万に近い個数のファイルによって構成されています。 特にC言語で記述されたソースコードの中には1ファイルが1万行を超えているものもあり、 (今回の実装に関連する部分はそのごくごく一部とは言え)処理の流れを把握するのが大変でした。

インターン期間の最初の1日は、ソースコードを読みながらバイナリデータを手でデコードし、おおよその処理の流れとデータ構造を理解していきました。

マルチプラットフォームなソフトウェアであること

Rubyは様々なOS、CPU、etcで実行される可能性のあるプログラムです。 そのため、どのような環境であっても正しく動作をするようにプログラムを書く必要がありますが、 C言語はその言語仕様の詳細 (例えば整数型のサイズと表現可能な数値の範囲、式の評価方法、評価結果など) の一部を"処理系定義"としています。

"処理系定義"の動作はコンパイラや環境によって異なる可能性があるため、 ある環境では動作をするが別の環境では動作しない、というようなことが起こらないように常に意識をする必要がありました。

まとめ

バイナリの読み込み速度を損なうことなく、そのサイズを70%以上も削減することに成功しました。 2019年12月リリース予定のRuby 2.7にこれらの変更が取り込まれ、実際のRailsアプリケーション上で動作するようになります。

世界的に有名なOSSに対して1ヶ月という短期間で貢献できたことは非常に貴重な経験になりました。 この場を借りて、メンターである笹田さんと遠藤さんに御礼を申し上げます。

退職処理を可能な限り自動化する

$
0
0

技術部 SRE グループの id:itkqです。2019 夏アニメで一番好きな作品は Re:ステージ!ドリームデイズ♪ です。この記事では SRE が運用している退職処理の自動化について説明します。

退職処理とは

入社後に業務のための様々なアカウントを作成するのと反対に、退職時にはそれらのアカウントを無効化する必要があります。これを退職処理と呼んでいます。SRE が管轄している典型的な例では、SSO に対応していない SaaS のログインアカウント・AWS の IAM User・データベースの個人ログインユーザなどが該当します。これらのアカウントは社員によって要否が異なったり必要な権限が異なるため、入社時に一括で用意せず必要に応じて申請してもらう形をとっています。一方で退職時にはそれらのアカウントをすべて無効化する必要があります。 退職処理は繰り返され、自動化の余地のあるタスクです。また、SRE 以外でも退職処理を行うチームがあることは分かっていたため、退職処理の自動化のための共通の仕組みを考えることにしました。自動化のための第一歩として、退職のイベントを扱いやすい形で発生させる必要があります。

退職イベントを発生させる

クックパッドでは、ヘルプデスク1が社内 IT のアカウントを管理しており、退職時はヘルプデスクが退職者の Active Directory (以降 AD と略記) アカウントを無効化します。入社時のアカウントの作成は人事システム経由で自動化されている一方で、退職時は退職者によってタイミングなどが複雑であることがしばしばあるため、「人間による無効化」によって退職処理を開始させるようにしています。 AD とは別に、SSH や GitHub Enterprise ログインなどに使う目的で OpenLDAP によるアカウント管理も運用しています。これまでヘルプデスクと SRE でそれぞれ AD, OpenLDAP を運用してきましたが、手を取り合って AD に一本化する計画を進めています。移行期間中は AD と OpenLDAP 間で齟齬が起きることが予想されたため、AD と OpenLDAP 間で属性を同期し、パスワード変更時は AD と OpenLDAP 間で同時に変更させることで、将来の統合を楽にする目的の pasuwado というシステム (id:sora_h作) が稼働しています。 pasuwado は AD と OpenLDAP 間の属性の同期をバッチ処理で行っています。AD のアカウントを無効化情報を pasuwado に管理させることは、本来の責務から外れすぎず、同じようなバッチ処理で実装できる見込みがあったたため、AD アカウントの無効化タイミングを保存しつつ退職イベントを扱いやすい形で発生させる機能を pasuwado に組み込みました。実運用では、誤って AD アカウントを無効化してしまうヒューマンエラーや、アカウントの無効化やリソースの削除を遅らせたい要求があることを考慮する必要があります。そこで「無効化が発見された直後」、「無効化を発見してから 3 日後」、… のようにいくつかのタイミングでイベントを発生させ、受け取る側では都合のいいようにフィルタする設計にしました。SRE では「無効化を発見してから 3 日後」のタイミングで退職が確定したとみなすようにしています。このイベントは pub/sub メッセージングサービスである Amazon SNS に送信します。次に示すのは SNS に送信されるメッセージの例です。detected_at は pasuwado のバッチが AD アカウントのサスペンドを発見した時刻、elapsed はサスペンドを発見してから経過したおおよその秒数です。

{"name": "mana-shikimiya",
    "detected_at": "2019-04-23T00:00:00+09:00",
    "elapsed": 0}

退職イベントを受け取り自動処理する

退職イベントが送信される SNS を購読することで、自動化する退職処理のトリガーにすることができます。SNS を使うことで購読方法は選択肢の中から好きなものを選ぶことができ、また自動化処理同士を独立させられます。実際に自動化されている処理について例を挙げながら説明します。

例1: GitHub に issue を立て Slack に通知する

AD アカウント無効化直後のイベントを受け取ると、退職処理をまとめる GitHub リポジトリに issue を立てるジョブを実装しました。この issue は他の退職処理の結果をコメントしていき、スタックする用途のものです。また、誤って AD アカウントを無効化してしまったことに気づきやすいように Slack への通知も同時に行っています。 このジョブは barbeque2ジョブとして実装しています。barbeque は ECS を基盤とする非同期ジョブ実行環境で、ジョブのリトライなどの管理を任せながら、SNS を購読し ECS で動作するジョブの実装を少ない手間で行うことができます。次の図は pasuwado から barbeque ジョブまでの動作フローです。

f:id:itkq:20191009220409p:plain
pasuwado から barbeque ジョブ実行までの動作フロー

例2: IAM User を削除する

AWS の IAM User は miamという IaC ツールで Git 管理しています。master ブランチが更新されると CI が走り AWS 上のリソースが変更されます。 無効化から 3 日後のイベントを受け取ると退職が確定したとみなし、IAM を管理するリポジトリをクローンし、退職者の IAM User を発見したらそれを削除する Pull Request を出しマージするジョブを同様に barbeque ジョブとして実装しました。この Pull Request は、例1 で述べた issue に紐づけています。

その他

上で挙げた例以外に、SRE が持つ稼働中の自動化ジョブは以下のものがあります。

  • MySQL 個人ログインユーザの削除
  • GitHub Enterprise アカウントのサスペンド
  • PagerDuty のユーザーをチームから削除
  • Amazon SNS Topic の個人 email subscription の削除
  • github.com のアカウントを cookpad organization から削除

また、他のチームの利用事例として、DWH チームによる Redshift の個人ログインユーザの削除・個人スキーマの削除があります。

自動化による SRE の退職処理運用の変化

退職処理の自動化によって、運用がどう変わったかについて説明します。

自動化以前

週次で SRE のうち一人がアサインされ、スプレッドシートに記録された退職者のアカウント情報を元に手動で処理していました。具体的には、SRE が管理するサービスのアカウントやリソースに該当アカウントがないかチェックし、見つかればそれを削除したり無効化してスプレッドシートに作業内容を記録していました。SRE 管轄のアカウントはそれなりに量があるため、場合によっては面倒な作業でした。

自動化以後

退職処理を伴う退職者が存在したかどうか週ベースで自動チェックし、存在した場合は SRE のうち一人がアサインされます。アサインされる issue には、退職処理済みのアカウントに対応する、例1 で述べた issue が紐付けられています。この issue には、自動処理の結果と自動化しきれず手動で行うべき処理が書かれており、これを手動で行ったら issue をクローズし、紐付けられたすべての issue をクローズしたら作業完了です。 元々あったスプレッドシートの運用は、作業内容を誰がいつ行ったかを記録する目的のものだったため、それが issue 上で行われるようになった現在では不要となりました。また、自動化できない処理とは例えば、SaaS のアカウントを無効化したいが無効化を行う API が無いなどです。いい感じの API が生えてくれることを願っています。

まとめ

SRE で運用している退職処理自動化の仕組みについて説明しました。汎用的な仕組みとして設計したため、この仕組みを活用して退職処理を自動化している SRE 以外のチームもあります。 繰り返される自動化可能なタスクを可能な限り自動化していくことにより、本質的な作業にかける時間を増やすことができます。この仕組みを導入してから 1 年以上が経過しており、自動化を実装する時間と比較しても退職処理にかける時間をだいぶ省けている実感があります。退職処理が面倒だと感じている方はこの記事で述べたような自動化の仕組みを検討してみてはいかがでしょうか。


  1. 通称。正式にはコーポレートエンジニアリング部サービスデスク・インフラグループ

  2. https://techlife.cookpad.com/entry/2016/09/09/235007を参照

データ分析プロジェクトの品質をキープしつつ効率的な検証をサポートする一時ファイル群の管理

$
0
0

研究開発部の takahi_iです。本稿はデータ分析、 機械学習関係のプロジェクトで数多く生成される一時オブジェクトおよびそれらのオブジェクトを保持するファイル(一時ファイル)を管理する取り組みについて解説します。

本稿の前半はデータを分析するプロジェクトの一般的なフローと起こりがちな問題(コードの品質管理)について解説します。後半はプログラム上で生成されるオブジェクト群をファイルに自動でキャッシュを管理するツール(Hideout)を使って、コードを整理整頓しやすくする施策について紹介します。

データを分析するプロジェクトの一般的なフロー

まずデータを処理するプロジェクトや機械学習プロジェクトの典型的なフローについて考えてみます。まずは単純に機械学習器を取得した入力に対して適用するプロジェクト、次にもう少し複雑な事例、アプリケーションで利用するデータを生成するプロジェクトのフローについて見てゆきます。

単一の機械学習器を適用するプロジェクト

データ分析するプロジェクトの典型例として機械学習器を入力データに対して適用するプロジェクトを考えます。

学習用のデータのように静的に変化しないデータはレポジトリに同梱されることもありますが、Webサービスのデータが対象の場合、推論用のデータの多くは日々変化するため、データベース(DWH)から動的に取得します。

機械学習を適用するプロジェクトは取得したデータを多段に変換しながら出力となる判別結果を生成してゆきます。

以下の図は一般的な機械学習を利用したプロジェクト検証スクリプトのフローです。

f:id:takahi-i:20191015114010p:plain
単一の機械学習器を適用するプロジェクト

上記のプロジェクトでは必要なデータを取得、学習、推論(テスト)と連続して処理がおこなれています。各処理ごとにデータは変換され、生成されます(例えば学習後にはモデルオブジェクトが生成されます)。

たとえば、レシピに含まれるステップを本来のステップとそれ以外(コメントなど)に分類する バッチ処理(こちらを参照)はちょうどこのような内容になっています。このバッチでは一部のデータを切り出して処理しているのですが、それでも取得、学習、推論(テスト)ステージでかなりの時間がかかっています。

アプリケーション用データを生成するプロジェクト

さらに自然言語のような非定形データを扱う中でも複雑なタスク(対話や質問応答)ではDWHから取得した データを多くのオブジェクトを生成しつつ多段で加工してゆくことも一般的です(End-to-Endの学習で一度に処理してしまう場合もありますが)。

f:id:takahi-i:20191015114231p:plain
多段に処理をするプロジェクト

クックパッドの研究開発部においてこのような多段の処理を適用するプロジェクトに レシピのMRRへの変換や、 クックパッドのAlexaスキルが提供する調理補助用の質問応答で利用されているKnowledge Baseの生成プロジェクトがあります。

これまで紹介した2つのタイプのプロジェクト(単一の機械学習器を適用するプロジェクト、アプリケーション用データを生成するプロジェクト)のどちらのプロジェクトタイプでも、まず一つ以上の入力データを抽出します。データは入力データ、必要なリソース(辞書)、機械学習器が出力したアノテーション結果などがあります。そしてその後のデータ変換、集約処理が多段に続きます。各処理では一時オブジェクトを入力として別のオブジェクトを生成します。

そしてこのオブジェクトの集約、加工、生成処理の実行はそれぞれ時間がかかります。このことがデータ分析プロジェクトを中長期メインテナンスする場合に品質上の問題を引き起こします。

データ処理スクリプトに対する修正要求とコード品質問題

データを分析、加工するプログラムでも普通のプログラムと同じように機能追加の要求にさらされ修正され続けます。 多くの場合、修正するべき箇所はプログラムの中の一部に過ぎませんが、修正が問題をはらんでいないかをチェックする にはE2Eテスト(機械学習の学習、Inferenceなど)を走らせます。また、自然言語を入力とするタスクの場合にはテスト しきれないことがどうしても発生するため、一部のデータを使ってプログラムを実行して変更が問題を発生していないかも確認したくなります。

このような検証目的の実行時に扱うデータの規模は大規模ではなくても、各ステージごとの処理に十秒から数分かかってしまいます。

結果、微細な修正をした後にコードに問題がないかを確認するだけでも結構な時間を消費してしまいます。 実行に時間がかかりコードを修正をするコストが大きくなるため、コードを修正するサイクルが大きく(試行できる回数が少なく)なり、コードを整理するハードルが高くなってしまいます。このような状況ではプログラムの検証実行や、テストに時間がかかりすぎるためコードの品質をキープしづらくなります。

不十分な解決方法

検証時の実行に時間がかかってしまう問題に対するする解決策として、以下のような対処方法が考えられます。

一時ファイルの手動追加

データの取得、生成時間を省略するために、データベースから切り出した入力データや機械学習器が出力したモデルファイルのような一時データを保持するファイル(一時ファイル)をレポジトリや、ローカルディレクトリに同梱しているプロジェクトを見かけます。データ分析プロジェクトの一時ファイルは、モデル、データベースから抽出した辞書リソース、前処理済みの入力データなどがあります。

たとえば以下のプロジェクトでは入力ファイルに前処理を適用したファイル(preprocessed_input1.txt)と、機械学習器が生成したモデルファイル(validation_model1.dat)をレポジトリに同梱しています。

.
├── Makefile
├── README.md
├── config
│   ├── __init__.py
│   └── env.py
├── data
│   ├── dictionary.dic
│   ├── preprocessed
│   │   ├── preprocessed_input1.txt
│   │   └── preprocessed_input2.txt
│   ├── models
│   │   ├── validation_model1.dat
│   │   └── validation_model2.dat
...

もちろんこれらの一時ファイル群は本来はレポジトリに含まれるべきではありません。それでも、こういった一時ファイルを利用することでプログラムの動作検証の速度を向上できます。

しかし、このように安易に加工済み入力ファイルやモデルファイルをレポジトリに同梱してしまうと問題が発生します。

問題の一つは加工済み入力データの生成方法がコードから分離してしまい、プロジェクトが進むにつれデータの加工方法と乖離してしまう点です。 入力データも含め、プログラムで扱うデータやオブジェクトはコード修正とともに変化してゆきます。 たとえばモデルファイルのような生成されたデータを一時ファイルから読み出して検証的に実行している場合、コードの修正によってデータがファイルから読み出しているものから変化し本来は実行時に問題が発生していることがあります。残念ながら一時ファイルを利用して検証実行している場合、このような問題に気がつくのは難しいです。というのも修正した部分(たとえば検証用に切り出した小規模データでの学習処理)はキャッシュファイルを使うと実行されず、テストやローカル環境での実行は中途半端にうまく動作してしまうのです。

さらにテストがレポジトリに添付されたモデルファイルを利用してしまっている場合には、CI環境でもコードの修正にともなうバグを検知できません。 このような状況で問題が発覚したときにはコミットすでにがかなり積まれてしまい、問題箇所を同定するのが難しくなっていることがあります。

もう一つの問題は、レポジトリを中長期メインテナンスすると発生します。本来は一時的な目的でVCSレポジトリに追加されたはずの中間オブジェクトを保持するファイル群は、役割を全うした後も消されることなく(消し忘れ)レポジトリにとどまり続けることがあります。このような消し忘れファイル群はプロジェクトの開発時には問題にならないのですが、時間経過(半年、一年)を経るとエンジニアは生成方法を記憶していないため特に引き継ぎ時に大きな問題になります。

キャッシュ処理の追加

必要な一時ファイルを活用するためのもう少しましな解決方法に、関数へのキャッシュ処理の追加があります。 たとえばMRRの生成プロジェクトでは、入力、中間データ(それぞれが数十〜数百MBのデータ)をファイルにキャッシュをすることで、 検証時の実行速度を向上して開発速度を高めました。以下はMRRの生成プロジェクトで利用されているメソッドの一部です。

defget_ingredient_id_map(cache_file_path):
    if os.path.exists(cache_file_path) andnot self.force:
        withopen(cache_file_path, mode='rb') as f:
            return pickle.load(f)

    ingredient_id_map = _get_ingredient_map_impl()

    ifnot os.path.exists(cache_file_path) ornot self.force:
        withopen(cache_file_path, mode='wb') as f:
            pickle.dump(ingredient_id_map, f)
    return ingredient_id_map

この関数は、キャッシュファイルがあればロードしたものを返し、なければ生成したうえで、(キャッシュ)ファイルに保存します。 この関数を使うことでローカル環境で(2回目の実行以降)オブジェクトを生成するコストは低減できます。

大きめのオブジェクトを生成する関数にキャッシュファイルを生成する処理を付与することでテストや検証目的に実行していたプログラムの実行時間が、数分から10秒程度に減少できました。これによりコードを積極的に整理できるようになりました。 またCIで簡単な学習➡推論をすることで、学習プロセスに問題ないかを常時クリーン環境でテストし続けられるという 利点があります。

しかしこのやり方にも問題があります。数多く存在する中間オブジェクトごとに上記のようなキャッシュする処理をつけるのは面倒ですし 、処理内容とは関係のない内容で関数を埋めてしまうのにも抵抗があります。また、多くの機能追加の要請で必要な修正は コードの一部のコンポーネントに限られます。そのため一部のキャッシュだけは効かせたくない場合がありますが、 各変換処理にキャッシング処理をベタ書きした状態では対応が難しいです。

こういった問題を解決するため、最近はオブジェクトのファイルへのキャッシュ処理を自動化する Hideoutという簡素なツールを作って利用しています。

Hideout: データ分析プロジェクト用、ファイルキャッシュ

Hideoutはオブジェクトを生成するタイミングでキャッシュファイルもあわせて生成するツールです。 実行時に環境変数で指定するキャッシュ設定がオンになっていてかつ生成されたキャッシュファイルが存在すれば、 キャッシュファイルをロードしてオブジェクトを返し、なければ指定された生成用関数を呼び出します。

基本的な使い方

たとえば以下の generate_large_object関数はオブジェクトを生成するのに時間がかかります(人工的なサンプルですが)。

defgenerate_large_object(times):
    sleep(1000)
    returnmap(lambda x: x*2, range(times))

この関数から生成されるオブジェクトを Hideout でキャッシュするには以下のように記述します。

large_object = hideout.resume_or_generate(
    label="large_object",
    func=generate_large_object,
    func_args={"times": 10}
)

funcにはオブジェクトを生成する関数、func_argにはfuncを実行するのに必要な引数を辞書として渡します。

HideoutはデフォルトではキャッシュがOffになっています。そのため、 デフォルトではキャッシュはされず単にfuncに指定された関数を実行してオブジェクトを生成します。

キャシュをOnにしてオブジェクトを使いまわしたい場合には環境変数、HIDEOUT_ENABLE_CACHETrueを設定します。 ローカルで検証しているときにはコマンドを実行するターミナルで環境変数を指定します。

使用例

クックパッドのAlexaスキルで使用している質問応答用のKnowledge Baseを生成するプロジェクトではHideoutを利用して 生成される中間オブジェクトの一部をキャッシュしています。

該当レポジトリは Cookiecutter Docker Scienceテンプレートで生成されているので、 Makefileをワークフローの管理に使用しています。MakefileにはKnowledge Base生成用のターゲットを登録してあります。 ローカルにおける検証では以下のようにキャッシュをOnにして実行しています。

$ make generate BATCH_SIZE=500 HIDEOUT_ENABLE_CACHE=True 

テストではモデルファイルを使ったE2Eのケースも含まれていますが、同じように make コマンドに HIDEOUT_ENABLE_CACHE=Trueを指定した上で実行すると数秒で終わります。

$ make test HIDEOUT_ENABLE_CACHE=True

Hideoutにおいてキャッシュ設定はデフォルトではOffになっているので、CIやプロダクション環境で誤ってキャッシュファイルが生成されることはありません。 そのため修正時にはPull Requestをこまめに作り細かくコミットをプッシュすると、キャッシュが効いていない環境でテストが走るため、思わぬ不具合に気づけて便利です。

ステージごとにキャッシュOffを指定

本稿の前半で紹介したようにデータ処理をするプロジェクトには複数のデータソースを複数のステージで多段に加工するものがあります。

f:id:takahi-i:20191015114414p:plain
複数のステージから成るプロジェクト

上記の図ではデータを抽出した後「Preliminary1」や「Preliminary2」、「Transform」というステージがあります。 このような少々の複雑さをもつプロジェクトであっても機能拡張依頼が来たとき、多くの場合には修正する箇所は ソースコードの一部でしかありません。

このようなとき修正が必要な一部のステージだけキャッシュファイルをロードする処理をオフにしたいことがあります。この目的のために HideoutはHIDEOUT_SKIP_STAGESという環境変数を提供しています。たとえばキャッシュした ファイルを利用して実行したいが、Preliminary2Transformステージだけはキャッシュを Offにしたい場合が考えられます。 このような場合、make build HIDEOUT_ENABLE_CACHE=True HIDEOUT_SKIP_STAGES=Preliminary1,Transformと キャッシュをしないステージを指示します。

Hideoutにおいて指定するステージ名はhideout.resume_or_generatelabelオプションで付与します。

large_object = hideout.resume_or_generate(
    label="Preliminary1",
    func=generate_preliminary_object,
    func_args={"times": 10}
)

今後

Hideoutを利用するユーザはプログラム中の関数ではなく、キャッシュファイルを生成している部分に適用する部分に処理を追加します。これはキャッシュする部分を追いやすくするためで、インターフェース名もHideoutが利用される箇所がわかりやすくなるように長く(resume_or_generate)なっています。 ただ最近、同僚から「デコレータでやったほうがシンプルなんでは」というコメントを頂きました。そこで今後デコレータのインターフェースも提供してみたいと考えています。

まとめ

本稿はデータ分析をするプロジェクトにおける一時オブジェクトを保存したファイル(一時ファイル)の扱いについて解説しました。一時ファイルはデータ分析の結果を高速に検証するのに便利ですが、安易に レポジトリに追加するとプロジェクトの保守性が下がります。

また、必要な入力データや中間オブジェクトをファイルにキャッシュする処理を追加するのも コストがかかります。そこで本稿の後半では、このようなプロジェクトを保守するのに役立つキャッシュツール、Hideoutについて紹介し、一時ファイルを利用しつつもレポジトリをクリーンに保つ方法について解説しました。


クックパッド社内に工房(Fab)を作ってプロトタイプ開発をした話

$
0
0

今年1月に研究開発部から分離して発足しましたスマートキッチン事業部の山本です。
スマートキッチン事業部では、クックパッドが提供するレシピ情報を様々な家電機器と連携させて、料理体験をより楽しく快適にする、スマートキッチンサービス OiCyの開発をすすめています。

クックパッド社内に工房(Fab)を作りました

スマートキッチンサービスOiCyは、レシピ情報と家電機器の連携で生み出されるサービスで、サービス開発に加えてサービスと連携する家電機器が必要になります。そのため、家電メーカーとの連携をすすめていますが、同時に自前での家電機器開発も行っています。そして、自前の家電開発を効率的に行なえるようにする目的で、社内で加工製作ができる工房(Fab) を、恵比寿のクックパッドオフィス内に立ち上げました。工房には、3Dプリンタやレーザーカッターなどの加工設備が設置されており、業務内外を問わず社員の利用が可能になっています。(要安全講習)

f:id:ymmttks:20191016150422j:plain:w420
工房の様子

工房生まれの自前(改造)家電たちの紹介

この工房で生まれた、クックパッド自前(改造)家電の一部が、先日開催されたスマートキッチンサミットジャパン2019(SKSJ2019)で公開されました。

OiCyService動画

SKSJ2019クックパッドデモの動画
↓SKSJ2019の関連記事はこちらを参照
・PC Watch ロボット化する家電から寿司シンギュラリティまで、人を食でエンパワーする「スマートキッチンサミット2019」 森山 和道
・CNET JAPAN 未来の台所を創造する「SKSJ 2019」から見えてくるもの 近藤克己

OiCy Water

f:id:ymmttks:20191016150433j:plain:w320
OiCyWater外観
OiCy Waterは、水の『硬度』と『分量』をレシピに合わせてボタン一つで出してくれる、電動ウォーターサーバーです。スマートフォンのアプリ上でレシピを閲覧すると、そのレシピに書かれている水の『硬度』と『分量』(※)が自動的に装置に転送されます。ユーザーは装置上のボタンを押すだけで、閲覧しているレシピで使うための適切な水を得ることができます。『硬度』と『分量』は、ジョグダイヤルを回して手動で調整することもできます。

※現状ではクックパッドのレシピ全てに硬度に関する記述があるわけではありません。

↓水の硬度が料理に与える影響についてはこちらを参照
SKSJ2019 ⾃分の「おいしい」を⾃分でつくれる感動を クックパッド 金子晃久

OiCyWaterの構成

f:id:ymmttks:20191016150453p:plain:w420
OiCyWaterの構成
本機は、2つのチューブポンプを用いて、『硬水』と『軟水』2つのボトルから水を排出する装置です。使用したチューブポンプは3.5ml刻みで排出量を制御、最速で1分間に1.4Lの水を排出することができます。
制御用のマイコンシステムにはM5Stackを使いました。理由は、技適が取れていてかつとても安価、ネット上に参考にできる情報が豊富にあって、ライブラリも充実しているからです。周辺デバイスへの信号は、モーターの速度制御をするPWM信号のみM5StackのGPIOから直接出していますが、それ以外はI2C接続したGPIOエクスパンダを経由してやり取りをしています。GPIOエクスパンダ側で、信号の変化を割り込み制御をする予定でしたが、WiFiのライブラリと同時使用するとファームにリセットがかかるという現象があり、イベント直前で時間がなかったためこの問題解析は保留して、割り込みなしのポーリングで、ジョグやモーターの回転を拾う処理になっています。
モーターの回転は、チューブポンプの回転部分とポンプ外装の間に隙間があるので、100円ショップのネオジウム磁石をそこに接着、ポンプ外側に設置した磁気センサから非接触で回転検出をしています。この方法では回転方向は分からないのですが、チューブポンプは負荷が非常に大きくギア比の大きなモーターが付いており、外部から強制的に回すことはほぼ不可能です。そのためポンプ回転部分はモータードライバに入れている信号の向きにしか回らないため、特に回転方向を検出する必要はありません。磁石の貼り付け位置を、チューブポンプのローラー部分にすることで、水の排出綺麗に途切れるところで正確にモーターを止められる”位置制御にも利用しています。
f:id:ymmttks:20191016150442p:plain:w320
OiCyWaterのメカ構造
チューブポンプはモーター部分が長く突き出した構造をしていて筐体へのおさまりが悪いため、ベルトとプーリーを用いてモーター部分をポンプ本体とタンデム構造にし、2つのチューブポンプを向かい合わせに対向させるメカ構成にしました。2つのチューブポンプ、2つのモーターは2mm厚のステンレス製の背骨に固定されて、チューブポンプを回す強力なモータートルクに負けない強靭な剛性を持たせました。工房のレーザーカッターでは、金属の切り出しはできないため、この背骨部分の制作のみ外注先に頼んで特急で作っていただきました。ペットボトルを下向きに指すジョイント部分については、弁のついたペットボトルキャップと交換する部分はペット用の給水器の部品を流用、刺さる側の部品は3DプリンタをつかってABS樹脂で成形しました。2Lの水の水圧がかかっても水漏れをしない構造を作るのには試作検証改良を繰り返す必要がありましたが、3Dプリンタが手元にあることは短期間での開発にとても役立ちました。

OiCyサービス対応電子レンジ

f:id:ymmttks:20191016150446j:plain:w420
OiCyサービス対応電子レンジ SIGMA
メーカー様からお叱りを受けるかもしれないのであまり大きな声では言えないのですが、市販の電子レンジを改造して、スマートフォンのアプリ上で閲覧しているレシピに書かれている 『加熱ワット数』、『加熱時間』をWiFiを通じて自動的に装置に転送されるようにしたものが、OiCyサービス対応WiFi電子レンジ(開発名:SIGMA)です。

SIGMAの構成

f:id:ymmttks:20191016150457p:plain:w420
SIGMAの構成
電子レンジは、強電系に非常に高圧な回路と大容量コンデンサを搭載しており、改造には危険を伴います。専門的な知識のない場合には絶対に真似しないようにお願いします。今回の改造は、できる限り装置の深い制御部分に手を入れず、UI部分を乗っ取る形でHackしました。こうすることで、強電系の回路に一切触れずに欲しい機能を実現することができました。
電気量販店で、改造しやすそうな電子レンジを探すところから、開発は始まります。改造用の電子レンジを選択する上でのポイントは、液晶表示やタッチパネルなどを用いてるものは、現在の状態を正確に把握するための難易度が高いため避けます。LEDのみ、物理スイッチのみでUIが構成されていて、かつスイッチに複数の機能が割り当てられていないものが好適です。
OiCy Waterと同様に、制御用のマイコンシステムにはM5Stackを使いました。この電子レンジは、UIが、LEDと2つのジョグスイッチだけで構成されていたので、これらの入出力と扉の開閉センサをロジック回路処理(時分割表示のLED信号の復調回路)を噛ませてGPIOエクスパンダに接続し、M5Stackからは全体が一つのI2Cデバイスとして見えるようにしました。 外装は、操作パネル部分のジョグやLEDをすべて外し、レーザーカッターで加工した3mm厚の乳白色のパネルで覆い、表面上からはM5Stackの表示パネルとボタンのみしか見えない構造になっています。スマホからのレシピ情報転送以外に、M5Stackのボタン操作によるレンジ設定変更が可能です。加熱のスタートやキャンセルはドアの開閉で行い、ボタン操作は不要です。

OiCyサービス対応IHプレート

f:id:ymmttks:20191016150426j:plain:w420
OiCyサービス対応IHプレート OMEGA
こちらもメーカー様からお叱りを受けるかもしれないのであまり大きな声では言えないのですが、市販のIHプレートを改造して、スマホ端末で閲覧しているレシピに書かれている『火力』、『加熱時間』をWiFiを通じて自動的に装置に転送されるようにしたものが、OiCyサービス対応WiFi IHプレート(開発名:OMEGA)です。

OMEGAの構成

f:id:ymmttks:20191016150502p:plain:w420
OMEGAの構成

改造用のIHプレートを選ぶポイントも電子レンジとほぼ同じです。リバースエンジニアリングの結果、この機器は本体部分とUI部分をクロック同期式の変則的な双方向シリアル通信で構成されていることが分かったので、UI基板を取り外し、代わりにロジック回路処理(入出力信号の分離)を噛ませてGPIOエクスパンダに接続して、M5Stackからは全体が一つのI2Cデバイスとして見えるようにしました。
外装は、UIパネル幅に対してM5Stackの方が奥行方向に長いため、張り出した顎の部分を3Dプリンタで成形して、表面処理をした上で塗装したものを取り付けてあります。パネル部分は、電子レンジと同様にレーザーカッターで加工した3mm厚の乳白色のパネルで覆って統一感を持たせてあります。

社内に工房がある強み

 製品やサービス、機能など、我々がこれから作ろうとしているものが、本当に顧客にとって価値があるのか?モノができてから実際にユーザーに提供してみたら、期待した価値がなく失敗・・・のでは時間と資本を大きくロスしてしまいます。そこで、我々は、Googleのスプリントに則って、3〜5日で課題から仮説を導きだし、ソリューションを立て、プロトタイプを作り、ターゲットユーザに当てて仮説検証を行う、という方法をよく用いています。
 ↓高速コンセプト開発メソッドについてはこちらを参照
 ・SKSJ2019 一週間で回す高速コンセプト開発メソッド教えます クックパッド 佐藤彩香
 
 スマートキッチン事業部でも、短いものでは3日程度で、ハードウェアのプロトタイプを作って、実際にユーザーに使ってもらって、顧客価値を検証しています。今回紹介した3つの家電デバイスは、各2週間程度の開発期間がかかっていますが、顧客価値検証用に現場で実際に使用し、継続的に改良が行われています。こういった機器の開発改良は、外部のリソースに頼っていてると短時間での開発は難しく、価値検証に時間がかかってしまいます。使いたいときにいつでも加工・製作に使える『場』と『機材』が社内にあることは、超高速ハードウェアプロトタイピングでは極めて重要です。そして、この『場』と『機材』を活かしきれる、メカ設計〜加工〜回路設計〜回路製作〜ファームウェア設計実装〜実機デバッグといった一連のプロトタイプ開発を一人で一貫してできる、フルスタックエンジニア人材を絶賛募集中です。

まとめ

『その程度のプロト、俺がやれば1週間でできる!』『3日でできる!』というプロトタイプエンジニアスキルをお持ちの猛者のかたは、是非我々のプロジェクトにJoinしてください。
クックパッド キャリア採用 職種:プロトタイプエンジニア(スマートキッチン)

同時に、こんなイカれたデバイスを操作するiOSアプリを書きたいという、キワモノ好きのiOSエンジニアとデバイスとアプリをユーザーとつなぐ素敵なUI/UXデザインを担当するデザイナーも絶賛募集中です。
クックパッド キャリア採用 職種:iOS エンジニア
クックパッド キャリア採用 職種:UI/UXデザイナー(スマートキッチン)

データ活用基盤の今 〜DWH外観図〜

$
0
0

こんにちは、今年の1月に会員事業部から技術部データ基盤グループへ異動した佐藤です。先日、京まふ2019前夜祭イベントに参加するために人生で初めてピカピカ光る棒を買いました。

新卒で入社してから2年ほど分析作業をしていた身から、データ活用基盤を作る側へ立場を変えました。今回は新たに身を移したデータ活用基盤の外観を説明したいと思います。

2017年にも同内容の記事が投稿されていますので、当時との違いを中心に説明していきます。

外観図

以下が2019年10月現在におけるクックパッドのデータ活用基盤の全体像です。

クックパッドのDWH外観図
クックパッドのDWH外観図

masterデータのインポートがMySQL以外にも複数種対応し始めたことと、PrismとSpectrum(S3+Glue)周りと、Tableau Serverが大きな変更点となっています。2017年の図にDmemoはありませんでしたが、記事本文にある通り当時から活用していました。

図が煩雑にならないよう、レシピサービスを中心にまとめていますが、クックパッド社が運営する全サービスについて同様のワークロードでRedshiftにデータを集約させています。また、図では省略しましたが、Firebaseなどの外部サービスから得られるデータもRedshiftに集めています。

一つ一つの処理をみていきましょう。

入力: マスターデータの取り込み (master data flow)

クックパッド社内でサービスのマスターデータを管理するDBとして利用するDBMSは主にMySQL・PostrgreSQL・DynamoDBがあります。最も多いのがMySQLで、最近DynamoDBが増えつつあります。

MySQLについてはpipelined-migratorという独自開発の専用システムを利用しています。 こちらは管理コンソール用の専用Webサイトが社内ネットワーク上にあり、テーブル取り込みの様子を確認できます。また、ボタンひとつでRedshiftに取り込むテーブルやDBの追加・削除が手軽にできるようになっています。

pipelined-migratorのコンソール
pipelined-migratorのコンソール

PostgreSQLについてはAWS Database Migration Service(以下DMS)を利用しています。pipelined-migratorがまだ現段階ではMySQLにしか対応しておらず、かつ社内でPostgreSQLの利用が比較的少ないため、一時的にDMSを利用しています。PostgresSQL対応版も開発中であり、今後PostgreSQLからのテーブル取り込みもpipelined-migratorに一本化していく予定です。

DynamoDBについてはまだ実績が少ないのもあり、定型化しているものの手作業でインポートしています。 DynamoDB Streamでデータの更新を検知し、LambdaとKinesis Firehose経由で、S3に追加データを吐き出します。S3に配信後は後述するログデータと同様にRedshift Spectrumでクエリアクセスができるようになります。

DynamoDBデータの取り込みフロー
DynamoDBデータの取り込みフロー

pipelined-migratorはbricolageとmys3dumpを組み合わせて作られています。

入力: ログデータの取り込み (log data flow)

ログデータの取り込みには、2017年の記事に書かれたStreaming Loadシステムと昨年末に書かれた記事に登場したPrismの両方が使われています。 クックパッドのDWHではRedshift Spectrumを中心にして構築するようにデータ移行作業を実施中です。このため、Streaming Loadシステムからのログ取り込みはゆくゆくは退役する予定です。 現在は移行期であるため外観図にはStreaming LoadからRedshift内部へのロードとPrismからRedshift外部(Spectrumでアクセス可能なS3バケット)の2経路が同時に存在していますが、次にまたDWH外観図を書く頃にはPrismに一本化されていることでしょう。

サービス開発者側から「新規にログを取り始めたい」となった場合、以下の手順でロードを行います。

  1. *.strdefというYAML形式のファイルにログ定義を書く
  2. tech/dwhというDWHが管理するリポジトリへ上記ファイル追加PRを出してもらう
  3. ログ関係者&DWHチームメンバーがログ定義に関するレビューをする
  4. アプリケーションから送られてきてS3にログが到達したしたことを確認し、strdefファイルの適用を行う

サービス開発者に1~3までを行ってもらい、DWHでは3~4を担当します。 こうしてみるといちいちログをとるのに手順が多く、面倒に思われるかもしれません。このフローで運用している背景には、ログを取りたいと思った人にstrdef定義を通してきちんとログ設計をしてもらいたいという意図があります。これはDWHに限らない話ですが、ロギングが始まってしまえばログは修正できません。プログラムのリファクタ感覚でカラム名や型定義は変更できないのです。どうしてもやむを得ず発生することはありますが、問題を先送りにした場合はツケが回ってきます。ログ取り自体は気軽に行えるが、その設計にはきちんと考える時間を取りましょうという思いがあって、こういった手順となっています。

Streaming Load・Prismのどちらもコンソールが用意されており、ログが順次ロードされていく様子を確認できます。

Streaming Loadのコンソール
Streaming Loadのコンソール
Prismのコンソール
Prismのコンソール

Streaming Loadの実装はbricolage-streaming-preprocssorとbricolage-streaming-loaderとして公開されています。

Redshift内部での加工処理

Prism登場によりRedshift Spectrum活用が進んできましたが、Redshift内部の処理に関しては既にだいぶ完成していたため新たに手を加える必要はありませんでした。 2017年当時と同様、bricolageを用いて書かれたSQLバッチをKuroko2というジョブ管理システムで定期実行しています。Redshift内部のデータアーキテクチャについても従来通りの入力層・論理DWH層・論理データマート層の3層区切りとなっています。ただ、2年間運用してきたことでこれら各層に格納されているデータは充実してきており、2年前と比べて3層ともに成長しています。

Redshift内部の加工処理はbricolageとKuroko2を組み合わせて作られています。

出力: アドホックな分析

社内からのアドホックな分析に用いられるツールに関しては特に変化がなく、Bdash・Postico・Jupyter がそのほとんどです。あるいはTableau Desktopでアドホックな分析作業を行っているかもしれません。これら各ツールは全て内部テーブルと同じようにSpectrumテーブルへアクセスできるため、各自分析者が自分の使いたいツールを自由に選べる状態になっています。

分析者は後述するDmemoやSlackにおけるデータ分析お悩み相談チャンネル、#data-analysisを駆使して社内のデータを分析しています。日々、#data-analysisやissue上で分析用SQLのレビューが行われています。

BdashはこちらのGithubリポジトリで公開されています

出力: ダッシュボード用BIツール

from redash to tableau

2017年の記事においてRedashからTableauへの移行を検討中と書かれていましたが、現在では完全に移行体制が整い社内のほとんどのダッシュボードはTableau Serverに移行されました。移行に至った理由は過去の記事にも書かれた通り、Redashのキューまわりの実装に難があったためです。

Tableau Serverに移行した今では各部署で活用されており、エンジニア以外にも職種を問わず広く利用されています。

ただし、Tableau ServerとTableau Desktopではライセンスが別契約となるため、実際にダッシュボードを作りたい人はライセンス申請が必要となります。この申請フローは定型化されており、ヘルプデスクへ申請を出すだけでライセンスキーが割り当てられるようになっています。

Redashは諸事情がありまだ完全退役とはなっていませんが、アクセス&更新がなされているダッシュボードはほぼありません。

出力: DWH内のデータベースドキュメント

データベースドキュメント管理システム dmemo のご案内にて登場したDmemoも現役で活躍中です。 Dmemoは毎晩Redshiftにアクセスし、DWH内部にある最新の全テーブル情報を取り込みます。取り込んだDB・スキーマ・テーブル・カラムの各階層ごとに説明を書くことができ、その履歴も残せます。Dmemoに十分情報が蓄積されていれば、PR上でのやり取りやデータ分析についてSlack上で聞かれたときなどにDmemoのURLを貼って一言二言伝えるだけでスムーズにデータに関する情報伝達が行えるようになります。

Dmemo操作例
Dmemo操作例(Techlife用にローカル開発環境上でダミーデータを用いて撮影したものです)

新しく入社した社員やインターン生にデータに触れてもらうときに一旦DmemoのURLを共有しておくとその後の話が円滑に進むようになります。また、ログに関する何かしらのインシデントが発生した際には「v1.0.0のアプリケーションではIDがズレている」等々のメモを書いておくことで、後でログデータを集計して奇妙な結果が出たときに即座に気づくことができるようになります。

Redshift Spectrum移行作業に合わせて、外部テーブルやRedshift特有の機能であるlate-binding viewにも対応しました。

DmemoはこちらのGithubリポジトリで公開されています。

出力: バッチ処理用バルクエクスポート

こちらも2017年の記事と変わらず、Queueryとredshift_connectorが使われています。他のサービスからも利用できるようなDWHを構築しておくことで、データ活用基盤が分析のみならず様々なサービスやプロダクトにまで活用されるようになります。

過去にTechlifeでご紹介したデータ活用基盤を利用したシステム運用の記事を下記に載せておきます。

Redshiftから外部システムへのバルクエクスポートはQueueryとredshift_connectorが使われています。

DWHの課題

上記がデータ活用基盤の外観図となります。しかし、まだこれで完成ではなくこれからも開発を続けていく必要があります。最後に、DWHに残っている主な課題について述べたいと思います。

Redshift Spectrum移行

ログデータ取り込みの項目でも書きましたが、現在は内部テーブルへロードする旧方式のStreaming Loadと外部テーブルへロード(S3へのParquet変換)する新方式のPrismの両システムが並列で稼働しています。 Streaming Loadでのロードを廃止するためには内部テーブルに依存している全ジョブの停止・外部テーブルへの移行が必要となります。 186のジョブと284テーブルを一つ一つ検証した上で移行と削除を行っていく作業は自動化ができない、泥臭い手作業となります。その中で歴史的経緯に基づく仕様を発見したり、現状のジョブが間違っていたり等が発見されていきます。 この移行作業についてはDWH総出で丁寧に移していく他無く、地道にやっていくことになります。

Tableau Server運用

Tableau Serverの運用も依然として作業コストがかかっており、なんとかすべき課題です。 まず、Tableau ServerはTableau社側から新しいバージョンが提供されるたびにアップグレードしていく必要がありますが、ダッシュボードのポータルサイトという重要なサービスであるためこの作業は慎重に行う必要があります。アップグレード作業の際には事前に社内アナウンスを出し、メンテナンス時間を確保して行っています。 過去にアップグレード作業に失敗し、Tableauサポートと連絡をとりつつアップグレードをするということもありました。このアップグレード作業をより低コストに抑えることができないか、というのがTableau Server運用における1つ目の課題です。

また、TableauはSlackとの連携が弱いという弱点があります。一応、メール通知とZapierという外部のオートメーション化サービスを組み合わせることで自動通知を実現できますが、こちらにも実は問題があります。現在クックパッドでは専用botでの通知運用をしつつ、Tableau公式によるSlack連携機能がくる日を待ちわびています。

さらにライセンス管理についても課題があります。先にはダッシュボード用BIツールの項目で「ヘルプデスクへ申請を出すだけでライセンスキーが割り当てられる」と書きましたが、定型化したとはいえここは裏側で手作業が発生しています。根本的な負荷削減にはなっていないため、できる限りライセンス付与の作業負荷がなくなるよう自動化をしたいところです。

データ活用の推進

データ活用基盤が整いつつある今、基盤業務のその先のデータ分析の啓蒙活動に比重を置くフェイズに突入しつつあります。 今までも社内でSQL勉強会が開かれたり等、各所でデータ分析の民主化は進んできていました。ですが、草の根運動に頼るだけでなく、DWHが組織として継続的にデータ活用を推進していく必要があります。 こちらに関しては具体的な方策やマイルストーンもありませんが、来期からの一番の課題となることでしょう。

まとめ

今回は以前お伝えしてから2年経ったデータ活用基盤の全体像をお話しました。一度に書ききるには多すぎるため、前回との差分を中心に書きましたのでぜひ2017年に公開した記事と比較してみてください。

私達、技術部データ基盤グループは「クックパッドの全社員がデータに基づいた意思決定を行えるようにする」を目標に日々の業務に取り組んでいます。 分析者の望む最強のデータ分析環境を提供したい方、余計な手間をを要せず分析作業にのみ集中できる最高のデータ分析環境に浸ってみたい方、ぜひ私達と一緒にデータを駆使してより良いサービス作りをしてみませんか。

クックパッド株式会社 | クックパッド株式会社 採用サイト

クックパッドアプリ(Android)の開発効率化のためにやったこと/やっていること

$
0
0

モバイル基盤部のこやまカニ大好き(id:nein37)です。 モバイル基盤部では、CI環境の改善やアプリのリリースサイクル自動化といった開発・リリースフローの効率化に加え、アプリのビルド速度改善や開発のしやすさを改善する様々な取り組みを行っています。 今回はその中から、クックパッドアプリ(Android)に対して行った開発効率化の取り組みの一部を紹介したいと思います。

あわせて読みたい : Android版クックパッドアプリで採用している技術の現状確認 2018年版

日々のメンテナンス系

不要になったソースコードやリソースの削除

気がつくとどこからも参照されなくなったソースコードやリソースはどうやっても発生するので定期的に消しています。 特にモバイル基盤部のタスクと決まっているわけではないですが、単に綺麗になって嬉しいこととapkサイズが少しでも小さくなれば良いという気持ちで手の空いた時に Android Studio の Analyze ツールや konifar/gradle-unused-resources-remover-pluginで検出されたものをシュッと消しています。

Lint設定の最適化/Lint警告の除去

クックパッドアプリでは以前、朝Lintという取り組みで細かいLint指摘事項への対応を行っていました。 この取り組みは警告も減るしなんとなく成果が出た気持ちになって良いものだったのですが、最終的に修正コストが異常に高かったり本当に対応が必要かと思えるような警告が残って誰も手を付けられなくなり、やがて途絶えてしまいました。

僕もすっかり朝Lintのことを忘れていたのですが、ある時なんとしても令和の朝Lintというプルリクエストを出したいと思い手元で Lint を動かしてみるとおよそ280件の指摘事項があり、うちいくつかはクックパッドアプリでは考慮しなくても良いようなものだとわかりました。 lintOptionsを見ると、これは逆に対応したほうが良いと思えるものもいくつかあります。 この lintOptionsをクックパッドアプリの現状に合わせたものを令和の朝Lintとしてプルリクエストにしました。

f:id:nein37:20191021143111p:plain

このときのプルリクエストでは各指摘事項の変更に対して、なぜ enable/disable にするのか、どのように修正すれば良いのかをコメントしておいたので、今後 Lint 設定を再度見直すときにも利用できると考えています。

f:id:nein37:20191021143140p:plain

同じくモバイル基盤メンバーの吉田さんが社内ブログでも朝Lint活動について広報してくれていて、朝Lintという習慣がひっそりと復活しつつあります。

※ クックパッドアプリではプルリクエストに含まれる変更に関する Lint や ktlintの指摘事項は Danger によってプルリクエスト中に指摘され修正するようになっており、朝Lintの対象となるコードを増やさないような仕組みづくりも同時に行っています。

画像リソースのWebP化/WebPおじさん化

あるときどうしても apk サイズを小さくしたくなり、画像リソースをまとめてWebPに変換しました。 Android Developers にはWebPはサイズが小さくて最高とか画像リソースをWebPに変換するとビルドが速くなるといった夢のようなことが書いてあり、それを信じて変換しました。 Android Studio には WebP の変換ツールが組み込まれていて、アプリ内の画像を一括で WebP に変換することができます。 また、クックパッドアプリは minSdkVersion21 になっているため、ロスレスや透過といった WebP の機能をフルに活かすことができるのも利点でした。 Lossy WebP への変換では画質が劣化するために以前のバージョンと比較する必要がありますが、Lossless WebPへの変換であれば理論上画質の劣化はありません。 WebP に乗り換えるためにすべての画面のすべての画像の画質チェックをしなくても良いのは便利でした。

f:id:nein37:20191021143203p:plain

6月頃に一括でアプリ内の画像およそ1000個の画像を Lossless WebP に変換し、その結果、apk サイズを1.2MB縮小することが出来ました。 それ以降は主にプルリクエストレビュー時に WebP おじさんとして活動しています。

f:id:nein37:20191021143229p:plain

得られた知見として、 Lossless WebP への変換ではほとんどの場合画像サイズが小さくなりますが、以下の2パターンではうまく縮小できませんでした。

  1. 元画像が十分に小さい Indexed Color のみで縮小されたPNGである場合
  2. 元画像がまったく軽量化されていない巨大な画像である場合

1.はWebPに変換した場合に仕組み的に縮小される余地がないためか、ほぼ誤差範囲ですがPNGよりもWebP画像のほうが大きくなる事があります。 Android Studio の変換ツールに容量が節約できない場合は変換をスキップするという設定があるため、この場合は変換対象から外しています。

2.は Lossless WebP ではなく Lossy WebP で変換すべきものです。正直見た目では Lossless にすべきか Lossy にすべきかわからないので、現状は解像度とファイルサイズをみて判断しています。

将来的にはLossless/Lossy WebPへの変換はDangerに指摘させることができると良いなと思っています。この記事を書いている途中でプルリクエスト内に png, jpg や大きすぎる WebP が含まれている場合は Danger に指摘させるような修正を入れたのでWebPおじさん業はなくなりました。

Danger により機械化されたWebPおじさんの様子 f:id:nein37:20191021143256p:plain

なお、ビルド速度への影響は計測できないほどわずかでした。

minSdkVersion 21 後の変更

Ripple 対応

これまで backgroundリソースを StateListDrawableで切り替えてタッチフィードバックを実装していたような箇所を Rippleによるタッチフィードバックに置き換えていきました。 API 21から RippleDrawableも使えるようになっていて、この部分は素直に minSdkVersion21 の恩恵を受けられた部分でした。

android:elevation の指定で影をつける

当たり前なんですが古い端末での挙動とか何も考えずに elevation で影が落ちるというのが本当に楽で良いのです。

*-v21系代替リソースの整理

Style や Theme 系リソースに *-v21で分岐させていたリソースがあったので、minSdkVersion 21 を期に整理しました。 これまで Material Design のバックポートに関する知識がないと Theme を変更するのが難しかったのですが、minSdkVersion21 になったことで Theme や Style の編集はだいぶ簡単になりました。

ツール導入など

AndroidKTX導入

クックパッドアプリ内の Kotlin 比率が高くなって来たのでAndroidKTXを導入しました。 去年の11月時点ではクックパッドアプリのおよそ20%が Kotlin でしたが、現在ではさらに Kotlin への置き換えが進み半分ほどがKotlinで書かれている状態です。 f:id:nein37:20191021143324p:plain

Firebase Performance Monitoring

去年突然アプリのパフォーマンス監視がしたくなりFirebaes Performance Monitoringを導入しようとしました。 このときはクックパッドアプリが依存していた一部のjarと競合してうまく導入できなかったのですが、今年になり Android Gradle Plugin を更新したりFirebase Performance Plugin 自体が更新されたりした結果、いつのまにか導入できるようになっていました。 現在は社内の主要なアプリでは大体有効になっていて、特にレスポンスが遅い API の特定やアプリ起動時間の測定に使われています。

Stetho から Flipper への乗り換え

もともと Stetho を利用していたのですが、Mirrativ tech blogさんの記事を参考に Flipper を試してみたら良かったので乗り換えました。 アタッチしなくても良いのは本当に素晴らしく、アプリのデータ削除などでプロセスキルを挟んだ場合でもアプリを立ち上げれば自動でログを見られるようになるのはとても便利です。

R8導入

Android Gradle Plugin v3.4 でデフォルト有効になったR8ですが、クックパッドアプリではそれ以前からR8を利用して難読化処理を利用するとビルド時間が倍になるという問題が発覚していました。 BetaやRCでバージョンが上がるたびに試していましたがまったく改善せず、とうとう v3.4 が stable になっても解決しなかったため一時的にR8を無効化していました。 そのままでもアプリのビルドはできるのですが、標準ツールが自分のアプリで利用できないのは悲しいので、定期的に時間をとって調査していました。

やるぞ!という気持ちのissue f:id:nein37:20191021143401p:plain

あるとき annotations.jarという古代のProguard設定の仕組みがビルド時間に影響をしていることに気が付き削除することで無事に有効化することができましたが、普段から最新のビルドツールや設定を試して問題を検出することの重要性を感じました。

マルチモジュール関連

以前マルチモジュールにしていく話をしてから一年以上経ち、クックパッドアプリも25モジュール構成になりました。 最初の頃は StyleThemeなどをまとめたUIモジュールやログ用の仕組みをまとめたログモジュールなど、比較的変更の少ない静的なモジュールばかり切り出していましたが、これらのモジュールは切り出した後もほとんど変更が入っておらずビルドキャッシュを最大限利用できた上、位置づけもわかりやすいので早めに切り出しておいてよかったと思います。 そこから各種機能をモジュールに切り出す作業を進めていますが、:legacyに依存しないモジュールで機能を実装できるようになるまではまだもう少し掛かりそうです。

モジュールの命名

最近 Google Developers の日本語ブログにもAndroid のモジュールのパスに関するちょっとしたヒントという記事が出ていましたが、クックパッドアプリでもAndroidビューにおけるモジュール表示がわかりにくいという問題は早い段階で発覚していました。 上記の記事で紹介されていたような projectDirによる解決も検討したのですが、結局 features_のような prefix をモジュールにつけることにしました。 クックパッドアプリでは単純な解決方法を選ぶという方針でこのようにしましたが、このあたりはチームやモジュール分割の方針によって最適解が変わると思います。

モジュール切り替えによるアプリの設定変更

以前のクックパッドアプリはビルドバリアントによって接続先とデバッグ機能の有無を切り替えていました。

  • stage flavor dimension(開発用設定の切り替え)
  • mode flavor dimension(接続先設定の切り替え)
    • internal(社内ステージングサーバ向け、Hyperionなどの開発用機能あり)
    • external(本番サーバ向け、リリース用設定)
  • buildType
    • debug(minify,Proguard なし)
    • billingBeta(決済確認用の特殊なビルド)
    • release(minify, Proguard あり、リリース用証明書)

上記設定の組み合わせによってビルド時に必要な設定を利用していましたが、マルチモジュール構成のプロジェクトではアプリがこれらのビルドバリアントを設定している場合、依存しているライブラリプロジェクトにも同様のビルドバリアントを設定する必要があります。 (ライブラリプロジェクトに同名のビルドバリアントが存在しない場合、 ./gradlew testProdInternalDebugUnitTestのようなテストコマンドでライブラリプロジェクトのテストが実行されなくなる場合があります)

モジュールが増えていくにしたがってこの設定が面倒になり、 Android Studio 上でのビルドバリアントの切り替えも大変になってきたことから、 flavor ではなく依存先モジュールの切り替えによって接続先の切り替えや開発用機能の追加を行うように切り替えました。 変更後の各モジュールの依存は以下のようになっています。(dev flavor は minSdkVersion 21 化したことにより分岐がほぼなくなったので不要になりました)

  • :app_cookpad (本番サーバ向けビルドをリリースするためのモジュール)
    • :settings_external (本番サーバの接続先情報モジュール)
  • :app_cookpad_internal社内向けアプリをビルドするためのモジュール)
    • :settings_internal (社内サーバの接続先情報モジュール)
    • :features_debug (開発用機能モジュール)
  • :app_cookpad_billingBeta決済確認用のアプリをビルドするためのモジュール)
    • :settings_internal (社内サーバの接続先情報モジュール)
    • :features_debug (開発用機能モジュール)
    • アプリモジュールとして定義したことにより buildType billingBeta は廃止しました

この変更により、今まで ./gradlew assembleProdExternalReleaseという呪文のようだったビルドコマンドが ./gradlew :app_cookpad:assembleReleaseだけで良くなります。 AndroidStudio 上でもビルド対象のモジュール(=必要なアプリの種類)とbuildType(=minify,proguard,証明書)だけ意識すればよくなり、GUIでの操作もかなり簡略化されました。 クックパッドアプリはこれまでの長期間の開発で Gradle ファイルがかなり複雑化していたのですが、ビルドバリアントの整理とモジュール分割によってそれぞれ設定を書く場所がわかりやすくなり、多くの部分を共通化してシンプルな構造になっていきました。 この方式を採用するとモジュール数はどうしても増えていくのですが、クックパッドアプリのような大きいプロジェクトでも ./gradlew testDebugUnitTestのような基本的なコマンドが何も考えなくてもちゃんと動くというのは開発のしやすさという点で非常に重要だと思っているので、今年やっておいてよかった変更の一つだと考えています。

おわりに

僕の趣味で比較的地味な変更ばかり紹介してしまいましたが、今年はこの他にも多くの(機能追加以外の)変更が行われています。 モバイル基盤部ではこれからも新機能を簡単に開発し、素早くユーザーに届けるためにモバイルアプリの開発効率化を続けていきます。

興味がある方はぜひ一度クックパッドオフィスに遊びに来てください。 https://info.cookpad.com/careers/

Firebase In-App MessagingのUIをカスタマイズして運用する

$
0
0

Komerco事業部エンジニアの岸本(@_sgr_ksmt)です。
昨年Cloud Firestoreのrulesのテストを全てローカルエミュレータを使うように書き換えた話を書いてからだいぶ間が空いてしまいましたが投稿します。

今回はFirebase In-App Messagingを利用する際にカスタムUIを適応して運用している話をしたいと思います。

f:id:sgrksmt:20191025105713p:plain:w300

In-App Messaging

Firebase In-App Messaging(以下FIAMと呼びます)は、指定した条件で絞り込んだアクティブユーザーに対して、
メッセージやボタンのアクションを設定し、アプリ内で表示するためのFirebaseの一つの機能です。
表示形式としてはいわゆる「ポップアップ」「画面上部のバナー」といった形式で表示することが可能です。

ユーザーに出すための条件にアプリのターゲット、バージョン、オーディエンス、ユーザープロパティといった情報を活用することができるので、特定のユーザーにプロモーションを行うことが容易にできます。

また、メッセージを表示するUIはSDK側が標準で提供してくれるので、クライアント側はSDKをインストールするだけで実装完了になります。 また、FIAMの設定では文字の色、ボタンのアクションなど、ある程度カスタマイズすることも可能になっています。

f:id:sgrksmt:20191025105732p:plain:w600

標準で使う場合の難点

しかし、標準のまま活用するとUIに関して次のような問題点がでてきます。

  • 文字の大きさ、フォントを変更することができない
  • 文字や画像の並びを変更することができない
  • 一部の表示形式だとボタンの背景色を変更できない
  • ボタンの大きさを変更することができない


表示用UIを標準で用意してくれているのは大変うれしいのですが、どうしてもサービスのUIと比べると浮いてしまうことと、カスタマイズ可能な範囲が狭いというのが難点になってきます。
もしかしたら読者の中にはその標準UIが微妙でFIAMの使用を断念してしまった方も居るのではないでしょうか。 というわけで、次項からカスタムUIを適応していく方法を紹介します。 (※ちなみにKomercoはiOSアプリのみ配信している関係で以降の内容はiOSでのカスタムUIの適応の話になります。

カスタムUIを適応する

公式のドキュメントにあるこちらの内容を紐解きながら解説していきます。

Podfileの編集

Podfileを開いて、Firebase/InAppMessagingDisplayFirebase/InAppMessagingに変更してインストールし直します。

- pod 'Firebase/InAppMessagingDisplay'+ pod `Firebase/InAppMessaging'


CustomMessageDisplayComponentクラスの作成

次に、InAppMessagingDisplayプロトコルに適合したCustomMessageDisplayComponentクラスを作成します。
displayMessage(_:displayDelegate:)メソッドを実装し、引数で渡ってくるmessageのデータを判別し「Card」「Modal」「Banner」「Image Only」それぞれ表示するUIを出し分けるようにします。 以下はCardタイプの場合に、サービス内で使用しているPopupクラスを活用して表示する例を示しています。

import Firebase
import Foundation

privateenumIAMDisplay {
    case unknown
    case card(InAppMessagingCardDisplay)
    case modal(InAppMessagingModalDisplay)
    case banner(InAppMessagingBannerDisplay)
    case imageOnly(InAppMessagingImageOnlyDisplay)

    init(_ messageForDisplay:InAppMessagingDisplayMessage) {
        switch messageForDisplay.type {
        case .card:self= (messageForDisplay as? InAppMessagingCardDisplay).map { .card($0) } ?? .unknown
        case .modal:self= (messageForDisplay as? InAppMessagingModalDisplay).map { .modal($0) } ?? .unknown
        case .banner:self= (messageForDisplay as? InAppMessagingBannerDisplay).map { .banner($0) } ?? .unknown
        case .imageOnly:self= (messageForDisplay as? InAppMessagingImageOnlyDisplay).map { .imageOnly($0) } ?? .unknown
        @unknowndefault:self= .unknown
        }
    }
}

finalclassCustomMessageDisplayComponent:InAppMessagingDisplay {
    funcdisplayMessage(_ messageForDisplay:InAppMessagingDisplayMessage, displayDelegate:InAppMessagingDisplayDelegate) {
        DispatchQueue.main.async {
            displayDelegate.impressionDetected?(for:messageForDisplay) // ★switch IAMDisplay(messageForDisplay) {
            caselet .card(card):
                Popup.show(
                    title:card.title,
                    body:card.body,
                    image:URL(string:card.portraitImageData.imageURL),
                    primaryButton:card.primaryActionButton.buttonText,
                    secondaryButton:card.secondaryActionButton?.buttonText,
                    buttonActionHandler: { button inswitch button {
                            case .primary:
                                print(card.primaryActionURL)
                                // URLを開く処理case .secondary:
                                print(card.secondaryActionURL)
                                // URLを開く処理
                        }
                    }   
                )
            caselet .modal(modal):// Modalタイプの場合の表示実装
            }
        }
    }
}



messageForDisplay.typeを見ることでどの表示形式か判定できるのでそれを活用し、更にそれぞれの表示形式で扱うクラスにダウンキャストして使用します。 各種表示形式でアクセスすることが出来る情報(プロパティは次のようになっています)

f:id:sgrksmt:20191025105720p:plain:w600


また、カスタムUIを表示する際はで示しているdisplayDelegate.impressionDetected?(for:)メソッドを呼び出す必要があります。 例で示しているPopupクラスはUI含めてご自身で実装してください。

messageDisplayComponentを指定する

CustomMessageDisplayComponentを実装できたら、In-App Messagingに適応します。 次のコードをFirebaseApp.configure()の呼び出し以降で設定します。可能であればこの呼出の直後に次のコードを書くと良いでしょう。

InAppMessaging.inAppMessaging().messageDisplayComponent = CustomMessageDisplayComponent()


これで、In-App Messagingの配信をアプリが受け取った際にカスタムUIで表示することが可能になります。

カスタムUIを使う際のルールを決めておく

Komercoでは、カスタムUI側でフォントの色などを指定して運用するようにしたため、 FIAMでのメッセージ配信の設定画面では色に関する設定はしない(無視する)ようにしています。

f:id:sgrksmt:20191025105735p:plain:w600

Before/After

ここまで実装ができると、同じ設定でも変更前後でこのようにUIが変わります。

Before After
f:id:sgrksmt:20191025105659p:plain:w300f:id:sgrksmt:20191025105650p:plain:w300

デバッグがしやすくなるTips

ちょっとしたTipsですが、FIAMは指定したアナリティクスイベントを発火させないと表示されないですが、次のようにコードでメッセージ配信設定したアナリティクスイベントの名前を指定してあげると即座に表示させることができます。

InAppMessaging.inAppMessaging().triggerEvent("show_product_detail")



- 参考: In-App-Messagingのキャンペーンを手動で呼び出せるようになった

メッセージ表示やボタンタップ時のイベントをアナリティクスに別途送りたい

カスタムUIでメッセージ表示をしたり、ボタンを押した時に別途アナリティクスイベントを収集する場合は、messageForDisplay変数からキャンペーン名を取得することができるため、これを活用するとどのキャンペーンでのイベント発火だったのか判断することができます。

f:id:sgrksmt:20191025105741p:plain

letcampaignName= messageForDisplay.campaignInfo.campaignName // 設定したキャンペーン名が取得できる
Logger.postLog(.showInAppMessaging(campaignName:campaignName))


注意点

displayDelegate.impressionDetectedの呼び忘れに注意

displayDelegate.impressionDetected?(for:)メソッドを呼び忘れすと、SDK側でユーザーが見たかどうかの集計が行われないため、条件に設定しているアナリティクスイベントが発火するたび何度もユーザーに表示されてしまいます。

ポップアップの表示制御が必要なら別途実装する

もしカスタムUIで表示するポップアップのクラスをサービス内の別の場所で使用していたり、FIAMの制御外でも非同期通信を経て何かしら表示する可能性があったりする場合は自信で重複して表示されないように制御ロジックを実装しておきましょう。

funcdisplayMessage(_ messageForDisplay:InAppMessagingDisplayMessage, displayDelegate:InAppMessagingDisplayDelegate) {
    DispatchQueue.main.async {
        if Popup.isAlreadyShown { return }
        // カスタムUI表示処理を続行
    }
}


カスタムUIを適応すると標準UIは使えなくなる

この方法でカスタムUIを適応した場合、標準UIを呼び出すことはできなくなります。 例えば、Card、Modalタイプであれば用意したUIを使い、Banner、Image Onlyタイプであれば標準のUIを呼び出す、といったことは不可能です。
もし標準のUIを実装したい場合は、「SDK側」のソースコードを参考に作成するか、そのタイプの使用を諦めるのも一つの手になります。

KomercoではBannerタイプ、Image Onlyタイプの配信は行わない事にしたので表示実装はしていません。

まとめ

カスタムUIを適応してあげることで、よりサービスに馴染んだ形でメッセージ配信を行うことができるのでよりプロモーションに活かせるようになると思います。
標準UIを敬遠して使ってなかった方、そこがネックでFirebaseを使っているにも関わらず自前でポップアップの配信機能を実装をしていた方、これを機にカスタムUIを適応してFIAMを使ってみてはいかがでしょうか。

クックパッド採用説明会「クックパッドはサービスの作り手を採用したいんです。」を開催しました!

$
0
0

こんにちは、メディアプロダクト開発部の長田(おさだ)です。

クックパッドは、エンジニア、デザイナーを絶賛大募集しています。先日「クックパッドはサービスの作り手を採用したいんです。」というイベントを開催したのでその時の様子をお伝えします。
https://cookpad.connpass.com/event/149581/

開始

まずはクックパッドの紹介から始まりました。

f:id:osadake212:20191107175307j:plain

ご存知ない方もいらっしゃるかと思うのですが、クックパッドではレシピサービス以外にも、スマートキッチンサービスの OiCyクッキング Live 配信が視聴できる cookpadLiveおいしい食べ方を学習できる たべドリ生鮮食品 EC プラットフォームの cookpad mart料理が楽しくなるマルシェアプリ komercoなど、たくさんのサービスを開発しています。

また、レシピサービスは全世界に展開しており、2019年10月末時点で73カ国/地域、30言語に対応しています。全世界の月間利用者数は1億人近く、投稿されているレシピ数は590万品を突破しています。

完成されたサービスに見えるとよく言われるクックパッドですが、実はやりたいことの1%もできていないのが現状です。
「毎日の料理を楽しくする」ことで世の中をよくしていきたい我々は、レシピサービスにとどまることなく、 いくつもの新しい機能や新規事業を立ち上げ拡大している最中です。

このイベントは、そんなクックパッドで一緒にサービスを作ってくれる仲間を探すために開催されました。
発表のセクションでは、クックパッドのサービス開発の様子をクックパッドマート、 cookpadLive の開発を通して紹介しました。

発表① クックパッドマート ディレクターのいない◯◯な開発スタイル

クックパッドマートからは、長野 佳子(@naganyo)・米田 哲丈(@tyoneda)による、開発スタイルについての発表を行いました。

f:id:osadake212:20191107175316j:plain

クックパッドマートでは「なにをつくるか」をどのように決めるのか、またそれをどのように実現しているのかについて紹介しました。

「なにをつくるか」は KGI ブレークダウンで決まるのがベースになります。
さらにそれだけではなく、サービスに対する「気づき」を得る機会を増やし様々な観点からサービスを俯瞰することで、なにをつくるかが日々生まれています。
生まれてきた「なにをつくるか」は事業的な観点から絞り込まれ、開発・リリースされていきます。

また、「どうやってつくるか」についてはディレクターがいないので、全員で起案し、デザインも開発も同時に進めています。
アイデアの可視化、開発しながら機能をブラッシュアップ、リリース後にチューニングするなど、職種にとらわれない「サービスの作り手」が集まっています。

詳しくはこちらの資料をご覧ください。
https://speakerdeck.com/tyoneda/20191030-kutukupatudomato-deirekutafalseinaioonakai-fa-sutairu

発表② cookpadLive 短期間で行うサービス開発術

cookpadLive からは、若月 啓聡(@puzzeljp)・長田 卓哉(osadake212)による、こちらも開発スタイルについての発表を行いました。

f:id:osadake212:20191107175325j:plain

cookpadLive では、短期間でサービスを実現するために、デザイナー・エンジニアがどのような動きをしているのかをそれぞれの視点で工夫していることを紹介しました。

デザイナーはビジネス要件(納期)も考慮しながらエンジニアがより高速に開発ができる取り組みをしています。
取り外し可能なデザインを作成することで、サービスの価値を損なわないことを担保しながら、エンジニアの実装の都合に合わせて柔軟にデザインを変更することが可能になります。

エンジニアは、設計や実装だけではなく役割を越えて、アイデアだし・仕様・画面遷移の検討・オペレーションの検討を行い、サービス開発に積極的に関わっています。
職種にとらわれず、全員がサービスの成長に対してできることを実行しながら開発を進めています。

詳しくはこちらの資料をご覧ください。
https://speakerdeck.com/osadake212/cookpadlive-duan-qi-jian-dexing-usabisukai-fa-shu

Q&Aパネルディスカッション

このセクションでは、イベント開始前に受け付けていた質問や、発表を聞いて気になった質問に対して社員が答える形式で、サービス開発や社内の様子等についてディスカッションしていきました。

f:id:osadake212:20191107175329j:plain

ありがたいことに、タイムテーブルの40分では答えきれないくらいの質問をいただきました。
質問内容は、クックパッドマート・cookpadLive のサービスについて、クックパッド全体のサービス開発チームの雰囲気について、入社してからの働き方についてなど、サービス開発だけではなく幅広くご質問をいただきました。

個別面談・懇親会

f:id:osadake212:20191107175311j:plain

「イベントでいきなり個別面談ってなに?」となりそうですが、本イベントでは希望者の方に、エンジニア採用責任者の一人である勝間(買物事業部 副部長)、デザイナー採用責任者の一人である倉光(デザイン戦略部 部長)と個別に一対一でお話ができる場を設けました。
一人10分弱の面談枠をそれぞれ4つ用意していたのですが、いったい何人の方に個別面談していただけるのだろうか・・・と不安だったのですが、なんと全ての枠が埋まり8名の方と面談することができました。

懇親会では、弊社のサービス開発に関わっているエンジニア、デザイナー6名がそれぞれテーブルにわかれ、ざっくばらんに参加者とお話させていただきました。
私、長田のテーブルでは、クックパッドの開発基盤の話から、サービスのグロースの話、チーム・プロジェクトマネジメントの話など、本当にたくさんのことについて話すことができ、弊社の様子をより伝えることができたのではないかと感じます。

まとめ

冒頭でも触れたように、クックパッドでは一緒にサービスを作ってくれる仲間を募集しています。
現在募集中のポジションはこちらからご確認いただけます。
https://info.cookpad.com/careers/jobs/

「まずは話だけでも聞いてみようか」というのも大歓迎なので気軽にお声がけいただけると幸いです。
recruit@cookpad.com

サービス特性にあった検索システムの設計戦略

$
0
0

こんにちは!研究開発部ソフトウェアエンジニアの林田千瑛(@chie8842)です。あまりたくさん飲めないけど日本酒が好きです。 クックパッドが提供するサービスの検索や推薦機能の構築・改善を行っています。

本稿では、クックパッド本体の検索改善や推薦システム構築の傍らで、新規サービスであるクックパッドマート向けの検索システムをつくったので、その際の設計や精度改善の工夫について書きます。

新規サービスクックパッドマートと検索

クックパッドマートは、生鮮食品に特化したECサービスで、ステーションと呼ばれる場所に購入した食品を届けてくれるという特徴をもっています。2018年夏にサービス開始して以来順調にユーザ数を伸ばしています。中でも商品検索機能は、クックパッドマートの追加機能として9月にリリースしました。

検索システムの要件

プロダクトチームの当初の要件は以下のとおりでした。

  • まずは 1ヶ月でリリースしたい
  • 最初は商品検索機能を提供したいが、その後GISデータを用いた食品を受け取るステーション検索などが必要になる可能性がある
  • 商品検索は、UI/UXの要件上、絞り込み検索などではなく単純なキーワード検索がしたい
  • 商品インデックスのリアルタイム更新はあまり重要でない

また、データを眺めたり実際にサービスを使ったりする中で以下のようなことを予想できました。

  • インデックスサイズについて

    • 現状のインデックスサイズはそれほど大きくない(現在1G程度)
    • サービスの成長率が高く、将来的に商品数が増えることでインデックス化するデータは格段に増える可能性はある。しかし、配送機能をもつというサービスの特性上、配送エリアごとにインデックスを分けるといったことでインデックスサイズの上限は抑えられる
  • 検索精度のチューニングについて

    • 検索の使われ方と商品数を考慮すると、適合率よりユーザの目的にヒットしうる商品の再現率を高めることを重要視した方がよさそう
    • サービス側のキャンペーンなどの施策の追加が想定される。そのため今後インデックスのスキーマ変更やクエリのチューニングを行いやすいようにしたい
  • プロダクトチームの体制について

    • プロダクトチームはスピードを重視しており、メンテナンスコストは低い方がいい

上記を考慮して、最初のリリースに向けては以下の設計方針ですすめることにしました。

  • 検索インデックスやクエリは一旦今ある情報をもとに設計して一部のユーザにリリースし、実際の使い勝手を見ながらチューニングしつつ利用者を広げる(オフラインテストなどはあまりかっちりやらない)
  • 基盤設計は慎重に行い、今後のシステムのスケールやメンテナンスを行いやすいようにする

検索基盤の設計

検索サーバにはElasticsearchを利用します。 クックパッドはインフラ環境にAWSを利用しており、その上にElasticsearchサーバをデプロイする方法としては以下の3つが考えられます。

  1. Amazon Elasticsearch Service(以降AES)を利用する方法
  2. EC2上に構築する方法
  3. ECSクラスタ上に構築する方法

1.のAmazon Elasticsearch Serviceを利用するか2.3.の方法で自前でElasticsearchを構築するかという判断がまずあります。

AESはクックパッドの他のサービスでも一部取り入れられているという背景もあり、最初はAESを使うのがよいのではないかという声がありました。しかし、マネージドサービスは、システム管理の負荷を軽減できるというメリットがある一方で、そのマネージドサービスが提供する機能は大なり小なり限定されることを考慮する必要があります。よってマネージドサービスで提供される機能がサービスが必要とする機能範囲をカバーできるかどうかを見定める必要があります。

今回の要件にAESが合致するかどうかは検証を行い、結果的に利用しないことに決めました。 ボトルネックとなったのは以下の点です。

  • 既存インデックス上のAnalyzerの設定変更ができない(設定の異なる新規インデックスを作成した上でaliasを切り替えることで代用はできるがオペレーションが煩雑化する1
  • ユーザ辞書やシノニム(同義語)辞書をファイルで指定できない
  • AESはサポートされるプラグインが限られており、中でも日本語のTokenizerとしてkuromoji_tokenizerは利用できるが、素のElasticsearchであれば利用できるNEologd, UniDicといった別の辞書を用いるTokenizerを提供するプラグインが利用できない2
  • 出力できるログが限定的である。(エラーログとスローログしか取得できない)

クックパッドマートの商品検索は、例えば「じゃがいも」と入力したら「メークイン」や「ジャガイモ」でマッチする商品も検索結果に出したいといったように、日本語の辞書やシノニムといったAnalyzerのチューニングが検索精度に大きく影響するタイプです。 日本語を使わない場合やTwitterやInstagramで見られるようなシノニムの重要度が高くないタイプのキーワード検索、geolocation検索であればAESで提供される機能で十分な場合があると思いますが、今回の要件には合致しないという判断になりました。

さらに残る 2.のEC2と3.のECSについて検討を行いました。Elasticsearchはインデックスを保存するという意味である種のDBの機能を持ちますが、DBは基本的にECSなどのコンテナオーケストレーション環境にデプロイする例はあまりないと思います。なぜかというと、Elasticsearchやその他のDBはクラスタネットワーク内でノードディスカバリを行い、データの永続化やレプリケーションを行うことでデータの可用性とスケーラビリティ担保するクラスタ構成機能を持ちますが、エフェメラルな環境としてアプリのデプロイを行うことを目的として発展したコンテナ環境はこうしたことを前提として作られていないからです。(できないとは言っていないです。)

上記を考慮すると、基本的にはデプロイ先としてEC2を選ぶべきです。しかし、今回のケースは、以下のことがわかっていました。

  • (将来を考慮しても)1サーバ上でデータを持てるくらいインデックスデータが小さく保てる
  • インデックスのリアルタイムな更新が必要ないため、障害時のデータ保証を考慮したデータの永続化が比較的簡素で済む

この場合、複数台によるクラスタを組まずとも、Elasticsearchを単なる1ノードの検索アプリケーションと見てデータは外部ストレージ上に永続化することで、コンテナオーケストレーション環境上で他のRailsなどのアプリケーションと同じように可用性とリクエストに対するスケーラビリティを担保したデプロイができます。

EC2上にデプロイすることになると、物理サーバによるクラスタのメンテナンスコストがかかってしまうため、S3上でインデックスデータを管理し、ECS上にデプロイすることにしました。

f:id:chie8842:20191118113708p:plain

クックパッドマートは、他機能もECS上で動作しています。検索サーバも管理を同じ環境にすることでDockerのデプロイに慣れているメンバであれば検索サーバのデプロイ学習コストを小さくとどめることができました。

精度のチューニング

上述したとおり、今回の検索サーバは、エリア内の生鮮食品の検索という特性からそもそも適合するアイテム数が少なく、それらをすべて検索結果に出すことが大切になります。一方で明らかに関係のないアイテムが混じりすぎるのも検索体験的によくありません。今回のキーワード検索においてこのバランスを保つために開発当初から今までに行った主な改善を書いておきます。

商品検索時に利用するテキストの選択

検索のインデックスはサービスのデータベース上にある商品データから作成し、それに対して検索クエリを実行します。検索対象とすべきテキストデータとしては、主に以下がありました。

  • 商品タイトル
  • 商品の説明
  • カテゴリ情報
  • ショップの名前

当初は重みをつけた上で、上記のデータをすべて使うことを考えましたが、現在は商品検索には商品タイトル、カテゴリ情報、ショップ名を利用しています。 理由は以下のとおりです。

商品の説明を利用しないことにした理由

「商品の説明」のデータでは、例えば野菜の商品の説明で、「豚肉と炒めるとおいしいです」といった文章が出てくることがあります。こうした場合、「豚肉」と検索したときにこの野菜の商品がヒットしてしまいます。このような要因によって検索体験が下がる影響が大きいだろうという判断をしました。

カテゴリ情報を利用する理由

クックパッドマートには、「とり皮」、「砂肝」など、焼き鳥の串の商品があります。(ちなみにおいしかったので買ってみてください。) しかし商品タイトルのみに対して検索を行う場合、「鶏肉」というクエリに対してこれらの焼き鳥はヒットしません。カテゴリ情報を利用すると、これらの焼き鳥の商品に対して、「鶏肉」や「肉」といったテキスト情報を利用することができるようになり、「鶏肉」の検索結果に焼き鳥の商品をヒットさせることができるようになりました。

ショップの名前を利用する理由

ショップの名前はリリース時には商品検索のためのインデックスとしては入れていませんでした。しかしながらリリース後のユーザのクエリをみると、ショップ名で商品を検索しているユーザが一定数いることが判明しました。そこでショップ名からも商品を検索できるようにしました。

Analyzerのチューニング

検索結果の再現率を上げるためには、Analyzerのチューニングも重要です。チューニングでは、主に以下のことを行いました。

Tokenizerにkuromoji-ipadic-neologdとNGram Tokenizerを併用する

Elasticsearchにおける日本語に特化したTokenizerには、Elasticsearch本体に組み込まれているkuromoji_tokenizer(辞書はIPADic)の他に、形態素解析エンジンには同じkuromojiを利用してNEologd、UniDicといった別の辞書を利用するものや、最近作られたSudachiという形態素解析エンジンを利用するものがあります。また、辞書にない単語をとってくる方法として、NGram Tokenizerなどがあります。今回は、語彙数が多いkuromoji_ipadic_neologd TokenizerとNGram Tokenizerを重み付けして併用することで、できるだけ多く適合するアイテムをヒットさせることを目指しました。 なお、辞書としてUniDicを利用した方が細かい単位で単語を取得できる可能性もありますが、今回のようなヒットするアイテム数が少ないケースでは、検索結果のランク順くらいしか違いがでない、かつランク順がそれほど重要でないだろうと予想されるため試していません。

シノニムの重要性

上で例を出したとおり、じゃがいもを購入したいとき、検索結果には「メークイン」や「男爵いも」も出した方がいいでしょう。 このようにクックパッドマートでは、シノニムを考慮した検索が重要でした。 クックパッド本体で利用している辞書資源から必要なデータを取得できたので、最初のリリース後にシノニム情報を追加しました。

チューニング結果

クックパッドマートの検索精度は、リリース後にプロダクト側からの意見をヒアリングしつつ改善しました。 サービス自体がまだ若く、A/Bテストを行うような基盤はないため、別期間での比較になってしまいますが、リリース直後(チューニング前)では検索画面から目的の商品に出会えた確率(検索画面から商品ページへの遷移率と検索画面からカートイン率の合計)が 61%だったのが、チューニング後には 84%まで増加することができました。

今後の課題

検索システムは一度作って終わりというわけにはいきません。今後もユーザやプロダクトの成長に合わせて精度の改善を行う必要があります。また、今後は今の商品検索以外の検索機能が必要となる可能性もあります。 サービスの成長にあわせて検索の精度改善や機能追加を進めていけるとよいと思います。

最後に

検索に限らず、よいシステムをつくるには必要な機能を決めるためにUI・UXを考えるデザイナ、プロダクトオーナの協力が大切です。今回の検索システム構築においては、ryo-katsumaをはじめとしたクックパッドマートのプロダクトチームが明確な検索ストーリーを提示してくれ、また積極的に検索システムを使って実際に使って課題や要望を伝えてくれたため、スムーズに要件を固めて改善につなげることができました。 また、検索精度のチューニングについては、同じ研究開発部の@takahi-iが親身にレビューをしてくれました。

このようにサービスのドメイン知識と技術知識をもつメンバ同士で連携できたことで、スピード感をもってよい検索システムを作ることができたと思います。

Amazon Athena を使ったセキュリティログ検索基盤の構築

$
0
0

こんにちは。技術部セキュリティグループの水谷(@m_mizutani)です。最近はFGOで一番好きな話がアニメ化され、毎週感涙に咽びながら視聴しています。

TL;DR

  • これまでセキュリティログ検索にGraylogを使っていたが、主に費用対効果の改善のため新しいセキュリティログ検索基盤を検討した
  • 自分たちの要件を整理し、Amazon Athenaを利用した独自のセキュリティログ検索基盤を構築した
  • まだ完全に移行はできていないが対象ログを1ヶ月間分(約7.5TB1)保持してもコストは1/10以下である3万円に収まる見込み

はじめに

セキュリティグループでは日頃、社内ネットワークやPC環境、クラウドサービスに関連するセキュリティアラートに対応するセキュリティ監視業務を継続しておこなっています。アラートに対応する時に頼りになるのはやはり様々なサービスやシステムのログで、そのアラートに関連したログを調べることにより誰が、いつ、どのようなコンテキストでそのアラートに関わったのかということを知ることができ、アラートのリスク評価において大きな役割を担っています。

一方でこのようなセキュリティ監視のためのログは大量になるため検索をできるようにするための基盤を整えるのも簡単ではありません。クックパッドのセキュリティグループではこれまでGraylogを使ってセキュリティ監視のためのログ検索基盤を構築・運用してきましたが、運用していく中でいくつかの課題が浮き彫りになったため、現在はAmazon Athenaを使ったログ検索基盤への移行を進めています。本記事では開発・移行を進めている新セキュリティログ検索基盤について解説します。

Graylogを利用する際の課題

過去に本ブログでも記事として紹介しましたが、クックパッドでは様々なログをセキュリティ監視のために取り込むことによって、アラート発生時に横断的な調査ができるようになっています。今日の企業における活動というのは1つの情報システムやサービスだけで完結するということはほとんどなく、複数のシステム・サービスをまたがって遂行されます。そのためアラートがあったときに全体像の把握をするためには複数種類のログを横断的に検索できるGraylogは非常に有用でした。しかし運用を続ける中で以下のような課題もでてきました。

  1. 弾力性があまり高くない:クックパッドで利用しているGraylogのバックエンドはAmazon Elasticsearch Serviceを利用しており、データノードをスケールイン・スケールアウトする機能が備わっており、データノードの負荷増大やディスク空き容量の減少に対して簡単に対応できます。ただ、スケールイン・スケールアウトは瞬時に実施できるわけではなく、経験則としては数分〜数十分の時間を要します。そのため、急なログ流量の増加のスムーズに対応するのは難しく、ログの取りこぼしなどが発生する可能性と向き合う必要があります。
  2. 使用頻度に対してコストが高い:弾力性の問題に対して余裕を持ったリソースを常時運用するという方法もありますが、今度はコストが問題になってきます。Graylogのバックエンドとして利用するElasticsearchはそれなりにリソースが必要となるため、利用するインスタンスの性能やディスクの容量を大きくとらなくてはなりません。現在クックパッドで運用しているGraylogでは一日あたり合計250GBのログを受け取り、ログの種類に応じて保管期間を最大1ヶ月、流量が多いものは1週間程度に収めることで、およそ月額40万円強、年間500万円ほどのコストをかけています。Graylogはインタラクティブかつ高速に検索が可能であるため、業務時間中は常にセキュリティ分析のために検索をしているような状況であればコストに見合ったメリットがあると考えられます。しかし、(幸いにも)クックパッド内で発生するセキュリティアラートは平均して日に2〜3回程度であるため、そのために年間500万円もかけるのはあまり効率的ではない、と考えています。
  3. Elasticsearchの運用が辛い:先述したとおりクックパッドで利用しているGraylogのバックエンドはAmazon Elasticsearch Serviceなので、自らでインスタンスを立てて運用するのに比べると比較的楽ですが、それでも負担はそれなりにあります。先述したとおりかなりの流量のログを投入するため、流量が想定を超えるとログの投入だけでなく検索にも影響がでます。また、様々な種類のログを投入するため、ログのスキーマや内容によって過大な負荷がかかるということがしばしばありました。そのため、それほど流量が多くないログでも投入に慎重にならざるをえず、またログの種類によってはそもそも取り込むのが難しいというようなこともありました。

これらの問題を解決するために、AWSのオブジェクトストレージであるS3をベースにした検索基盤が作れないか、ということをかれこれ1年くらい模索していました。

「セキュリティ監視」におけるログ検索の要件

具体的にどのようなアーキテクチャを採用したのかを説明するために、セキュリティ監視という業務をする上でのログ検索の要件を整理します。具体的には以下の5点になります。

  1. 複数種類のログに対してスキーマに依存しない検索ができる
  2. ニアリアルタイムで検索ができる
  3. 単語を識別した検索ができる
  4. ログの投入時に容易にかつ迅速にスケールアウト・スケールインが可能である
  5. 全体的な費用負担を減らす

Graylogはこれらの要件のうち1、2、3は十分に満たしていましたが、4、5の部分が期待する水準ではなかったと言えます。以下、各要件を詳しく解説します。

(要件1) 複数種類のログに対してスキーマに依存しない検索ができる

セキュリティ監視という業務の特性による最も重要な要件がこれだと考えています。通常、データ分析をしたい場合はデータごとに決まっているスキーマを理解し、そのスキーマに合わせたクエリを発行すると思います。一方でセキュリティ監視においては「あるキーワード(IPアドレス、ドメイン名、ユーザ名など)がどのフィールドに含まれるかを気にせず一気に検索する」というユースケースが圧倒的に多くなります。ログを絞り込んでいく上でフィールドを指定する必要はありますが、まず全文検索のような形でログを検索してどういった種類のログがどういった傾向で出現しているかという全体像を把握することが大切になってきます。

ログの種類が2〜3種類のみであればまだ人間がスキーマを覚えてクエリを作る事ができるかもしれませんが、数十種類になってくると人間がこれを覚えてクエリを作るのはあまり現実的ではありません。また、コード上でスキーマを管理してクエリを自動生成するというような方法も考えられますが、今度はメンテナンスが面倒になってきます(3rd partyサービスのログのスキーマがいきなり変わることはしばしばあります)そのため、ログのスキーマを全く気にせずに "10.1.2.3"というキーワードが含まれるログをバシッと検索できる仕組みが必要になります。

(要件2) ニアリアルタイムで検索ができる

セキュリティアラートが発生した場合、なるべく迅速に分析をできる状態になっていることが望ましいです。具体的なリアルタイム性(どのくらいの遅延を許容できるか)については一般的なManaged Security Service(MSS)のSLAが参考になります。いくつかのMSSではおよそ15分以内にアラートに関する第一報を報告するよう定められています。

これを基準とした場合、分析などに5〜10分ほどかかることを考えるとログが到着してからできれば5分以内、遅くても10分以内には分析ができる状態になっているのが望ましいと言えるでしょう。完全なリアルタイム性を実現する必要はありませんが、到着したログが逐次検索可能になるようなパイプラインは必要かと考えられます。

(要件3) 単語境界を識別した検索ができる

これは非常に地味ですが、意外と大切な要件だと考えています。検索する際に "10.1.2.3"を指定したら、 "110.1.2.3"や "10.1.2.30"を含む全てのログではなく、"10.1.2.3"のみが検索結果に出てくるようにするべきだと考えています。これはJSONなどの構造化データにおいて1つのフィールドに1つの単語しか入らない、ということが定まっているログについてはあまり悩む必要がありません。しかしsyslog由来のログやアプリケーションから出力されるログは自然言語のように記述されたログが頻出するため、ログ全文に対する単純な文字列マッチでは実現が難しいです。

単純な文字列マッチだけでなく、正規表現などを利用して前後の区切り文字などを排除するという方法もあることにはあります。ただその場合だと利用する人間がオリジナルのログの構文や構造などを強く意識してクエリを作成しなくてはならず、さらにトライ&エラーが発生するのを前提としてしまうため、あまり望ましくありません。さらに人間だけでなく別のシステムから自動で検索をかけたいと思った場合にトライ&エラーは期待できないため、一発で期待する検索ができることが望ましいです。

(要件4) ログの投入時に容易にかつ迅速にスケールアウト・スケールインが可能である

Graylog運用における課題1で述べたとおりですが、ログの種類が増えたり、ログの種類が同じでも流量が増えた際に速やかにスケールアウトし、負荷が低くなったらスケールインできる仕組みが求められます。場合によってはかなり頻繁にログの種類が追加されるということもあるため、その都度事前に流量や負荷の度合いを計算して準備をする、といった煩わしさがないような仕組みが望ましいと言えます。

(要件5) 全体的な費用負担を減らす

これもすでにGraylog運用における課題の2で述べたとおりですが、なるべく費用の負担が小さいに越したことはありません。特にセキュリティ監視という領域はいざというときに事業を守るために必要ではあるものの、お金をかけるほどビジネスが加速する、というものではないので費用を抑えられるに越したことはありません。

ログ検索のためのアプローチ

ここまでで説明させてもらった要件を満たす基盤を構築するため、以下の2つの方針を考えました。

(1) ログはAWS S3に保存して検索はAthenaを利用する

AWSのS3はオンラインストレージ(例えばEC2にアタッチされたEBSなど)と比較して非常に安価に巨大なデータを保持することができます。課題の部分でも説明させてもらったとおり、1日中高頻度に検索をするというシステムには向かないかもしれませんが、低頻度にアクセスするデータを保持しておくには最適な選択肢の一つだと考えられます。これによって大量のログデータを保持する場合でも全体的な費用負担を抑えることができます。

S3に保存したデータから必要となるログを検索するには、自分でSDKを使いS3からオブジェクトをダウンロードするようなコードを書く、S3 Selectを使う、Redshift Spectrumを使う、などいくつかの選択肢が用意されていますが、今回はクエリは低頻度であること+ある程度複雑なクエリが発生することなどを踏まえ、Amazon Athenaを利用することにしました。S3 selectは基本的に文字列のフィルタのみになるので、複雑な条件を指定するような検索には不向きになります。また、Redshift Spectrumは複雑なクエリを扱えますがクラスタを構築して常時稼働させるのが前提となってしまうため、低頻度にしか利用しないという今回のユースケースでは過剰に料金がかかってしまいます。Amazon Athenaは通常のSQLと同等のクエリが記述でき、さらにクエリによって読み取ったデータの量に応じて課金されるため、今回のユースケースに適していました。

(2) ログの投入時にLambdaを利用してインデックスを作成する

S3 + Athenaを使うことで費用面に関する問題は解決できますが、一方でログをそのまま保存していただけでは「スキーマに依存しない検索ができる」という課題を解決できません。AthenaはSQL形式のクエリを発行して指定したフィールドの値、あるいは集計結果を取得するため、そのままAthenaで横断的なキーワード検索をしようとすると全てのフィールドに対して検索をかけるという無茶が必要になってしまいます。

そこで、ログを保存用S3バケットに投入する際に元のログだけではなく、ログからインデックスを作成し、それをAthenaで検索できるようにします。全てのログを投入前に一度パースして辞書型に変換し、そのキーと値をもとにインデックスを作成します。例えばJSON形式で {"src_addr":"192.168.0.1", "dst_addr": "10.1.2.3"}というログがあったら、("src_addr", "192.168.0.1"), ("dst_addr", "10.1.2.3")という2つのレコードを作成し、それぞれにS3オブジェクトのIDや行番号などを付与します。これをインデックスとしてAthenaで扱うテーブルの1つとして作成します(Indexテーブル)。さらにログ本文とオブジェクトのID、行番号を含むテーブルも作成します(Messageテーブル)。イメージとして以下のようなテーブルをそれぞれ作成します。Object IDはただのカウンタであり、行番号はそのオブジェクト内で何番目にでてくるログなのかを示しています。最終的にはMessageテーブルの「ログメッセージ本文」が検索結果として返され、セキュリティ分析をする際に参照することになります。

f:id:mztnex:20191118230126p:plain

このようなテーブルを作成することで、全てのログから特定の値をもつフィールドを検索したり、あるいは特定のフィールドにのみ含まれる値をIndexテーブルから探すことができます。この結果をMessageテーブルと結合することで、特定の値を含むログをスキーマに依存せず探し出すことができるようになり、スキーマに依存しない検索が実現できます。

また、Lambdaを使ってインデックスの作成をすることで容易にスケールイン・アウトができるようになります。Lambdaは特別な設定をすることなく、タスクの増減に応じて同時実行数がシームレスに増えていきます。そのためログの流量が増えたとしても事前にスケールアウトするなどの準備も必要なく、AutoScalingが間に合わず処理の遅延やログの消失が発生する、というようなことも回避できます。また、ログ発生のイベントを受け取りLambdaで処理してS3に投入するというパイプラインによって、検索可能になるまでの遅延もおよそ2〜3分程度に抑えることができ、セキュリティアラートが発生したあとログ検索ができなくて待たされるという問題が解決できます。

ログ検索基盤の設計と実装

f:id:mztnex:20191118230147p:plain

アプローチの節で説明したものを実装したアーキテクチャが上の図になります。実装名は Minerva(ミネルヴァ) と呼んでいます2。今回は原則としてサーバーレスで実装しており、点線内のS3バケット以外はCloudFormationでデプロイしています。これによって、バージョン変更時にstaging用の環境を作りたいと思ったら新しい設定ファイルを用意すればコマンド一発で真新しい環境を作ったり逆に不要になった環境を削除できます。Web UIについてもECSのspot instance上(参考記事)で動かしています。

このアーキテクチャは主に (1)ログの投入、(2)パーティションの作成、(3)ログのマージ、(4)ログの検索、という4つのパートに別れて構成されています3。それぞれ解説したいと思います。

f:id:mztnex:20191118230159p:plain

(パート1) ログの投入

ここまでの説明ではわかりやすさのために「ログが生成されたらそのままインデックスが生成される」というような書き方をしていましたが、実際にはログが本来保存されるS3に投入される → そのイベントをSNS+SQSで流してLambdaを起動する → Lambdaが対象のオブジェクトをダウンロード&インデックス作成をした後にMinerva用のS3バケットに投入し直す、ということをやっています。これはログの保全という観点から、まずはログをS3バケットに格納して保全できる状態にし、そこからいろいろな処理にパイプラインをつなげて可用性を高める、という考え方に基づくものです。これによってインデックス作成の処理が失敗したとしても、元のS3バケットを再度参照することで容易にリトライが可能になります。

先述の通り、処理にはLambdaを使うことでエンジニアが気にしなくてもスケールイン・アウトが可能となっています。実際には事故を防ぐために最大同時実行数を制限して運用していますが、最大同時実行数を多めに設定してもそれ自体に課金はされないので、計算した分だけの料金ですみます。

またログ検索時に本文中の単語を適切に識別して検索ができるように、Lambdaでパースをした際に単語を記号や空白と言った文字で分解したものをインデックスとして登録するようにしています。これはElasticsearchにおけるStandard Tokenizerに近い、トークン分割の独自実装を利用しています。例えば tani@cookpad.comというような文字列は tani, cookpad, comに分解してインデックスを作成し、検索する時にはこれらの単語を完全一致で全て含むログを検索するようにします。これによって tani@cookpad.comを検索したいときに mizutani@cookpad.comも引っかかってややこしい、という状況を回避できます。(もちろん、意図的に %taniを指定することで、mizutani@cookpad.comも引っかかるようにできます)

(パート2) パーティションの作成

次のパートはAthenaのテーブルのパーティション作成になります。Athenaは読み込んだS3オブジェクトのサイズの合計のみで課金が決まるため、少しでも不要な読み込みを減らすのがパフォーマンスおよびコストにとって重要になってきます。この読み取りサイズを減らす一つの方法として有効なのがパーティション作成です。S3のパスのプレフィックスをパーティションとして登録しておき、WHERE節でそのパーティションを指定するように条件を記述すれば、他のプレフィックスが読み込まれなくなり、読み込むデータサイズを大幅に削減できます。

例えばMinervaでは以下のようなフォーマットで変換したオブジェクトを保存しています。保存先のバケット名が ***-bucketです。

s3://***-bucket/some-prefix/indices/dt=2019-11-01-05/some-bucket/some-key.parquet

s3://***-bucket/some-prefix/indicesまではただのプレフィックスですが、dt=2019-11-01-05がパーティションを示すためのパスの一部になります。このパスの dt=2019-11-01-05(2019年11月1日5時の意味)をAthenaに登録しておくと、 WHERE dt = '2019-11-01-05'とすることによって、上記のプレフィックスを持つオブジェクトしかデータが読み込まれなくなります。これは '2019-11-01-04'<= dt AND dt <= '2019-11-01-06'と指定すると、4時から7時前(6時代)までを検索対象とできます。まず短い時間の範囲から検索したい単語などを探し、検索したい単語が見つからなかったりより深く対象の行動を追いたいということがあれば、その後必要に応じてより長い時間の範囲を検索していくことで、必要最低限のデータ読み込みですむ(=料金も安くてすむ)という使い方を想定しています。

(パート3) ログのマージ

IndexテーブルやMessageテーブルに投入されたオブジェクトはそのままでも検索はできるのですが、検索のパフォーマンス(クエリ速度)が悪化するという問題があります。Athenaのパフォーマンスチューニングのベストプラクティスによると128MB以下のファイルが多いとオーバーヘッドがでてしまうとあるのですが、現在のクックパッド内の環境だとログとして保存する際にある程度の大きさになるオブジェクトもあれば、完全に細切れで保存されるオブジェクトもあり、特に細切れのオブジェクトはパフォーマンスに影響を与えてしまう可能性が高いです。

そのため、一定時間ごと(現在だと1時間毎)にCloudWatch Eventを発火させて、マージすべきオブジェクトの一覧作成&どのようにマージすべきかの戦略をlistParquetというLambdaが判断し、mergeParquetというLambdaが複数のオブジェクトをマージするという作業をしています。これによって1日数十万単位で作成されていたオブジェクトを1000個程度に抑えることができるようになりました。

(パート4) ログの検索

最後は実際のログの検索です。ログの検索は当然Athenaに直接SQLを送っても結果を得られるのですが、「(1)ログの投入」パートで説明したとおり、検索対象を単語で分割するようなやや複雑なクエリを使う必要があります。これを利用する側が意識せずによりシンプルなAPIとして使えるようにAPI gatewayを通してリクエストできるようにしています。これは、この検索基盤をエンジニアがWeb UIを通して使うだけではなく、将来的に他の社内セキュリティシステムとも連動させたいと考えており、その際にSQL文構築の知識を分散させずにMinervaの中に閉じ込めておきたい、という意図もあります。

Web UIについてはまだ開発中ではありますが、現在は以下のようなシンプルなインターフェイスで動かせるよう開発を続けています。

f:id:mztnex:20191118230849p:plain

本アーキテクチャによる効果

先程の述べたとおりまだ開発中のものもありMinervaに完全に移行できたわけではないのですが、当初に考えていたコスト削減については大きな効果が期待できそうです。前述したとおり、現在クックパッドのGraylogはログの保存期間を流量に応じて1週間〜1ヶ月と調整しながら使っていて月に40万円以上かかっていますが、現在の開発しながら使っているMinervaのコストは全てのログを1ヶ月間(未圧縮で約250GB/日 x 30日=約7.5TB)保持しても月あたり3万円以下に収まっています。これによって当初の目的であったコストの抑制には大きく貢献できる見通しがたちました。

今後の課題

コストの面ではかなり大きな成果をあげられそうなMinervaですが、まだ開発における課題は以下のようなものが残っており引き続き開発を進めていきたいと考えています。

  • インデックス作成に関する計算パフォーマンスを向上させる:このシステムはインデックス作成が肝になっており、実はかかっている料金の半分以上がインデックス作成+マージのための計算コストになっています。現在は列指向ファイルの恩恵をうけるためにParquet形式を利用していますが、そのためのオブジェクト生成のためのCPUおよびメモリが必要になっており、これがコストに影響しています。そのため、より効率の良い方法でparquetに変換できるようにすることで、さらにコストを抑制できる可能性があり、コストを抑制できればさらに多くのログを長期間保存しやすくなるため、引き続き改良を続けていきたいと考えています。
  • 検索時のパフォーマンスを向上させる:当初から「検索時のクエリがGraylogより遅くなることは許容する」という前提で開発をしていたため、クエリ結果が返ってくるのが遅いという点については許容するものの、やはり早く応答が返ってくるに越したことはありません。現在だとやや複雑なクエリを投げているせいもあり、およそ20秒程度クエリに時間がかかってしまっています。一度結果が取得できればその結果からの絞り込みなどはできるようにしていますが、なるべくインタラクティブな検索を実現したいと考えています。そのためデータ構造やAthenaの使い方について、より検討を進めたいと考えています。

最後に

クックパッドではこうした「エンジニアリングでセキュリティの問題を解決する」ことを一緒にやっていける仲間を募集しています。興味のある方はぜひこちらをご参照いただくか、ご質問などあれば水谷(@m_mizutani)などまでお声がけください!


  1. 7.5TBという数字はあくまでもとのログの非圧縮状態でのデータサイズです。実際に保存される際はトークン分割などの変換や圧縮がかけられるので単純計算はできませんが、実測値でおよそ1/3〜1/4程度になります。

  2. これは決して厨二病を発症したわけではなく、Athenaの元々の意味であるギリシア神話の女神アテナの別名がミネルヴァとのことだったので、Athenaを少し変わった使い方をすることからこのような名前にしました。

  3. 今回のセキュリティログ検索基盤は弊社で別途運用されているデータ活用基盤のprismを大いに参考にさせてもらっています。詳しくはこちらの記事もご参照ください


cookpad storeLive のクライアントアプリ開発の裏側

$
0
0

こんにちは。メディアプロダクト開発部の柴原(@nshiba310)です。 趣味は Destiny2 というゲームです。

普段は cookpad storeLive(以下、storeLive)のクライアントサイド(AndroidTV)の開発を行っています。 本記事では storeLive のクライアントサイドの開発についてご紹介したいと思います。

storeLive とは

スーパーマーケットの店頭に設置した縦型55インチの大型サイネージで、著名人や料理研究家による料理デモンストレーション映像をLiveや収録動画で提供するアプリです。
プレスリリースはこちら

スーパーの担当者はまず storeLive が置かれている売り場に適した再生したい動画を選択します。 storeLive は担当者が動画を止める操作をするまで選択された動画をループで再生し続けます。
また storeLive では土・日曜日など、比較的人が集まりやすいタイミングでライブ配信を行うことがあり、ライブ配信が始まるとアプリは自動的にライブ閲覧画面を起動し、終了すると自動的に前に再生していた動画再生画面に戻るようになっています。

データの自動更新

storeLive は通常のアプリと違い基本的に人間の操作が動画の選択時しかありません。
そのため、ライブ閲覧画面の起動や動画情報の更新などは自動で行う必要があり、 storeLive ではポーリング機能を実装しライブ配信や動画の情報を自動的に更新しています。

仕様としては、

  • サーバーリクエスト時にレスポンスに次回実行時間が含まれていた場合にはその時間で次の実行を登録
    • ライブ配信が近づいてきたら高頻度で情報の更新を行うため
  • デフォルトは10分間隔で実行
    • ライブ配信を本番より15分ほど先に開始しておきクライアントから10分間隔でアクセスすることにより、必ず全ての端末でライブ閲覧画面を起動させる
    • 高頻度リクエスト時は1分間隔で実行されることを想定

の2つがあり、これを実現するために storeLive では AlarmManager を採用しました。

AlarmManager を採用する理由

近年の Android アプリ開発において、定期実行処理を実装する場合には JetPack Components の WorkManagerを使うことが候補としてあがってくると思います。 しかし、 WorkManager には以下の制限があり今回要求されている仕様を満たすことができないため使用することができませんでした。

  • 最低の繰り返し実行間隔は15分
  • 厳密な実行時間は保証されない
    • 実行間隔は WorkManager に設定した時間間隔の中で端末の状態が最適かどうか(Doze Mode や WiFi 接続している等)を判断し最適なときに実行されるため常に何秒/何分後に実行される、という保証ができない

一方で AlarmManager には setAlarmClock()というメソッドがあり、これを用いると1秒単位で実行時間を設定でき、きちんと設定した時間に発火してくれます。

WorkManager にはリトライ機構もあり可能なら使いたかったですが、今回は仕様に合わず AlarmManager を採用しました。

以下の例では 100 秒後に発火するアラームを設定しています。

val triggerTimeSec = 100val calendar = Calendar.getInstance().apply {
    timeInMillis = System.currentTimeMillis()
    add(Calendar.SECOND, triggerTimeSec)
}
val intent = Intent(context, TestBroadcastReceiver::class.java)
val pendingIntent =
    PendingIntent.getBroadcast(
        context,
        0,
        intent,
        PendingIntent.FLAG_UPDATE_CURRENT
    )
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.setAlarmClock(
    AlarmManager.AlarmClockInfo(calendar.timeInMillis, null),
    pendingIntent
)

このようなバックグラウンド処理に関しては Android Developers にガイドがあるため一度読んでおくことをおすすめします。
https://developer.android.com/guide/background

オフライン機能

スーパーはいろいろな人が出入りする場所のためいろいろな電波が飛び交う可能性があり、ネットワーク状況が不規則に不安定になる可能性があります。
また storeLive が稼働する環境はスーパーの固定された位置を想定しているため、ネットワーク状況が悪くなってもネットワーク状況がいい場所に移動できないということもあり、ネットワークが不安定でアプリがうまく動かないという問題が稼働当初からありました。
他にも、 storeLive は基本的に動画を再生し続けるアプリであり人間の操作はほとんど存在しません。それに加えて、アプリを操作して動画を再生する人と動画を閲覧する人は別の人間です。そのため、通常のアプリでネットワークに接続出来ない場合によく取る手段としてリトライボタンを表示したり、 ネットワークの接続を確認してくださいといったような表示で凌ぐことはできません。

これらの問題がありますが、ネットワーク接続が切れるたびに担当者に復旧作業を行うようお願いしていては運用コストが跳ね上がってしまいかなりの負担になってしまうため、 storeLive ではできるだけアプリの安定性を高めるためにオフライン機能を実装しました。

オフライン機能を実装するにあたって取得したデータをローカルに保存する必要があり、今回はDBに保存することにしました。
Android でDBを扱うライブラリはいくつかあると思いますが、今回は Roomを採用しました。
Room を採用した理由については、 storeLive プロジェクトでは Kotlin coroutines を採用していることや、 ViewModel や LiveData といった JetPack Components を採用しているので、これらと連携する手段が公式で用意されているためです。
また、チームとして Google が推奨しているアーキテクチャや JetPack Components をなるべく採用していこう、という動きがあるのも理由の一因です。

Room と LiveData で実現するオフライン機能

Room と LiveData を組み合わせて使うと簡単にそしてシンプルにオフライン機能を実装することが可能です。

Room 以下のようなデータを定義します。

@Entity(tableName = "movies")
dataclass Movie(
    @ColumnInfo(name = "id") val id: Int,
    @ColumnInfo(name = "movie_url") val movieUrl: String
)

次に Room で DAO を定義します。
以下の例では select 文を発行する関数を実装していますが、このとき戻り値を LiveData をラップすることでデータを LiveData 経由で非同期に取得することが可能です。
ちなみに LiveData を使う場合は裏側で勝手に別スレッドでDBに問い合わせするため、 selectAvailableMovies()関数を呼び出すときに別スレッドで呼び出す必要はありません。

@Daoabstractclass MovieDao {

    @Query("SELECT * FROM movies")
    abstractfun selectAvailableMovies(): LiveData<List<Movie>>
}

使用するときは普通に observe してあげるだけです。
LiveData にデータが渡されるタイミングとしては、メソッドを呼び出した時の他に、 SELECT * FROM moviesのクエリ結果に変更があった場合(movies table にデータが insert された場合等)に新しいデータが渡されます。

class MainActivity: DaggerAppCompatActivity() {

    @Injectlateinitvar movieDao: MovieDao

    onCreate(savedInstanceState: Bundle?) {
    
        movieDao.selectAvailableMovies.observe(this, Observer { movies ->// 渡されたデータを処理する  
        })  
    }
}

こうすることで Activity や Fragment などデータが欲しい場所で API を叩くのではなく、 LiveData を observe しておき、新しいデータが欲しい場合は API を叩き結果を DB に insert すると、 UI の更新が可能です。
一見 DB が間に入っているため面倒ですが、一度でも UI が表示できれば DB にデータが入っているため、ネットワークがないとき、つまりオフラインでもアプリが動作するのでこれでオフライン機能の完成です。

また今回のオフライン機能は DB のデータを真としているため表示してる Activity/Fragment 以外からのデータの更新が可能です。
前述したとおり、 storeLive ではポーリング機能が存在してます。
もともとはポーリングでデータの更新を行ったあと BroadcastReceiver を用いて現在表示している Activity にデータの更新通知を送っていたのですが、今回のオフライン機能を実装したことでポーリングでデータの更新を行ったあと DB にデータを insert するだけで良くなったのは非常に嬉しかったです。

リストの表示には Paging と組み合わせて使う

JetPack Components の中にはリスト表示を行うためのライブラリとして Pagingがあります。 詳細な説明は省きますが、通常 Paging は DataSource クラスを使ってデータを読み込みます。
DataSource クラスには Factory クラスが用意されており、 Room は DataSource.Fractory クラスを戻り値にすることができます。

@Daoabstractclass MovieDao {
    
    @Query("SELECT * FROM movie_list_item")
    abstractfun selectMovieList(): DataSource.Factory<Int, MovieListItem>
}

また、 ktx の version 2.1.0-alpha01から DataSource.Factory クラスに toLiveData()という拡張関数が用意されています。
この関数は内部で LivePagedListBuilder を用いて LiveData<PagedList>に変換してくれるため、そのまま RecyclerView でリストの表示が可能となります。

val movieList = movieDao.selectMovieList().toLiveData(
        config = Config(
            pageSize = 10,
            prefetchDistance = 10,
            enablePlaceholders = false
        )         
    )

movieList.observe(this, Observer {
    // RecyclerView で表示
    adapter.submitList(it)
})

オフライン機能の難しかった点

管理画面で操作した内容はいつ端末に反映されるのか

オフライン機能ではローカルにデータを保存するため、一度データを取得したあとはネットワークに繋がらなくてもアプリの表示が可能です。
そのため、何もしなければアプリ上からは これはいつのデータかというのはわからず、管理画面からデータの変更を行っても、それはいつ端末に反映されたのか、すでに反映された後なのか、といった判断が難しいです。
そのため、どういった操作をすれば必ずサーバーと通信しデータを更新できるのか、というのも決めておく必要があり、 storeLive では、画面遷移をするときには その画面に必要な情報自動更新に関するデータ(ライブ配信情報など)を更新する API を必ず叩くようにしました。

また、ネットワークに接続出来ていない場合には全画面上右上に ネットワークに接続していませんという表示を出し、ひと目でネットワークに接続出来ているかどうかを判断できるようにしています。
ネットワーク接続判断は以下のように、接続状況が変わったら通知がくる LiveData を作り各画面で observe しています。

class NetworkCallbackLiveData(privateval connectivityManager: ConnectivityManager) : LiveData<Boolean>() {

    privateval networkCallback = object : ConnectivityManager.NetworkCallback() {
        overridefun onAvailable(network: Network) {
            super.onAvailable(network)
            postValue(true)
        }

        overridefun onUnavailable() {
            super.onUnavailable()
            postValue(false)
        }

        overridefun onLost(network: Network) {
            super.onLost(network)
            postValue(false)
        }
    }

    overridefun onActive() {
        super.onActive()
        val builder = NetworkRequest.Builder()
        connectivityManager.registerNetworkCallback(builder.build(), networkCallback)

        val network = connectivityManager.activeNetwork
        val networkCapabilities = connectivityManager.getNetworkCapabilities(network)
        value = network != null&& networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
    }

    overridefun onInactive() {
        super.onInactive()
        connectivityManager.unregisterNetworkCallback(networkCallback)
    }
}

余談ですが、アプリ上ではネットワークに繋がっている判定で ネットワークに接続していませんの表示がでないのですが、ルーターから先のネットワークに接続できないらしく Unable to resolve host "example.com": No address associated with hostnameというエラーが出てしまってデータの更新が出来なくなる、という問題が発生していて困っています。

表示するコンテンツに掲出期間があるか

こちらはアプリやサービスの性質によりますがコンテンツには表示期間が存在することがあり、実際に storeLive では各動画に表示期間を設けています。 何度も言う通り、オフライン機能ではローカルにデータを保存するため、一度データを取得したあとはネットワークに繋がらなくてもコンテンツの表示が可能です。
逆に言うと、ネットワークに繋がらなければデータの更新は一生行われません。
そのため、表示期間が定められていると期間を過ぎてアプリ上に表示されてしまうとまずい状況になってしまう可能性があり、いつまで表示していいのかという情報も一緒に保存しておく必要があります。

Room ではデータを取得する時に where 句を書くことができるので、テーブルに表示期間のカラムを入れておくことでデータ取得時にフィルタリングできるので便利です。

@Entity(tableName = "movies")
dataclass Movie(
    @ColumnInfo(name = "id") val id: Int,
    @ColumnInfo(name = "movie_url") val movieUrl: String,
    @ColumnInfo(name = "starts_at") val startsAt: String,
    @ColumnInfo(name = "ends_at") val endsAt: String
)
@Daoabstractclass MovieDao {
    
    @Query("SELECT * FROM movies WHERE starts_at <= :date AND :date <= ends_at")
    abstractfun selectMovieList(date: String): DataSource.Factory<Int, MovieListItem>
}

1つ注意したいのが、この時に何も考えずに端末時刻を比較に使用してしまうと Android では端末時刻は容易に変更できるため表示期間のチェックとしては不十分な場合があります。
storeLive の場合では、アプリや端末自体の操作はユーザーには行われない想定なので、端末時刻は変わらないという前提の元比較に使用しています。もし、この方法を参考に表示期間のチェックをする場合には注意してください。

まとめ

storeLive の主にポーリング機能やオフライン機能の開発についてご紹介しました。
storeLive はまだまだサービスのあり方を模索している段階で機能追加や仕様変更などがたくさんあります。また大きな機能の実装がありましたご紹介したいともいます。

興味がある方いらっしゃいましたら、気軽にお声がけください。一緒に色々チャレンジしていきましょう。

info.cookpad.com

【開催レポ】Cookpad Tech Kitchen #22 決済基盤の最新事情

$
0
0

こんにちは。ユーザー・決済基盤部の大石です。 2019年11月27日にCookpad Tech Kitchen #22 決済基盤の最新事情を開催しました。

f:id:eisuke-oishi:20191212180318p:plain

クックパッドの技術的な知見を定期的にアウトプットすることを目的とする本イベントの22回目のテーマは「決済」ということで、我々ユーザー・決済基盤部から宇津三吉大石が登壇し、クックパッドの決済基盤での取り組みについて発表させていただきました。

発表プログラム

大石 英介「クックパッドにおける決済基盤の歴史とこれから」

まず最初に大石からクックパッドの決済基盤 Financier の変遷とユーザー・決済基盤部という決済基盤の運用や開発を行う部署としてなぜ独立しているのか、そしてこれからの展望について話させていただきました。

クックパッドの決済基盤は日々進化しており、その中でもアプリ内課金のサポートが一つの大きな転機であり、このあとの宇津、三吉によるアプリ内課金に関する発表につながるイントロダクションとなりました。

宇津 宏一「アプリ内課金の最新事情 クライアントサイド編」

次に宇津より、決済基盤とアプリ内課金の機能をつなぐクライアントライブラリ Cusine を中心に、決済に関するクライアントライブラリの技術スタックや導入理由などを発表いたしました。

三吉 貴大「アプリ内定期購入における状態管理と"通知"の活用」

最後は三吉より、決済基盤で実装されている Google Play real-time developer notification / Apple App Store server-to-server notification を活用したサブスクリプションの状態管理について話させていただきました。

Q&Aセッション

最後に登壇者を交えてQ&Aセッションを行いました。いくつかの質問をピックアップして紹介致します。

決済部分専門のチームだと新規開発よりも運用工数が増えていくことで増してくるマンネリ感。打開策としてどういったことをやっていたりしますか?

運用に関してはできるかぎり自動化や利用者が解決できるようにして、人間によるオペレーションや運用負荷を抑えるようにしています。いまのところは運用に忙殺されてなにもできないということは避けられてると思います。

APIを扱うSDKはイメージが着くが、決済や認証などはアプリのライフサイクル/UIのどこでその処理を行うか?が重要だと思っている。が、そのSDKを呼び出すタイミングなどはどう制御しているのか?(導入時に支援としてモブしたりとか??)

SDKにおいては実装ガイドラインも提供しています。ガイドラインでは導入時に実装すべき事と呼び出しタイミングについても触れられており、これを元に実装して頂いています。もちろん、モブプログラミングのように直接導入支援を行ったケースもあります。

感想

決済をテーマにしたイベントというのは他のテーマに比べて多くはない印象でどうなるか不安もありました。しかし参加者の皆様の多くが決済の実装や運用に関わっていらっしゃったようで、懇親会では突っ込んだ質問が飛び交い決済に関わるエンジニア同士の一体感を感じました。 参加者の皆様の取り組みなどもお聞きし、我々の決済基盤もまだまだやるべきことがあるなと刺激にもなりました。今回お話した内容はごく一部ではあるのでまた開催できればと企んでいます。

最後に

我らユーザー・決済基盤部は決済あるいはユーザー管理や認証・認可に興味のあるエンジニアを募集しております。まずはお話だけでもという方も歓迎ですので、下記より応募ください! https://cookpad.wd3.myworkdayjobs.com/ja-JP/jobs/job/-/_R-001685

センサクッキング 技術検証とユーザー体験検証

$
0
0

こんにちは.スマートキッチン事業部の鈴本 (@_meltingrabbit) です.
今回は 失敗しない範囲で自分の料理を創作したいという願望を叶えられるかもしれないセンサクッキングについてです.

はじめに

日々の料理を可観測に,可制御に

唐突ですが,私は,料理とは複雑系であり,調理とは創造的であると思っています.
そのような中で,より日々の調理をより楽しくしたい,もっと変化が欲しい,ちょっと実験的な要素を加えてみたい,と考える人は多いのではないでしょうか?
かといって,ただでさえ忙しい日常において,ちょっと変わったことをして失敗して,時間やお金,食材を無駄にしたくないと思うのも当然です.
そんなわけで,調理中に料理の内部状態量が把握でき,失敗を防ぎつつちょっと冒険できるようなシステムの技術検証とユーザー体験の検証についての話をしたいと思います.

センサクッキング

f:id:meltingrabbit:20191211172344p:plain
センサクッキング イメージ図

センサクッキングとは,その名の通り,料理中に多種多様なセンサを駆使して,料理の内部状態量を把握できるようにしようとするものです.
これによって,

調理中の料理の内部状態量が把握できる
 ↓
自らの行為・操作がどのように料理に影響するのかを理解・学習できるようになる
 ↓
データを蓄積すれば,ひいては自分の料理の好みや傾向を把握できるようになる
 ↓
日々の料理がもっと楽しく創造的になる

となることを期待しています.

「あれを試したらどうなるだろう?」「 今度はこういうふうに作ってみよう! 」というように,自分で能動的に考えた操作を自由に試せ,その自身の体験から得た知識が増えることでさらに創作への意欲が湧くという,好循環が生まれると考えています.

さらに,副次的な結果として,例えば,

  • 低温調理を試してみたけど,温度がわかるので火が通るのを確認できた
  • 使ったことのない調味料を使ってみたが,塩分濃度がわかるので味が濃すぎることはなかった

といったように,調理を失敗しないためのセーフティになる,という側面もあります.

既存調理器におけるセンサ利用

そもそも,既存の調理器においては,どのようなセンサが使われているのでしょうか?
例えば炊飯器には,本炊きから蒸らしまでの温度プロファイルを制御するために温度計がついています.
さらに最近では高機能なものも増えてきており,所望の温度へ素早く正確へ制御するための質量センサ(三菱IHジャー炊飯器など)や,安全のための圧力計などがついているものもあります.
最近流行りの自動調理器 (例えばヘルシオ ホットクック) などは,追加でかき混ぜる機構などが追加され,料理のレパートリが格段に増えましたが, 取り付けられたセンサの種類が格段に増えた,といったことは耳にしませんし,私達ユーザーがそのセンサ情報を見ることはできません.

既存の料理用センシング機器

最近は “スマートキッチン” という言葉もよく聞くようになりました.
それに伴い,様々な調理家電が販売されています.

なかでもスマートオーブンは,肉に温度計を刺し,内部温度をコントロールしながら加熱することができます.
このような家電はいくつかありますが,基本的には温度しか測定できないものが多い印象です.

f:id:meltingrabbit:20191211175717j:plain
june smart oven

また,健康志向の高まりも相まって,手軽に塩分や糖度を測れるデバイスも販売されています.
ただし,調理中に連続的に測定するといった使い方は想定されていません.

f:id:meltingrabbit:20191211180329j:plain
TANITA塩分計

今回の目的

さて,今回の検証の目的は,大きく次の2点です.

フィージビリティスタディ

フィージビリティスタディ,つまり技術的な側面から実現可能性を検証します.
システムとしては,

  • 調理器具に多様多種なセンサを取り付ける
  • 取得されたデータを無線で送信する
  • リアルタイムに可視化する

という要素によって構成されます.
最も重要となるのは,調理の邪魔にならず,安全にリアルタイムにセンシングできるかです.

  • 液体がある部分は100度以上には上がらないので,温度要求が緩和される
  • そもそも液体があることを前提としたセンサが多い(濃度計など)

などの理由から,今回はモデルケースとして鍋を使った調理に焦点を当てました.
そのために,簡単なプロトタイプ製作を行いました.

また,料理での変化がセンサできちんと検出できるかも検証ポイントです.

ユーザー体験の検証

  • 調理中にセンシングをするとどのように測定値が変わるのか
  • 自分の行為がどう測定値に影響するのか
  • そもそも調理中にセンシングをして楽しいのか?(最も重要)

などを検証するために,プロトタイプで作ったシステムを用いて実際に調理実験を行いました.

やったこと

システム構築

まずはじめに,
センサ → マイコン → 無線データ転送 → リアルタイム可視化
のシステムを構築しました.

特にこだわりもないので,マイコンは(社内に100台以上転がっていると噂の)M5Stackを,無線はBLE (Bluetooth Low Energy) を,可視化ツールは自作Webアプリとしました.

f:id:meltingrabbit:20191211224657j:plain
とりあえず熱センサとガスセンサを鍋に取り付けた.

f:id:meltingrabbit:20191211224711j:plain
システム全景.右側のPCにリアルタイムでグラフ化される.

安価に購入可能なセンサをシステムへ統合

秋月電子などの電子部品屋に売っているようなセンサをひとしきり買いあさり,鍋に取り付け,いい感じならそのままシステムへ統合していきました.
買ったセンサは,
温度計(熱電対),pH計,ガス(一酸化炭素,二酸化炭素,アンモニア,エタノール,etc...)センサ,蒸気センサ,色(RGB)センサ,赤外線6バンドスペクトルセンサ,マイク,ピエゾ素子(振動センサ),転倒センサ,加速度センサ,土壌水分センサ,などなどです.
個人的に欲しかった粘度センサは,高価すぎて断念.

f:id:meltingrabbit:20191211225325j:plain
買いあさったセンサたち

既存の調理用センサを分解・改造しシステムへ統合

塩分計と糖度計が欲しかったのですが,いい感じにセンサ部分だけを手に入れることができなかったため,既存のデバイスをばらして使うことにしました.

塩分計はばらしてみると比較的単純な回路だったため,センサ部分の電極をM5Stackへ引き出し,その信号から塩分濃度を推定させました.
また,塩分計自身のスイッチもM5Stackから制御できるようにし,任意のタイミングで測定可能となりました.

f:id:meltingrabbit:20191211231011j:plain
バラされ,センサの電極を外部に引き出される塩分計

f:id:meltingrabbit:20191211231042j:plain
センサ部分に加え,スイッチ部分もM5Stackに乗っ取られた塩分計

対して,糖度計はセンサ部分が堅牢でアクセス不能だったため,備え付けられていた液晶画面への信号を読み取り,測定値を取得するようにしました.
こちらも塩分計同様,M5Stackからスイッチを制御して測定します.

f:id:meltingrabbit:20191211231441j:plain
バラされ,配線をつけられた糖度計
f:id:meltingrabbit:20191211231452j:plain
液晶画面は,1桁につき数字7セグメント+小数点の8セグメント,それが4桁分ある.なお,ダイナミック駆動という信号方式を採用しているので,セグメント数と比較すると信号線数は大幅に少ない.
f:id:meltingrabbit:20191211231447j:plain
無事,液晶に表示されている数字を読み込めた.

センサクッキングを体験

ひとしきりのセンサを鍋に取り付け終わったところで,さっそく調理で使ってみました,

配線がすごいことになっていますね.
きちんと回路と構体を作ればもっときれいに小さく作れますが,今回はプロトタイプであり,検証として使えるものを手早く作ることが目的であったので,これで良いのです.

よだれ鶏,筑前煮,卵スープなどを作ってみました.

f:id:meltingrabbit:20191211233540j:plain
多様多種なセンサが取り付けられた鍋.右の黒いのはバッテリ.

f:id:meltingrabbit:20191211233544j:plain
調理風景

f:id:meltingrabbit:20191211233041p:plain
センサから無線で受け取ったデータを可視化する画面

技術的なフィージビリティ検証結果と考察

全体的なフィージビリティ

f:id:meltingrabbit:20191216011807p:plain
センサクッキング デバイス コンセプト図

まず,システム全体としての技術的な実現可能性についてです.
実現可能性は十分ある一方,部分的に難しい点がありそうです.
 

目的で挙げた,

  • 取得されたデータを無線で送信する
  • リアルタイムに可視化する

の2つはすでに成熟した技術があり,技術的ハードルは低いです.

  • 調理器具に多様多種なセンサを取り付ける

に関しては,検証が必要です.

センサ自体は,上図のように大きく以下の4つに大別されます.

  • プローブ系:液面にセンサ部分を突き刺して/浸して使う.濃度計など.
  • 非接触系:鍋の縁にクリップのように取り付ける.色センサなど.
  • 環境系:調理環境に設置するもの.気温計や湿度計など.
  • 制御履歴系:コンロなどの調理器具の入出力履歴など.

要求が厳しいのが上の2つであり,

  • 液面に触れたり油はねなどで壊れないための防水性
  • 加熱調理に耐えられる耐熱性
  • 料理を邪魔しないための小型化

が求められます.

まず,小型化に関しては容易でしょう.
センサ部分を除いて,マイコン,無線モジュール,操作用のスイッチ2個程度の回路であれば,2cm四方程度の回路で十分収まるはずです.

耐熱性も,鍋系であれば,センサが触れる液面の温度は100度以下なので,そこまで問題にならないはずです.
ICなどは100度でも動くものが多いです.
しかし,はんだは200度くらいから溶ける可能性があるので,揚げ物などは対策を施さないと難しいでしょう.
また,急激な温度変化はクラックの原因となるので,熱がゆっくり伝わるようにといった対策は必要かもしれませんが,そこまで難しくないはずです.
プローブ系であれば,回路は熱環境のよい上部に取り付け,センサ部分(電極部分)のみを高温にさらされる部分に配置するなどの工夫も可能です.

防水性は,色センサなどは透明な容器で覆ってしまえばよく,熱電対などセンサ部分が電極のものはもともと問題がない一方,ガスセンサなどセンサ部分が暴露しているものはもしかしたら難しいかもしれません.

様々なセンサを使ってみてわかった,気をつけなければならないこと

各センサについての具体例は後述しますが,様々なセンサを実際に使用してみて気づいた,実際に調理用センサを作り込んでいく上で課題になりそうなことについてまとめておきます.

まず,調理環境という極めて(センサにとって)劣悪な環境にセンサを置くことについてです.
耐熱,防水についてはすでに述べましたが,他にも考えなければならないことは多々あります. 例えば,キッチンによっても明るさなどの環境は異なるし,同じキッチンでも状況によって環境は変わります.
さらにノイズ源が多いという問題もあります.例えば,

  • 人が近づいたことによる外乱
  • 鍋をかき混ぜた事による外乱
  • IHクッキングヒーターの高周波外乱

などです.

一方で,センサクッキングの目的を考えると,規格化された使用を想定する必要がない,とみることもできます.
どういうことかというと,大事なのは自分の行為・操作が料理にどのような影響を与えるのかがわかればいいので,たとえセンサ値が急に変わったとしても,それが窓を開けたことによるのか,センサを覗き込んだことによるのか,鍋をかき混ぜたことによるのか,自分がわかれば問題ない,という考え方です.

ただ,そうであったとしても,再現性の高さ(つまり,同じように調理したら,同じような測定結果になること)が重要であることには変わりありません.
この再現性を担保するのに重要なのがセンサの校正作業なのですが,これがユーザビリティを下げるのは間違いなさそうです.
特にpH計などはいちいち標準液にセンサを浸すなど,めんどくさい作業が多いのが実情です.
(高校生時代に次のようなことをよく言われた.機械(センサのこと)とは,目盛りを正確に刻むのはとくいだけれど,絶対値を決めることはできない.例えば,機械に0度と100度を教えてあげれば,その間を0.1度や,0.001度などといった精度で刻むことは可能だけれど,機械が自ら基準値を知ることはできない.)

他にも,人間の味覚とセンサの物理的な定量評価のギャップも注意しなければならないと考えます.
例えば,温めると塩味をより強く感じる,など,人の味覚は様々な要因に影響されます.
同じ塩分濃度でも,それを食べたときの感じ方は様々であることを考慮に入れて,センサの測定値を解釈していく必要がありそうです.

個別センサについて

ここから,いくつかの個別のセンサについて,使用感を取り上げてみたいと思います.

温度計

熱電対を使用しました.
これは,2種類の金属の接合部分に温度差があると起電力が生じることを利用したセンサです.
測定したい箇所に熱電対を貼ればいいだけなので,極めて容易であり,耐熱性や防水性もバッチリです.

今回使ってみて一番良かったのは,リアルタイムでグラフ化することのメリットが結構大きいということです.
とりわけ,温度グラフの傾きがわかるのが良かったです.
傾きから,「この火加減だとどれくらいで湧きそう」とか,「あと数分は目を離していても問題ない」といったことが読み取れました. また,他のセンサログと組み合わせることで,「この温度のタイミングで調味料を入れるといい」といった発見も期待できるかもしれません.

なお,今回測定したのは鍋の表面や煮物の液体部分であって,食材の内部温度などではありません.

これは,対象に光をあて,その反射光の強度をRGBそれぞれについて取得できるセンサです.

料理中,かなりダイナミックに変わって楽しかったです.
沸かした水に塩を入れただけでも変わりました.
ただ,センサ直下に何があるかや,環境光によって値が大きく変わってしまうので,調理としての再現性はあまり望めないかもしれないです.

塩分計

今回使用したのは,交流電圧を印加して液体の伝導率を測ることで,溶解している電解質分量から塩分濃度を推定するセンサです.
しかし,鍋の素材によってセンサ出力にものすごいノイズが載ってしまう,などといった難しい点がありました.

pH

pHの計測にはガラス電極法という計測手法が主流であり,今回もこの類のセンサを使用しました.
これは特殊なガラス膜の両側でpHが異なると,その間に起電力が生じることを利用しています.

使ったセンサの問題かもしれませんが,センサの時定数が分オーダーと,かなり大きかったです.
「この調味料を入れたらpHがこうなった」などの変化を見るのは厳しいかもしれません.
センサの時定数と料理の時定数の関係は,他のセンサでも鍵となる可能性があります.

ガスセンサ

MEMSタイプのガスセンサを使用しました.

隣で炒めものすると値が跳ね上がったりしました.
ただ,取得して嬉しいかと言われると...?

糖度計(屈折計)

溶液の屈折率を測定することで,糖度を推定するセンサを用いました.

糖度を直接測定しているのではなく.屈折率を測定しているため,原理的に糖分以外にも反応してしまうようです.
たとえば,アルコール計も屈折計なので,センサ値が糖によるものかアルコールによるものかの切り分けはできません.
このように,ある物理量に影響を与える料理のパラメタが複数ある,といったことは他のセンサでも起きうるので,難しい問題です.

ピエゾ素子(振動センサ)

ピエゾ素子,別名圧電素子を用いました.
これは,特殊なセラミックに圧力を加えると電圧が生じる素子なので,例えば,薄いピエゾ素子を小刻みに振動させると,局所的に応力が生じ,起電力が生じます.

沸騰の様子などを捉えられるか? と思っていましたが,なぜかIHクッキングヒーターの高周波外乱をもろに拾ってしまい,全然だめでした.
IHクッキングヒーターがOFFのときは,液面の揺れなどを捉えることができて,なかなか面白くはありました.

鍋以外の調理への展開

鍋料理以外への展開についても少し考察しておきます.
非接触系のセンサであれば,そこまで問題にならないはずです.
ただし,長時間高温にさらされ,センサ内部まで温度が上がってしまう可能性や,長時間にわたって油はねを受けてセンサ部分が汚れたりする可能性は否定できません.
また,濃度計などのように水分があることが前提のセンサは基本的に使えないので,使えるセンサの種類は減ってしまうかもしれません.

他にも,小型化や耐熱性が難しいとは思いますが,米粒サイズの9軸センサなどをチャーハンと一緒に炒めて,米の振る舞いなどを知れたら面白いかもしれませんね.

センサクッキング ユーザー体験の検証結果と考察

2つ目の検証目的である,ユーザー体験についてです.

楽しいのか問題

そもそも,「センサクッキングをしてたのしいのか?」という問題があります.
今回,数回に渡って実際にセンサクッキングを体験しましたが,基本的には楽しかったです.
(基本的には,とあるのは,自分の作ったシステムが正常に動作するかに神経を使っていたため)

ただし,この楽しさが持続するかは別問題です.
楽しさが持続しなければ,「はじめに」の項で述べた,自らいろんなことを試し,いろんなことを発見し,理解し,日々の料理をより創造的にするサイクルは回せません.
最初は何もかもが新鮮で,目新しく面白いかもしれませんが,だんだん飽きてくる可能性も十分にありえます.
今回は数回使っただけですが,今後はデバイスをきちんと作り込み,例えば数週間使ってみるといった長期的な検証が必要になると考えます.

あたりまえ or なにもわからない の二極化

センサによって,“あたりまえ” と “なにもわからない” に二極化する傾向がありそうでした.

例えば,温度計や塩分濃度計などはわかりやすく,「加熱したから温度が上がった」や「調味料を足したから塩分濃度が上がった」と,比較的自明な結果が得られるセンサと,赤外線スペクトルセンサや屈折計(アルコールにも糖度にも感度がある,要因が複合的なセンサ)のように,値の変化がよくわからないセンサのように,体験が二極化する傾向が見られました.

前者は,言い方を変えると驚きがなく,つまらないと感じるかもしれません.
一方で,自分の操作がどのように料理へ影響するかのイメージがつかみやすく,学習効果という意味合いではよい体験が得られるかもしれません.

対して後者は,意外性という意味では面白いかもしれませんが,「なんか値が変わったんだけど,なんで?」「これは何を意味するんだ?」のように,値はみれるが,結局何なのかよくわからず理解につながらないかもしれません.

あくまで主観ではありますが,ざっくりと今回使ったセンサを体験でプロットしてみました.

f:id:meltingrabbit:20191216015927p:plain
センサ ユーザー体験マップ

ユーザー体験のさらなる検証

「楽しいのか問題」の項でも述べましたが,ユーザー体験のさらなる検証には長期の使用に耐えられるデバイスをきちんとつくり,長期間使い続ける必要があると考えます.

  • 何度も使っていくことによって,本当に自分の中で相関関係を見いだせるのか?
  • 前回の調理との差分がわかることで,料理の感度解析ができるのか?

などといった体験の検証には,もう少し時間が必要です.

また今回の検証で,センサクッキングで料理の内部状態量の可観測性が向上する可能性を見出すことはできましたが,そこからどう自分の料理を創造的に変えていくかという制御性の部分については検証できませんでした.
この検証も同様に長期使用試験が必要であると考えます.

まとめ

センサクッキングのプロトタイプを作り,実際にそれを体験しました.

それによって,

  • 調理環境のセンサ情報をリアルタイムで可視化することは可能
  • 環境耐性の高い温度計などは事前の想定通り有益な情報が容易に取得できた
  • 調理中に様々なセンサ値の変化が観察できた

などは概ね事前想定通りでしたが,

  • 繊細なセンサなどは調理環境で使用するために工夫が必要
  • 調理環境は想像以上にノイズ源(特にIHクッキングヒーター)が多い
  • センサの校正も含めた再現性の担保は難しい
  • 調理中の些細な変化や急峻な変化にセンサの精度や応答速度がどこまで追従できるかなどの検証は別途必要
  • センサ値の変化が理解しづらいものもある

といった,新たな知見が得られました.

また,センサクッキングによって,料理の内部状態量の可観測性は向上しそうだけれど,そこから複雑・膨大な料理の内部状態量を正しく理解できるか,についてはもう少し検証が必要そうです.
例えば,塩分濃度が◯◯なので,この食材の硬さが△△になって,味が□□になる,と正しく知識化でき,そしてそれをコントロールできるかは,また別問題です.
ただし,センサクッキングの体験がこのような理解への手助けとなり,自身の料理に対して何かしらのフィードバックをもたらす可能性は大きそうです.

個人的には,結構面白かったので,きちんと作り込み長期的に使ってみたいです.
そして,適切にデータを蓄積していけば,好みや調理の傾向以外にも,思っても見なかったことがわかるかもしれません.

プロと読み解くRuby 2.7 NEWS

$
0
0

技術部の笹田(ko1)と遠藤(mame)です。クックパッドで Ruby (MRI: Matz Ruby Implementation、いわゆる ruby コマンド) の開発をしています。お金をもらって Ruby を開発しているのでプロの Ruby コミッタです。

去年の記事「プロと読み解く Ruby 2.6 NEWS ファイル」に続き、今年も本日 12/25 リリース予定の Ruby 2.7 の NEWS ファイル(https://github.com/ruby/ruby/blob/master/NEWS Ruby 2.7 リリース後に URL を変更します)の解説をしてみようと思います。NEWS ファイルとは何か、というのは去年の記事を見て下さい。

実は最近、NEWS ファイルを読みやすくしよう、と例を入れたりしていて、以前のものに比べて読みやすくはなっています(英語だけど)。記事中のコードも、NEWSファイルから引用しているものがあります。本記事では、変更の解説に加え、執筆者らが開発に携わっているということを活かして、「なぜ変更が入ったのか」という背景を、わかる範囲で紹介していきます。

Ruby 2.7 は、来年 2020 年にリリース予定の Ruby 3 へ移行するために、そこそこ多くの変更が入ったリリースです。また、いつものように、便利な新機能や性能向上が取り込まれています。結構、盛りだくさんになりました。お楽しみ下さい。

他にも Ruby 2.7 を解説している記事があります。我々が見つけられたものだけご紹介。ご参考になさって下さい。

言語の変更

文法など、プログラミング言語 Ruby の意味などについての変更です。

パターンマッチ

データ構造をいい感じにチェック・分解する機能が入りました。

パターンマッチとは

case {a: 0, b: 1, c: 2}
in {a: 0, x: 1}
  :unreachablein {a: 0, b: var}
  p var #=> 1end

一見すると、見慣れたcase/whenのように見えますが、case/inなので新構文です。意味もcase/whenと似ていて、{a: 0, b: 1, c: 2}の値にあうパターンを上から探していきます。

in {a: 0, x: 1}は「キーaが存在し、その値は0でなければダメ」かつ「キーxが存在し、その値は1でなければダメ」ということを表現しています。 {a: 0, b: 1, c: 2}は1つめの条件は満たしていますが、xがないので、このパターンにはマッチしません。

マッチしなかったら次のパターンin {a: 0, b: var}を調べます。これは「キーaが存在し、その値は0でなければダメ」かつ「キーbが存在し、その値はなんでもいいので変数varに代入して」ということを表現しています。 これは両方の条件を満たすのでマッチし、varに1を代入した上で、この中の節(ここではp var)を実行する、ということになります。

もしどのパターンにもマッチしなかったら、NoMatchingPatternError例外が投げられます。ここはcase/whenと違うので注意してください。

具体的なユースケースとしては、JSONデータが期待した構造になっているかチェックし、そこから必要なデータを一気に取り出す、というようなときに使えるでしょう。もう#digに頼らなくてもいいんだ。

json = <<END{"name": "Alice","age": 30,"children": [{ "name": "Bob", "age": 2 }]}ENDJSON.parse(json, symbolize_names: true) in 
  {name: "Alice", children: [{name: child_name, age: age}]}

p child_name #=> "Bob"
p age        #=> 2

パターンマッチの詳細を説明しだすと長いので、詳しくはRubyのパターンマッチを設計・実装した辻本和樹さんによる資料を見てください(ちょっと古いところもあります)。

パターンマッチ導入の何が困難だったか

さて、ここからは変更の背景です。

パターンマッチは主に静的型付き関数型プログラミング言語で使われている機能です。Rubyで模倣したり提案したり試作したりということは古くから行われていて、待望されていたと言えます。

しかし、言語組み込みにふさわしい構文がなかなか提案されませんでした。というのも、Rubyの構文は柔軟すぎて拡張の余地が少なく、かといって新たなキーワードを導入するのは互換性の観点で難しく、その上、パターンマッチのパターンは基本的にそのデータを作る構文に似たものにする(配列だったら[x, y, z]、ハッシュだったら{a: x, b: y})という慣習もあり、なかなか期待にあう構文が発見できなかったのでした。

この状況を打破したのが辻本さんでした。辻本さんはinというキーワードを再利用することを提案しました。Rubyには繰り返しの構文for ... inがあり(現代ではほとんど使われない構文です)、この構文のためにinはすでにキーワードだったので、新たに導入する必要はありません。パターンマッチを表現するキーワードとしてベストかどうかは議論の余地があるものの、case/inという構文はそれなりに直感的であり、パターンマッチ導入の現実味が高まりました。

辻本さんが2018年に文法と意味のたたき台を作ったことで議論が本格化し、2019年になって実装され、RubyKaigi 2019のタイミングでコミットされ、半年以上の実験と議論を重ねて、無事2.7に入ります。

ただし、まだあくまで実験的導入という位置づけであり、利用すると次のように警告が出ます。

$ ./miniruby -e 'case 1; in 1; end'
-e:1: warning: Pattern matching is experimental, and the behavior may change in future versions of Ruby!

今後、より広く使ってもらって細かい改善を経て安定していくものと思います。プロダクションのコードに入れるのはやりすぎかもしれませんが、「実験段階なら使うの避けるか」とか思わず、ぜひ試してフィードバックいただければと思います。

(文責:mame)

Ruby 3のキーワード引数分離に向けた警告

  • Automatic conversion of keyword arguments and positional arguments is deprecated, and conversion will be removed in Ruby 3. [Feature #14183]

Ruby 3では、「キーワード引数分離」という非互換が予定されています。Ruby 2.7では、Ruby 3から動かなくなるコードを警告するようになりました。

Ruby 2のキーワード引数の功罪

Ruby 2のキーワード引数は、ただのハッシュの引数として渡されます。これはRuby 1時代の慣習を引き継いだもので、当時としては自然な拡張だったと思います。しかし、この設計は数多くの非直感的挙動を生む罠でした。

なにが問題かと言うと、呼び出されたメソッド側からはキーワードだったのかハッシュだったのか区別できないことです。具体例で示します。

deffoo(x, **kwargs)
  p [x, kwargs]
enddefbar(x=1, **kwargs)
  p [x, kwargs]
end

という、そっくりなメソッドを2つ定義します。これらにハッシュを渡して呼び出します。

foo({}) => [{}, {}]
bar({}) => [1, {}]

挙動が違ってビックリしませんか。メソッド側からは、最後の引数がハッシュオブジェクトだったのかキーワードだったかわからないので、「必須引数>キーワード引数>オプション引数」という微妙な優先度で解釈をします。2.0リリース当初は「キーワード引数>必須引数>オプション引数」でしたが、バグ報告が来たので微妙に変更されました。

barにオプション引数として{}を渡すにはどうすればいいでしょうか。bar({}, **{})というのを思いつくかもしれません。しかし、Ruby 2.6ではこれは期待に反する結果となります。

bar({}, **{}) => [1, {}]

**{}は「何も指定しないのと同じ」とみなされ、1つめの{}がキーワードとして解釈されてしまうためです。barに引数{}を渡すには、bar({}, {})と呼ぶのが正解でした。こんなのわかるわけ無いですね。

なお、当初の2.0では「**{}は一貫して{}を渡す」という意味でしたが、「**{}は無と同じであるべき」というバグ報告が来たので後から変更されました。何かを直すと新たな非直感が生まれる、というのをRuby 2のキーワード引数は繰り返し続けています。

Ruby 3でのキーワード引数

Ruby 2の問題は、キーワード引数を単なるハッシュとして渡すという基本設計に起因しています。Ruby 3ではここを根本的に直します。つまり、キーワード引数とただの引数を分離します。

Ruby 3では、foo({})は一貫して普通の引数を渡します。foo(**{})は一貫してキーワード引数を渡します。完璧にわかりやすいですね。

# in Ruby 3
foo({}) #=> [{}, {}]
bar({}) #=> [{}, {}]

foo(**{}) #=> wrong number of arguments (given 0, expected 1)
bar(**{}) #=> [1, {}]

しかしこのために、キーワード引数を渡すつもりでfoo(opt)などと書いていたコードは動かなくなってしまいます。ここはfoo(**opt)と書き直す必要があります。

そこでRuby 2.7は、原則としてRuby 2.6と同じように動きますが、このように動かなくなる呼び出しをやったら警告を出すようになっています。

deffoo(**kw)
end

foo({}) #=> test.rb:4: Using the last argument as keyword parameters is deprecated; maybe ** should be added to the call#   test.rb:1: warning: The called method `foo' is defined here

この警告が出たら、**を足すなどの対応をしないとRuby 3では動かない、ということになります。

キーワード引数分離についてより詳しくは

これだけなら簡単なのですが、キーワード引数分離の移行のための書き換えは、しばしばもっと難しいときがあります(特に委譲がからむケース)。Rubyの公式サイトに移行ガイドを掲載しているので、見てみてください。

キーワード引数分離の裏話

これは懺悔ですが、Ruby 2でキーワード引数を実装したのは私(遠藤)です。言い訳すると、多少違和感のあるコーナーケースがあることもわかっていたのですが、Ruby 1からあったハッシュのブレースを省略する関数呼び出し(foo(:a => 1, :b => 2))の自然な拡張なので、互換性を考慮すると悪くない妥協であると思ってました。

しかし、2.0リリース後に多数の非直感的挙動が報告され、そのたびにどんどん複雑怪奇度を増していってしまいました。言語設計をバグ報告ベース、コミュニティベースでアドホックに進めると失敗する例。

このことはずっと後悔していて、Ruby 3で直せるなら直したいと思っていました。クックパッドでフルタイムコミッタになったころに、このことをmatzに話したところ、matzも同じように思っていたようで、RubyWorld Conference 2017やRubyConf 2017などで変更することが宣言されました。

2017年の終わり頃からmatz、akr、ko1、自分の4人で、問題点の整理やRuby 3の設計、移行パスの立案に取り組んでいました。遠藤は、設計案ができたら試作して、題材としてクックパッドのRailsアプリを実行してみて、影響を測る、というのを繰り返しました。その結果はチケット [Feature #14183]にも残っています。

当初は完全な分離を目指していましたが、あまりにも非互換が大きい(def foo(opt = {}); endというメソッドにfoo(k: 1)を渡すコードはあまりにも多い)ということをチケット上でJeremy Evansという人が主張し、このケースだけは分離を諦めることでチケット上で合意しました。これが2019年4月ごろ。

Jeremy Evansはこの議論や実験をきっかけにコミッタになりました。彼は単に主張するだけではなく、非常に精力的に実装や実験もやってくれました。後半の実装の多くは彼の手によります。感謝。

(mame)

Numbered parameters

  • Numbered parameters as default block parameters are introduced. [Feature #4475]

  • You can still define a local variable named _1 and so on, and that is honored when present, but renders a warning.

Numbered parameters という、ブロックパラメータの宣言を省略できる機能が導入されました。

ary = [1, 2, 10]という配列 aryの中身をそれぞれ、16進表記する文字列に変換する処理は、ary.map{|e| e.to_s(16)}と書くことが出来ます。このブロックパラメータ eという変数名は、elementという意味で、名前を考えるのが面倒なときに、私が適当に選ぶ変数です。ただ、整数なんだから、integer の |i|も捨てがたいですね。いやいや、number の |n|もいいかもしれません。どれにしましょうか。うーん。名前付けは面倒くさい。

そう、名前をつけるのは面倒くさいのです。簡単なプログラム(配列の中身を16進表記に変換するのは、Ruby ではとても簡単なプログラムでしょう)で、イチイチ考えるのはいやなのです。

そこで、Numbered parameter という新機能が導入されました(Feature #4475: default variable name for parameter)。ブロックの引数を、_1_2として、名前を付けずに参照できるという機能です。先ほどの16進表記への変換は、ary.map{_1.to_s(16)}と書くことができます。

ary.map{|e| e.to_s(16)}
ary.map{_1.to_s(16)}

並べてみると、3文字ほど省略できているのがわかります。まぁ、文字数よりも、書く時に名前を考える手間が減るのが、嬉しいところかと思います。

Numbered parametersの細かい話

書いてみるとわかると思うんですが、沢山 _1_2と並んでいると、わけがわからなくなります。本当にちょっとしたプログラムにしか用いないように注意するといいでしょう。もう、誰か RuboCop のプラグイン、書いてますかね? また、他の人に適切に意味がわかるようにしなければならない時も、使わない方がいいでしょう。わかりやすいプログラムは、適切な変数名から。

ブロックが入れ子になっていてもわかりづらいので、ブロックの入れ子で Numbered parameter を利用できるのは、ある一つのブロックのみとなっています。例えば、最内ブロックの中で使うようにするといいでしょう。もし使っちゃうと、エラーになります。

3.times do
  _1.times do
    p _1
  endend#=># t.rb:3: numbered parameter is already used in# t.rb:2: outer block here#     p _1# ^~~~~~~~

エラーメッセージ、わかりやすいですね。

なお、ブロックで第一引数を利用するとき、iter{|e|}と、iter{|e,|}という、よく似た二つの表記を利用することができます。ブロック引数の宣言での |e||e,|の違い、つまりコンマがついているかいないかです。まぁ、あんまり気にしなくていいと思うし、面倒なので細かい解説はしませんが、ブロック中に_1が1個しか書いていなければ、|e|の意味になります。

_1は、ローカル変数やメソッド名に使える普通の変数です。が、今後は利用しないほうが良いでしょう。すでにそう書いたプログラムがある場合は、変更をお勧めします。

もし、ブロックの外側で _1というローカル変数があれば、それは外側のスコープの変数として利用され、暗黙のブロック引数にはなりません。ただし、_1 = ...という式に対して警告が出るようになっています。

_1 = 0#=> warning: `_1' is reserved for numbered parameter; consider another name
[1].each { p _1 } # prints 0 instead of 1

Numbered parameters についての議論

この機能自体は、結構昔から議論されていました。ただ、なかなか決まりませんでした。主に、表記と機能の問題です。

例えば、Groovy という言語には itという、今回の _1相当の機能がありました。ただ、Ruby では RSpec などですでに利用されている識別子です。また、第一引数のみで良いのか、という議論がありました。個人的には、Scheme(SRFI 26) の <>が良かったんですが、!=に見える、みたいな意見もありました。

ただ、なかなか決まらなかったところ、@1, @2, ... というのでいっか、と、あるときの Ruby 開発者会議(毎月1回、まつもとさんと仕様を検討する会議です)でストンと決まりました。なお、このときは @1だけ利用するときは |e,|と同じ意味でした。

その後、表記について、いろいろな議論がありました。例えば Misc #15723: Reconsider numbered parameters - Ruby master - Ruby Issue Tracking Systemというチケットでは、129 個のコメントが集まってますね。

また、遠藤さんが、機械的にブロック引数を @1, ... に変更して表記を確認してみる、といった実験をしてくださいました(https://twitter.com/mametter/status/1159346003536838656)。これを見て、まつもとゆきひろさんは、@1ってちょっとインスタンス変数っぽすぎるな、と言ってました。

そんなこんなで、代わりに _1, _2, ... という表記となりました。また、圧倒的に |e,|ではなく、|e|として利用することが多いため、_1だけの利用では、|e|と書いているのと同じ意味になりました。いやぁ、決まるまで長かった。

この機能って、結局ブロックをいかに簡単に書くか、という話なんですよね。上記16進表記への変換では、例えば16進表記文字列へ変換する Integer#to_s16があれば、ary.map(&:to_s16)と書くことができます。みんな大好きなアレですね。しかし、ないので ary.map{_1.to_s(16)}と書けると、そういうブロックを簡単に書くニーズに応えられるんじゃないか、という理由で導入されました。他の案としては、引数16を渡したProcを生成する仕組みを提供するのはどうか、といった提案もありました。例えば ary.map(&:to_s(16))ですね。こんなふうな色々な表記のリクエストがきており、それらをだいたい解決するかな、ということで、今回の numbered parameter (_1, ...)が導入されました。

この辺、関数型言語っぽい機能をRubyで使うための仕組みを、すっきりデザインしてくれる人がいれば、また違った機能が入るかも知れません。

(ko1)

proc/lambdaをブロックなしで呼ぶのは非推奨/禁止になりました

  • Proc.new and Kernel#proc with no block in a method called with a block is warned now.
  • Kernel#lambda with no block in a method called with a block raises an exception.

proc{...}とすれば、Procオブジェクトを生成できます。さて、ブロックを渡さないとどうなるか知ってますか?

deffooproc.call #=> 1end

foo{p 1}

実は、ブロックを指定しないと、procを呼び出したメソッドにわたってきたブロックを、Procオブジェクトとして返します。

で、この機能は、そもそもブロック渡し引数がないときのデザインだったので、もうこの機能は辞めましょう、というのが今回の変更になります。使うと、warning: Capturing the given block using Kernel#proc is deprecated; use '&block' insteadという、警告が出ます。

lambdaは以前から警告が出ていたのですが、例外(ArgumentError)が出るようになりました。

ブロック無し proc/lambdaを使わない書き方

今後は、ブロック渡し引数を用いて、

deffoo&b
  b.call #=> 1end

foo{p 1}

こんなふうに書き直してください。

なお、あるメソッドが、引数で Procを渡すか、もしくはブロックを渡すかを選択できるメソッドを定義するとき、ブロック無し procが利用されていました。

deffoo(pr = proc)
  pr.call
end

foo(proc{p 1}) #=> 1
foo{p 2}       #=> 2

これをブロック渡し引数だけで再現することはできません。

deffoo pr = nil, &b
  pr = pr || b
  pr.call
end

こんな感じで、1行余分に条件文を付けることで対応可能です。まぁ、読みづらいので、どちらも受けるという API は辞めていくのがいいのではないでしょうか。

ブロック無し proc/lambda禁止の背景

この修正が行われたきっかけは、実はまつもとゆきひろさんの提案(入ってない)で、ブロックを受け付けないメソッドを def foo(&nil)と定義できるようにするとどうだろうか、というものでした。この提案は、ついうっかり間違えてブロックを使わないのにブロックを渡してしまう、というミスを回避することを目的としていました(間違えて、p{...}とか、書いたことありませんか?)。ただ、これを入れると、「ブロックを用いないすべてのメソッド定義」、つまり大部分のメソッド定義に &nilを付けまくる、勤勉な風習が増えそうだったので、全力で反対しました。

その代わりに、インタプリタがメソッドでのブロックの利用をチェックし、ブロックを利用しないメソッドにブロックを渡していたら、警告もしくはエラーにすれば良さそうです。ちょっと試すと、2つくらいバグを見つけることができました([Feature #15554])。ただ、この提案はいくつか問題があって、入っていません。というのも、実際のプログラムで、いくつか意図的に「メソッドでは使わないのにブロックを渡す」というプログラムが発見されたためです。Ruby 3 に期待。

さて、あるメソッドがブロックの利用の可否を判断するために、いくつか障害がありました。その一つが今回のブロック無し procです。procはブロックを用いますが、インタプリタからは、ただのメソッドに見えます。procという名前のメソッド呼び出しが、本当に Kernel#procなのか、コンパイル時に判断する方法は Ruby にはないので、きちんとわからないのです。

さて、そういう背景もあり、とりあえず前々からいらんのでは、と言われていたブロックなし proc(と lambda)は obsolete となったのでした。

(ko1)

beginless range

  • A beginless range is experimentally introduced. It might not be as useful as an endless range, but would be good for DSL purpose. [Feature #14799]

Ruby 2.6でendless rangeが入ったので、次はbeginless rangeが入りました。

ary = [1, 2, 3, 4, 5]
p ary[..2] #=> [1, 2, 3]

endless rangeと違い、beginless rangeは#eachができないので、値の範囲を表現するための用途に限られるでしょう。

Companies.where(sales: ..100)

1.clamp(0..)

case age
when (...18)
  "未成年"when (18...)
  "成人"end

(mame)

特殊変数$;$,の廃止

  • Setting $; to non-nil value is warned now. Use of it in String#split is warned too. [Feature #14240]
  • Setting $, to non-nil value is warned now. Use of it in Array#join is warned too. [Feature #14240]

Perlから引き継いだ特殊変数を廃止する動きの一環で、$;$,の使用が警告されるようになりました。どういう変数だったか調べるのも面倒ですが、String#splitArray#joinに関係があるものだったのだと思います。

このように、そもそも知られていないし使われてもいないことに加え、String#splitなどの挙動をグローバルに変更するため、ライブラリが想定外の動きをする可能性があり危険である、というのも廃止の理由です。

(mame)

引用ヒアドキュメントの識別子は改行禁止

  • Quoted here-document identifier must end within the same line.

ヒアドキュメントで、識別子をクオートで囲むことができます。ちなみに、<<'EOS'のように書くと、文字列の埋め込みを禁止することができます。さて、この EOSにあたる部分は、実は改行を含むことが許されていました。ちょっと何を言っているかわからないと思いますが、

<<"EOS"# This had been warned since 2.4; Now it raises a SyntaxErrorEOS

こういうコードが書けたわけです。この場合は、EOS\nが区切り文字になりました。Ruby 2.4 から、このようなプログラムは警告が出ていましたが、Ruby 2.7 ではエラーにする、という変更になります。利用例とかあったのかなぁ。

(ko1)

flip-flop が戻ってきた

Ruby 2.6 で flip-flop は obsolete となりましたが、「まだ使ってるよ!」という声が根強かったので(多分)、戻ってきました(obsolete ではなくなりました)。ファンの方、おめでとうございます。声はあげてみるものですね。

(ko1)

.barのようなメソッドチェインを一部コメントアウト可能に

  • Comment lines can be placed between fluent dot now.
    foo
      # .bar
      .baz # => foo.baz

こんなふうに、メソッドチェイン foo.bar.bazを、.の前に改行を挟んで記述しているとき、.barの部分だけコメントアウトしたい、ということが、試行錯誤しているときとかありそうです。以前は、そこだけコメントアウトすると文法エラーとなっていましたが、Ruby 2.7 では foo.bazの意味になるようになりました。

ちなみに、コメント行ではなく、空行だと文法エラーです。

foo

  .bar
  #=> syntax error, unexpected '.', expecting end-of-input

(ko1)

self.を付けてもプライベートメソッドが呼べるようになった

プライベートメソッドはレシーバを付けて呼び出すことができませんでした。すなわち、プライベートメソッド fooを、 recv.fooのように呼ぶことはできませんでした。self.fooも同様に駄目でした。ただ、いくつかの理由から、self.くらいつけたい、という用途があって、Ruby 2.7 ではそれが許されるようになりました。

self.p 1#=># Ruby 2.6: t.rb:1:in `<main>': private method `p' called for main:Object (NoMethodError)# Ruby 2.7: 1

なお、正確に self.とレシーバを書かなければならず、s = selfのような変数を使って s.fooとしてもプライベートメソッドは呼べません。

この機能により、今まで self.private_methodと呼ぶと、method_missingが呼ばれていたのが、素直に呼べるようになったので method_missingが呼ばれなくなるという、若干の非互換が入りました。そこに依存したプログラムがあるとは思いたくない...。

(ko1)

多重代入における後置 rescueの優先度の変更

  • Modifier rescue now operates the same for multiple assignment as single assignment. [Bug #8279]
    a, b = raiserescue [1, 2]
    # Previously parsed as: (a, b = raise) rescue [1, 2]# Now parsed as:         a, b = (raise rescue [1, 2])

コメントにある通りなんですが、後置 rescueが、多重代入式で使われたとき、どの部分にかかるか変わりました。変更後の括弧の位置は、想定と同じでした?

私、後置rescueは難しくて使わないんですよねえ。どの例外をキャッチするんだっけ、とかすぐわからなくなっちゃって。

(ko1)

シングルトンクラスの中で yieldは廃止予定

  • yield in singleton class syntax is warned and will be deprecated later [Feature #15575].

何を言っているのかわからないだろうし、わかっても、なんでここで yieldすんねん、という感じですが、次のようなコードは Ruby 2.6 以前で動きます。

deffooclass<< Object.new
       yieldendend
   foo { p :ok } #=> warning: `yield' in class syntax will not be supported from Ruby 3.0.

が、わけわかんないのでやめましょう、と警告が出ます。やらないよね? こんなの。Ruby 3 では文法エラーになる予定です。

変更の背景は、実はブロック無し proc禁止と同じです。

(ko1)

引数を委譲する記法 (...)

受け取った引数をそのまま他のメソッドに渡す委譲のための記法が導入されました。

deffoo(...)
  bar(...)
end

受け取る方も渡す方も両方とも(...)でないと構文エラーになります。

従来は次のように書いていたと思います。これは煩わしいことに加え、最適化がしにくかったり、Ruby 3ではさらに**optも受け渡さないといけなくなったりするということで、導入されました。

# Ruby 2の委譲deffoo(*args, &blk)
  bar(*args, &blk)
end# Ruby 3の委譲deffoo(*args, **opt, &blk)
  bar(*args, **opt, &blk)
end# (...)を使った委譲(Ruby 2.7とRuby 3以降の両方で動く)deffoo(...)
  bar(...)
end

なお、「Rubyのメソッド呼び出しはカッコが省略できる」と覚えている人も多いと思いますが、この委譲の記法はカッコが必須です。なぜかというと、bar ...はendless rangeと解釈されてしまうためです。

(mame)

$SAFEの廃止

  • Access and setting of $SAFE is now always warned. $SAFE will become a normal global variable in Ruby 3.0. [Feature #16131]

Ruby がもつ古のセキュリティ機構である $SAFEが廃止されました。そもそも、$SAFEって知ってますか?

$SAFEは、基本的には信頼できないデータ(文字列など)にフラグを付けておいて(taintフラグ)、systemopenといった、危ない操作っぽいものをしようとしたら、インタプリタが「危ないからやめて!」と止めてくれる(SecurityErrorが出ます)という機能です。

ただ、現代のフレームワークでは、$SAFEについて考慮せず、適切な taintフラグの付与が行われないなど、$SAFEが実質的に利用可能ではなくなっていました。このような不完全な状況で、$SAFEを間違って信頼してしまうとセキュリティ上問題なので、いっそ消してしまおう、というのが今回の提案です。

$SAFEに何か代入しても、下記のような警告が出ます。

$SAFE = 1#=> t.rb:1: warning: $SAFE will become a normal global variable in Ruby 3.0

Ruby 3.0 では、$SAFEはただのグローバル変数に戻るとのことです。てっきり、永久欠番みたいな扱いにするのかと思ってました。

  • Object#{taint,untaint,trust,untrust} and related functions in the C-API no longer have an effect (all objects are always considered untainted), and are now warned in verbose mode. This warning will be disabled even in non-verbose mode in Ruby 3.0, and the methods and C functions will be removed in Ruby 3.2. [Feature #16131]

この変更に伴い、Object#taintなどのメソッドは、何も効果がなくなります。つまり、taintフラグのついたオブジェクトは存在しないと言うことです。Ruby 3.2 でこれらのメソッドは削除されるようなので、早めに対処しましょう。

(ko1)

Object#methodなどでRefinementsを考慮するようになった

  • Refinements take place at Object#method and Module#instance_method. [Feature #15373]

今まで、Object#methodなどで取り出すメソッドは、Refinementsを気にしていませんでしたが、ちゃんと気にするようになりました。

# [[Feature #15373]](https://bugs.ruby-lang.org/issues/15373) から一部変更して引用# default call to #ppmoduleP2PPrefineKerneldodefp obj
      pp obj
    endendendusingP2PP

method(:p).call ['1' * 40, '2' * 40]
#=># Ruby 2.6: オリジナルの p が呼ばれる#   ["1111111111111111111111111111111111111111", "2222222222222222222222222222222222222222"]# Ruby 2.7: Refinements (using) が効いて pp になる#   ["1111111111111111111111111111111111111111",#    "2222222222222222222222222222222222222222"]

(ko1)

組込クラスのアップデート

Array#intersectionの導入

配列の共通の要素だけを取り出すArray#intersectionが導入されました。もともとあったArray#&と大体同じです(3つ以上の配列のintersectionも取れるところがちょっと違う)。

ary1 = [1, 2, 3, 4, 5]
ary2 = [1, 3, 5, 7, 9]

p ary1.intersection(ary2) #=> [1, 3, 5]

Ruby 2.6でArray#|に対応するものとしてArray#unionが導入されましたが、intersectionは要望がなかったため見送られていました。が、今回要望が来たので入りました。Rubyの開発はたまに偏執的なほど要望ベースで動きます。

(mame)

Array#minmax, Range#minmaxの性能向上

  • Added Array#minmax, with a faster implementation than Enumerable#minmax. [Bug #15929]

ary.minmaxを実行すると、Enumerable#minmaxが実行されましたが、Array#minmaxを別途用意することで、より高速に実行することができるようになりました。#eachを使わないからですね。

Enumerable + eachで良い感じに全部定義できる、というのはわかりやすいけど、現実的にはこういう変更が入ります。言語処理系開発者としては、本当はこうしなくても速くできるといいんですけどね。

  • Added Range#minmax, with a faster implementation than Enumerable#minmax. It returns a maximum that now corresponds to Range#max. [Bug #15807]

同じように、Range#minmaxも別途用意されました。なお、最大値を算出するアルゴリズムが、Enumerable#maxではなく、Range#maxを用いるため、もしかしたら非互換が出るかも知れません。

(ko1)

Comparable#clampがRange引数に対応

-1.clamp(0..2) #=> 01.clamp(0..2) #=> 13.clamp(0..2) #=> 2

見ての通り、0..2の範囲に収まるように切り上げ・切り下げをやります。n.clamp(0, 2)で同じことはできていたのですが、どうしてもRangeで書きたいという声があり、対応しました。なおexclusive rangeを与えると例外になります(超えたときの切り下げの意味が定義できないので)。

0.clamp(0...2) #=> ArgumentError (cannot clamp with an exclusive range)

(mame)

Complex#<=>の導入

  • Added Complex#<=>. So 0 <=> 0i will not raise NoMethodError. [Bug #15857]

比較が定義できないことで有名なComplexに比較メソッドが導入されました。

Complex(1, 0) <=> 3#=> -2

のように、虚数部が0のときは比較できてほしい、というためのもののようです。なお虚数部が0でないComplexの比較はnilになりました。

Complex(0, 1) <=> 1#=> nil

(mame)

Dir.globDir.[]がNULセパレートパターン非対応に

  • Dir.glob and Dir.[] no longer allow NUL-separated glob pattern. Use Array instead. [Feature #14643]

誰も知らなそうな機能がひっそりと消えました。たとえばファイルfooとbarがあるディレクトリで"f*\0b*"というパターンをDir.globすると

Dir.glob("f*\0b*") #=> ["foo", "bar"]

というように、パターンのORを書くことができました。が、廃止されました。同じことがやりたければ配列が使えます。

Dir.glob(["f*", "b*"]) #=> ["foo", "bar"]

(mame)

CESU-8 というエンコーディングの追加

CESU-8 というエンコーディングが追加されました。

よく知らないのですが、非推奨のエンコーディングだそうなので(UTR #26: Compatibility Encoding Scheme for UTF-16: 8-Bit (CESU-8))、他のシステムが使っているとか、そういうのがなければ関係ないでしょう。

(ko1)

Enumerable#filter_mapの追加

filtermapを同時にやるメソッドが追加されました。

[1, 2, 3].filter_map {|x| x.odd? ? x.to_s : nil } #=> ["1", "3"]

ブロックで変換した結果が偽(falsenil)だったら消されます。両方捨てるべきか、nilだけ捨てるべきかは一長一短で、幾度も議論されましたが、まつもとゆきひろさんの直感によって両方捨てることに。

おおよそ、次と同じ意味です。filterしてmap

[1, 2, 3].filter {|x| x.odd? }.map {|x| x.to_s }  #=> ["1", "3"]

次の例はmapしてfilterのように見えなくもないです。

# 配列の先頭要素を集める、ただし偽は捨てる
[ary1, ary2, ary3].filter_map {|ary| ary.first }

[ary1, ary2, ary3].map {|ary| ary.first }.filter {|elem| elem }

filtermapは組み合わせて使いたいことが多いので、中間配列を作らなくて済む専用メソッドとして導入されました。ケチな話です。

(mame)

Enumerable#tallyの追加

要素の数を数える便利メソッドが導入されました。

["A", "B", "C", "B", "A"].tally
  #=> {"A"=>2, "B"=>2, "C"=>1}

要素をキー、個数を値としたハッシュにして返します。誰しも1度は自分で実装したことがあるのではないでしょうか。

ちなみにtallyとは、線を書きながら数を数える動作を表す単語だそうです(Tally marks - Wikipedia)。日本語だと「正」の字を書いていくやつ。

(mame)

Enumerator.produceの追加

  • Added Enumerator.produce to generate Enumerator from any custom data-transformation. [Feature #14781]

Enumeratorで無限列を作るのに便利なクラスメソッドが追加されました。

naturals = Enumerator.produce(0) {|n| n + 1 } # [0, 1, 2, 3, ...]
p naturals.take(10) #=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

引数が初期値で、それに対して繰り返しブロックの変換を適用することで無限列を作ります。

要望は2.6のころからあったものの、名前がなかなか決まらなかったメソッドでした。Haskellではiterateという名前ですが、Rubyではイテレータという言葉があって紛らわしく、generateはちょっと一般的すぎるのではないか、recurrenceは良くわからない、fromはどうか、などともめて、結局まつもとゆきひろさんの好みでproduceに。

(mame)

Enumerator::Lazy#eagerの追加

  • Added Enumerator::Lazy#eager that generates a non-lazy enumerator from a lazy enumerator. [Feature #15901]

Enumerator::LazyからEnumeratorに変換するメソッドです。これを理解するには、ちょっと長い背景を理解する必要があります。

Rubyには要素の列を表すっぽいデータがArrayEnumeratorEnumerator::Lazyの3種類あります。

Arrayは、すべての要素をメモリ上に並べたデータのクラスで、要素の数だけメモリを消費する特性があります。 一方EnumeratorEnumerator::Lazyは、「次の要素をyieldする計算」を内部表現とするクラスで、同じ列を表現するのでもメモリ消費を回避できます。その代わり、遅い、ランダムアクセスできない、などのデメリットもあり、トレードオフになっています。

EnumeratorEnumeartor::Lazyの違いは何ともややこしいのですが、Enumeratorのメソッドを呼ぶと要素を列挙してArrayにしてしまうことが多いのに対し、Enumerator::Lazyはメソッドを呼んでも#forceメソッドを呼ぶまで要素の列挙が遅延されます。

たとえば、0から10,000までの列に対し、各要素を2倍し、先頭の5要素を取り出したいとします。

# Enumeratorの場合
e = (0..10000).each # Enumerator
p e.map {|n| n * 2 } #=> [0, 2, 4, 6, ...] (Array)# Enumerator::Lazyの場合
e = (0..10000).each.lazy
p e.map {|n| n * 2 }               #=> #<E::Lazy: #<...>:map>
p e.map {|n| n * 2 }.take(5)       #=> #<E::Lazy: #<...>:take>
p e.map {|n| n * 2 }.take(5).force #=> [0, 2, 4, 6, 8] (Array)

Enumerator#mapはいきなり要素を列挙してArrayを返してしまうので、長さ10,000の配列を作ってしまい、メモリを大量に消費してしまいます。 一方Enumerator::Lazy#mapは、要素の列挙を後回しにしているので、ArrayではなくEnumerator::Lazyを返します。それに対してtake(5)をして最後にforceをすることで、長さ10,000の配列を作らずに先頭5要素を取り出すことができます。

さて、Enumeratorを受け取って、最後に現れた奇数を返すlast_oddメソッドを考えます(あまりいい例ではないですが)。

deflast_odd(e)
  e.select {|x| x.odd? }.last
end

このメソッドにEnumerator::Lazyを渡したいとき、どうすればいいでしょうか。そのまま渡すと、selectの結果もEnumerator::Lazyとなり、それにはlastが定義されていないのでエラーになります。forceArrayにしてから渡せば動きますが、メモリを無駄遣いすることになります。Enumerator::LazyからEnumeratorに変換できればいいのですが、実は簡単に変換するAPIがありませんでした。

ということで、今回入ったのがEnumerator::Lazy#eagerです。

e = (0..10000).each.lazy
e = e.map {|n| n * 2 }.take(5).eager #=> [0, 2, 4, 6, 8] 相当の Enumerator(この時点ではまだメモリ確保しない)
p e.map {|n| n + 1 }                 #=> [1, 3, 5, 7, 9] (Array)

これを使ってEnumeratorにすれば、無事last_oddEnumerator::Lazyを渡せます。

e = (0..10000).each.lazy

last_odd(e)       #=> NoMethodError
last_odd(e.force) #=> 9999 (動くけど非効率)
last_odd(e.eager) #=> 9999

いやあ、ややこしいですね。ArrayEnumerator::Lazyだけなら良かったのに、という気もします。

(mame)

Enumerator::Yielder#to_procの追加

  • Added Enumerator::Yielder#to_proc so that a Yielder object can be directly passed to another method as a block argument. [Feature #15618]

Enumerator::Yieldereach&で渡せるようになりました。といってわかる人はほとんどいないと思う。

Enumeratorは作る方法が2つあります。1つは上で示した(0..10000).eachのように、ブロックを省略したeachなどを呼び出す方法、もう1つはEnumerator.new {|y| ... }を使う方法です。

e = Enumerator.new do |y|
  y << 1
  y << 2
  y << 3end
  
p e.to_a #=> [1, 2, 3]

このyEnumerator::Yielderクラスのオブジェクトです。これにto_procが追加されたので、次のようなことが書けます。

e = Enumerator.new do |y|
  [1, 2, 3].each(&y)
  # 次と同じ意味# [1, 2, 3].each {|x| y << x }end
  
p e.to_a #=> [1, 2, 3]

Enumeratorってどのくらいの人が使いこなしてるのか、気になります。

(mame)

Fiber#raiseの追加

  • Added Fiber#raise that behaves like Fiber#resume but raises an exception on the resumed fiber. [Feature #10344]

Fiber#raiseというメソッドが追加されました。何をするかというと、まず resumeして、その後、resume先のコンテキストで、例外を発生させます。

f = Fiber.new doFiber.yield
  #=> Fiber#raise によって、ここで例外が発生するrescueException
  p $!end

f.resume
f.raise

Fiber が worker みたいなことをしているとき、処理を中断させるような例外を出すような用途が考えられます。

worker = Fiber.new doloopdo
    task = Fiber.yield
    do_task(task)
  endend

worker.resume # kick
worker.resume 1
worker.resume 2
worker.raise StopIteration# loop から抜ける

が、なんか難しいので(Fiber 作成者の意図しない例外フローを生成する可能性が生じます)、できれば利用を避けた方がいいと思うなあ。resumeで明示的に処理をすればいいように思います。

例えば、さっきの例だと、

worker = Fiber.new dowhile task = Fiber.yield
    do_task(task)
  endend

worker.resume # kick
worker.resume 1
worker.resume 2
worker.resume nil

nilが来たら終わり、と書けば済みます。

(ko1)

File.extnameのコーナーケースが変更

  • File.extname now returns a dot string at a name ending with a dot on non-Windows platforms. [Bug #15267]

ファイル名文字列から拡張子を得るFile.extnameが微妙に変化しました。

File.extname("foo.") #=> ""  in Ruby 2.6#=> "." in Ruby 2.7

なぜかというと、basenameの結果とextnameの結果を結合したときに元に戻るようにするためです。

f = "foo."
b = File.basename(f, ".*") #=> "foo"
e = File.extname(f)        #=> "."
p b + e == f               #=> true

なお、深遠な理由によりWindowsではこれは""のままになりました。よくわからないですがWindowsでは最後がドットで終わるファイル名は無効だからだそうです。

(mame)

FrozenErrorreceiverをサポート

  • Added FrozenError#receiver to return the frozen object that modification was attempted on. To set this object when raising FrozenError in Ruby code, pass it as the second argument to FrozenError.new. [Feature #15751]

freeze されたオブジェクトに対して更新を試みると、FrozenError例外になります。どのオブジェクトを更新しようとしたか、FrozenError#receiverで知ることができるようになりました。

begin''.freeze << 1rescueFrozenError => e
  p e.receiver
  #=> ""、つまり Frozen な文字列に対して変更しようとしたことがわかるend

なお、FrozenError.new(receiver: obj)として、receiverを指定して FrozenErrorを作れるようになりました。が、まぁ、こんなの作る機会は滅多にないでしょうねぇ。

と、思って rubygems で公開されている Gem のコードを FrozenError.newで検索してみると、122 行みつかりました。意外とあるな。

(ko1)

GC.compactの追加

  • Added GC.compact method for compacting the heap. This function compacts live objects in the heap so that fewer pages may be used, and the heap may be more CoW friendly. [Feature #15626]

Ruby 2.7 の目玉の一つとも言える、ヒープのコンパクション機能です。GC.compactとありますが、GC のたびに行うわけではなく、手動で GC.compactメソッドを実行する度に、コンパクションを行います。

詳細は当該チケットを見て下さい。

GC.compact: コンパクションとは何か

「ヒープのコンパクション」とは何か、ちょっとご紹介します。まず、Ruby のオブジェクトは、ヒープに格納されています。そして、ヒープはページの集合として実装されています。ページは、オブジェクトを格納するスロットの並びです。

さて、オブジェクトを生成するとき、空きスロットを探してそのスロットを新しいオブジェクトとして利用します。GC が発生すると、使っているスロットはそのままに、使っていないオブジェクト(のスロット)を回収し、そのスロットを空きスロットとして確保します。つまり、GC 後は、空きスロットと利用中スロットがそれぞれまばらに存在することになります。

MRI におけるヒープのコンパクションとは、空きスロットのあるページに、生きているオブジェクトを別のページから動かして詰めていく、というものです。結果的に、「空きもあるページ」が沢山あった状態から、「空きのないページ」「空だけのページ」(と、いくらかの「空きもあるページ」)にすることができます。フラグメンテーションの解消ということですね。空きだけのページを解放すれば、メモリ効率がよくなります。

GC.compact: 今回導入された機能

GCアルゴリズムの一つにコピーGCというのがあるのですが、コンパクションを自動的に行うようなアルゴリズムです。存在はもちろん知っていて、なんとかならないかなぁ、とは思っていました。ただ、いろいろな理由(主に性能的な理由)から、コピーGCのような、毎回コンパクションを行うような GC を導入するのは難しいなあと思っていたんですが、今回導入された GC.compactは「人間が明示的に指示する」という発想の転換で、見事に実現されました。

なお、技術的には、MRIでは、「オブジェクトを動かす」ということが出来ないオブジェクトがいくつかあります(歴史的経緯です)。そのため、それらはそのまま残して、動かせる奴だけ動かす、というアルゴリズムになっています。mostly compaction algorithm と呼ばれます。

GC.compactのための変更規模は大変大きく、もしかしたらまだ問題が残っているかも知れません。ご利用する際は、もしかしたら問題あるかなー、という覚悟を持ってご利用下さい。多分、あまり自分で呼ぶようなものでもないと思います(フレームワークが呼んでくれるかも?)。何か問題を見つけたら教えて下さい。

(ko1)

IO#set_encoding_by_bomの追加

  • Added IO#set_encoding_by_bom to check the BOM and set the external encoding. [Bug #15210]

Unicode データの最初に、BOMがついていることがあります。IOにBOMがついていれば、それにあわせた外部エンコーディングを設定し、BOMを読み捨てる IO#set_encoding_by_bomが追加されました。

なお、IO は binmode で開いておく必要があります。

io = open("with_bom", 'rb')
p io.tell                #=> 0
p io.external_encoding   #=> #<Encoding:ASCII-8BIT>
p io.set_encoding_by_bom #=> #<Encoding:UTF-8>
p io.tell                #=> 3 (読み捨てられた)
p io.external_encoding   #=> #<Encoding:UTF-8>

(ko1)

Integer#[]がRangeをサポート

nビット目の数字を0か1を取り出すInteger#[]というメソッドがあるのですが、これを範囲に対応させました。

# 2ビット目から5ビット目までの4ビットを取り出す0b01001101[2, 4]  #=> 0b00110b01001100[2..5]  #=> 0b00110b01001100[2...6] #=> 0b0011#   ^^^^ この位置の4ビットを取り出す# ビット演算で同じことをやるなら
(0b01001100>> 2) & ((1<< 4) - 1)

ビット演算はわりと複雑になるので、そういうことをやりたいときには便利なんじゃないでしょうか。

(mame)

Method#inspectの結果がリッチに

Method#inspectの表記がリッチになりました。

具体的には、

  • (1) パラメータの情報
  • (2) 定義された場所の情報

の二つの情報が入りました。

deffoo(a, b=1, *r, p1, k1: 1, rk:); end
p method(:foo)
#=> #<Method: main.foo(a, b=..., *r, p1, rk:, k1: ...) t.rb:2>

このとき、(1) は (a, b=..., *r, p1, k1: ...)で、(2) はt.rb:2です。

このメソッドなんだっけ? という時、とりあえず inspectすれば良い、と言う意味で、便利になったんじゃないかと思います。pry 上だと $で、色々情報取れるそうですが。

余談ですが、最初、(1)と(2)の間は、場所を表す "at"の意味で、@で区切っていました。が、端末上でファイル名をコピペするとき、スペース区切りのほうが、ダブルクリックだけで済むから楽、という理由でスペース区切りにしました。なるほどなぁ。

(ko1)

Module#const_source_locationの追加

  • Added Module#const_source_location to retrieve the location where a constant is defined. [Feature #10771]

定数の定義位置を返す Module#const_source_locationが追加されました。位置は [file_name, line_number]の配列で返します。

classCclassDendend

p Object.const_source_location('C')
#=> ["t.rb", 1]
p Object.const_source_location('C::D')
#=> ["t.rb", 2]

(ko1)

Module#autoload?inheritオプションに対応

  • Module#autoload? now takes an inherit optional argument, like as Module#const_defined?. [Feature #15777]

Module#autoload?に、継承元のクラスの autoloadの状況を見るかどうかを指示する inheritオプションが追加されました。デフォルトは trueです。

サンプルを RDoc から引用します。

classAautoload:CONST, "const.rb"endclassB< AendB.autoload?(:CONST)          #=> "const.rb", found in A (ancestor)B.autoload?(:CONST, false)   #=> nil, not found in B itself

(ko1)

いろいろ、Frozen な文字列に

  • Module#name now always returns a frozen String. The returned String is always the same for a given Module. This change is experimental. [Feature #16150]

  • NilClass#to_s, TrueClass#to_s and FalseClass#to_s now always return a frozen String. The returned String is always the same for each of these values. This change is experimental. [Feature #16150]

モジュール名を返す Module#nameや、true.to_sなどの結果が、一意な frozen な文字列になりました。以前は、毎回書き換え可能なアロケーションしてたんですよね。

ちなみに、Symbol#to_sの結果も frozen にしようって実験が行われたそうですが、そっちはうまくいかなくて revert されました。

(ko1)

ObjectSpace::WeakMap#[]=がシンボルなども保持できるように

  • ObjectSpace::WeakMap#[]= now accepts special objects as either key or values. [Feature #16035]

ObjectSpace::WeakMapという、直接使うことが推奨されていないクラスの話なので、ここは読まなくていいです。読むな。

WeakMapはハッシュみたいなオブジェクトですが、キーや値がGCに回収されたら中身が勝手に消えます。

o = ObjectSpace::WeakMap.new
o["key"] = "value"
p o.size #=> 1GC.start

p o.size #=> 0

消えることはGCに依存していて保証はされてないので、イメージです。あと# frozen-string-literal: trueだと消えないので注意。

WeakMapは内部的に、キーや値にファイナライザを設定するので、シンボルや数値のようにファイナライザが設定できないオブジェクトを持たせることはできませんでした。

が、今回それを許すようにしました。WeakMapをキャッシュ的に使う上でこの制限が面倒だったから、ということですが、そもそも直接利用を推奨されてないクラスなので、何が起きるやらわかりません。生暖かく見守っていきましょう。

(mame)

$LOAD_PATH.resolve_feature_pathの追加

Ruby 2.6で、requireを呼んだときに読み込まれるファイルを特定するRubyVM.resolve_feature_pathというメソッドが入りましたが、これが$LOAD_PATHの特異メソッドに移動しました。

RubyVMはいわゆるMRI(Matz Ruby Implementation)特有のものを置くところなのですが、resolve_feature_pathは他の実装でも使いたい可能性がある、ということで、外に移そうということになりました。が、多くの人が使うメソッドでもないのでKernelに置くほどのものでもなく、行き先に困り、議論の末、$LOAD_PATHの特異メソッドという大変微妙な位置になりました。

(mame)

Unicodeのバージョンが上がった

対応するUnicodeのバージョンが11から12.1.0に上がりました。

Unicode 12にはたとえば、小さい「ゐ」(U+1B150)が入ったそうです。遠藤の環境では表示できませんでしたが。これはinsmallkanaextensionという文字プロパティを持ってるそうなので、正規表現でこれにマッチできます。

p /\p{insmallkanaextension}/ =~ "\u{1b150}"#=> 0

非常に地道ですが、普及したころには恩恵を受ける人もいるのではないでしょうか。

(mame)

Symbol#start_with?Symbol#end_with?の追加

タイトルの通りで、内容もメソッド名見ればわかりますよね。Stringにある二つのメソッドが Symbolに追加されました。

StringSymbolは、どこで線が引かれるんですかねえ。この辺、歴史のある課題です。Symbol 原理主義者はまったく別物であると主張し、String 過激派は同じにしろと主張しています。どちらかというと、String 過激派のほうに流れていっているような気がしますね。

(ko1)

Time#ceilTime#floorメソッドの追加

Timeオブジェクトは、実は秒より高い精度の時間(ナノ秒)を持つことができます。

p Time.now.nsec #=> 532872900

Time#roundという、メソッドは、時間を秒で丸める処理をしますが、同じように、floor(切り上げる)と ceil(切り下げる)が追加されました。

(ko1)

Time#inspectTime#to_sと別になり、inspectはナノ秒まで出力

  • Time#inspect is separated from Time#to_s and it shows its sub second. [Feature #15958]

で、Ruby 2.6 までは、to_sinspectは秒より細かい情報を切り捨てて表示していたので、比較したら異なる値のはずが、pなどで見ると同じ、という現象がありました。

そこで、Time#to_sTime#inpsectを分離し、Time#inpsectはナノセカンドまで(もしあれば)返すようになりました。Time#to_sが変更されなかったのは、互換性維持のためだそうです。

t = Time.now
p [t.to_s, t.floor.to_s]
#=> ["2019-12-21 04:27:05 +0900",#    "2019-12-21 04:27:05 +0900"]#   .to_s だと同じに見える

p [t.inspect, t.floor.inspect]
#=> ["2019-12-21 04:27:05.3067204 +0900",#    "2019-12-21 04:27:05 +0900"]#   .inspect だと別物だとわかる

p t.round == t #=> false

(ko1)

UnboundMethod#bind_callの追加

Ruby上級者向けの機能です。普通のプログラムでは使わないでください。

クラスを継承してメソッドをオーバーライドすると、新しいメソッドが呼ばれるようになります。

classFoodeffoo"foo"endendclassBar< Foodeffoo# override"bar"endend

obj = Bar.new
p obj.foo #=> "bar"

当たり前ですね。しかし、黒魔術的なケースでごくまれに、オーバーライドされる前のメソッドを呼び出したい、という要求があります。このとき、UnboundMethod#bindMethod#callを組み合わせる悪魔イディオムを使う人がいます。

p Foo.instance_method(:foo).bind(obj).call #=> "foo"

Fooのインスタンスメソッドオブジェクト(UnboundMethod)を取り出し、それをターゲットオブジェクトにbindして、callするので、オーバーライドされる前のメソッドが呼び出せてしまいます。しかし、bindしてcallするのは結構重たい演算なので、まとめてやれば多少速くなる、ということでbind_callが導入されました。

p Foo.instance_method(:foo).bind_call(obj) #=> "foo"

なお、この悪魔イディオムが必要になるのは、ppやランタイムモニタのように、どんなオブジェクトが来るのかまったく予想できないというケースです。普通のプログラムでは決して使わないでください。

(mame)

警告のカテゴリ別フィルタの追加(Warning.[], Warning.[]=の追加)

  • Added Warning.[] and Warning.[]= to manage emit/suppress of some categories of warnings. [Feature #16345]

Warning[category] = true or falseとすることで、categoryに属する警告を、有効 or 無効にすることができるようになりました。

Ruby 2.7 では互換性が変更するところが多く、まとめて警告を消す方法が議論されました。ただ、すべての警告を消してしまうと、興味があるかもしれない警告も一緒に抑制してしまいます。

そこで、あるカテゴリの警告のみ有効・無効を制御したい、ということで、Warning.[]および Warning.[]=が追加されました。現在カテゴリは :deprecated(非対応警告)、:experimental(実験機能警告)の二つだけしかありません。今後、整理されていくのではないでしょうか(でもなぁ、誰がやるのかなぁ)。

Warning[:deprecated] = falsedeffooproc# デフォルトでは deprecated 警告が出るが、その警告を抑制したend
foo{}

(ko1)

標準添付ライブラリのアップデート

ライブラリも、いろいろアップデートしました。NEWS にいくつか載っていますが、興味のあるところだけご紹介します。

エスケープがあるとき、CGI.escapeHTMLが2~5倍高速化

CGI.escapeHTMLが速くなったそうです。

(ko1)

IRBの刷新

irbが刷新され、次のような機能が搭載されました。

  • 複数行編集
  • オートインデント
  • メソッド名補完
  • ドキュメント(rdoc)検索
  • シンタックスハイライト

文章でごちゃごちゃ説明するのは無粋なので、ぜひ実際に体験してください。いますぐ2.7をインストール。

……がすぐに出来ない人は、実は2.6でも動くので、gem install irbしてみてください。……それも難しい人は、リリースアナウンスに載っている動画を見て雰囲気を感じてください。

Rubyの対話的環境というとすっかりpry一色でしたが、irbが追いつき追い越す面も出てきたので、競争で便利になるといいですね。pryも複数行編集のサポートを考えているという噂です。

IRBの刷新: すごさと注意点

ターミナルというのは、タイプライタの時代から改良が続けられてきた超絶レガシーで、意外と大変です。色を付けるくらいなら簡単なのですが、複数行編集・オートインデント・補完となると、ちょっとしたエディタになります。それがLinuxだけでなくWindowsのコンソールでも動きます。JRubyでも動きます。ncursesみたいなライブラリはいろいろ制約があって使えないので全部自力でやっていて、簡易screenやtmuxくらいの複雑さになってます。それがちゃんと動いているのですごい。

一方で、今回MRIのパッケージに含まれ、はじめて広く使われることになります。先に述べた通りターミナルというのは超絶レガシーで、ターミナルによって挙動の違いがあったり、コーナーケースがあったりします。この改良を成し遂げた糸柳さんは2018年ごろから開発を始めたようなので、様々な環境・使い方は経験できておらず、枯れているとは言えません。ぜひ使ってみて、おかしな挙動を見つけたらフィードバックしてください。「とりあえず今動かなくて困る!」というときは、irb --legacyというオプション付きで起動すれば、おおよそ従来バージョンで動きます。

(mame)

OptionParserでオプションをtypoしたらdid you meanが表示されるように

Rubyにはしばらく前からdid_you_mean gemが組み込まれていて、メソッド名や定数名のtypoで修正候補を出してくれますが、それをOptionParserにも組み込んでみました。

$ ruby test.rb --hepl
Traceback (most recent call last):
tt:6:in `<main>': invalid option: --hepl (OptionParser::InvalidOption)
Did you mean?  help

--helpを打ち間違えて--heplとしてしまっていますが、"Did you mean? help"というふうに修正候補を挙げてくれます。

test.rbは普通にOptionParserを使っているだけです。

require'optparse'OptionParser.new do |opts|
  opts.on("-f", "--foo", "foo") {|v| }
  opts.on("-b", "--bar", "bar") {|v| }
  opts.on("-c", "--baz", "baz") {|v| }
end.parse!

(mame)

非互換

  • The following libraries are no longer bundled gems. Install corresponding gems to use these features.

    • CMath (cmath gem)
    • Scanf (scanf gem)
    • Shell (shell gem)
    • Synchronizer (sync gem)
    • ThreadsWait (thwait gem)
    • E2MM (e2mmap gem)

これらのライブラリは bundled gem(つまり、Ruby のインストール時に勝手にインストールされる gem)ではなくなりました。もし必要なら、Gemfileなどに入れるようにして下さい。

(ko1)

Proc#to_sのフォーマットが変わった

Proc#to_sProc#inspectもaliasなので同じ)は、ファイル名と行番号を含んだ文字列を返します(Proc を生成した場所です)。2.6 では、...@file.rb:123だったのが、... file.rb:123のように、@が空白に変わりました。

つまり、こんな感じです。

p proc{}.to_s
#=># Ruby 2.6# "#<Proc:0x0000024cc385c3e0@t.rb:1>"# Ruby 2.7# "#<Proc:0x0000024cc385c3e0 t.rb:1>"

Method#to_sにあわせた変更ですね。

たいした違いじゃないんですが、minitest のテストだったかで、正規表現を使ってファイル名を取り出しているコードがあって、失敗しちゃってました。念のため非互換のところに入れています。

(ko1)

ライブラリの非互換

Gem化

  • Promote stdlib to default gems
    • The following default gems was published at rubygems.org
      • benchmark
      • cgi
      • delegate
      • getoptlong
      • net-pop
      • net-smtp
      • open3
      • pstore
      • singleton

これらのライブラリはデフォルトgemになりました。rubygems.orgでも公開されます。

  • The following default gems only promoted ruby-core, Not yet published at rubygems.org.
    • monitor
    • observer
    • timeout
    • tracer
    • uri
    • yaml

これらのライブラリはデフォルトgemになりましたが、rubygems.org ではまだ公開されていません(調整中だそうです)。

  • The did_you_mean gem has been promoted up to a default gem from a bundled gem

did_you_mean gem が、bundled gem から default gem になりました。

(ko1)

Pathname()

  • Kernel#Pathname when called with a Pathname argument now returns the argument instead of creating a new Pathname. This is more similar to other Kernel methods, but can break code that modifies the return value and expects the argument not to be modified.

Pathname(obj)で、objPathanmeだったとき、新しいPathnameを返すのでは無く、obj自身が返るようになりました。

p1 = Pathname('/foo/bar')
p2 = Pathname(p1)
p p1.equal?(p2)
#=> Ruby 2.6: false#   Ruby 2.7: true#=> 

(ko1)

profile.rb, Profiler__

  • Removed from standard library. No one maintains it from Ruby 2.0.0.

標準ライブラリから外されました。誰もメンテナンスしていないからとのことです。Gem で提供予定ですが、調整中とのことです。

(ko1)

コマンドラインオプションの変更

-W:(no-)categoryオプションの追加

Warning[category] = true or falseの機能を、コマンドラインでも指定できるようになりました。

  • 警告を有効にする: -W:category
  • 警告を無効にする: -W:no-category

と指定します。Warining[category]と同様、現在カテゴリは deprecatedexperimentalの二つです。

利用例:

    # deprecation warning
    $ ruby -e '$; = ""'
    -e:1: warning: `$;' is deprecated

    # suppress the deprecation warning
    $ ruby -W:no-deprecated -e '$; = //'

    # works with RUBYOPT environment variable
    $ RUBYOPT=-W:no-deprecated ruby -e '$; = //'

    # experimental feature warning
    $ ruby -e '0 in a'
    -e:1: warning: Pattern matching is experimental, and the behavior may change in future versions of Ruby!

    # suppress experimental feature warning
    $ ruby -W:no-experimental -e '0 in a'

    # suppress both by using RUBYOPT
    $ RUBYOPT='-W:no-deprecated -W:no-experimental' ruby -e '($; = "") in a'

(ko1)

C API の変更

  • Many *_kw functions have been added for setting whether the final argument being passed should be treated as keywords. You may need to switch to these functions to avoid keyword argument separation warnings, and to ensure correct behavior in Ruby 3.

Ruby 3 でキーワード分離を行うために、_kwで終わる関数名の関数が追加されました。

  • The : character in rb_scan_args format string is now treated as keyword arguments. Passing a positional hash instead of keyword arguments will emit a deprecation warning.

rb_scan_args()のフォーマット文字列 :が最後のオプショナルハッシュではなく、最新のキーワード引数の意味にあわせました。

関数ポインタを受け取るとき、その引数がよくわからん、というのを示す ANYARGSという機能が使えなくなりました。ちゃんと関数ポインタの型を書きましょうね、という話です。

(ko1)

性能向上

Ruby 2.7向けに行われた性能向上についてです。

Fiberとスレッドの実装向上

  • Allow selecting different coroutine implementation by using --with-coroutine=, e.g.
         ./configure --with-coroutine=ucontext
         ./configure --with-coroutine=copy

configureで、Fiberの実装方法を選べるようになりました。が、まぁ、気にする必要は無いでしょう(デフォルトでよいでしょう)。

  • Replace previous stack cache with fiber pool cache. The fiber pool allocates many stacks in a single memory region. Stack allocation becomes O(log N) and fiber creation is amortized O(1). Around 10x performance improvement was measured in micro-benchmarks. https://github.com/ruby/ruby/pull/2224

Fiberのために割り当てられるスタックの戦略を色々と改善して、Fiberの生成とかが10倍くらい速くなりました。やった!

環境によるんですが、mmapでドーンと大きな領域を確保しておき、それを分割して使っていくという戦略になります。

  • VM stack memory allocation is now combined with native thread stack, improving thread allocation performance and reducing allocation related failures. ~10x performance improvement was measured in micro-benchmarks.

同じような話なんですが、VMスタックをマシンスタックから allocaで取得することにより、VMスタック割り当て時間が随分へりました。これも 10 倍くらい速くなったそうです。... 何と比べてだろう?

(ko1)

realpath(3)の利用

  • File.realpath now uses realpath(3) on many platforms, which can significantly improve performance.

使えるならrealpath(3)を利用することで性能が向上したそうです(よく知らない)。

(ko1)

Hash のデータ構造の改善

小さいハッシュ(具体的には 1~8 要素)が必要とするメモリが、192バイトだったのが128バイトになりました(64ビット環境)。

キーと値のペアごとにハッシュ値を保存していたのを、1バイトだけ保存するように変えることで実現しています。効果あるといいなぁ。

(ko1)

MonitorのC実装化による高速化

Monitorクラス(MonitorMixinモジュール)はRubyで書かれていたのですが、handle_interruptという機能を使って実装していると、無視出来ないオーバヘッドとなってしまったそうです。とくに、Ruby 2.6 で、適切な実装にするために追加してしたコードが遅かったとか。

そこで、C で書き直すことにより、以前よりもそこそこ速くなりました。

(ko1)

インラインメソッドキャッシュの改善

メソッド呼び出しを行うところに前回のメソッド探索結果をキャッシュしておくインラインメソッドキャッシュにおいて、クラスは一致しないが同じメソッドを参照する場合、それらのクラスもキャッシュのキーとして保存することで、メソッドキャッシュがより効くようになりました。

discourse ベンチマークという、Rails アプリを対象にした実験において、インラインメソッドキャッシュのヒット率が89%から94%に向上したそうです。Ruby でメソッド呼び出しは大量に行われる処理なので、ここが速くなるのは大変重要なわけです。

(ko1)

JITの改善

  • JIT-ed code is recompiled to less-optimized code when an optimization assumption is invalidated.

高度な最適化のために、いくつか前提とする条件があるのですが、その条件が外れたとき、その条件を緩和して、その最適化を行わないバージョンに再度コンパイルするようになりました。

  • Method inlining is performed when a method is considered as pure. This optimization is still experimental and many methods are NOT considered as pure yet.

「ピュア」なメソッドをインライン化する実験的な機能が実装されました。「ピュア」の定義は面倒なので省略しますが、ほとんどの場合、ピュアじゃないと思います。

  • Default value of --jit-max-cache is changed from 1,000 to 100

--jit-max-cacheというパラメータのデフォルトが1,000から100になりました。これは何かというと、何個のメソッドをJITしたままにしておくか、という数になります。

  • Default value of --jit-min-calls is changed from 5 to 10,000

--jit-min-callsというパラメータのデフォルト値が、5から1万になりました。このパラメータは、何回呼ばれたらJITコンパイルするか、そのしきい値になります。

(ko1)

コンパイル済み命令列のサイズ削減

  • RubyVM::InstructionSequence#to_binary method generate compiled binary. The binary size is reduced. [Feature #16163]

RubyVM::InstructionSequence#to_binaryというメソッドで、VMが実行する命令列、いわゆるバイトコードをバイナリに変換し、出力することができます。これらのバイナリは、Bootsnap などで利用されており、Rubyアプリケーションの起動の高速化に利用されています。

この出力は、非常に無駄が多いフォーマットだったので、クックパッドにインターンに来て頂いた永山さんに、仕様を検討してもらい、スリムにして出力サイズを削減してもらいました。詳細はRuby中間表現のバイナリ出力を改善する - クックパッド開発者ブログをご参照下さい。

(ko1)

その他

そのほかの変更です。

IA64 のサポートを中止

  • Support for IA64 architecture has been removed. Hardware for testing was difficult to find, native fiber code is difficult to implement, and it added non-trivial complexity to the interpreter. [Feature #15894]

Itaniumの製造って終了したらしいですね。というわけで、もうサポートやめようか、ということになりました。結構特殊な処理が入ってたんですよね。

(ko1)

C99の利用

MRIの実装を、C89 ではなく、C99 を用いて書くことができるようになりました(いくつか制限があります)。//コメントが書けるようになった! でも、もう20年前の仕様じゃん。

(ko1)

Git化

  • Ruby's upstream repository is changed from Subversion to Git.

ソースコードが Git で管理されるようになりました。Github で全部管理するんじゃなくて、Git リポジトリが別にあり、そこと Github のリポジトリが良い感じに同期しているような構成になっています。

  • RUBY_REVISION class is changed from Integer to String.

Git 化にともない、今まで Subversion のリビジョン(数値)だった RUBY_REVISIONが、Git のコミットハッシュになりました。

p RUBY_REVISION#=> "fbe229906b6e55c2e7bb1e68452d5c225503b9ca"
  • RUBY_DESCRIPTION includes Git revision instead of Subversion's one.

同じく、Subversion のリビジョンを含んでいた RUBY_DESCRIPTIONがコミットハッシュを含むようになりました。

p RUBY_DESCRIPTION#=> "ruby 2.7.0dev (2019-12-17T04:15:38Z master fbe229906b) [x64-mswin64_140]"# 開発版の表記なので、リリース版は多分また違うんだと思います。

(ko1)

組込クラスを Ruby で書くためのサポート

  • Support built-in methods in Ruby with __builtin_ syntax. [Feature #16254] Some methods are defined in *.rb (such as trace_point.rb). For example, it is easy to define a method which accepts keyword arguments.

RubyKaigi 2019 で私が発表した内容(詳細は RubyKaigi 2019: Write a Ruby interpreter in Ruby for Ruby 3 - クックパッド開発者ブログ)の話です。

簡単にまとめると、現在は Arrayなどの組込クラスはCで記述するしかなかったのが、RubyとCを簡単に組み合わせることで書ける、というものです。

現在は、いくつかのクラス(例えば、trace_point.rbというファイルで TracePointの定義が書いてあります)でだけ利用していますが、今後はこちらに寄せていきたいと思っています(:contribution_chance:)。

この仕組みについての詳細は、また今度まとめたいと思います。

なお、この仕組みを入れる前提として、前述のコンパイル済み命令列のサイズ削減が役に立ちました。

(ko1)

おわりに

Ruby 2.7 も、様々な変更がありました。ぜひ、使ってみてください。

来年はついに Ruby 3 のリリースが予定されています。楽しみですね。ちゃんと出るといいなぁ。

では、メリークリスマス!

Amazon Elasticsearch ServiceをつかったRDSのスロークエリの集計と監視

$
0
0

こんにちは、SREの菅原です。

クックパッドの多くのシステムは AWS 上で稼動しており、そのWebサービスの多くはデータベースにAmazon RDSを使っています。

WebサービスがDBを使う場合、ボトルネックになりやすいDBのパフォーマンスを落とさないためにスロークエリの監視はとても重要です。そこで、Amazon Elasticsearch Serviceを使ったスロークエリの集計・監視システムを構築したので、それについて紹介したいと思います。

※今のところMySQLエンジンのみを対象としています

システム構成

システムの構成は以下のようになります。

f:id:winebarrel:20191225135113p:plain

また、社内のシステムと完全に同じ訳ではありませんが、同様の構成のSAMプロジェクト(Elasticsearch Serviceに保存するまでの部分)をGitHubで公開しています。

https://github.com/winebarrel/sam-rds-slowquery-to-es

Elasticsearch Service使ったスロークエリの集計はよくある構成ですが

  • pt-fingerprintでクエリを正規化して集計しやすくしている
  • Elasticsearch Service(Open Distro)のAlerting機能を使って、スロークエリが発生したときにアラートを出すようにしている
  • Alertingの設定をGitHubでコードとして管理している

などといったあたりが他のシステムには見られない部分だと思います。

pt-fingerprintを使ったクエリの正規化

「どのようなクエリに時間がかかっているか」「件数が多いのはどのクエリか」などを集計しようと思うと、クエリを正規化して値やフォーマットだけ違うようなクエリも同じものとして扱える必要があります。

mysqldumpslowやpt-query-digestなどのツールを使うとクエリを自動的に正規化して集計してくれますが、Elasticsearchにはそのような機能がないため、Elasticsearchへクエリの投入を行うLambdaファンクション内でpt-fingerprintを実行して、クエリを正規化しています。

クエリを正規化することで、Kibana上でpt-query-digestのようなダッシュボードを作成することができます。

f:id:winebarrel:20191225133508p:plain

また、クエリにはメールアドレスなどのセンシティブな情報が含まれることもあるため、そのような情報をマスクしてElasticsearchから見られないようにするという意味もあります。

Alerting機能を使った監視と設定の管理

Amazon Elasticsearch Service(Open Distro)には、オリジナルのElasticsearchのX-Packとは別に独自のAlerting機能が使えるようになっています。

Alerting機能を使うと、単位時間あたりのスロークエリの発生件数が閾値を超えた場合にSlackなどにアラートを通知することができます。

f:id:winebarrel:20191225133704p:plain

このシステムではさらに、Alertingの設定ファイルをGitHubでコードとして管理して、マージされた場合に自動的にElasticsearch Serviceの設定を変更するようにしました。

f:id:winebarrel:20191225133724p:plain

これにより、モニターの作成や閾値などの変更を容易にすることができました。

設定ファイルは以下のように記述されます。

 local action = import '../lib/action.libsonnet';

{
  type: 'monitor',
  name: 'my-service',
  schema_version: 1,
  enabled: true,
  schedule: {
    period: {
      interval: 1,
      unit: 'HOURS',
    },
  },
  inputs: [
    {
      search: {
        indices: [
          'aws_rds_cluster_my-service_slowquery-*',
        ],
        query: {
          size: 0,
          query: {
            bool: {
              filter: [
                {
                  range: {
                    timestamp: {
                      from: '{{period_end}}||-1h',
                      to: '{{period_end}}',
                      include_lower: true,
                      include_upper: true,
                      format: 'epoch_millis',
                      boost: 1,
                    },
                  },
                },
                {
                  bool: {
                    must_not: [
                      {
                        term: {
                          'log_stream.keyword': {
                            // バッチ用のDBでサービスには影響が出ないため、このDBへのクエリは無視する
                            value: 'db-batch-001',
                            boost: 1,
                          },
                        },
                      },
                      {
                        term: {
                          'sql_fingerprint_hash.keyword': {
                            // すぐに修正することが難しいため、このハッシュ値のクエリはいったん無視する
                            // see http://github.com/cookpad/my-service/issue/123
                            value: 'a43f9b2b1800fc8aa09bbcddcf63eab445b5af87',
                            boost: 1,
                          },
                        },
                      },
                    ],
                    adjust_pure_negative: true,
                    boost: 1,
                  },
                },
              ],
              adjust_pure_negative: true,
              boost: 1,
            },
          },
          aggregations: {},
        },
      },
    },
  ],
  triggers: [
    {
      name: 'slowquery-trigger',
      severity: '1',
      condition: {
        script: {
          source: 'ctx.results[0].hits.total.value > 10',
          lang: 'painless',
        },
      },
      actions: [
        action.slowqueryNotifier(
          'ap-northeast-1',
          'cluster:my-service',
          std.join(
            ' AND ', [
              'identifier.keyword:my-service',
              'NOT log_stream.keyword:db-batch-001',
              'NOT sql_fingerprint_hash.keyword:a43f9b2b1800fc8aa09bbcddcf63eab445b5af87',
            ]
          )
        ),
      ],
    },
  ],
}

※詳しい書き方についてはOpen Distroのドキュメントを参照してください

Alerting設定はJsonnetで定義され、CodeBuildからElasticsearch ServiceにポストするときにJSONに変換されます。

スロークエリはアラートでは「特定のクエリを無視したい」(例: 深夜の実行でサービスへの影響が少ない・すぐの対応が難しいクエリは無視)「特定のサーバへのクエリを無視したい」(例: バッチ用サーバへのクエリは無視)などといったことがあるので、モニタリング対象の条件にクエリのハッシュ値やサーバ名などを指定できるようにして、ノイズとなるアラートが上がらないようにしています。

そのほかに工夫した点

Lambda上でpt-fingerprintの実行

LambdaではRubyランタイム上でRubyのスクリプトを動かしているのですが、PerlのData::Dumperモジュールが含まれておらず、そのままの状態でpt-fingerprintを動かすことはできませんでした。そのため、pt-fingerprintに以下のようなパッチを適用して、Data::Dumperモジュールを利用しないようにしています。

https://github.com/winebarrel/sam-rds-slowquery-to-es/blob/master/pt-fingerprint.patch

Data::Dumperモジュールはデバッグ出力としての利用だけなので、この変更による動作への影響はないと考えています。

断片的なSQLの無視

CloudWatch Logsに出力されるスロークエリは、基本的に1メッセージに対して 1 つのクエリのログが出力されますが、まれに非常に長いクエリが複数のメッセージにまたがって出力されることがあります。 これを正しくパースするためには分割されたメッセージを一時的にDynamoDBなどに保存し、後続のメッセージがきたタイミングで他の断片と結合してパースする必要があります。

しかし、正しくパースしようとするとシステムが複雑になり運用のコストが上がってしまうため、このシステムでは単純に無視するようにしています。

rdsadminユーザの無視

RDS(MySQL)ではlog_queries_not_using_indexesを有効にすることで、実行時間にかかわらずインデックスを使用していないクエリをスローログに出力することができます。 log_queries_not_using_indexesをただ有効にしただけだと大量のスロークエリが出力されてノイズになってしまうので、min_examined_row_limitを同時に設定することで走査行数の少ないクエリが出力されるのを抑止することができます。

これらの設定を一部のRDSで有効にしたところ、rdsadminユーザによるスロークエリが大量に出力されるようになってしましました。

これは min_examined_row_limitのスコープが「セッション」であるため、既存の接続が切れない限りセッションが維持されており、新しいセッション変数が使われないことが原因でした。 rdsadminユーザはRDS側のシステムが利用するユーザであり、AWS 利用者側からは操作できません。そのため、接続を切る方法をサポートに問い合わせたところ、再起動以外の方法でrdsadminユーザの接続を切る方法はないとのことで、ワークアラウンドとしてrdsadminユーザのスロークエリは無視するようにしました。

rdsadminユーザがスロークエリを発行する可能性がないわけではないので、接続が切れるタイミングがあれば、無視する処理はなくしたいと考えています。

まとめ

RDSのスロークエリをElasticsearch Serviceに流すことで、わかりやすくダッシュボードにまとめることができ、また、アラートの設定によりスロークエリの増大を素早く気づけるようになったと思います。

じわじわと増え続けるスロークエリはすぐにサービスに影響を出すものではないため見落とされがちなのですが、突発的なアクセスの増大などがあると簡単にサービスをダウンさせてしまうものなので、このシステムでスロークエリを減らしていきたいです。

Viewing all 734 articles
Browse latest View live