要旨
- 一つのアプリケーションに一つのglobalなadviceにすべき
- 画面とweb-apiとをそれぞれ個別のアプリケーションに分離するとか、出来なければcontrollerにlocalの
ExceptionHandlerで完結させてglobalのadiviceは一つにするとか、adviceのスコープを可能な限り限定するのが望ましい
- 画面とweb-apiとをそれぞれ個別のアプリケーションに分離するとか、出来なければcontrollerにlocalの
- 複数のadviceが避けられない場合はその仕組みを十分に理解してselectorを注意深く使うべき
- 詳細は以下に述べるが、springの知識を基にすれば挙動は納得できるものの、意外とややこしい挙動をする。長期運用を想定するならこういうのは避けるべきだろう。
ソースコードと解説
plugins {
id 'java'
id 'org.springframework.boot' version '4.1.0'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
複数adviceの回避案
まずは複数adviceの回避あるいはトラブルが発生しにくい設定方法について考える。
各controllerにlocalのExceptionHandler
グローバルな例外処理とは別に一部のcontrollerにだけ共通の例外処理を設定したい場合はそのcontrollerにlocalの@ExceptionHandlerを設ける。これは@ControllerAdviceよりも前に適用される。
Global @ExceptionHandler methods, from an @ControllerAdvice, are applied after local ones, from the @Controller.
https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-advice.html
@RestController public class ApiController { @ExceptionHandler(RuntimeException.class) public ResponseEntity<ProblemDetail> exception() { return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR));
例えば、大半が@Controllerの画面だが補助的にajaxとして使用する@RestControllerが一部存在する、等の場合に有効と考えられる。ただ、この例だとlocalの@ExceptionHandlerで捕捉出来ないと@Controller用のadviceに飛んでweb-apiなのにtext/htmlを返す、という事態になりかねない点には注意が必要である。
adviceのselector
@ControllerAdvice, @RestControllerAdviceにはその適用範囲を限定する属性がある。@ControllerAdviceのjavadocではselectorと呼称している。
Use selectors such as annotations(), basePackageClasses(), and basePackages() (or its alias value()) to define a more narrow subset of targeted controllers.
basePackageClassesやbasePackagesのpackage
そのadviceが適用されるpackageを指定する。
例えば、@Contollerと@RestControllerがそれぞれ異なるpackageに配置してあり、それぞれに異なるadviceを指定したい場合の例は以下となる。複数のadviceが相互排他になるよう指定したい場合にはこれが有効と思われる。
@ControllerAdvice(basePackageClasses = WebController.class) public class SampleControllerAdvice {
@RestControllerAdvice(basePackageClasses = ApiController.class) public class SampleRestControllerAdvice {
annotations
例えば、下記のような複合アノテーションと@ControllerAdviceのannotations属性の使い方が考えられる。
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @RestController public @interface MyApi2 {}
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @Controller public @interface MyWeb2 {}
@ControllerAdvice(annotations = MyWeb2.class) public class SampleControllerAdvice {
@RestControllerAdvice(annotations = MyApi2.class) public class SampleRestControllerAdvice {
うまくいかないケース
ここからは複数adviceが一見意図した挙動になりそうだけど意図しない挙動になるパターンについて。
annotationsだけではControllerとRestControllerの異なるadviceは上手くいかない
以下のように複数adviceそれぞれのannotationsに@Controllerと@RestControllerを指定すればそれぞれwebとapi専用のadviceを設定可能に見えるがこれは上手くいかない事がある。自分の環境だと両方ともSampleControllerAdviceに行ってしまう。つまり、web-apiなのにtext/htmlを返してしまう。
@ControllerAdvice(annotations = Controller.class) public class SampleControllerAdvice {
@RestControllerAdvice(annotations = RestController.class) public class SampleRestControllerAdvice {
そこで、以下のように@Orderで SampleRestControllerAdvice -> SampleControllerAdvice の順にすると意図通りの挙動になる。
@ControllerAdvice(annotations = Controller.class) @Order(20) public class SampleControllerAdvice {
@RestControllerAdvice(annotations = RestController.class) @Order(10) public class SampleRestControllerAdvice {
このように、複数adviceはmanged-beanの順序によっては偶然意図通りの動作に見えてしまう事がありうる。
原因の解説
一言で言えば、@RestControllerAdviceは@ControllerAdviceであり、内部的にはadvice単位で管理され、複数存在する場合は@Order等で評価の順序が決まるため。
内部的にはどっちもControllerAdvice
まず、以下の通り@RestControllerAdviceは@ControllerAdvice + @ResponseBodyの複合アノテーションである。
// (一部省略) @ControllerAdvice @ResponseBody public @interface RestControllerAdvice {
このため、@ControllerAdviceと@RestControllerAdviceはどちらも内部的には@ControllerAdviceとして扱われる。なので、@RestControllerで発生した例外が意図に反して@ControllerAdviceに行ってしまった、というのは仕様通りの設定ミスな事が多い、と思われる。ただ、一見は上手くいくように見えるても次に述べる要因により偶然意図通り動作してしまう場合があるので注意が必要。
複数のControllerAdviceの順序は実装依存
次に、advice内部をものすごく簡略化すると Map<@ControllerAdvice, @ExceptionHandlerのメソッド> で保持しており、複数adviceが存在する場合は@Order等で評価の順序が決まるのは他のmanaged-beanと同様。
なので、複数の@ControllerAdviceが存在し、かつ、@Order等の明示的な順序が無い場合はadviceの評価順序は実装依存となる。なので、運が良ければ意図通りの順序のadviceになるが、何らかの事情で順序が変わると突然挙動が変わりうる。まあ実際には早々内部実装が変わる事は無いので発覚しない事も多そうではある。
selectorのannotationsは再帰的
annotationsのselectorは内部的にAnnotationUtils.findAnnotationを使用して再帰的に探索するので記述の以下の例は上手くいかない事がある。
@ControllerAdvice(annotations = Controller.class) public class SampleControllerAdvice {
@RestControllerAdvice(annotations = RestController.class) public class SampleRestControllerAdvice {
annotations = Controller.classは@Controllerにも@RestControllerにもマッチするのに対してannotations = RestController.classは@RestControllerにだけマッチする。前述の通り@Orderを付与すれば意図通りの振る舞いになる事はなる。まぁでも直感的にはなんで??? って感じなので自分ならやらない。
感想
可能な限り複数adviceは避けるべき。