HackToTech

Hack To Technology

SpringBootでAutowireする際に複数のBeanがマッチした際の挙動をみたのでメモ

雰囲気で挙動を理解していたのをちゃんと見たのでメモ

spring.pleiades.io

@Nullable
public Object doResolveDependency(DependencyDescriptor descriptor, @Nullable String beanName,
        @Nullable Set<String> autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException {
        ...
        // Step 3b: direct bean matches, possibly direct beans of type Collection / Map
        Map<String, Object> matchingBeans = findAutowireCandidates(beanName, type, descriptor); // ここで型に対して一致するbeanの一覧を取得する
        ...

        // Step 4: determine single candidate
        if (matchingBeans.size() > 1) {
            autowiredBeanName = determineAutowireCandidate(matchingBeans, descriptor); // 複数のbeanが当てはまる場合はどれを使うかを決定する
            if (autowiredBeanName == null) {
                if (isRequired(descriptor) || !indicatesArrayCollectionOrMap(type)) {
                    // Raise exception if no clear match found for required injection point
                    return descriptor.resolveNotUnique(descriptor.getResolvableType(), matchingBeans);
                }
                else {
                    // In case of an optional Collection/Map, silently ignore a non-unique case:
                    // possibly it was meant to be an empty collection of multiple regular beans
                    // (before 4.3 in particular when we didn't even look for collection beans).
                    return null;
                }
            }
            instanceCandidate = matchingBeans.get(autowiredBeanName);
        }
        ...
}
protected String determineAutowireCandidate(Map<String, Object> candidates, DependencyDescriptor descriptor) {
    Class<?> requiredType = descriptor.getDependencyType();
    String primaryCandidate = determinePrimaryCandidate(candidates, requiredType); // @Primaryがないかを優先して探す
    if (primaryCandidate != null) {
        return primaryCandidate; // 見つかったら返す
    }
    String priorityCandidate = determineHighestPriorityCandidate(candidates, requiredType); // @Priorityで指定した中で最も優先度の高い(値の低い)ものを探す
    if (priorityCandidate != null) {
        return priorityCandidate; // 見つかったら返す
    }
    // Fallback: pick directly registered dependency or qualified bean name match
    for (Map.Entry<String, Object> entry : candidates.entrySet()) {
        String candidateName = entry.getKey();
        Object beanInstance = entry.getValue();
        if ((beanInstance != null && this.resolvableDependencies.containsValue(beanInstance)) ||
                matchesBeanName(candidateName, descriptor.getDependencyName())) {
            return candidateName; // Autowireする際の変数名が一致するものが見つかったら返す
        }
    }
    return null;
}

@Priority でInjectするbeanを操作しようとした際に、↓は一見 @Priority が評価されそうに見えるが、
実際は評価されず exampleAexampleB どちらを使えば良いかわからずに終了する

import jakarta.annotation.Priority
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered


interface MarkerExample
data class ExampleImpl1(val value: String): MarkerExample
data class ExampleImpl2(val value: String): MarkerExample

@Configuration
class ExampleConfig {
    @Bean
    @Priority(Ordered.LOWEST_PRECEDENCE)
    fun exampleA(): MarkerExample {
        return ExampleImpl1(
            value = "ExampleA",
        )
    }

    @Bean
    @Priority(Ordered.HIGHEST_PRECEDENCE)
    fun exampleB(): MarkerExample {
        return ExampleImpl2(
            value = "ExampleB",
        )
    }

    @Bean
    fun exampleC(example: MarkerExample): String {
        return "ExampleC"
    }
}

逆に↓は動く(Bで解決される)

import jakarta.annotation.Priority
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered


interface MarkerExample
@Priority(Ordered.LOWEST_PRECEDENCE)
data class ExampleImpl1(val value: String): MarkerExample
@Priority(Ordered.HIGHEST_PRECEDENCE)
data class ExampleImpl2(val value: String): MarkerExample

@Configuration
class ExampleConfig {
    @Bean
    fun exampleA(): MarkerExample {
        return ExampleImpl1(
            value = "ExampleA",
        )
    }

    @Bean
    fun exampleB(): MarkerExample {
        return ExampleImpl2(
            value = "ExampleB",
        )
    }

    @Bean
    fun exampleC(example: MarkerExample): String {
        return "ExampleC"
    }
}

こういったパターンはほぼ @Primary があれば足りるし使うことはないが、
思っていた挙動と違ったので意外だった

@Configurationで@Beanを宣言した際に誰がどう登録しているのかを見てたのでメモ

詳しく書いてあるところが見当たらなかったので単にコードを追った
完全に個人的なメモ

tl;dr

最終的に DefaultSingletonBeanRegistry で持ってそう spring.pleiades.io

バージョン

  • Spring Boot 3.1.5
  • Spring Framework 6.0.13

コード

data class Example(val value: String)

@Configuration
class ExampleConfig {
    @Bean
    fun exampleA(): Example {
        return Example(
            value = "ExampleA",
        )
    }
}

メモ

PGAuditのログを減らしたくてバージョンごとの挙動の違いを見てたのでメモ

github.com

  • PGAuditのissueとcommitを眺めていて、 個人的に悩みだった下記の問題が解決してそうだったのでバージョンごとの違いを確認する
    • INSERT時の外部キーの監査ログがWRITEとして出てしまう問題
      • 正直あまり意味のあるログではないので、出来る限り取りたくない
    • SELECT FOR UPDATEがWRITEのログとして出てしまう問題
      • WRITEだけログに出したいのに、キューとして使用しているテーブルのポーリングログ(SELECT FOR UPDATE)が出て邪魔になったりしている

検証結果

  • PostgreSQLの13以降を使用していて、且つ pgaudit.logall とか read じゃなければ、
    オブジェクト監査ログで SELECT を取得しているテーブル以外は、これらのログを取得しなくても済むようになりそう

以下は、それぞれ検証したPostgreSQLのバージョン(PGAuditのバージョン)

12.17(1.4.3)

  • INSERTの外部キー監査ログ
2023-11-28 16:38:34.149 UTC [75] LOG:  AUDIT: SESSION,2,1,WRITE,INSERT,TABLE,public.ref,"INSERT INTO ref values(1, 'insert');",<none>
2023-11-28 16:38:34.150 UTC [75] LOG:  AUDIT: SESSION,2,2,WRITE,UPDATE,TABLE,public.main,"SELECT 1 FROM ONLY ""public"".""main"" x WHERE ""id"" OPERATOR(pg_catalog.=) $1 FOR KEY SHARE OF x",1
  • SELECT FOR UPDATEのログ
2023-11-28 16:42:18.902 UTC [75] LOG:  AUDIT: SESSION,5,1,MISC,BEGIN,,,BEGIN;,<none>
2023-11-28 16:42:22.407 UTC [75] LOG:  AUDIT: SESSION,6,1,WRITE,UPDATE,TABLE,public.ref,SELECT * FROM ref WHERE main_id = 1 FOR UPDATE;,<none>
2023-11-28 16:42:35.654 UTC [75] LOG:  AUDIT: SESSION,7,1,WRITE,UPDATE,TABLE,public.ref,UPDATE ref SET memo = 'update' WHERE main_id = 1;,<none>
2023-11-28 16:42:39.230 UTC [75] LOG:  AUDIT: SESSION,8,1,MISC,COMMIT,,,COMMIT;,<none>

13.13(1.5.2)

  • INSERTの外部キー監査ログ
2023-11-28 17:01:04.890 UTC [42] LOG:  AUDIT: SESSION,4,1,WRITE,INSERT,TABLE,public.ref,"INSERT INTO ref values(1, 'insert');",<none>
2023-11-28 17:01:04.890 UTC [42] LOG:  AUDIT: SESSION,4,2,READ,SELECT,TABLE,public.main,"SELECT 1 FROM ONLY ""public"".""main"" x WHERE ""id"" OPERATOR(pg_catalog.=) $1 FOR KEY SHARE OF x",1
  • SELECT FOR UPDATEのログ
2023-11-28 17:02:49.548 UTC [42] LOG:  AUDIT: SESSION,5,1,MISC,BEGIN,,,BEGIN;,<none>
2023-11-28 17:02:49.548 UTC [42] LOG:  AUDIT: SESSION,6,1,READ,SELECT,TABLE,public.ref,SELECT * FROM ref WHERE main_id = 1 FOR UPDATE;,<none>
2023-11-28 17:02:49.549 UTC [42] LOG:  AUDIT: SESSION,7,1,WRITE,UPDATE,TABLE,public.ref,UPDATE ref SET memo = 'update' WHERE main_id = 1;,<none>
2023-11-28 17:02:49.549 UTC [42] LOG:  AUDIT: SESSION,8,1,MISC,COMMIT,,,COMMIT;,<none>

14.10(1.6.2)

  • INSERTの外部キー監査ログ
2023-11-28 17:13:31.837 UTC [41] LOG:  AUDIT: SESSION,4,1,WRITE,INSERT,TABLE,public.ref,"INSERT INTO ref values(1, 'insert');",<none>
2023-11-28 17:13:31.838 UTC [41] LOG:  AUDIT: SESSION,4,2,READ,SELECT,TABLE,public.main,"SELECT 1 FROM ONLY ""public"".""main"" x WHERE ""id"" OPERATOR(pg_catalog.=) $1 FOR KEY SHARE OF x",1
  • SELECT FOR UPDATEのログ
2023-11-28 17:13:57.347 UTC [41] LOG:  AUDIT: SESSION,5,1,MISC,BEGIN,,,BEGIN;,<none>
2023-11-28 17:13:57.347 UTC [41] LOG:  AUDIT: SESSION,6,1,READ,SELECT,TABLE,public.ref,SELECT * FROM ref WHERE main_id = 1 FOR UPDATE;,<none>
2023-11-28 17:13:57.347 UTC [41] LOG:  AUDIT: SESSION,7,1,WRITE,UPDATE,TABLE,public.ref,UPDATE ref SET memo = 'update' WHERE main_id = 1;,<none>
2023-11-28 17:13:57.347 UTC [41] LOG:  AUDIT: SESSION,8,1,MISC,COMMIT,,,COMMIT;,<none>

検証環境構築

バージョン違うだけでほぼ同じなので12のパターンだけ

docker run --name pg12.17 -e POSTGRES_PASSWORD=password -d postgres:12.17
docker exec -it pg12.17 /bin/bash

# PGAuditのbuildに必要なものとかを入れる
apt update &&\
apt install -y build-essential curl libssl-dev libkrb5-dev postgresql-server-dev-12

cd tmp &&\
curl -L https://github.com/pgaudit/pgaudit/archive/refs/tags/1.4.3.tar.gz | tar xz --strip 1 &&\
make install USE_PGXS=1
  • shared_preload_libraries を設定する
psql -Upostgres
ALTER SYSTEM SET shared_preload_libraries TO 'pgaudit';
  • 読み込ませる為にコンテナの再起動をする
docker restart pg12.17
  • 再起動後に、PGAuditの設定をする
CREATE EXTENSION pgaudit;
CREATE ROLE auditor;
ALTER SYSTEM SET pgaudit.role = 'auditor';
ALTER SYSTEM SET pgaudit.log = 'all';
ALTER SYSTEM SET pgaudit.log_catalog = off;
ALTER SYSTEM SET pgaudit.log_level = 'log';
ALTER SYSTEM SET pgaudit.log_parameter = on;
ALTER SYSTEM SET pgaudit.log_relation = on;
ALTER SYSTEM SET pgaudit.log_statement_once = on;
SELECT pg_reload_conf();
  • SQLを流してログを確かめる
CREATE TABLE main (
   id SERIAL PRIMARY KEY
);

CREATE TABLE ref (
   main_id SERIAL REFERENCES main,
   memo    TEXT
);

INSERT INTO main values(1);
INSERT INTO ref values(1, 'insert');

BEGIN;
SELECT * FROM ref WHERE main_id = 1 FOR UPDATE;
UPDATE ref SET memo = 'update' WHERE main_id = 1;
COMMIT;

PGAuditのPR

SpringBoot + KotlinでListの要素にConstraintsをかけたかった話

tl;dr コンパイラオプションに -Xemit-jvm-type-annotationsを指定する必要がある
あとの文章は個人的なメモ

リストの要素にConstraintsをかけたかったが、
なんか一見正しく見えるのに正しく動かなかった

package com.example.exampleboot3

import org.hibernate.validator.constraints.Length

data class Request(val values: List<@Length(min = 1) String>)
package com.example.exampleboot3

import jakarta.validation.Valid
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController

@RestController("/")
class SampleController {
    @PostMapping
    fun post(@RequestBody @Valid r: Request): String {
        return "ok"
    }
}

そのまま投げると、通ってしまう

curl -XPOST localhost:8080 -H "Content-Type: application/json" -d '{"values": ["test", ""] }'
ok 

でまあ、公式を見て -Xemit-jvm-type-annotations をコンパイルオプションに付与すればいいという話だった

kotlinlang.org

# build.gradle.kts
tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf(
            "-Xjsr305=strict",
            "-Xemit-jvm-type-annotations",
        )
        jvmTarget = "17"
    }
}

