ChuckNorrisException

ChuckNorrisException

O tym, jak stworzyć nieprzechwytywalny wyjątek w Javie, na podstawie mojej prezentacji z konferencji Devoxx Poland 2017.

piątek, 23 czerwca 2017

Informatyka

Kilka lat temu podczas dnia programisty, obchodzonego tradycyjnie w 256 dzień roku, postanowiłem napisać coś naprawdę dziwnego. Akurat byłem po lekturze faktów o Chucku Norrisie na temat Javy, wśród których znalazły się następujące perełki:

  • Chuck Norris rzuca wyjątki, których nie da się przechwycić.
  • Jeśli złapiesz ChuckNorrisException, naprawdopodobniej umrzesz.

Pomyślałem sobie, że to jest ciekawe zagadnienie, tym bardziej że w Internecie znalazłem sporo pytań na temat czy da się w ogóle taki wyjątek w Javie napisać. Zastanowiłem się, pokombinowałem i znalazłem odpowiedź: tak, da się. Początkowo prosty kawałek kodu z biegiem czasu został znacząco rozbudowany tak, że potrafi bez problemu dotrzeć do uncaught exception handlera, omijając najróżniejsze próby złapania go, zaś samą implementację miałem przyjemność zaprezentować szerszej publiczności podczas konferencji Devoxx Poland 2017.

ChuckNorrisException

Działanie nieprzechwytywalnego wyjątku łatwo pokazać na następującym przykładzie:

public class Example {
   public static void main(String[] args) {
      try {
         throw new ChuckNorrisException();
      } catch(ChuckNorrisException thr) {
         System.err.println("I've caught "+thr.getClass().getSimpleName());
      }
   }
}

Gdybyśmy mieli do czynienia ze zwykłym wyjątkiem, wynik byłby oczywisty. Jednak w przypadku ChuckNorrisException dostajemy kopa z półobrotu:

Finalization.
Exception in thread "main" com.zyxist.chuck.ChuckNorrisException: I'm uncatchable!
    at com.zyxist.Example1.main(Example1.java:8)

Co gorsza, jeśli użyjemy klauzuli catch (Exception exception) czy nawet... catch (Throwable thr), rezultat będzie ten sam! Nie pomoże nam nawet zrobienie pustej klauzuli catch:

public class Example {
   public static void main(String[] args) throws InterruptedException {
      try {
         throw new ChuckNorrisException();
      } catch(Throwable thr) {
      }
      System.out.println("Buahahaha!");
      Thread.sleep(10000);
      System.out.println("Still alive!");
      Thread.sleep(10000);
      System.out.println("Still alive! #2");
   }
}

Z pewnością nie jest to coś, co chcielibyśmy zobaczyć na produkcji...

Implementacja

Implementacja wyjątku jest ciekawym kawałkiem kodu, który wykorzystuje kilka mniej znanych cech maszyny wirtualnej Javy oraz kompilatora, a następnie zaprzęga je do robienia rzeczy, których projektanci z pewnością nie przewidzieli. Poniżej opisuję krótko każdą z nich. Pełny kod wraz z przykładami można znaleźć na Githubie: github.com/zyxist-chuck-norris-exception - tu pokażę tylko wybrane kawałki.

Ładowanie klas

Najważniejsza część implementacji to kod pozwalający ominąć klauzulę catch (ChuckNorrisException exception). Bazuje on na pewnej właściwości maszyny wirtualnej Javy, która ma bardzo praktyczne uzasadnienie oraz zastosowanie. Mianowicie możliwe jest współistnienie w jednej maszynie wirtualnej kilku różnych klas o tej samej nazwie. Warunek jest jeden: każda z nich musi zostać załadowana przez inną ładowarkę klas (classloader). Wykorzystują to serwery aplikacji oraz OSGi do izolowania od siebie poszczególnych aplikacji bądź modułów, które mogą korzystać z różnych wersji tych samych zależności.

