Quantcast
Viewing all 802 articles
Browse latest View live

クックパッドマートAndroidアプリの画面実装を最高にした話【連載:クックパッドマート開発の裏側 vol.4】

こんにちは。 連載シリーズ4日目を担当します、買物事業部 Androidエンジニアの門田(twitter: @_litmon_ )です。

↓↓↓以前の3日分のエントリはこちらから参照ください↓↓↓

買物事業部では、クックパッドの生鮮食品ECサービス「クックパッドマート」の開発を行っています。 今日は、先日リリースしたばかりのクックパッドマートAndroidアプリを開発する上で、画面実装の工夫について紹介しようと思います。

クックパッドマートAndroidアプリはこちらからダウンロードできます。ぜひ実際に触りながら記事を読んでみてください。 play.google.com

クックパッドマートAndroidアプリの画面実装

クックパッドマートAndroidアプリの主な画面は、大きく分けて3つに分類されます。この分類は、多少の違いはあれど一般的なAndroidアプリに対しても同様のことが言えるのではないでしょうか。

  • 一覧画面: データのリストを一覧表示する画面
  • 詳細画面: 一覧画面の特定のデータに対して詳細を表示する画面
  • 入力画面: データを登録したり追加したりするために入力を行う画面
一覧画面詳細画面入力画面
Image may be NSFW.
Clik here to view.
f:id:litmon:20190411123251p:plain
Image may be NSFW.
Clik here to view.
f:id:litmon:20190411123345p:plain
Image may be NSFW.
Clik here to view.
f:id:litmon:20190411123414p:plain

現代のAndroidアプリ開発において、一覧画面にはRecyclerViewが使われるのが一般的です。RecyclerViewは、同一のレイアウトを複数持つ一覧画面において非常に高いパフォーマンスを発揮するViewですが、AdapterやViewHolderなど実装するものが多く、若干扱いにくいのが難点です。

詳細画面の実装に関しては、スマートフォンのディスプレイは縦に長く、スクロールの方向も縦になるアプリが多いため、 ScrollViewやNestedScrollViewを使ってその中にレイアウトを組むのが一般的だと思います。

入力画面には、EditTextやCheckBoxなどを利用して入力欄を用意すると思います。また、入力項目が多くなった場合には詳細画面同様にScrollViewなどを使ってスクロール出来るように実装することが多いのではないでしょうか。

クックパッドマートAndroidアプリでは、上の例に漏れず一覧画面ではRecyclerViewを使い、詳細画面ではScrollViewを使うというスタイルを取っていたのですが、 このレイアウト手法で開発を進めていくのが大変になっていきました。 特に、詳細画面の実装をScrollViewで行っていくことに関して非常に苦しい思いをした例を紹介します。

レイアウトエディタでのプレビューが活用しづらい

ScrollViewを使って縦に伸びるレイアウトを組む場合、縦に伸びれば伸びるほどレイアウトエディタのプレビュー機能が使いにくくなっていきます。 また、レイアウトファイルも肥大化し、非常に見通しの悪い実装になりがちです。

詳細画面の実装がActivity, Fragmentに集中して肥大化しやすい

RecyclerViewを使うと、Viewの実装の大半はViewHolderクラスに分離することが出来ます。 しかし、詳細画面ではScrollViewを使っているため、データをViewに紐付ける処理をどうしてもActivity, Fragment内に書くことが多くなります。 DataBindingやMVVMアーキテクチャなどを使ってViewの実装をActivity, Fragmentから分離する手法などもありますが、プロジェクトによってはあまり適さないケースもあるでしょう。 また、RecyclerViewを使う一覧画面と実装差異が出てしまい、処理の共通化などが難しくなってしまいます。

なにより実装していて苦しい

詳細画面のような複雑なレイアウト構成を1つのレイアウトファイルに対して上から順に実装していくのは、精神的にも苦しいものがあります。 長くなればなるほどレイアウトエディタ, XML両方の編集作業が難しくなっていくため、細かい単位でレイアウトを分割できるRecyclerViewのような仕組みが欲しくなってきます。

includeタグ?知らない子ですね……

すべての画面をRecyclerView化する

そこで、RecyclerViewをうまく使うことで詳細画面もうまく組み立てることが出来るのでは?と考えました。RecyclerViewは、レイアウトを行ごとに分割して作成することが出来るし、ViewHolderへViewの実装を委譲出来るため、ActivityやFragmentの肥大化を防ぐことが出来ます。 ただ、RecyclerViewの実装には、AdapterとViewHolderの実装が必要で、特に複雑な画面になるほどAdapterの実装が面倒になっていきます。

class DeliveryDetailOrderItemsAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    overridefun getItemCount(): Int =
        1 + 1 + items.size + 1overridefun getItemViewType(position: Int): Int {
        if (position == 0) {
            return R.layout.item_delivery_detail_header_label
        }

        if (position == 1) {
            return R.layout.item_delivery_detail_order_item_note
        }

        if (position == itemCount - 1) {
            return R.layout.item_delivery_detail_order_item_footer
        }

        return R.layout.item_delivery_detail_order_item
    }
}

RecyclerViewで受け取り詳細画面を実装したときの一部を抜粋してきました。 表示するpositionに応じてそれぞれのitemViewTypeを変える必要があるのですが、全く直感的ではなく、頭を使って実装する必要があります。 また、Viewを追加したいという変更があったときに、他の部分にも影響が出る場合があるので、保守性も高くありません。

すべての画面でこのような複雑なRecyclerView.Adapterを実装するのは気が滅入りますし、現実的ではありません。 しかも、読み込んだデータに応じて表示を変えなければいけない、となるとより面倒になるのは必至です。 そのため、RecyclerView.Adapterの実装を簡素に行うためのライブラリを導入することにしました。

Groupieを使う

RecyclerView.Adapterの面倒な実装を便利にしてくれるライブラリは巷にいくつかありますが、今回はGroupieを使うことにしました。 同様の仕組みを持つEpoxyというライブラリも候補に上がっていましたが、判断の決め手となったのは以下の点でした。

  • EpoxyはannotationProcessorを使ったコード生成機構が備わっており、DataBindingと連携させるととても便利だが、クックパッドマートAndroidアプリではDataBindingを使っておらずオーバースペックだった
  • GroupieはRecyclerView.Adapterを置き換えるだけで使えるので非常に簡素で、今回のユースケースにマッチしていた

例えば、Groupieを使って一覧画面のようなデータのリストを表示させるために必要なコードは以下です。

dataclass Data(val name: String)

class DataItem(valdata: Data) : Item<ViewHolder>() {
   overridefun getLayoutId(): Int = R.layout.item_data

   overridefun bind(viewHolder: ViewHolder, position: Int) {
       viewHolder.root.name.text = name
   }
}

val items = listOf<Data>() // APIから返ってきたリストとするval adapter = GroupAdapter<ViewHolder>()
recycler_view.adapter = adapter

adapter.update(mutableListOf<Group>().apply {
    items.forEach {
      add(DataItem(it))
    }
})

たったこれだけです。とても簡単ですね。

詳細画面の場合、データの有無で表示する/しないを切り替える必要があったりしますよね。 例えば、クックパッドマートAndroidアプリのカート画面では、カートに商品が追加されていない場合と追加されている場合で表示が異なります。

Image may be NSFW.
Clik here to view.
f:id:litmon:20190411123600p:plain:h300
Image may be NSFW.
Clik here to view.
f:id:litmon:20190411123620p:plain:h300

このようなレイアウトになるようにRecyclerView.Adapterを自前で実装しようとすると、 getItemViewType()メソッドの実装に苦しむ姿が簡単に想像できますね……絶対にやりたくありません。

しかしこれも、Groupieで表現すると以下のように簡単に表現することができます。簡略化のため、表示が変わる部分のみを表現します。

class Cart(
    val products: List<Product>
) {
    class Product
}

class CartEmptyItem : Item<ViewHolder>() { /* 省略 */ }
class CartProductItem(val product: Cart.Product) : Item<ViewHolder>() { /* 省略 */ }

val cart = Cart() // APIから返ってきたカートオブジェクトval adapter = GroupAdapter<ViewHolder>()
recycler_view.adapter = adapter

adapter.update(mutableListOf<Group>().apply {
    if (cart.products.isEmpty()) {
        add(CartEmptyItem()) // 商品が追加されていない旨を表示する
    } else {
        cart.products.forEach {
            add(CartProductItem(it)) // カートの商品をリストで表示する
        }
    }
})

非常にコンパクトな実装に収まります。 前述の例とあわせて見ると、getItemViewType()を実装するのに比べて直感的になることも理解できると思います。

LiveDataと組み合わせて使う

LiveDataと組み合わせて使う場合もとても簡単です。Fragment内で使うことを例に挙げてみましょう。

class CartFragment : Fragment() {

    class CartViewModel : ViewModel {
        valdata = MutableLiveData<Cart>()
    }

    val viewModel by lazy {
        ViewModelProviders.of(this).get<CartViewModel>()
    }

    val adapter = GroupAdapter<ViewHolder>()

    overridefun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_cart, container, false)
    }

    overridefun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        recycler_view.adapter = adapter

        viewModel.data.observe(this, Observer {
            it?.let { cart ->
                adapter.update(mutableListOf<Group>().apply {
                    cart.products.forEach { product ->
                        CartProductItem(product)
                    }
                })
            }
        })
    }
}

非常に簡単ですね。 Groupieはupdate時に内部でDiffUtilsを使って差分更新を行ってくれるため、APIリクエストを行った結果をLiveDataで流すだけで簡単に更新が出来ます。

その際、GroupieのItemに対して以下の2点を見ておく必要があります。

  • id が同一になるようになっているか
  • equals が実装されているか

idの設定は、getId()メソッドをoverrideすると良いでしょう。 もしくは、Itemクラスのコンストラクタ引数にidを渡すことも出来ます。

equals()メソッドの実装は、Kotlinならばdata classで簡単に実装することが出来ます。 また、引数を持たないようなItemで、特に中の内容も変わらないような場合は自前で実装してしまっても良いでしょう。

dataclass CartProductItem(val product: Cart.Product) : Item<ViewHolder>() {

    overridefun getLayoutId(): Int = R.layout.item_data

    overridefun getId(): Int = product.id

    overridefun bind(viewHolder: ViewHolder, position: Int) {
        viewHolder.root.name.text = name
    }
}

// idをコンストラクタで指定class CartEmptyItem : Item<ViewHolder>(0) {

    overridefun getLayoutId(): Int = R.layout.item_data

    overridefun hashCode(): Int = 0// 同じItemなら同じと判定して良いoverridefun equals(other: Object): Boolean =
        (other instanceOf CartEmptyItem)

    overridefun bind(viewHolder: ViewHolder, position: Int) {
        // ignore
    }
}

これらの設定がうまくいっていない場合、更新されたときに無駄なアニメーションが走ってしまうため、できるだけ全てのItemに実装しておくことをオススメします。

実際にクックパッドマートAndroidアプリでは、ほぼすべての画面がこの構成を取って実装していて、画面回転時やFragmentのView再生成にも問題なく状態を再現してくれるのでとても便利な構成になっています。

Groupieを使うことで良くなった点

Groupieを使うことで、RecyclerViewの面倒な実装を簡略化でき、かつすべての画面の実装を定型化することが出来ました。 これにより、以下のような効果が生まれました。

  • Fragmentの実装をすべての画面でほぼ定形化出来るため、精神的に楽に実装できるようになり、かつ処理の共通化が簡単になった
  • RecyclerView.Adapterに比べて、複雑なレイアウトを組むのが非常に簡単なので、実装工数を大幅に削減することが出来た
  • レイアウトが強制的にItem単位になるため、シンプルにレイアウトを作成することが出来るようになった

1つ目の処理の共通化には、例えばエラー画面が挙げられます。 読み込みエラー時の画面表示をGroupieのItemで用意することで、非常に簡単に全ての画面で同一のエラー画面を用意することが出来ます。

class ErrorItem(val throwable: Throwable): Item<ViewHolder>() {
    /* 省略 */
}

apiRequest()
    .onSuccess { data->
        adapter.update(mutableListOf<Group>().apply {
            add(DataItem(data))
        })
    }
    .onError { throwable ->
        adapter.update(mutableListOf<Group>().apply {
            add(ErrorItem(throwable))
        })
    }

また、アプリ内のItemの間に表示されている罫線も、RecyclerViewのItemDecorationを使うことでアプリ全体で簡単に共通化することが出来ました。 RecyclerViewにLinearLayoutManagerとあわせてdividerを設定することがとても多かったため、RecyclerViewに以下の拡張関数を実装して使うようにしています。

fun RecyclerView.applyLinearLayoutManager(orientation: Int = RecyclerView.VERTICAL, withDivider: Boolean = true) {
    layoutManager = LinearLayoutManager(context).apply { this.orientation = orientation }
    if (withDivider) {
        addItemDecoration(DividerItemDecoration(context, orientation).apply {
            ContextCompat.getDrawable(context, R.drawable.border)?.let(this::setDrawable)
        })
    }
}

Groupieで難しかった点

Groupieを使っていて、難しかった点もいくつかあります。 例えば、受け取り場所の詳細画面には地図を表示しているのですが、MapViewにはMapFragmentをアタッチする必要があります。 単純にaddするだけの実装だと、スクロールして戻ってきたときにクラッシュしてしまうので、unbind時にFragmentを取り除く必要がありました。 苦肉の策ですが、現状は以下のような実装になっています。

dataclass SelectAreaDetailMapItem(
    val item: Location,
    val mapFragment: SupportMapFragment,
    val fragmentManager: FragmentManager
) : Item<ViewHolder>() {
    overridefun getLayout(): Int = R.layout.item_select_area_detail_header

    overridefun getId(): Long = layout.toLong()

    overridefun bind(viewHolder: ViewHolder, position: Int) {
        val markerPosition = LatLng(item.latitude, item.longitude)
        fragmentManager.beginTransaction()
            .add(R.id.item_select_area_detail_map, mapFragment)
            .commit()
        mapFragment.getMapAsync { map ->
            map.addMarker(MarkerOptions().position(markerPosition))
            map.moveCamera(CameraUpdateFactory.newLatLng(markerPosition))
            map.setMinZoomPreference(15f)
        }
    }

    overridefun unbind(holder: ViewHolder) {
        super.unbind(holder)
        fragmentManager.beginTransaction()
            .remove(mapFragment)
            .commit()
    }
}

すべての画面でGroupieを使うことで実装が簡単になった、アプリ全体で処理を共通化出来たというメリットはありましたが、こういう風に扱いに困るケースもあるため、用法用量を守って正しくお使いください。

まとめ

  • Androidアプリ開発において主要な画面はだいたいRecyclerViewで表現できる
  • Groupieを使うと実装も簡単になって最高
  • 難しい画面もあるので適材適所で使い分けよう

おしらせ

4/24(木)に、買物事業部のエンジニアによる発表とエンジニアとのミートアップを開催します!!! cookpad.connpass.com

そこでは、今回語らなかったAndroidアプリの技術的な部分を紹介していこうと思います。 実際のソースコードも見せたり……あるかもしれませんね。 ぜひぜひ!!ご興味のある方は参加お待ちしています!


新規事業のIoTプロダクト開発に必要なこと【連載:クックパッドマート開発の裏側 vol.5】

クックパッド 買物事業部の篠原 @shanonimです。社内の新規事業「クックパッドマート」でエンジニアをやっています。

このエントリは、連載シリーズ【連載:クックパッドマート開発の裏側】の第5回目です。本日が最終回となります。 以前のエントリはこちらからご参照ください。

今回はクックパッドマートのIoTプロダクト開発について、開発の概要、これまでの歴史、そしてプロダクトのこれからについてご紹介したいと思います。

このエントリに書いてあること

  • クックパッドマートについて
  • IoTプロダクトの開発経緯
  • 開発の歴史
  • 開発を通して得た学び

このエントリで紹介しないこと

  • IoTプロダクトを構成する個々のデバイスに関する詳しい説明

クックパッドマートの仕組み

クックパッドマートは、生鮮食品に特化したECサービスです。 商品を自宅に直接届けるのではなく、マートステーションと呼ばれる生鮮宅配ロッカー(冷蔵庫)に商品を配送します。
マートステーションは街の様々な場所に設置されており、ユーザーは自分の注文した商品を自分で取りに行くことができます。

Image may be NSFW.
Clik here to view.
f:id:shanonim:20190412174027j:plain
クックパッドマートの仕組み

クックパッドマートのIoTプロダクト

現在、クックパッドマートには大きく2つのIoTプロダクトがあります。

一つは、上図「② 商品の準備」に必要なラベルプリンターです。 こちらについては、2019/4/10に投稿された @imashin_の記事に詳しい説明があります。

techlife.cookpad.com

もう一つは、上図「④ 商品の受け取り」に関連するスマートロックです。 クックパッドマートのアプリからマートステーションの鍵を操作することで、商品を受け取るユーザーだけがマートステーションにアクセスできる仕組みを作っています。

Image may be NSFW.
Clik here to view.
f:id:shanonim:20190412174146j:plain:w400

このエントリでは、スマートロック開発についてご紹介します。

スマートロックの必要性

マートステーションには大きく、

  • 有人ステーション
  • 無人ステーション

の2つの形式があります。
前者は、街なかのドラッグストアや酒店・カラオケ店など、有人店舗の店内に設置されています。現在オープンしているマートステーションはすべて有人ステーションです。

これに加えて、よりユーザーにとって便利な選択肢を増やすために計画しているのが無人ステーションです。

  • 例えば、駅の構内に無人ステーションがあれば、帰宅途中に最寄り駅で生鮮食品をピックアップすることができます。
  • 例えば、マンションの共用部に無人ステーションがあれば、建物の外に出なくても食品を受け取ることができます。

無人ステーションが解決しなければいけない問題の一つに、セキュリティ問題があります。 有人監視のない無人ステーションの場合、悪意ある第三者による商品へのいたずらや盗難といったリスクを否定できません。「特定の人だけがマートステーションにアクセスできる」仕組みが必要です。

そこで、無人ステーションの本格的展開に先駆けて、マートステーション向けのスマートロックを開発することになりました。

開発の方向性

初めからスマートロック付きの冷蔵庫を買ってきて導入できれば話は早いのですが、私たちのニーズにマッチした商品はなかなか見つかりませんでした。 そこで、既存の冷蔵庫を改修してスマートロックを開発することにしました。

Image may be NSFW.
Clik here to view.
f:id:shanonim:20190412174328j:plain
スマートロック実装前のマートステーション

改修と言っても勝手に冷蔵庫を分解して改造することはできません。
今回の開発は冷蔵庫を分解・改造せずに、スマートロックの機構だけを外付け実装するという条件で進めています。

スマートロックの仕組み

鍵の仕組みと一言で言っても、世の中には様々な方法が存在します。物理鍵を鍵穴に差し込んで回すもの、ダイヤル式の番号を合わせるもの、カードをかざして開け閉めするもの...
マートステーションに必要な鍵の条件は、次の2つでした。

  • 遠隔で操作できる仕組みが作れること
  • 物理的な施錠能力が高いこと

条件に合う仕組みを探した結果、電磁錠に辿り着きました。

Image may be NSFW.
Clik here to view.
f:id:shanonim:20190412174434j:plain

電磁錠は、電磁石の特性を利用した鍵です。金属と電磁石を重ねて設置して通電すると、磁石の力でロックされます。 (通電時施錠型と通電時解錠型がありますが、マートステーションでは前者を使用しています。) 通電状態を遠隔でコントロールできれば、施錠状態をコントロールすることができそうです。
また、物理的な施錠能力も非常に高く、大人が思いっきりドアを引っ張ってもビクともしないくらいの強度があります。

スマートロックは、この電磁錠を中心として開発が進められています。

開発の歴史

現在進行中の開発も加え、これまで5つのプロトタイプを製作してきました。それぞれの世代にはコードネームとして貝の名前がつけられています。(貝の甲羅が開いたり閉じたりする様子が鍵の開け閉めを連想するという @_litmon_のアイディアです。)

第一世代: シジミ

Image may be NSFW.
Clik here to view.
f:id:shanonim:20190412174717j:plain
Image may be NSFW.
Clik here to view.
f:id:shanonim:20190412174642j:plain

鍵の制御用デバイス 鍵の切り替え用デバイス
電磁錠(通電時施錠型) M5Stack Grove - Dry-Reed Relay

冷蔵庫の上部に電磁ロックを取り付けています。アプリからインターネットを介して制御装置(M5Stack)に解錠コマンドを送ると、冷蔵庫の扉を開けることができます。

課題

  • 熱問題: 電磁錠を連続可動させると、本体が熱を持ってしまう問題がありました。機器が壊れるほどの温度ではありませんが、連続稼働に不安があります。
  • 耐久性: 鍵自体の耐久性に問題がありました。鍵がかかっていることを知らずに冷蔵庫の扉を開けたユーザーが鍵を壊してしまう事件がありました。

第二世代: アサリ

Image may be NSFW.
Clik here to view.
f:id:shanonim:20190412174743j:plain
Image may be NSFW.
Clik here to view.
f:id:shanonim:20190412174801j:plain

部品構成は第一世代と同じですが、電磁錠の設置場所を冷蔵庫上部から冷蔵庫内部に変更しています。 これにより、前世代の熱問題を解決することができました。

課題

  • 耐久性: 冷蔵庫内部に固定した電磁錠が時折ずれてしまい、うまく鍵が閉まらない事象が頻発しました。

第三世代: ハマグリ

Image may be NSFW.
Clik here to view.
f:id:shanonim:20190412174830j:plain

第三世代では、電磁錠の設置場所が冷蔵庫下部に変更されています。 冷蔵庫の製造メーカーに設置方法を相談したり、スマートロックの先行事例を研究したり、様々な試行錯誤の末にようやく落ち着いた仕様でした。

第四世代: ホタテ

Image may be NSFW.
Clik here to view.
f:id:shanonim:20190412174903j:plain:w400

現在マートステーションの一部で稼働しているスマートロックは、この第四世代です。 この世代では、物理鍵以外のデバイスを刷新しました。

鍵の制御用デバイス 鍵の切り替え用デバイス
電磁錠(通電時ロック型) Androidタブレット スマートプラグ

鍵の制御用デバイスの刷新

これまで鍵の制御に使っていたマイコン(M5Stack)には2つの課題がありました。

  • 安定性: 長時間の連続稼働が難しく、物理的な故障リスクが高い。
  • 視認性: 前面のディスプレイが小さく、鍵がかかっているのかいないのか分かりにくい。

これらを解決するために、マイコンをAndroidタブレットに変更しました。

鍵の切り替え用デバイスの刷新

これまで、物理鍵の制御(電流の制御)は自分で手作りしたデバイスを使っていました。動作的には問題ないのですが、一般的に市販されているデバイスと比べてみると、安全性や耐久性にやや不安が残ります。

Image may be NSFW.
Clik here to view.
f:id:shanonim:20190412174931j:plain:w600
手作りしていた鍵の切り替え用デバイス

そのため、このデバイスを市販のスマートプラグに変更しました。

第五世代: サザエ

第五世代は、現在開発中の次世代プロダクトです。 デバイスの耐久性をより強固にしたり、プロダクトの状態を外部から常時監視できる仕組みを作りたいと思っています。

新規事業のIoTプロダクトの開発に必要なこと

アイディアを片っ端から試す

今回のスマートロック開発では、プロトタイピングのために必要な資材を買って、試して、ダメなら壊す、を高速で繰り返してきました。 日々手探りの連続ですが、思いついたアイディアを片っ端から試していくことが大切です。
思いついた時点では微妙だなーと思うアイディアも、実際に作ってみると案外悪くなかったり、逆にこれでいける!!と思ったアイディアも作ってみるとダメだったりします。
初期の開発においては「雑でもいい、まだプロトタイプなんだから」と割り切ってとにかく手を動かして学びを得ていくのが一番の近道だと思います。

実際に使ってもらう

実際にプロトタイプが形になったら、とにかくいろんな人に使ってもらうことをおすすめします。 これはソフトウェアのサービス開発におけるユーザーインタビューと同じ概念かもしれません。

マートステーションはクックパッド社内にも設置しており、スマートロック開発の際はここを物理ステージング環境として使いました。

Image may be NSFW.
Clik here to view.
f:id:shanonim:20190412175017j:plain

今もユーザー(クックパッド社員)に意見をもらいながら、日々改善を繰り返しています。

まとめ

マートステーションのスマートロック開発を通して得た「IoTプロダクト開発に必要なこと」をご紹介しました。 最初から完璧なIoTプロダクトを作ることはほぼ不可能です。仕様の検討や実装に時間がかかってしまうため、現実的ではありません。
必要なのは、「次世代版までの時間を稼ぐ現世代版を開発する繰り返し」だと思っています。

  • まずは1~10個世代を作る
  • その世代での学びを、次の10~100個世代の開発に活かす
  • さらにその先の運用に耐える100~1000個世代世代を開発する

現在のマートステーションもまだ完成形ではありません。これからも着実かつ高速に、この繰り返しを続けていきたいと思っています。

cookpad mart Meetup

このエントリでお伝えしきれなかった技術的な話やもっと深い話を、4/24に開催されるミートアップでご紹介する予定です。 cookpad.connpass.com

ご興味のある方はぜひご応募ください!

クックパッド一同は、RubyKaigi 2019でみなさんにお会いできることを楽しみにしています!

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

先月公開した【RubyKaigi 2019 参加者に捧ぐ】福岡で起業した男が本気で書いた福岡グルメまとめは見ていただけましたでしょうか? 私は、ブログへの掲載はありませんでしたが、福岡の餃子には牛肉をタネに使っているお店があるとの情報を入手したので、食べに行くぞと意気込んでいます。

クックパッド株式会社は、2019年4月18日(木)〜20日(土)に福岡にて開催される RubyKaigi 2019に、Ruby Committers’ SponsorとWi-Fi Sponsorとして協賛します。

Wi-Fi Sponsorに関しては、調達・設計・構築・運用などを、昨年に引き続き @sorahが担当しております。

クックパッドに所属する4名が登壇し、 @asonas@sorahが運営として関わってくれています。

本ブログでは、登壇する社員のセッションのスケジュールや、ブースで行う登壇者へのQ&Aタイム、Cookpad Daily Ruby Puzzles など限定企画の紹介をいたします。RubyKaigi 2019に参加する弊社メンバーは内定者を含め約30名! みなさまと交流することを楽しみにしています。

参加社員一覧

@mirakui, @tapster, @kanny, @takai, @hogelog, @l15n, @ko1, @mame, @hokaccha, @eisuke, @giga811, @riseshia, @inohiro, @ukstudio, @hfm, @davidstosik, @aadityataoaria, @sikachu, @asonas, @sorah, @pndcat, @sankichi92, @kojitaniguchi, @to9nariyui

登壇スケジュール

はじめに、社員が登壇するセッションのスケジュールを紹介します。

1日目 4月18日(木)

  • 14:20-15:00 笹田耕一(@ko1): Write a Ruby interpreter in Ruby for Ruby 3
    本発表では、RubyインタプリタをRubyで記述するために必要となる要素技術についてご紹介します。Rubyで、といっても、コア部分は相変わらずCで書く必要がありますが、組込メソッドをなるべくRubyで置き換えていきたいという話になります。ここで、課題になるのは、(1) Ruby だと呼び出しが遅いかもしれない (2) Ruby だと読み込みが遅いかも知れない、の二つです。本発表では、これらをどのように解決するかについて議論します。
  • 15:40-16:20 遠藤侑介(@mame): A Type-level Ruby Interpreter for Testing and Understanding
    Ruby 3の静的解析技術の1案として、Rubyプログラムを型レベルで仮想的に実行するRuby処理系を提案します。すでに提案されている他の静的解析と異なり、型注釈がなくてもなんとなく検査・推定ができるところが特徴です。仮想的な実行の過程で発見された型エラーの可能性や、メソッド呼び出しの引数や返り値の型を報告することで、ユーザのテストやプログラム理解を支援することを目指しています。本発表では、詳しいアイデアと、他手法との比較、現在どこまで実装できているかなどを説明します。

2日目 4月19日(金)

  • 17:20-18:20 ライトニングトーク
    • 井上寛之(@inohiro): Write ETL or ELT data processing jobs with bricolage.

3日目 4月20日(土)

  • 10:00-11:10
    • Cookpad Presents: Ruby Committers vs the World こちらのお時間では、Cookpad Ltd CTOの Miles Woodroffeがご挨拶いたします。また、笹田耕一と遠藤侑介が司会を務めます。
  • 11:20-12:00 Sangyong Sim(@riseshia): Cleaning up a huge ruby application
    cookpad.com を支える巨大なレポジトリから未使用コードの削除を進めています。この作業は比較的コストが高い割にリターンが見えづらいです。この問題をどういった仕組みで解決しようとしているのかについて、お話しします。 Ruby 2.6 で導入された Oneshot coverage などを利用し、本番で実行されたコードを記録する仕組みも紹介します。

ブース

RubyKaigi 2019にて出展するクックパッドブースでは、福岡県にゆかりがある料理の限定レシピ集をお配りするほか、 @mame@ko1が考案した Cookpad Daily Ruby Puzzles を一日3問ずつ公開いたします。各問題に最小数の文字を追加し、 "Hello world"を出力してください。

