フレームワークを作りながら、ServiceLoaderとアノテーションプロセッサーを使ってみました


## はじめに
現在、ドメインごとに依存関係を分離し、命名規則を強力に検査するDIフレームワークを作成しています。このフレームワークを開発する中で、`ServiceLoader` と `Annotation Processor` を使用してみました。そこで、これらが何であるか、そしてどのように使用したかについて書いてみようと思います。


- 韓国人として、日本語とコンピュータの勉強を同時に行うために、ここに文章を書いています
- 翻訳ツールの助けを借りて書いた文章なので、誤りがあるかもしれません

## 状況
現在、私は命名規則を厳格に管理しています。

クラス名がルートドメインではなく、サブドメインに属する場合、必ず守るべき命名規則を定義しました。

基本的には以下のような命名規則が適用されます。

```java
"Service", "Repository", "Controller", "Handler", "Manager"
```

これにより、次のように記述できます。

```java
 import org.haroya.annotation.Container;

@Container(domain = "login")
public class LoginController {}
```
ルートドメインとして `login` を登録した場合、コンパイル時にクラスの命名をチェックし、

```java
LoginService
```
`login` がドメインとして登録され、`Service` が命名規則に登録されている場合は通過し、

```java
LoginUtil
```

`login` がドメインとして登録されていても、`Util` が命名規則に登録されていない場合はコンパイルエラーが発生するようにしたいと考えています。

しかし、以下のように

```java
"Service", "Repository", "Controller", "Handler", "Manager"
```
このように基本的な命名規則だけを適用すると、開発者の自主性を過度に制限し、拡張性を妨げ、フレームワークにドメインが侵入的になる可能性があると考えました。

:::note info
なぜランタイムでこの検査を行わないのか。
1. 物理的に依存関係を分離したくありませんでした。（リソースの無駄、できるだけ軽量にしたい）
2. 命名規則はコンパイル時にチェックするのが適切だと考えました
3. アノテーションベースで動作すると、ユーザーがフローを追いにくくなるため、コンパイル時に検査して依存を避けたいと考えました
:::



## 要件
- 基本的な命名規則を提供する
- ユーザーが、基本命名規則に加えて独自の命名規則を追加または削除できるようにする
- すべてはコンパイル時に行われ、命名規則に違反したクラスはコンパイルエラーを発生させる

## ServiceLoader

基本的にJavaのすべてのオブジェクトは実装に基づいて生成されます。

例を挙げると次のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/3a41b09c-0a12-605b-f2ad-7c0eca4e550b.png)
次のようにインターフェースを定義し、 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/070ff5a6-3df2-784f-6445-1bd93ec3c41c.png)

犬と牛のオブジェクトを鳴き声を含む形で再定義しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/5561260a-37bd-1618-5668-260a07f0b0e8.png)
両方のオブジェクトで実装を定義し、

```java
Animal aniaml = new 動物;
```
このように定義した結果、それぞれが正しく鳴き声を出すことを確認できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/c93e688d-8e68-f528-8a54-c6f0583035d2.png)

しかし、次のように定義してみてはどうだろうか。