で、期待した通りに動くようにはなった

curl -XPOST localhost:8080 -H "Content-Type: application/json" -d '{"values": ["test", ""] }'
{"timestamp":"2023-10-28T17:51:52.475+00:00","status":400,"error":"Bad Request","path":"/"}

で、なんでそもそもJava8以降をターゲットとしてコンパイルする必要があるんだっけと思ったら

JSR 308 Explained: Java Type Annotations

JSR 308, Annotations on Java Types, has been incorporated as part of Java SE 8. This JSR builds upon the existing annotation framework, allowing type annotations to become part of the language. Beginning in Java SE 8, annotations can be applied to types in addition to all of their existing uses within Java declarations. This means annotations can now be applied anywhere a type is specified, including during class instance creation, type casting, the implementation of interfaces, and the specification of throws clauses. This allows developers to apply the benefits of annotations in even more places.

そもそもJSR308がJava8からの話だった(この辺よく理解してなかった)
1.8 よりも前を指定したらコンパイルコケるのか試したかったが、 1.6 指定したら Unknown Kotlin JVM target: 1.6 とか言われたので、古い環境用意するのも面倒になって諦めた

最近の環境ならデフォルトで有効でも良いんじゃないか?と思ってissueを眺めてたが、
基本的な使い方をする分には困らないけど、反変/共変での射影とかまだ色々と問題を抱えているから各自で有効にしてくれという話だと理解した https://youtrack.jetbrains.com/issue/KT-35843

