Pułapki transakcji Springa dla programistów Kotlina

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.

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 po RuntimeException)

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.

Creative Commons License
Except where otherwise noted, the content by Piotr Kubowicz is licensed under a Creative Commons Attribution 4.0 International License.