Tabelog Tech Blog

食べログの開発者による技術ブログです

段階的な移行を成功させたストラテジパターン

この記事は 食べログアドベントカレンダー2023 の1日目の記事です🎅🎄


こんにちは。食べログシステム本部 アプリ開発部の 和田 です。普段は、Android 版食べログアプリの保守対応をしています。

以前、当ブログで記事を執筆したことがあるため、ご存知の方がいるかもしれませんね。

本日は、ストラテジパターンを Android 版食べログアプリのプロダクトコードに導入したら、保守性が向上し、段階的な移行を効率的に実施することができたという話をします。

目次

輪読会とストラテジパターンとの出会い

私が所属しているアプリ開発部 基盤チームでは、毎週木曜日に1時間、輪読会を開催しています。輪読会では次のようなことを実施しています。

  • 開発者界隈で話題の書籍を購入して、チームメンバが輪読する
  • Android Studio の最新機能や Android OS の仕様差分を確認する
  • オープンソースソフトウェアの配布元が公開しているリリースノートを確認する
  • 勉強会の主催者、実行委員の方が公開しているセッション動画を視聴する

もちろん、情報を確認するだけではなく、得た情報をいかに食べログアプリの保守、開発に役立てるか という観点で、参加者が議論します。この議論は学びが多く、すごく面白いです。なぜなら、私が気づかなかった情報の使い道が多く提案されるからです。

今回は、輪読会の題材にさせて頂いた書籍 「良いコード/悪いコードで学ぶ設計入門ー保守しやすい 成長し続けるコードの書き方」第6章 条件分岐 ー迷宮化した分岐処理を解きほぐす技法ーに記載されているストラテジパターンの技法を Android 版食べログアプリのプロダクトコードに適用したら、アプリの保守性が向上したという話をします。

良いコード/悪いコードで学ぶ設計入門ー保守しやすい 成長し続けるコードの書き方の紹介

本題に入る前に、「良いコード/悪いコードで学ぶ設計入門ー保守しやすい 成長し続けるコードの書き方」 について、基本情報を紹介させてください。

  • 正式タイトル:良いコード/悪いコードで学ぶ設計入門ー保守しやすい 成長し続けるコードの書き方
  • 著者:仙塲大也
  • 出版社:技術評論社
  • ISBN:978-4-297-12783-1

その他の基本情報については、 公式サイト をご確認ください。

食べログアプリの口コミ詳細画面が抱えていた問題

さて、ここから本題に入ります。
食べログアプリには、口コミ詳細と呼ばれる画面があり、そこへの導線がたくさんあります。その中で、一番簡単なのは、タイムラインに表示された口コミをタップすると表示される導線でしょうか。

一見すると、どの導線から口コミ詳細画面を開いても同じ画面が表示されているように見えますが、内部的には次の3つの仕様に分かれています。画像は、修正当時のものです。

ユーザ系
店舗系
ディープリンク系
user_type
restaurant_type
deeplink_type

これら3つの仕様は、できることはほとんど一緒ですが、表示する内容や遷移先の画面が異なるケースがあります。
そのため、以前の口コミ詳細画面のソースコードは、以下のような条件分岐が乱立しており、お世辞にも可読性が高いとは言えませんでした。

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    PageType type = getPageType();
    if(type == PageType.User) {
        // ユーザ系の処理
    } else if(type == PageType.Restaurant) {
        // 店舗系の処理
    } else if(type == PageType.DeepLink) {
        // ディープリンク系の処理
    }
}

@Override
protected void onResume() {
    super.onResume();

    switch(getPageType()) {
        case User:
            // ユーザ系の処理
            break;
        case Restaurant:
            // 店舗系の処理
            break;
        case DeepLink:
            // ディープリンク系の処理
            break;
    }
}

/**
 * 画面遷移用のパラメータを取得する
 * 
 * @param type 画面の仕様を識別するタイプコード
 * @return パラメータ
 */
public TransitionParameter getTransitionParameter(PageType type) {
    TransitParameter.Builder builder = ...
    builder.set...

    if(type == PageType.User) {
        // ユーザ系の時だけ特殊な処理を行う
        builder.set...
    }

    if(type == PageType.Restaurant) {
        // 店舗系の時だけ特殊な処理を行う
        builder.set...
    }

    if(type == PageType.Restaurant || type == PageType.DeepLink) {
        // 店舗系またはディープリンク系の時だけ特殊な処理を行う
        builder.set...
    }

    return builder.build();
} 