There are a bunch of open questions that should be carefully discussed and full support of type annotations in JVM bytecode can't be fully implemented without discussing them.

でまあ一応中身の違いがどうなっているのかも気になったので、オプションの無効/有効で生成したclassファイルもみてみた

無効

public com.example.exampleboot3.Request(java.util.List<java.lang.String>);
descriptor: (Ljava/util/List;)V
flags: (0x0001) ACC_PUBLIC
Code:
  stack=2, locals=2, args_size=2
     0: aload_1
     1: ldc           #10                 // String values
     3: invokestatic  #16                 // Method kotlin/jvm/internal/Intrinsics.checkNotNullParameter:(Ljava/lang/Object;Ljava/lang/String;)V
     6: aload_0
     7: invokespecial #19                 // Method java/lang/Object."<init>":()V
    10: aload_0
    11: aload_1
    12: putfield      #22                 // Field values:Ljava/util/List;
    15: return
  LineNumberTable:
    line 5: 6
  LocalVariableTable:
    Start  Length  Slot  Name   Signature
        0      16     0  this   Lcom/example/exampleboot3/Request;
        0      16     1 values   Ljava/util/List;
Signature: #7                           // (Ljava/util/List<Ljava/lang/String;>;)V
RuntimeInvisibleParameterAnnotations:
  parameter 0:
    0: #9()
      org.jetbrains.annotations.NotNull
