クラシル開発ブログ

クラシル開発ブログ

思った以上に大変だったクラシルでの Scoped Storage 対応

f:id:rnitame:20201211113547p:plain

本記事は dely #1 Advent Calendar の 23 日目の記事です。

adventar.org

dely #2もあるのでこちらも見てみてください!

adventar.org

こんにちは、Android エンジニアの tummy です。

昨日はうめもりさんの「Androidも宣言的UI(が当たり前になりそうな)時代に非宣言的UIライブラリでこの先生きのこるには」でした。 絶賛このアーキテクチャで実装中ですが、以前より diff が少なくなったのとどこに何を書けばいいのかが明確になってとても捗ってます。ただ、まだ悩みポイントもあるのでこれから潰していきたいと思ってます 😎

さて、タイトルにある通りクラシルでも先日 Scoped Storage 対応をしました。

(今回は Scoped Storage についての説明は省きます、ドキュメントをぜひ参照してください 💁‍♀️)

developer.android.com

MediaStore 使ったりいい感じに権限つければいいんじゃないの?と軽く考えてたんですが、思ったより大変だったのでやったことを書いていこうと思います!

クラシルでの画像使用部分

クラシルでは以下の 2 箇所で画像を用います。

たべれぽ プロフィール
f:id:rnitame:20201211175341p:plain f:id:rnitame:20201211175402p:plain

カメラで撮影したものをそのまま使用できる他、ギャラリーから選択できるようになっています。 たべれぽのみ、フィルターをかけたりクロップすることもできます。

流れとしては以下の図のとおりになります。

f:id:rnitame:20201214173950p:plain

起きていた事象

切り抜きの画面でファイルが見つけられずに無限にローディング状態になっていました。(お問い合わせしていただいたユーザーさんのおかげです、ありがとうございます!)

またこの状態になると、謎のグレーな画像が端末内に保存されることがわかりました。

ローディングが終わらない 謎のグレーな画像たち
f:id:rnitame:20201211135054p:plain f:id:rnitame:20201211135409p:plain

当初は Intent の渡し方が悪かったのかな?と思ったのですが、よくよく調べていくと Scoped Storage に対応しないとだめだということがわかり、着手しました。

行った対応

カメラかギャラリーか判定見直し

ギャラリーを起動する際のコードを以下のようにしました。

val intentGallery = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
intentGallery.type = "image/jpeg"

こうするとギャラリーの Intent の情報に data がセットされるので、これがあるかないかで判定ができるようになります。 今までは変数として保持している Uri があるかないかで判断していたので、このロジックを変更しました。

FileProvider の活用

Uri.fromFile() を使うと共有の制限にひっかかるのと、権限をアプリ内で使うパスに渡すために FileProvider を使います。 qiita.com

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-path
        name="external_files"
        path="." />
    <cache-path
        name="cache"
        path="." />
</paths>
private fun getUriFromFile(context: Context, file: File): Uri {
    return FileProvider.getUriForFile(
        context,
        BuildConfig.APPLICATION_ID + ".provider",
        file
    )
}

キャッシュを作ってそれの Uri を使い回す

Google Photos 経由で画像を選ぶと Uri が以下のような形式になり、Google Photos の ContentsProvider に対して権限をもらえないためエラーになります。

content://com.google.android.apps.photos.contentprovider/-1/1/content%3A%2F%2Fmedia%2Fexternal%2Fimages%2Fmedia%2F19018/ORIGINAL/NONE/~}

stackoverflow.com

そのためキャッシュを作り、その Uri を使い回すことで対応しました。

OkHttp の RequestBody に content スキームの Uri をのせるためのクラスを作る

普通のファイルであれば MultipartBuilder などを使って渡せましたが、content スキームの場合は渡せないのでひと手間加える必要がありました。

そのため以下のような RequestBody を拡張したクラスを作り、渡すようにしました。

class ImageRequestBody(
    private val context: Context,
    private val uri: Uri
) : RequestBody() {

    override fun contentType(): MediaType? = MediaType.parse("image/jpeg")

    override fun writeTo(sink: BufferedSink) {
        val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r") ?: return

        Okio.source(FileInputStream(parcelFileDescriptor.fileDescriptor)).use {
            sink.writeAll(it)
        }
        parcelFileDescriptor.close()
    }
}

初期対応で足りなかったところ

ドキュメントを開く Intent も投げる

画像の Intent のみだと、Galaxy などでデフォルトで入っているギャラリーアプリが認識されないことがわかりました。そのためドキュメントの Intent を使い、type を画像にしぼって開くことで認識してくれるようになったので、この対応を行いました。

 val intentDocument = Intent(Intent.ACTION_OPEN_DOCUMENT)
intentDocument.type = "image/*"
intentDocument.addCategory(Intent.CATEGORY_OPENABLE)

できてないこと

カメラで撮った際に画像を端末内に反映させていたのですが、その処理を消したのでできてません。冒頭に少し触れた、実装中のリアーキテクチャでできるといいな…と思っています。

さいごに

delyではエンジニアを絶賛募集中です。ご興味のある方はぜひこちらのリンクを覗いてみてください。 カルチャーについて説明しているスライドや、過去のブログもこちらから見ることができます。

join-us.dely.jp

delyの開発部では定期的にTechTalkというイベントを開催し、クラシル開発における技術や組織に関する内容を発信しています。
クラシルで使われている技術や、エンジニアがどのような働き方をしているのか少しでも気になった方はぜひお気軽にご参加ください!

bethesun.connpass.com

Androidも宣言的UI(が当たり前になりそうな)時代に非宣言的UIライブラリでこの先生きのこるには

f:id:delyumemori:20201221172934p:plain

こんにちは。dely株式会社でAndroidチームのマネージャーをやっているうめもり(Twitter: @kr9ly)です。

この記事は「dely #1 Advent Calendar 2020」の22日目の記事です。

21日目の記事は、kazkobay さんによる「デザイナーからPdMになる時に役に立った本と方法まとめ」でした。エンジニアがPdMになるときにも役に立ちそうな本なので、暇を見つけて読んでみようと思いました。

note.com

「dely #2 Advent Calendar 2020」もありますので、是非そちらもご覧ください。

Jetpack Compose前夜

皆さん、Jetpack Compose使ってますか?直近ですと12/2に1.0.0-alpha08、12/16に1.0.0-alpha09がリリースされ、開発が大分活発なのがうかがえますね。正式版リリースは来年末か再来年でしょうか?さすがにまだalpha版なので、業務で本格導入してる方はほとんどいないのではと思いますが、Reactから始まった宣言的UIの流れがとうとうAndroidにも来るのかという感じですね。

試しに触ってみたところすでになかなかの完成度で、React(+React Hooks)のような宣言的UIで普通にAndroidアプリを作る、という体験が2年後くらいには当たり前になっているような気がしました。

React(VirtualDOM)がUIプログラミングの世界にもたらしたもの

VirtualDOMを用いたReactというフレームワークがWebフロントエンド界隈にもたらしたものがなんだったか、ということはWebで調べればたくさん出てきますが、かいつまんで言えば以下の1点に集約されるでしょうか。

プログラマーをUIコンポーネントの状態の差分管理から解放した

UIコンポーネント(DOM)の状態の差分検知&更新処理をVirtualDOMという仕組みが一手に担うことで、プログラマーは常にアプリケーションの最新の状態を反映するVirtualDOMを構築するコードを書くだけで済むようになりました。その上、VirtualDOMが最小の差分を自動的に検出してUIを更新してくれるため、UIを更新するコストをあまり意識せずともパフォーマンスの高いコードが書くことができるようになった、ということはとても大きいでしょう。(もちろん差分検出処理自体の重さもアプリケーションが大きくなれば意識する必要が出てくることだとは思いますが、ここでは触れないでおきます)

もちろんWebフロントエンドではない世界なのでDOMもVirtualDOMもAndroidの世界には存在しませんが、Jetpack Composeでも同じような仕組みでUIコンポーネントの状態を差分更新することからプログラマーを解放してくれます。UIを組み立てる参照等価な関数を組み合わせるだけで、Jetpack ComposeはUIコンポーネントツリーの差分を検知してUIを更新してくれるので、プログラマーはアプリケーションの状態からUIコンポーネントツリーを組み立てることだけを意識してプログラミングすることができます。

プログラマーをUIの差分更新から解放したことで生じたもう一つ大きな変化は、アプリケーションの状態を一か所に集約しやすくなったことです。状態の差分をプログラマーが管理しなければならなかった時は、前の状態をどこかに持っておいて比較しながらUIを更新する必要があるため、どうしてもアプリケーションの状態がコードの中にバラバラに点在してしまいがちでした。