Nasza sztuczka jest bardzo prosta. W kodzie mamy klasę ChuckNorrisException - jest ona normalnie kompilowana, widoczna dla IDE i tak dalej i pod względem API przypomina zwyczajny wyjątek. Jednak pod spodem, w momencie tworzenia jej obiektu, robimy tak naprawdę kopię istniejącej klasy przy pomocy biblioteki Javassist i zmieniamy jej nazwę na ChuckNorrisException, aby później użyć jej do utworzenia właściwego wyjątku, który zostanie rzucony zamiast tego stworzonego przez programistę.

public class ChuckNorrisException extends Exception {
   // ...
   private Class<?> rewriteWithDifferentClassLoader() throws Exception {
      ClassLoader cl = new ClassLoader(){};

      ClassPool pool = ClassPool.getDefault();
      CtClass chckCl = pool.get(RoundhouseKick.class.getCanonicalName());
      chckCl.setName(this.getClass().getCanonicalName()); 
      return pool.toClass(chckCl, cl, Class.class.getProtectionDomain());
   }
}

Kluczowa jest tutaj pierwsza linijka, która tworzy również nową instancję anonimowej ładowarki klas, dzięki czemu nasz wygenerowany ChuckNorrisException będzie mógł istnieć razem z domyślnym. W ten sposób dla maszyny wirtualnej Javy będą to dwie zupełnie oddzielne klasy. Klauzula catch(ChuckNorrisException exception) wymaga zgodności zarówno pod względem nazwy, jak i ładowarki, aby rozpoczęło się jej wywołanie. Ten warunek nie jest tutaj spełniony, a nasz wyjątek przelatuje wyżej bez zatrzymania.

Zauważmy, że do zrobienia kopii wykorzystuję zupełnie inną klasę, RoundhouseKick. Wskazane jest, żeby kod implementujący dalsze sztuczki nie znajdował się w klasie ChuckNorrisException, gdyż będziemy musieli stworzyć jeszcze kilka klas i inaczej wpadlibyśmy w problemy z ich załadowaniem.

Główny konstruktor

public class ChuckNorrisException extends Exception {
   public ChuckNorrisException() {
      Throwable uncatchable = null;
      try {
         Class<?> hackedChuck = rewriteWithDifferentClassLoader();
         uncatchable = instantiateHackedClass(hackedChuck);
         uncatchable.setStackTrace(this.getStackTrace());
         callInstallShutdownHook(hackedChuck, uncatchable);
      } catch(Exception exception) {
         throw new RuntimeException("Chucking didn't work: " + exception.getMessage(), exception);
      }
      dontLetItLiveTooMuch(uncatchable);
      throwAsUnchecked(uncatchable);
   }
   // ...
}

Tak wygląda główny konstruktor klasy ChuckNorrisException. W jego wnętrzu tworzę tak naprawdę nowy wyjątek, używając dopiero co wygenerowanej klasy, a następnie rzucam go. Jednak w naiwnej postaci taka manipulacja byłaby łatwa do wykrycia, ponieważ w stacktrace widzielibyśmy, że wyjątek poleciał z konstruktora wyjątku. Na szczęście w API jest metoda .setStackTrace(), za pomocą której mogę podmienić informacje o miejscu wywołania na inne - kopiuję je zatem z oryginalnego obiektu, a wyjątki stają się dla programisty nie do odróżnienia.

Jak z Exception zrobić Throwable

Aby ominąć klauzulę catch (Exception exception), trzeba sprawić, aby nasz rzucony wyjątek nie rozszerzał tej klasy. Ponieważ korzystamy z generowania kodu, nie jest to trudne zadanie. Do wygenerowania ChuckNorrisException używamy innej klasy - RoundhouseKick. Zerknijmy sobie na jej deklarację - domyślnie dziedziczy ona po Throwable, zatem tę funkcjonalność uzyskujemy za darmo.

public class RoundhouseKick extends Throwable {
   // ...
}

Sztuczka z getMessage()

Ominięcie catch (Throwable exception) jest bardzo trudne, a w zasadzie niemożliwe, ponieważ maszyna wirtualna posiada zabezpieczenia na poziomie ładowania klas przed próbami manipulowania podstawowym API. W tym miejscu musimy posłużyć się kilkoma sztuczkami psychologicznymi. Zastanówmy się: co robi typowy programista, gdy przechwyci wyjątek? Cóż, zapewne wyświetla komunikat wyjątku, albo odczytuje stacktrace. I na szczęście, Java pozwala nam nadpisać te metody naszymi złowrogimi implementacjami:

public class RoundhouseKick extends Throwable {
   // ...
   @Override
   public String getMessage() {
      if (!isWithinUndeclaredThrowableHandler()) {
         throwAsUnchecked(this);
      }
      roundhouseKicked = true;
      return super.getMessage();
   }
}

Działają one zgodnie z oczekiwaniami wyłącznie w uncaught exception handlerze, natomiast w każdej innej sytuacji powodują propagację wyjątku w górę. Nie unikamy w ten sposób złapania, ale sprawiamy, że przy próbie zrobienia czegokolwiek z przechwyconym wyjątkiem, zostanie on rzucony ponownie i tak aż do skutku. Aby sprawdzić czy jesteśmy już w uncaught exception handlerze, robimy po prostu inspekcję stosu bieżącego wątku. Można się do tych informacji dostać, tworząc dowolny wyjątek bez rzucania go - chodzi tylko o to, by wywołać .getStackTrace().

Rzucanie wyjątków bez deklarowania ich

Spostrzegawczy czytelnicy zauważyli zapewne, że ChuckNorrisException to wyjątek kontrolowany (checked exception), zatem Java powinna krzyczeć we wszystkich możliwych metodach, że brakuje odpowiedniej klauzuli throws. Tak się jednak nie dzieje i nie bez powodu, bowiem odpowiada za to kolejna ze sztuczek. Kontrola wyjątków wykonywana jest tylko podczas kompilacji, natomiast maszyna wirtualna zupełnie się tym nie przejmuje. Okazuje się, że istnieje sposób, aby przy pomocy typów generycznych oszukać kompilator i zmusić go do wygenerowania kodu, który rzuca ChuckNorrisException bez deklarowania go w nagłówku metody:

public class RoundhouseKick extends Throwable {
   // ...
   static void throwAsUnchecked(Throwable exception) {
      RoundhouseKick.<RuntimeException>throwChuckedException(exception);
   }

   @SuppressWarnings("unchecked")
   private static <E extends Throwable> void throwChuckedException(Throwable e) throws E {
      throw (E) e;
   }
   // ...
}

W naszej sztuczce potrzebne są dwie metody - jedna z nich posiada generyczny argument E określający typ rzucanego wyjątku. Jest ona wołana przez drugą metodę z jawnym określeniem typu jako RuntimeException - w jej treści wykonujemy rzutowanie. Kompilator pozwala nam na zrobienie rzutowania z jednej klasy na dowolną inną, generując wyłącznie ostrzeżenie, że ta operacja jest potencjalnie niebezpieczna - zresztą, jest to coś, czego się spodziewamy, bowiem w tym momencie mówimy kompilatorowi: "hej, wiem co robię!". Jednak zauważmy - jeśli rzutujemy coś na typ generyczny, jest to wyłącznie wskazówka dla kompilatora. Po kompilacji nasz RuntimeException znika i zmienia się w Throwable, a kod zostaje i działa. I od tego momentu możemy już rzucić każdy wyjątek bez deklarowania go.

Zabójczy shutdown hook

Wróćmy jednak do problemu z ominięciem catch (Throwable exception). Obecna sztuczka zadziała tylko wtedy, kiedy programista faktycznie wywoła jedną z podanych metod. Jednak w sytuacji, gdy klauzula catch będzie pusta, jesteśmy bezbronni. Dlatego zabezpieczamy się przed tym, instalując shutdown hook, który sprawdza czy nasz wyjątek został obsłużony, odczytując flagę roundhouseKicked. Jeśli nie, jest on rzucany w wątku shutdown hooka podczas zamykania maszyny wirtualnej - stacktrace pozostaje niezmieniony i w komunikacie błędów wszystko wygląda tak, jakby poleciał on z oryginalnego miejsca, gdzie został stworzony, wprawiając programistę w osłupienie.