口コミ詳細画面は、利用者が多く、よく見られる画面なので、より見やすくなるよう、仕様変更の対象になりやすい画面です。このままでは、仕様変更による修正に耐えられなくなると判断されました。そこで、基盤チームが以前から継続して実施しているリアーキテクチャの一環として、口コミ詳細画面の設計を抜本的に修正することになりました。リアーキテクチャなので、条件分岐以外の問題も解決したのですが、当記事では、条件分岐の問題に絞って解決方法を紹介します。

ストラテジパターンの適用

口コミ詳細画面の条件分岐の問題をどう解決するか悩んでいた時、「良いコード/悪いコードで学ぶ設計入門ー保守しやすい 成長し続けるコードの書き方」 を題材にした輪読会が開催されました。そこで私は、次の根拠によって「良いコード/悪いコードで学ぶ設計入門ー保守しやすい 成長し続けるコードの書き方」に記述されていたストラテジパターンを問題解決の技法として活用できると考えました。

  • ユーザ系、店舗系、ディープリンク系と3つの仕様があるが、細かい仕様差分ばかりで、大部分は同じ処理をしていた
  • 一度、コンストラクタで仕様ごとのストラテジを決定すれば、以降、条件分岐が必要な処理は、そのストラテジに代替することができそうだった

私は、早速ストラテジ用のインターフェースと、仕様ごとの実装クラスを準備しました。次は、表示の仕様に関わるストラテジの実装例です。実際には、計測やデータアクセスの仕様に関わるストラテジも実装しているのですが、似たようなコードなので割愛します。

/**
 * 口コミ詳細画面の表示に関わるストラテジ
 */
interface ViewStrategy {
    /**
     * ヘッダを表示する
     *
     * @param headerInformation ヘッダに表示する情報
     * @param binding 口コミ詳細画面の ViewBinding
     */
    fun showHeader(
        headerInformation: HeaderInformation,
        binding: TotalReviewBinding,
    )
}

class UserViewStrategy : ViewStrategy {
    fun showHeader(
        headerInformation: HeaderInformation,
        binding: TotalReviewBinding,
    ) {
        // ユーザ系のヘッダの表示処理
        binding.header.setupRestaurant(headerInformation.restaurant)
        ...
    }
}

class RestaurantViewStrategy : ViewStrategy {
    fun showHeader(
        headerInformation: HeaderInformation,
        binding: TotalReviewBinding,
    ) {
        // 店舗系のヘッダの表示処理
        binding.header.setupUser(headerInformation.user)
        ...
    }
}

class DeepLinkViewStrategy : ViewStrategy {
    fun showHeader(
        headerInformation: HeaderInformation,
        binding: TotalReviewBinding,
    ) {
        // ディープリンク系のヘッダの表示処理
        binding.header.setupRestaurant(headerInformation.restaurant)
        binding.header.setupUser(headerInformation.user)
        ...
    }
}

なお、特定の仕様の時だけ何らかの処理をする時にもストラテジパターンを適用することができます。処理が不要な場合、次のように空実装するだけです。

/**
 * サンプル用のストラテジ
 */
interface SampleStrategy {
    /**
     * 何らかの処理をする
     * 
     * @param information 処理に必要な情報
     */
    fun doSomeProcessing(information: Information?)
}

class UserSampleStrategy : SampleStrategy {
    fun doSomeProcessing(information: Information?) {
        // ユーザ系の場合、何か処理をする
        information ?: throw IllegalArgumentException("There is no information.")
        ...
    }
}

class RestaurantSampleStrategy : SampleStrategy {
    // 引数は null で良い
    fun doSomeProcessing(information: Information?) {
        // 店舗系は no-op
    }
}

class DeepLinkSampleStrategy : SampleStrategy {
    // 引数は null で良い
    fun doSomeProcessing(information: Information?) {
        // DeepLink 系は no-op
    }
}

