entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

KSP・自作アノテーションでフィーチャーフラグ運用を改善する

こんにちは。pixivコミックのAndroid版アプリを開発しているfusumaと申します。

今回はpixivコミックAndroidアプリのフィーチャーフラグ運用を、KSP・自作アノテーションで改善した事例を紹介します。

フィーチャーフラグとは

フィーチャーフラグとは、コードを変更せずに個別の機能のOn/Offを切り替える手法で、特定のユーザーを対象にした機能やA/Bテストなどに利用します。以下はフィーチャーフラグの簡単な実装です。

if (featureFlag.enabled) {
    // 機能On
} else {
    // 機能Off
}

その他、開発中の機能をOffにしておくことで、そのままメインブランチにマージできます。小まめにマージできるので、長期に渡る新機能開発でも大きなコンフリクトが起きづらくなります。

フィーチャーフラグの有効期限チェック

A/Bテストや開発中機能のためのフィーチャーフラグはいずれ役目を終えます。役目を終えたフラグが残ったままだと、実際に利用しているフラグが分からなくなり、管理コストが増していきます。不要になったフラグはきっちり消していきたいところです。

pixivコミックでは各フラグに有効期限を定め、期限を過ぎたフラグが残っていないかチェックするようにしました。フラグの有効期限をユニットテストでチェックし、期限を過ぎていたらテストを失敗させます。CIで回しているテストが失敗することでフラグの削除漏れに気付く、という仕組みです。

フィーチャーフラグの定義:

sealed class FeatureFlag(enabled: Boolean, deadline: LocalDate) {
    object FeatureA() : FeatureFlag(enabled = BuildConfig.Debug, deadline = LocalDate.of(2023, 01, 01))
}

有効期限を過ぎていたら失敗するユニットテスト:

class FeatureFlagTest {
    @Test
    fun testNGFlagDeadline() {
        assertThat(FeatureFlag.FeatureA.deadline).isGreaterThan(LocalDate.now())
    }
}

フィーチャーフラグを追加する際は、このようにフラグの定義に加えてテストを追加していくことになります。

これは中々面倒ですね。

さらに、ユニットテストの実装を機械的に強制できないので、漏れなくテストが書かれていることをレビューで担保することになります。

この手間を解消するために、フィーチャーフラグにアノテーションを付与するだけで有効期限チェックができるようにしました。

自作アノテーションに置き換える

まずは自作アノテーションの定義と利用例をみていきます。アノテーションを取得してコードを解析する実装については後述します。

次の二つのアノテーションを定義しました。

Deadline : 現在の日付が指定した有効期限日を過ぎていたらビルド時にエラーになる

DeadlineRequired : このアノテーションを付与したsealed classのサブクラスは @Deadline が付与されていないとビルド時にエラーになる

アノテーションの定義:

@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
annotation class Deadline(
    val year: Int,
    val month: Int,
    val day: Int
)
// sealed classに付与することで、subclassへの@Deadlineの付与を強制させる
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class DeadlineRequired

アノテーションの利用例:

// sealed classのサブクラスに@Deadlineの付与を強制するアノテーション
@DeadlineRequired
sealed class Flag(enabled: Boolean) {

  // -> 有効期限を過ぎているためエラーになる
  @Deadline(year = 2023, month = 1, day = 1)
  object FeatureA : Flag(enabled = BuildConfig.DEBUG)

  // -> @DeadlineRequiredを付与していることにより、@Deadline付与漏れはエラーになる
  object FeatureB : Flag(enabled = BuildConfig.DEBUG)
}

この例をビルドしようすると、 このようなエラーとなります。FeatureA は有効期限切れのため、FeatureB @Deadline 付与漏れのためそれぞれエラーとなりました。

アノテーションで有効期限のチェックとチェックの強制を実現できています。

次はKSPで実際にアノテーションを取得してコードを解析する実装についてみていきます。

KSPによるアノテーションの取得とコードの解析

Kotlin Symbol Processing API | Kotlin Documentation (kotlinlang.org)

Kotlin Symbol Processing (KSP)はKotlinで軽量なコンパイラプラグインを開発するためのAPIです。

kaptでもアノテーションを取得して何らかの処理を加えることは出来ますが、KSPは処理が速い・sealed classのようなJavaにないシンボルを認識できるなどのメリットがあります。

今回定義したアノテーション DeadlineRequired を取得する実装は以下のようになります。