public class RoundhouseKick extends Throwable {
   // ...
   private void installShutdownHook(Class<?> shutdownHookClass) {
      try {
         Thread thr = (Thread) shutdownHookClass.getConstructor(String.class, Throwable.class).newInstance(Thread.currentThread().getName(), this);
         Runtime.getRuntime().addShutdownHook(thr);
      } catch(NoSuchMethodException | SecurityException | InstantiationException | IllegalAccessException | InvocationTargetException ex) {
         System.err.println("Oh cr44p");
      }
   }

   public static class ShutdownThread extends Thread {
      private final Throwable hackedException;

      public ShutdownThread(String originalThreadName, Throwable hackedException) {
         super(originalThreadName);
         this.hackedException = hackedException;
      }

      @Override
      public void run() {
         try {
            Field roundhouseKickedField = hackedException.getClass().getDeclaredField("roundhouseKicked");
            roundhouseKickedField.setAccessible(true);
            boolean value = (Boolean) roundhouseKickedField.get(hackedException);
            if (!value) {
               throwAsUnchecked(hackedException);
            }
         } catch(NoSuchFieldException | SecurityException | IllegalAccessException ex) {
            System.err.println("Oh sheet");
         }
      }
   }
}

W tym miejscu musimy mocno korzystać z refleksji, ponieważ pamiętajmy, że mamy do czynienia z wygenerowaną klasą, która nie istnieje w momencie kompilacji. Gdybyśmy próbowali po prostu zrzutować nasz wyjątek i odwołać się np. do gettera, spotkałby nas ten sam los, co klauzulę catch (ChuckNorrisException exception) - dostalibyśmy błąd, że nie da się wykonać podanego rzutowania.

Killer Thread

Maszyna wirtualna, zwłaszcza na serwerze, może pracować przez bardzo długi czas, my natomiast nie chcemy czekać aż tak długo na kopniaka z półobrotu. Dlatego musimy odpalić w tle jeszcze jeden wątek, killer thread, który czeka 100 milisekund i jeśli ChuckNorrisException wciąż nie został obsłużony, to... ubija maszynę wirtualną, wywołując shutdown hook i sprawiając, że nasz wyjątek dociera do kodu obsługi nieprzechwyconych wyjątków:

public class ChuckNorrisException extends Exception {
   // ...
   private void dontLetItLiveTooMuch(final Throwable uncatchable) {
      new Thread() {
         @Override
         public void run() {
            try {
               Thread.sleep(100);
               try {
                  Field roundhouseKickedField = uncatchable.getClass().getDeclaredField("roundhouseKicked");
                  roundhouseKickedField.setAccessible(true);
                  boolean value = (Boolean) roundhouseKickedField.get(uncatchable);
                  if (!value) {
                     System.exit(0);
                  }
               } catch(NoSuchFieldException | SecurityException | IllegalAccessException ex) {
                  System.err.println("Oh sheet");
               }
            } catch(InterruptedException exception) {
               System.exit(0);
            }
         }
      }.start();
   }
}

I to wszystko. Myślę, że zaproponowana implementacja w pełni odpowiada przytoczonym na początku wpisu faktom, gdyż osiągamy nasz ostateczny cel - wyjątek dociera do uncaught exception handlera.

Wnioski

ChuckNorrisException nie jest zbyt przydatną w praktyce klasą, chyba że chcemy w widowiskowy sposób pożegnać się z dotychczasowym pracodawcą. Pokazuje jednak kilka ciekawych właściwości maszyny wirtualnej oraz kompilatora, które warto znać, aby rozumieć, co się dzieje pod maską naszego własnego kodu. Warto również zastanowić się nad rolą wyjątków w naszych aplikacjach. Wyjątki powstały, aby radzić sobie z niecodziennymi sytuacjami i osobiście mianem ChuckNorrisException określam każdą próbę zaciemnienia obsługi błędów w programie. Błędy będą pojawiać się zawsze i jeśli w aplikacji nie widać śladów pomysłu, jak sobie z nimi radzić, programiści będą natrafiać na niespójności i radzić sobie poprzez uciekanie się do sztuczek. Dlatego uważam, że każdy projekt, który ma być utrzymywany przez dłuższy czas, powinien mieć jasno określoną strategię obsługi błędów oraz rolę wyjątków w tej strategii.

