見出し画像

Now in REALITY Tech #66 Gradleのモジュール構成図を描いてみた

最近原神にハマっているAndroidエンジニアのメタルおじさんです。2月から遊び始めて2ヶ月くらい、気がついたら冒険ランク50になってました。

さて、みなさんはAndroid アプリのモジュール化してますか?

REALITY Androidのコードベースはマルチモジュール構成を取っており、非常にたくさんのモジュールが絡み合って一つのアプリをビルドするようになっています。今ざっと数えてみた感じ100個近くモジュールがありました。
これだけたくさんのモジュールがあると、モジュール構成全体がどうなっているかを把握するのが難しくなります。

モジュール構成を把握するために、モジュール構成図がほしい!

そう思って「gradle module dependency graph」などで検索してみたところ、Gradleのモジュール間の依存関係をグラフ化するスクリプトが出てきました。有名そうなのはJakeWhartonさんのprojectDependencyGraphではないでしょうか。

projectDependencyGraphのスクリプトを実際にREALITY Androidのコードベースに組み込み、動かしてみました。出来上がった図がこちらになります。

なんもわからん

ご覧の通り、ノードの数も関係の数も多すぎ、図にしたところで「結局よくわからん…」という感じになってしまいました。これでは意味がないですね。もっとわかりやすくするにはどうしたら良いでしょうか?

そもそも、何を知れれば「モジュール構成を理解できた」と言えるのでしょうか?

私は、「モジュール構成を理解する」ということは、以下の3つについて知ることであると考えました。

  • あるモジュールが、どこのモジュールに依存を持っているか

  • あるモジュールが、どこのモジュールから依存されているか

  • あるモジュールが、システム全体でどこに位置づけられているのか

この内、「あるモジュールがシステム全体でどこに位置づけられているのか」については、個々のモジュール単位というよりモジュールのグループ単位で記述した方がわかりやすいのでは?と思います。

  • モジュールのグループ同士の依存関係

  • モジュール単位の依存関係

これらをそれぞれ二種類の図に分けることで、巨大なモジュール構成でも全体を把握しやすくなるのではないでしょうか?

モジュールのグループ同士の依存関係図

まず、モジュールをいくつかのグループに分類し、グループ同士の関係図を描きました。グループ同士の依存関係というのはそうそう頻繁に方針が変わるものではないと思うので、draw.ioを使って手作業で作図しました。

REALITY Androidのドキュメントに描かれている図ほぼそのままです

以前のREALITYのコードはFeatureモジュールとLibraryモジュールの2種類に分類されていました。現在はここにAndroid アプリのモジュール化のガイドの内容も取り入れ、dataモジュール・coreモジュールなども加えました。

モジュール単位の依存関係図

グループ同士の依存関係がわかったら、次はモジュールごとの依存関係を図示します。100個近いモジュールの依存関係を人力で調べて作図するのは無理すぎるので、Gradleのタスクを作って自動生成させることにしました。

最近のGitHubはMarkdownの中でMermaidによる作図をサポートしているので、今回作成する図でもMermaidを利用することにしました。Mermaidで作図すれば、差分もGitで簡単に読めるので便利です。

冒頭で紹介したJakeWhartonさんのprojectDependencyGraphをスタート地点としつつ、最終的にはほとんど異なるコードに仕上がりました。

できあがったコードがこちらになります。

// /buildSrc/src/main/kotlin/ProjectDependencyGraph.kt

import java.io.File
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.ProjectDependency

private fun Project.dependencyProjects(): Sequence<Project> = sequence {
    configurations.forEach { config ->
        config.dependencies
            .withType(ProjectDependency::class.java)
            .map { it.dependencyProject }
            .filter { it != project }
            .forEach { dependency ->
                if (config.name.toLowerCase().endsWith("implementation")) {
                    yield(dependency)
                }
            }
    }
}

private val Project.mermaidId: String get() = path.replace(":", "")