(もう一方のアノテーション Deadline も実装の流れは同じなので具体的な実装は割愛します)

依存関係の記述

annotation というモジュールを作成し、そこにアノテーションの処理を置くことにします。これを app モジュールから利用します。

annotation/build.gradle:

plugins {
    id 'org.jetbrains.kotlin.jvm'
    id "com.google.devtools.ksp" version "1.7.20-1.0.7"
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib"
    implementation "com.google.devtools.ksp:symbol-processing-api:1.7.20-1.0.7"
}

app/build.gradle:

plugins {
    ...
+   id 'com.google.devtools.ksp' version "1.7.20-1.0.7"
}

dependencies {
    ...
+.  ksp project(":annotation")
+   implementation project(":annotation")
}

SymbolProcessor, SymbolProcessorProvider の実装

実際に処理を行うKSPのinterfaceを実装していきます。

SymbolProcessor.process がメインロジックにあたります。ここでアノテーションの取得、コードの解析を行なっていきます。

annotation/src/main/java/jp/pxv/android/manga/annotation/DeadlineRequiredProcessor.kt:

class DeadlineRequiredProcessor(
    private val logger: KSPLogger,
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        // @DeadlineRequiredが付与されたシンボルを取得する
        val symbols = resolver.getSymbolsWithAnnotation(DeadlineRequired::class.qualifiedName.toString())
        symbols
            .filterIsInstance<KSClassDeclaration>() // @DeadlineRequiredはクラスへの付与を想定しているので、シンボルをクラスに絞る
            .forEach { deadlineRequiredParent ->
                deadlineRequiredParent
                    .getSealedSubclasses() // sealed classのサブクラスを取得する 
                    .forEach { sealedSubclass ->
                        val deadlineExists = sealedSubclass.isAnnotationPresent(Deadline::class)
                        if (!deadlineExists) {
                            // @Deadlineが付与されていない場合、KSPLogger.errorで失敗させる
                            logger.error(
                                "subclasses of `$deadlineRequiredParent` are required annotation `@Deadline`",
                                sealedSubclass
                            )
                        }
                    }
            }

        return emptyList()
    }
}

まず Resolver.getSymbolsWithAnnotation に取得したいアノテーションを渡し、アノテーションが付与されたシンボルのリストを取得しています。

@DeadlineRequiredはクラスへの付与を想定しているので、シンボルをクラスに絞って処理していきます。

sealed classのサブクラスを取得し、サブクラスに@Deadline が付与されていなければKSPLogger.error を実行します。

KSPLogger.error が実行されるとビルドが失敗し、エラーが出力されます。第二引数の symbol にアノテーションが付与されたシンボルを渡すと対象が出力されて便利です。

symbol を渡さない場合:

[ksp] subclasses of `Flag` are required annotation `@Deadline`

symbol を渡した場合:

[ksp] {path to project root}/app/src/main/java/jp/pxv/android/manga/featureflag/Flag.kt:16: subclasses of `Flag` are required annotation `@Deadline`

実装したSymbolProcessor SymbolProcessorProvider create で返します。

annotation/src/main/java/jp/pxv/android/manga/annotation/DeadlineRequiredProcessorProvider.kt:

class DeadlineRequiredProcessorProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
        return DeadlineRequiredProcessor(
            environment.logger
        )
    }
}

SymbolProcessorProviderを実行するには、所定のファイルにクラスを記述する必要があります。

(モジュールのルートから見て) resources/META-INF/services ディレクトリに

com.google.devtools.ksp.processing.SymbolProcessorProvider というファイルを作成し、そこに記述します。

annotation/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider :

jp.pxv.android.manga.annotation.DeadlineRequiredProcessorProvider

これでビルド時にSymbolProcessorが実行されるようになりました!

おわりに

今回は自作アノテーションとKSPを用いたコードチェックで運用を効率化する事例を紹介しました。今後もさらなる効率化・開発体験向上の取り組みを行なっていきます!

Androidエンジニア募集中!!

pixivではAndroidエンジニアを絶賛募集しております。興味を持って下さった方はぜひ下記エントリーフォームよりご応募お待ちしております!

https://hrmos.co/pages/pixiv/jobs/003hrmos.co

fusuma
2021年7月に中途入社。pixivコミックの開発に携わっており、主にAndroidアプリを担当している。