LoginSignup
5
0

KotlinにおけるJava検査例外の扱いとSpringの@Transactionalの罠

Last updated at Posted at 2023-12-03

Javaと同じような感覚でKotlin使えるわー、と考えていたら思わぬところでハマったので共有します

環境

  • Spring Boot 3.1.5
  • Amazon Corretto 17
  • Kotlin 1.9.10

Javaの検査例外と非検査例外

Javaには他の主要なプログラミング言語と異なり「検査例外」と呼ばれる例外が存在します。「検査例外」と「非検査例外」には以下の違いがあります。

  1. 検査例外
    • try-catchでハンドリング or throwsで呼び出し元に例外を伝播させないとコンパイルエラーになる例外
      • → 検査しないとエラーになるので「検査例外」
    • Exceptionを継承した例外のうち、非検査例外ではない例外が対象
    • 代表的なものだと、IOExceptionやClassNotFoundExceptionなどが存在
  2. 非検査例外
    • 検査例外のようにハンドリングをしなくてもコンパイルエラーにならない例外(ハンドリングすることも可能)
      • → 検査しなくてもエラーにならないので「非検査例外」
    • RuntimeExceptionを継承した例外が対象
      • RuntimeException自体はExceptionを継承している
    • 代表的なものだと、NullPointerExceptionやIllegalArgumentExceptionなどが存在
    • 「実行時例外」とも呼ばれる

以下のようなメソッドがあった場合、前者のみコンパイルエラーになります。

Java
void throwIOException() {
    throw new IOException(); // NG: 検査例外なのでtry-catch or throwsが必要
}

void throwNullPointerException() {
    throw new NullPointerException(); // OK: 非検査例外なのでコンパイルが通る
}

try-catch or throwsしてあげることでコンパイルが通るようになります。

Java
void throwIOException_trycatch() {
    try {
        throw new IOException();
    } catch (IOException e) {} // OK: catchしているからOK
}

void throwIOException_throws() throws IOException { // OK: throwsで伝播させているからOK
    throw new IOException();
}

Kotlinには検査例外が存在しない

公式にも記載されているように、Kotlinには検査例外が存在しません。

「検査例外は実際には握り潰されるケースが多く、生産性やコードの品質を下げている」というのが主張のようです。

補足としてリンクされている以下の記事も面白いため、興味がある人は読んでみてください。

Javaの例と同じようにIOExceptionが発生する可能性があるメソッドの場合でも、特に例外の発生を明示しなくてもコンパイルが通るようになっています。

Kotlin
fun throwIOException() {
    throw IOException() // OK
}

Kotlin + Spring Boot使用時の罠

さて、ここまでJavaとKotlinの検査例外について解説しましたが、ここからがハマったポイントの本題です。

KotlinとSpring Bootの関係

Javaの有名なWebフレームワークにSpring および Spring Bootが存在します。特にSpring BootはJavaのWebフレームワークのデファクトスタンダードと言っても過言ではないほどの普及率を誇っています。

そんなSpring Bootですが、Kotlinでも使用することができるため他のKotlin製フレームワークを使用せずにSpring Bootが使われるケースも多いです。1

Spring Bootにおけるトランザクション管理

Spring Bootでは@Transactionalを付与するだけでトランザクション管理ができます。

Java
@Transactional // これだけでOK
void exsample() {
    // なんらかの処理
}

メソッド呼び出し時にトランザクションを開始(BEGIN)し、指定の例外が発生すればロールバック(ROLLBACK)、発生しなければコミット(COMMIT)されます。

ロールバック対象になる例外はrollbackForなどのオプションで指定でき、未指定の場合はJavaの検査例外は対象外になります。(↓ドキュメント参照)

Transactional
	/**
	 * ...省略...
	 * <p>By default, a transaction will be rolled back on {@link RuntimeException}
	 * and {@link Error} but not on checked exceptions (business exceptions). See
	 * {@link org.springframework.transaction.interceptor.DefaultTransactionAttribute#rollbackOn(Throwable)}
	 * for a detailed explanation.
	 * ...省略...
	 */
	Class<? extends Throwable>[] rollbackFor() default {};

このデフォルトの挙動ではJavaの検査例外は対象外と言うのが罠に繋がります。

Kotlinで@Transactionalが罠な理由

@Transactionalの挙動自体はJavaでもKotlinでも変わらないため、一見Javaで問題なければKotlinでも問題ないように思えます。しかし、ここで「Kotlinには検査例外が存在しない = Javaの検査例外もハンドリング不要」という言語仕様が効いてきます。

Javaで検査例外が発生する場合は必ずtry-catch or throwsをする必要があるため、ロールバックが不要な場合は適切にtry-catchで処理をしたり明示的にthrowsさせることができます。ロールバックして欲しい場合でもtry-catchでRuntimeExceptionに変換したり、throwsさせるとしても明示的にrollbackForをカスタマイズするなどして対応できます。

Kotlinで@Transactionalを使用する際に困る最大のポイントは、どんな例外(特にException系列のJavaの検査例外)が発生するのかコンパイルエラーなどで気付けない点にあります。自分で明示的にthrowしている例外ならともかく通常開発時には多くのライブラリを使用することになるためライブラリがどんな例外をthrowしているか把握し切ることは大変困難です。2