Moja strategia

W swoich aplikacjach stosuję dość prosty model. Bardzo lubię kontrolowane wyjątki, głównie z tego powodu, że nie lubię dowiadywać się o głupich błędach z produkcji, kiedy może mi je bez problemu wskazać kompilator. Jednak większy formalizm niesie za sobą możliwość wpakowania się w kłopoty, jeżeli nie umiemy z niego korzystać. Mam trzy główne reguły, które pozwalają mi uniknąć frustracji:

  1. aplikacja posiada przeważnie dwa miejsca obsługi błędów, np. na poziomie całej aplikacji oraz pojedynczego żądania,
  2. w aplikacji są około dwie bazowe klasy wyjątków - używam ich głównie w interfejsach, by dać programiście wolność,
  3. z bazowych wyjątków dziedziczą te bardziej wyspecjalizowane - używam ich w implementacjach interfejsów, gdzie stanowią też formę dokumentacji.

Oczywiście zdarza się, że w interfejsie użyję bardziej wyspecjalizowanego typu wtedy, kiedy jest to uzasadnione tym, co jakaś metoda ma robić. Wspomniane bazowe wyjątki natomiast opisują dwie główne klasy problemów:

  • błędy aplikacji - mogą to być np. problemy z danymi wejściowymi lub cokolwiek, co związane jest z tym, co aplikacja robi,
  • błędy środowiska - mówią o tym, że coś złego dzieje się wokół aplikacji (np. utrata połączenia z bazą danych),
  • do tego należy dodać oczywiście RuntimeException mówiący, że mamy problemy z programistą :).

Zauważmy, że takie wyjątki łatwo jest zmapować na kody HTTP, a także określić strategię reagowania. W przypadku błędu środowiska, możemy wysłać sygnał do jakiegoś systemu monitorowania, że dzieje się coś złego. Błędy aplikacji to zupełnie inny kaliber - tutaj istotne jest, aby poinformować sprawcę, co źle zrobił, aby mógł się poprawić. Mam bardzo ciekawy przykład praktycznego zastosowania tego podziału w projekcie środowiska testów automatycznych dla systemów rozproszonych, które rozwijałem przez kilka lat. Występowały tam wyjątki TestException informujące o problemie z testowanym systemem oraz EnvironmentException opisujące problemy platformy testowej lub środowiska, w którym pracowała aplikacja. Oba typy wyjątków mapowały się na inne statusy testów i już pobieżna inspekcja raportu z testów pozwalała na określenie czy trzeba zająć się konfiguracją sieci, czy analizą logów pod kątem znalezienia defektu. Oszczędzało to bardzo dużo czasu.

Oczywiście nie jest to jedyna słuszna strategia. Jeżeli nasza aplikacja mocno korzysta z elementów programowania funkcyjnego, podczas wykonywania obliczeń błąd będzie raczej jednym z możliwych wyników, którego w żaden sposób nie "oceniamy". Na wyjątek możemy zamienić go później. Tak naprawdę ważne jest, abyś przemyślał, jak obsługujesz błędy w swoich aplikacjach i czy w ogóle je obsługujesz. Jeżeli Twoją strategię da się opisać w kilkunastu słowach, to jest to dobra strategia, ponieważ wtedy inni programiści szybko ją przyswoją i albo zaczną stosować, albo znajdą w niej luki, prowokując poszukiwania lepszego rozwiązania. A to może wyjść wyłącznie na dobre - reszta to kwestia doświadczenia. Tak więc zastanów się nad swoją strategią i nie rzucaj ChuckNorrisException w kodzie produkcyjnym.

-- Tomasz Jędrzejewski

Autor zdjęcia nagłówkowego: U.S. Army, CC-BY-2.0

Komentarze (0)

Skomentuj

Od 3 do 40 znaków.

Wymagany, nie będzie publikowany.

Odpowiedz na pytanie.

Edycja Podgląd

Od 10 do 8000 znaków.

Wszystkie komentarze są moderowane i muszą być zatwierdzone przed publikacją.