それを一か所に集約できるようになったため、状態をUIコンポーネントとして組み立てるコードと、集約された状態を更新するコードにアプリケーションコードを明確に分離することが出来るようになりました。集約された状態からUIコンポーネントに状態を反映する仕組みは、Reactでは "single source of truth" という言葉を用いて説明されています( https://ja.reactjs.org/docs/forms.html#controlled-components )。今ではAndroid開発者にもお馴染みだと思いますが、Flux(Redux, etc...)のような単方向データフローアーキテクチャは、この二つのコードが明確に分離しやすくなったからこそ生まれたアーキテクチャです。Fluxのような単方向データフローアーキテクチャーをAndroidアプリに適用しようとする試みは過去色々な場所で行われてきたことだと思いますが、VirtualDOMの仕組みのないAndroidの世界でFluxを実現しようとするのは、どうしても無駄なボイラープレートコードの増加や、暗黙的なアーキテクチャの制約などを生み出してしまいがちで、結果的に大きな生産性の改善には繋がりにくかったのではと思います。

UIコンポーネントの状態の差分管理からの解放と、UIコンポーネントの更新のコードとアプリケーションの状態の更新のコードの明確な分離は、確実にAndroidのプログラミングにおいてもコードの保守性を高め、UIプログラミングの生産性の向上に繋がるはずです。

……Jetpack Composeさえ導入できれば。

だがまだ本格的にJetpack Composeを導入するわけにはいかない我々がこの先生きのこる方法

Jetpack Composeはまだalpha版、下手すれば本格的に導入できるのは2年後でしょう。我々は2年後まで指をくわえて待っているのがよいのでしょうか?一応Androidの世界にもAndroid Data Bindingがあります(Android Data Bindingはプログラマーが状態の差分を意識しなければいけないのは何も変わらないですし、大きなコンパイル時間のペナルティがあるという問題もありますが…)し、今まで通りアプリケーションを作ることは問題なくできます。

しかし、Reactがもたらした

  • UIコンポーネントの状態の差分管理からの解放
  • UIコンポーネントの更新とアプリケーションのデータの更新の責務の明確な分離

については、部分的にその利点を取り入れながらAndroidアプリケーションの作り方をアップデートすることはできます。この記事では、その方法の要点だけ説明したいと思います。

非宣言的UIライブラリでsingle source of truthを実現する

差分更新と一括更新が同一のコードで行うことができるような仕組みを用意する

VirtualDOMのような差分検知&差分更新のライブラリがない世界では、何らかの方法で差分検知&差分更新の仕組みを実現し、UIコンポーネントの差分を管理する仕事からプログラマーを解放する必要があります。クラシルのAndroidアプリでは、以下のようなコードでUIコンポーネントの状態の更新を実現できるような仕組みを作りました。

override fun view(
    context: Context,
    state: RecipeItemState,
    updater: ViewUpdater<BaseBinding>
) {
    ...(省略)...
    updater.update(state.title, state.isPr) { layout, title, isPr ->
        val titleBuilder = SpannableStringBuilder()
        titleBuilder.append(title)
        if (isPr) {
            titleBuilder.setSpan(
                LeadingMarginSpan.Standard(
                    32.dpToPx(context), 0
                ), 0, title.length, 0
            )
        }
        layout.titleLabel.text = titleBuilder
    }

    updater.update(state.thumbnailUri) { layout, uri ->
        layout.image.bind(imageLoaderFactories.thumbnail(uri).build())
    }

    updater.update(
        state.isPremiumRecipe,
        state.isPremiumUnlocked
    ) { layout, isPremiumRecipe, isPremiumUnlocked ->
        layout.premiumLabel.visibility = isPremiumRecipe.visibleOrGone()
        layout.lockedLabel.visibility =
            (isPremiumRecipe && !isPremiumUnlocked).visibleOrGone()
    }

    updater.update(
        state.isFavorite,
        state.id,
        state.favoriteStateUpdatedTimestamp
    ) { layout, isFavorite, _, _ ->
        layout.favoriteButton.isActivated = isFavorite
    }

    updater.update(state.isPr) { layout, isPr ->
        layout.prLabel.root.visibility = isPr.visibleOrGone()
    }
}

updater.update関数にパラメータを渡して、その中でAndroidの通常のViewの操作を使ってUIを更新しています。

最初にこの関数を呼んだ時と、updater.updateに渡したパラメータのいずれかが更新されることでViewが更新される仕組みになっており、プログラマーが差分の粒度を意識する必要はあるのがVirtualDOMを使ったコードとのプログラミングとは違うところですが、最低限一括更新と差分更新の両方がこのコードだけで実現することはできています。

内部処理は結構単純で、以下のようなコードで実現されています。

class DiffDetector {

    private object EndOfState

    private var dirty = false

    private var index = 0

    private var diffBuffer = mutableListOf<Any?>()

    inline fun update(callback: () -> Unit) {
        startUpdate()
        callback()
        finishUpdate()
    }

    fun startUpdate() {
        dirty = false
        index = 0
    }

    fun detect(value: Any?): Boolean {
        if (dirty) {
            append(value)
            return true
        }
        if (index >= diffBuffer.size) {
            append(value)
            return true
        }
        if (diffBuffer[index] === EndOfState) {
            dirty = true
            append(value)
            return true
        }
        if (value == null || value::class.javaPrimitiveType != null) {
            if (value != diffBuffer[index]) {
                append(value)
                return true
            }
        } else {
            if (value !== diffBuffer[index]) {
                append(value)
                return true
            }
        }
        index++
        return false
    }

    fun finishUpdate() {
        append(EndOfState)
    }

    private fun append(value: Any?) {
        if (index < diffBuffer.size) {
            diffBuffer[index] = value
        } else {
            diffBuffer.add(value)
        }
        index++
    }
}

このクラスをラップしたのがViewUpdaterですが、内部的にはパラメータの配列を持っておいて、呼ばれた順にindexを動かしてパラメータを突き合わせているだけです。非プリミティブ型の場合は参照を突き合わせていますが、後述のStateをイミュータブルにすることで特に問題が発生しないようになっているのと、差分の検知処理がパフォーマンス上の問題にならないように気を付けています。

もちろんupdater.update関数をネストされるなどされると状態がおかしくなってしまうのですが、そこはネストした場合に例外を吐くなどの処理を追加することでおかしな呼ばれ方ができないようになっています。

Stateを集約し、イミュータブルにする

クラシルのAndroidアプリでは基本的にコンポーネント(Activity, Fragmentくらいの粒度で管理しています)内では単一のオブジェクトでStateを管理しています。Stateは原則イミュータブルで、State内のフィールドのいずれかが更新されるたびにStateのインスタンスが更新されるような仕組みになっています。

以下はクラシルのmodel関数(コンポーネントの状態を更新するロジックをmodelというfunctionにまとめています)内から一部抜き出したコードですが、基本的にStateはdata classとして定義しており、dispatcher.dispatch内でstateオブジェクトをcopyすることで新しいStateを作るという形で状態を更新しています。単一のオブジェクトであるStateが更新されるたびに、view関数が呼ばれて状態がUIコンポーネントに反映される仕組みになっています。

override fun model(
    action: Action,
    props: Props,
    state: State,
    dispatcher: StateDispatcher<State>
) {
    when (action) {
        is RecipeDetailListSnippet.Actions.UpdateRecipeSummary -> {
            dispatcher.dispatch {
                state.copy(
                    summary = action.summary
                )
            }
        }
        is RecipeDetailListSnippet.Actions.UpdateRecipeDetail -> {
            favoriteFeature.addBrowsingHistory(props.id)
            dispatcher.dispatch {
                state.copy(
                    detail = action.video
                )
            }
        }
        is RecipeDetailListSnippet.Actions.UpdateQuestions -> {
            dispatcher.dispatch {
                state.copy(
                    questions = action.questions
                )
            }
        }
        is RecipeDetailListSnippet.Actions.UpdateTaberepoInfo -> {
            dispatcher.dispatch {
                state.copy(
                    taberepos = action.taberepos,
                    taberepoCount = action.taberepoCount
                )
            }
        }
    }
}

コードの責務を分割し、Stateの更新によらないViewの更新を抹殺する

ここまでのコードで気づいた方もいらっしゃるかもしれませんが、クラシルのUIコンポーネント周りのコードは cycle.js のModel-View-Intentというアーキテクチャからの発想でコードを3つの関数に分割しています。

f:id:delyumemori:20201221171509p:plain

  • Android ViewからActionを発行するintent関数
  • ActionとStateをもとにアプリケーションの状態を更新するmodel関数
  • StateをもとにAndroid Viewを更新するview関数

Intentのコードの雰囲気だけ共有しておくと、こんな感じです。

override fun intent(
    layout: ComponentViewBinding,
    dispatcher: StatelessActionDispatcher<Argument>
) {
    layout.clearButton.setOnClickListener {
        dispatcher.dispatch {
            SearchInputSnippet.Actions.UpdateInputText("")
        }
    }

    layout.voiceButton.setOnClickListener {
        dispatcher.dispatch {
            SearchInputSnippet.Actions.StartVoiceInput
        }
    }

    layout.searchInput.addTextChangedListener { text ->
        if (layout.searchInput.hasFocus()) {
            dispatcher.dispatch {
                SearchInputSnippet.Actions.UpdateInputText(text.toString())
            }
        }
    }
}

責務を3つのコードに分割して、view関数だけでUIの状態を更新するようになっています。原理上はintentでViewを更新できてしまいますが、Actionが受け取れないこと、Stateが参照できないことで通常の用途では更新しにくいことで抑止になっていることと、あとはコードレビューでそういったコードが混入しないように気を付けています。

クラシルのAndroidアプリでは、single source of truthの考え方をこれらの工夫で実現しています。どうしてもAndroid Viewが元々そういった発想で作られていないこともあり、たまにワークアラウンド的なコードが必要になることもありますが(layout.searchInput.hasFocus()はそのワークアラウンドの一部だったりします)、基本的にはすっきりコードを書くことが出来ています。

コンポーネントツリー全体でもsingle source of truthを実現する

クラシルのAndroidアプリではFragmentの採用をやめたのですが、一方でコンポーネントツリーの分割統治はUIアプリケーションを作るうえでコードの再利用性などを考えても避けて通れない問題なので、独自の仕組みでコンポーネントツリーを分割統治できるようにしています。その詳細については今回は触れませんが、Fragmentなどを採用する場合でも考え方は一緒だと思いますので参考になればと思います。

ReactのパラメータがState/Propsに分かれている理由

クラシルのAndroidアプリを今のアーキテクチャにした際に、最初は全て単一のStateで作っていたのですが、途中からReactのように内部でプライベートで持つ状態をState、外部から渡される内部からは変更できない状態をPropsと分けて管理するようになりました。最初はなんらかのパラメータを外から渡してStateを更新する仕組みだったのですが、例えばディープリンクなどの処理によって親からコンポーネントの状態を変更することが発生すると、Stateと分離してPropsという状態を導入したほうが都合がいいことが分かったためです。

具体的には、親から渡されたパラメータをStateに反映し、かつ内部でそのパラメータを変更する場合に問題が発生しました。内部で親から渡されたパラメータを更新するようになると、上のコンポーネントはその変更を把握していないため、おかしなタイミングでStateが親から渡されるパラメータで上書きされてしまうという問題が発生しました。結局内部のパラメータを変更したいタイミングで上のコンポーネントに対してActionを投げるようにする、という形で対応したのですが、それは結局Propsという内部から変更不能なパラメータでUIの状態を更新しているのと同じことだということに後で気づき、その形に落ち着きました。

"一つ上のComponent"からStateを更新することを意識する

State/Propsの仕組みにアーキテクチャを移行する際に、コンポーネントツリー全体でsingle source of truthを実現する際に意識すべきことが一つあることがわかりました。それは、

親が知っているべきことは必ず親がStateを持ち更新すること

ということです。single source of truthに則ったアーキテクチャを採用すると、基本的には親から子供のコンポーネントに状態を問い合わせることがなくなるため、自然にそういった考え方になっていくのではないかとは思いますが。

限界はある、やりすぎないことも大事

今回クラシルのAndroidアプリのアーキテクチャについて全てのことをご紹介できたわけではないですが、以上のような工夫で宣言的UI時代も戦えるようなアーキテクチャにアップデートをはかっています。今後も生産性が高く、素早くユーザーに価値を提供できるアーキテクチャを維持していきたいですね。

とはいえ、基本的には非宣言的UIライブラリでやっていることなので、限界やちょっとした癖もあります。例えばRecyclerViewのスクロールのようなStateで管理すると困るような状態もあったり、アーキテクチャの統一感を意識しすぎると無理が出てくるような局面もあります。

95%位のユースケースは自然に書けるが、いざという時の5%くらいの抜け道は残しておくという発想で、開発に著しい無理が出ないような形を保っていきたいですね。

We're Hiring!

クラシルのAndroidチームでは、生産性の高いアーキテクチャを維持・アップデートしつつも楽しくプロダクト開発できる人、したい人を求めています。今回の記事を読んで面白そうだな、もっと話を聞いてみたいなと思った人、クラシルの開発に興味が出てきた人、是非お話ししましょう。クラシルの採用に興味なくても、記事が面白いと思ってくれた人とは是非お話してみたいのでTwitterなどで連絡いただけると嬉しいです。

f:id:delyumemori:20201221172144p:plain
きのこる先生

システム管理者に贈る「運用改善に役立った!」AWSの機能4選

こんにちは!SREチームの松嶋です。

こちらは「dely #2 Advent Calendar 2020」の20日目の記事です。 adventar.org

delyのアドベントカレンダーは#1もあるので、こちらもぜひ。 adventar.org

昨日は、maseoさんの「Google Optimizeでテストをしてる話」という記事でした。A/BテストでGoogle Optimizeを導入するか検討しているフロントエンジニアの皆さんはぜひ読んでみてください!

tech.trilltrill.jp

はじめに

私は昨年の11月にdelyへ入社しましたが、もう既に1年が過ぎてしまいました。体感的にまだ半年しか経っていない気持ちですが、そんな時間が過ぎるスピードの速さもdelyならではかもしれないと感じる今日この頃です。

delyのSREチームは、2020年9月までの約3年間、最大2人体制でなんとか頑張ってきました。10月以降は、新しくdelyにジョインしてくれたり、育休から復帰したメンバーが戻ってきたことによって、チームメンバーが4人に倍増しました。今までは足元課題を解決するために時間を取られていたのですが、現在は中々着手できていなかった課題にもアプローチができつつあるので、今後システムの改善速度が右肩上がりになる予感しかありません。なんだかとっても良い感じです。

少し前置きが長くなりましたが、現在delyでは、皆さんご存知の「クラシル」という動画レシピサービスの他にも「TRILL」という女性向けメディアの運営にも携わっています。最近、TRILLのバックエンドをGCPからAWSに移行したのもあり、delyが運営しているサービスの大部分はAWS上で動いています。

この記事では、クラシルやTRILLの実運用において、システム管理者の視点で「これは結構良いかも」と感じたAWSの機能を4つPickupして紹介しようと思います。

1. ECSサービス状態がCloudWatch Eventsで取得可能になった

システム管理者としていち早く知りたいと思うのは、サービスの異常状態ではないでしょうか。従来まではECSサービスの状態変化を取得するためには、ECS APIを経由する手段しかありませんでした。そのため、サービスイベントの状態を検知する仕組みを自前で実装し、運用する必要がありました。

昨年11月のリリースによって、ECSがCloudwatchEventsに向けてECS Service Action イベントを発行するようになったので、ECSサービスの状態変化がとても簡単に検知できるようになりました。

aws.amazon.com

このアップデートをうけてクラシルやTRILLでは、ECSサービスの異常を出来るだけ早く検知したかったので、「ERROR」と「WARN」イベントが発生したときはSlack通知するように設定しました。

例えば、実際どのような場合に助かったかというと、ECSのタスクがスケールアウトができない状態を早い段階で検知できたときです。この原因は、暫くデプロイされていないECSサービスのコンテナイメージがECRのライフサイクルポリシーによってイメージが削除されていたためでした。

f:id:akngo22:20201219014114p:plain
ECS サービスアクションの通知
この「SERVICE_TASK_START_IMPAIRED」というWARNイベントは、タスクが正常に立ち上がることができないことを意味しています。

タスクがスケールアウトできなくてユーザーに影響が出始めてから気づく・・・というような恐ろしい状態になる前に気づいて対応できたので、本当に良かったです。

2. Systems Managerオートメーション

続いて2つ目は、Systems Managerのオートメーションです。一昔前のクラシルのデプロイは、ジョブスケジューラーのRUNDECKを使ってEC2サーバーにSSHし、デプロイスクリプトを実行するというやり方でした。

しかし、RUNDECKサーバーは、本番環境と開発環境のどちらにも接続できる状態になっていたり、サーバー自体がコード管理されておらずブラックボックスになっていたりと課題を多く抱えていました。

何か代替手段がないかと検討したときに、候補としてあがったのが「Systems Managerのオートメーション」です。

docs.aws.amazon.com

オートメーションの良いところは、JSONドキュメントを書いてしまえば簡単にデプロイを自動化することができるところです。さらに、RUNDECKようにサーバーを自前で立てる必要もなし、SSHしないので鍵管理も不要なためセキュリティリスクも防げて、サーバーをメンテナンスするコストも削減できるので、今まで抱えていた課題をオートメーションによって解決することができました。

3. FireLens

続いて3つ目は、こちらも昨年11月に発表されたFireLensです。皆さんはコンテナログの収集はどのように管理していますか。小規模なアプリケーションであれば、コンテナから直接CloudwatchLogsにログを送るのが一番楽だと思いますが、クラシルやTRILLのような規模になってくるとログ収集だけで毎月のコストが無視できないほどになってきます。コスト面を考慮するなら、サイドカー方式で自前でFluentdコンテナを立ててログをElasticsearchやS3に転送する等が考えられますよね。

FireLensの何が良いかというと、サイドカー方式でFluentdやFluent Bitを利用するときのログ収集を楽にしてくれるところです。また、Fluent Bitを使うときはAWSが管理しているマネージドなDockerイメージを使うことができますし、タスク定義の中でログの出力先を指定すれば、FluentdやFluent Bitの設定ファイルをいじらなくてもログ転送が可能になります。

Filterプラグイン使ってログ加工したい場合は、カスタム設定ファイルが必要になりますが、それでも自前でコンテナ立てる場合よりシンプルになると思います。

aws.amazon.com

さらに、FirelensだとデフォルトでECS メタデータがフィードに追加されているので、どのクラスターのどのタスク定義から送られてきたログなのか識別することも容易です。このECS メタデータは、タスク定義内のオプションで有効/無効にすることが可能なので、FluentdやFluent Bitの設定ファイル側で何か設定する必要もありません。

f:id:akngo22:20201219111407p:plain
赤枠のフィードを出力するか否か「enable-ecs-log-metadata」オプションで指定できる

TRILLでは、FireLensを使いコンテナログをKinesis Data Firehoseを経由してElasticsearchに転送しています。TRILLは月間利用者数が4000万を超えており、日々大規模なトラフィックを捌いているのですが、現状ログ周りがボトルネックならずに運用できています。

prtimes.jp

4. AWS Chatbot

最後に紹介するのは、AWS Chatbotです。暫くの間ベータ版でしたが、今年の4月に一般提供が開始されました。弊社もChatOpsの仕組みを自分たちで作っているものもありますが、AWS Chatbotを使えばとても簡単にSlack連携ができるようになります。

aws.amazon.com

例えば、CloudwatchAlarmやCodeシリーズの通知(実行開始、成功、失敗)などを簡単にSlack通知することが可能となります。CloudwatchAlarmの場合は、アラートになっているメトリクスのグラフを添付してくれたり、CodeBuildが失敗した場合は、失敗したフェーズのエラーメッセージを通知してくれるので結構便利だなと感じました。

f:id:akngo22:20201219120732p:plain
CodeBuildの失敗通知例

しかし、通知内容のカスタマイズはできないので、自分たちが欲しい情報が取れない場合はChatbotは使えないですが、そんな手間かけずに知りたい情報に関してはChatbotで十分事足りるなと思います。

まだTerraformが対応していないのが1つの難点ですが、皆さんで"Thumb up👍🏻"してリリースされるのを後押しして待ちましょう。

github.com

まとめ

今回は、運用改善につながったと感じるAWSの機能をクラシルやTRILLの実例を交えて紹介しました。気になったものがあれば、ぜひ導入を検討してみてください。

明日は、TRILLの可愛くて素敵なデザイン周りを担当しているyuaoさんの「デザインの指示に迷った時は、 「要素に分解」がいいかもという話」です!お楽しみに!

最後に

また、dely ではエンジニアを絶賛募集中です! ご興味ある方はこちらのリンクから。

join-us.dely.jp

まず、dely開発部の雰囲気を知りたい方は、TechTalk というイベントを定期的に実施していますので、お気軽に参加いただけると嬉しいです。カジュアル面談もこちらから応募いただけます!

bethesun.connpass.com

Xcodeプロジェクト管理ツール「Tuist」を試している

こんにちは。
delyコマース事業部のJohn(@johnny__kei)です。
この記事は「dely #1 Advent Calendar 2020」の20日目の記事です。

adventar.org

adventar.org

昨日はtakaoさん(takaoh717)の「エンジニアが始めるプロダクトマネジメント最初の一歩」という記事でした。

はじめに

今回は、iOS開発で試しに使っているTuistというツールを紹介したいと思います。 TuistはXcodeプロジェクトの生成、管理を楽にしてくれるコマンドラインツールです。
似たようなツールでXcodeGenがあります。

大きな特徴は、Project.swift というファイルでの中でプロジェクトの定義をSwiftで記述するところです。 ここがイケてるやんと思って使い始めました。
(↓サイトトップに記載されている例)

import ProjectDescription
import ProjectDescriptionHelpers

let project = Project.featureFramework(
  name: "Home",
  dependencies: [
    .project(target: "Features", path: "../Features"),
    .framework(path: "Carthage/Build/iOS/SnapKit.framework")
    .package(product: "KeychainSwift")
  ]
)

また、*.xcodeproj*.xcworkspaceはgitignore前提なので、コンフリクトして辛い思いをすることはありません。

SoundCloudでの開発にも使われているようです。 developers.soundcloud.com

Tuistはドキュメントもいい感じで、さくっと試してみるのも簡単です。

Getting Started

tuist.io ドキュメントに沿って雰囲気をお伝えします。

tuist init

tuist init で始めると以下のようなフォルダ構成が生成されます(tuist ver 1.28.0)

|--.gitignore
|--Project.swift
|--Targets
| |--MyApp
| | |--Resources
| | | |--LaunchScreen.storyboard
| | |--Sources
| | | |--AppDelegate.swift
| | |--Tests
| | | |--AppTests.swift
| |--MyAppKit
| | |--Sources
| | | |--MyAppKit.swift
| | |--Tests
| | | |--MyAppKitTests.swift
| |--MyAppUI
| | |--Sources
| | | |--MyAppUI.swift
| | |--Tests
| | | |--MyAppUITests.swift
|--Tuist
| |--Config.swift
| |--ProjectDescriptionHelpers
| | |--Project+Templates.swift

tuist edit

tuist editで定義用Xcodeプロジェクトを開きます。

f:id:JohnnyKei:20201217204204p:plain

Project.swift はこんな感じです。

let project = Project.app(name: "MyApp",
                          platform: .iOS,
                          additionalTargets: ["MyAppKit", "MyAppUI"])

Project.app(xxx)というのはProject+Templates.swiftに記載されているHelperメソッドです。
MyAppというアプリケーションとMyAppKitMyAppUIというEmbeddedFrameworkがあるプロジェクトを定義しています。

tuist generate

tuist generateProject.swiftの定義からMyApp.xcworkspaceMyApp.xcodeprojが生成されます。 MyApp.xcworkspace を開くとこんな感じです。 f:id:JohnnyKei:20201217210257p:plain

実践編

他にどんな感じかで使うかの例を少し挙げます。

WorkSpace.swift で複数のプロジェクトをまとめる

複数のProject.swiftを作成してWorkspace.swiftでまとめることもできます。 Getting Startedのときから、フォルダ構成も若干変えています。

f:id:JohnnyKei:20201217213103p:plain

|--.gitignore
|--Projects
| |--MyApp
| | |--Project.swift
| | |--Resources
| | | |--LaunchScreen.storyboard
| | |--Sources
| | | |--AppDelegate.swift
| | |--Tests
| | | |--AppTests.swift
| |--MyAppKit
| | |--Project.swift
| | |--Sources
| | | |--MyAppKit.swift
| | |--Tests
| | | |--MyAppKitTests.swift
| |--MyAppUI
| | |--Project.swift
| | |--Sources
| | | |--MyAppUI.swift
| | |--Tests
| | | |--MyAppUITests.swift
|--Tuist
| |--Config.swift
| |--ProjectDescriptionHelpers
| | |--Project+Templates.swift
|--Workspace.swift

MyApp/Project.swiftはこんな感じです。

let project = Project.app(name: "MyApp", platform: .iOS, dependencies: [
    .project(target: "MyAppKit", path: .relativeToManifest("../MyAppKit")),
    .project(target: "MyAppUI", path: .relativeToManifest("../MyAppUI"))
    
])

tuist generateで生成されたMyApp.xcworkspaceはこんな感じになります。

f:id:JohnnyKei:20201217213841p:plain

特定のプロジェクトにフォーカス

tuist focus XXXでとその依存関係を解決したxcworkspaceが生成されます。
機能単位でXcodeプロジェクトがわかれているといじらないコードやターゲットも表示されないのでプロジェクトファイルがシンプルになるし、Indexingも早くなり、ファイル検索も膨大な中から探さなくて良くなるので、開発が捗ります。 SwiftがちょっとわかるデザイナーがUIComponentの確認や調整をするときは、UIのみのプロジェクトを動かす、などができます。(Xcode Previewを使用など)

tuist focus MyAppUIをした時はこんな感じです。 f:id:JohnnyKei:20201217214154p:plain

依存関係が一目でわかる

tuist graphで依存関係のグラフを生成できます。
プロジェクト間にどのような依存関係があるか一目でわかるので便利です。

f:id:JohnnyKei:20201218141929p:plain

μFeatures

tuistはμFeaturesという考えた方をしていて、僕たちの開発において、だいぶ参考にしています。

tuist.io

ほかにもいっぱい

ほかにもいっぱい機能があるので、気になった方はぜひ触ってみてください。

さいごに

delyでは、定期的にイベントを行っています。 僕も中の人ながら、聴くのを楽しみにしています。 メンバーがどんな感じの雰囲気かもわかるので、ぜひお気軽にご参加ください。

bethesun.connpass.com

各ポジション募集中なので、気になるなぁとか思った方はぜひイケてる募集サイト見てみてください。 join-us.dely.jp

明日はこばさん(@kazkobay)による記事です。お楽しみに!

エンジニアが始めるプロダクトマネジメント最初の一歩

こんにちは、delyでクラシルのiOSエンジニア兼PdMをしているtakao(takaoh717)です。

この記事は「dely #1 Advent Calendar 2020」の19日目の記事です。

昨日はデザイナーredさんの「Material DesignでUIデザインをブーストしよう」という記事でした。

adventar.org

adventar.org

はじめに

delyに入社して3年が経過し、僕は今iOSエンジニア兼PdMとしてプロダクトに関わっていますが、今の役割になるまでにこれまでいろいろな立ち回りをしてきました。 最近弊社で開催したPdMに関するイベントで「コードを書くことを主としているエンジニアがどうやってPdMとしての能力を磨くべきか」という質問をいただきましたが、イベント内では具体的な話をあまりできなかったので本記事で僕自身の事例を交えながら紹介したいと思います。 この記事がPdMとしてのキャリアを検討しているエンジニアの方やそういった動きを今後していきたい方の参考になる話ができればと思います。

プロダクトマネジメントのスタートは社内のボール拾いから

プロダクトマネジメントの最初の一歩としては社内で転がっているボール拾いをすることから始めるのが良いかなと思っています。 ボール拾いというのは、誰もディレクションする人がいなかったり宙に浮いてしまっている状態のプロジェクトや課題がある場合にそれらを自分から巻き取りに行くようなことを指しています。

自分からボールを拾って物事を進めることができれば、PdMに必要なスキルが徐々に身につけられると思います。

ボールを拾うためにはプロダクトに関するあらゆる物事を自分ごとに捉える必要がある

普段コードに向かっているとなかなか他のことに目を向けられないなんてことはエンジニアであればよくあることだと思います。 もちろん集中してコードを書かないといけないときはありますし、それはそれでとても良いことだと思います。 しかし、社内でボールを拾っていくためには受け身の状態でいるのではなく主体的に動いていく必要があります。 どこに課題があるか、どこが改善できそうか、なにかおかしいところがないかなど常にアンテナを張って、情報収集を続けることで気付けることがあると思います。 そうしていると、自分が介入することでうまく回る部分を見つけられ、次のアクションに繋げられるようになります。

部署間のステークホルダーを繋ぐハブになる

誰かが舵取りをしたほうが良さそうだけれど、誰も舵取りをしなくてなんとなく現場メンバーでふわっと進めてしまったり、全体を把握できている人がいないことで無駄なコミュニケーションが大量に発生する、なんてことはないでしょうか。 関わっているプロジェクトがもしそういった状況に陥っている場合は率先して舵取りをしていけると良さそうです。

delyでは今でこそスクワッド体制になって各プロジェクトの舵取りをするPdMがいるのであまりそういった問題は起こりにくくなっていますが、PdMをそもそも明確に立てていなかったときやPdM1人体制だった頃などはプロジェクトの振り返りをしたときに責任者不在による問題の発生についてよく話が上がっていました。

f:id:takaoh717:20201218134855p:plain
スクワッド体制

PdMになるまで

僕は2017年11月にdelyにiOSエンジニアとして入社し、今も(頻度はかなり減りましたが)iOSのコードを書いていますが、どちらかというとPdMとしての役割をメインで担っています。明確にPdMという役割になったのは今からちょうど一年前くらいだったと思いますが、それまでもPdMっぽい動きを度々することがありました。 今考えると、僕の場合はボール拾いを何度も繰り返すことで少しずつエンジニアリング以外のスキルの幅を広げられたのかなと思っているので、その際にやっていたことや意識していたことなどを紹介します。

CSチームとの連携の改善

入社して1ヶ月くらいが経過したころ、ユーザーからの問い合わせの対応に課題を感じました。 当時感じた課題としては以下のようなものがありました

  • CS対応が属人化していた
    • 開発部のサーバーサイドエンジニア1人 ⇔ CS担当だけでクローズドにコミュニケーションしていた
  • 伝言による無駄なコミュニケーションが発生していた
    • 問い合わせ内容はアプリに関することがほとんどだけどサーバーサイドエンジニアが対応していたので、都度アプリエンジニアに質問や確認をするコミュニケーションが発生
    • 無駄なコミュニケーションが発生することにより返答に時間がかかってしまうことがあった
  • 開発チームが不具合を認識するまでに時間がかかっていた
  • 開発チームがユーザーの声を拾えていなかった
  • CS担当者が対応するためのドキュメントやテンプレートなどが整備されていなかった

改善に向けて起こしたアクション

こういった課題を解決するためにまずはドキュメントの作成と周知を行い、改善に向けて以下のようなアクションをしました。

  • CS担当の人にプロダクトの基本的なことに関しては内部の仕組みも理解してもらえるようにする
  • 一次回答はなるべくCSチームで対応できるような対応集を用意する
  • 開発チームで他のメンバーも対応できる仕組みを作る
  • 問い合わせの状況、開発の状況をお互いに見やすい環境を作る
  • 問い合わせの一次回答で必要な情報はできるだけユーザーに聞いておく
  • アプリ内によくある質問を掲載する

当時はまだステークホルダーもCS担当の方と当時数名のエンジニアだけだったのでそんなに難しいことはしていませんでしたが、こういったことをやり始めた結果その後も1年くらいはCSチームとの連携を続けることができ、ゆくゆくはプロダクトに変更を加えるような施策やCSチーム全体の運用の改善につながるような施策も色々と進められました。 (今は別のメンバーが担当しています)

上記のようなアクションを通して、例えば以下のようなスキルを身に着けていくことができました。

  • ドキュメントを使った言語化力
  • 部署を跨いだステークホルダーとのコミュニケーション力
  • ユーザの行動を知るためのデータ分析力(+SQL)

これらのスキルはPdMとして課題を解決していくためにどれも必要なものだと思いますが、職種に閉じたエンジニアリングをやっているだけではなかなか身につけるのは難しいと思います。 しかし、少し視野を広げて自分から動いていけば自然と身についていくことが多いと思います。

まとめ

今回この記事で特にお伝えしたかった内容は以下の2点です。

  • プロダクトマネジメントはボール拾いから始める
  • プロダクトに関わるあらゆる出来事を自分ごととして捉えることが重要

今現在エンジニアとしてガリガリコードを書いていて、キャリアとしてPdMとしての道を考えている人の参考になれば幸いです。

おわりに

明日のアドベントカレンダーの記事はジョンさんの「Xcodeプロジェクト管理ツール「Tuist」を試している」です。ぜひ御覧ください!

また、dely ではエンジニア/デザイナーを絶賛募集中です。 ご興味ある方はこちらのリンクからお気軽にエントリーください!

delyではエンジニアを絶賛募集中です。ご興味のある方はぜひこちらのリンクを覗いてみてください。 カルチャーについて説明しているスライドや、過去のブログもこちらから見ることができます。

join-us.dely.jp

delyの開発部では定期的にTechTalkというイベントを開催し、クラシル開発における技術や組織に関する内容を発信しています。
クラシルで使われている技術や、エンジニアがどのような働き方をしているのか少しでも気になった方はぜひお気軽にご参加ください!

bethesun.connpass.com

Firebase Remote ConfigのConditionsでちょっと複雑な振り分け方を設定する

f:id:kenzo_aiue:20201215144809p:plain

こんにちは。delyでAndroidエンジニアをしているkenzoです。

この記事は「dely #1 Advent Calendar 2020」の17日目の記事です。

昨日はサーバサイドエンジニア高松さんの「バンディットアルゴリズムをライトに解説」という記事でした。
A/Bテストとバンディットアルゴリズムを用いた施行が進む様子を並べて見比べられるのが面白かったです。ご興味ある方はぜひこちらも御覧ください!

adventar.org

adventar.org

今回はFirebaseのRemote Configを用いて一定割合のユーザーに値を振り分ける(A/Bテストをする)ときの設定方法についてお話します。

delyではクラシルを使用してくれているユーザーにより良い料理体験を届けられるよう、日々の機能開発・改善やキャンペーン施策等に対して開発部・マーケティング部のメンバーがFirebase Remote ConfigやA/B Testingを使ってユーザーに値を振り分け、機能の出し分け等を行うことが頻繁にあります。
今回少し複雑な設定を試す必要があり、改めて設定方法について検証したので、その内容をご紹介します。

今回は主にFirebaseのコンソール画面におけるRemote ConfigのConditionsタブでの値の振り分けの設定の仕方と反映のされ方についてご紹介します。
Remote Configと組み合わせて使用するA/B Testingも便利な機能でよく利用していますが、本記事ではあまり触れません。
また、今回はアプリ側の実装についても触れません。

こちらの順で説明していきます。

今回主に用いる設定

Remote Config画面のConditionsタブ右上の「条件を追加」ボタンを押して表示されるポップアップにおいて、

f:id:kenzo_aiue:20201214140257p:plain

「ユーザー(ランダム %)」と、

f:id:kenzo_aiue:20201214134440p:plain

「%」の右のボタンで設定できる「キー」を利用してユーザーを分けていきます。

f:id:kenzo_aiue:20201214134643p:plain

シンプルに振り分ける例

2分割

50:50のユーザーに振り分ける場合はこちらのように <= 50%> 50% の条件を作成します。
キーにはどちらの条件にも同じ値をセットします(ここでは test_a )。
(条件を1つだけにしてデフォルトを利用することもできます)

f:id:kenzo_aiue:20201214134802p:plain

作成した条件を用いてRemote Configのパラメータを作成します。

f:id:kenzo_aiue:20201214142430p:plain

作成したパラメータを公開して少し経つとパラメータの値が振り分けられたユーザーの割合が表示されます。
このように50:50のユーザーに値を振り分けることができました。

f:id:kenzo_aiue:20201214134920p:plain

3分割以上

3つ以上のグループに値を振り分けたい場合はこちらのように複数の条件を作成します。
もちろんこれらの条件のキーは揃えます。
この場合は <= > のどちらかに統一するのがわかりやすくておすすめです。

f:id:kenzo_aiue:20201214135209p:plain

こちらの設定ではデフォルトも含め4グループに値を振り分けています。

f:id:kenzo_aiue:20201214135249p:plain

少し複雑になるので、ユーザー群に対してどのように値が振り分けられるのか説明します。

どのように振り分けられるのか

引き続き上記の3分割以上の例について見ていきます。

f:id:kenzo_aiue:20201214135209p:plain

Conditionsにある条件は上にあるものが優先されるので(画像だと A_3 > A_2 > A_1)、下記の順に条件を満たしたユーザーに値が振り分けられていきます。*1

  • random_user_test_A_3 で対象のユーザー100%のうち25%以下に当たる25%のユーザーに値 3 が振り分けられる
  • random_user_test_A_2 残りの75%(25~100%)のうち50%以下(25~50%)に当たる25%のユーザーに値 2 が振り分けられる
  • random_user_test_A_1 残りの50%(50~100%)のうち70%以下(50~70%)に当たる20%のユーザーに値 1 が振り分けられる
  • 残りの30%のユーザーにはデフォルトの空の文字列が振り分けられる

図にするとこんな感じです。

f:id:kenzo_aiue:20201214145823p:plain

その結果このように値が振り分けられています。

f:id:kenzo_aiue:20201214135249p:plain

具体的なユースケースでの設定例

今度は架空のユースケースにおける設定を試してみます。

  • 新機能をリリースし、その機能を30%のユーザーに対してのみ表示させる
  • 新機能が表示されているユーザーの中でもプレミアムユーザーにのみ、新機能の特別な使い方をお知らせするページへ飛べるバナーを表示させる
    • プレミアムユーザーかどうかはユーザープロパティを使用して判定 *2
    • この検証の環境ではプレミアムユーザーの割合は50%程度

Conditionsにてこのような条件を作成します。

f:id:kenzo_aiue:20201214190307p:plain

作成した条件を用いてRemote Configのパラメータを設定します。

f:id:kenzo_aiue:20201214194431p:plain

これで上記の仕様通りにパラメータが function banner に割り振られますが、今回はRemote Configの画面を見るだけでは正しく割り振られたことが確認できません。
クラシルではRemote Configで設定した値がどのように割り振られたのか知るために、ログ基盤にどんな値が割り振られたのかを送るようになっています。
今回はこのようにログ基板に送られたログを確認することで、仕様通りにパラメータが割り振られたのかを確認します。

このような感じのSQLで実際に振り分けられた結果のログを確認します。
AB_TEST_LOGテーブルにユーザー毎に割り振られた値が入っているものとします。

f:id:kenzo_aiue:20201214195722p:plain

実際に上記の仕様と同様の設定をしてログに溜まった値を計測した結果がこちらです。*3

f:id:kenzo_aiue:20201216110204p:plain

  • 新機能はおよそ30%(0.87 + 13.29 + 15.86 = 30.02)がonで、およそ60%(33.15 + 36.83 = 69.98)がoff
  • バナーは新機能がonのユーザーのみがonで、かつ、プレミアムユーザーのみがon

となっており、上記の仕様を満たしています。
(プレミアムユーザーで新機能がon、バナーがoffのユーザーが少しの割合存在していますがこれはログ送信のタイミングによって生じている誤差です。*4

注意事項

設定の際に気を付けておくことをご紹介します。

キーが異なる条件の組み合わせだとうまくいかない

下記のように設定されたキーが別の条件を組み合わせてパラメータを作成すると、

f:id:kenzo_aiue:20201215104255p:plain

条件ごとにユーザーのマッピングが変わってしまうため、このように意図しないユーザー群に振り分けられてしまいます。

f:id:kenzo_aiue:20201216113237p:plain

実際に反映された値はこちらのようになっていました。

f:id:kenzo_aiue:20201215104526p:plain

条件の順番を間違えるとうまくいかない

3分割以上の場合、上の説明のように値が割り振られていくため、条件に設定するユーザーの割合は狭い範囲の条件から順に反映されるように設定します。
Conditionsにある条件は上にあるものが優先されるので、狭い範囲の条件から順に並べておきます。

逆に、こちらのように広い範囲の条件が優先されるように設定してしまうと、

f:id:kenzo_aiue:20201215110522p:plain

このように広い範囲の条件のみに値が割り振られてしまいます。

f:id:kenzo_aiue:20201215111438p:plain

まとめ

Firebase Remote ConfigのConditionsを用いた少し複雑な設定をする方法をご紹介しました。
ユーザーに値を振り分けるのは今回の方法の他にも同じFirebaseのA/B Testingや他社サービスでも実現できますが、今回のように具体的なユースケースとして紹介した振り分け方も知っておくと、選択肢が1つ増えると思います。

また、今回の方法は設定が少し複雑になるため運用上のミスも発生しやすい箇所となりますので、実際に振り分けられても影響のない値で検証してから利用することをおすすめします。

今回の内容が皆様の日々の改善の一助になれば幸いです。

おわりに

明日はデザイナーredさんの「Material DesignでUIデザインをブーストしよう」です。ぜひ御覧ください!

また、dely ではエンジニアを絶賛募集中です。 ご興味ある方はこちらのリンクからお気軽にエントリーください!

delyではエンジニアを絶賛募集中です。ご興味のある方はぜひこちらのリンクを覗いてみてください。 カルチャーについて説明しているスライドや、過去のブログもこちらから見ることができます。

join-us.dely.jp

delyの開発部では定期的にTechTalkというイベントを開催し、クラシル開発における技術や組織に関する内容を発信しています。
クラシルで使われている技術や、エンジニアがどのような働き方をしているのか少しでも気になった方はぜひお気軽にご参加ください!

bethesun.connpass.com

*1:参照: Remote Config のパラメータと条件

*2:参照: Remote Config とユーザー プロパティ

*3:実際はプレミアムユーザーではなく50%くらいとなる条件を設定し、そのログを計測した結果です

*4:「非プレミアムユーザーがプレミアムユーザーになったタイミング」と「それがFirebaseにユーザープロパティとして反映されて値を再取得するまで」の間にログ送信のタイミングがきてしまったことによるものです

C# 9.0時代のnull判定解剖

f:id:meilcli:20201211111840j:plain

どうもC#erの@MeilCliです。仕事ではAndroidエンジニアしてますがC#erなのでアドベントカレンダーではC#について書きます

今回参加してるアドベントカレンダーはこちらです。16日目の記事になります

adventar.org

あと同様なカレンダーがもう1つあります

adventar.org

また、この記事の一部をクイズにしたものも投稿していますのでよろしければそちらもご覧ください

祝: C# 9.0リリース

さて、つい先日.NET 5と共にC# 9.0がリリースされました。C# 9.0の新機能は多々あるのですがその中でパターンマッチングの強化の一貫でvalue is not nullのようにnot条件が追加されました。この新機能によってC# 8.0のようにnot null判定をするためにvalue != nullvalue is T nonNullvalue is {}を書かずとも自然言語的な文章で書けるようになりました

C#では前述のようにバージョンアップに連れ様々なnot null判定ができる構文が追加され、どの構文を使えばいいのか迷うところでもありました。null判定も同様に様々な構文があり、場合によっては特定の構文は使わないほうがパフォーマンス的に良いということさえありました

というわけで今回はC#における歴代のnot null・null判定の構文紹介とC# 9.0時代の最適な判定方法を探していこうと思います

様々なnull・not null判定方法

null判定

null判定はC#バージョンによっての判定方法の追加が少なく、よく使われるものだと2種類あるかと思います*1

// 素直に==比較
bool isNull = value == null;

// C# 7.0のパターンマッチング(定数パターン)
bool isNull = value is null;

それ以外のものだと以下のような方法が考えられると思います*2

bool isNull = object.Equals(value, null);

// 参照型の場合
bool isNull = object.ReferenceEquals(value, null);

bool isNull = EqualityComparer<T>.Default(value, null);

null判定に関しては方法が少ないためパフォーマンスが同じならば書き手の好きな方を選べばいいとなるかと思いきや、==演算子がオーバーロード可能なため型によってはnullと==比較しているのにfalseを返すような邪悪なことをされる恐れがあります*3。より意図した通りのコードにしたいならばvalue is nullの判定方法が一択になるでしょう

not null判定

not null判定はnull判定よりも方法が多く、自分が知っているだけでもよく使われるもので5種類あります

// 素直に!=比較
bool isNotNull = value != null;

// is演算子
bool isNotNull = value is string; // string?の場合、int?ならばvalue is intになる

// C# 7.0のパターンマッチング(型パターン)
bool isNotNull = value is string notNullValue;

// C# 8.0のパターンマッチング(プロパティーパターン)
bool isNotNull = value is { };

// C# 9.0のパターンマッチング(not expression + 定数パターン)
bool isNotNull = value is not null;

それ以外にもnull許容値型の場合はHasValueプロパティによる判定もできます

// null許容値型の場合
bool isNotNull = value.HasValue;

さて、not null判定でもより意図した通りのコードにしたいならば前述のように!=演算子を避けた判定方法を取るといいのですが、それ以外の選択肢がたくさん存在しています。これは実際にパフォーマンスを計測してみるしかありませんね

ベンチマーク

パフォーマンスを測るためのベンチマークツールはいつも通りBenchmarkDotNetを使います

計測対象のプロジェクトでは.NET5でnull許容参照型を使ったりするので以下のようなcsprojにします

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BenchmarkDotNet" Version="0.12.1" />
  </ItemGroup>

</Project>

また計測対象となるクラスの基本構成はこんな感じです

[SimpleJob]
[MeanColumn, MinColumn, MaxColumn]
[MemoryDiagnoser]
public class Bench
{
    [Params(null, "")] // or null, 1
    public string? Value { get; set; } // or int?

    [Benchmark]
    public bool Method()
    {
        bool result = false;
        for (int i = 0; i < 10; i++)
        {
            result = Value is null; // ここを計測したいケースごとに変える
        }
        return result;
    }
}

本来ならば計測対象のメソッドにforループで演算回数の水増しをしないほうがいいのですが、単純に1回限りの演算だと実行時間が早すぎて計測できないため10回ループさせています*4

ベンチマークケース

今回は値型と参照型の基本的な想定ケースとしてstring?int?をnullと空文字または1の値のときを計測しました

まとめると4回の計測になります

  • 参照型(string?)のnull判定するとき
  • 参照型(string?)のnot null判定するとき
  • 値型(int?)のnull判定するとき
  • 値型(int?)のnot null判定するとき

また、null・not null判定は!演算子やfalse比較で反転した結果にすることで同様な判定方法を記述できるため計測パターンは!演算子を使いつつnull判定とnot null判定でほぼほぼ同じ判定式になるようにケースを作成しました

参照型・null判定

メソッド名 判定式
EqualOperator Value == null
IsOperator !(Value is string)
PatternMatchNull Value is null
PatternMatchNotNull7 !(Value is string notNullValue)
PatternMatchNotNull8 !(Value is { })
PatternMatchNotNull9 !(Value is not null)
ObjectEquals object.Equals(Value, null)
ObjectReferenceEquals object.ReferenceEquals(Value, null)
EqualityComparer EqualityComparer<string?>.Default.Equals(Value, null)

参照型・not null判定

メソッド名 判定式
EqualOperator Value != null
IsOperator Value is string
PatternMatchNull !(Value is null)
PatternMatchNotNull7 Value is string notNullValue
PatternMatchNotNull8 Value is { }
PatternMatchNotNull9 Value is not null
ObjectEquals !(object.Equals(Value, null))
ObjectReferenceEquals !(object.ReferenceEquals(Value, null))
EqualityComparer !(EqualityComparer<string?>.Default.Equals(Value, null))

値型・null判定

メソッド名 判定式
EqualOperator Value == null
HasValue !(Value.HasValue)
IsOperator !(Value is int)
PatternMatchNull Value is null
PatternMatchNotNull7 !(Value is int notNullValue)
PatternMatchNotNull8 !(Value is { })
PatternMatchNotNull9 !(Value is not null)
ObjectEquals object.Equals(Value, null)
EqualityComparer EqualityComparer<int?>.Default.Equals(Value, null)

値型・not null判定

メソッド名 判定式
EqualOperator Value != null
HasValue Value.HasValue
IsOperator Value is int
PatternMatchNull !(Value is null)
PatternMatchNotNull7 Value is int notNullValue
PatternMatchNotNull8 Value is { }
PatternMatchNotNull9 Value is not null
ObjectEquals !(object.Equals(Value, null))
EqualityComparer !(EqualityComparer<int?>.Default.Equals(Value, null))

結果

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i9-10900K CPU 3.70GHz, 1 CPU, 16 logical and 8 physical cores
.NET Core SDK=5.0.100
  [Host]     : .NET Core 5.0.0 (CoreCLR 5.0.20.51904, CoreFX 5.0.20.51904), X64 RyuJIT
  DefaultJob : .NET Core 5.0.0 (CoreCLR 5.0.20.51904, CoreFX 5.0.20.51904), X64 RyuJIT

計測環境はこんな感じです*5

f:id:meilcli:20201128201025p:plain
参照型・null判定

f:id:meilcli:20201128201217p:plain
参照型・not null判定

f:id:meilcli:20201128201347p:plain
値型・null判定

f:id:meilcli:20201128201447p:plain
値型・not null判定

また、計測コードなどの詳細はGitHubに公開しているのでそちらを参照ください

github.com

要約

ちょっと結果が多すぎるためそれぞれの平均値を取ってみます*6

メソッド名 参照型 値型
EqualOperator 3.106 3.282
HasValue N/A 3.25
IsOperator 3.129 226.055
PatternMatchNull 2.65 3.746
PatternMatchNotNull7 2.621 6.292
PatternMatchNotNull8 3.097 3.274
PatternMatchNotNull9 2.631 3.275
ObjectEquals 14.37 248.335
ObjectReferenceEquals 3.075 N/A
EqualityComparer 16.75 52.655

総合的にはPatternMatchNotNull9がよく、それ以外の場合ではそれぞれ参照型・値型で長短があったりそもそも遅かったりという感じでしょうか

ちなみに

EqualityComparerのケースではEqualityComparer<T>.Defaultを取得する時間が影響を与えてる可能性があったため、string?とint?それぞれのインスタンスを取得する時間のベンチマークを取りました

[SimpleJob]
[MeanColumn, MinColumn, MaxColumn]
[MemoryDiagnoser]
public class EqualityComparerBench
{
    [Benchmark]
    public IEqualityComparer<string?> StringEqualityComparer()
    {
        return EqualityComparer<string?>.Default;
    }

    [Benchmark]
    public IEqualityComparer<int?> IntEqualityComparer()
    {
        return EqualityComparer<int?>.Default;
    }
}

f:id:meilcli:20201125091317p:plain

結果としては計測できないほど早い処理が行われてそうという感じでした。.NET Core 2.1におけるDevirtualization関連の最適化によってランタイム側でEqualityComparer<T>.Defaultをすり替えて仮想メソッド呼び出しのコストを回避したという話もあるようなのでそのあたりが影響してるのではないかなと思います

こういうこともあったり、どこからどこまで*7をベンチマーク対象として捉えればいいのかややこしくなってくるということもあるので今回の計測ではIEqualityComparer<T>.Default.Equals(Value, null)を計測することにしました

ベンチマーク結果の解剖

さて、ベンチマークを出して終わりではありません。.NET 5(on Windows)の結果はわかりましたのでそれぞれのベンチマークケースでなぜ差が生じたのかを紐解いていきます

C#でこのようなベンチマークケースの差を探るにはまずC#のコンパイル結果となるCIL*8を見るのが手っ取り早いです。今回はILSpyを使ってデコンパイルしました

また、CILは中間言語ということもあってコードが長くなる傾向になります。この場ではできる限り省いたものを載せるので全文を読みたい方はGitHubリポジトリーを参照してください

参照型・null判定

// Value == null
// !(Value is string)
// Value is null
// object.ReferenceEquals(Value, null)
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
ceq

object.ReferenceEquals(Value, null)がコンパイル時の最適化によってあとかたもなくなっていることには驚きですね。ldarg.0でスタックから引数0(つまりこのクラスのインスタンス)を読み込んでcall instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()でプロパティの値を読み込み、ldnullで読み込んだnull参照とプロパティの値をceqで等値比較するという感じです

// !(Value is string notNullValue)
// !(Value is { })
// !(Value is not null)
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
cgt.un
ldc.i4.0
ceq

こちらはcgt.unで大小比較し、cgt.unでint32の1か0がstackにpushされているのでldc.i4.0(つまりint32の0)とceqで等値比較しています。前述のValue == nullなどのケースより命令数が多くなってるのでパフォーマンス的に不利かと思いきや、ベンチマーク結果的にはあまり差がないようです

// object.Equals(Value, null)
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
call bool [System.Runtime]System.Object::Equals(object, object)

object.Equalsの場合はメソッドの呼び出し結果をそのまま使うという感じでした。あまり面白みがないのですがあとでobject.Equalsの実装を探ってみます

// EqualityComparer<string?>.Default.Equals(Value, null)
call class [System.Collections]System.Collections.Generic.EqualityComparer`1<!0> class [System.Collections]System.Collections.Generic.EqualityComparer`1<string>::get_Default()
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
callvirt instance bool class [System.Collections]System.Collections.Generic.EqualityComparer`1<string>::Equals(!0, !0)

EqualityComparer<string?>.Default.Equals(Value, null)に関してはCIL的にはcallvirtで仮想メソッド呼び出しを行っている箇所がコストになりそうなものの、ランタイム側で最適化されるという話もあるためCILレベルではあまり判断できそうにないですね。ここに関しては実装を深堀っていこうと思います

参照型・not null判定

// Value != null
// Value is string
// Value is string notNullValue
// Value is { }
// Value is not null
// !(object.ReferenceEquals(Value, null))
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNotNullBench::get_Value()
ldnull
cgt.un

こちらはnull判定の時とは逆にcgt.unで大小比較するという形になっていますね。ベンチマーク結果的にはValue != nullValue is stringValue is { }が他のケースよりちょっと遅いかな(?)という印象もありましたがCIL的には同じ結果にコンパイルされているので誤差の範囲なのでしょうか…(MinとMaxでも明らかに差がついているのでランタイム側でなんらかの最適化が入ってそうな気がしないこともないですがこれ以上はわかりませんね)

// !(Value is null)
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNotNullBench::get_Value()
ldnull
ceq
ldc.i4.0
ceq

このケースのみ他のパターンマッチングなどと違い、ceqでnull参照と等値比較し、さらにその結果をldc.i4.0と等値比較しています。否定演算子を正直に変換している感じがしますね。こちらはCILレベルでは命令数的に不利ですが前述のケースとの差はなさそうです

!(object.Equals(Value, null))!(EqualityComparer<string?>.Default.Equals(Value, null))に関してはnull判定のときからldc.i4.0ceqで結果を反転してるだけなので省きます

値型・null判定

// Value == null
// !(Value.HasValue)
// Value is null
// !(Value is { })
// !(Value is not null)
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
stloc.2
ldloca.s 2
call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()
ldc.i4.0
ceq

さて値型の場合ですが、null許容値型は内部的にはNullable<T>構造体で表現されています。プロパティから値を取ってきたあとはstloc.2でローカル変数に保存し、ldloca.s.2でそのローカル変数のアドレスを取得しています。そしてそのアドレスに対しNullable<T>構造体のHasValueプロパティのgetter実装であるget_HasValueメソッドを呼び出しています。そのあとは値を反転するためにldc.i3.0ceqを使っていますね

// !(Value is int)
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
box valuetype [System.Runtime]System.Nullable`1<int32>
isinst [System.Runtime]System.Int32
ldnull
cgt.un
ldc.i4.0
ceq

IsOperatorのケースが値型で極端に遅いというベンチマーク結果が出ていましたが、原因はboxでボックス化しているからですね。この遅さはC# 7.3の頃に調べてみた結果と同様なままのようです。コンパイラーの最適化次第な領域ではありますが、現時点でボックス化される形にコンパイルされることを鑑みると値型においてはvalue is intみたいな形式は避けておいたほうが無難でしょう

!(Value is int notNullValue)
IL_0006: ldarg.0
IL_0007: call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
IL_000c: stloc.2
IL_000d: ldloca.s 2
IL_000f: call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()
IL_0014: brfalse.s IL_0021

IL_0016: ldloca.s 2
IL_0018: call instance !0 valuetype [System.Runtime]System.Nullable`1<int32>::GetValueOrDefault()
IL_001d: pop
IL_001e: ldc.i4.1
IL_001f: br.s IL_0022

IL_0021: ldc.i4.0

IL_0022: ldc.i4.0
IL_0023: ceq

今まで行数は省略して紹介してきましたが、今度のはジャンプする命令があるため行数も書いています。brfalse.sではstackの値(ここではget_HasValueの結果)がfalse(つまり0の値)の場合に引数の行数であるIL_0021にジャンプさせています、つまり早期リターンのようなものですね。trueだった場合はその直後の命令が実行されていき、GetValueOrDefaultを呼び出しています。しかし、C#コード上では宣言したnotNullValue変数をしていない箇所が直訳されてるようで、popによってGetValueOrDefaultの結果を捨てています。このような無駄な命令があるため、他の早いケースと比べるとちょっと遅くなってしまっています

参照型の場合では跡形もなく消えていた未使用変数部分が値型の場合では直訳されるようなのでまだ少し最適化の余地があるという感じのようです

// object.Equals(Value, null)
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
box valuetype [System.Runtime]System.Nullable`1<int32>
ldnull
call bool [System.Runtime]System.Object::Equals(object, object)

object.Equalsの場合もボックス化が走っているようですね。これが遅い原因だとは思いますがあとでobject.Equalsの実装を覗けたらなと思います

// EqualityComparer<int?>.Default.Equals(Value, null)
call class [System.Collections]System.Collections.Generic.EqualityComparer`1<!0> class [System.Collections]System.Collections.Generic.EqualityComparer`1<valuetype [System.Runtime]System.Nullable`1<int32>>::get_Default()
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
ldloca.s 2
initobj valuetype [System.Runtime]System.Nullable`1<int32>
ldloc.2
callvirt instance bool class [System.Collections]System.Collections.Generic.EqualityComparer`1<valuetype [System.Runtime]System.Nullable`1<int32>>::Equals(!0, !0)

EqualityComparerのケースは少しややこしいですね*9 1スタック目*10ldarg.0callによってValueプロパティの値をpushし、2スタック目にldloca.s 2でローカル変数のNullable構造体のアドレスをpushし、initobjでNullable構造体の初期化を行っています(ここでスタックは消費している)。そして2スタック目にldloc.2で初期化したNullable構造体のローカル変数の値をpushし、callvirtでそれらの値を使ってEqualityComparerのメソッドを呼んでいます

EqualityComparerのケースがボックス化してるケースよりは早いけど時間がかかっているのはcallvirtしてるからという可能性もありますが、Devirtualizationされてると思われる箇所なのでEqualityComparerの実装体が少し遅い処理ということなんじゃないかなと想像できますね

値型・not null判定

// Value != null
// Value.HasValue
// Value is { }
// Value is not null
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNotNullBench::get_Value()
stloc.2
ldloca.s 2
call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()

こちらは値型・null判定でのValue is nullなどのケースからldc.i4.0ceqをしなくなったバージョンです。単純にbool値の反転がなくなったということですね

Value is intValue is int notNullValueでも同様にldc.i4.0ceqの命令がなくなっていました

// !(Value is null)
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNotNullBench::get_Value()
stloc.2
ldloca.s 2
call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()
ldc.i4.0
ceq
ldc.i4.0
ceq

!(Value is null)のケースではValue is nullのケースからさらにldc.i4.0ceqで値の反転をしています

これと同様に!(object.Equals(Value, null))!(EqualityComparer<int?>.Default.Equals(Value, null))もC#コードの通りにldc.i4.0ceqによる値の反転がされていました

Object.Equalsの実装

さて、Object.Equalsの実装が気になるので調べてみましょう。Objectは.NETの基礎となる型です。そのためソースコードを見るならば.NET5や.NET Coreのランタイム側を見るとよさそうです

.NET5や.NET Coreのランタイムはdotnet/runtimeに公開されています*11

GitHubの左上にある検索ボックスでfilename:Objectと検索してみます。すると大量のファイルがマッチするのでその中からObject.csを探し出します

src/libraries/System.Private.CoreLib/src/System/Object.csが検索結果の3ページ目ぐらいのところにあるのでそこからたどっていくことにします

public virtual bool Equals(object? obj)
{
    return RuntimeHelpers.Equals(this, obj);
}

public static bool Equals(object? objA, object? objB)
{
    if (objA == objB)
    {
        return true;
    }
    if (objA == null || objB == null)
    {
        return false;
    }
    return objA.Equals(objB);
}

コードを読むstatic bool Equalsのほうで早期リターンを行っている部分があるものの、早期リターンできなかった場合はRuntimeHelpers.Equalsを呼び出していることがわかります。そのままだと闇雲に探すことになってしまうのでヘッダー部分のusingされている名前空間を見ておきましょう

using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;

名前的にSystem.Runtime.CompilerServicesSystem.Runtime.InteropServicesにありそうですね

今度はfilename:RuntimeHelpersで検索してみます。すると8件ほどヒットするので目星をつけた名前空間に着目すると

の3つが該当しました。src/mono/netcoreというMonoなのか.NET Coreなのかよくわからないディレクトリーがありますが、まずはObject.csと同様な場所にあるsrc/librariesから読もうとなりましたがこのファイルでは定義されていません

partial classとなっているのでどうやらプラットフォームごとの別のソースコードを参照する形で実装されている様子です

[MethodImpl(MethodImplOptions.InternalCall)]
public static extern new bool Equals(object? o1, object? o2);

src/coreclrのほうを見てみるとこのようになっているため、どうやらCの世界まで潜らないといけないようです

検索ボックスでRuntimeHelpersをCやC++に絞って検索してみるとそれっぽいものが2つありました

src/coreclr/vm/corelib.hではDEFINE_CLASS(RUNTIME_HELPERS, CompilerServices, RuntimeHelpers)と記述されているだけなのでどうやらクラスを宣言してるだけのようです(CやC++詳しくないので間違ってるかもしれません)

src/coreclr/vm/ecalllist.hのほうを見てみます

FCFuncStart(gRuntimeHelpers)
    /* 略 */
    FCFuncElement("Equals", ObjectNative::Equals)
    /* 略 */
FCFuncEnd()

するとなにやら関数を登録してそうな処理が入っています。ObjectNativeに答えがありそうです

ObjectNativeで検索するとsrc/coreclr/classlibnative/bcltype/objectnative.cppsrc/coreclr/classlibnative/bcltype/objectnative.hが引っ掛かりますが、.hはヘッダーファイルなので.cppに実装がありそうです

.cppのほうでEqualsと検索するとこの処理がヒットしました

FCIMPL2(FC_BOOL_RET, ObjectNative::Equals, Object *pThisRef, Object *pCompareRef)
{
    CONTRACTL
    {
        FCALL_CHECK;
        INJECT_FAULT(FCThrow(kOutOfMemoryException););
    }
    CONTRACTL_END;

    if (pThisRef == pCompareRef)
        FC_RETURN_BOOL(TRUE);

    // Since we are in FCALL, we must handle NULL specially.
    if (pThisRef == NULL || pCompareRef == NULL)
        FC_RETURN_BOOL(FALSE);

    MethodTable *pThisMT = pThisRef->GetMethodTable();

    // If it's not a value class, don't compare by value
    if (!pThisMT->IsValueType())
        FC_RETURN_BOOL(FALSE);

    // Make sure they are the same type.
    if (pThisMT != pCompareRef->GetMethodTable())
        FC_RETURN_BOOL(FALSE);

    // Compare the contents (size - vtable - sync block index).
    DWORD dwBaseSize = pThisRef->GetMethodTable()->GetBaseSize();
    if(pThisRef->GetMethodTable() == g_pStringClass)
        dwBaseSize -= sizeof(WCHAR);
    BOOL ret = memcmp(
        (void *) (pThisRef+1),
        (void *) (pCompareRef+1),
        dwBaseSize - sizeof(Object) - sizeof(int)) == 0;

    FC_GC_POLL_RET();

    FC_RETURN_BOOL(ret);
}
FCIMPLEND

C#erには少し厳しいC++です…頑張って読み解いていきましょう

最初のCONTRACTLのところはおそらく防衛をしてるだけなのでスキップ。if (pThisRef == pCompareRef)trueを返してるので真っ先に参照を比較していますね

次にif (pThisRef == NULL || pCompareRef == NULL)でnullチェックをしています

そのあとのif (!pThisMT->IsValueType())ではコメントに値クラスではない場合は値で比較しないでください的なコメントが書かれています。参照型の場合は最初の参照比較で等値比較を終わらせていて、ここで値型以外はfalseとして返すようにしてるようです

そのあとのif (pThisMT != pCompareRef->GetMethodTable())では同じ型でなければfalseにするという処理が入っています

// Compare the contents (size - vtable - sync block index).
DWORD dwBaseSize = pThisRef->GetMethodTable()->GetBaseSize();
if(pThisRef->GetMethodTable() == g_pStringClass)
    dwBaseSize -= sizeof(WCHAR);
BOOL ret = memcmp(
    (void *) (pThisRef+1),
    (void *) (pCompareRef+1),
    dwBaseSize - sizeof(Object) - sizeof(int)) == 0;

さて、最後のこの比較が難関な匂いがします

まず真っ先に出てくるDWORDとはなんぞやというところからなので、グーグル大先生でclr dwordとググってみます。検索にヒットしたVB.NET/VB6.0/CLR/C/C++/Win32API 型一覧表 - 山崎はるかのメモによるとDWORDはC#でいうところのuintのようです、またそのあとに出てくるWCHARはC#でいうところのcharのようです

dwBaseSizeはMethodTableから取得しているようなのでおそらくその型が使用するメモリー量かなと思います。そのあとMethodTableがg_pStringClassだったらWCHARのサイズ分小さくしてるのはString末尾のヌル文字分減らしてるんじゃないかなと思いますが、値型でなかったらここまで到達しないはずでは…?というのもあるので謎ですね

そのあとはコメントの通りにmemcmpで参照先のメモリーブロックを比較しているという感じだと思います。(dwBaseSizeからsizeof(int)を減らしてる理由はわからないです)

Object.Equalsが遅かった理由

さて、本題に戻りObject.Equalsが参照型の場合はだいたい10ns、値型の場合はだいたい250nsかかっていたことについてですが、値型の場合はBenchmarkDotNetの結果をみるとAllocatedされているので明らかなのですが、Object.Equalsを呼び出すときにボックス化が行われていることがコストになっているようです。それを抜きにしても参照型・値型双方で通常の比較よりも多少の時間がかかっています

nullの場合はstatic bool Equalsのほうで早期リターンされるので早く終わってもいいはずですが、ベンチマーク結果的にはnullと空文字の場合であまり時間差がないため、メソッドの呼び出しコストに7nsぐらいかかってるんじゃないかなという匂いがします。一方で値型の場合は躊躇に差が表れているのでnullの場合は早期リターンされ、1の場合*12はObjectNativeの処理のあたりまで行ってるんじゃないかなと創造できます

真相は不明です、コード上からだとここが限界どころですね

ちなみにsrc/mono/netcoreのほうは

public static new bool Equals(object? o1, object? o2)
{
    if (o1 == o2)
        return true;

    if (o1 == null || o2 == null)
        return false;

    if (o1 is ValueType)
        return ValueType.DefaultEquals(o1, o2);

    return false;
}

値型以外の場合は単純な処理のようです。値型の場合だとValueType.DefautEqualsで比較するようです

internal static bool DefaultEquals(object o1, object o2)
{
    RuntimeType o1_type = (RuntimeType)o1.GetType();
    RuntimeType o2_type = (RuntimeType)o2.GetType();

    if (o1_type != o2_type)
        return false;

    object[] fields;
    bool res = InternalEquals(o1, o2, out fields);
    if (fields == null)
        return res;

    for (int i = 0; i < fields.Length; i += 2)
    {
        object meVal = fields[i];
        object youVal = fields[i + 1];
        if (meVal == null)
        {
            if (youVal == null)
                continue;

            return false;
        }

        if (!meVal.Equals(youVal))
            return false;
    }

    return true;
}

DefautEqualsに関してはこのメソッド名で検索するとこのファイルしか候補に上がらないため比較的楽に見つかりましたがInternalEqualsが嫌な予感しますね

[MethodImplAttribute(MethodImplOptions.InternalCall)]
private static extern bool InternalEquals(object o1, object o2, out object[] fields);

同じファイルに宣言されてましたが、どうやらまたCの世界に行くようです

InternalEqualsで調べるとsrc/coreclrのファイルも引っ掛かりますが、今回はsrc/monoコンテキストなのでその配下にあるそれっぽい結果のsrc/mono/mono/metadata/icall-def.hに探りをいれるとHANDLES(VALUET_1, "InternalEquals", ves_icall_System_ValueType_Equals, MonoBoolean, 3, (MonoObject, MonoObject, MonoArrayOut))とあるのでves_icall_System_ValueType_Equalsが本命のようです

同じように検索するとsrc/mono/mono/metadata/icall.cが出てきました。目的のメソッドは170行ぐらいあるのでここでは割愛しますが、リフレクションのような処理を行っているように見えます

さてC#erのみなさん、ここで思い出しましょう、値型のEqualsメソッドでは規定でリフレクションが使われるので遅いということを。このことは公式リファレンスの型の値の等価性を定義する方法 (C# プログラミング ガイド)でも書かれてることなので知ってる方も多いことでしょう*13。リファレンスに書かれてる通りのような実装をされているのでsrc/monoのほうは納得できるでしょう、しかしここまで読んでいただけた方はsrc/coreclrのほうではリフレクションではなくメモリブロックの比較を行っていたことにお気づきだと思います。自分のソースコード探索が間違っていなければいつの間にかにより高速だと思われる比較に変わっているということになりますね*14

Runtimeによる差を確認

前述のとおりsrc/monoはどうやら値型でEqualsメソッドを使うとリフレクションで遅いようだということがわかりました。本当にそうなのか比較したいところですがdotnet/runtimeのsrc/monoは謎の存在です(たぶんXamarin.Androidあたりのためにmono/monoからクローンしてるんじゃないかなと想像)

mono/monoでdotnet/runtimeのsrc/monoにあったDefaultEqualsを検索するとほぼ同様なコードがありましたので前述のとおりにmonoでは値型のEqualsメソッドでリフレクションが使われるという前提のもとその違いが出ないかの調査をします*15

調査と言ってもRuntimeによってEqualsの速度差が出るはずなのでBenchmarkDotNetによって差を計測していくことにします

今のcsprojファイルではそのまま計測することができないので少し手を加えます

<TargetFrameworks>net5.0;net48</TargetFrameworks>
<PlatformTarget>AnyCPU</PlatformTarget>
<LangVersion>9.0</LangVersion>

csprojのPropertyGroupにほとんどの場合ではTargetFrameworkが記述されてるかと思いますが、それはTargetFrameworksに変え.NET Framework 4.8であるnet48を記載します。それと同時にPlatformTargetLangVersionを指定します。ここでは.NET 5に合わせて9.0にしていますが.NET Framework 4.8やMonoだと対応するC#バージョンが異なりますので一部C# 9.0の機能が使えなくなります(検証では関係ありませんが)

次に.NET Framework 4.8とMonoを準備します。.NET FrameworkはDeveloper Packを入れ、Monoは公式サイトからインストーラーを入れて環境変数にPathを通せばいいです

[SimpleJob(RuntimeMoniker.Net48)]
[SimpleJob(RuntimeMoniker.NetCoreApp50)]
[SimpleJob(RuntimeMoniker.Mono)]
[MeanColumn, MinColumn, MaxColumn]
[MemoryDiagnoser]
public class ValueTypeEqualsBench
{
    private object expect = 1;

    [Params(null, 1)]
    public object? Value { get; set; }

    [Benchmark]
    public bool ValueTypeEquals()
    {
        return object.Equals(Value, expect);
    }
}

まず検証するのはこちらのベンチマークケースです。単純にメソッドの実行速度の差を計測したいのであらかじめボックス化をさせておきobject.Equalsを呼び出すだけのコードにしています

計測に関してですがMonoが.NET FrameworkのBCLを参照する必要があるらしくHost Processを.NET Framework 4.8にするためにdotnetコマンド(dotnet run -c Release -f net48)でHost Processを指定することで実施しています

そして結果がこう:

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042, VM=Hyper-V
Intel Core i9-10900K CPU 3.70GHz, 1 CPU, 16 logical and 8 physical cores
  [Host]        : .NET Framework 4.8 (4.8.4250.0), X64 RyuJIT
  .NET 4.8      : .NET Framework 4.8 (4.8.4250.0), X64 RyuJIT
  .NET Core 5.0 : .NET Core 5.0.0 (CoreCLR 5.0.20.51904, CoreFX 5.0.20.51904), X64 RyuJIT
  Mono          : Mono 6.12.0 (Visual Studio), X64 

f:id:meilcli:20201213185456p:plain

.NET Framework 4.8が.NET 5より少し早いという結果になりましたがMonoが予想通り遅そうな結果が出ていますね。リフレクションで比較しているということは比較対象のフィールドが多くなればなるほど差が躊躇に現れるはずです

[SimpleJob(RuntimeMoniker.Net48)]
[SimpleJob(RuntimeMoniker.NetCoreApp50)]
[SimpleJob(RuntimeMoniker.Mono)]
[MeanColumn, MinColumn, MaxColumn]
[MemoryDiagnoser]
public class ValueTypeLongStructEqualsBench
{
    public struct BigStruct
    {
        public long Value1;
        public long Value2;
        public long Value3;
        public long Value4;
        public long Value5;
        public long Value6;
        public long Value7;
        public long Value8;
    }

    private object expect = new BigStruct();

    public IEnumerable<object> Source()
    {
        yield return new BigStruct();
    }

    [Benchmark]
    [ArgumentsSource(nameof(Source))]
    public bool ValueTypeEquals(object value)
    {
        return object.Equals(value, expect);
    }
}

今度は無理やり肥大化させた構造体でベンチマークをしてみます

f:id:meilcli:20201213190231p:plain

結果としては予想通りMonoが躊躇に遅くなりました。どうやらいつかわからないタイミングで値型のEqualsのパフォーマンスチューニングが施されていたようです

EqualityComparer<T>.Defaultの実装

さてEqualityComparer<T>.Defaultの実装を深掘っていこうと思いますが、すでにEqualityComparer.Defaultの実装を追ってみる。 - ねののお庭。で先駆者の方が実装を追っているようです。どうやら正攻法でコードを読んでいくと沼になるようなので趣向を変えてDevirtualizationが実装されたPullRequestを見ていこうと思います

EqualityComparer<T>.DefaultのDevirtualizationが実装されたのは.NET Core 2.1の頃なのでdotnet/runtimeリポジトリーではなくdotnet/coreclrリポジトリー*16を探すことになります

PullRequestを検索するとJIT: devirtualization support for EqualityComparer.Default #14125というそれっぽいPullRequestが見つかります

中身を見てみるとDevirtualizationはIntrinsicAttributeをC#コードに付けそれをJITが見つけると特殊対応をする構造になっているようです

EqualityComparer<T>.Defaultの場合は元々は

public static EqualityComparer<T> Default { get; } = (EqualityComparer<T>)ComparerHelpers.CreateDefaultEqualityComparer(typeof(T));

というコードだったものが

public static EqualityComparer<T> Default { [Intrinsic] get; } = (EqualityComparer<T>)ComparerHelpers.CreateDefaultEqualityComparer(typeof(T));

というコードに変更されています

それと同時にsrc/mscorlib/src/System/Collections/Generic/ComparerHelpers.csのCreateDefaultComparerメソッドにand in vm/jitinterface.cpp so the jit can model the behavior of this method.というドキュメントコメントが追記されているためJIT側でDefaultのEqualityComparerを作成してるようです

JIT側のコードはC++でよくわからないので割愛するとして、Devirtualizationの特殊対応をする対象メソッドはsrc/jit/namedintrinsiclist.hのNamedIntrinsic列挙型で管理されているようです

enum NamedIntrinsic
{
    NI_Illegal                                                 = 0,
    NI_System_Enum_HasFlag                                     = 1,
    NI_MathF_Round                                             = 2,
    NI_Math_Round                                              = 3,
    NI_System_Collections_Generic_EqualityComparer_get_Default = 4
};

このPullRequestの時点では対象となるメソッドは少ないようですが気になるので現在の.NET 5の実装を見ましょう

dotnet/coreclrのコードはdotnet/runtimeだとsrc/coreclrの中に移ってるはずなのでそのディレクトリーからそれっぽいところを探すとnamedintrinsiclist.hが見つかりました

enum NamedIntrinsic : unsigned short
{
    NI_Illegal = 0,

    NI_System_Enum_HasFlag,
    NI_System_Math_FusedMultiplyAdd,
    NI_System_Math_Sin,
    NI_System_Math_Cos,
    NI_System_Math_Cbrt,
    NI_System_Math_Sqrt,
    NI_System_Math_Abs,
    NI_System_Math_Round,
    NI_System_Math_Cosh,
    NI_System_Math_Sinh,
    NI_System_Math_Tan,
    NI_System_Math_Tanh,
    NI_System_Math_Asin,
    NI_System_Math_Asinh,
    NI_System_Math_Acos,
    NI_System_Math_Acosh,
    NI_System_Math_Atan,
    NI_System_Math_Atan2,
    NI_System_Math_Atanh,
    NI_System_Math_Log10,
    NI_System_Math_Pow,
    NI_System_Math_Exp,
    NI_System_Math_Ceiling,
    NI_System_Math_Floor,
    NI_System_Collections_Generic_EqualityComparer_get_Default,
    NI_System_Buffers_Binary_BinaryPrimitives_ReverseEndianness,
    NI_System_Numerics_BitOperations_PopCount,
    NI_System_GC_KeepAlive,
    NI_System_Threading_Thread_get_CurrentThread,
    NI_System_Threading_Thread_get_ManagedThreadId,
    NI_System_Type_get_IsValueType,
    NI_System_Type_IsAssignableFrom,
    NI_System_Type_IsAssignableTo,

    /* 略 */

現在だとかなりのメソッドが特殊対応の対象のようですがほとんどがMathクラスのものですね

EqualityComparer<T>.Defaultの実装の調査に関しては沼なのでここまでにしておきます

結局のところなにがいいのよ

総合評価(安全性・可読性・速度)をするとnull判定はvalue is null、not null判定はvalue is not nullが無難という感じでしょうか。もちろん他の表現方法でも気にする必要がないケースがほとんどだろうのでなんでもいいっちゃいいという感じではありますが

ちなみに

ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
cgt.un
ldc.i4.0
ceq

最初のほうで!(Value is not null)の場合は上記のようなCILになってたよと紹介しましたが

public class ReferenceNull
{
    public bool PatternMatchNotNull9(string? value)
    {
        return !(value is not null);
    }
}

というコードのCILを見ると

.method public hidebysig 
    instance bool PatternMatchNotNull9 (
        string 'value'
    ) cil managed 
{
    // Method begins at RVA 0x2102
    // Code size 5 (0x5)
    .maxstack 8

    IL_0000: ldarg.1
    IL_0001: ldnull
    IL_0002: ceq
    IL_0004: ret
} // end of method ReferenceNull::PatternMatchNotNull9

というコードになっていました。式も文脈によっては異なるCILに変換されるようなので今回のベンチマークケースでこうなったからといってif文やreturn文で同様になるとは限らなさそうです。ボックス化している箇所についてはほぼ確実にどのような場所でもおきそうですがCILの命令数的な誤差は目をつぶるしかなさそうです

おまけ

public class Evil
{
    public static bool operator ==(Evil left, Evil right) => true;
    public static bool operator !=(Evil left, Evil right) => false;
}

public class Program
{
    public void Example()
    {
        var evil1 = new Evil();
        var evil2 = new Evil();
        bool isEquality = evil1 == evil2;
    }
}

C#的には合法(コンパイル可能)で邪悪なコードですがExampleメソッドのCILに変換されたコードを見るとオーバーロードした==演算子が呼ばれていることがわかります

.method public hidebysig 
    instance void Example () cil managed 
{
    // Method begins at RVA 0x2060
    // Code size 22 (0x16)
    .maxstack 2
    .locals init (
        [0] class Evil evil1,
        [1] class Evil evil2,
        [2] bool isEquality
    )

    IL_0000: nop
    IL_0001: newobj instance void Evil::.ctor()
    IL_0006: stloc.0
    IL_0007: newobj instance void Evil::.ctor()
    IL_000c: stloc.1
    IL_000d: ldloc.0
    IL_000e: ldloc.1
    IL_000f: call bool Evil::op_Equality(class Evil, class Evil)
    IL_0014: stloc.2
    IL_0015: ret
} // end of method Program::Example

Evilの変数をobject型として受け取ればobjectの規定の動作通りに==演算子で比較したようになったりしますが、たいていの場合はそういうわけにもいかないので==演算子のオーバーロードは注意が必要だったりします

おわりに

この記事はアドベントカレンダーだしC#の記事書いとくか〜と書き始めたら止まらなくなり肥大化してしまったものです(スコープの管理ができてない)。最後の方はダレてしまって手抜き感がありますがご了承ください、気になればいつの日か調査するかもしれません

昨日の「dely #2 Advent Calendar 2020」はnancyさんの「iOSのサブスクリプション機能 プロモーションオファーを触ってみた」でした

明日は永井さんの「Merged Manifest を使って uses-permission を調査した話」ですお楽しみに!


join-us.dely.jp

bethesun.connpass.com

*1:サンプルコードはすべてC# 9.0ベースです

*2:ポインターとか参照をUnsafeに比較する方法とかあるかもしれません

*3:普通はそんなオーバーロードをしないので普通のプラットフォーム向けのコードの場合は気にしなくていいですが、気にしないといけないプラットフォームがあるので闇です

*4:こういう場合のスマートな解決策があれば教えてください

*5:i9-10900Kは10core20threadなCPUですが、計測はHyper-V上のWindowsで行ったため8core16threadです。フルパフォーマンスとは言えませんが同環境での比較となるためベンチマーク結果としては有効かと思います

*6:ガチで判断するならnullの頻度分布によって調整をかけないといけませんがここでは手抜きということで平均値です

*7:たとえばIEqualityComparerのインスタンスが用意できてるという前提でcomparere.Equals(Value, null)を計測するのかとか

*8:Common Intermediate Language、共通中間言語、一部からはMSILとも呼ばれる

*9:ここのCILを理解するのに5分考えこみました

*10:EqualityComparerは0スタック目という数え方

*11:ちょっと前まではdotnet/coreclrで公開されていましたね

*12:nullと対比するための値として設定した1のことです

*13:自分はすっかり忘れてました

*14:それでもボックス化で遅いのは変わらず

*15:すべてのコードを確かめたわけではありませんが雰囲気的にはdotnet/runtimeのsrc/monoはmono/monoから手書きクローンしてそうな感じがしました

*16:今はdotnet/runtimeに移行されてコードがほとんどない状態ですがCommitやPullRequestは残っています