class DependencyInfo(
    val project: Project,
    val dependencies: Set<Project>,
    val depended: Set<Project>,
) {
    fun toMermaid(): String {
        val all: String = sequence<String> {
            yield("  ${project.mermaidId}(\"${project.path}\")")
            dependencies.forEach {
                yield("  ${it.mermaidId}[\"${it.path}\"]")
            }
            depended.forEach {
                yield("  ${it.mermaidId}[\"${it.path}\"]")
            }
        }.joinToString(separator = "\n")

        val relations: String = sequence<String> {
            dependencies.forEach {
                yield("  ${project.mermaidId} --> ${it.mermaidId}")
            }
            depended.forEach {
                yield("  ${it.mermaidId} --> ${project.mermaidId}")
            }
        }.joinToString(separator = "\n")

        return """
```mermaid
graph TB
$all
$relations
```
"""
    }
}

/**
 * [rootProject]配下の依存関係情報を作成する。
 */
fun createDependencyInfos(rootProject: Project): Set<DependencyInfo> {

    val dependencyInfos = mutableSetOf<DependencyInfo>()

    // Project -> 依存しているProjectたち のMap
    val tempProjectDependencyMap = mutableMapOf<Project, Set<Project>>()
    rootProject.allprojects
        .forEach { project ->
            val deps = project.dependencyProjects().toSet()
            if (deps.isNotEmpty()) {
                tempProjectDependencyMap[project] = deps
            }
        }

    tempProjectDependencyMap.forEach { project, dependencies ->
        // projectへ依存しているProjectたちを探す
        val depended = tempProjectDependencyMap.entries.filter { it.value.contains(project) }.map { it.key }.toSet()
        dependencyInfos.add(
            DependencyInfo(
                project = project,
                dependencies = dependencies,
                depended = depended,
            ),
        )
    }

    return dependencyInfos
}

/**
 * projectDependencyGraph タスクを追加するPlugin。
 */
class ProjectDependencyGraph : Plugin<Project> {
    override fun apply(target: Project) {
        target.tasks.create("projectDependencyGraph") {
            doLast {
                val rootProject = target.rootProject
                val dependencyInfos = createDependencyInfos(rootProject)
                val outputFile = File(rootProject.rootDir, "docs/ModuleDependencyGraph.md")

                outputFile.printWriter().use { writer ->
                    writer.println("# ModuleDependencyGraph")

                    dependencyInfos.forEach { dependencyInfo ->
                        writer.println("## ${dependencyInfo.project.path}")
                        writer.println(dependencyInfo.toMermaid())
                    }
                }
            }
        }
    }
}

上記を buildSrc/src/main/kotlin/ProjectDependencyGraph.kt として保存した上で、プロジェクトルートのbuild.gradle.ktsに以下の行を追加します。

apply { plugin<ProjectDependencyGraph>() }

以上で準備完了です。モジュール構成図の出力をするには、以下のコマンドでGradleタスクを実行します。

./gradlew projectDependencyGraph

すると、以下のようなMarkdownのテキストファイルが生成されます。

# ModuleDependencyGraph
## :moduleA

```mermaid
graph TB
  moduleA(":moduleA")
  moduleX(":moduleX")
  moduleY(":moduleY")
  moduleZ(":moduleZ")
  moduleA --> moduleX
  moduleY --> moduleA
  moduleZ --> moduleA
```

## :moduleB
...

このMarkdownをGitHubのHTMLプレビューにかけると、以下のようなモジュール構成図がレンダリングされます。

モジュールAが、どこから依存され、どこへ依存するのかがわかる

モジュール構成図を出してみた感想

このモジュール依存持ちすぎワロタ
libraries→uiという依存方向はおかしいぞ?
みたいなのが見つかったりします

依存先が多い・依存されているのが多いこと自体が直ちに悪いことというわけではないのですが、もしかするとそのようなモジュールは責務を持ちすぎていたりするのかもしれません。今のモジュール分割が適切なのかどうかを判断する一つの目安にもなると思います。

みなさんのプロジェクトでもモジュール構成図を作ってみてください。思わぬ発見があるかもしれませんよ?

REALITYでは、機能開発だけでなく開発環境の改善に取り組みたいエンジニアも募集しております。