```java
Animal animal = new Animal();
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/9291d939-a495-2688-72e8-c9b13f8ce637.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/33a2c4cc-7e90-2d92-b2e8-3a43d466d493.png)

しかし、次のように定義したらどうでしょうか？

このように、コンパイルエラーが発生することがわかります。  
なぜなら、Javaではコンパイル時に具体的な実装でインスタンス（`new`）を生成しない場合、  
コンパイルエラーが発生するためです。

これをランタイムで決定するようにするために、`ServiceLoader` を使用することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/ee181507-05a9-d866-0ca9-2eeda1fc27e1.png)
次のように`resources/META_INF/services/{パッケージ名}` に実装クラスのパスを指定しておくと、

`ServiceLoader` を通じてランタイムでどの実装クラスを使用するかを動的にロードすることができます。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/64f4c823-d15b-e1a8-2129-5dbd1144831d.png)


次のように LazyLoading 方式で `Cow` インスタンスがロードされることを確認できます。 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/4a92bbb8-0fe5-c505-3f52-849e706d7649.png)
その後、同様に Dog をロードします。

:::note info
ServiceLoader はインスタンスの生成を LazyLoading で行います。（情報のみを読み込み、実装クラスは必要な時点でロードします）
:::

しかし、私がまとめた必須条件には以下のような条件がありました。

- すべてはコンパイル時に行われ、命名規則に違反したクラスはコンパイルエラーを発生させること

これはドメインの分離を行うためのものであり、もしランタイムでロードされると、  
開発者がエラーに気づくのが難しくなります。

また、この規則がドメインの領域にまで侵入することも望んでいませんでした（DDDの観点から）。

命名や規則がコード領域やランタイム領域に侵入しないようにするためには、  
`コンパイル条件` という条件を満たす必要がありました。

https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/ServiceLoader.html

## アノテーションプロセッサの使用

コンパイル時に処理を行うため、アノテーションプロセッサを使用しました。

`resources/META-INF/javax.annotation.processing.Processor`

上記のように定義すると、Processorとして登録され、`javac` のコンパイル時にフックとして呼び出されます。

これは `round` プロセスを経て、前のプロセスでロードされたすべてのオブジェクトを巡回し、コンパイル時に処理を行います。

> 詳細な `javac` コンパイルプロセスについては、以下の記事を参考にしてください。

https://qiita.com/haroya01/items/b0355e1ebbdd666524f3


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/375109f5-fb80-25c6-b342-e5a40750ef29.png)

上記のパスのようにフックするクラスをマッピングし、


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/55eabff0-f5d9-e774-5945-56c44866a591.png)

次のようにインターフェースを構成しました。

- `initDomainTypeNames()`：命名規則に追加できます
- `removeDomainTypeNames()`：命名規則から削除できます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/678d2aa3-92bf-6806-307c-52b7e8458506.png)

次のように基本提供のコンベンションを定義し、 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/224c6e8e-fa72-f695-cf07-617aa0b0c7db.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/e16d613d-2171-ab09-9572-1e04062cfe32.png)

次のように `ConventionLoaderManager` を作成し、順番にローダーを呼び出すようにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/9f924be2-f36b-675e-c4e0-ab52e130b14c.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/109698c1-b8e6-9ea9-be33-1ef4dc91f3f6.png)

次のように YAML でコンベンションを作成することもできます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/4deebe2d-6c2d-e6cf-d147-ccf7ff3f4d1e.png)

次のように、サービスローダーを使用して、ユーザーのJavaコードでもコンパイル時に検査できるようにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/f6f3cb4c-9e71-ce37-f697-de410f2c8526.png)

これらすべては、アノテーションプロセッサである `DomainNamingConventionProcessor` がコンパイル時に実行します。

```java
@SupportedAnnotationTypes("org.haroya.annotation.Container")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class DomainNamingConventionProcessor extends AbstractProcessor {
```

:::note info
@SupportedAnnotationTypes：使用するアノテーションを指定します。
@SupportedSourceVersion：対応するJavaのバージョンを定義します。
:::

https://docs.oracle.com/en/java/javase/17/docs/api/java.compiler/javax/annotation/processing/package-summary.html

## Testコードで検証しよう
- 基本的な命名規則を提供する
- ユーザーが基本命名規則に加え、独自の命名規則を追加または削除できるようにする
- すべてはコンパイル時に行われ、命名規則に違反したクラスはコンパイルエラーを発生させる

この3つの規則を検証するためのTestコードを作成しますが、`JAVAC` コンパイラーエラーをどのようにテストするかがわからず調べてみました。

```
testImplementation 'com.google.testing.compile:compile-testing:0.21.0'
```
このように、Googleが提供するライブラリを使用してコンパイルテストを行えば、より簡単にテストできるようです。

- 基本的な命名規則を検査（テスト中の例）
```java
    @Test
    @DisplayName("無効なサフィックスのテスト")
        void invalidSuffixTest() {
            JavaFileObject invalidClass = JavaFileObjects.forSourceString(
                    "test.UserInvalid",
                    """
                            package test;
                            import org.haroya.annotation.Container;
                            @Container
                            public class UserInvalid {}
                            """
            );

            Compilation compilation =
                    Compiler.javac()
                            .withProcessors(new DomainNamingConventionProcessor())
                            .compile(invalidClass);

            CompilationSubject.assertThat(compilation)
                    .hadErrorContaining("must end with one of these suffixes");
        }
```

次のように `JavaFileObjects` と `Compilation` を使用して、`javac` コンパイルを簡単にテストできました。

```java
@Test
@DisplayName("YAML ベースの命名規則の追加が成功する")
void yamlConventionLoadTest() throws IOException {
    // テスト用の YAML ファイルを src/test/resources に作成
    Path resourcePath = Paths.get("src/test/resources/haroya.yml");
    String yamlContent = """
        naming:
          conventions:
            include:
              - Wow
        """;
    Files.write(resourcePath, yamlContent.getBytes());

    JavaFileObject domainClass = JavaFileObjects.forSourceString(
            "test.UserController",
            """
                    package test;
                    import org.haroya.annotation.Container;
                    @Container(domain = "user")
                    public class UserController {}
                    """
    );

    JavaFileObject testClass = JavaFileObjects.forSourceString(
            "test.UserWow",
            """
                    package test;
                    import org.haroya.annotation.Container;
                    @Container
                    public class UserWow {}
                    """
    );

    Compilation compilation =
            Compiler.javac()
                    .withProcessors(new DomainNamingConventionProcessor())
                    .compile(domainClass, testClass);

    CompilationSubject.assertThat(compilation).succeeded();
}

```
次のように `Wow` コンベンションも正常に登録できました。

## リファレンス
https://travisdazell.blogspot.com/2012/10/writing-annotation-based-processor-in.html