Wydaje się, że Spring i Kotlin to dobre połączenie. Z jednej strony najbardziej popularny framework dla Javy, ale deklarujący wsparcie dla języka od JetBrains. Z drugiej Kotlin został zaprojektowany dla jak największej kompatybilności z Javą. Na papierze i w ogłoszeniach na social mediach wszystko gra. Tyle że w rzeczywistości można bardzo nieprzyjemnie się zaskoczyć, niestety zazwyczaj dopiero na produkcji.
Problem będący głownym bohaterem tego artykułu leży w różnym podejściu do wyjątków w obu światach.
Pisząc w Kotlinie można zapomnieć o dyskusjach programistów Javy, czy wyjątek ma być checked czy nie. Jeśli w repozytorium piszemy wyłącznie w jednym języku, wszystko nam jedno i nie trzeba sobie tym zawracać głowy. Chyba że kod działa w Springu. Wtedy brak uwagi kończy się zobaczeniem UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
. Zgodnie ze słowami 'unexpected’ i 'silent’, jest to sytuacja zaskakująca, a do tego upierdliwa w zdiagnozowaniu.
Spis treści
Kod pokazujący problem
Weźmy wydawało by się zupełnie oczywisty kod:
@Service class UserDeactivationService(private val pictureRemovalService: PictureRemovalService) { private val log = LoggerFactory.getLogger(javaClass) @Transactional fun deactivateUser(user: User): Boolean { try { pictureRemovalService.removeProfilePicture(user) return true } catch (e: CannotRemovePicturesException) { log.warn("Continuing without removing profile picture") return false } } } @Service class PictureRemovalService { @Transactional fun removeProfilePicture(user: User) { throw CannotRemovePicturesException("Can't remove profile picture") } } class CannotRemovePicturesException(msg: String) : RuntimeException(msg)
Mam do niego test, żeby nikt nie musiał wierzyć mi na słowo:
class UserDeactivationServiceTest( private val userDeactivationService: UserDeactivationService, ) : BaseTest() { @Test fun `removes profile picture`() { val user = aUser() assertTrue( userDeactivationService.deactivateUser(user) ) }
Jeszcze niedawno do głowy nawet by mi nie przyszło, że ten test może zakończyć się inaczej niż albo sukcesem albo błędem asercji Expected: true Actual: false
.
„Dzięki” obecności na classpath biblioteki spring-tx
, wynik jest inny. To wyjątek przerywający test:
Transaction silently rolled back because it has been marked as rollback-onlyorg.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:804)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:758)
at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:698)
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:416)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:727)
at pl.pkubowicz.UserDeactivationService$$SpringCGLIB$$0.deactivateUser(<generated>)
at pl.pkubowicz.UserDeactivationServiceTest.removes profile picture(UserDeactivationServiceTest.kt:15)
Czemu rollback
Nie zrobiłem błędu w łapaniu wyjątku. Jest on łapany, w konsoli wypisywany jest komunikat Continuing without removing profile picture
i wykonanie opuszcza pomyślnie kod serwisu.
Wyjątek pochodzi z miejsca, które jest „typowym Springiem”, czyli AOP (Aspect-Oriented Programming, taki hype sprzed ponad 20 lat). Test nie woła bezpośrednio serwisu, Spring stawia po drodze proxy (nazwy ze stacktrace’a: CglibAopProxy, TransactionInterceptor). Tuż przed powrotem z serwisu do testu, Springowi coś się nie podoba i rzuca wyjątek, przy okazji robiąc bazodanowy rollback.
Co się nie podoba? Rzucany wyjątek, a dokładniej bycie „checked”. Zacytuję oficjalną dokumentację Springa, w sekcji Data Access:
In its default configuration, the Spring Framework’s transaction infrastructure code marks a transaction for rollback only in the case of runtime, unchecked exceptions. That is, when the thrown exception is an instance or subclass of RuntimeException.
Już w momencie rzucenia wyjątku, który jest unchecked, los całego wywołania serwisu jest przesądzony. Nieważne, czy złapiemy ten wyjątek, co z nim zrobimy po złapaniu – nie da się zrobić tak, żeby wywołanie zakończyło się sukcesem (zwróceniem wyniku i commitem po stronie bazy danych).
Jak naprawić
Można kombinować z adnotacjami.
Zamiast domyślnego @Transactional
skonfigurować @Transactional(noRollbackFor =
. Albo mieszać z propagacją.
Jest szansa, że to zadziała, ale uważam, że to zła droga. Naprawiamy w ten sposób jedno miejsce. Nie można przewidzieć wszystkich miejsc, gdzie może być rzucony dany wyjątek. Nawet jeśli naprawimy wszystkie możliwe adnotacje dziś, to pewnie jutro ktoś napisze nowy kod, w którym nie będzie tego specjalnego hacka.
Rozwiązanie to pisanie w Kotlinie zgodnie z zasadami narzucanymi na Javę przez Springa:
- wyjątki reprezentujące niespodziewane sytuacje, których nie da się odratować, powinny być unchecked (dziedziczyć po
RuntimeException
) - wyjątki reprezentujące przewidziane z góry sytuacje z domeny biznesowej powinny być checked (dziedziczyć po
Exception
ale nie poRuntimeException
)
W przykładowym kodzie z początku artykułu, poprawka jest jednolinijkowa:
class CannotRemovePicturesException(msg: String) : Exception(msg)
Takie podejście wymaga więcej zrozumienia, ale lepiej reaguje na dodawanie i usuwanie kodu dookoła.
Podsumowanie
Czyli jednak pisząc w 100% Kotlinie nie możemy zapomnieć, czemu Java dawno dawno temu podzieliła wyjątki na checked i unchecked.
Ciężko taką zasadę wyegzekwować. Dlatego trzeba testować – integracyjnie z bazą danych, pamiętając o scenariuszach negatywnych (w happy path błędny kod z początku działał). Oduczyć się testowania z mockami, bo na ten problem pomogłyby tyle, co nic.
Jeśli ktoś potrzebuje amunicji, by walczyć ze Springiem – proszę bardzo, możecie użyć mojego przykładu. Też nie jestem wielkim fanem Springa, ale łatwo od niego nie uciekniemy.
Idąc jeszcze bardziej ogólnie, chyba sprawa mówi coś o nauczaniu Kotlina.
Być może są ludzie, którzy poznają Kotlina jako pierwszy język, przed poznaniem Javy. Być może JetBrains i Google chciałyby, żeby tak odbywała się nauka. Nie mam dużo przeciwko, jeśli taka osoba nie pisze bibliotek, używa też wyłącznie frameworków i bibliotek pisanych specjalnie pod Kotlina.
Jednak Spring nie jest narzędziem dla Kotlina, nieważne, co napiszą w komunikatach marketingowych jego autorzy. Jest biblioteką z dużym bagażem historycznym, stworzoną do rozwiązywania problemów programistów Javy.
Jeśli ktoś chce się zabierać do programowania w Kotlinie w połączeniem ze „starymi” bibliotekami jak Spring, nie ma drogi na skróty. Najpierw trzeba zbudować mocne podstawy w Javie, nawet jeśli w Javie nie napiszemy zawodowo ani linijki.
Pisząc w Kotlinie, nie powinniśmy traktować go jako nowego tworu działającego w próżni. Warto pamiętać, że musimy grać według reguł Javy.