
Kotlin Coroutines withContextを使ってもStackTraceを諦めたくない

IT技術
はじめに
本記事は、以前の記事
Kotlin Coroutines withContextを使っているとStackTraceを用いた不具合調査が辛いのをどうにかしたい
の解決提案編となります。
どんな問題だったか
Koltin CouroitnesのwithContextを利用してスレッド変更すると
StackTraceが途切れてしまい不具合発生時の処理履歴が追えなくなる
という問題でした。
詳しくは前回の記事を確認してください。
解決案1:withContextごとに、例外ラッパークラスにStackTrace情報を詰め込む(問題あり…)
↓まずは、例外ラッパークラスを作成。
1/*
2 * Exceptionをラップするためのクラス
3 */
4class WrappedException(
5 message: String,
6 cause: Exception
7) : Exception(message, cause) {
8
9 /**
10 * 例外の根本的な原因を取得するプロパティ
11 *
12 * このプロパティは、WrappedExceptionがラップしている例外の根本的な原因を返す
13 * もしWrappedExceptionがラップしている例外が別のWrappedExceptionであれば、その根本的な原因を再帰的に取得
14 */
15 val rootCause: Throwable?
16 get() = cause?.let {
17 if (it is WrappedException) {
18 it.rootCause
19 } else {
20 it
21 }
22 }
23}
↓次に、独自のwithContextを作成
1/*
2* withContextを使用して、例外をラップするための関数
3 *
4 * @param context CoroutineContext
5 * @param block suspend CoroutineScope.() -> T
6 * @return T
7 *
8 * 呼び出し元のメソッド名と行番号を取得し、例外が発生した場合にその情報を含む[WrappedException]を投げる。
9 */
10suspend fun withContextWrapException(
11 context: CoroutineContext,
12 block: suspend CoroutineScope.() -> T,
13): T {
14 val throwable = Throwable()
15 return withContext(context) {
16 try {
17 block()
18 } catch (e: CancellationException) {
19 // CancellationExceptionはそのまま投げる(suspend中断例外の伝播のため)
20 throw e
21 } catch (e: Exception) {
22 val callerStackTraceElement = throwable.stackTrace[1] // withContextWrapExceptionを呼び出しているメソッド
23 val callerOfCallerStackTraceElement = throwable.stackTrace[2] // 「withContextWrapExceptionを呼び出しているメソッド」を呼び出しているメソッド
24 val callerInfo =
25 "(${callerOfCallerStackTraceElement.fileName}:${callerOfCallerStackTraceElement.lineNumber}→${callerStackTraceElement.fileName}:${callerStackTraceElement.lineNumber})"
26 throw WrappedException(callerInfo, e)
27 }
28 }
29}
↓あとは、withContextの代わりに使うだけ。
1 // 適当なRpository
2class HogeRepository(
3 private val dispatcherIO: CoroutineDispatcher = Dispatchers.IO
4) {
5 suspend fun getHoge(): String = withContextWrapException(dispatcherIO) {
6 throw RuntimeException("適当な例外")
7 }
8}
↓ViewModelから呼び出すと
1 // 適当なViewModelから呼び出す
2class MainViewModel(
3 private val repository: HogeRepository = HogeRepository()
4) : ViewModel() {
5
6 fun onClickButton() {
7 viewModelScope.launch {
8 try {
9 hogeRepository.getHoge()
10 } catch (e: CancellationException) {
11 throw e // Jobキャンセル例外はキャッチしたらダメです
12 } catch (e: Exception) {
13 Log.e("hoge", "onClickButton", e)
14 _errorMessage.update { e.stackTraceToString() }
15 }
16 }
17 }
18}
↓以下のStackTraceが出力されます。※行数ズレてるのは気にしないでください。
これなら、どれだけスレッド変更しても大丈夫です
↓試しにUseCaseを作成
1 // HogeUseCase
2class HogeUseCase(
3 private val hogeRepository: HogeRepository = HogeRepository(),
4 private val dispatcherDefault: CoroutineDispatcher = Dispatchers.Default,
5) {
6 suspend fun fetchHoge(): String = withContextWrapException(dispatcherDefault) {
7 val hoge = hogeRepository.getHoge()
8 return@withContextWrapException hoge
9 }
10}
↓ViewModelから呼び出してみる
1 // 適当なViewModelから呼び出す
2class MainViewModel(
3 private val repository: HogeRepository = HogeRepository()
4) : ViewModel() {
5
6 fun onClickButton() {
7 viewModelScope.launch {
8 try {
9 hogeUseCase.fetchHoge()
10 } catch (e: CancellationException) {
11 throw e // Jobキャンセル例外はキャッチしたらダメです
12 } catch (e: Exception) {
13 Log.e("hoge", "onClickButton", e)
14 }
15 }
16 }
17}
↓これで出力されたStackTrace。※行数ズレてるのは気にしないでください。
ViewModel→UseCase→Repositoryの呼び出しがあったことがよく分かる。
が、しかし。
この方法には致命的な問題点があります。
それは・・・
難読化されていると使えない。
えー。そうなんです。
ThrowableからStackTraceを取得して文字列としてWrappedExceptionに渡しているため
難読化されているとまともなログが出力されません。
解決案2:CoroutineNameを活用する
致命的な問題を抱えている案1はちょっと・・・
というわけで、案2です。
案1のWrappedExceptionはそのまま利用して
↓withContextWrapExceptionをちょっと改良します。
1 // withContextの位置変わってるので注意
2suspend fun withContextWrapException(
3 context: CoroutineContext,
4 callerCoroutineName: String? = null,
5 block: suspend CoroutineScope.() -> T,
6): T {
7 return try {
8 withContext(
9 if (callerCoroutineName != null) {
10 context + CoroutineName(callerCoroutineName)
11 } else {
12 context
13 }
14 ) {
15 block()
16 }
17 } catch (e: CancellationException) {
18 // CancellationExceptionはそのまま投げる(suspend中断例外の伝播のため)
19 throw e
20 } catch (e: Exception) {
21 val callerInfo = coroutineContext[CoroutineName]?.name.orEmpty()
22 throw WrappedException(callerInfo, e)
23
24 }
25}
↓UseCaseもちょっと修正
1 // HogeUseCase
2class HogeUseCase(
3 private val hogeRepository: HogeRepository = HogeRepository(),
4 private val dispatcherDefault: CoroutineDispatcher = Dispatchers.Default,
5) {
6 suspend fun fetchHoge(): String = withContextWrapException(
7 context = dispatcherDefault,
8 callerCoroutineName = "HogeUseCase"
9 ) {
10 val hoge = hogeRepository.getHoge()
11 return@withContextWrapException hoge
12 }
13}
↓ViewModelからの呼び出しもちょっと修正
1 // 適当なViewModelから呼び出す
2class MainViewModel(
3 private val repository: HogeRepository = HogeRepository()
4) : ViewModel() {
5
6 fun onClickButton() {
7 viewModelScope.launch {
8 try {
9 // withContextじゃなくて、launchにCoroutineNameを渡すのもアリ
10 withContext(CoroutineName("MainViewModel.onClickButton")) {
11 hogeUseCase.fetchHoge()
12 }
13 } catch (e: CancellationException) {
14 throw e // Jobキャンセル例外はキャッチしたらダメです
15 } catch (e: Exception) {
16 Log.e("hoge", "onClickButton", e)
17 }
18 }
19 }
20}
これで実行すると・・・難読化されてても処理を追うログを出力できる。
↓ちょっと分かりづらいがMainViewModel→HogeUseCase→HogeReposiytoryの順で呼ばれていることがわかる。
最後に
案2は、せっかく難読化しているのにログに出力してしまっていいのかな・・・?
という気持ちもあり・・・正直スッキリしていない。
withContextでスレッドを切り替えるたびにtry-catchするのが一番まじめなんだろうな・・・
面倒くさがってはだめ・・・ということですかね。
もっと良い解決方法ないかなぁ・・・
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