SemanticDB を利用して、ソースコードから依存関係を可視化する

こんにちは、KAJIです。

本記事では、SemanticDBを利用してソースコードよりシステムの依存関係を把握する方法を紹介します。

背景 | それはどこにでもよくある、開発の現場での困りごと

プロダクト開発では、機能追加しリリースした後はフィードバックを元に改善したりなど、今あるものをエンハンスしていくことが求められます。

チームのアウトプットを最大化するためにも、機能の目的や設計内容、およびその意図などをチームの皆と共有できるよう、 プロダクトの開発と合わせてドキュメンテーションが重要になります。 言わずもがなですが、ドキュメントがないシステムは理解が困難であり、 何がどう動いているのか把握するためにソースコードを読み漁る必要があり、非常に効率が悪いです。

ドキュメントといっても、いろいろなものがありますが、ここでは以下のようなパターンに分類します。

  • システムが提供する機能の背景や目的など、WHYを説明するもの
  • システムが何を提供するか、WHATを説明するもの。例えばAPI I/F仕様書やモデル定義書など
  • システムがどのように振る舞うかHOWを説明するもの

1つ目は、これがないと我々の立ち位置が迷子になってしまします。世の中には口伝の現場もあったりしますが。

2つ目のドキュメントは頻繁に更新されるものなので、そのサイクル含めて仕組み化することが重要になります。 OpenAPITypeSpecなどを活用すれば、 一部を機械化でき、ある程度の手間を省くことができます。 2つ目のドキュメントは開発する前に設計を整理するために必要なものであり、開発してから作るものではありません。

この仕組み化が整備できていない場合、ドキュメントの正確性の維持が難しくなり、 開発効率や品質に影響を及ぼすことがあります。

3つ目については、現場によって扱い方が大きく変わってくるものです。 また、このタイプは作成するのに時間がかかる一方で、システムとの乖離が発生しやすいものもあり、扱い方が難しいものでもあります。

3つ目のドキュメントは、システムの課題発生時の影響範囲把握やトラブル発生時の原因特定に役立ちます。 日常的には参照頻度が低いかもしれませんが、いざという時に備えて整備しておくと安心です。

その中のひとつとして、コンポーネント間の依存関係を整理したドキュメントがあります。 この、依存関係をドキュメントとして整理するのは中々大変です。手間のかかる作業は機械化が有効です。

そこで、今回のテーマであるSemanticDBについて紹介したいと思います。

SemanticDB とは

scalameta.org

SemanticDBは、Scalaソースコードを解析し、クラスやメソッド、変数の定義や参照関係などの情報を機械可読な形式で出力するツールです。 例えばScalafixはSemanticDBを利用していたりします。

実際のプログラムに対してSemanticDBを利用すると何が得られるのかを説明したほうがわかりやすいので、以降で実例含めて記載していきます。

サンプルプログラム

依存関係の可視化例として、以下のソースコードを用意しました。 このサンプルでは、Controllerがどのようなクラスやメソッドを参照しているかをSemanticDBで可視化します。

  • app/todoapp/presentation/controller/ToDoTaskController.scala
  package todoapp.presentation.controller

  import play.api.mvc.{AbstractController, Action, AnyContent, ControllerComponents}
  import play.api.libs.json.*
  import todoapp.application.ToDoTaskApplication
  import todoapp.domain.model.value.Id
  import todoapp.domain.repository.entity.UserEntity
  import todoapp.presentation.controller.request.CreateTaskRequest
  import todoapp.presentation.controller.response.FetchTasksResponse

  import javax.inject.{Inject, Singleton}
  import scala.concurrent.{ExecutionContext, Future}

  @Singleton
  class ToDoTaskController @Inject()(
    cc: ControllerComponents,
    application: ToDoTaskApplication
  )(implicit ec: ExecutionContext) extends AbstractController(cc) {

    def list(filterUserIds: Seq[String] = Seq()): Action[AnyContent] = Action.async { _ =>
      val userIds = filterUserIds.map(id => Id.of[UserEntity](id))
      val tasks = application.fetchTasks(userIds)

      val response = FetchTasksResponse.toResponse(tasks)
      Future.successful(Ok(Json.toJson(response)))
    }

    def create(): Action[CreateTaskRequest] = Action.async(parse.json[CreateTaskRequest]) { request =>
      val command = request.body.toCommand(Id.of[UserEntity]("..."))
      val task = application.createTask(command)

      Future.successful(Created)
    }
  }

SemanticDB の設定

まずは、対象のプロジェクトでSemanticDBを利用できるようにします。 build.sbtに以下の設定を追加してください。

scalaVersion := "3.3.6"