MethodParameters:
  Name                           Flags
  values

有効

public com.example.exampleboot3.Request(java.util.List<java.lang.String>);
descriptor: (Ljava/util/List;)V
flags: (0x0001) ACC_PUBLIC
Code:
  stack=2, locals=2, args_size=2
     0: aload_1
     1: ldc           #13                 // String values
     3: invokestatic  #19                 // Method kotlin/jvm/internal/Intrinsics.checkNotNullParameter:(Ljava/lang/Object;Ljava/lang/String;)V
     6: aload_0
     7: invokespecial #22                 // Method java/lang/Object."<init>":()V
    10: aload_0
    11: aload_1
    12: putfield      #25                 // Field values:Ljava/util/List;
    15: return
  LineNumberTable:
    line 5: 6
  LocalVariableTable:
    Start  Length  Slot  Name   Signature
        0      16     0  this   Lcom/example/exampleboot3/Request;
        0      16     1 values   Ljava/util/List;
Signature: #7                           // (Ljava/util/List<Ljava/lang/String;>;)V
RuntimeVisibleTypeAnnotations:
  0: #9(#10=I#11): METHOD_FORMAL_PARAMETER, param_index=0, location=[TYPE_ARGUMENT(0)]
    org.hibernate.validator.constraints.Length(
      min=1
    )