# example
def foo
  "Hello world" if
    false
end

puts foo

早く回答できた方にはCookpad Pad RubyKaigi 2019 Edition など、数量限定で特別なプレゼントをご用意しています。みなさんの挑戦お待ちしております! また、下記スケジュールの通り、登壇者へのQ&Aタイムなどを予定しております。グッズの配布や昨年好評だった豆つかみもバージョンアップして行いますので、ぜひお立ち寄りくださいね。

Image may be NSFW.
Clik here to view.
f:id:tokunarigyozadaisuki:20190415112930j:plain

1日目 4月18日(木)

  • 15:10-15:40 【ブースイベント】午後休憩: Q&Aタイム by @ko1
    この時間は、クックパッドブースに @ko1がおりますので、1日目 14:20-15:00 Write a Ruby interpreter in Ruby for Ruby 3 に関するご質問がある方は、ぜひこの時間にブースにて、本人に聞いてみてください。

2日目 4月19日(金)

  • 12:30-13:00 【ブースイベント】ランチ休憩: Q&Aタイム by @mame
    この時間は、クックパッドブースに @mameがおりますので、1日目 15:40-16:20 A Type-level Ruby Interpreter for Testing and Understanding に関するご質問がある方は、ぜひこの時間にブースにて、本人に聞いてみてください。
  • 15:00-15:40 【ブースイベント】午後休憩: クックパッド子会社 ウミーベ株式会社タイム
    昨年、クックパッドグループに加わったウミーベ株式会社。ウミーベのオフィスはRubyKaigi 2019の開催地、福岡県福岡市の海辺にあります。本時間には、ウミーベCTO @Niaがブースにおりますので、ウミーベのサービスについてご質問がある方、福岡で働くことに興味がある方はぜひお気軽に話しを聞いてみてください。

3日目 4月20日(土)

  • 12:30-13:00 【ブースイベント】ランチ休憩:Q&Aタイム by @riseshia
    この時間は、クックパッドブースに @riseshiaがおりますので、3日目 11:20-12:00 Find out potential dead codes from diff に関するご質問がある方は、ぜひこの時間にブースにて、本人に聞いてみてください。

  • 15:10-15:40 【ブースイベント】午後休憩:Cookpad Daily Ruby Puzzlesの解説
    @mameより、ブースにて三日間に渡って出題した全9問の Cookpad Daily Ruby Puzzles の解説を行います。解けた方も解けなかった方も、考案者からの解説を聞いてスッキリしてください! 

おわりに

クックパッドでは、料理で世界に挑戦する仲間を探しています。クックパッドで働くことにご興味のある方は、お気軽にブースにお越しください。また、会場でクックパッド社員をお見かけの際には、お声がけいただけますと嬉しいです! みなさまにお会いできることを社員一同楽しみにしております。

RubyKaigi 2019 "A Type-level Ruby Interpreter for Testing and Understanding"の発表要旨

こんにちは、クックパッドで仕事で Ruby の開発をしている遠藤(@mametter)です。もうすぐ RubyKaigi ですね! クックパッドはいろんな形で RubyKaigi に参加していく予定なのでよろしくお願いします。詳しくは昨日の記事をごらんください。

さて、そういうわけで RubyKaigi です。遠藤は "A Type-level Ruby Interpreter for Testing and Understanding"という発表を予定しています。遠藤の発表予定の内容をあらかじめざっと紹介してみます。

この予稿は発表資料を作り終えてから書いているのですが、発表資料よりも要点がまとまっている気がします。

はじめに: Ruby 3の静的解析

2020 年にリリースが予定されている Ruby 3 は、「静的解析」「高速化」「並列性」の 3 つを備えることを目標に掲げています。この発表は 1 つめの「静的解析」に関わるものです。

Ruby 3 に向けた型システムとして、SteepSorbetが提案されていますが、いずれもメソッドの型はユーザが指定する前提になっています。

本発表では、「型は絶対に書きたくないでござる」の人たちのために、型注釈がない Ruby プログラムに適用可能な静的型解析器、「型プロファイラ」を提案します。

型プロファイラとは

型プロファイラは、

  • 型注釈がない素の Ruby プログラムを入力して、
  • 型エラーの可能性を警告したり(Testing)、
  • 型シグネチャのプロトタイプを生成したり(Understanding)

できるツールです。

Testing の例

型エラーを警告する例を示します。

deffoo(n)
  if n < 10
    n.timees {|x| # TYPO!
    }
  endend

foo(42)

このプログラムは Integer#timesを typo して timeesと書いています。このプログラムに型プロファイラを適用すると、次のような出力が得られます。

$ ./run.sh /tmp/test.rb
/tmp/test.rb:3: [error] undefined method: Integer#timees
Object#foo :: (Integer) -> (NilClass | any)

Integer#timeesは undefined である、というエラーが出ています。なお、元のプログラムを普通に実行するだけではこのバグを検知できないことに注意してください(n < 10 なので)。

Understanding の例

次は型シグネチャのプロトタイプを得る例です。

deffoo(n)
  n.to_s
end

foo(42)
foo("STR")
foo(:sym)

このプログラムに型プロファイラを適用すると、次のような出力が得られます。

$ ./run.sh /tmp/test.rb
Object#foo :: (Integer) -> String
Object#foo :: (String) -> String
Object#foo :: (Symbol) -> String

fooはオーバーロードされていて、

  • Integer を受け取ったら String を返す
  • String を受け取っても String を返す
  • Symbol を受け取っても String を返す

ということを表現する型シグネチャのプロトタイプとして使えます。

これが型プロファイラの基本です。

もっとデモがみたい!

このへんにいろいろ転がってます。詳しくは発表で。

https://github.com/mame/ruby-type-profiler/tree/master/smoke

型プロファイラをどのように使うか?

おおまかに 2 つの使い方を想定しています。

  1. 開発中にテストと合わせて実行し、型エラーの可能性を調べてみる
  2. Ruby プログラムから型シグネチャをプロトタイプし、手修正の上で型検査器(Steep や Sorbet)を使ってきちんと検証する

前者の使い方は、従来の Ruby のプログラミング体験にあまり影響を与えず、静的解析を補助的なテストとして利用する方法です。 推定される型シグネチャは特に利用しないか、参考程度にします。

後者の使い方は、型シグネチャの生成支援です。 型シグネチャをあとからまとめて書きたい場合、特に既存の Ruby プログラムに対して型検査器を適用する際に役立つと思います。 また、よくわからない Ruby プロジェクトをいじらないと行けないとき、プログラムの中にどのようなクラス・メソッド定義があるかを俯瞰するためにも有用かもしれません。

型プロファイラのメリット・デメリットは?

メリットはただ 1 点に集約されます。

  • 型注釈がなくても型検査・型推論っぽいことができる

デメリットはいろいろあります。

  • 誤った警告(false positive)を出すことがある
  • 各メソッドを起動する型レベルのテストが必要、足りないとバグの見逃しにつながる
  • 原理的に扱えない Ruby の言語機能がある(たとえば Object#send や特異クラス)
  • スケーラビリティに問題がある

長くなるのでこの記事では説明を省きますが、発表ではこれらの問題の分析や、それらに対して何ができると考えているかについて駆け足で語ります。

型プロファイラはどのように動いているか?

今回のメインコンテンツです。型レベルで Ruby プログラムを解釈実行するインタプリタがコアになっています。

deffoo(n)
  n.to_s
end

foo(42)

というコードがあったとき、普通のインタプリタであれば

  1. 関数 fooに整数 42 を渡して呼び出す
  2. 関数 fooの中を n = 42の環境で評価する
  3. n.to_sを実行した結果の文字列 "42"を関数 fooがリターンする
  4. foo(42)の呼び出しが "42"を返して実行再開する

というように実行が進んで行きます。型プロファイラはこれを型レベルで行います。つまり、

  1. 関数 fooに整数 Integer を渡して呼び出す
  2. 関数 fooの中を n :: Integerの環境で評価する
  3. n.to_sを実行した結果の String を関数 fooがリターンする
  4. foo(42)の呼び出しが String を返して実行再開する

このような型レベルでの実行を記録し、各関数に渡された引数(Integer)や返した返り値(String)を集めて、型シグネチャのような形式にして出力します。

分岐があったらどうするか?

関数に型レベルの情報しか渡さないので、分岐の条件を正確に評価できなくなります。たとえば次の例。

def foo(n)
  if n < 10
    n
  else
    "string"
  end
end

foo(42)

n < 10という条件式がありますが、nには 42 という具体的な値ではなく Integer という型レベルの情報しか入っていないので、分岐を正確に実行することはできません。

型プロファイラは、分岐があったら状態をフォークします。つまり、true の可能性と false の可能性を両方とも実行します。上の例で言うと、true のケースは n (Integer) をリターンする、false のケースは "string" (String) をリターンする、ということで、これらを組み合わせて

$ ./run.sh /tmp/test.rb
Object#foo :: (Integer) -> (String | Integer)

というシグネチャを生成して出力します。

このフォークのせいで、うまくないコードを書くと状態爆発につながってしまいます。通常のコードで状態爆発が起きにくいように抽象化の粒度や状態管理をうまくやるのが、型プロファイラの設計のむずかしいところです。

この手法は何なのか?

普通の型システムとはいろいろ異なると思います。普通の型システムは、メソッドなどのモジュール単位で検査できるよう、メソッド間をまたがない(intra-procedural な)解析になるように設計されます。この点、型プロファイラはメソッド呼び出しがあったときにメソッド本体を呼び出すので、メソッドをまたがる(inter-procedural な)解析になっています。

型プロファイラの手法を指すぴったりの技術名は調べてもわかりませんでしたが、どちらかというと、抽象解釈や記号実行といった技術に近いようです。

なお、inter-procedural な解析は、先に問題として述べたとおり、スケーラビリティとの戦いになりやすく、型プロファイラも例外ではありません。発表ではどのように対策してきたか、対策していきたいと考えているかを議論します。

型プロファイラの完成度は?

発表で詳しくいいますが、端的に言えば、残念ながらまだまだ完成度が低いです。ソースコードは mame/ruby-type-profilerに公開してありますが、正直に言って、まだまだみなさんのコードに適用を試せる段階にはないです。スケーラビリティのための根本的な対策検討から、地道な組み込み機能のサポートまで、やることがたくさんあって手が回っていません。型を書かない Ruby 体験を維持したいと思っている方は協力をご検討いただけると嬉しいです。

まとめ

本発表では、型注釈がない Ruby プログラムに適用可能な静的型解析器、「型プロファイラ」を提案します。 抽象解釈の考え方に基づいていて、現在のところ型なし Ruby 体験を維持できそうな静的解析アプローチの唯一の提案になっています。

発表では、ここまでに実装できている機能のデモや、現状の問題点の解説、preliminary ながら評価実験などをいろいろご紹介したいと思ってます。 ぜひ聞いていただいて、前向きに興味を持ってくれた方とも批判的な立場の方ともいろいろ議論できることを楽しみにしています。

RubyKaigi 2019: Write a Ruby interpreter in Ruby for Ruby 3

技術部の笹田です。フルタイム Ruby コミッタとして働いているので、明日から始まる RubyKaigi 2019 は仕事で行きます。あまり日のあたることが少ない我々の晴れの舞台です。

宣伝もかねて、RubyKaigi 中に自分がどんな仕事があるか並べてみました(クックパッド全般の話は、「クックパッド一同は、RubyKaigi 2019でみなさんにお会いできることを楽しみにしています!」 をご覧下さい)。

  • 毎朝、クックパッドブースで「Cookpad Daily Ruby Puzzles」を紙で配付しますので、興味がある方はお持ち下さい。
  • 1日目
    • 11:20-「Ruby 3 Progress Report」まつもとさんの keynote 後、Ruby 3 の進捗みたいなことをご紹介します。
    • 14:20-「Write a Ruby interpreter in Ruby for Ruby 3」私の accept された発表です。
    • 15:00- 休憩時間、クックパッドのブースにおりますので、ご質問がある方はお運びいただければ幸いです。
  • 3日目
    • 10:00- 「Ruby Committers vs the World」恒例のコミッタを壇上に並べる出し物です。Q&A になるかと思います。https://forms.gle/f7zZt1pKCA5HTABe9から、まつもとさんやコミッタに質問をお寄せ下さい。
    • 15:00- 休憩時間、クックパッドのブースにて、「Cookpad Daily Ruby Puzzles」の解説が遠藤さんからあるのを眺める予定です。
    • 終了後、RubyKaigi 子供会という、子供連れが集まる宴会を企画しています(保護者会だったかもしれない)。

あれ意外と少ない。京都でやったときは、一日中並列化の議論をしていた気がする。開催の前日に Developer's meeting と、翌日に after hackathon があるので、まぁやはり大変かも知れません。

さて、本稿では、私の発表、「Write a Ruby interpreter in Ruby for Ruby 3」についてご紹介します。下手な英語で発表する予定なので、こちらでは日本語で記事として残しとこうという意図になっています。

この発表は?

Image may be NSFW.
Clik here to view.
f:id:koichi-sasada:20190417011701p:plain
title_image

発表タイトルを直訳すると、「Ruby 3 にむけて、Ruby でインタプリタを書いていこうぜ」という感じになるでしょうか。

今、MRI (Matz Ruby Interpreter) は、ほぼすべて C で書かれています。タイトルを読むと、これを Ruby に全部置き換えよう、と見えるかも知れませんが、意図としては、「Ruby で書いた方がよいところは Ruby で書けるようにしよう」というものです。Ruby で Ruby をすべて self-host しよう、みたいな RubyでつくるRubyのような話ではありません。

現実的に、良い感じの仕組みを導入して、Ruby 3 をよりよくしましょう、という提案になります。

発表資料は http://www.atdot.net/~ko1/activities/2019_rubykaigi2019.pdfからダウンロード頂けます(修正等、随時更新が入ります)。

背景:現状と問題点

MRI での組込クラス・メソッドの定義の方法

現在、Ruby の組込クラス・メソッドのほとんどは、C で記述されています。String だとこんな感じ。

void
Init_String(void)
{
    rb_cString  = rb_define_class("String", rb_cObject);
    ...
    rb_define_method(rb_cString, "<=>", rb_str_cmp_m, 1);
    rb_define_method(rb_cString, "==", rb_str_equal, 1);
    rb_define_method(rb_cString, "===", rb_str_equal, 1);
    rb_define_method(rb_cString, "eql?", rb_str_eql, 1);
    ...
    rb_define_method(rb_cString, "length", rb_str_length, 0);
    rb_define_method(rb_cString, "size", rb_str_length, 0);
    ...
}

基本的に、rb_define_classというクラスでクラスを定義して、そのクラスに rb_define_methodでメソッドを追加していく、というものです。rb_define_methodでは、名前と実装している関数、それから arity (引数の数)を指定します。String#lengthの場合は、rb_str_lengthという関数で実装されているようです。

VALUE
rb_str_length(VALUE str)
{
    return LONG2NUM(str_strlen(str, NULL));
}

こんな感じで、C で Ruby のメソッドが記述できます。この場合、String#lengthメソッドが呼ばれると、最終的には rb_str_length()が呼ばれる、というものです。C プログラマなら、見ればわかるような構造になっていて、わかりやすいです。

(実は、prelude.rbという、Ruby で定義を書く方法もあったりしますが、あまり使われていません)

なお、このように C で定義されたメソッドを C メソッド、Ruby で定義されたメソッドを Ruby メソッドと呼ぶことにしましょう。

現状の問題点

さて、このわかりやすい構造ですが、現在はいくつか問題があります。4つにまとめてみました。

  • (1) アノテーション(メタデータ)の問題
  • (2) 性能の問題
  • (3) 生産性の問題
  • (4) API に context を追加したい問題

これらの問題を解説します。

(1) アノテーション(メタデータ)の問題

C メソッドには、いくつかの意味で情報が足りていません。

(a) Ruby メソッドに比べて情報が足りません。

例えば、Method#parametersという、パラメータ名を取得刷るメソッドを利用すると、

defhello(msg) puts "Hello #{msg}"; end
p method(:hello).parameters
#=> [[:req, :msg]]

このように、Ruby メソッドの引数の名前 msgを取得することができます。他にも、バックトレース情報など、こんな感じで Ruby メソッドに比べて情報が落ちているところがあります(時々聞く、stack-prof で C メソッドが出てこなくて困る、というのは、これが理由です)。

これらは、Ruby で定義すれば、持っていたはずの情報になります。

(b) 最適化のために必要な情報が足りません。

とくに、メソッドをまたぐ最適化を行おうとすると、あるメソッドがどのような性質を持つか、例えば「副作用を持つ・持たない」という情報はとても重要になります。しかし、C で実装されたメソッドの性質を調べようとすれば、C のソースコードの解析が必要になり、現実的ではありません。

たとば、str.gsub("goodby", "hello")というプログラムでは gsubに渡した引数を弄るかも知れないので、呼び出す度に2つの文字列を生成します。しかし、gsubは引数を弄らないので、本来であれば、frozen な文字列を(毎回生成せずに)渡すだけで良いはずです。frozen-string-literal pragma を使えば、プログラマがそのように指定することができますが、煩雑です。gsubがこのようなメソッドである、という情報を付加できれば、MRI が自動的に判断できそうです(がんばれば)。

これらは、MRI 開発者ががんばって付けていく情報になります。

(c) どれくらいメソッドが定義されるか、事前にわかりません。

rb_define_methodで定義すると、起動が終わらないと、あるクラスに、どれくらいのメソッドが定義されるかわかりません。わかっていれば、先にメソッドテーブルをそのサイズで確保する、みたいなことができます。が、現在そういうのができません。

定義が事前に解析出来る形で書いてあれば、得られる情報です。

(2) 性能の問題

多くの場面で、C は Ruby よりも速いです。いろんな理由がありますが(最近、なぜrubyは他の言語と比べて遅いのでしょうか?という Quora の質問にこたえてみましたので、よかったら参考にして下さい)、まぁ適材適所、向いてる言語を使うべきでしょう。Ruby の主要部分を C で書くのは、そこそこ妥当だと思います(現代では、Rust などのより安全な言語を視野にいれるべきだとは思います)。

ですが、いくつかの場面で、実は Ruby は C で書くよりも速いことがあります。典型的な例は、キーワード引数の処理です。

# Rubydefdummy_func_kw(k1: 1, k2: 2)
  dummy_func2(k1, k2)
end

こういう処理を C で書こうとすると、結構面倒ですがこんな感じになります。

static VALUE
tdummy_func_kw(int argc, VALUE *argv, VALUE self)
{
    VALUE h;
    ID ids[2] = {rb_intern("k1"), rb_intern("k2")};
    VALUE vals[2];

    rb_scan_args(argc, argv, "0:", &h);
    rb_get_kwargs(h, ids, 0, 2, vals);
    return tdummy_func2(self,
                        vals[0] == Qundef ? INT2FIX(1) : vals[0],
                        vals[1] == Qundef ? INT2FIX(2) : vals[1]);
}

これらのメソッドの速度を比較してみましょう。

Image may be NSFW.
Clik here to view.
f:id:koichi-sasada:20190417011812p:plain
キーワード引数のあるメソッドの呼び出しの速度比較

キーワード引数がないときは、C の方が速いです。というのも、Ruby での dummy_func2()呼び出しは、C での tdummy_func2)()関数呼び出しよりも圧倒的に遅いからです。

しかし、キーワードを与えると、圧倒的に Ruby で書いた方が速いです。というのも、キーワード引数のあるメソッドに、Ruby でキーワード引数を渡すときは、ハッシュオブジェクトを生成しない、特別な最適化が施されているからです。

例外処理も、同じような理由で Ruby で書いた方が速いです。

# in Rubydefem_dummy_func_rescuenilrescuenilend
static VALUE
dummy_body(VALUE self)
{
    return Qnil;
}
static VALUE
dummy_rescue(VALUE self)
{
    return Qnil;
}
static VALUE
tdummy_func_rescue(VALUE self)
{
    return rb_rescue(dummy_body, self,
             dummy_rescue, self);
}

Image may be NSFW.
Clik here to view.
f:id:koichi-sasada:20190417011909p:plain
例外処理の速度比較

このように、たまにある「Ruby で書いた方がいい場合も、C で書いちゃう」という問題があります。

(3) 生産性の問題

(2) で例を出したように、Ruby だと数行のものが、C で書くと何十行、複数関数にまたがる、みたいなことがよく起きます。 C で表現するためにしょうがない部分なんですが、大変です。

例外処理やイテレータ、キーワード引数の処理なんかが該当しそうです。

また、あまり呼ばれないメソッドの場合、ささっと Ruby で定義しちゃってもいいかもしれませんね。今だと gem でやれって言われるかもしれませんが...。

余談ですが、私は C でキーワード引数の処理を書きたくなさ過ぎて、prelude.rbで Ruby 2.6 で導入した TracePoint#enable(target:)を実装しました。楽だったー。

(4) API に context を追加したい問題

rb_deifne_method()で登録する関数の引数は、基本的に selfとパラメータ情報になります。しかし、我々が進めている並列処理機構である Guild では、現在の「コンテキスト」情報を渡す必要があります。mruby における mrb_state *です。

# mruby String#length
static mrb_value
mrb_str_size(mrb_state *mrb, mrb_value self)
{
  mrb_int len = RSTRING_CHAR_LEN(self);
  return mrb_fixnum_value(len);
}

Thread-local-storage (TLS) に保存する、と言う方法もありますが、とくに shared library 経由で利用すると、とても遅いことが知られています(詳細は、笹田等:Ruby 用マルチ仮想マシンによる並列処理の実現 (2012))。そこで、第一引数に、mruby みたいに情報を引き渡したすために API の変更が必要です。

問題の最後 (4) に持ってきましたが、個人的にはこれが一番なんとかしたい問題です。ただ単に API を変更しても、なかなか追従してもらえないんですが、いろんな特典がついたほうが移行しやすいよね、という戦略です。

問題のまとめ

4つの問題をまとめました。

  • (1) アノテーション(メタデータ)の問題 -> DSL が必要
  • (2) 性能の問題 ->時々 Ruby のほうが速い
  • (3) 生産性の問題 -> Ruby で十分のときがある
  • (4) API に context を追加したい問題

(1) は、新たにメソッド定義のための DSL があれば解決しそうです。あれ、そういえば、DSL を構築しやすい言語に心当たりがあったような?

解決案:Ruby をつかおう!

問題を解決するために、Ruby で定義もしくは宣言を行うことを考えました。すべてを Ruby で置き換えるわけではなく、C で書いた方がよいところは C で書いて、Ruby の定義から簡単に呼び出せるようにすれば(FFIの導入)、既存の資産も有効活用でき、C の圧倒的な性能も利用できて良さそうです。

Ruby で書いておけば、後から解析することで、いろいろなことがわかります。また、内部DSL的にメソッドにアノテーションを付けることも可能でしょう。

問題点はこのように解決できます。

  • (1) アノテーション(メタデータ)の問題 -> Ruby で DSL を書いて解決
  • (2) 性能の問題 ->素直に Ruby が得意なところで Ruby を書けば解決
  • (3) 生産性の問題 -> Ruby で簡単に済むところは Ruby で済ますことで解決
  • (4) API に context を追加したい問題 -> FFI で context を渡すようにすれば解決

新しい書き方

では、具体的にどんなふうに書いていくでしょうか。

文字列のメソッドを定義する string.rbを新設し、lengthメソッドを定義することを考えます。

# string.rbclassStringdeflength
    __ATTR__.pure
    __C__.str_length    
  endend
# String#length impl. with new FFI
static VALUE
str_length(rb_ec_t *ec, VALUE str)
{
    return LONG2NUM(
      str_strlen(str, NULL));
}

こんな感じで、__C__.str_lengthと書くと、str_length()が呼ばれる、という仕組みです。

なお、__C__は適当です。多分、変わると思います。また、特別な実行モードでのみ利用可能になると思います。普段はローカル変数(もしくはメソッド名)ですね。

__ATTR__.pureも適当にでっちあげてるだけですが、こんな感じで、String#lengthの属性を人間が書けるようにしていければなと思っています。

これを使うと、プログラマはこんな感じになると思います。

  • Ruby の機能を使うことで、簡単に書けるところは簡単に書けるようになる。
  • C の関数を簡単に呼べるので、性能を落とさずにちゃんと書けるようになる。
  • いくつかの点に気を付けなければならない
    • GVL リリースや、GC タイミングなどが変わるので、気にする人はきにしないといけません。
    • 従来通りにしたければ、単に C の関数を呼び出す、というようになります。

疑問

さて、どうでしょうか。書きやすく、良さそうな感じがしないでしょうか。

ただ、きっと、パフォーマンスについて気にする人(私とか)は、次の点が気にならないでしょうか。

  • ランタイムオーバヘッド:FFI で C 関数呼び出しって遅いんじゃないの?
  • スタートアップ時間:Ruby スクリプトを読み込むから、スタートアップ時間が長くなってしまうんじゃないの?

この二つの疑問に答えるために、本発表では、次の二つの技術的成果についてご紹介します。

  • 高速な FFI を実現するための VM 命令の追加
  • ロード時間削減のためのコンパイルバイナリフォーマットの改善

ざっくり結論を申しますと、この二つの技術的成果を用いることで、C で全部書くよりは、若干遅いけど、でも十分速くなるので、多分問題ないんじゃないかな? という感じです。

ここまできて、やっと本題にたどり着きました。

高速な FFI を実現するための VM 命令の追加

長くなったので、手短に行きます。

__C__.func(a, b)のように関数を呼び出せるようにするために、invokecfuncという命令を VM に追加しました。fiddle などのミドルウェアを用いずに C の関数を呼び出すので高速です。

# string.rbclassStringdeflength
    __C__.str_length    
  endend

こういうプログラムは、

== disasm: #<ISeq:length@string.rb:10>
0000 invokecfunc                     
0002 leave

こんな感じでコンパイルされます。

ただ、invokecfuncを用いる関数呼び出しは、従来の C メソッドよりもオーバヘッドがあります。

  • (1) 引数を VM スタックに push するので遅い
  • (2) leave 命令でフレームを抜けるので、1命令実行が余分にかかり遅い

そこで、(1) の問題のために、__C__.func(a, b)に渡す実引数が、そのメソッドの仮引数 def foo(a, b)とまったく等しいとき、VM スタックにプッシュするのではなく、関数の引数にメソッドの引数をそのまま利用する invokecfuncwparam命令を追加することにしました。

defdummy_func2 a, b
  __C__.dummy_func2(a, b)
end
0000 invokecfuncwparam<dummy_func2/2>
0002 leave

これで、「(1) 引数を VM スタックに push するので遅い」の問題が解決します。組み込み関数は、だいたい C で書いてある関数をそのまま呼ぶことになるんじゃないかと思うので(つまり、C 関数への delegator のような実装になるんじゃないかと思うので)、この命令を作る価値はあるのではないかと判断しました。

そして、leaveをわざわざ次命令でやるのは無駄じゃないかと言うことで、invokecfuncwparam命令の次の命令が leaveの場合、その命令内でフレームを終了させる invokecfuncwparamandleave命令を用意しました。

つまり、上記 dummy_func2関数は、次のようにコンパイルされます。

0000 invokecfuncwparamandleave …
0002 leave

TracePointreturnイベントに対応するために、leaveイベントは残す必要がありますが、基本的には invokecfuncwparamandleave命令のみ実行するメソッドになります。

評価

さて、結果はどうなったでしょうか。

defdummy_func0
  __C__.dummy_func0
enddefdummy_func1 a
  __C__.dummy_func1(a)
enddefdummy_func2 a, b
  __C__.dummy_func2(a, b)
end

このように定義したメソッドと、これに対応する C メソッドの実装の実行時間を比べてみたのが次のグラフです。

Image may be NSFW.
Clik here to view.
f:id:koichi-sasada:20190417011942p:plain
FFIの高速化の評価結果

invokecfuncを用いるのみが baseline ですが、それだと C メソッドよりも遅かったのが、最適化を組み合わせることで、Cメソッドよりも高速に実行できることがわかります。

発表資料には、もう少しいろいろな評価があるので、そちらもご参照下さい。

まとめと今後の課題

まとめると、「FFI を用いると、ランタイムオーバヘッドは高いのでは?」という疑念に対し、「なんでもやる強い気持ちをもって最適化を行うと、問題ない(ことが多い)よ」ということです。性能を気にせず、Ruby で書けそうです。

今後の課題として、オプショナル引数などはまだ遅いので、オーバーローディングの仕組みを入れるなどして、典型的な例は速い、みたいなことを目指せればと思っています。引数の数によってメソッド実装を選ぶようなことを想定していますが、インラインキャッシュが使えるので、そこそこ feasible なのではないかと思っています。

関連研究に、私が10年前にやっていた「Ricsin: RubyにCを埋め込むシステム (2009.3)」という研究があります。これは、Ruby の中に、直接 C のプログラム片を埋め込めるようにする、という研究です。