lazy val root = (project in file("."))
  .enablePlugins(PlayScala)
  .settings(semanticdbEnabled := true)    // この定義を追加

sbtファイルを再読込し、コンパイルするとtargetディレクトリ配下に.semanticdbファイルが出力されているはずです。 上記で記載したサンプルの場合だと、以下のようにtarget/scala-x.x.x/meta/META-INF/semanticdb配下に ソースファイルのディレクトリ構成と同じディレクトリ構成で.semanticdbファイルが出力されます。

(PROJECT_ROOT)
+- app/
|   +- todoapp/
|       +- (中略)
|       +- presentation/
|           +- controller/
|               +- ToDoTaskController.scala
+- conf/
+- ...
+- target/
|   +- scala-3.3.6/
|   |   +- classes/
|   |   +- meta/
|   |       +- META-INF/
|   |           +- semanticdb/
|   |               +- app/
|   |                   +- todoapp/
|   |                       +- (中略)
|   |                       +- presentation/
|   |                           +- controller/
|   |                               +- ToDoTaskController.scala.semanticdb
...

この.semanticdbファイルに、ソースコードを解析した内容が保存されています。 .semanticdbファイルを読み込む方法として、公式ページではprotocやmetapが紹介されています。 ですが、構造を分析する際はプログラムから直接扱うほうが制御しやすいので、 ここではライブラリの依存関係を追加して.semanticdbファイルを直接扱えるようにします。

ただし、現時点(2025年7月現在)では、SemanticDBファイルを直接解析できるライブラリはScala2系のみ対応しています。 そのため、Scala3プロジェクトのSemanticDBを解析する場合は、別途Scala2.13のプロジェクトを用意する必要があります。

build.sbtに以下の依存関係を追加します。

libraryDependencies += "org.scalameta" %% "semanticdb-shared" % "4.13.8"

解析用プロジェクトにて、以下のコードを実装してください。

import java.nio.file.Files
import scala.meta.internal.semanticdb.TextDocuments

object SemanticParser {

  def main(args: Array[String]): Unit = {
    val file = new java.io.File("path/to/your/file.semanticdb")

    val textDocuments: TextDocuments = TextDocuments.parseFrom(Files.readAllBytes(file.toPath))

    textDocuments.documents.foreach { document =>
      // 実装されている位置順にソート
      val occurrences = document.occurrences.sortBy { occ =>
        occ.range match {
          case Some(range) => (range.startLine, range.startCharacter, range.endLine, range.endCharacter)
          case None => (Int.MaxValue, Int.MaxValue, Int.MaxValue, Int.MaxValue)
        }
      }

      occurrences.foreach{occurrence =>
        println(s"getRange : ${occurrence.getRange}, symbol : ${occurrence.symbol}, role : ${occurrence.role}")

        // ...
      }
    }
  }
}

TextDocuments型のインスタンスTextDocument型の配列documentsフィールドを保持しており、 TextDocument型は以下のフィールドを保持しています。

  • uri : 対象となるソースコードuri
  • symbols : SymbolInformation型の配列。該当クラスで定義されているフィールドやメソッドに関する情報
  • occurrences : SymbolOccurrence型の配列。該当クラスの定義や参照がどこに記載されているかの情報

今回利用するのはoccurrencesです。 occurrencesは、ソースコード上のどの位置に、フィールドやメソッド、ローカル変数の定義をしているのか、 またはどのクラスのメソッドやフィールドを参照しているのか情報を扱っています。

  • range : 実装箇所の位置 (開始行数、開始位置、終了行数、終了位置)
  • symbol : 参照している型やメソッドの完全修飾名

SemanticDB の解析結果について

上記サンプルコードの場合だと、以下のような情報がoccurrencesに含まれています (一部を抜粋しました)。

...
getRange : Range(19,6,19,10), symbol : todoapp/presentation/controller/ToDoTaskController#list()., role : DEFINITION
getRange : Range(19,11,19,24), symbol : todoapp/presentation/controller/ToDoTaskController#list().(filterUserIds), role : DEFINITION
getRange : Range(19,26,19,29), symbol : scala/package.Seq#, role : REFERENCE
getRange : Range(19,30,19,36), symbol : scala/Predef.String#, role : REFERENCE
...
getRange : Range(20,8,20,15), symbol : local1, role : DEFINITION
getRange : Range(20,18,20,31), symbol : todoapp/presentation/controller/ToDoTaskController#list().(filterUserIds), role : REFERENCE
...

Rangeの各フィールドは順に、開始行、開始位置、終了行、終了位置(含まない) を示しており、値は0はじまりです。 上記の例が何を示しているかとコメントすると以下のようになります。