RuntimeInvisibleParameterAnnotations:
  parameter 0:
    0: #12()
      org.jetbrains.annotations.NotNull
MethodParameters:
  Name                           Flags
  values

RuntimeVisibleTypeAnnotations を見れば分かる通り、オプションによって有無の違いが見て取れる

docs.oracle.com

-Xmsと-Xmxを指定しても必ずしもすぐに物理メモリ上で確保されるわけではないという話

最初から確保されるものだと思い込んでいたら、
そういうわけではなかったので自分用に備忘録

理由はStackOverflowで解説されているので、それを読むのが良い stackoverflow.com

どうやらAlwaysPreTouchを指定すると(その分起動は遅くなるが)、
JVMの初期化時に強制的にマッピングされるらしいのでSpringのdemoアプリケーションで試した

AlwaysPreTouchあり

./gradlew bootRun -PjvmArgs="-Xms2g -Xmx2g -XX:+AlwaysPreTouch"

> Task :bootRun

2023-09-24 01:03:59.352  INFO 8070 --- [           main] com.example.demo.DemoApplicationKt       : Starting DemoApplicationKt using Java 11.0.20 on local with PID 8070 (/home/atr0phy/IdeaProjects/demo/build/classes/kotlin/main started by atr0phy in /home/atr0phy/IdeaProjects/demo)
<==========---> 83% EXECUTING [1m 33s]

AlwaysPreTouchなし

./gradlew bootRun -PjvmArgs="-Xms2g -Xmx2g"

> Task :bootRun

2023-09-24 01:05:51.778  INFO 8208 --- [           main] com.example.demo.DemoApplicationKt       : Starting DemoApplicationKt using Java 11.0.20 on local with PID 8208 (/home/atr0phy/IdeaProjects/demo/build/classes/kotlin/main started by atr0phy in /home/atr0phy/IdeaProjects/demo)
<==========---> 83% EXECUTING [3m 32s]

違いはRESを見れば分かる通り、2.2GBと284MBになっている

個人的にはXmsとXmxを同じ値で指定することが多いが、一応↓見た感じXmsのサイズが確保されるらしいとのことだったので、試した stackoverflow.com

一長一短あるので常に指定するのが良いとは限らないが、
起動後に少ししてから大してメモリをまだ使ってないにも関わらず、
GCがはしってCPUの使用率が高くなってレイテンシが悪化する(OS上のメモリの使用量がXmsに向けて増加しつつあったので恐らく裏側でページフォールトしてる)、みたいなことが解消できたので個人的には助かった