# Writing C in Ruby codedefopen_fd(path)
  fd = __C__(%q{ // passing string literals to __C__ methods    /* C implementation */    return INT2FIX(open(RSTRING_PTR(path), O_RDONLY));})
  raise'open error'if fd == -1yield fd
ensureraise'close error'if-1 == __C__(%q{    /* C implmentation */    return INT2FIX(close(FIX2INT(fd)));})
end

C の中から、Ruby の変数にアクセスできるのがキモ面白いところだと思っています。将来的には、こういう拡張ができるようにしても面白いかも知れないと思っています。

なお、本稿では、FFI の実装に必要になる関数テーブルの作成部分は、ちょっと面倒なので省略しました。正直、ここがブートストラップで一番難しいところなんですよね。

ロード時間削減のためのコンパイルバイナリフォーマットの改善

ランタイムオーバヘッドの懸念が解消されたら、次はスタートアップタイムが伸びてしまうんじゃないかという懸念についての返答です。Ruby でメソッドを定義するようにしたら、複数の .rb ファイルを起動時に読むから遅そうなんじゃないの、という話です。

Ruby では 2.3 から、バイトコード(MRI では ISeq という用語を使います)をバイナリにダンプする仕組みを持っています。

# dump
bin = RubyVM::InstructionSequence#to_binary# loadRubyVM::InstructionSequence.load_from_binary(bin)

AOT コンパイルみたいな用語を使ってもいいと思います。bootsnap でも使っていますね。事前にコンパイルすることで、コンパイルのコストを抑えられるんじゃないか、という期待で作ったものです。

で、そのバイナリデータを、例えば C の配列表現にして MRI と一緒にコンパイルすれば、MRI のバイナリに統合することができます。ちなみに、起動後に mmap しても、だいたい同じような感じにすることができます。実験では、前者を使いましたが、正直最初から mmap でやればよかったな。

で、それだけだとなんなので、二つの仕組みをさらに有効にすることで、より効率的に出来るんじゃないかと思います。

Lazy ローディング

ISeq は、ツリー構造になっています。トップレベル iseq が、クラス定義 iseq をもち、それがメソッド iseq を持つ、という感じです。メソッドを起動されない限り、メソッド iseq は使われません。つまり、iseq のロードを、実際に使われるまで、遅延することができるということです。これを lazy ローディングと言います。

実は、この lazy ローディング、Ruby 2.3 の段階で入っていたんですが(vm_core.h の USE_LAZY_LOADマクロ)、イマイチ使わないかなーと思ってたんですが、起動時に全部の iseq を作るよりも、実際に使うメソッドやブロックだけロードするほうが圧倒的に速いので、これを有効にしちゃおうかなあと思っています。

たいてい、あるプログラムで呼ばれるメソッドなんて、定義されたメソッドのごく一部でしょうから、そこそこ納得感ある話なんじゃないかと思います。

ロード済みかイチイチチェックが入るので、若干遅くなるんですが、分岐予測で十分カバー出来る範囲かな、と思っています。

なお、この仕組みについては、RubyKaigi 2015 の私の発表や、「笹田等: Ruby処理系のコンパイル済みコードの設計 (2015)」に詳しいです。もう3~4年前なんだな。

複数のファイルをサポート

現在の compiled binary は、一つのファイルが一つのバイナリを出すようにしかなっていません。しかし、複数のファイルをまとめて一つのバイナリにすれば、共有部分が増えて、リソースが若干節約できます(多分)。

そこで、複数ファイルを一つの compiled binary にまとめることができるようにしました。現在は数値インデックスでしかアクセス出来ませんが、ファイル名でアクセスできるように拡張する予定です。

bin = RubyVM::InstructionSequence.to_binary(iseq1, iseq2, ...)と複数の iseq を読み込み、

loader = RubyVM::InstructionSequence::Loader.new(bin)
iseq0 = loader.laod(0)

のように取り出すことができるようにしてみました。

評価

評価のために、3000個のクラス C0~C2999 を作り、各クラスが 1~20個のメソッドを持つ(def m0; m; endのような単純なメソッド。全合計3万メソッドくらい)、というサンプルを作って実験してみました。

  • 1ファイルに詰め込む場合
    • .rb が 582KB
    • compiled binary が 16MB
    • それを C の配列表現にすると 79MB(!)
  • 各クラスごとにファイルを作る
    • .rb が 3000 個
    • まとめた compiled binary が 17MB
    • それを C の配列表現にすると 86MB
  • 従来の C メソッドでの定義の仕方を用いると、4.2MB の .c

この3通りを用いて、ロードして Ruby が起動する時間をはかってみました。なお、--disable-gemsで rubygems などのライブラリはロードしないようになっています。

結果は次のようになりました。

Image may be NSFW.
Clik here to view.
f:id:koichi-sasada:20190417012010p:plain
ロード時間の評価結果

結果を見ると、従来の C での定義が最も速く 27.5 秒で、lazy loading を用いることで、だいたい 2 倍程度の性能低下で済む、という具合です。

単なる compiled binary のロードだと 3 倍遅い。普通に .rb としてロードするよりも6~16倍程度遅い、という結果になりました(一番下の結果ひどいな)。というわけで、従来手法に比べると、やはり速いのだけれど、まだ C メソッド定義に及ばず、というところです。

まとめと今後の課題

スタートアップタイムについては、従来の C メソッドのロード時間より遅い、ということはなかったのですが、まだ若干遅いです。

.rb を書いておくと、事前にどのクラスにどんなメソッドが定義される、というのがわかるので、先にテーブルだけ作っておいて、メソッド問い合わせが来たときに初めて iseq のロードを始めるような、より lazy なやり方なんかが効くんじゃ無いかと思います。そこまでやれば、C メソッドのロードよりも速くなるんじゃないかな?

あと、単純にコンパイル済みバイナリがむっちゃでかいんですよね。わざと小さくしないようにしたんですが、さすがに大きすぎなのでなんとかしたい。多分、簡単に 1/5 くらいにはなると思います。誰かやってくれません?

本稿のまとめ

本稿では、私の RubyKaigi 2019 の発表である「Write a Ruby interpreter in Ruby for Ruby 3」について述べました。

現在 MRI では、ほぼすべて C で記述されていますが、それを良い感じに Ruby と混ぜるために、特別な FFI 記法の導入を提案しました。

そして、そこで懸念される「ランタイムオーバヘッド」および「スタートアップタイムの増加」について、いくつかのテクニックをご紹介し、そこそこ feasible な結果を出すことで、懸念をそこそこ払拭できたんじゃないかと思います。

現在の組込クラス・メソッドの定義を書き換えるとなると、多くの人手が必要になります。まだ、この方針で Ruby 3 向け(Ruby 2.7 向けかな?)に書き換えるという合意は取れていませんが、取れたらばばばーと書き換える作業が発生します。ある程度機械的な作業になるんですが、良い機会なので興味がある方、一緒にやりませんか?

われわれは Ruby Hack Challenge というイベントを開催しており、次回は Ruby Hack Challenge Holiday #3が 5/11 (土) に行います。こういう場で、Ruby (MRI) 開発に参加してくれる方がいらっしゃいましたら、お声かけ頂けましたら幸いです。

というわけで、RubyKaigi 2019 でお会いできることを楽しみにしております。

Bitrise & Cookpad Developer Meetupを開催しました

モバイル基盤部の@hiragramです。先日try! Swiftに合わせて来日したBitriseチームを恵比寿オフィスに招いてミートアップを開催しました。

Bitriseはモバイルアプリ開発に特化したクラウドCIサービスで、最近日本での採用もスタートされたそうです。

cookpad.connpass.com

東京へのカウントダウン! - Bitrise Blog

ミートアップでは、モバイルCIをテーマにBitrise社のサービス紹介やクックパッド社内のモバイルアプリ向けCI環境やその上で行なっている取り組みについてのトークがありました。

まず、BitriseCTOのViktorさんから、Bitriseのサービス紹介とジョブの最適化についての発表をしていただきました。

モバイル基盤部長の@slightairが、社内のモバイルCI環境で動いているタスクや、社内向けベータ配信の仕組みについて発表しました。

クックパッドのモバイル向けCI環境では、Pull Requestごとに実行されるユニットテストの他に、日次で実行されるUIテストや、社内向けにベータ版を配信するサービスが動いています。品質を担保するために必要な各種テストやベータ版の配布を自動化することで、開発者は繰り返し発生するそれらの作業に追われる事無く、手元の開発に集中することができます。

また、毎週金曜日に実行されるAppStore Connectへの自動サブミットによって「機械に人間が合わせる」というリリースフローが確立されており、部署をまたいだスケジュールの調整や、コードフリーズ日のすり合わせなどをする必要が無くなりました。

過去のテックブログや前回のiOSDCなどでも社内向け配信やサブミットの自動化などについて紹介しているので、ぜひそちらも合わせて御覧ください。

クックパッドアプリはみんなが寝ている間にサブミットされる | クックパッド開発者ブログ

続いて、モバイル基盤部の@vinsentisambartが、モバイル向けCI環境の具体的な構成などについて発表しました。

クックパッドのモバイル向けCI環境は、Jenkinsの上に構築されており、 iOSは4台のMac mini、Androidは3組の EC2 Linux instance + Genymotion Cloud Instance という Slave 構成になっています。iOS/AndroidそれぞれのCIを実現するに当たって、それらの環境の良いところ/悪いところを紹介しています。周辺ツールの更新などが自由度高く行える一方で、マシンの追加や環境構築のコストなどが課題として指摘されています。

おわりに

クックパッドモバイル基盤部では、アプリ開発のフローを効率化/自動化することで、サービス開発者の生産性を高めるための取り組みをしています。開発者の生産性向上に興味がある方はぜひ一度クックパッドオフィスに遊びに来てください。

クックパッドメンバーに直接カジュアルにお声がけいただいてもいただいてもいいですし、以下のページからご連絡いただいても大丈夫です!お待ちしております!

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

GraphQL Asia 2019 で登壇しました

こんにちは。メディアプロダクト開発部の我妻謙樹(@itiskj)です。 ソフトウェアエンジニアとして、広告配信システムの開発・運用を担当しています。好きな言語は Go と TypeScript です。

先日、GraphQL に関してアジア圏初の大型カンファレンス、GraphQL Asiaがバンガロールで開催されました。元 Facebook で GraphQL の策定者の一人である Lee Byron を始めとし、Twitter, PayPal, Airbnb, Atlassian などのエンジニアが登壇し、GraphQL の導入事例やベストプラクティスの紹介が行なわれました。私も CFP が通過し発表してきたので、他の登壇者の内容と合わせて紹介いたします。

GraphQL Asia 2019 での発表

全発表資料は後ほど https://www.graphql-asia.org/にて公開されるとのことですが、先にいくつかピックアップしてご紹介します。

BrikL - A GraphQL native

まず、GraphQL Asia 2019 の主催者の一社である BrikLから、GraphQL を用いた自社製品の開発の変遷について紹介がありました。

個人的には、S3/DynamoDB/Elasticsearch などの外部サービスを Directivesを用いて宣言している点がユニークで学びでした。

Image may be NSFW.
Clik here to view.
f:id:itiskj:20190422134337p:plain
graphql asia brikl slide capture

APIS.GURU - GraphQL Tools are easy or how to write one in less than 100 lines

https://github.com/graphql/graphql-jsのコミッタや https://github.com/APIs-guru/graphql-voyagerのメンテナをしている OSS 開発者からの発表でした。

GraphQL tool を書くことは思ったより簡単であることを、実際に demo で GraphQL coverage tool を書きながら伝える内容でした。

GraphQL 界隈は、基本的なものに関しては出揃ってきた感がありますが、エコシステム全体的にはツールも production ready でないものが多かったり、作りかけのものが多かったりと、成熟期には達していません。GraphQL community に対して、ツールを書くことに敷居を下げ、ヒントや手法を与えた、という意味でこの発表はとても有意義なものでした。

Airbnb - GraphQL @ Airbnb

Airbnb における GraphQL 活用事例の紹介でした。

Airbnb では、monolithic な Rails アプリ、いわゆる "Monorail"が 10 年以上稼働していました。2 年前から Airbnb SOA というアーキテクチャを導入し、徐々に service oriented な構成へと移行しているとのこと。その中でも、presentation layer に GraphQL Gateway を導入しているとのことでした。

この発表の最大の価値は、RPC フレームワークであるFacebook's branch of Apache Thriftと GraphQL を併用した場合のアンチパターン及びエッジケースについて紹介されていることでした。おそらくこの組み合わせを大規模なサービスで用いているのは Airbnb が初めてとのことで、彼らならではのチャレンジから生まれる知見が世に出たことは、今後の GraphQL community の資産となることでしょう。

発表資料

メディアプロダクト開発部のうち、動画領域を担当するプロダクト開発グループでは AWS AppSync を利用しており、GraphQL および AppSync の活用事例として、以下のような資料が公開されています。

一方、私が所属する広告領域を担当するマーケティングサービス開発グループでも、社内の広告管理システムにおいて GraphQL を導入しています。今回は、こちらのシステムに GraphQL を入れるにあたって行った技術選定の過程や、実際に GraphQL を利用して得た知見を共有する、という内容で発表を行いました。

GraphQL の導入事例は他にも Tokopedia, Intuit や Phillips, Adobe からもいくつかありましたが、どの会社も違った課題を抱えており、それぞれのユースケースの講演も参考になりました。

感想

国際カンファレンスに参加したのは初めてだったのですが、Speaker として参加したからこそ得られた知見や経験が非常に貴重でした。というのも、Speaker 同士の交流会や市内観光などもカンファレンススタッフによって予定されていたのですが、それらに参加する中で、リアリティに富んだ新鮮度の高い情報(GraphQL の欧米圏における浸透具合や、今必要とされているツールや技術)のみならず、決して SNS では得られることのできないような情報についても聞くことができたのは、非常に有意義な点でした。また、彼らと接点を持てたことも大きいです。

発表資料は後からオンラインに公開されるので世界中どこに住んでいても同じ情報にアクセスできますが、やはり直接会うからこその経験を得ることができるのも、カンファレンス(国内・国外問わず)に参加することの意義だということを再認識しました。

また、国外ではカンファレンス渡航費を会社が出してくれないという Speaker もそれなりにいました。今回、渡航費をサポートしてくれたり、出張中に業務をカバーしてくれたりした同僚や上司の方々には感謝しかありません。ありがとうございました。

まとめ

今までは、"Header Bidding 導入によるネットワーク広告改善の開発事情""cookpad storeTV の広告配信を支えるリアルタイムログ集計基盤"など、まさに広告領域を代表するような技術およびシステムについて紹介してきました。

しかし、広告領域ではネットワーク広告や配信サーバーのみならず、社内入稿システムも開発しています。そして、その入稿システムを利用する業務推進チームや制作チームなどが、会社の広告事業の売上を支えてくださっています。そのメンバーの業務効率を上げるために、社内入稿システムの開発及び改善・保守にも力を注いでいます。今回の記事をきっかけに、また違った観点からの技術的チャレンジもお伝えできれば嬉しいです。

広告領域は、技術的にチャレンジングな課題も多く、かつ事業の売上貢献に直結することが多いだけでなく、入稿から配信まで様々なシステムが複雑に絡み合う、非常にエキサイティングな領域です。ぜひ、興味を持っていただけたら、Twitterからご連絡ください。

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

RubyKaigi 2019 Cookpad Daily Ruby Puzzles の正解と解答

Ruby 開発チームの遠藤です。RubyKaigi 2019 が無事に終わりました。すばらしい会議に関わったすべてのみなさんに感謝します。

開催前に記事を書いたとおり、クックパッドからはのべ 7 件くらいの発表を行い、一部メンバは会議運営にもオーガナイザとして貢献しました。クックパッドブースでは、様々な展示に加え、エンジニアリングマネージャとトークをする権利の配布やクックパッドからの発表者と質疑をする "Ask the speaker"など、いろいろな企画をやりました。

クックパッドブースの企画の 1 つとして、今年は、"Cookpad Daily Ruby Puzzles"というのをやってみました。Ruby で書かれた不完全な Hello world プログラムを 1 日 3 つ(合計 9 問)配布するので、なるべく少ない文字を追加して完成させてください、というものでした。作問担当はクックパッドのフルタイム Ruby コミッタである ko1 と mame です。

RubyKaigi の休憩時間を利用して正解発表してました↓

問題と解答を公開します。今からでも自力で挑戦したい人のために、まず問題だけ掲載します。(会議中に gist で公開したものと同じです)

問題

Problem 1-1

# Hint: Use Ruby 2.6.
puts "#{"Goodbye" .. "Hello"} world"

Problem 1-2

puts&.then {
  # Hint: &. is a safe# navigation operator."Hello world"
}

Problem 1-3

includeMath# Hint: the most beautiful equationOut, *,
     Count = $>,
             $<, E ** (2 * PI)
Out.puts("Hello world" *
         Count.abs.round)

Problem 2-1

defsay
  -> {
    "Hello world"
  }
  # Hint: You should call the Proc.yieldend

puts say { "Goodbye world" }

Problem 2-2

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentially Fiber.yield"Hello world"end

puts e.next

Problem 2-3

$s = 0defsay(n = 0)
  $s = $s * 4 + n
end

i, j, k = 1, 2, 3

say i
say j
say k

# Hint: Binary representation.$s != 35or puts("Hello world")

Problem 3-1

defsay s="Hello", t:'world'"#{ s }#{ t } world"end# Hint: Arguments in Ruby are# difficult.

puts say :p

Problem 3-2

defsay s, t="Goodbye "# Hint: You can ignore a warning.
  s = "#{ s }#{ t }"
  t + "world"end

puts say :Hello

Problem 3-3

defsay"Hello world"iffalse&& false# Hint: No hint!end

puts say

以下、ネタバレになるので空白です

自力で解いてみたい人は挑戦してみてください。











解答

では、解答です。重要なネタバレですが、すべての問題は 1 文字追加するだけで解けるようになってます。

Answer 1-1

作問担当は ko1 でした。問題再掲↓

# Hint: Use Ruby 2.6.
puts "#{"Goodbye" .. "Hello"} world"

解答↓

# Hint: Use Ruby 2.6.
puts "#{"Goodbye" ..; "Hello"} world"

"Goodbye" ..の後に ;を入れています。これにより、Ruby 2.6 で導入された終端なし Range (Feature #12912) になります。この Range は使われずに捨てられ、"Hello"が返り値になって文字列に式展開されるので、Hello worldが出力されるようになります。

この問題の勝者は tompngさんでした。なお、tompng さんは 1-2 と 1-3 も最初に 1 文字解答を発見しましたが、勝者になれるのは 1 人 1 問だけ、としました。

Answer 1-2

作問担当は ko1 でした。問題再掲↓

puts&.then {
  # Hint: &. is a safe# navigation operator."Hello world"
}

解答↓

puts$&.then {
  # Hint: &. is a safe# navigation operator."Hello world"
}

&.の前に $を入れて $&.にしています。$&は正規表現にマッチした部分文字列を表す特殊変数です。ここでは正規表現マッチは使われていないのでこの変数は nilになりますが、重要なのはこの書換によって putsメソッドに $&.then { "Hello world" }を引数として渡す、というようにパースされるようになることです。thenメソッドはブロックの返り値を返すので、この引数は文字列 "Hello world"になり、めでたく Hello world プログラムになります。

この問題の勝者は Seiei Miyagiさんでした。

Answer 1-3

作問担当は mame でした。問題再掲↓

includeMath# Hint: the most beautiful equationOut, *,
     Count = $>,
             $<, E ** (2 * PI)
Out.puts("Hello world" *
         Count.abs.round)

解答↓

includeMath# Hint: the most beautiful equationOut, *,
     Count = $>,
             $<, E ** (2i * PI)
Out.puts("Hello world" *
         Count.abs.round)

E ** (2i * PI)というように iを入れました。

これはちょっと知識問題で、Image may be NSFW.
Clik here to view.
e^{i\pi} = -1
という公式を使います。この公式は「オイラーの公式」と呼ばれ、ヒントにあるように「最も美しい等式」などと言われることもあります。Ruby で書くと Math::E ** (1i * Math::PI) #=> -1です。E ** (2i * PI)はそれの二乗になので、浮動小数点数演算の誤差もあるのでおよそ 1 になります。Count,abs,roundによって正確に 1 になって、Hello world プログラムとなります。

この問題には別の意図もありました。これらの問題は 1 文字で解けると知っていたら、ブルートフォース(いろんな箇所にいろんな文字を挿入して実行してみるのを網羅的に試す)によって頭を使わずに解けてしまうのですが、この問題はそれをじゃまするために用意しました。というのは、$<の前に *を挿入して *$<とすると、標準入力を配列化する演算となり、標準入力を待ち受けて動かなくなるようになります。よって、下手にブルートフォースをするとここで実行が止まります。ただ、このトラップにひっかかった人はいたかどうかはわかりません。

この問題の勝者は pockeさんでした。

Answer 2-1

作問担当は ko1 でした。問題再掲↓

defsay
  -> {
    "Hello world"
  }
  # Hint: You should call the Proc.yieldend

puts say { "Goodbye world" }

解答↓

defsay
  -> {
    "Hello world"
  }.
  # Hint: You should call the Proc.yieldend

puts say { "Goodbye world" }

}..を追加してあります。これにより、yieldはブロック呼び出しではなく、上の Proc 式に対して yieldメソッドを呼び出すようになります。Proc#yieldProc#callの別名なので、このラムダ式が実行され、"Hello world"を返すようになります。

この問題の勝者は Shyouheiさんでした。

Answer 2-2

作問担当は mame でした。問題再掲↓

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentially Fiber.yield"Hello world"end

puts e.next

普通に考えたら、次の 2 文字の解答になります。

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentially Fiber.
  g.yield "Hello world"end

puts e.next

Enumerator の最初の要素として "Hello world"yieldメソッドで渡し、Enumerator#nextによってそれを取り出し、それを表示します。Enumerator についてはドキュメントの class Enumeratorを参照ください。

ヒントに従って考えると、次の 6 文字の解答にたどり着きます。

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentially Fiber.Fiber.yield "Hello world"end

puts e.next

Enumerator は Fiber のラッパのようなものなので、実はブロックの中で Fiber.yieldを呼ぶことでも要素を渡すことができ、上のプログラムと同じように動きます。

ただしこれは 6 文字も追加しているのでまったく最短ではありません。どうすればよいかというと、次が 1 文字解答です。

解答↓

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentiallyFiber.
  yield "Hello world"end

puts e.next

コメントの中の essentiallyFiber.の間に改行文字を追加しました。コメントの中にある Fiber.という文字列を利用するのがミソでした。すべての問題に適当なヒントコメントが書いてあるのは、この問題にだけヒントコメントをもたせることで不自然になってしまわないようにするためでした。

余談ですが、より面白い想定回答は↓でした。

e = Enumerator.new do |g|
  # Hint: Enumerator is#
 essentially Fiber.
  yield "Hello world"end

puts e.next

essentiallyの前に改行を入れています。essentiallyは関数呼び出しとみなされますが、引数が Fiber.yield "Hello world"なのでこちらが先に評価され、essentiallyが実際に呼び出されることはなく、正しく動きます。この解答にたどり着いた人はいなかったようです。

この問題の勝者は youchanさんでした。

Answer 2-3

作問担当は mame でした。問題再掲↓

$s = 0defsay(n = 0)
  $s = $s * 4 + n
end

i, j, k = 1, 2, 3

say i
say j
say k

# Hint: Binary representation.$s != 35or puts("Hello world")

2 文字解答はたくさんあります。3535-8に変えたり、say jsay j*2に変えたり、or putsor 0;putsと変えたり、いろいろなやり方が発見されていました。

1 文字解答は、意外と理詰めでたどり着けるようになっています。sayメソッドは「$sを右に 2 ビットシフトし、引数 nを足す演算」です。ヒントにあるとおり 35 の二進数表現を考えると 10 00 11になります。それぞれ二進数で 2, 0, 3 なので、say(2); say(0); say(3)という順序で sayを呼び出せばいいことがわかります。say i; say j; say ksay(1); say(2); say(3)なので、say kはいじらなくて良さそうです。また、sayの引数を省略したら 0になるので、say i; say jをうまくいじって say j; sayという意味にする方法はないか、と考えます。ということで答えです。

解答↓

$s = 0defsay(n = 0)
  $s = $s * 4 + n
end

i, j, k = 1, 2, 3

say if
say j
say k

# Hint: Binary representation.$s != 35or puts("Hello world")

say iのあとに fを足して、後置 if 文にします。条件式は次行の say jです。これにより、先に say jが評価されて、say jは真の値を返すので、ifの中の sayが無引数で呼び出されます。それから say kが呼ばれることで、所望の挙動になります。

この問題の勝者は k. hanazukiさんでした。

Answer 3-1

作問担当は ko1 でした。問題再掲↓

defsay s="Hello", t:'world'"#{ s }#{ t } world"end# Hint: Arguments in Ruby are# difficult.

puts say :p

解答↓

defsay s="Hello", t:'world'"#{ s }#{ t } world"end# Hint: Arguments in Ruby are# difficult.

puts say t:p

say :psay t:pに書き換えています。これにより、シンボルの :pを渡していたところから、キーワード tのキーワード引数として pを渡すように変わります。pKernel#pの呼び出しで、無引数の場合は単に nilを返します。よって、s = "Hello"かつ t = nilになり、"#{ s }#{ t } world""Hello world"になります。

この問題の勝者は Akinori Mushaさんでした。

Answer 3-2

作問担当は mame でした。問題再掲↓

defsay s, t="Goodbye "# Hint: You can ignore a warning.
  s = "#{ s }#{ t }"
  t + "world"end

puts say :Hello

解答↓

defsay s, t=#"Goodbye "# Hint: You can ignore a warning.
  s = "#{ s }#{ t }"
  t + "world"end

puts say :Hello

t=#"Goodbye "というように、オプショナル引数のデフォルト式をコメントアウトしています。これにより、次の行にある式がデフォルト式になります。この場合、s = "#{ s } #{ t }"がデフォルト式です。sはすでに受け取った引数で :Helloが入っています。引数 tは未初期化の状態で参照され、これは nilになります(コメントにあるとおり、それは問題ないです)。よってこのデフォルト式は "Hello "という文字列になります。あとはそのまま。

この問題の勝者は DEGICAさんでした。

Answer 3-3

作問担当は mame でした。問題再掲↓

defsay"Hello world"iffalse&& false# Hint: No hint!end

puts say

解答↓

defsay"Hello world"if%
    false&& false# Hint: No hint!end

puts say

ifの後に %を書き足します。答えを見ても意味がわからない人のほうが多いのではないでしょうか。

Ruby には %記法というリテラルがあります。%!foo!と書くと、文字列リテラル "foo"と同じです。デリミタ(先の例では !)には、数字とアルファベット以外の任意の文字を使うことができます。上の例は、このデリミタとして改行文字を使っています。わかりやすく、デリミタを改行文字から !に書き換えると、こうなります。

defsay"Hello world"if%!    false && false!# Hint: No hint!end

puts say

後置 if の条件式に文字列リテラル(常に真)を書いたことになるので、このメソッド sayは常に "Hello world"を返します。

なお、%記法のデリミタに改行文字や空白文字を使える仕様は、matz が「やめたい」と言っていたので、将来廃止されるのかもしれません。

この問題の勝者は cuzicさんでした。

まとめ

Cookpad Daily Ruby Puzzles の問題と解答と解説でした。今回はわりと手加減せずに Ruby の仕様の重箱の隅をつつくような問題ばかりでしたが、「クックパッドのパズルがおもしろかった」という声も結構いただきました。まだやっていないかたは、今からでも(上の解説を見ずに)楽しんでいただければ幸いです。

こういうパズルが入社試験として出るわけではありませんが、このパズルをきっかけにクックパッドに興味を持ってくれた人は、↓からぜひ応募してください。

cookpad.jobs

Special thanks

  • hogelog:クックパッドのブースに「超絶技巧パズル(ってなに?)置いておこう」と発案した人
  • sorah:シュッとチラシをデザインした人
  • ブースにいた全員:パズルの配布や運営をした人たち
  • 参加してくれた全員:解けた人も解けなかった人も

おまけ

もっと遊びたい人のためにエクストラステージを用意しておきました。答えはないので考えてみてください。

Extra 1

作問担当:mame

Hello = "Hello"# Hint: Stop the recursion.defHello
  Hello() +
    " world"end

puts Hello()

Extra 2

作問担当:mame

s = ""# Hint: https://techlife.cookpad.com/entry/2018/12/25/110240
s == s.upcase or
  s == s.downcase or puts "Hello world"

Extra 3

作問担当:ko1

(1 文字解答が 2 つあります)

defsay
  s = 'Small'
  t = 'world'
  puts "#{s}#{t}"endTracePoint.new(:line){|tp|
  tp.binding.local_variable_set(:s, 'Hello')
  tp.binding.local_variable_set(:t, 'Ruby')
  tp.disable
}.enable(target: method(:say))

say

RubyKaigi 2019 Cookpad Daily Ruby Puzzles の正解と解説

Ruby 開発チームの遠藤です。RubyKaigi 2019 が無事に終わりました。すばらしい会議に関わったすべてのみなさんに感謝します。

開催前に記事を書いたとおり、クックパッドからはのべ 7 件くらいの発表を行い、一部メンバは会議運営にもオーガナイザとして貢献しました。クックパッドブースでは、様々な展示に加え、エンジニアリングマネージャとトークをする権利の配布やクックパッドからの発表者と質疑をする "Ask the speaker"など、いろいろな企画をやりました。

クックパッドブースの企画の 1 つとして、今年は、"Cookpad Daily Ruby Puzzles"というのをやってみました。Ruby で書かれた不完全な Hello world プログラムを 1 日 3 つ(合計 9 問)配布するので、なるべく少ない文字を追加して完成させてください、というものでした。作問担当はクックパッドのフルタイム Ruby コミッタである ko1 と mame です。

RubyKaigi の休憩時間を利用して正解発表してました↓

問題と解答を公開します。今からでも自力で挑戦したい人のために、まず問題だけ掲載します。(会議中に gist で公開したものと同じです)

問題

Problem 1-1

# Hint: Use Ruby 2.6.
puts "#{"Goodbye" .. "Hello"} world"

Problem 1-2

puts&.then {
  # Hint: &. is a safe# navigation operator."Hello world"
}

Problem 1-3

includeMath# Hint: the most beautiful equationOut, *,
     Count = $>,
             $<, E ** (2 * PI)
Out.puts("Hello world" *
         Count.abs.round)

Problem 2-1

defsay
  -> {
    "Hello world"
  }
  # Hint: You should call the Proc.yieldend

puts say { "Goodbye world" }

Problem 2-2

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentially Fiber.yield"Hello world"end

puts e.next

Problem 2-3

$s = 0defsay(n = 0)
  $s = $s * 4 + n
end

i, j, k = 1, 2, 3

say i
say j
say k

# Hint: Binary representation.$s != 35or puts("Hello world")

Problem 3-1

defsay s="Hello", t:'world'"#{ s }#{ t } world"end# Hint: Arguments in Ruby are# difficult.

puts say :p

Problem 3-2

defsay s, t="Goodbye "# Hint: You can ignore a warning.
  s = "#{ s }#{ t }"
  t + "world"end

puts say :Hello

Problem 3-3

defsay"Hello world"iffalse&& false# Hint: No hint!end

puts say

以下、ネタバレになるので空白です

自力で解いてみたい人は挑戦してみてください。











解答

では、解答です。重要なネタバレですが、すべての問題は 1 文字追加するだけで解けるようになってます。

Answer 1-1

作問担当は ko1 でした。問題再掲↓

# Hint: Use Ruby 2.6.
puts "#{"Goodbye" .. "Hello"} world"

解答↓

# Hint: Use Ruby 2.6.
puts "#{"Goodbye" ..; "Hello"} world"

"Goodbye" ..の後に ;を入れています。これにより、Ruby 2.6 で導入された終端なし Range (Feature #12912) になります。この Range は使われずに捨てられ、"Hello"が返り値になって文字列に式展開されるので、Hello worldが出力されるようになります。

この問題の勝者は tompngさんでした。なお、tompng さんは 1-2 と 1-3 も最初に 1 文字解答を発見しましたが、勝者になれるのは 1 人 1 問だけ、としました。

Answer 1-2

作問担当は ko1 でした。問題再掲↓

puts&.then {
  # Hint: &. is a safe# navigation operator."Hello world"
}

解答↓

puts$&.then {
  # Hint: &. is a safe# navigation operator."Hello world"
}

&.の前に $を入れて $&.にしています。$&は正規表現にマッチした部分文字列を表す特殊変数です。ここでは正規表現マッチは使われていないのでこの変数は nilになりますが、重要なのはこの書換によって putsメソッドに $&.then { "Hello world" }を引数として渡す、というようにパースされるようになることです。thenメソッドはブロックの返り値を返すので、この引数は文字列 "Hello world"になり、めでたく Hello world プログラムになります。

この問題の勝者は Seiei Miyagiさんでした。

Answer 1-3

作問担当は mame でした。問題再掲↓

includeMath# Hint: the most beautiful equationOut, *,
     Count = $>,
             $<, E ** (2 * PI)
Out.puts("Hello world" *
         Count.abs.round)

解答↓

includeMath# Hint: the most beautiful equationOut, *,
     Count = $>,
             $<, E ** (2i * PI)
Out.puts("Hello world" *
         Count.abs.round)

E ** (2i * PI)というように iを入れました。

これはちょっと知識問題で、Image may be NSFW.
Clik here to view.
e^{i\pi} = -1
という公式を使います。この公式は「オイラーの公式」と呼ばれ、ヒントにあるように「最も美しい等式」などと言われることもあります。Ruby で書くと Math::E ** (1i * Math::PI) #=> -1です。E ** (2i * PI)はそれの二乗になので、浮動小数点数演算の誤差もあるのでおよそ 1 になります。Count,abs,roundによって正確に 1 になって、Hello world プログラムとなります。

この問題には別の意図もありました。これらの問題は 1 文字で解けると知っていたら、ブルートフォース(いろんな箇所にいろんな文字を挿入して実行してみるのを網羅的に試す)によって頭を使わずに解けてしまうのですが、この問題はそれをじゃまするために用意しました。というのは、$<の前に *を挿入して *$<とすると、標準入力を配列化する演算となり、標準入力を待ち受けて動かなくなるようになります。よって、下手にブルートフォースをするとここで実行が止まります。ただ、このトラップにひっかかった人はいたかどうかはわかりません。

この問題の勝者は pockeさんでした。

Answer 2-1

作問担当は ko1 でした。問題再掲↓

defsay
  -> {
    "Hello world"
  }
  # Hint: You should call the Proc.yieldend

puts say { "Goodbye world" }

解答↓

defsay
  -> {
    "Hello world"
  }.
  # Hint: You should call the Proc.yieldend

puts say { "Goodbye world" }

}..を追加してあります。これにより、yieldはブロック呼び出しではなく、上の Proc 式に対して yieldメソッドを呼び出すようになります。Proc#yieldProc#callの別名なので、このラムダ式が実行され、"Hello world"を返すようになります。

この問題の勝者は Shyouheiさんでした。

Answer 2-2

作問担当は mame でした。問題再掲↓

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentially Fiber.yield"Hello world"end

puts e.next

普通に考えたら、次の 2 文字の解答になります。

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentially Fiber.
  g.yield "Hello world"end

puts e.next

Enumerator の最初の要素として "Hello world"yieldメソッドで渡し、Enumerator#nextによってそれを取り出し、それを表示します。Enumerator についてはドキュメントの class Enumeratorを参照ください。

ヒントに従って考えると、次の 6 文字の解答にたどり着きます。

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentially Fiber.Fiber.yield "Hello world"end

puts e.next

Enumerator は Fiber のラッパのようなものなので、実はブロックの中で Fiber.yieldを呼ぶことでも要素を渡すことができ、上のプログラムと同じように動きます。

ただしこれは 6 文字も追加しているのでまったく最短ではありません。どうすればよいかというと、次が 1 文字解答です。

解答↓

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentiallyFiber.
  yield "Hello world"end

puts e.next

コメントの中の essentiallyFiber.の間に改行文字を追加しました。コメントの中にある Fiber.という文字列を利用するのがミソでした。すべての問題に適当なヒントコメントが書いてあるのは、この問題にだけヒントコメントをもたせることで不自然になってしまわないようにするためでした。

余談ですが、より面白い想定回答は↓でした。

e = Enumerator.new do |g|
  # Hint: Enumerator is#
 essentially Fiber.
  yield "Hello world"end

puts e.next

essentiallyの前に改行を入れています。essentiallyは関数呼び出しとみなされますが、引数が Fiber.yield "Hello world"なのでこちらが先に評価され、essentiallyが実際に呼び出されることはなく、正しく動きます。この解答にたどり着いた人はいなかったようです。

この問題の勝者は youchanさんでした。

Answer 2-3

作問担当は mame でした。問題再掲↓

$s = 0defsay(n = 0)
  $s = $s * 4 + n
end

i, j, k = 1, 2, 3

say i
say j
say k

# Hint: Binary representation.$s != 35or puts("Hello world")

2 文字解答はたくさんあります。3535-8に変えたり、say jsay j*2に変えたり、or putsor 0;putsと変えたり、いろいろなやり方が発見されていました。

1 文字解答は、意外と理詰めでたどり着けるようになっています。sayメソッドは「$sを右に 2 ビットシフトし、引数 nを足す演算」です。ヒントにあるとおり 35 の二進数表現を考えると 10 00 11になります。それぞれ二進数で 2, 0, 3 なので、say(2); say(0); say(3)という順序で sayを呼び出せばいいことがわかります。say i; say j; say ksay(1); say(2); say(3)なので、say kはいじらなくて良さそうです。また、sayの引数を省略したら 0になるので、say i; say jをうまくいじって say j; sayという意味にする方法はないか、と考えます。ということで答えです。

解答↓

$s = 0defsay(n = 0)
  $s = $s * 4 + n
end

i, j, k = 1, 2, 3

say if
say j
say k

# Hint: Binary representation.$s != 35or puts("Hello world")

say iのあとに fを足して、後置 if 文にします。条件式は次行の say jです。これにより、先に say jが評価されて、say jは真の値を返すので、ifの中の sayが無引数で呼び出されます。それから say kが呼ばれることで、所望の挙動になります。

この問題の勝者は k. hanazukiさんでした。

Answer 3-1

作問担当は ko1 でした。問題再掲↓

defsay s="Hello", t:'world'"#{ s }#{ t } world"end# Hint: Arguments in Ruby are# difficult.

puts say :p

解答↓

defsay s="Hello", t:'world'"#{ s }#{ t } world"end# Hint: Arguments in Ruby are# difficult.

puts say t:p

say :psay t:pに書き換えています。これにより、シンボルの :pを渡していたところから、キーワード tのキーワード引数として pを渡すように変わります。pKernel#pの呼び出しで、無引数の場合は単に nilを返します。よって、s = "Hello"かつ t = nilになり、"#{ s }#{ t } world""Hello world"になります。

この問題の勝者は Akinori Mushaさんでした。

Answer 3-2

作問担当は mame でした。問題再掲↓

defsay s, t="Goodbye "# Hint: You can ignore a warning.
  s = "#{ s }#{ t }"
  t + "world"end

puts say :Hello

解答↓

defsay s, t=#"Goodbye "# Hint: You can ignore a warning.
  s = "#{ s }#{ t }"
  t + "world"end

puts say :Hello

t=#"Goodbye "というように、オプショナル引数のデフォルト式をコメントアウトしています。これにより、次の行にある式がデフォルト式になります。この場合、s = "#{ s } #{ t }"がデフォルト式です。sはすでに受け取った引数で :Helloが入っています。引数 tは未初期化の状態で参照され、これは nilになります(コメントにあるとおり、それは問題ないです)。よってこのデフォルト式は "Hello "という文字列になります。あとはそのまま。

この問題の勝者は DEGICAさんでした。

Answer 3-3

作問担当は mame でした。問題再掲↓

defsay"Hello world"iffalse&& false# Hint: No hint!end

puts say

解答↓

defsay"Hello world"if%
    false&& false# Hint: No hint!end

puts say

ifの後に %を書き足します。答えを見ても意味がわからない人のほうが多いのではないでしょうか。

Ruby には %記法というリテラルがあります。%!foo!と書くと、文字列リテラル "foo"と同じです。デリミタ(先の例では !)には、数字とアルファベット以外の任意の文字を使うことができます。上の例は、このデリミタとして改行文字を使っています。わかりやすく、デリミタを改行文字から !に書き換えると、こうなります。

defsay"Hello world"if%!    false && false!# Hint: No hint!end

puts say

後置 if の条件式に文字列リテラル(常に真)を書いたことになるので、このメソッド sayは常に "Hello world"を返します。

なお、%記法のデリミタに改行文字や空白文字を使える仕様は、matz が「やめたい」と言っていたので、将来廃止されるのかもしれません。

この問題の勝者は cuzicさんでした。

まとめ

Cookpad Daily Ruby Puzzles の問題と解答と解説でした。今回はわりと手加減せずに Ruby の仕様の重箱の隅をつつくような問題ばかりでしたが、「クックパッドのパズルがおもしろかった」という声も結構いただきました。まだやっていないかたは、今からでも(上の解説を見ずに)楽しんでいただければ幸いです。

こういうパズルが入社試験として出るわけではありませんが、このパズルをきっかけにクックパッドに興味を持ってくれた人は、↓からぜひ応募してください。

cookpad.jobs

Special thanks

  • hogelog:クックパッドのブースに「超絶技巧パズル(ってなに?)置いておこう」と発案した人
  • sorah:シュッとチラシをデザインした人
  • ブースにいた全員:パズルの配布や運営をした人たち
  • 参加してくれた全員:解けた人も解けなかった人も

おまけ

もっと遊びたい人のためにエクストラステージを用意しておきました。答えはないので考えてみてください。

Extra 1

作問担当:mame

Hello = "Hello"# Hint: Stop the recursion.defHello
  Hello() +
    " world"end

puts Hello()

Extra 2

作問担当:mame

s = ""# Hint: https://techlife.cookpad.com/entry/2018/12/25/110240
s == s.upcase or
  s == s.downcase or puts "Hello world"

Extra 3

作問担当:ko1

(1 文字解答が 2 つあります)

defsay
  s = 'Small'
  t = 'world'
  puts "#{s}#{t}"endTracePoint.new(:line){|tp|
  tp.binding.local_variable_set(:s, 'Hello')
  tp.binding.local_variable_set(:t, 'Ruby')
  tp.disable
}.enable(target: method(:say))

say

XcodeGenによる新時代のiOSプロジェクト管理

こんにちは。モバイル基盤部の[twitter:@giginet]です。平成最後のエントリを担当させていただきます。

iOSアプリの開発では、Xcodeが生成するプロジェクトファイルである、*.xcodeprojをリポジトリで共有するのが一般的です。

しかし、この運用は大規模なプロジェクトになるほど、数多くの課題が発生します。

クックパッドiOSアプリは巨大なプロジェクトであり、通常の*.xcodeprojによる管理には限界が生じていました。

そこで、昨年秋にXcodeGenというユーティリティを導入し、プロジェクト管理を改善したので、その知見をお伝えします。

Image may be NSFW.
Clik here to view.
f:id:gigi-net:20190425150234g:plain

従来のプロジェクト管理の問題点

ファイル追加の度にコンフリクトが発生する

*.xcodeprojファイルはプロジェクトに含まれるソースファイルの管理を行っています。

開発者がプロジェクトにファイルを追加すると、このプロジェクトファイルが更新されることになります。

そのため、同時に数十人が開発するクックパッドiOSアプリの開発環境では、プロジェクトファイルのコンフリクトが日常茶飯事で、解消に多くの工数が発生していました。

レビューがしづらい

*.xcodeprojは特殊なテキストデータで表現されますが、とても人間が読める物ではなく、差分をレビューするのは困難です。

簡単なファイルの移動でも複数の差分が発生します。

大きな変更に向かない

クックパッドiOSアプリでは、巨大なビルドターゲットを分割し、ビルド時間を改善する『霞が関*1と呼ばれる取り組みを行っています。

マルチモジュール化を行っていくに当たって、ドラスティックな*.xcodeprojの変更に耐える必要がありました。

単なるファイル追加であれば、コンフリクトの解消や、レビューの難しさという問題はまだ解決可能でしたが、ターゲットやBuild Configurationの追加、大量のファイルの移動といったプロジェクトの変更をもはや人類が適切に扱うことは困難でした。

XcodeGenとは

そこで導入したのがXcodeGenです。

XcodeGenは、XcodeのプロジェクトデータをYAMLで記述し、定義から冪等に*.xcodeprojを生成できるユーティリティです。

このようなYAMLを定義し

targets:Cookpad:type: application
    platform: iOS
    sources:- path: Cookpad

XcodeGenを実行すると、*.xcodeprojを自動生成することができます。

$ xcodegen
Loaded project:
  Name: Cookpad
  Targets:
    Cookpad: iOS application
  Schemes:
    Cookpad
⚙️  Generating project...
⚙️  Writing project...
Created project at Cookpad.xcodeproj

Image may be NSFW.
Clik here to view.
f:id:gigi-net:20190425150303p:plain

このツールの導入により、数々の問題が解消できました。

導入して良かったこと

ファイルツリー構成が強制される

まず、*.xcodeprojの問題点として、ファイルシステム上のツリーと、プロジェクトの保持するツリーが一致しないという問題がありました。

追加されるファイルは、ファイルシステム上の階層と必ずしも一致しませんし、思い思いに追加されるため、プロジェクトが煩雑になります。

XcodeGenによる生成では、ファイルツリーからプロジェクト構造を生成するため、この不一致が解消されます。

targets:Cookpad:sources:- path: Cookpad

例えば、この指定では、Cookpad以下のファイル全てがCookpadターゲットに所属するため、ファイルシステム上の位置を強制することができます。

Image may be NSFW.
Clik here to view.
f:id:gigi-net:20190425150316p:plain

コンフリクト解消が不要に

上記の仕様による一番わかりやすい恩恵は、プロジェクト差分のコンフリクトからの解放です。

従来の*.xcodeprojでは、開発者がソースファイルを追加する度に更新が入り、差分が発生していました。

しかし、XcodeGenの仕様においては、ソースファイルの追加時にリポジトリへのファイル追加以外の操作が不要になり、一切のプロジェクトのコンフリクトがない世界が到来しました。

ターゲットの追加が容易に

上記の特性はビルドターゲットの追加にも役立ちます。XcodeGenでは、わずか数行のYAMLの定義のみでビルドターゲット追加を行うことができます。

targets:CookpadCore:type: framework
    platform: iOS
    sources:- CookpadCore

Image may be NSFW.
Clik here to view.
f:id:gigi-net:20190425150330p:plain

また、ターゲット間のソースファイルの移動も簡単です。 従来は、1ファイルごとにどのビルドターゲットでビルドされるか、という情報が保持されていたため、ファイルを移動する度にプロジェクトに大きな差分が発生していました。

しかし、XcodeGenでプロジェクトを生成することにより、特定のディレクトリ下のソースコードは、必ず特定のビルドターゲットに含まれることを保証することができるようになりました。

これにより、ビルドターゲット間の移動は単にgit mvするだけで済むようになりました。

この特性は、プロジェクトのマルチモジュール化に大きく役立ちました。

XcodeGenの導入

XcodeGenを導入したい場合、残念ながら既存の*.xcodeprojから簡単にプロジェクト定義ファイルを生成する方法はありません。

基本的には、ドキュメントを追いながら、生成結果を目で見て確認していきます。

GUIでの設定値をプロジェクト定義に忠実に移植する為には、既存の*.xcodeproj/project.pbxprojをテキストエディタで開き、設定値を探していくという地道な作業も発生しました。綺麗なプロジェクト定義を記述するには、Xcodeプロジェクトの構造をよく理解している必要があるでしょう。

最終的にクックパッドiOSアプリは、400行程度のYAMLファイルでほぼ元の挙動を再現することができました。

そこで、複雑なプロジェクトをXcodeGenの定義ファイルで記述するためのテクニックをいくつかご紹介します。

SettingGroup

まずはSettingGroupの機能です。 複数のターゲットで共通して利用したいビルドフラッグなどの設定をSettingGroupとして定義しておき、利用したいターゲットで読み込んで使用することができます。

settingGroups:SharedSettings:configs:OTHER_SWIFT_FLAGS: -DDEBUG
targets:Cookpad:type: application
    settings:groups:[Shared]OtherFramework:type: framework
    settings:groups:[Shared]

パッケージ管理

プロジェクトにCarthage*2でインストールしたライブラリを統合したい場合も簡単に記述できます。

targets:Cookpad:type: application
    platform: iOS
    dependencies:- carthage: RxSwift

このcarthage指定を用いるだけで、Embed Frameworkの設定や、Frameworkのコピーなど、Carthageの利用に必要な設定を自動で行ってくれます。

Image may be NSFW.
Clik here to view.
f:id:gigi-net:20190425150345p:plain

一方で、CocoaPodsを併用する場合、事態は複雑です。

CocoaPodsは、プロジェクトファイルにあとからビルド設定の注入を行う必要があるからです。現在のXcodeGenでは、プロジェクト定義だけでそれを管理することはできません。

例えば一連の処理をMakefileに記述するというアプローチが考えられるでしょう。

xcodegen
bundle exec pod install

ソースコード生成

Sourceryなどのコードジェネレーションと、XcodeGenを併用する場合には少し工夫が必要です。

XcodeGenによるプロジェクトツリーは、通常、存在しているソースファイルのファイルシステム上の構成により構築されます。

一方で、ソースジェネレーションを行うためには、他の定義からソースコードを生成するため、プロジェクトツリーが必要になります。 このように、鶏と卵問題が発生してしまうのです。

そこで、optionalオプションで、生成前のソースファイルの参照だけ持ち、先にプロジェクトを構築し、あとからソースジェネレーションを行うことでこの問題を解決しています。

targets:CookpadTests:type: unit-test
    platform: iOS
    sources:- path:"CookpadTests/AutoGenerated/AutoGenerated.swift"optional:truetype: file

Image may be NSFW.
Clik here to view.
f:id:gigi-net:20190425150358p:plain

今後の課題

XcodeGenを大規模に運用している中で以下のような問題が発生しました。

主にプロジェクトの生成時間に関する課題で、現在解決している最中です。

CocoaPodsを利用するときの生成速度

CocoaPodsの利用時に、プロジェクト生成後、毎回pod installが必要なことは、先ほど触れました。

XcodeGenは冪等に実行されますが、それ故に、プロジェクト生成ごとにCocoaPodsによるビルド設定の注入を毎回行う必要が出てくるのです。

この仕組みでは、XcodeGenの生成ごとに毎回パッケージインストールが走り、数十秒の待ち時間が発生しています。

この問題を解決するアイディアはいくつかあります。

まずは、CocoaPodsによるプロジェクトの設定変更を無効化し、自分で依存関係を記述する方式です。*3

もう一つの方法は、CocoaPods 1.7で利用可能になったincremental_installを有効にすることです。 このオプションを有効にすることで、差分がある依存関係のみが生成されるため、プロジェクト生成速度が改善すると踏んでいます。

いずれの方式も構想段階でまだ実用できていません。

プロジェクトキャッシュの問題

毎回プロジェクトファイルを上書きしていると、希にXcodeのビルドキャッシュが無効になり、フルビルドが発生してしまう問題にも遭遇しています。

この問題は、生成されたプロジェクトの差分が発生しないようにしても再現しており、解決していく必要があります。

まとめ

ご覧いただいたように、XcodeGenを使ったプロジェクト運用は、クックパッドiOSアプリほどの規模であっても十分に実用できていると言えます。

*.xcodeprojで苦しむのは平成までです。皆さんもプロジェクトを破壊して新しい時代を迎えませんか。

クックパッドではXcodeプロジェクトのコンフリクト解消で消耗したくないエンジニアを募集しています。

*1:詳しくは2月に行われたCookpad Tech Confの資料をご覧ください https://techconf.cookpad.com/2019/kohki_miki.html

*2:ちなみに筆者は最近Carthageのコミッターになりました 💪

*3:この手法は integrate_targetsというオプションを有効にすることで実現できますが、難しいのでここでは解説しません

Google I/O 2019 に参加しました

こんにちは、技術部品質向上グループの加藤です。 普段は主にモバイルアプリのテスト周りに関わっています。 今回は先日開催された Google I/O 2019 に参加したので、現場の環境や気になったセッションを初参加の目線で書いていきます。

Google I/O 2019

毎年5月ごろに Google が開催するカンファレンスです。 Google が展開するプロダクトやサービスに関する情報が多く発表され、カンファレンス冒頭にある Keynote は毎年非常に注目を集めています。 カンファレンス中は広い会場で多くの発表が行われていますが、発表のセッション以外にも多くの企画があります。

Office Hour & Sandbox

カンファレンス中にはセッションが行われる施設とは別にいくつもの施設が併設されています。 そのなかでも今回は Office Hour と Sandbox について触れたいと思います。

Office Hour では時間帯ごとにテーマが定められ、枠を予約することでテーマに沿った内容についてそれに関わる Google 社員と直接会話することができます。 テーマは非常に多岐に渡り、Kotlin for Android のような一般的なものから、R8 / shrinking app code のような少しニッチな部分まで数多くのテーマが存在しています。 もちろんコミュニケーションには英語が必要とされますが、社員の方も熱心に耳を傾けてくれるため英語に自信がない自分でも内容を十分に伝え合う程度には会話をすることが可能でした。

Sandbox ではテーマごとにテントが設置されており、その中でカジュアルに Google 社員と会話をすることが可能です。 Office Hour と違い予約制ではないため、常に人に溢れているような環境ですが想像以上にしっかりと会話をすることができ、Office Hour と合わせて直接コミュニケーションを取ることができる場となっています。 ちなみに人が溢れているおり必然的に場がかなり騒がしくなっているので、強い気持ちでコミュニケーションをとる必要がありました。

I/O 参加前に社内で Android や Firebase 関連の質問と要望を取り纏めていましたが、Office Hour 及び Sandbox で全ての内容を Google 社員と直接議論することができました。 特に要望に関しては実際のユースケースを合わせて会話をすることで、一方的に要望を伝えるだけでなく、現状取り組めるアプローチ等の提案もあり非常に価値がありました。

セッション紹介

ここからは I/O のセッションで興味が惹かれたものを少し紹介します。

※筆者は Android アプリの開発に関わっているので、内容は Android のものばかりなってしまっています。

New Tools to Optimize Your App's Size and Boost Installs on Google Play

https://www.youtube.com/watch?v=rEuwVWpYBOY

タイトルの通り、アプリサイズを最適化(サイズダウン)することでアプリインストールを促進させるという内容です。 Play Store 上でのいくつかの変更と合わせてアプリインストールへ如何につなげるか、具体的にどの程度の効果が見込めるのかという話でした。 いくつか Play Store の変更点がありましたが、私が注目した点としては以下の2点です。

  • Play Store 上で表示されるアプリ評価で評価者のアプリ利用年数に応じて重み付けが始まる
  • Play Console 上にアプリサイズの項目が追加

1点目については昔から長くリリースを続けているアプリであればあるほど、開発側にとってメリットとなるように受け取ることができます。 利用年数に対しての重み付けの具体的なロジックは好評されていませんが、弊社のモバイルアプリに関しては評価を上げることとなりました。 新しいロジックによる評価への切り替えは 2019年8月 から始まるようです。

2点については情報が多いのですが、まとめると Play Console 上でアプリサイズについての情報をいくつか確認できるようになるようです。 設定した類似アプリのアプリサイズの中央値との比較や、端末の空き容量が 1GB 未満の端末の利用者やその環境でアンインストールを行った利用者を確認できるようになるとのことです。 App Bundle も合わせて、Google のアプリサイズ減少への強い意向が感じられる機能です。

他にもアプリサイズが大きくなってしまうゲームアプリ向けに対しての施策や、デバイス間でのファイル共有の話題がでました。

Customizable Delivery with the App Bundle and Easy Sharing of Test Builds

https://www.youtube.com/watch?v=flhib2krW7U

昨年の Google I/O で発表された App Bundle ですが、次のレベルとしてコンテンツ配信についての新たな仕組みが紹介されました。 In-app updates はそのうちの1つですが、緊急の強制アップデートとユーザが選択可能なアップデートの2種類の仕組みが提供されるとのことでした。

また新たな仕組みに合わせてそれらをテストするツールについてもアップデートがありました。 従来 App Bundle や Dynamic Feature を検証する際には、Play Console 上にアプリをアップロードする必要があり、なおかつアップロードされたテスト版のアプリの利用側にも登録が必要であるなど制約が存在していました。 これらの問題に対して Internal App Sharing の発表がありました。

Internal App Sharing では、version code の制限と配信対象者の登録が必要なくなり、インストールリンクを踏むだけでテスト番のアプリを利用することが可能となりました。 各インストールリンクごとに利用者の制限はあるようですが、これにより App Bundle を利用したアプリの検証の難易度が下がることとなりそうです。 App Bundle に関わらず幅広い用途が見込めるため、社内の多くのチームで検証が効率化されることを期待しています。

Build Testable Apps for Android

https://youtu.be/VJi2vmaQe6w

Testable な実装を目指して、テストピラミッドから実際の実装例までを包括的に説明する発表でした。 昨年の Google I/O で発表された Android Test を利用したモダンな実装例が紹介され、同日にこのセッションに合わせて Android Testing の Codelabが更新されたので、ご興味ある方はぜひお試しください。 また昨年テスト実行環境として、Nitrogen という ART や JVM、 Firebase Test Lab 等実行環境を意識することなくテストの実行を可能とする概念が発表されました(Jetpack の燃料ということで Nitrogen という命名)。 これまで完全に謎なものとなっていましたが、今回 Early Access Program が発表されました。 まだ全貌は明らかになっていませんが、いち早く応募してみなさんの目で確かめてください。

参加してみての感想

今回の Keynote 中で発表されたように すべての人に向けて 技術を提供するという目的に向けた内容が多かった印象でした。 紹介しなかったセッションでもアプリサイズの最小化や機械学習技術のアクセシビリティへの応用など発表や展示が非常に多く見受けられました。

また昨今のカンファレンスでは、セッションの発表内容がインターネット上で公開されることが多いなかで、 発表された多くの内容について直接開発者とコミュニケーションをとることで、実際の開発にどう活かせるのかという点を確認することができ、現地に赴く重要性が強く感じられました。

Image may be NSFW.
Clik here to view.
f:id:ksfee:20190604210755p:plain

クックパッドでは Google I/O で発表されるような最新の技術をガンガン取り込んでいく Android エンジニアを募集しています。

Working with AWS AppSync on iOS

Hi, this is Chris from Cookpad's Media Product Globalization department.

I'm going to discuss some pitfalls we've run into while working with AWS AppSync for iOS. This post is not a primer on AppSync, nor is it a general review of whether you should or should not use AppSync for your project. My goal is to point out some various lessons we've learned so far that weren't obvious at first. My second disclaimer is that AppSync itself is under active development, so you can probably expect that some of the points I cover in this post will be altered in the future.

Background

My team has been working on a standalone iOS app for shooting, editing, and sharing 1-minute, top-down recipe videos called Cookpad Studio (here's a completed example video). At the time of this posting, our app is still in closed beta.

The shooting and editing parts are local to an iOS device.

Image may be NSFW.
Clik here to view.
f:id:christopher-trott:20190614105506p:plain
Video editor screens

But the sharing to the community part relies on a server backend to share data between users.

Image may be NSFW.
Clik here to view.
f:id:christopher-trott:20190614105609p:plain
Community screens using AWS AppSync

For the community part of the app, we decided to use AWS AppSync and various AWS backend technologies as an alternative to more established frameworks like Ruby on Rails.

Our AppSync setup is a bit different than the standard use case. AppSync is designed to be configured by app developers through the Amplify CLI. Since our team has dedicated backend engineers, we've opted to do most configuration and server development through the AWS component services directly (e.g. AWS Lambda, DynamoDB, etc.).

SDKs

AppSync on the iOS side is an amalgamation of a few different AWS SDKs. Luckily, all of them are open source and you can dive into their code when necessary. The three SDKs we're using so far are:

  • Authentication - The SDK that facilitates user authentication via Cognito.
  • Storage - The SDK that facilitates file uploads/downloads to/from S3.
  • API - The GraphQL client that facilitates fetching and mutating records in DynamoDB.

The first thing to understand about these SDKs is that they're all very different. They were written at different times by different teams with different technologies and have evolved with different goals in mind.

To give you an idea of what I mean by different, here's some various specs about each SDK:

  • Authentication
    • Objective-C & some Swift wrappers
    • Uses AWSTask, a fork of Facebook's Bolts Framework, for async communication, alongside Cocoa conventions (e.g. delegates, closures, GCD).
  • Storage
    • Objective-C
    • Uses AWSTask alongside Cocoa conventions.
  • API
    • Swift
    • Uses a custom Promise implementation for async communication, alongside Cocoa conventions.
    • Uses .graphqlconfig.yml for additional GraphQL configuration.

Authentication SDK

Singletons

I generally prefer to use initializer-based dependency injection over singletons. This is often unavoidable, even when only using Apple's first-party SDKs.

I was pleased to find that code completion gave me a couple different initialization options for AWSMobileClient, the primary class for interfacing with the Cognito authentication APIs. The most complete of the initializers being:

- (instancetype)initWithRegionType:(AWSRegionType)regionType
                    identityPoolId:(NSString *)identityPoolId
                     unauthRoleArn:(nullableNSString *)unauthRoleArn
                       authRoleArn:(nullableNSString *)authRoleArn
           identityProviderManager:(nullableid<AWSIdentityProviderManager>)identityProviderManager;

I went down this path, discovering later that using this initializer leaves the AWSMobileClient instance in a very broken state.

AWSMobileClient is actually a Swift wrapper and subclass of the Objective-C _AWSMobileClient class. Inside you'll find some code that certainly stretches my understanding of subclassing rules across Swift and Objective-C:

publicclassAWSMobileClient:_AWSMobileClient {
    staticvar_sharedInstance:AWSMobileClient= AWSMobileClient(setDelegate:true)
    
    @objcoverridepublicclassfunc sharedInstance() ->AWSMobileClient {
        return _sharedInstance
    }
        
    @objcpublicvarisSignedIn:Bool {
        get {
            if (operateInLegacyMode) {
                return _AWSMobileClient.sharedInstance().isLoggedIn
            } else {
                returnself.cachedLoginsMap.count >0
            }
        }
    }
    
    // ...
}

Additionally, the initialize method that must be called by the client references itself and several other singletons:

  • _AWSMobileClient.sharedInstance()
  • DeviceOperations.sharedInstance
  • AWSInfo.default() - reads from awsconfiguration.json in the bundle.
  • AWSCognitoAuth.registerCognitoAuth(...)

Takeaway: For this SDK and the other AWS SDKs, you have to use the singletons.

Keychain credentials

The Authentication SDK uses the keychain APIs to store user credentials securely.

We changed server environments a few times during development. First, we had a prototype environment, then changed to a more long-term development environment, and finally to a production development in parallel with the development environment. By environment, I mean the keys used to locate our apps resources (e.g. PoolId, Arn, ApiUrl, ApiKey, etc.).

A few of our team members had installed and ran a release build of the app in the prototype environment at some point, thereby storing some Cognito tokens in their keychain. When we switched to the development environment, we started seeing deadlocks during our authentication bootstrapping process. The bootstrapping process happens on a cold launch and runs the required asynchronous AWSMobileClient initialization methods.

The debugging steps of deleting the app and reinstalling did not work because the keychain contents are retained by iOS across app installs for the same bundle ID.

Once we had determined that AWSMobileClient could not handle loading "bad" environment user credentials – user credentials created with a different AWS configuration parameters – I had to create special builds for these devices that called AWSMobileClient.sharedInstance().signOut() immediately on launch.

We actually saw a similar deadlock in AWSMobileClient when running the app on the iOS simulator during development, which threw me off the trail a bit during debugging.

Takeaway: Be careful when changing environment configuration parameters.

Drop in Authentication UI

The Authentication SDK includes a drop-in UI. Because we wanted to ship our app to beta users as quickly as possible to start gathering feedback, I was particularly pleased that I wouldn't need to write a custom UI for authentication.

Unfortunately, we found a few dealbreakers that prevented us from using the drop-in UI.

First, the drop-in UI has no support for localization. Since our first market is Japan, we definitely needed the UI to support Japanese. The localization issue has appeared in other contexts as well, especially errors returned by the SDK. I would keep this point in mind if the product you're working on requires any other language besides English.

Second, I was planning on presenting the authentication view controller from our root view controller, an instance of UIViewController. I found that the entry point to the drop-in UI requires a UINavigationController:

+ (void)presentViewControllerWithNavigationController:(UINavigationController *)navigationController
                                        configuration:(nullable AWSAuthUIConfiguration *)configuration
                                    completionHandler:(AWSAuthUICompletionHandler)completionHandler;

This seemed like an odd requirement since the drop-in UI view controller seemed to be presented modally. Digging into the code, I came to the same conclusion as this GitHub Issue: the only API used is the UIViewController presentation API.

There's also this long-running GitHub Issue with feature requests for the drop-in UI.

Takeaway: Using the drop-in UI may not be feasible for your use case.

Is initialize an asynchronous task?

The signature of AWSMobileClient's required initialization method is:

public func initialize(_ completionHandler: @escaping (UserState?, Error?) -> Void)

From this signature, I would assume this function is asynchronous, and therefore anything that depends on the result of this call needs to wait until the completionBlock is called.

However, if we look at the implementation:

internalletinitializationQueue= DispatchQueue(label:"awsmobileclient.credentials.fetch")

publicfuncinitialize(_ completionHandler:@escaping (UserState?, Error?) ->Void) {
    // Read awsconfiguration.json and set the credentials provider here
    initializationQueue.sync {
        // ... full implementation
    }
}

I wasn't sure what to expect when stepping through this code, but it looks like if initialize is called on the main thread, the implementation within the sync closure continues to be executed on the main thread. After the completion handler is called within initialize and that code runs, control flow returns to the end of initialize.

Image may be NSFW.
Clik here to view.
f:id:christopher-trott:20190614105742p:plain
Callstack during `AWSMobileClient.initialize`

Takeaway: You can probably assume that AWSMobileClient.sharedInstance().initialize(...) is synchronous. However, if you're paranoid about the implementation changing at some point, treat it in your calling code as asynchronous.

Storage SDK

Initialization

Similar to our takeaway from the Authentication's section above about singletons, I recommend being extra cautious about the set up of your AWSS3TransferUtility instance.

Internally, AWSS3TransferUtility the class maintains a static dictionary of instances and a default instance.

// AWSS3TransferUtility.mstatic AWSSynchronizedMutableDictionary *_serviceClients = nil;
static AWSS3TransferUtility *_defaultS3TransferUtility = nil;

There are some directions in the API docs about how to register an instance with custom configuration options.

However, if you decide to use the default instance like I did, you need to set the service configuration in a different singleton before calling AWSS3TransferUtility.default() for the first time. (I only learned this by eventually finding my way to the implementation of AWSS3TransferUtility.default() after struggling for hours with various unauthorized errors at runtime when trying to perform uploads).

AWSServiceManager.default()!.defaultServiceConfiguration = AWSServiceConfiguration(region: .APNortheast1, credentialsProvider:AWSMobileClient.sharedInstance())
lettransferUtility= AWSS3TransferUtility.default()

Takeaway: Register your own AWSS3TransferUtility. Or if you want to use the default, set an AWSServiceConfiguration in the AWSServiceManager singleton before calling AWSS3TransferUtility.default() for the first time.

AWSTask for upload & download operations

The Storage SDK uses AWSTask throughout. AWSTask is a fork of Facebook's Bolts Framework.

Tasks... make organization of complex asynchronous code more manageable.

The usage of the primary Storage SDK's APIs for uploading and downloading are shown in the API docs, but since I wanted to ensure all codepaths for errors were handled properly, I had to dig a little deeper to understand how these tasks work under the hood. I'll use multi-part uploading as an example, but this applies to all three scenarios (uploading, multi-part uploading, and downloading).

I've annotated the types so that you can see the identity of what's actually flowing around all these closures.

letexpression= AWSS3TransferUtilityMultiPartUploadExpression()
expression.progressBlock = { (task:AWSS3TransferUtilityMultiPartUploadTask, progress:Progress) in
    DispatchQueue.main.async(execute: {
        // ...
    })
}

letcompletionHandler:AWSS3TransferUtilityMultiPartUploadCompletionHandlerBlock= { (task:AWSS3TransferUtilityMultiPartUploadTask, error:Error?) ->Voidin
    DispatchQueue.main.async {
        // ...
    }
}

lettaskQueuedHandler: (AWSTask<AWSS3TransferUtilityMultiPartUploadTask>) ->Any? = { (task:AWSTask<AWSS3TransferUtilityMultiPartUploadTask>) ->Any? in
    DispatchQueue.main.async {
        ifletresult= task.result {
            // An `AWSS3TransferUtilityMultiPartUploadTask` was queued successfully.
        } elseifleterror= task.error {
            // The `AWSS3TransferUtilityMultiPartUploadTask` was never created.       
        } else {
            // Not sure if this code path is even possible.        
        }
    }
    returnnil
}

lettask:AWSTask<AWSS3TransferUtilityMultiPartUploadTask>= transferUtility.uploadUsingMultiPart(fileURL:fileURL, bucket:bucketName, key:objectKey, contentType:contentType, expression:expression, completionHandler:completionHandler)
task.continueWith(block:taskQueuedHandler)

The overloaded use of the identifier Task in the types caused me some confusion at first. AWSS3TransferUtilityMultiPartUploadTask is not a subclass or in any way related to AWSTask as a concept.

Let's start at the bottom. The transferUtility.uploadUsingMultiPart(...) method takes some parameters, two closures, and returns an AWSTask<AWSS3TransferUtilityMultiPartUploadTask>: an AWSTask that will asynchronously return an AWSS3TransferUtilityMultiPartUploadTask? or an Error? to the block provided to continueWith.

The moment of understanding I had was realizing that just creating an AWSS3TransferUtilityMultiPartUploadTask is an asynchronous, fallible operation, with an error case that must be handled. That is why we've defined taskQueuedHandler above.

Keep in mind that taskQueuedHandler may be called on a background queue.

completionHandler will always get called if the if let result = task.result code path in taskQueuedHandler executes. completionHandler still has to handle both success and failure cases.

If, for example, you start a UIActivityIndicatorView as loading before calling uploadUsingMultiPart, but you don't handle the task.continueWith error, it's possible that the UIActivityIndicatorView will spin forever.

Takeaway: If you're expecting the result of an upload or download at some point in the future, you need to handle the error case in task.continueWith.

AWSTask for get{*}Tasks

Since AWSS3TransferUtility maintains its own database of tasks, even across app cold launches, you may need to retrieve these tasks. This use case is shown in the API docs.

letdownloadTasks= transferUtility.getDownloadTasks().result
letuploadTasks= transferUtility.getUploadTasks().result
letmultiPartUploadTasks= transferUtility.getMultiPartUploadTasks().result

Note that even though these getter functions return an AWSTask, they're not asynchronous and the result is available immediately. There's also no way for the returned AWSTask to contain an error.

Takeaway: Sometimes the AWS SDKs return AWSTasks for synchronous operations. Sometimes they return AWSTasks for operations that are not fallible. However, be careful relying on this behavior because the underlying implementation could always be changed in a future version without your knowledge.

API SDK

Because AWSAppSyncClient in built on top of ApolloClient, some of the below points are applicable to Apollo GraphQL as well.

Offline Mutations

One of the marketing points of AppSync is that mutations (i.e. POST, PUT, or DELETE in the REST world) can be triggered by a user while they're offline, and the mutations will be queued in local storage and relayed to the server when the user's device has connectivity again.

This is a feature set available in certain types of apps, including many of Apple's own stock apps like Reminders or Contacts.

However, this behavior does not always make sense for all types of mutations. Even when it does make sense, it often comes with an additional heavy burden of proper UX design. Handling errors. Handling conflicts. These are problems that even the most mature apps still struggle with.

In our app, we have a pretty straightforward createUser mutation (i.e. sign up). createUser is a particularly poor candidate for offline mutation support:

  • It has several server-side validation rules for form elements (e.g. unique username).
  • The app is logically partitioned to only allow registered users to access certain parts of the app.

Before learning that offline mutations were the default in AppSync and could not be turned off, I was struggling to understand why when simulating network errors, the completion block to my mutation was never getting called, even beyond the timeout duration.

When I realized this behavior was intentional, it took more time to figure out a workaround that didn't require the huge maintenance burden of subclassing or implementing manual timeout code throughout the app.

It turns out the workaround is as simple as using the underlying appSyncClient.apolloClient instance.

// Before
appSyncClient.perform(mutation:mutation, queue: .main, optimisticUpdate:nil, conflictResolutionBlock:nil) { (result, error) in// ...
}

// After
appSyncClient.apolloClient?.perform(mutation:mutation, queue: .main) { (result, error) in// ...
}

From my reading of the AWSAppSyncClient source, it's safe to force unwrap apolloClient at the moment. But certainly use caution in your particular use case.

With the above code, mutations attempted while offline will fail with an error after the default timeout (60 seconds) and call the completion block.

Takeaway: Use appSyncClient's underlying apolloClient directly to perform mutations that shouldn't be queued offline.

Errors

Overall, GraphQL is a welcome addition of structure compared to REST. However, I've found the error story to be a little disappointing.

When writing my first AppSync API request handler, I soon found the control flow for errors to be a little overwhelming. All layers of the stack have their own set of errors, and Swift's untyped errors don't help the situation.

Let's look at an example fetch request. I've set up and documented the completion handler.

appSyncClient.fetch(query:query) { (result:GraphQLResult<Query.Data>?, error:Error?) in// 1ifletnetworkError= error as? AWSAppSyncClientError {
        // The first layer of error handling is a network stack error.// 2
    } elseifletunknownError= error {
        // This case probably shouldn't happen, but I don't know the network stack// well enough to guarantee that.// 3
    } elseifletdata= result?.data? {
        // This is sort of the happy path. We got the data we requested.// However, `result?.errors?` may still contain errors!// It depends on your use case whether you want to ignore them if// `data` is non-null.// 4
    } elseifletgraphQLErrors= result?.errors?, !graphQLErrors.isEmpty {
        // According to the GraphQL spec, graphQLErrors will be a non-empty list.// These errors are also more or less untyped.// 5
    } else {
        // Although logically we should have covered all the cases,// the compiler can't statically guarantee we have so we should throw// an `unknown` error from here.
    }
}
  1. The network stack is provided by AWSAppSyncHTTPNetworkTransport and throws AWSAppSyncClientError. In the .requestFailed case, the Cocoa error can be extracted and the localizedDescription shown to the user. The other cases probably aren't that useful. Note that although AWSAppSyncClientError conforms to LocalizedError, the error messages are English only and usually add various codes that would probably be unideal to show users.
  2. I haven't dug through the network stack enough to know whether there are other error types that can be thrown, but the presence of an error at this level of the stack probably means that result will be nil.
  3. The GraphQL spec says that result can contain both data and errors. It's up to you to determine whether you need to handle this case, and if so, how to handle it. For many use cases though, getting data means success.
  4. The GraphQL spec defines an error as a map with a message that's intended for developers, and optionally locations and path fields. As of the June 2018 spec, user fields should be contained within the extensions field. However, the AppSync spec was based on the October 2016 GraphQL spec, and therefore defines an errorType field in the root of the error map. errorType is a String type which makes it more readable to developers, but also more error prone.
  5. All those nullable fields have left us with an else case.

I really wish errors were typed in GraphQL (and Swift too!).

Takeaway: Handling the results of a fetch or perform requires some knowledge about the various layers of the network stack. Make sure you've considered the possible errors at each layer and how can you help your user recover from them.

Equatable structs

The codegen utility included in AWS Amplify and part of Apollo's tooling does not support generating structs that conform to Equatable. Generated enums do conform to Equatable.

The way structs are laid out, all the struct's data is stored in a dictionary [String: Any?] (typealiased as Snapshot). Its typed properties are decoded from or encoded into that dictionary on the fly in a property's getter and setter, respectively.

Equatable could probably be generated the old fashioned way by comparing all properties. I'm unsure of whether this could introduce performance problems for deeply nested structs due to the lazy (and non-cached) decoding.

This was discussed in a (now closed) GitHub issue.

Takeaway: Code generated enums conform to Equatable. Code generated structs do not conform to Equatable. If you need Equatable structs, you'll have to write the == function yourself manually, generate it with a tool like Sourcery, or create wrapper structs.

Query watching

AWSAppSyncClient has a useful watch feature that allows you to receive updates to any resources fetched by the query you're watching throughout the lifetime of the watch. Experimenting with this feature, I've found a few conceptual points to keep in mind.

watch works by first adding a subscription to any changes to the store. Next, it makes a normal fetch with the same configurable cache policy options available to fetch. The results of this initial fetch are used to create a list of dependentKeys. When the cache notifies the GraphQLQueryWatcher that its contents have changed, the GraphQLQueryWatcher checks if any of the changed keys are contained in its dependentKeys, and if so, it fetches the query again (with cache policy .returnCacheDataElseFetch) then calls the closure registered in watch with the result.

Set up the cache key identifier on your store

As stated in the docs, you have to tell apolloClient how you uniquely identify your resources:

// Use something other than "id" if your GraphQL type is different
appSyncClient?.apolloClient?.cacheKeyForObject = { $0["id"] }

In their example, it says that a Post with id = 1 would be cached as Post:1. However, in my testing, only the id itself is used (i.e. 1). Currently, we have ids that are unique across our resources, but if you don't, you may need to investigate this more to ensure you don't have key collisions in the cache.

A fetch must succeed before watching will work

Since dependentKeys are derived from the results of the first fetch (and is regenerated on subsequent fetches), this fetch has to be successful in order for the watch to respond to changes produced by other queries.

If you use watch, you have to allow your user to retry in case the initial fetch fails. Call GraphQLQueryWatcher.refetch(). Even if the same query is fetched from a different part of your app, this query must succeed at least once in order to receive changes.

Use a pessimistic cache policy

You essentially cannot (safely) use the .returnCacheDataDontFetch cache policy with watch.

Granted, it's rare case to want to do so. But if you thought that the partial results from a different query in your app could be picked up by a watch query, this won't work. It has to be the exact same query and it has to have been fetched before with the exact same parameters from the server.

If you used .returnCacheDataDontFetch as the cache policy and the fetch resulted in a cache miss, you would have to call refetch() anyway to make a fetch to the server.

It's not straightforward to use watch with paging queries

It's common in GraphQL to use a Connection type to implement indefinite paging.

Let's look at the following GraphQL schema:

type MovieConnection {
  movies: [Movie!]! # contains a maximum of 10 items
  nextToken: String
}

type Query {
  getLatestMovies(nextToken: String): MovieConnection!
  getMovie(id: Int!): Movie!
}

For example, if you set up a watch for the first call to getLatestMovies(nextToken: nil), this watch will only respond to changes to the 10 Movie resources returned by the query. If you make a normal fetch request for the next page using nextToken, the watch you have set up will not observe changes in the Movie resources returned in the second request.

If you wanted to respond to changes to any Movie returned in any pages, you'd have to do a watch for each page and add the GraphQLQueryWatcher to a collection. The logic in your result handlers would depend heavily on how you structured your data source since the result could be an add or an update.

It's not possible to watch resources outside a query

It's probably obvious from the interface to watch since the first parameter is of type GraphQLQuery, but you cannot watch for changes to an arbitrary key in the cache. For example, if there was a resource in your database keyed by id 12345, you can't simply register a watcher with a dependent key for this id.

Any connection between resources and queries must be resolved by the server

If you have two different queries that you know reference the same object, that relationship must be codified by the server.

Continuing with the getLatestMovies example in the previous section, imagine we received a list of 10 Movies and wanted to watch for granular changes in the Movie with id = 12345.

To accomplish this you might think you could simply call:

let watcher = appSyncClient.watch(query: GetMovieQuery(id: 12345), cachePolicy: .returnCacheDataDontFetch, queue: .main, resultHandler: { (result, error) in ... }

But this would not work! It would result in a cache miss and the watch would be inert until refetch() was called.

Although the Movie returned by GetMovieQuery(id: 12345) is already in the cache, the association between the query itself and the Movie resource can't be resolved by AppSync/Apollo until the server returns the result for the query and this result is cached too.

Conclusion

In this post, I outlined some development points to watch out for in the Authentication, Storage, and API SDKs of AWS AppSync. I hope the takeaways from this post are valuable for current and future users of AWS AppSync.

レシピ検索を支えるレガシーでクリティカルな大規模バッチを刷新した話

こんにちは、会員事業部の新井です。余暇を全て Auto Chess に喰われています。

過去このブログにはサービス開発に関する記事*1を投稿させていただいているのですが、今回はシステム改修についての記事になります。 クックパッドには検索バッチと呼ばれる大規模なバッチが存在するのですが、今回それを刷新することに成功しました。 そこでこの記事では旧システムに存在していた問題点、新システムの特徴や実際の開発について述べたいと思います。

背景

クックパッドのレシピ検索では Apache Solr を検索サーバーとした全文検索を利用しています。古くは Tritonn を利用して MySQL に作られた専用 table を対象に全文検索を実行していたようですが、その頃から「検索バッチ」と呼ばれるバッチが存在していました。 このバッチは、簡単に言うと「検索インデックス」と呼ばれる検索用メタデータを生成するものです。関連各所からデータを収集し、分かち書きやスコアの計算といった処理を実行して検索インデックスを生成し、現在はそれを Solr にアップロードするところまでを実行するバッチ群となっています。

この検索バッチは 10 年以上利用されており、年々検索のメタデータとして使用したいデータ(field)が増加してきたこともあって、種々の問題を抱えたレガシーシステムとなっていました。サービスにとって非常に重要なシステムであるがゆえに思い切った改修に踏み切れなかったのですが、今回のプロジェクトはその一新を目的としたものでした*2

旧検索バッチの問題点

複数の DB やサービスに依存している

検索バッチはレシピ情報にとどまらず、レシピに紐づく様々なメタデータや、別バッチによって集約された情報などを収集する必要があるため、依存先のサービスや DB が多岐にわたっていました。 DB でいえばレシピサービスが利用している main, 集約されたデータが格納されている cookpad_summary, 検索や研究開発関連のデータが格納されている search_summaryなどなど……。サービスへの依存についても、料理動画サービスの API を叩いてそのレシピに料理動画が紐付けられているかを取得してくるなどの処理がおこなわれており、新規事業が次々に増えている現在の状況を考えると、この手の依存はこれからも増大することが予想されていました。

cookpad_all に存在している

旧検索バッチは cookpad_all と呼ばれる、レシピ Web サービスとその管理画面や関連するバッチ群、mobile アプリ用 API などがすべて盛り込まれたレポジトリ上に存在しており、各サービス間でモデルのロジック等が共有されていました。このこと自体はそれほど大きくない規模のサービスであれば問題になることはありません。しかし、クックパッドについて言えば、ロジック共有を通したサービス間の依存を把握するのが困難な規模になっており、「レシピサービスに変更を加えたらバッチの挙動が意図せず変わった」というようなことが起こる可能性がありました。このような状況であったため、特に新しいメンバーがコードに変更を加える際に考えるべき要素が多すぎて生産性が著しく低下し、バグを埋め込んでしまう可能性も高くなってしまっていました。

不必要に Rails である

cookpad_all に存在するバッチ群は kuroko と呼ばれていますが、それらが Rails で実装されていたことから、旧検索バッチも Rails で実装されていました。 しかし、このバッチの実態は「大量のデータを収集して処理」することであり「user facing な Web アプリケーションをすばやく開発することができる」という Rails の強みが活かされるようなものではありませんでした。 実際の実装としても、その大部分が「データを取得するためだけに ActiveRecord のインスタンスを大量に生成する」といったロジックで構成されており、オーバーヘッドの大きさが目立つものになっていました。

責務が大きすぎる

旧検索バッチでは、検索インデックスにおける全ての field が一つのメソッド内で生成されていました。そのため、新たな field の追加や既存の field の編集において必ずそのメソッドに手を入れる必要があり、メンテナンス性に問題を抱えていました。

たとえば、新たな field を追加する際に該当メソッド内に存在する既存のロジックを踏襲したとします。しかし、クックパッドには「レシピ」を表現するモデルが複数存在するため、既存ロジックで利用されていた「レシピ」を表現するモデルと、新たな field のロジックが参照するべきだった「レシピ」のモデルが食い違っており、意図した挙動になっていなかったといったような問題が起こることがありました。

また、研究開発部の施策で検索インデックスに field を追加したいケースなど、レシピサービスにおける検索機能の開発以外を主業務としているメンバーも検索バッチに手を入れることがありました。このように、ステークホルダーの観点から見ても複数の理由からこのバッチが編集されており、「単一責任の原則」が満たされていないシステムになってしまっていました。

実行時間が長すぎる

旧検索バッチではすべての field を生成する処理が直列に実行されているため、Rails での実装であるということも相まって実行時間が非常に長くなってしまっていました。 この時間はバッチの構成がそのままであるうちは今後も field の増加に伴って増大していくことが予想されていましたし、実行時間短縮のために自前で並列実行の実装をおこなっていたのも可読性やメンテナンス性に影響を与えていました。

Image may be NSFW.
Clik here to view.
f:id:spicycoffee:20190617111728p:plain
旧検索バッチの構成

新検索バッチ概要

上に挙げた旧検索バッチの問題点を解消するため、新検索バッチ(以下 fushigibana*3)は以下の要素を実現するように実装されました。

データフローの一本化

先ほど「検索バッチはその性質上多くの箇所からデータを収集し加工する必要がある」と述べましたが、現在クックパッドには組織内のあらゆるデータが集約されている DWHが存在します。各種データソースから DWH へのインポートという作業が存在するためデータの更新頻度に関する問題はありますが、旧検索バッチの時点で検索結果の更新は日次処理であったことも鑑み、fushigibana が利用するデータソースは DWH に限定しました。こうすることで各種 DB やサービスへの依存が解消され、データフローを一本化することが可能となりました。

Rails ならびに cookpad_all からの脱却

fushigibana は redshift-connectorを用いて DWH から取得したデータに、Ruby で分かち書きなどの処理を施して検索インデックスを生成し、それを S3 にアップロードするというつくりになっており、plain Ruby で実装されています(Ruby を選択したのは社内に存在する分かち書き用の gem などを利用するため)。その過程で cookpad_all からもコードベースを分離し、完全に独立したバッチ群として存在することになりました。

クラスの分割と並列実行

fushigibana では検索インデックスの生成処理をサービスにおける意味やアクセスする table などの観点から分割し、「単一責任の原則」を満たすよう実装しています。分割されたクラスはそれぞれいくつかの field を持つ検索インデックスを生成します。最後にそれらのインデックスを join することですべての field を持った検索インデックスを生成しています。こうすることで、それぞれのクラスを並列実行することが可能になり、バッチの実行時間が短縮されました。

また、検索インデックスに新しく field を追加する際にも、既存のロジックに手を加えることなく新しいクラスを実装することで対応が可能となり、システム全体で見ても「オープン・クローズドの原則」を満たしたバッチとなりました。

Image may be NSFW.
Clik here to view.
f:id:spicycoffee:20190617144349p:plain
fushigibana の構成

fushigibana の開発と移行作業

ここからは、実際にどのようにして fushigibana を開発し、それをどのようにして本番環境に適用したかについて述べていきます。

開発の流れ

fushigibana の開発は、大まかに次のような流れでおこなわれました。

  1. 現状の調査
  2. ロジックの SQL 化
  3. 新ロジックの検証
  4. staging 環境における検索レスポンスの検証
  5. kuroko 上での本番運用
  6. コードベースの分離

現状の調査

旧検索バッチの改修に入る前にまずは現在利用されていない field を洗い出し、少しでも移行時の負担を軽減することを目指しました。 コードを grep して一見使われていなさそうな field について識者に聞いて回ります。 この辺りは完全に考古学の域に入っており、作業の中で過去のサービスについていろいろなエピソードを知ることができておもしろかったです。 この作業の後、最終的には 111 の field についてロジックの移行をおこなうことになりました。

ロジックの SQL 化

既存の Rails ロジックを凝視しながらひたすら SQL に書き換えていきます。中には既存のロジックの挙動が明確でないもの、単純にバグっているものなども存在しており、適宜直しながらひたすら SQL に書き換えます。最終的には 32 のクラスで 111 の field を生成することになりました。

新ロジックの検証

新旧ロジックで生成した検索インデックス同士を比較することで、新ロジックの妥当性を検証します。 「データソースが変わるためそもそもデータの更新タイミングが違う」「ロジック改修の際にバグ改修もおこなった」などの理由から厳密な比較は不可能でしたが、できる範囲で新ロジックの不具合を潰していきました。

開発環境における検索レスポンスの検証

それぞれの検索インデックスをアップロードした Solr に対して検索リクエストを投げ、そのレスポンスを比較します。 実際に開発環境のレシピサービスを利用して手動で挙動を確認することはもちろん、検索回数上位 1000 キーワードほどについてスクリプトを回し、「人気順」「新着順」「調理時間絞り込み」など、利用ユーザー数や重要度の観点から選択した、いくつかの機能で発行される検索クエリのレスポンス件数や順序を比較しました。 ここでも厳密な比較はできないものの、ユーザー視点で重要な体験に絞った上で「ある程度の誤差を許容する」「誤差の原因を特定することを目的とする」ことで費用対効果を意識して検証作業を進めました。

旧実行環境上での本番運用

本番運用に入るにあたっていきなりコードベースを分離するのではなく、まずは既存のバッチが動いているシステムの上で新ロジックを走らせる方針を取りました。これは、なにか問題があった際に、それが「コードベースの分離ではなく新しいロジックそのものに問題がある」ことを保証するためのステップです。

コードベースの分離

上記のステップで新ロジックに問題がないことを確認した上でコードベースを分離していきます。実際には cookpad_all 内に存在したロジックをいくつか社内 gem に移行するなどの作業が発生したため、新ロジックの妥当性が完全に保証された状態でコードが分離できたわけではありませんでしたが、それでも一度既存のシステム上で問題なく実行できていたため比較的不安なく分離を進めることができました。

移行作業における安全性の保証

検索バッチが影響を与えるレシピサービスは非常に多くのユーザーが利用しているサービスであり、移行作業に際して不具合が発生する可能性は可能な限り抑える必要がありました。 今回の開発ではシステムの安全性を以下の 4 地点で検証してから反映しています。

  1. 新ロジックの生成する検索インデックスと、旧ロジックの生成する検索インデックスを比較
  2. 新検索インデックスをアップロードした Solr が返すレスポンスと、現在の Solr が返すレスポンスを比較
  3. 新検索インデックスを production の Solr にアップロードした後、現在の検索結果と前日の検索結果を比較
  4. ユーザーからの問い合わせを監視

このうち、1 と 2 については上述した「開発の流れ」における「新ロジックの検証」と「開発環境におけるレスポンスの検証」そのものであるため、ここでは 3 と 4 について述べます。

Image may be NSFW.
Clik here to view.
f:id:spicycoffee:20190617111720p:plain
移行時の検証作業

前日との検索結果の比較

クックパッドの Solr は master-slave 構成で運用されており、検索インデックスが master Solr にアップロードされた後、ユーザーからのリクエストを受ける slave Solr がそれをレプリケーションしてくる形になっています(厳密にはこれに加えてキャッシュ機構があったりします)。逆にいうと検索インデックスをアップロードしても、slave のレプリケーション処理をおこなわなければユーザーへの影響は出ないということになります。

この仕組みを利用して、検索インデックスをアップロードした後検索回数上位の各キーワードについて前日の検索結果と新しい検索結果を件数ベースで比較し、大きな差があった場合レプリケーションを実行しないというテスト機構が存在していました。 この機構は検索インデックスの生成ロジックを変更しても問題なく利用できるものであったため、そのまま活用することになりました。

ユーザーからの問い合わせ監視

いくら開発段階での検証を繰り返しても、実際に不具合の出る可能性を 0 にすることはできません。当然のことではありますが、本番適用日はインフラやサポートチームに共有し、万が一のときにすばやくロールバックできるよう検索インデックスをユーザーからアクセスのこない slave Solr にバックアップした上で反映作業を実施しました。 その後もユーザーから届くお問い合わせには定期的に目を通し、fushigibana 導入による不具合らしきものが報告されていないかどうかを確認していました。

プロジェクトの振り返り

成果

以上に述べたように検索バッチの改修をおこなった結果、どのような成果を得ることができたのかをまとめます。 冒頭に上げた「旧検索バッチの問題点」についてはそれぞれ

  • 複数の DB やサービスに依存している
    • DWH をデータソースとすることで解消した
  • cookpad_all に存在している
    • 別レポジトリに切り出して実装することで解消した
    • 結果 cookpad_all から 1,357 行のコードを削除することに成功した
  • 不必要に Rails である
    • plain Ruby として実装することで解消した
  • 責務が大きすぎる
    • index-generator を複数のクラスに分割して実装することで解消した
    • 「小さな処理を並列で実行する」形に改修したことでリトライ処理も入れやすくなり、バッチ全体の安定性も向上した
    • 同様の理由でバッチ実行基盤の spot instance 化も達成され、将来的にはコスト削減にも繋がりそう
  • 実行時間が長すぎる
    • 分割実装した index-generator を並列実行することで解消した
    • 具体的には全体で 7.5h かかっていたものが 4.5h となり、約 3 時間の短縮化に成功した

という形で解決することができました。 丁寧に検証フェーズを重ねたこともあり、今のところ不具合やユーザーからのお問い合わせもなく安定して稼働しています。 また、上記に加えて「コードの見通しが改善したことによる開発の容易化」や「ドキュメンテーションによるシステム全体像の共有」といった成果もあり、検索バッチ周りの状況は今回のプロジェクトによって大きく改善されました。

反省

今回の一番大きな反省点はプロジェクトの期間が間延びしてしまったことです。 着手してみた結果見積もりが変わった・プロジェクトのスコープが広くなっていったという事実もあるため仕方のないところもありましたが、特に検証フェーズにおいてはより費用対効果の高い方法を模索することができたのではないかと思います。

たとえば検索インデックスの比較と Solr レスポンスの比較はかなり近いレイヤーに属するものであり、どちらか一方を省略しても検証の精度に大きな差は存在しなかった可能性があります。 結果として「不具合が出ていない」という事実は喜ばしいことですが、組織にとってはエンジニアリソースも重要な資源ですし、今後は「かかるコスト」についてもしっかりと意識をしてプロセスやアーキテクチャの選定をしていきたいと思います。

今後の課題

今回のバッチ改修はあくまで「レシピ検索」についてのものであり「つくれぽ検索」「補完キーワード検索」などについては(また別の)古いシステムで動いています。 今後はそれらの検索インデックスを生成するシステムについても改修をおこなう必要があると思いますし、その際に fushigibana に乗せるのか、あるいはどう関係させるのかというのはしっかりと考慮する必要があると思います。

fushigibana そのものについての課題としては、現在 AWS Glue へのスキーマ登録を AWS console から手動でおこなう必要があることがあげられます。 ドキュメントは残しているものの、この作業だけ fushigibana のリポジトリ上で完結しないのは開発者に優しくないと感じていますし「スキーマ定義ファイルの内容に従って AWS Glue の API を叩くスクリプトを実装する」といったような解決策を取るべきであると思っています。

まとめ

今回の記事ではクックパッドにおける検索バッチシステムの改修について解説しました。 「現状のシステムを調査することで洗い出した問題点を解決する構成を考え、技術を用いて可能な限りシンプルに実現する」という当然かつ難しいことを、規模の大きなシステムに対して実践するのは非常にやりがいがあり、エンジニア冥利に尽きる仕事でした。 システムの構成も現時点における「普通」にかなり近いものになっており、今後の開発にもいい影響があると期待されます。

クックパッドには検索バッチ規模のシステムが多数存在し、その多くはよりよい実装に改修されることが期待されているものです。もちろんそのためには多くのリソースが必要であり、弊社は年がら年中エンジニアを募集しています。 大規模なシステムの開発に挑戦したいエンジニア、多くのユーザーを支えるサービスに関わりたいエンジニア、技術の力でサービスをよくしたいエンジニアなど、少しでも興味を持たれた方は是非ともご応募ください。

*1:https://techlife.cookpad.com/entry/2018/02/10/150709https://techlife.cookpad.com/entry/2018/12/07/121515

*2:クックパッドでは 2017 年よりレシピサービスのアーキテクチャ改善を目的とするお台場プロジェクトが進んでおり、それに貢献する意味もありました

*3:Solr にデータを撃ち込む → ソーラービーム → フシギバナ

モダンBFFを活用した既存APIサーバーの再構築

技術部の青木峰郎です。 去年までは主にデータ分析システムの構築を担当していましたが、 最近はなぜかレシピサービスのサービス開発をやっています。 今日は、そのサービス開発をする過程で導入したBFF(Backends for Frontends)であるOrchaについて、 導入の動機と実装の詳細をお話しします。

Orcha導入にいたる経緯

まずはOrcha導入までの経緯、動機からお話ししましょう。

最初のきっかけは、わたしが去年から参加しているブックマークのようなサービスの開発プロジェクトでした。 このプロジェクトの実装のために新しいmicroserviceを追加することになったのですが、 そのときにいくつかの要望(制約)がありました。

1つめは、撤退するとなったときに、すぐに、きれいに撤退できること。

2つめが、スマホアプリからのAPI呼び出し回数はできるだけ増やしたくない、という要望です。

図1を見てください。 既存APIサーバーとは別に新しいmicroservice(API)を追加してスマホアプリから呼べば、 今回追加する部分はきれいに分かれていて実装も簡単です。 しかし、それではスマホアプリからのAPI呼び出し回数が増えてしまいます。

Image may be NSFW.
Clik here to view.
f:id:mineroaoki:20190621215345j:plain
図1: 単純にサービスを増やすとAPI呼び出し回数が増えてしまう

例えばクックパッドアプリのトップページは現在でもすでに10以上のAPIを呼んでいるので、 もうできるかぎりAPI呼び出し回数を増やしたくありません。

かと言って、既存APIサーバー(Pantry)の改修もしたくありません。 図2のように、Pantryから新サービスを叩くように変更すればAPI呼び出しを1つにまとめることはできます。 しかしこのPantryというサーバーは以前の記事で説明した「世界最大のモノリシックなRailsアプリケーション」であり、 理由はよくわからないがとにかくこれをさわるだけで開発期間が3倍になる優れモノです。 できることならいっさいPantryにさわることなく開発を終えたいわけです。

Image may be NSFW.
Clik here to view.
f:id:mineroaoki:20190621215427j:plain
図2: Pantryをいじれば目的は達成できるが対価が必要

つまり、API呼び出し回数は増やしたくないのでできれば既存のAPIに値を追加する形で実装したい。 しかしそのためにPantryはいじりたくない。

API呼び出し回数を増やしたくない……既存のAPIに手を加えたい……でもPantryはいじりたくない……。

この3つの思いが謎の悪魔合体を遂げて生まれたのがOrcha(オルカ)なのです。

Orcha 〜クックパッドのためのBFF〜

Orchaを導入した後のアーキテクチャを図3に示しました。 見てのようにOrchaはリバースプロキシと既存のAPIサーバーであるPantryの間にはさまって、 スマホアプリに特化したAPIを提供します。

Image may be NSFW.
Clik here to view.
f:id:mineroaoki:20190621215234j:plain
図3: Orchaのアーキテクチャ

今回は既存APIに新規サービスの情報を追加したいというのがそもそもの目的だったので、 まずOrchaがPantryのAPIを呼んで、レスポンスで得たJSONに新規サービスからの情報を差し込むことで目的を達成しています。 この場合のOrchaは「高機能なJSON用sed」のような働きをします。

OrchaはクックパッドのiOS/Androidアプリに特化したAPIを提供することを主眼としたシステムなので、 いわゆるBFF(Backends for Frontends)だとも言えます。 BFFとは、スマホアプリやウェブフロントエンドのような特定のクライアントに特化したAPIサーバーのことです。 汎用のAPIではなく、あるクライアントに密着した固有のAPIを提供することを目的にしています。 BFFについての詳細はこのあたりの記事をお読みください。

ちなみに、当初はBFFというよりオーケストレーション層を作るぞ! という気持ちのほうが強かったので、 "Orchestration Layer"の先頭を適当に切ってOrchaと命名しました。

すべてのAPIはカバーしない

Orchaはこのような経緯で導入したため、現在のところ、 スマホアプリが必要とするすべてのAPIを提供しているわけではありません。 スマホアプリのトップページのすべてのデータを返すトップページAPIなどの、スマホアプリに特化したAPIの一部のみを提供しています。 残りのAPIについては、現在もリバースプロキシからPantryへ直接リクエストを投げています。

そのような中途半端な入れかたをした1つめの理由は、 もし今回のプロジェクトがうまくいかなかったときは新規サービスをOrchaごと捨てて撤退する予定だったからです。 まるごと捨てるなら、必要最小限のAPIだけをOrchaにサーブさせておいたほうが、当然捨てるのも簡単です。

2つめは消極的な理由で、Orcha経由にするメリットが特にないからです。 既存のAPIをOrcha経由で呼ぶようにしたところで、単にレイテンシが数ミリ秒増えるだけで、たいしていいことがありません。 強いて言うとこの記事を書くときに「一部しか経由しませんよ」という説明をしなくて済むくらいでしょう。 それにもし将来メリットが発生してOrchaを経由するように変えようと思ったら、その時に変えればいいだけです。 したがって、当初は実装量がより少ないほうを選ぶことにしました。

Orchaの実装設計

Orchaの実装言語はしばし悩んだのちJavaに決めました。 Spring WebFluxとSpring Reactorを使って、非同期のリクエスト処理を実装しています。

JavaとSpringを選択した第一の理由はパフォーマンスです。 Pantryはやたらとリソース食いなので、 1 ECS task(サーバー台数とおおむね同じ意味)が3 CPUコア、メモリ4GBで、 毎日のピークタイムには150以上のECS taskが必要になっています。 これと同じ調子でリソースをバカ食いするサーバーをもう1つ立てるのはさすがに避けたいところです。

またレイテンシについても気を遣う必要があります。OrchaをPantryの前に立てるということは、 Orchaでかかったレイテンシーがそのまま既存のレイテンシーに追加されるということです。 Orchaのレイテンシーはできるだけ小さくしておかなければ、 スマホアプリの使い勝手を大きく悪化させてしまうことになるでしょう。 それを避けるには例えば、複数システムへのAPI呼び出しを並列化するなどの工夫をすべきです。

さらに、どのようなAPIを呼ぶことになるかは予測できないので、非常に遅いAPIもあるかもしれません。 そのような場合にもワーカーを使い果たして停止するようなことのないアーキテクチャを選択する必要があります。 ここまで来ると選択肢は非同期I/Oしかないでしょう。

非同期リクエストのフレームワークがあり、実行効率が高いとなると、定番はJVM系かGoです。 そこで結局、何度もJavaを利用した実績があったこと、 Java 8とJava 9での改善およびLombokの登場により言語仕様に目立った不満がなくなったこと、 さらに品質の高いAWS SDKやDBドライバがあること、の3点からJavaとSpringに落ち着きました。

なお正確に言うと、限定公開を始めた当初はリクエスト数も非常に少なかったため、 Spring WebMVCを使って同期リクエスト処理を実装しました。その後、全体公開することが決まった時点で、 API単位でSpring WebFluxに切り替えて非同期化していきました。 ここは同期・非同期のフレームワークが両方あり、しかも同居が可能なSpring Frameworkの利点が最大限に活きたところです。

認証処理の共通化

パフォーマンス向上という点ではOrcha導入にあたってもう1つ配慮したポイントがあります。 それは認証処理の共通化です。

図4はOrcha導入前のクックパッドアプリの認証経路です。 一言で言うとAuthCenterというシステムがすべての認証を請け負っており、 マイクロサービス各位はそれぞれ独立に認証を行うという仕組みです。

Image may be NSFW.
Clik here to view.
f:id:mineroaoki:20190621215506j:plain
図4: これまでの認証処理

これまではそれでも大きな問題はありませんでした。 なぜなら、基本的に1 APIは1システムによってハンドルされていたため、 APIリクエスト数と認証回数が等しかったからです。

しかしOrcha導入後は、1 APIリクエストにつき2回以上の認証処理が発生します。 つまり、最悪の場合はAuthCenterへのリクエスト数が突如として2倍以上になる可能性があるわけです(図5)。

Image may be NSFW.
Clik here to view.
f:id:mineroaoki:20190621215527j:plain
図5: 何も考えずにOrchaを導入したときの認証処理

AuthCenterは現時点でもすでに社内随一のリクエスト数を誇る人気サービスで、 ぶっちゃけた話DBがけっこうパツパツだったりするので、 いきなりリクエスト数が倍になれば陥落する可能性もあります。 それはいくらなんでもまずかろうということで、 Orchaの全体公開に合わせてID tokenを使った認証処理の共通化を実装しました(図6)。

Image may be NSFW.
Clik here to view.
f:id:mineroaoki:20190621215614j:plain
図6: ID tokenを使った認証の共通化

仕組みはこうです。 まず最初にリクエストを受けたOrchaはAuthCenterにアクセストークンを渡して検証してもらい、 認可などのためのメタデータを含むID tokenを受け取ります。 Orchaはアクセストークンの代わりに、AuthCenterから受け取ったID tokenを各サーバーに付与してリクエストします。

ID tokenは、JWTという形式のJSONを秘密鍵で署名したものです。 秘密鍵に対応する公開鍵は社内の全サービスに共有されているため、 そのトークンは間違いなくAuthCenterが発行したものであることが検証できます。 つまり各サービス内でその検証だけ行えば、 いちいちAuthCenterに問い合わせなくとも認証を完了することができるのです。

このへんはめんどくさかったのでわたしにはあまり知見がなかったので、 弊社の無敵万能エンジニア id:koba789に仕様決めから全部ぶんなげて実装してもらいました。 そのうち id:koba789が詳しいことを書いてくれると思います。

サービスメッシュを使った他システムとの連携

Orchaと他の上流システムとの通信は、 すべてクックパッドの標準的なサービスメッシュシステムを介して行いました。 サービスメッシュは特にBFFだから使うというものではありませんが、 個人的に今回いくつか利点を実感できたので述べたいと思います。

まずサービスメッシュでの自動リトライ機能について。 エンドポイントごとにタイムアウトを設定でき、タイムアウトした場合は自動的にリトライする、 それでもだめならしばらく通信を止める(サーキットブレーカー)という機能があり、これが非常に便利です。 最初はリトライなんていつ起きるんだよと疑っていたのですが、実際に試してみたら毎分起きていて認識を改めました。 また障害などで大量にエラーが発生したときにはサーキットブレーカーが働いて輻輳を防止してくれるので、 高いレベルで可用性を高めてくれます。

第二にクライアントサイドロードバランシングが容易に実装できる点。 Orchaには上流システムにgRPCのシステムがいくつかあるのですが、 普通のHTTP通信でも、クライアントサイドロードバランシングのgRPCでも、 こちら側の設定はほぼ同じ設定で通信できるようになるのでとても楽でした。

最後に、他システムとの通信のメトリクスが自動的に取得されて視覚化される点です(図7)。 これは正確に言えばサービスメッシュ自体の機能ではなく「サービスメッシュがあると容易に実装できる機能の1つ」です。 自システムで発生したエラーの数はもちろん、どの上流システムとの通信で500がいくつ出ているのか、 どのシステムとの通信が遅くなっているかも一目でわかるため、性能調査や障害調査に役立ちまくりでした。 他社のエンジニアにこの画面を見せると異常にうらやましがられる画面です。 この画面のためだけにでもサービスメッシュを実装する価値があると思います。

Image may be NSFW.
Clik here to view.
f:id:mineroaoki:20190621215640p:plain
図7: 他システムとの通信のモニター画面

Orcha導入後の評価

以上が、Orchaを入れた経緯とその設計などの詳細です。 これを踏まえて、現時点までの結果と評価を述べます。

まず、当初の目的であった 「API呼び出し回数を増やさずに、撤退しやすい仕組みで、新規サービスを高速に追加すること」は問題なく達成できたと思います。 Orchaと新規サービスを合わせて、インフラ構築からとりあえず動き出すまでをわたし1人だけで、1週間で完了できました。 これはPantryで開発をしていてはとても達成できない目標でした。 また、現在は新卒で入ったばかりのエンジニアにOrchaの開発をしてもらっているのですが、こちらもスムーズに開発できています。 これもPantryではありえないことです。

第二に、かなり真剣に考えたパフォーマンスについても、全体公開後の数値を見るかぎり問題なさそうです。 現在、ピークタイムでも全プロセスの合計リソースがCPU 1コア、メモリ8GBで余裕をもって全リクエストをさばけています。 もちろんECS(Docker)で動いていますし、オートスケールを設定してあるので、必要なときは勝手にECS task数が増減されます。 非同期処理に特有のつらい点として、「ものすごい勢いでメモリリークする」などの問題が全体公開直後に発生したりしましたが、 これも早期に解決できました(タイムアウト設定の問題でした)。

第三にJavaとSpringの選択についても満足しています。 Springについてはいろいろいい点はありましたが、 まずデフォルトでアプリケーション設定がファイル(application.yml)と環境変数で透過的に設定できる点が便利です。 開発環境ではいろいろと便利なデフォルトや設定例を提供したいのでapplication.ymlをレポジトリにコミットしておき、 本番環境ではDocker前提なのですべての設定を環境変数で設定する、ということが簡単にできるので大変便利でした。 また当然ながら設定項目はアノテーション一発でオブジェクトに自動マッピングしたうえDIで注入できます。

ちょっとした追加の機能実装をしたいときにほぼ間違いなくライブラリがある点も有利です。 例えば開発環境でだけ動く単純なリバースプロキシ機能を追加したくなったのですが、 Spring Cloud Gatewayを導入し、application.ymlを少し書くだけで簡単に実装できました。 このへんのライブラリの充実っぷりはさすがです。

総じて、アーキテクチャ・実装設計ともに現時点では満足しています。 次のチェックポイントは、スマホアプリのエンジニアがさわるようになったときでしょう。

これからのOrcha開発ロードマップ

最後に、今後のOrchaの開発ロードマップについて今考えていることを述べます。

直近の目標は、Orchaをより完全な集約層にすることです。 具体的には、既存のAPIサーバー(Pantry)に存在する、 実質的に集約層として機能しているAPIのコードをすべて剥がしてOrchaに移動することです。

集約層的なAPIは全体からすれば数は少ないですが、実装が複雑なので分量はけっこうあります。 このコード移動を完遂して、スマホアプリの開発者がOrchaをいじれるようになることが当面のゴールです。

また、集約層的なAPIの移動が完了すれば、 残るAPIはすべてリソースを処理するAPIになるはずなので、 そちらは小さいシステムに分割してgRPCにしてしまいたいところですが…… これが終わるにはあと何年かかるやら、という感じです。終わりが見えない。

まとめ

本稿では、クックパッドのレシピサービスに新たに追加したBFF "Orcha"に関して、 その動機と実装、評価をお話ししました。 今回、個人的に一番うまくやれたと思う点は、既存システムの改善と新機能の追加を両立できたことです。 通常、この2つは利益相反の関係にあることが多く、どちらを取るかジレンマに悩まされがちです。 しかしOrchaについては珍しいことに両者を同時に満たす一石二鳥の手を打てたので大変満足しています。

では最後にいつものやつです。

弊社は世界最大のモノリスを共に崩していく仲間を募集中です。 三度のメシよりRailsが大好きなかたも、 RailsアプリをJavaに書き換えてこの世から消滅させたいかたも、 あとついでに今回の話とは関係ないですがデータエンジニアも S3とSQSとLambdaでAWSピタゴラスイッチしたい人も、 ともに大募集しております。 興味を持たれたかたはぜひ以下のサイトよりご応募ください。

SwiftUIで使用されているSwift5.1の新機能

こんにちは。会員事業部の岡村 (@iceman5499) です。 普段はクックパッドアプリ(iOS)を開発しています。 先日San Joseで開催されたWorldwide Developers Conference 2019 (WWDC19)に参加し、そこでSwiftUIの発表をうけていくつか調べたことがあるので簡単にまとめておきたいと思います

SwiftUIの登場

今年のKeynoteの最後に、SwiftUIという新たなUIフレームワークが発表されました。 SwiftUIはReactやFlutterのような形式でViewを宣言して画面を構築できる、これまで使用されてきたUIKitとは全く異なる形式のフレームワークです (AppleのSwiftUI紹介ページ )

この発表をうけてKeynoteはとても盛り上がっていました。期間中もSwiftUIの話題でもちきりで、セッションも多く開かれていました

SwiftUIでできるようになること

  • DSLでViewを宣言的に適宜できるようになりUIの構成要素を簡単に表現できるようになった
  • コード編集中にリアルタイムにUIプレビューを利用できる *1
  • 余白調整やアクセシビリティ・ダークモード対応などがある程度自動で行われ、Human Interface Guidelinesに則った画面を作成しやすい
  • スムーズなアニメーションが簡単に設定できるようになった

UIKitではよくあるリスト形式の画面を作るだけでも TableViewDelegateTableViewDataSourceのメソッドを多数実装したり、ラベルを上下に並べるのに

label.constraint(equalTo:otherLabel.topAnchor, constant:16).isActive =true

などといった長いコードを書いていく必要がありましたが、SwiftUIではそれがすっきりして

 List(contents) { content in
    VStack {
      Text(content.title)
      Text(content.subtitle)
    }
 }

のような形でシュッと書けるようになりました 🎉

Image may be NSFW.
Clik here to view.
f:id:iceman5499:20190624145219j:plain
Introducing SwiftUI: Building Your First App

(Introducing SwiftUI: Building Your First Appより)

実際にさわってみた感想

2019年6月現在。macOS Catalina 10.15 Beta 2 と Xcode 11 Beta2 を使用しています

プレビューめちゃくちゃ使いやすい!?

  • 起動して目的の画面にたどり着くための操作をしなくてもいい
  • その場でタップフィードバックなども試せる
  • モックデータを簡単に挿せる

あたりの機能は非常に便利で、今後のプロトタイピング開発やエラー表示のテスト、デザインドキュメントとしての利用など様々な場面での活用が予想されます

新規プロジェクトならいい感じに動いたのですが、一方で既存プロジェクトで動かそうとした場合にいくつかの問題点に遭遇しました

  • ビルドターゲットがiOS13未満に設定されているとプレビュービルドができない *2

Swiftでは @availableを用いることで指定コードが有効化されるiOSバージョンを制御することができます。これを用いてビルドターゲットがiOS13未満のプロジェクトにおいては

@available(iOS 13.0, *)
structContentView:View {
    varbody:some View {
        Text("Hello World")
    }
}

のように記述をすることでビルド及び実行することができるようになります(iOS13未満ではSwiftUIを使用できないため代わりの実装を用意する必要があります)

こちらの書き方を使用して既存プロジェクトからSwiftUIを利用する場合、Xcode11Beta2時点ではプレビュービルドはエラーとなり利用することができませんでした

  • Objective-C製ライブラリ(Firebaseなど)を使ってるとたびたびそれらのビルドが走る

これはSwiftUIの問題ではなくビルドシステムの都合だと思うのですが、プレビューを使用する際は変更部分のみがリビルドされるはずがObjective-Cを利用している場合にそれらのビルドが走ることがあり、現実的な待ち時間でプレビューを使用することが難しいことがありました

  • 新規プロジェクトでも急に止まったり調子悪くなったりしがち

こちらはシンプルにプレビューの描画が止まったり明らかにおかしくなったり、ビルドが長くなったりなどです

と、このような障害もあり、まだBetaであるため安定してないのはしょうがないですが、安定しないままリリースされる可能性も十分にあり得るため今のところプレビューはあくまで補助的なものと捉えています。

個人的にあるだろうと思った機能がないこともある

触っていくとだんだんと気づくのですが、Betaということもあって個人的に必要だと思った機能が実は存在していないといったケースがあります

  • HStackなどを用いて複数のViewを等幅で配置できない *3
  • ボタンハイライト時の挙動を設定できない *4
  • 画面を閉じる or 戻るボタンを配置できない

コンポーネントはどんどん拡充されていくはずですので、リリース時点やその後のコンポーネントの拡充に期待です

SwiftUIで使用されているSwift5.1の新機能

SwiftUIにはSwift5.1で新規追加される機能がふんだんに使用されていました

  • @propertyDelegate
  • @_functionBuilder
  • Opaque Result Type
  • @_dynamicReplacement(for: )
  • KeyPathに対する @dynamicMemberLookup

順にみていきます

@propertyDelegate

Proposal: SE-0258

(※ Proposalでは Property Wrappersという命名になっていますが、Xcode11Beta2上ではまだ @propertyDelegateが使用されているため本記事ではこちらで表記します)

この修飾子をつけるとプロパティに対して新しいattributeを宣言できるようになります

例えば次のような Lazyを宣言してみます

@propertyDelegateenumLazy<Value> {
  case uninitialized(() ->Value)
  case initialized(Value)

  init(initialValue:@autoclosure@escaping () ->Value) {
    self= .uninitialized(initialValue)
  }

  varvalue:Value {
    mutatingget {
      switchself {
      case .uninitialized(letinitializer):letvalue= initializer()
        self= .initialized(value)
        return value
      case .initialized(letvalue):return value
      }
    }
    set {
      self= .initialized(newValue)
    }
  }
}

このような @propertyDelegateが宣言されているとき、次のようにその宣言された型の名前でattributeを宣言できるようになります

@Lazyvarfoo=1738

これは実際のコンパイル時に以下のように展開されます(イメージのための疑似コードです)

var_foo:Lazy<Int>= Lazy<Int>(initialValue:1738)
varfoo:Int {
  get { return _foo.value }
  set { _foo.value = newValue }
}

fooへのアクセスが _fooに移譲される形となり暗黙に Lazy<Int>の機能を利用できるようになります。Lazyでは遅延初期化の実装がされているため、lazy varと同じような機能が @Lazyをつけることによって利用できるようになりました

Property Delegateを使用したSwiftUIの型は多数存在しています。 例えば @StateではViewに使用される値の更新検知をしており、View.bodyの中で @Stateつきの変数にアクセスするとその変数の監視が始まり、その値が変化したときに自動的にViewが更新されるといった挙動をみせています

また $をつけることによってラップしている本来の型のオブジェクトにアクセスすることができます

$foo// → Lazy<Int>

このとき、さらにラップしている型に var delegateValue: T { get }が定義されていれば delegateValueを取り出すことになります

例えば @Stateでは var delegateValue: Binding<Value> { get }が定義されている*5ため、

@StatevarinputText:String...varbody:some View {
  // ↓ TextField.init(_ text: Binding<String>) に対して//   $inputText.delegateValue: Binding<String> を $inputText という記法で取り出して渡している
  TextField($inputText)
}

次のようなコードがある場合に $inputTextBinding<String>を返します。 TextFieldは自身への入力を Binding<String>を経由して別のところへ渡すというインターフェースをしています

ややこしいですが、これによってSwiftUIはViewへのデータバインディングのためのプロパティの更新検知を実現しています

@_functionBuilder

Forum、 Proposal: SE-XXXX

VStack {
  Text("Hoge")
  Text("Fuga")
}

このコードを見たときSwiftのエンジニアは当然 🤔となると思います。クロージャが返り値を持っておらず、途中で評価しただけの Textが何らかの形でクロージャの外に現れています

VStackのイニシャライザ(一部省略)はこうなっていて

init(@ViewBuilder content: () ->Content)

なるほど怪しい @ViewBuilderが生えてることがわかります

これは新たに追加された @_functionBuilderによる機能で、どこかに @_functionBuilder struct ViewBuilder {}が宣言されているときクロージャ引数に @ViewBuilderを付与できるようになり、そのクロージャの中で評価された式は ViewBuilderが持つ各種build関数の中を通って出力されます

例えば中で2つのViewが評価されていたときはViewBuilderの public static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View関数の中を通ります(1つ目に評価されたViewがc0、2つ目がc1として引数が与えられます)。結果、クロージャは TupleViewという型のインスタンスを返します

Swiftにこのような言語機能が搭載されたことによって、Swiftの型検査を有効にしたままDSLが記述できるようになっています

Opaque Result Type

Proposal: SE-0244

これまで関数の返り値にprotocolを指定した場合はexistential type *6にラップされて返され、ラップやアンラップの処理にオーバヘッドが発生していました。 またprotocolがassociated typeを持っていた場合はprotocolはGenericsの型パラメータを持てないので AnyHashableなど型消去のテクニックを用いて返却する必要がありました。これは元の型の情報を失っているために本来比較可能でないもの同士を比較できてしまうなどのコードを記述できてしまいました

Swift5.1からその問題を解決するために、 "protocol P を満たすある一つの型"を返すという意味で some Pという表現ができるようになりました。これによってPを満たす任意の型を実際の型を知らずとも扱えるようになりました

これがどのように作用しているかというと、 多くのSwiftUIの構造体は

structButton<Label>where Label :View {

のようにGenericsでそのViewの内部にあるViewの型を指定して受け取ります。existentialはそれ自身のprotocolに適合しないのでexistential経由でGenericsへ型パラメータをわたすことはできず、この型パラメータのためにはconcreteな型を知る必要があります

ただしSwiftUIの型は非常に複雑で、例えば上の

VStack {
  Text("Hoge")
  Text("Fuga")
}

VStack<TupleView<(Text, Text)>>型です。 これはまだマシですが、

List {
    Section {
        ForEach(names.identified(by: \.self)) { name in
            Text(name)
        }
    }
}

だと List<Never, Section<EmptyView, ForEach<IdentifierValuePairs<Array<String>, String>, Text>, EmptyView>>型になります。 こんな複雑な型をいちいち返り値に記述することは人間には難しいですし、変更に弱すぎます

そこで、それらをまるごとひっくるめて some Viewとして表現できるようになっています。 このような表現をSwift proposalでは、Opaque Result Typeと説明しています

varbody:some View {
    List {
        Section {
            ForEach(names.identified(by: \.self)) { name in
                Text(name)
            }
        }
    }
}

実際のコードはこうなので、上のような複雑な型を書く必要がなくなっています。 この機能によって実装中に複雑な型の存在を意識せずともViewを取り扱えるようになっています

@_dynamicReplacement(for: )

Forum

これはXcodeでのプレビュー用に使われている属性で、dynamic修飾子がついた関数などにこの属性がついたモジュールをロードしてあげるとその関数の実装を入れ替えることができるようになります。 SwiftUIのPreviewではこれを用いて実行中のシミュレータが持つバイナリの実装を動的に差し替えてリアルタイムなプレビューを実現しています

ちなみにこの挙動の存在は、Preview機能がクラッシュしたときのエラーログからXcodeがプレビュー対象のコードに @_dynamicReplacement(for: )をつけて回っていてあとから差し替えてる様子が確認できたことから確認しました

さながらObjective-C時代のMethod Swizzlingですね

KeyPathに対する @dynamicMemberLookup

Proposal: SE-0252

@dynamicMemberLookupは以前からSwiftに実装されている機能ですが、今回新たにKeyPathに対してsubscriptできるようになりました。 具体的な定義はこんな感じです

// BindingConvertibleの例subscript<Subject>(dynamicMember keyPath:WritableKeyPath<Self.Value, Subject>) ->Binding<Subject> { get }

任意のKeyPathをdynamicMemberLookupできるようになったため、プロパティアクセスのふりをしつつ型安全にsubscriptでアクセスできるようになりました。 これが具体的にどういうことか、以下のコードをみてみましょう

@dynamicMemberLookupstructBox<T> {
    varvalue:Tsubscript<U>(dynamicMember keyPath:WritableKeyPath<T, U>) ->U {
        return value[keyPath:keyPath]
    }
}

structUser {
    varname:String="taro"varage:Int=42
}

letboxedUser= Box(value:User())
print(boxedUser.age) // → 42

boxedUser.ageはいかにも Boxに生えているように見えますが、実際にアクセスする先は Userの持つ ageとなっています。 このようにして、 @dynamicMemberLookupを使用することで subscriptで指定されてる型に適合するkeyPathを \.ageなどの記法を使わずに取り出してあたかもプロパティ呼び出しであるかのようにsubscriptに流し込んで呼び出せるようになっています

これはSwiftUIではprotocolの BindableObjectで活用されており、

structViewModel:BindableObject {
  varname:String...
}

@ObjectBindingvarviewModel:ViewModel

として宣言されている viewModelに対して、

TextField($viewModel.name)

のように $viewModel.nameBinding<String>として取り出す操作を可能にしています。 (ObjectBindingのdelegateValueは ObjectBinding<BindableObjectType>.Wrapper型であり、それはKeyPathのdynamicMemberLookupで Binding<T>を返す) 一見viewModelに生えてるStringのプロパティを取り出しているように見せかけてBindingを返せているのでpropertyDelegateの恩恵をぶら下がってるプロパティにも適用できるようになっています

まとめ

SwiftUIで使用されているSwift5.1で追加された新機能について調べてみました。 マイナーアップデートでありながら大胆な機能が多数追加されてコードの様子が一気に様変わりしましたね。見た目は大きく変わりつつも中身は型の効いてるSwiftらしさがあり挙動や実装を調査していくのはとても楽しいですね

クックパッドアプリ(iOS)は1年前のiOSバージョンまでサポートする運用をしており、なんとあと1年とちょっと待てばSwiftUIが実用段階になる予定です。 また新規アプリを作成する際は最初からSwiftUIでやっていけるかもしれません。 クックパッドではSwiftUIを使ってすばやくサービス開発していくエンジニアや、SwiftやXcodeに詳しく開発環境を改善していけるエンジニアを募集しています

https://info.cookpad.com/careers/

*1:正確にはXcode11&macOS Catalinaの機能

*2:Beta2時点

*3:内部を HStack { Spacer(); content(); Spacer() } で囲む、GeometryReader でframe直打ちなどのやり方はありますがすっきりするものではありません

*4:longPressAction や DragGesture を使うという裏技もありますがすっきりするものではありません

*5:https://developer.apple.com/documentation/swiftui/state/3287851-delegatevalue

*6:https://blog.waft.me/2017/10/27/swift-type-system-08/などで詳しく解説されています


Markdown と GitHub で社内規程を便利に管理

VP of Technology の星 (@kani_b) です。技術基盤や研究開発領域などを担当しつつ、社内の色々なことを技術の力でいい感じにする仕事をしています。セキュリティや AWS の話が好きです。

さて、みなさんは、ご自身が勤務する会社の就業規則を読んだことはあるでしょうか。 エンジニアに限らず、会社の全スタッフが仕事をする上で関わってくるのが、就業規則や情報セキュリティドキュメントなど、会社のルールや規程を記す文書です。 特にセキュリティやインフラに携わるエンジニアは、その改訂も含め携わったことがある方もいるのではと思います。

よくある文書管理

こうした文書は、以下のように管理されていることが多いようです。

  • ベースドキュメントは Word
    • 保存時は PDF で保存
  • 版管理は Word の編集履歴 + PDF に保存する際のファイル名
  • 編集は担当部門, 担当者のみが行う

かつてのクックパッドでも、上記のように作成された PDF ファイルを Google Drive に保存して従業員向けに公開していました。 この記事を書くにあたり他のいくつかの企業の状況を伺ったところ、細かな差異はあれど同じような運用をされている例がほとんどでした。

つらい点

上記のような管理において、自分がつらいと感じる点がいくつかありました。以下に挙げていきます。

レイアウト難しい問題

複数人で編集することを前提とした文書の体裁を Word や他のワープロソフトで保ち続けるのはなかなか難しいものです。 全員が習熟していれば良いのですが、習熟度に差があると同じレイアウトでさえ記述方法が違っていたりします。 「番号付きリストかと思ったら番号は手動入力されていた」「中央寄せかと思ったら全角スペースの数でレイアウト調整されていた」「改行の数が違うとレイアウトが崩れる」なんてことはよくある話ではないでしょうか。

そもそも、そこまで頑張って整えている体裁は本当に必要…?

版管理難しい問題

ワープロソフト側に版管理の機能が備わっていることも多いのですが、複数人での編集を前提とするとき、全員が意識して同様の管理を行う必要があります。また、担当者の引き継ぎによって文化が失われてしまうような悲しい事態も起こります。 それ以外にも、規程閲覧側に公開されるのは最終成果物である PDF ファイルのみであることが多く、差分を確認するためにはそのバイナリに対応したソフトを利用する必要があります。閲覧側にとっても便利とはいえない状況です。

これら2点が感じていた大きな問題ですが、他にも

  • 文書を横断した検索性が悪い
  • 複数人でのレビューが難しい

といった問題を感じていました。

社内文書管理に求めていたこと

ここまでに書いた問題を感じつつ、つらい〜と鳴きながら文書編集をしていたのですが、ある日雑談の中でそうした文書管理を担当していた、いわゆるバックオフィスの同僚も同じようなつらさを感じていたことを知りました。 そこで、規程や社内マニュアルなどの文書に求められることを簡単にまとめてみたところ、概ね以下のような条件を満たしていれば良いのでは、という結論に至りました。

  • 然るべき責任者の承認のもと編集されていること
  • きちんと版管理が行われていること
  • 編集すべきときにすばやく編集できること
  • 見たいとき、見るべきときにすばやく参照できること
    • 閲覧しやすいフォーマットであることが望ましい

あれ、これって…?

あえてプレーンテキストを使う

というわけで、Word と社内ファイルサーバおよび Google Drive で管理されていた社内文書を、Markdown で書かれたファイルを Git (GitHub) で管理する形に移行しました。

Markdown は、 GitHub 上でリッチな表示を使ったレビューが可能です。また Groupad と呼ばれる社内 Wiki では長年 Markdown 記法が使われていました。このため、Markdown という名前を知らなくても 「Groupad と同じ記法」と説明すれば通じる状況にあり、利用をはじめるにあたりあまり障壁はありませんでした。

Image may be NSFW.
Clik here to view.
f:id:kani_b:20190626175720p:plain
Markdown で書かれた就業規則

また、GitHub についても、数年前から全社員に GitHub Enterprise のアカウントが発行されており、人事部門や法務部門も Issue ベースでのやり取りに慣れているといったところから、ある程度スムーズに利用を開始することができました。

GitHub からのファイル編集

Git 移行にあたって最も障壁となりやすいのは、編集作業をどのような環境で行うか、という点だと思います。 素の Git コマンドをターミナルから使ってくれ、ではハードルはどこまでも高くなるだけですので、Git を使ったことがない同僚にはまず GitHub の編集機能を使ってもらうことにしました。 あまり利用されていないようにも思えますが、GitHub そのものにも編集機能が用意されており、ファイル編集や変更の Commit, Branch や Pull Request の作成なども可能になっています。Git の概念をすべて理解してもらうのでなく、使う機能を限定することで、極力移行をスムーズにしました。

改訂フロー

現在の文書改訂フローは以下のようなものです。

  • 担当者が改訂案を Markdown で起案し Pull Request を作成
    • 必要に応じて他の担当や上長からのレビューを受ける
  • 責任者は確認し、内容に問題がなければ承認とマージを行う*1
    • GitHub の Branch Protection を使い、責任者の Approve がなければマージできないようになっている
    • 会議体の承認が必要な文書は Pull Request のスクリーンショット (記録のため) ごと会議体にまわり承認されたのち、責任者によって Approve される
  • マージ後は自動的に公開される

GitHub を使った開発でも行われるようなフローで文書管理を行えるようになっています。

文書の公開

GitHub における Markdown ビューを使っても、文書として読むのに問題ないビューを得ることができます。 ですが、文書の移行をすすめるうち、複数ある社内文書のインデックス作成やカテゴリ分類、少し複雑な計算式表現などが必要になりました。*2

そこで、Markdown が利用可能な静的サイトジェネレータである Jekyll を使い、Markdown で作成した文書から静的ページを生成して社内に公開しています。ドキュメント用の Jekyll テーマである tomjoht/documentation-theme-jekyllを採用しました。 Jekyll は GitHub Pages によって GitHub 側でビルドを行えるので、作成した文書をそのまま提供することもでき便利です。

当初 GitHub Pages による提供を考えていたのですが、例えば入社が内定された (入社前の) 方に公開するなど、アクセス制御の要件が増えてきたため、現在では Jenkins 上で自動ビルドを行って Amazon S3 上でホストしています。

Image may be NSFW.
Clik here to view.
f:id:kani_b:20190626175943p:plain
Jekyll でビルドされ公開されている就業規則

クックパッドでの利用状況

現在クックパッドでは、就業規則や賃金規程などほぼすべての社内規程が上記のような形で管理されており、法務部門や人事部門も GitHub を使った文書管理を行っています。 従業員はビルドされた HTML ドキュメントを参照することも可能ですし、元の Markdown を確認することや、Git のコミットログを確認することも可能です。

情報セキュリティのガイドブックといった、改訂しやすい文書は担当者外からの Pull Request も受け付けています。レイアウトの修正のほかに、ルールそのものに対する提案も Pull Request として来るものが生まれており、議論しやすくなったと感じています。

また、文書全体のレイアウトを整えやすくなり、より構造を意識して書くようになったり、従業員からの検索性が上がったり*3といった効果もありました。

あくまで個人的な感想ですが、編集しやすくなったことで、改訂に対する (気持ち的な) 腰の重さも軽くなったように思います。規則やそれを記述する文書は従業員の業務を助けるものであり、会社や世間の状況が変わった際すばやく改訂できる状態を保つことはとても重要と考えています。

まとめ

クックパッドにおける社内規程の文書管理を Markdown および GitHub を使った管理に移行した事例についてご紹介しました。

担当者全員が Git を覚えなければならない世界にするのでなく、GitHub といったある程度親しみやすいインタフェースを間に利用するといったように、担当者や閲覧する従業員にとって良い形を追い求めていくことは非常に重要です。こうした改善は「やりたい人の独り善がりにならないようにすること」がとても大事な、かつ楽しい部分だと感じています。

また、クックパッドでは、今回ご紹介したように技術を活用しながら全社の業務をより良くしたいコーポレートエンジニアや、自らの領域を一緒に改善していける財務・人事といったコーポレート部門スタッフを大募集しております。興味をお持ちいただけましたら、キャリア採用情報 (https://info.cookpad.com/careers/jobs/) から詳細を是非ご確認ください。

*1:軽微な修正のため、担当者の判断でマージできる文書もあります

*2:たとえば、賃金規程において、賃金の計算式を示すのに MathJaxを使っています

*3:現在は GitHub における検索にまかせています

おすすめの食べ方を見ながら食材を買える体験を作った話

はじめに

こんにちは、買物事業部のデザイナー兼エンジニアの長野です。

生鮮食品ECサービス「クックパッドマート」の開発チームで、注文ユーザー向けのサービス開発全般を担当しています。

今日は、先日クックパッドマートのiOSアプリでリリースした新機能とその開発プロセスについて、お話ししたいと思います。

クックパッドマートの詳細については、以前にも サービス立ち上げ期の話や、エンジニアメンバーの連載記事が投稿されているので、そちらもぜひご参照ください。

食べ方を想像しながら食材を選ぶ

今回リリースした新機能は、クックパッドマートで扱う様々な商品(食材)に対して、その食材を使ったおすすめの「食べ方」を提案する、というものです。

これまでのバージョンのアプリでも、商品におすすめのレシピを紐付けて見せるということは行なっていたのですが、今回のアップデートではより幅広い種類の「食べ方」とそのレシピを見ることができるようになりました。

Image may be NSFW.
Clik here to view.
f:id:yoshiko-nagano:20190627094151p:plain
「食べ方」が表示されるホーム画面と商品詳細画面

例えるなら、お店の人と対面で買い物をするときに「この魚は煮付けでもいいし、塩焼きでも美味しいよ」などと会話して、自分の気分で食卓をイメージして食材を購入する体験のようなものです。

このような機能のかたちに至るまでのプロセスを順を追ってご紹介していきます。

数ある課題の何から手をつけていくか?

カスタマージャーニーマップで現状を把握する

今回プロジェクトの開始時点では、アプリのどの部分に手を入れるか、どのような課題にアプローチするか、何も決まっていませんでした。「色々課題はありそうだけど、何から手をつけよう?」という状態です。そこで、まずは現状を正しく把握するためのカスタマージャーニーマップを作成するところからプロジェクトを開始しました。

Image may be NSFW.
Clik here to view.
f:id:yoshiko-nagano:20190627094202p:plain
作成したカスタマージャーニーマップ

社内でサービスを日常的に使っている数名にインタビューをし、サービスの利用フローとその時の思考の流れをマップに書き起こしました。

本来はよりリアリティのある社外ユーザーの情報を集めたいところですが、まだ開始間もないサービスでユーザー数が少ないこと、社内でも普段使いしている人が複数いたことから、スピード優先で社内リサーチを選択しました。

数名のインタビュー結果から、ユーザー属性によって傾向が見られたので、最終的に3人のペルソナとしてまとめることができました。

非同期型ワークショップで課題を洗い出す

カスタマージャーニーマップからは様々な課題が見えてきます。それらを洗い出すために、チームメンバー全員参加の非同期型ワークショップを行いました。

クックパッドマートのチームは、アプリ開発をするメンバーもいれば、日々の配送を回していく流通系のメンバーなど、担当領域が多岐に渡ります。それぞれの視点から見た課題意識をきちんと洗い出すために、今回は「全員参加」の形式にこだわりました。

とはいえ、総勢25名近いメンバー全員の時間を確保するのは非常にコストが高いです。そこで「非同期」で参加できるワークショップという形をとりました。

ワークショップのやり方は以下です。

  • 開催期間は3日間
  • 3人のペルソナのマップを、席近くの壁3箇所に貼り出す
  • メンバーは好きな時間に席近くのマップを眺めて、気づいた課題と解決策のアイデアをポストイットに書いて貼る
  • 毎日マップの掲載場所を入れ替え、全てのマップに目を通してもらう

Image may be NSFW.
Clik here to view.
f:id:yoshiko-nagano:20190121112748j:plain
非同期ワークショップ会場の様子

結果、3日後には25人の視点から洗い出された現状の課題とアイデアを集めることができました。

課題をマッピングし、優先順位をつける

大量に洗い出された課題は似た観点の課題をグルーピングし、以下の3つに仕分けをしました。

  • 今集中して掘り下げるべきもの
  • 今意識しなくても必然的に取り組むことになるもの
  • 今のフェーズではやらなくてよいもの

Image may be NSFW.
Clik here to view.
f:id:yoshiko-nagano:20190627094514p:plain
グループごとに3色の付箋を貼って仕分けた様子

ここまでくると、現状のサービスの課題とそれらの優先度が自ずと見えてきて、次のトライを集中して考えられる状態になりました。

どうやって課題を解決する?

デザインスプリントで解決策を探る

上記の課題の整理から、直近フォーカスして掘り下げる課題が以下に定まりました。

  • どうすれば、日常の買い物の選択肢になれるか?
  • どうすれば、食材の購入だけに止まらない買い物体験を作れるか?
  • どうすれば、使い続けても飽きないサービスになれるか?

そこで、これらに対する解決策を考えていくためのデザインスプリントを実施しました。

デザインスプリントでは、課題に対して一つのソリューションをプロトタイピングし、ユーザーインタビューを行なって検証するという一連のプロセスを、短期間で集中して行います。(こちらの書籍に詳しく書かれているGoogleの手法を踏襲して実践しています)

Image may be NSFW.
Clik here to view.
f:id:yoshiko-nagano:20190627094601p:plain
スプリント中の様子

Image may be NSFW.
Clik here to view.
f:id:yoshiko-nagano:20190627094627p:plain
家にある食材をベースに食べ方を提案するプロトタイプを作成

ここで作成したプロトタイプがそのまま使えるものになったわけではありませんでしたが、スプリントを通した検証結果から下記のような学びを得ることができました。

  • 食材から単一のレシピへ誘導すると、ユーザーの気分や制約条件(家族の状況、調理時間、使用する調味料や調理器具など)とのミスマッチが起こりやすい (現行アプリはこの状態だった
  • レシピより一段階抽象的な「食べ方(唐揚げ、和え物、煮付けなど)」を複数提示されると、ユーザーは気分に合う料理をイメージしやすい
  • 一つの「食べ方」に対してレシピを複数提示できると、ユーザーは自分の制約条件をクリアしたレシピに出会いやすい

したがって、これらをサービスに落とし込めれば、 いつサービスを訪れても自分にあった料理と必要な食材を見つけることができ、クックパッドマートが日々の買い物の選択肢になれるのではないかという仮説を立てることができました。

本当に実現可能なの?

コアになる技術要素を検証する

スプリントを終えて、試す価値のある仮説が得られたものの『本当にユーザーにとってグッとくる「食べ方」を複数提案できるのか?』というサービスのコアとなる部分の実現可能性には疑問が残っていました。実現できなければプロダクトが成り立たなくなってしまうので、早々に検証を進めました。

幸い、クックパッドには毎日料理をしているユーザーさんが提供してくれるたくさんのデータがあります。これまでのレシピサービスの開発の過程で、レシピデータや検索ログを元にして、食材に対する「食べ方」のデータを返す機能が複数開発されていました。そのような既存機能を活用して、クックパッドマートに適した「食べ方」を提案する方法がないかを検証していきました。

検証の対象としたのは、レシピサービスの検索部分でもすでに利用されている「食べ方検索」機能のロジックや、検索キーワードと関連性の強い食材を抽出するロジック、食材に対して多くのユーザーがよく作っている定番のメニューを抽出するロジックなどです。

Image may be NSFW.
Clik here to view.
f:id:yoshiko-nagano:20190627094722p:plain
クックパッドアプリで提供している食べ方検索機能

検証の方法はシンプルで、クックパッドマートで扱う主な食材キーワードを各機能に投げ、返ってきた結果をスプレッドシートにまとめました。それを、日常的に料理をしている人にユーザー目線で見てもらい、一番グッとくる結果を返せた機能はどれかを精査しました。

結果として、一番良さそうという感触が得られたのは「定番メニュー抽出ロジック」でした。定番メニューと言えども、10件以上提案されると自分の頭だけでは浮かんでこなかった食べ方に出会うことができる実感が得られました。また「定番」なので、突飛過ぎずにイメージしやすいといういい塩梅の提案が出せることがわかりました。

仮説をサービスに落とし込む

技術的な実現目処もたち、いよいよサービスの中でかたちにしていきます。

Figmaで画にしてレビューを繰り返す

UIデザインは基本的にFigmaを使って共有しながら作っていますが、初期はパターンをとにかくたくさん出し、良さそうな案を探っていきます。

Image may be NSFW.
Clik here to view.
f:id:yoshiko-nagano:20190627094756p:plain
まずは手書きのスケッチから

Image may be NSFW.
Clik here to view.
f:id:yoshiko-nagano:20190627094822p:plain
Figmaでたくさんのパターンをつくる

クックパッドマートチームは、日常的に料理をしているメンバーが多く、部内で簡易なユーザーテストをしてみるだけでも有益なフィードバックが多く得られます。Figmaのプロトタイピング機能を使って実機でデザインを見せるということを繰り返し、デザインの方向性を固めていきました。

また、画にする <->フィードバック のサイクルを出来るだけ短くする方法として、最近はペアデザインも試しています。UIデザイナーとPMで数時間社内の空きスペースにこもり、画にすることと議論することを同時進行で進めることで、デザインの精度とアウトプットのスピードが高められることが実感できました。

Image may be NSFW.
Clik here to view.
f:id:yoshiko-nagano:20190627094857p:plain
オフィスの片隅でペアデザインをする様子

実データを見られるようにする

ダミーデータでデザインをしていると、どうしてもこちらの都合の良い見え方だけでデザインが進んでしまいがちです。なので、早い段階で実データを見られるようにすることも意識しました。

レシピサービス側に必要なAPIのエンドポイントを作成し、クックパッドマートの管理画面からアクセスしてデータを取得できるようにする機能を早めの段階で実装しました。実際にどんなデータが何件返ってくるのかを確認できることで、デザインと現実のギャップを埋めることができました。

段階的に実装・リリースする

今回は、変更全体を一度にリリースするのではなく、商品詳細に「食べ方」を表示するフェーズと、アプリのホームに「食べ方」を表示するフェーズの二段階に分けて、リリースを進めました。

リリースを分けた理由はいくつかありますが、実装範囲が絞られることで既存機能への予想外の影響を少なくできることと、QAの対象範囲を狭めてQA期間を最小限にできることが大きいと思っています。

出来るだけリリーススピードを落とさず、チームが常にサービスを改善し続けている実感を持てる状態が、健全なサービス開発を進めていく上でとても大切だと考えています。

まとめ

「何から手をつけようか…?」と完全に手探りなところから、様々な手法を使って仮説を定め、一つの新機能としてサービスに落とし込むまでのプロセスをご紹介しました。

クックパッドマートは、買い物を便利にするだけのサービスにとどまらず、食材を買うことの先にある「料理をして食べる」という体験全体をデザインしていくことが重要だと考えています。おいしい食材でおいしい料理を作って大切なひとと食べる時間を世の中にもっともっと増やしていくために、今後もサービスを進化させていきたいと思います。

この記事を読んでクックパッドマートの開発にご興味を持っていただけた方がいれば、ぜひ一緒にサービスを作りましょう!ご応募おまちしております。

www.wantedly.comwww.wantedly.com

Firebaseで運用するKomercoの管理用アプリケーションの開発

こんにちは。Komerco事業部エンジニアの高橋(id:yosuke403)です。「料理が楽しくなるマルシェアプリ」であるKomercoの開発を行っています。

Webサービス開発と聞くとユーザが利用するWebアプリやモバイルアプリの開発を思い浮かべますが、運営スタッフがサービスのデータを閲覧・更新するための管理用アプリケーションの開発も必要になることがほとんどです。

KomercoはバックエンドにFirebaseを活用しているのを一つの特徴としているサービスです。 今回はKomercoの開発事例を通して、Firebaseを用いた管理アプリケーション開発の知見をご紹介したいと思います。

Komercoの管理用アプリケーションについて

KomercoではFirebaseのHostingを利用し、Webで管理用アプリケーション(以下、管理アプリ)を提供しています。

Image may be NSFW.
Clik here to view.
f:id:yosuke403:20190705083445p:plain

Komercoの管理アプリでできることとして、

  • 登録商品の監視
  • 販売許可証の審査
  • コメルコバナシ(アプリ内の記事コンテンツ)や特集(テーマに沿った商品のピックアップ)の更新
  • Push通知

などがあります。

管理アプリの利用者はKomercoのスタッフですが、エンジニア以外のメンバーが触ることが多いです。また、社外の業務委託先にも管理アプリを利用してもらい、一部の業務を依頼しています。

アクセスログを残す

管理アプリからは一般ユーザのプライベートな情報も閲覧できますし、またデータの更新が一般ユーザに影響を与えることもあるので、トラブルに備えて誰が、いつ、どんな操作をしたかを記録する必要があります。

KomercoではデータベースにFirebaseのFirestoreを利用していますが、Firestoreには誰がどんな操作をしたのかをログ出力する機能がありません。 そのため面倒ではありますが、Webフロントエンドから直接Firestoreへはアクセスさせず、Cloud Functionsを経由して読み込み、書き込みを行うようにしています。

Cloud Functionsはイベント駆動でアプリケーションを実行できるFirebase(GCP)の機能です。イベントトリガーにはいろいろな種類がありますが、 functions.https.onCallをトリガーとしてセットした場合、渡されてくるCallableContextのデータから簡単にユーザIDが取得できます。このユーザIDはFirebase Authenticationに登録されているIDで、これを確認することで誰がこのFunctionを呼んだかが分かります。

またCloud FunctionsからFirestoreへのアクセスは通常Firebase Admin SDKを使用しますが、 Firestoreへのアクセスを記録するためにの薄いラッパーを作っていて、それを経由してSDKを呼ぶようにしています。 これによりFirestoreへのデータ読み込み・書き込みが発生すると、誰がFirestoreへどんなアクセスしたか、ログに吐き出されるようになります。

Image may be NSFW.
Clik here to view.
f:id:yosuke403:20190705085904p:plain

こちらは実際に出力されているログの例です。uidに対応するスタッフがproductとadminのドキュメントをgetしたことが分かります。

Image may be NSFW.
Clik here to view.
f:id:yosuke403:20190705083450p:plain

Webフロントエンドから直接Firestoreへアクセスできないのが面倒ではありますが、Firestoreのセキュリティルールが複雑化しなくて済むというメリットもあります。 もし直接Firestoreにアクセスできるようにするなら、一般ユーザ向けアプリ用のルールと管理アプリ用のルールが混在するようになってしまいます。

管理アプリユーザごとの権限を設定する

管理アプリユーザが誤って意図しない操作を行ってしまったり、閲覧してはいけないデータを参照したりしないよう、管理アプリユーザごとにロールを設定しています。 Functionが実行されたときは、まず最初にアクセスユーザのロールをチェックし、リクエストのあった機能の利用権限があるかを判定しています。

現状は簡易な設計をしていて、Firestoreに管理者を登録するコレクションを作成し、各ドキュメントにはユーザIDをドキュメントIDとし、ロールに対応する文字列の配列をフィールドとして持たせています。 Functionが実行されたときにこのドキュメントを参照して権限チェックを行います。

Image may be NSFW.
Clik here to view.
f:id:yosuke403:20190705083510p:plain

パフォーマンス問題

Cloud Functions経由でデータ取得・更新するようになって問題になったのが、管理アプリのデータ表示にかかる時間が長くなったことです。

管理アプリは基本的に限られたスタッフしか利用しないので、Functionが呼ばれる頻度が低く、その結果多くの場合においてFunctionのコールドスタート(実行環境がゼロから初期化されてスタートする状態)が発生しやすい状況になっていました。 コールドスタート時に遅くなる原因はいくつかありますが、大きな原因の一つがFunctionを実行するインスタンスとFirestore間で新規にコネクションを張る処理時間でした。 これはFirestoreへの最初のリクエストの時点で発生します。

Functionが変わればそれを実行するインスタンスも変わるので、コネクションが新規に必要になります。 管理アプリでは画面ごとに異なるFunctionが呼ばれることがほとんどで、新しい画面を開く度に時間がかかり、非常にストレスでした。

Image may be NSFW.
Clik here to view.
f:id:yosuke403:20190705083504p:plain

メモリ割り当てを増やす

Firestoreとのコネクションの確立を速くするにはメモリ割り当てを増やすのが効果的です。

次のグラフはメモリ割り当てを変更してコールドスタートの実行時間を計測したものです。 Firestoreから指定IDのドキュメントを一つだけ取得する処理を行っています。

exportconst testFunction = functions.https.onRequest(async(req, res)=>{const result =await admin.firestore().doc('/aaa/bbb').get()
    console.log(result)return res.send('succeeded')})

Image may be NSFW.
Clik here to view.
f:id:yosuke403:20190705083443p:plain

実行時間が半分ぐらいになっているのが分かります。 GCPのコンソールを見れば分かるのですがメモリ使用量としては256Mでも十分で、実はメモリ割り当てに紐付いて変更されるCPU割り当てが効いているものと思われます。 もちろん料金はその分上がるのですが、今のところ管理アプリ用のFunctionは実行回数がそこまで多くないので、高めに設定しています。

Functionを統合して呼び出しごとのコールドスタートを減らす

もう一つの対策として、管理アプリから利用するのFunctionを1つに統一し、クエリパラメータに応じてロジックを分岐するようにしました。 これにより一度確立したFirestoreのコネクションを、どのロジックを実行する場合でも流用できるため、画面遷移ごとにコールドスタートすることは少なくなりました。

Image may be NSFW.
Clik here to view.
f:id:yosuke403:20190705083506p:plain

Function統合のメリット・デメリット

Functionの統合はコネクションの流用以外にもメリットがあります。Functionが分かれないので、先の項目で説明したロールのチェックをはじめとする全Function共通の処理を一箇所に置くことができます。また、Functionの数が減るのでデプロイの負荷も抑えることができます。

Function統合のデメリットとしては、本来Functionごとに分かれるログがひとつに混ざってしまうことです。 しかし、現状限られたスタッフのみが管理アプリを利用している状況であるため、今のところログの量も比較的少なく、Webコンソールの検索機能から十分追えています。

パフォーマンスの改善だけであればメモリ割り当ての変更のみでもよいのですが、Komercoでは以上を踏まえ両方の対応を行いました。

データの検索

管理アプリとしてよく要求される機能がユーザや商品の検索機能なのですが、Firestoreで文字列検索を行うのは難しく、必要ならAlgolia等の外部サービスと連携するする必要があります。とはいえ、わざわざ外部サービスと連携するのは面倒です。

完全な解決作ではありませんが、前方一致だけなら対応できます。 例えばproductコレクションのnameフィールドに対して前方一致検索をかけたい場合、

firebase.firestore()
  .collection('product')
  .orderBy('name')
  .where('name','>=', input)
  .where('name','<=', input + '\uf8ff')

と書くことができます。

複雑な検索が不要な場合はこれで対応しています。

Cloud FunctionsのエラーをSlackに通知

Cloud Functionsで発生したエラーはSlackに流してすぐに気づけるようにしています。 Cloud Functionsには直接Slackに通知する機能はありませんが、ログはGCPのStackdriverに記録されているため、StackdriverのError Reporting機能を使うことができます。これによりメール通知としてエラー報告を受け取れます。Gmailでこのメールを受信し、フィルタと転送、及びSlackのEmail Integration機能を使うことでSlackに通知が流れるようになります。

Image may be NSFW.
Clik here to view.
f:id:yosuke403:20190705083412p:plain

プレビュー機能で結果を分かりやすく

管理アプリのいくつかの機能では、データ入力後にユーザにどう見えるかをプレビューする機能をつけています。 管理アプリはエンジニア以外のメンバーが使うことが多いので、自分が入力した情報がユーザにどのように表示されるのかを伝える必要があります。 プレビュー機能があると、管理アプリのユーザは安心して情報入力できるようになります。

例えばこちらはコメルコバナシの編集画面です。右半分がアプリに表示される様子をプレビューしています。

Image may be NSFW.
Clik here to view.
f:id:yosuke403:20190705083440p:plain

こちらはユーザへのPush通知画面画面です。画面下にiOSで通知される様子が表示されています。 FirebaseコンソールにもPush通知を送る画面はありますが、こちらの方が結果がより分かりやすいかと思います。

Image may be NSFW.
Clik here to view.
f:id:yosuke403:20190705083455g:plain

スタッフから喜ばれる機能なので、エンジニアも自主的に開発に取り組んだりしています。

最後に

Komercoの管理アプリを通して、Firebaseの特徴を踏まえた管理アプリの開発の知見をご紹介しました。

管理アプリはユーザの目には直接触れませんが、スタッフの効率に直接影響するため、なるべくよいものを提供したいと思っています。 Firebaseは運用にかかるコストが低く、エンジニアがサービス開発に専念できるのがとてもよいところです。 管理アプリも積極的に開発して全体の効率を上げていきたいです。

Komercoでは、モノで料理を楽しくしたいエンジニアを募集しています。 Firebaseを使ったサービス開発に興味のある方いましたらぜひご連絡ください! ご応募お待ちしております!

www.wantedly.com

www.wantedly.com

Simpacker: Rails と webpack をもっとシンプルにインテグレーションしたいのです

技術部の外村(@hokaccha)です。Rails で webpack を使うためのシンプルな gem を作ったのでそれについて紹介します。

Webpacker

Rails で webpack を利用した Web フロントエンドの環境を作る場合、最近では Webpacker が選択されることが多いでしょう。Rails 6 からは Webpacker が標準になることもあり、この流れはますます加速すると思われます。

私自身もこれまでいくつかのプロジェクトで Webpacker を利用してきました。Webpacker は webpack を Rails から簡単に利用でき非常に便利なのですが、使っているうちにいくつか不満な点がでてきました。

一番大きい問題として Webpacker が @rails/webpacker という npm パッケージに webpack の設定を隠蔽し、Webpacker 独自の API や webpacker.yml という設定ファイルを通して webpack の設定をする必要があるというところです。

Webpacker のドキュメントに書いてある範囲内であったり、Webpacker にデフォルトで組み込まれている設定であれば webpack のことを知らなくてもなんとなく webpack が使えるという点は便利なのですが、webpack のことをすでに知っていたり、Webpacker のドキュメントにない設定をしようとした場合、webpack の設定を Webpacker に反映する方法を調べなければいけません。なので使っているうちに、「頼むから webpack の設定を直接書かせてくれ!」となります(少なくとも私はなりました)。

また、webpack のバージョンの Webpacker に引きづられてしまうというのも問題の一つです。webpack 4 がリリースされてから、Webpacker の webpack 4 対応バージョンが正式リリースされるまで1年以上の間がありました。

Simpacker

Webpacker は webpack を知らない人でも簡単に使えるというところがいいところだと思うのですが、もう少しシンプルに webpack とのインテグレーションだけをしてくれるツールがほしいと思い作ったのが Simpacker という gem です。クックパッドでもいくつかのプロジェクトで導入し、うまく動いています。

Simpacker は基本的なところは Webpacker と同じです。manifest.json という、ハッシュ値が付与されたファイル名ともとのファイル名のマッピング情報を持つファイルをもとに、JS や CSS を読み込むタグを生成するヘルパーを提供します。Webpacker と違うのは、Simpacker は webpack 側の設定を一切管理せず、出力する manifest.json のパスしか知らないというところです。開発者は好きな webpack のバージョンを使い、直接 webpack.config.js を書いて、Simpacker で設定されたパスに manifest.json を出力すればいいだけです。

そのため、原理的には webpack でなくても Parcel や Rollup などのモジュールバンドラーでも利用できます。実際に Parcel で動くことは確認してはいています1

例えば webpack では webpack-manifest-pluginなどのプラグインで manifest.json を出力できます。これによって以下のような JSON が出力されます。

{"application.css": "/packs/application-6ebc34fd09dc6d4a87e9.css",
  "application.js": "/packs/application-6ebc34fd09dc6d4a87e9.js",}

Rails 側では Webpacker と同じように、javascript_pack_tagstylesheet_pack_tagなどが使えます。

<%= stylesheet_pack_tag 'application' %>
<%= javascript_pack_tag 'application' %>

これは manifest.json の値を読んで以下のようなタグが出力れます。

<linkrel="stylesheet"href="/packs/application-6ebc34fd09dc6d4a87e9.css" /><scriptsrc="/packs/application-6ebc34fd09dc6d4a87e9.js"></script>

Simpacker の導入

simpackerをGemfileに追加してインストールしたら以下のコマンドで初期設定のファイルがインストールできます。

$ rails simpacker:install 

これで必要最低限の設定とJavaScriptのコードがインストールされるのでnpx webpackでビルドしてjavascript_pack_tag 'application'のヘルパを書けば動きます。

ただし、これでインストールされる webpack.config.js は本当に最低限の設定と manifest.json を出力する設定がしてあるくらいです。Webpacker ではデフォルトで組み込まれる、Babel や PostCSS、file-loader、webpack-dev-server などは、どれも入りません。これは、利用しないものがデフォルトで色々入るのは個人的にあまり好きではないという理由が大きいところです。

また、Webpacker であれば webpacker:install:reactなどのコマンドで React をインストールしたりできますが、このような機能も提供していません。Babel や TypeScript などのトランスパイラ、React や Vue などのフレームワーク、CSS も 素の CSS から Sass、PostCSS など様々な組み合わせがあり、それらの組み合わせをインストーラーでまかなうのは複雑すぎると思ったからです。

なので開発者自身が自分が使いたいものを選んで webpack.config.js を設定していく必要があります。webpack や各種言語やフレームワークのドキュメントを見て webpack の設定をがんばってください、でもいいのですが、何も知らない状態から webpack を設定するのはまあまあ難しいことが知られているので、各種設定例を用意することにしました。現時点では以下のような設定例を用意しています。

お察しの通り、半分以上 Simpacker は関係なくて単に webpack の設定です。初期の最低限の設定から、必要なものだけをこれらから選んで設定することで webpack の設定についても知ることができるので、webpack の入門にもいいのではないかと思います。単なるドキュメントでなく実際に動く例なので手元に持ってきて色々試すのにも便利です。

Simpacker にない機能

Webpacker が提供している機能で、Simpacker が提供していない機能もいくつかあります。

デプロイ

例えば、デプロイ関連の機能です。Webpacker では webpacker:compileという rake タスクが提供されていて、このタスクは assets:precompileの後に自動で実行されます。したがって、多くの場合はデプロイの設定を変えずにこれまでと同じようにデプロイできるはずです。

Simpacker はどんなコマンドで webpack のコマンドが実行されるか知りませんし、package manager に npm と yarn のどちらを使うのかも知りません。当然そのような設定項目を足せば実現できますが、シンプルさを重視してこの機能は提供しないことにしています。

とはいえ難しいことはなくて、以下のようなコマンドをデプロイフローに足せば済むはずです。

$ npm install
$ NODE_ENV=production ./node_modules/.bin/webpack

デプロイに関しては上記の設定例にもいくつか例を書いています。

リクエスト時のコンパイル

また、Webpacker には webpack --watchwebpack-dev-serverなどの監視プロセスを立てなくても、リクエストの処理中に Rails 側で webpack のコンパイルを実行するという機能があります。当然リクエストのたびにコンパイルが走ると遅すぎて話にならないので、対象ファイルの変更があったときだけコンパイルが走りますが、フロントエンドを開発しているときは当然毎回ファイルが更新されてコンパイルが走ることになるので、監視プロセスを別途立てるのが一般的です。この機能は普段フロントエンドを開発しない開発者が別途監視プロセスを立てなくても開発できる、というためのものと理解しています。

この機能は便利なケースもあると思うですが、これを実現するには Simpacker が知らないといけない webpack 側の情報がかなり多くなり、設定が大幅に複雑化してしまうので、一旦対応を見送っています。webpack --watchwebpack-dev-serverなどのコマンドを foreman などで実行すればよいだけなので、そこまで困ることはないと思っています。

まとめ

Rails 自身がそうなのですが、Webpacker も同じように、いかに簡単に使えるか(easyさ)を重視した仕組みと言えます。それに対して Simpacker はひとつのことだけうまくやる、シンプルさを大事にした設計にしました。そのような設計思想の違いがあるので、easy さを好む人は Webpacker を使うほうがいいと思います。シンプルさを求めて webpack の設定を直接書きたいという場合はぜひ Simpacker も検討してみてください。

クックパッドではWebフロントエンドの開発基盤をつくったりすることが好きなエンジニアも募集しています!!!


  1. manifest.json を作るプラグインが .ts.vueなどの拡張子でうまく動かなかったり、ファイル名にハッシュ値をつけるためにエントリポイントとして html が必要だったりするので、現時点では現実的な利用は難しいかもしれません。

EuRuKo 2019 で発表してきました

技術部でフルタイム Ruby コミッタをしている遠藤(@mametter)です。フルタイムで Ruby を開発しています。

先日、オランダのロッテルダムで開催された EuRuKo 2019 で発表してきたので、簡単にレポートします。

EuRuKo とは

EuRuKo は、毎年ヨーロッパのどこかで開催されている Ruby のカンファレンスです。

Image may be NSFW.
Clik here to view.
f:id:ku-ma-me:20190709131404j:plain
EuRuKo 2019 会場

シングルセッション

世界の Ruby カンファレンスといえば、アメリカの RubyConf 、ヨーロッパの EuRuKo 、日本の RubyKaigi だと勝手に思っていますが、この中で EuRuKo の特徴というと、シングルセッションなことです*1。つまり、発表を聞く会議室は 1 つだけです。どれを聞くか迷わなくていいですね。

必然的に、発表の数は少ないです。YouTube の動画の数を見てみると、RubyConf 2018 は 70 件、RubyKaigi 2019 は 65 件ですが、今回の EuRuKo 2019 の発表は 15 件、LT を含めても 20 件でした。発表したい人からすると、採択されるのが最難です(たぶん採択率 1 桁)。

city pitch

また、次回の開催地が会期中に投票で決められるのも特徴です。立候補した街の人が city pitch(街の宣伝)を行い、会場の拍手喝采の大きさで決められていました。

Image may be NSFW.
Clik here to view.
f:id:ku-ma-me:20190709131545j:plain
EuRuKo 2020 開催に立候補した都市

オーストラリアのメルボルンが立候補してるのが話題でしたが、来年はヘルシンキになりました。

EuRuKo 2019 の様子

オランダのロッテルダムにある、ss Rotterdam っていう退役したクルーズ船が会場でした。現在は岸に固定されてホテルになってます。

Image may be NSFW.
Clik here to view.
f:id:ku-ma-me:20190709131400j:plain
EuRuKo 2019 の会場(ss Rotterdam)

EuRuKo 発表の傾向としては、Ruby コア開発の話よりは Ruby を使う話が中心です。そういう意味で、RubyKaigi よりは RubyConf に似ています。

その中でも印象に残った話を 3 つほど簡単に紹介します。

Functional (Future) Ruby - Yukihiro Matsumoto

matz のキーノート。動画がすでに上がっています。

ざっと内容を紹介します。まずは Ruby 3 の 3 つのゴールである Static analysis 、Performance 、Concurrency の進捗が説明されました。詳細はいろいろなところで見つかるので、主にキーワードだけ挙げておきます。

Static analysis は、公式型シグネチャフォーマットとなる予定の ruby-signature、型シグネチャのない Ruby プログラムをゆるく型検証し型シグネチャを推定する type profiler、型シグネチャを前提として、型シグネチャとコードの対応を検証する SorbetSteepなどが登場してきたことがダイジェストで紹介されました。 Performance は、オブジェクトを並べ替えてメモリのフラグメンテーションを軽減する Object compactionによってメモリボトルネックなアプリの効率を上げ、また JIT コンパイラである MJIT や MIR によって CPU ボトルネックなアプリの実行効率を上げることが述べられました。 Concurrency については、並列実行向けには Guilds(仮称)が計画されており、大規模並行処理向けには AutoFiber(仮称)が検討されていることが触れられました。

それから、関数型プログラミングっぽい他言語からインスパイアされた検討中の機能が紹介されました。

  • ブロックの無名引数

@1で引数を暗黙的に参照できます。

3.times { p @1 }
# 3.times {|i,| p i } と同じ

@2だと 2 番目の引数、@3だと 3 番目の引数を参照できます。しかし現実的には複数の引数を参照仕分けるユースケースは稀なので、引数 1 つに特化し、別の記号(@、Kotlin や Grooby 風の it、Scheme の <>)などにするか検討中とのこと。他人事のように言ってますが、itを提案しているのは遠藤です(Feature #15897)。

  • パターンマッチング。

データ構造が想定パターンになっているか調べて、その要素を変数に束縛することができます。

case JSON.parse(json, symbolize_names: true)
in {name: "Alice", children: [name: "Bob", age: age]}
  p age
in _
  p "no Alice"
end

典型的には、JSON の処理に便利そうです。

  • パイプライン演算子

パイプライン演算子が試験的に Ruby に取り込まれました。

1..100
  |> map {|x| rand(x)}
  |> sort
  |> reverse
  |> take 5
  |> display

「パイプライン演算子はプライマリの引数を呼び出しにわたすもの」「Ruby ではプライマリの引数はレシーバ」ということで、実質的にメソッド呼び出し演算子(.)と同じものになっています(このへんの詳細は遠藤のブログを参照ください)。しかし非常に controversial であり、名称・記号を変更するか、キャンセルするか(まだリリース前なので互換性の問題なく取り消せる)、現在も悩んでいるとのことです。

というように、様々な意見はありつつも Ruby はまだまだ新しい機能を取り入れて進化していってます。

What causes Ruby memory bloat? - Hongli Lai

Ruby プロセスのメモリ肥大化の原因を調査した報告です。発表者の Hongli Lai は Passenger や Ruby Enterprise Edition の作者です。この発表はだいたい次のブログで発表済みだった内容ですが、今回の EuRuKo で一番テクニカルで、個人的には一番おもしろい発表でした。

www.joyfulbikeshedding.com

Ruby のメモリ肥大化と言えば、「Ruby: mallocでマルチスレッドプログラムのメモリが倍増する理由(翻訳)」という記事が有名です。端的に言えば「jemalloc を使え、もしくは環境変数 MALLOC_ARENA_MAX=2 を設定しろ」というやつで、見たことがある人も多いのではないでしょうか。しかし Hongli Lai はこの記事を疑い、再調査したところ、メモリ肥大化の本当の原因は別にあったということです。

かいつまんで説明します。Ruby がメモリを使うのは 2 種類あります。

  • (1) Ruby オブジェクトのヒープ
  • (2) Ruby オブジェクト以外のヒープ(主に文字列や配列など)

(1) については、あるアプリケーションでオブジェクトのヒープサイズを測定したところ、全使用メモリ 230 MB に対して 7 MB だったので、肥大化の原因としては無視できます。

(2) についてはちょっとややこしいのですが、malloc したメモリが一部だけ生き残って断片化が起きるという前提で、glibc の malloc はスレッドごとにメモリアリーナを確保するので問題がひどくなるというのが元記事の主張で、そのために MALLOC_ARENA_MAX=2を設定すればアリーナの数が高々 2 に抑えられるので問題が軽減するとされています。

しかし Hongli Lai は「malloc 領域の中で断片化は本当に起きているのか?」と疑問を持ち、可視化機能付き malloc ラッパを書いて確かめたそうです(すごい)。その結果がこれ。

Image may be NSFW.
Clik here to view.
f:id:ku-ma-me:20190709131409j:plain
"What causes Ruby memory bloat?" Ruby プロセスのメモリ使用状況の可視化

赤い領域は使用中、灰色は free 済み(すでに使用されていない)だが OS に返却されていない領域、白は返却済みの領域です。赤い領域が飛び飛びにあるので断片化は多少起きているものの、完全に灰色のところも多いことがわかります。つまり、断片化はそれほどひどくなく、むしろ OS に領域を返却できていないことが問題です。 使用済みだが OS に返却されていないのは、こまめに返却すると実行効率が下がることがあるためだと思われます。malloc_trimという関数を使えば使用済み領域を明示的に OS に返却できる *2ということで、Ruby にパッチをあてて再実験した結果がこちら。

Image may be NSFW.
Clik here to view.
f:id:ku-ma-me:20190709131803j:plain
"What causes Ruby memory bloat?" malloc_trim パッチ後のメモリ使用の様子

めでたく、白い部分が増えました。これにより、使用メモリ 230 MB だったところが 60 MB 減ったということです。MALLOC_ARENA_MAX=2を指定したら 53 MB なので、効果としてはほぼ同じとのこと。

また、Fullstaq Rubyという Ruby のディストリビューションを始めたということが発表されていました。今回の調査結果を含むということです。

The Past, Present, and Future of Rails at GitHub - Eileen M. Uchitelle

GitHub で働く Rails コミッターである Eileen の発表。Rails わかんないのでさらっと紹介です。

なんと、GitHub では、Rails をフォークして使っていたそうです。

Image may be NSFW.
Clik here to view.
f:id:ku-ma-me:20190709131553j:plain
"The Past, Present, and Future of Rails at GitHub" GitHub は Rails をフォークしていた

しかし、先端の Rails にどんどん置いてかれて開発もメンテナンスも辛くなり、採用やセキュリティ対応も厳しくなってきたので、がんばってフォークを卒業した、ということでした。

Image may be NSFW.
Clik here to view.
f:id:ku-ma-me:20190709131558j:plain
"The Past, Present, and Future of Rails at GitHub" GitHub は Rails のフォークをやめた

ということで、「フォークをしても良いことないよ」「ちゃんと Rails の最新版を使っていこうね」ということが何度も語られていました。

がんばってフォークを卒業するまでに相当がんばったのではないかと思われるのですが、あんまりその辺の苦労は語られていなかったのが若干心残りです。しかし、Eileen は Rails 6 に GitHub で必要な機能(Multi-DB とか)を入れまくった人なので、その背景が想像される感じで面白かったです。独自フォークを無くすためには、フォークを公式にすればいいわけです。*3

A Plan towards Ruby 3 Types - Yusuke Endoh

ついでに自分の発表です。発表資料はこちら。

発表内容は大筋で Ruby 3 の静的解析の計画と、その中で自分が作っている型プロファイラの進捗報告でした。おおよそは RubyKaigi と同じなので、クックパッド開発者ブログで書いた遠藤の過去記事をご覧ください。

進捗としては、解析速度に難があった(型プロファイラ自身のプロファイルに 10 分、optcarrotに 3 分)だったので、解析アプローチを大幅に見直して、精度を少し犠牲にしつつ高速化をしたところ、それぞれ 2.5 秒・6 秒になった、というところです。とはいえまだまだ未実装な機能だらけなので、引き続きがんばってます。

Image may be NSFW.
Clik here to view.
f:id:ku-ma-me:20190709131550j:plain
発表のときに壇上から撮った会場の様子

おまけ:発表者の特典

前述のとおり EuRuKo は採択率が非常に低いのですが、そのためか発表者のおもてなしがなんかすごかったです。

  • 旅費・宿泊費支給(いろいろあって遠藤は旅費をクックパッドに、宿泊費を EuRuKo に出してもらいました)
  • 空港からの送迎
  • 水上タクシーでの周辺観光
  • 現地の Rails Girls イベントへの招待
  • スピーカディナーへの招待(発表者と運営だけが参加できるディナー)
  • スピーカラウンジ(発表準備したり、議論したり、ゲームで遊んだりできるスペース)
  • 記念品(会場だった ss Rotterdam の模型)

Image may be NSFW.
Clik here to view.
f:id:ku-ma-me:20190709133117j:plain
EuRuKo 2019 発表者記念品と名札

まとめ

EuRuKo 2019 のダイジェスト紹介でした。

ちなみに今回の日本人参加者は matz と自分の 2 名だけでした(たぶん)。海外の Ruby カンファレンスと言うとアメリカの RubyConf が注目されがちですが、EuRuKo も行ってみるといいのではないでしょうか。来年はフィンランドのヘルシンキです。

Image may be NSFW.
Clik here to view.
f:id:ku-ma-me:20190709133255j:plain
EuRuKo 2019 エンディングの様子

*1:ちなみに歴史は RubyConf(2001 年)、EuRuKo(2003 年)、RubyKaigi(2006 年)の順で、最近の規模は RubyKaigi(1000 人程度)、 RubyConf(800 人程度)、EuRuKo(600 人程度)です。

*2:ドキュメントによると「ヒープの一番上の未使用メモリーの解放を試みる」となっていますが、Hongli Lai がソースを読んだ限りだと、一番上に限らず未使用領域を返却していくらしいです。

*3:手前味噌ですが oneshot coverageもクックパッドの Ruby フォークを本家に整理して再実装した感じです。

Viewing all 802 articles
Browse latest View live