...
# 20行7文字目から同じ行10文字目に メソッド `todoapp/presentation/controller/ToDoTaskController#list()` が定義されている
getRange : Range(19,6,19,10), symbol : todoapp/presentation/controller/ToDoTaskController#list()., role : DEFINITION

# 20行目12文字目から同じ行24文字目に メソッド `todoapp/presentation/controller/ToDoTaskController#list()` の引数 `filterUserIds` が定義されている
getRange : Range(19,11,19,24), symbol : todoapp/presentation/controller/ToDoTaskController#list().(filterUserIds), role : DEFINITION

# 20行目27文字目から同じ行29文字目で `scala/package.Seq` を参照している
getRange : Range(19,26,19,29), symbol : scala/package.Seq#, role : REFERENCE

# 20行目31文字から同じ行36文字目で `scala/Predef.String` を参照している
getRange : Range(19,30,19,36), symbol : scala/Predef.String#, role : REFERENCE

...

# 21行目9文字目から同じ行15文字目に ローカル変数が定義されている
getRange : Range(20,8,20,15), symbol : local1, role : DEFINITION

# 21行目19文字目から同じ行31文字目で `todoapp/presentation/controller/ToDoTaskController#list()` の引数 `filterUserIds` を参照している
getRange : Range(20,18,20,31), symbol : todoapp/presentation/controller/ToDoTaskController#list().(filterUserIds), role : REFERENCE

...

この出力から、以下のような傾向が読み取れます。

  • roleがDEFINITIONとなっているものが、クラスやメソッド、変数などの定義を示し、 REFERENCEが参照を示している
  • symbolが().で終わっているものはメソッド

厳密なsymbolの内容については、TextDocumentのsymbolsフィールドが保持しているSymbolInformationより取得できるので、 これよりクラスに定義されたメソッドなのか等の情報を判断可能です。

メソッド定義と参照を関連付ける

さて、今回は以下の情報に着目します。下図は、メソッド定義と参照の関係を色分けして示したものです。

赤色で囲った箇所がDEFINITIONとして取得できるoccurrenceで、青色で囲った箇所がREFERENCEとして取得できるoccurrenceです。 ローカル変数やメソッド引数のDEFINITIONは読み飛ばすようにしてください。 メソッド定義はsymbolが().で終わることを判断すればよいでしょう。

すると、メソッド定義のDEFINITIONから次のDEFINITIONの間のREFERENCEは、 直前のDEFINITIONが呼び出していることがわかります。 (上記の例だと、20行目のlist()は20〜27行目までのREFERENCEを呼び出している)

これにより、「呼び出し元のメソッド -> 参照先のメソッド」の構造がプログラムから取得できるようになります。 SemanticDBの便利なところは、import文がワイルド指定されていても、厳密なクラス名が取得できることです。 更に参照先のメソッドが定義されたクラスの.semanticdbファイルを解析することで、 その先の参照先のメソッドを紐づけることができます。

これを再帰的に繰り返すことで、プログラム全体のメソッドの呼び出し構造、 つまりはメソッドレベルの依存関係がルールベースで把握できるようになります。

利用シーンについて

世の中のアプリケーションの大半は、以下のようなルールがあるはずです。

  • 外部のサービスを呼び出すコンポーネントをxxxx.adaptor.yyyyパッケージにXxxxAdaptorというクラス名で定義する
  • DBのあるテーブルを参照するのはxxxx.dao.yyyyパッケージに、テーブル名を含めたXxxxTableDaoクラスを定義する

つまり、パッケージ名やクラス名からおおよそ参照先が判断できるはずです。

APIサービスの場合、エンドポイントごとに、どのテーブルや外部サービスを呼び出しているのか、 俯瞰して把握できるようになります。 依存関係を俯瞰することで、改善点や設計上の課題を発見しやすくなります。

以下の図はあるサービスの一部を抽出したものです。

これを俯瞰することで、依存関係の濃淡を把握でき、以下のような改善点が見つかるかもしれません。

  • 大半のAPIが依存しているテーブルは、本来は共通処理で制御を担うべきなのか
  • もしくは1テーブルに複数の業務情報が混在しているから分離したほうがよいのか
  • 実は想定していなかった依存が埋め込まれていたのか

このSemanticDBの解析結果は様々な用途で活用できそうです。 例えば、ControllerクラスのSymbolInformationを活用すればopenapi.ymlの自動生成など、 ドキュメント作成の自動化にも応用できるかもしれません。

本記事ではSemanticDBを活用した依存関係の可視化について紹介しました。 今後も、より効率的な開発・運用のための活用方法を模索していきます。 ご意見やご質問があれば、ぜひお知らせください。