他にも、ストラテジ用のインターフェース実装クラスのコンストラクタに処理に必要な情報を渡したり、戻り値を持つメソッドを定義したりすることもできます。
ストラテジを準備できたら、次はタイプコードに応じたストラテジを返すよう Map をセットします。ストラテジを利用するクラスのコンストラクタでセットするのが、無駄がなくて良いと思います。

private val _viewStrategies: Map<PageType, ViewStrategy> = mapOf(
    PageType.User to UserViewStrategy(),
    PageType.Restaurant to RestaurantViewStrategy(),
    PageType.DeepLink to DeepLinkViewStrategy(),
)
private val viewStrategy: ViewStrategy
    get() = _viewStrategies[receiveParameter.pageType]
        ?: throw IllegalStateException("No corresponding strategy defined.")

後は、条件分岐が必要な箇所でストラテジを呼び出すだけです。前述した Java のコードは、Kotlin 化し、かつストラテジを利用することで次のように書き換えることができました。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val information = ...
    val binding = ...
    viewStrategy.showHeader(information, binding)
}

override fun onResume() {
    super.onResume()
    val information = ...
    sampleStrategy.doSomeProcessing(information)
}

fun getTransitionParameter(parameterInformation: ParameterInformation): TransitionParameter {
    return transitParameterStrategy.createTransitParameter(parameterInformation)
}

どうでしょうか?最初の Java のコードに比べて、だいぶ見通しのよいコードになったと言えるのではないでしょうか?

ストラテジパターンは単体テストしやすい

ストラテジパターンを導入すると、単体テストしやすくなります。
様々な処理に似たような条件分岐が点在している場合、それぞれの処理で各分岐を通すための前準備が必要になります。ストラテジパターンを導入している場合、コンストラクタによるストラテジの選択さえ単体テストで保証できれば、分岐についてのテストはそれだけで十分です。もちろん、ストラテジの各実装クラスについても単体テストを実施する必要がありますが、条件分岐を通すための前準備が不要なので、どのような処理をしているかによりますが、比較的簡単にテストできます。

簡単に対応できた段階的な移行

口コミ詳細画面は多くの機能を有しており、仕様が複雑で、開発規模が大きくなることから、比較的仕様が簡単なユーザ系口コミ詳細だけ先行してリアーキテクチャしてリリースする段階的な移行が採用されました。店舗系とディープリンク系の口コミ詳細画面は、ユーザ系口コミ詳細画面の状況(クラッシュ等)を確認してから、リアーキテクチャしてリリースするという移行計画です。ストラテジパターンは、段階的な移行ととても相性が良かったです。
ユーザ系口コミ詳細画面をリリースする時は、次のようにユーザ系のストラテジ実装クラスだけ用意しておきます。

private val _viewStrategies: Map<PageType, ViewStrategy> = mapOf(
    PageType.User to UserViewStrategy(),
)
private val viewStrategy: ViewStrategy
    get() = _viewStrategies[receiveParameter.pageType]
        ?: throw IllegalStateException("No corresponding strategy defined.")

二段階目のリリースに向けて、店舗系、ディープリンク系のストラテジを用意する場合、ストラテジを判別するタイプコード(上の例の場合、receiveParameter.pageType)に PageType.User 以外の値を指定すれば、実装が必要な箇所が実行時にクラッシュするので、スタックトレースを確認して必要なストラテジを実装すれば良いです。この方式の場合、段階的な移行における条件分岐の実装漏れを防ぐことができます。
ストラテジパターンを導入したことで、口コミ詳細画面のコードを段階的な移行に強い設計にすることができました。

さいごに

いかがだったでしょうか?
私は 「良いコード/悪いコードで学ぶ設計入門ー保守しやすい 成長し続けるコードの書き方」 を読んだ後、ストラテジパターンを Android 版食べログアプリのプロダクトコードに導入したくてウズウズしてしまいました。そのため、実際にストラテジパターンをプロダクトコードに導入できて、私は今、ものすごく満足感に満たされています(笑)
「良いコード/悪いコードで学ぶ設計入門ー保守しやすい 成長し続けるコードの書き方」には、ストラテジパターン以外にもプロダクトコードを改善するための多くの技法が紹介されています。私に言われるまでもなく素晴らしい書籍なので、まだ未読の方がいたら一度手にとってみると良いかもしれませんね。

明日は @sadashi さんの「Spek やめました」です。お楽しみに!