Java
// Javaでは検査例外の処理が必須なため、コンパイルエラーになってくれる
@Transactional
void throwException() {
    // Javaの検査例外が発生する処理やライブラリなどの呼び出し
}
// Kotlinでは検査例外という概念がないためこのままでもコンパイルエラーにならず、実行時に@Transactionalでロールバックされずコミットされてしまう
@Transactional
fun throwException() {
    // Javaの検査例外が発生する処理やライブラリなどの呼び出し
}

Kotlin + Spring Bootの情報は少なく、Java + Spring Bootの情報を元に開発を進めることが多いと思います。その多くが「@Transactionalを付与すればトランザクション管理ができますよ」といったものなのでKotlinでの使用時にこのような罠があることに気づきづらいと思います。3

おわりに

KotlinはJavaの代替言語として注目されています。Javaと似た使用感で実装できますしコード量の削減効果やNull安全などのメリットもあります。Javaのコードを段階的にKotlinに置き換えたりJavaのライブラリをそのまま使用できるなど親和性も高いです。

ですが、あくまでも別の言語であることには変わりません。なんとなくで使用していると思わぬところでしっぺ返しを食らいますので言語仕様は正しく把握した上で使用するようにしましょう。

余談

throwsはJavaバイトコード的には不要っぽい

Kotlinで例外をthrowした場合にJavaとバイトコードでどのような差が出るのか調べていたのですが、どうやらバイトコードとしてはthrows宣言があってもなくても動作に影響はしないようです。

以下のJava/Kotlinクラスは同じバイトコードになる(=バイトコードになる際にKotlinにthrowsが自動的に付与される)ことを期待したのですが、そうではないようです。

Java
public final class JavaSample {
    public final void throwIOException() throws IOException {
        throw new IOException();
    }
}
Kotlin
class KotlinSample {
    fun throwIOException() {
        throw IOException()
    }
}

それぞれjavac/kotlincでコンパイル後、javapで内容を確認

$ javap -v JavaSample   
...省略...
  public final void throwIOException() throws java.io.IOException;
    descriptor: ()V
    flags: (0x0011) ACC_PUBLIC, ACC_FINAL
    Code:
      stack=2, locals=1, args_size=1
         0: new           #7                  // class java/io/IOException
         3: dup
         4: invokespecial #9                  // Method java/io/IOException."<init>":()V
         7: athrow
      LineNumberTable:
        line 7: 0
    Exceptions:
      throws java.io.IOException
}
SourceFile: "JavaSample.java"
...省略...

$ javap -v KotlinSample 
...省略...
  public final void throwIOException(); # throwsが勝手に付与されるわけではない
    descriptor: ()V
    flags: (0x0011) ACC_PUBLIC, ACC_FINAL
    Code:
      stack=2, locals=1, args_size=1
         0: new           #13                 // class java/io/IOException
         3: dup
         4: invokespecial #14                 // Method java/io/IOException."<init>":()V
         7: athrow
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       8     0  this   Lcom/example/KotlinSample;
    # JavaのようにExceptionsの記載はない
}
SourceFile: "KotlinSample.kt"
...省略...

このことから、javacコンパイラがthrowsがないとコンパイルエラーにしてくれているだけで、実際のバイトコード実行時にthrowsが必須というわけではなさそうなことがわかります。

ちなみ、Kotlinにはどのような例外がthrowされるか明示する@Throwsアノテーションが存在するのですが、こちらを付与するとバイトコードにもthrowsが出力されるようです。4

Kotlin
class KotlinSample {
    @Throws(IOException::class)
    fun throwIOException() {
        throw IOException()
    }
}
$ javap -v KotlinSample 
...省略...
  public final void throwIOException() throws java.io.IOException; # throwsの記載がある
    descriptor: ()V
    flags: (0x0011) ACC_PUBLIC, ACC_FINAL
    Code:
      stack=2, locals=1, args_size=1
         0: new           #13                 // class java/io/IOException
         3: dup
         4: invokespecial #14                 // Method java/io/IOException."<init>":()V
         7: athrow
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       8     0  this   Lcom/example/KotlinSample;
    Exceptions:
      throws java.io.IOException # Exceptionsの記載がある
}
SourceFile: "KotlinSample.kt"
...省略...

参考

検査例外について

筆者はJavaが一番長いので検査例外というものに対して特に違和感を覚えていなかったのですが、今回の件で改めて他言語を見てみると多くの言語で検査例外が採用されていないことに驚きました。

色々調べていく中で個人的に以下の記事が分かりやすかったので参考情報として掲載します。

  1. Spring Bootに限らずKotlinは多くのJavaライブラリやフレームワークをそのまま使用することができます

  2. そのため使用時は@Transactional(rollbackFor = [Exception::class])などに設定してあげるのが良いでしょう

  3. Springは機能が多いため、初学者の触りの情報として「@Transactionalを付与するだけ」というのは誤りだとは思いません。アーキテクチャを検討するメンバーが正しく仕様を把握した上で採用するようにしましょう

  4. @ThrowsはJavaやSwift, Objective-Cなど検査例外が存在する言語からKotlinプログラムを呼び出す必要がある場合に使用されます

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0