Kotlin / Java-Fehlerbehandlung: Wie geht das richtig?


Quelle


Die Fehlerbehandlung in jeder Entwicklung spielt eine entscheidende Rolle. Fast alles kann im Programm schief gehen: Der Benutzer gibt falsche Daten ein oder sie kommen über http oder wir haben einen Fehler beim Schreiben der Serialisierung / Deserialisierung gemacht und während der Verarbeitung stürzt das Programm mit einem Fehler ab. Ja, es kann sein, dass der Speicherplatz knapp wird.


Spoiler

¯_ (ツ) _ / ¯, es gibt keinen einzigen Weg, und in jeder spezifischen Situation müssen Sie die am besten geeignete Option auswählen, aber es gibt Empfehlungen, wie Sie es besser machen können.


Vorwort


Leider (oder nur so ein Leben?) Geht diese Liste weiter und weiter. Der Entwickler muss ständig darüber nachdenken, dass irgendwo ein Fehler auftreten kann, und es gibt zwei Situationen:


  • wenn der erwartete Fehler beim Aufrufen der von uns bereitgestellten Funktion auftritt und versuchen kann, diese zu verarbeiten;
  • wenn während des Vorgangs ein unerwarteter Fehler auftritt, den wir nicht vorhergesehen haben.

Und wenn die erwarteten Fehler zumindest lokalisiert sind, kann der Rest fast überall passieren. Wenn wir nichts Wichtiges verarbeiten, können wir einfach mit einem Fehler abstürzen (obwohl dieses Verhalten nicht ausreicht und Sie dem Fehlerprotokoll mindestens eine Nachricht hinzufügen müssen). Aber wenn gerade die Zahlung verarbeitet wird und Sie einfach nicht fallen können, müssen Sie zumindest eine Antwort über den erfolglosen Vorgang zurückgeben?


Bevor wir uns mit Möglichkeiten zum Umgang mit Fehlern befassen, einige Worte zu Ausnahmen (Ausnahmen):


Ausnahme



Quelle


Die Hierarchie der Ausnahmen ist gut beschrieben und Sie können viele Informationen darüber finden, so dass es keinen Sinn macht, sie hier zu malen. Was immer noch manchmal zu heftigen Diskussionen führt, werden checked und Fehler unchecked . Und obwohl die Mehrheit unchecked Ausnahmen als bevorzugt akzeptierte (in Kotlin gibt es überhaupt keine checked Ausnahmen), sind nicht alle damit einverstanden.


Die checked Ausnahmen hatten wirklich die gute Absicht, sie zu einem bequemen Fehlerbehandlungsmechanismus zu machen, aber die Realität nahm ihre Anpassungen vor, obwohl die Idee, alle Ausnahmen, die von dieser Funktion aus in die Signatur geworfen werden können, in die Signatur einzuführen, verständlich und logisch ist.


Schauen wir uns ein Beispiel an. Angenommen, wir haben eine method , die eine überprüfte PanicException . Eine solche Funktion würde folgendermaßen aussehen:


 public void method() throws PanicException { } 

Aus ihrer Beschreibung geht hervor, dass sie eine Ausnahme auslösen kann und dass es nur eine Ausnahme geben kann. Sieht es ganz bequem aus? Und während wir ein kleines Programm haben, ist es das. Wenn das Programm jedoch etwas größer ist und es mehr solcher Funktionen gibt, treten einige Probleme auf.


Geprüfte Ausnahmen erfordern laut Spezifikation, dass alle möglichen geprüften Ausnahmen (oder ein gemeinsamer Vorfahr für sie) in der Funktionssignatur aufgeführt sind. Wenn wir also eine Kette von Aufrufen a -> b -> c und die am meisten verschachtelte Funktion eine Ausnahme auslöst, sollte sie für alle in der Kette abgelegt werden. Und wenn es mehrere Ausnahmen gibt, sollte die oberste Funktion in der Signatur eine Beschreibung aller enthalten.


Wenn das Programm komplexer wird, führt dieser Ansatz dazu, dass Ausnahmen an der obersten Funktion allmählich zu gemeinsamen Vorfahren zusammenfallen und letztendlich zu Exception . Was in dieser Form einer unchecked Ausnahme ähnelt und alle Vorteile von geprüften Ausnahmen negiert.


Und da sich das Programm als lebender Organismus ständig ändert und weiterentwickelt, ist es fast unmöglich, im Voraus vorherzusagen, welche Ausnahmen darin auftreten können. Infolgedessen müssen wir beim Hinzufügen einer neuen Funktion mit einer neuen Ausnahme die gesamte Kette ihrer Verwendung durchlaufen und die Signaturen aller Funktionen ändern. Stimmen Sie zu, dies ist nicht die angenehmste Aufgabe (selbst wenn man bedenkt, dass moderne IDEs dies für uns tun).


Aber der letzte und wahrscheinlich größte Nagel in geprüften Ausnahmen hat Lambdas aus Java 8 "getrieben". Es gibt keine geprüften Ausnahmen ¯_ (ツ) _ / ¯ in ihrer Signatur (da jede Funktion in Lambda mit jeder aufgerufen werden kann Signatur), sodass jeder Funktionsaufruf mit einer aktivierten Ausnahme vom Lambda erzwingt, dass er als deaktiviert in eine Ausnahmeweiterleitung eingeschlossen wird:


 Stream.of(1,2,3).forEach(item -> { try { functionWithCheckedException(); } catch (Exception e) { throw new RuntimeException("rethrow", e); } }); 

Glücklicherweise gibt es in der JVM-Spezifikation überhaupt keine geprüften Ausnahmen, sodass Sie in Kotlin nichts in dasselbe Lambda einwickeln können, sondern einfach die gewünschte Funktion aufrufen können.


obwohl manchmal ...

Dies führt zwar manchmal zu unerwarteten Konsequenzen, wie zum Beispiel der fehlerhaften @Transactional von @Transactional im Spring Framework , bei der nur nicht unckecked Ausnahmen "erwartet" werden. Dies ist jedoch eher ein Merkmal des Frameworks, und möglicherweise wird sich dieses Verhalten im Frühjahr in naher Zukunft ändern .


Ausnahmen selbst sind spezielle Objekte. Neben der Tatsache, dass sie durch Methoden "geworfen" werden können, sammeln sie bei der Erstellung auch Stacktrace. Diese Funktion hilft dann bei der Analyse von Problemen und der Suche nach Fehlern, kann jedoch auch zu Leistungsproblemen führen, wenn die Anwendungslogik stark an ausgelöste Ausnahmen gebunden ist. Wie im Artikel gezeigt , kann das Deaktivieren der Stacktrace-Baugruppe in diesem Fall die Leistung erheblich steigern. Sie sollten jedoch nur in Ausnahmefällen darauf zurückgreifen, wenn dies wirklich erforderlich ist!


Fehlerbehandlung


Die Hauptsache bei „unerwarteten“ Fehlern ist, einen Ort zu finden, an dem Sie sie abfangen können. In JVM-Sprachen kann dies entweder ein Stream-Erstellungspunkt oder ein Filter- / Einstiegspunkt für die http-Methode sein, wo Sie einen Try-Catch für die Behandlung unchecked Fehler setzen können. Wenn Sie ein Framework verwenden, kann es höchstwahrscheinlich bereits allgemeine Fehlerbehandlungsroutinen erstellen, da Sie beispielsweise im Spring Framework Methoden mit der Annotation @ExceptionHandler .


Sie können Ausnahmen zu diesen zentralen Verarbeitungspunkten "auslösen", die wir an bestimmten Stellen nicht behandeln möchten, indem Sie dieselben nicht unckecked Ausnahmen unckecked (wenn wir beispielsweise nicht wissen, was an einer bestimmten Stelle zu tun ist und wie der Fehler zu behandeln ist). Diese Methode ist jedoch nicht immer geeignet, da manchmal der Fehler behoben werden muss und Sie überprüfen müssen, ob alle Stellen von Funktionsaufrufen korrekt verarbeitet werden. Überlegen Sie, wie Sie dies tun können.


  1. Verwenden Sie weiterhin Ausnahmen und denselben Try-Catch:


      int a = 10; int b = 20; int sum; try { sum = calculateSum(a,b); } catch (Exception e) { sum = -1; } 

    Der Hauptnachteil besteht darin, dass wir „vergessen“ können, es an der Stelle des Aufrufs in einen Try-Catch zu packen und den Versuch, es an Ort und Stelle zu verarbeiten, zu überspringen, wodurch die Ausnahme bis zum allgemeinen Punkt der Fehlerverarbeitung ausgelöst wird. Hier können wir zu checked Ausnahmen (für Java) gehen, aber dann werden wir alle oben genannten Nachteile bekommen. Dieser Ansatz ist praktisch, wenn eine Fehlerbehandlung nicht immer erforderlich ist, in seltenen Fällen jedoch erforderlich ist.


  2. Verwenden Sie die versiegelte Klasse als Ergebnis eines Anrufs (Kotlin).
    In Kotlin können Sie die Anzahl der Klassenerben begrenzen und sie in der Kompilierungsphase berechenbar machen. Auf diese Weise kann der Compiler überprüfen, ob alle möglichen Optionen im Code analysiert wurden. In Java können Sie eine gemeinsame Schnittstelle und mehrere Nachkommen erstellen, wobei jedoch Überprüfungen auf Kompilierungsebene verloren gehen.


     sealed class Result data class SuccessResult(val value: Int): Result() data class ExceptionResult(val exception: Exception): Result() val a = 10 val b = 20 val sum = when (val result = calculateSum(a,b)) { is SuccessResult -> result.value is ExceptionResult -> { result.exception.printStackTrace() -1 } } 

    Hier erhalten wir so etwas wie einen golang Fehler-Ansatz, wenn Sie die resultierenden Werte explizit überprüfen (oder explizit ignorieren) müssen. Der Ansatz ist sehr praktisch und besonders praktisch, wenn Sie in jeder Situation viele Parameter eingeben müssen. Die Result Klasse kann mit verschiedenen Methoden erweitert werden, die es einfacher machen, das Ergebnis mit einer Ausnahme zu erhalten, die oben ausgelöst wurde, falls vorhanden (d. H. Wir müssen den Fehler am Ort des Aufrufs nicht behandeln). Der Hauptnachteil wird nur die Erstellung überflüssiger Zwischenobjekte (und ein etwas ausführlicherer Eintrag) sein, aber es kann auch mit inline Klassen entfernt werden (wenn ein Argument für uns ausreicht). und als besonderes Beispiel gibt es eine Result von Kotlin. Es ist wahr, es ist nur für den internen Gebrauch, als In Zukunft kann sich die Implementierung geringfügig ändern. Wenn Sie sie jedoch verwenden möchten, können Sie das Kompilierungsflag -Xallow-result-return-type hinzufügen.


  3. Als einer der möglichen Typen von Anspruch 2 kann die Verwendung des Typs aus der funktionalen Programmierung von Either entweder ein Ergebnis oder ein Fehler sein. Der Typ selbst kann entweder eine sealed Klasse oder eine inline Klasse sein. Unten finden Sie ein Beispiel für die Verwendung der Implementierung aus der arrow :


     val a = 10 val b = 20 val value = when(val result = calculateSum(a,b)) { is Either.Left -> { result.a.printStackTrace() -1 } is Either.Right -> result.b } 

    Either am besten für diejenigen geeignet, die einen funktionalen Ansatz lieben und gerne Anrufketten aufbauen.


  4. Verwenden Sie Option oder nullable Typ von Kotlin:


     fun testFun() { val a = 10 val b = 20 val sum = calculateSum(a,b) ?: throw RuntimeException("some exception") } fun calculateSum(a: Int, b: Int): Int? 

    Dieser Ansatz ist geeignet, wenn die Fehlerursache nicht sehr wichtig ist und wenn es sich nur um eine handelt. Eine leere Antwort wird als Fehler betrachtet und höher geworfen. Der kürzeste Datensatz, ohne zusätzliche Objekte zu erstellen, aber dieser Ansatz kann nicht immer angewendet werden.


  5. Verwendet ähnlich wie in Punkt 4 nur einen Hardcode-Wert als Fehlermarkierung:


     fun testFun() { val a = 10 val b = 20 val sum = calculateSum(a,b) if (sum == -1) { throw RuntimeException(“error”) } } fun calculateSum(a: Int, b: Int): Int 

    Dies ist wahrscheinlich der älteste Ansatz zur Fehlerbehandlung, der von C (oder sogar von Algol) stammt. Es gibt keinen Overhead, nur einen Code, der nicht ganz klar ist (zusammen mit Einschränkungen bei der Auswahl des Ergebnisses), aber im Gegensatz zu Schritt 4 ist es möglich, verschiedene Fehlercodes zu erstellen, wenn mehr als eine mögliche Ausnahme erforderlich ist.



Schlussfolgerungen


Alle Ansätze können je nach Situation kombiniert werden, und es gibt keinen, der in allen Fällen geeignet ist.


So können Sie beispielsweise mithilfe sealed Klassen einen golang Ansatz für Fehler erzielen. golang dies nicht sehr praktisch ist, fahren Sie mit unchecked Fehlern fort.


Oder verwenden Sie in den meisten nullable Typ als Markierung dafür, dass der Wert nicht berechnet oder von irgendwoher abgerufen werden konnte (z. B. als Indikator dafür, dass der Wert nicht in der Datenbank gefunden wurde).


Und wenn Sie voll funktionsfähigen Code zusammen mit arrow oder einer ähnlichen Bibliothek haben, ist es höchstwahrscheinlich am besten, Either zu verwenden.


Bei http-Servern ist es am einfachsten, alle Fehler auf zentrale Punkte zu heben und nur an einigen Stellen den nullable Ansatz mit sealed Klassen zu kombinieren.


Ich werde mich freuen, in den Kommentaren zu sehen, dass Sie dies verwenden, oder gibt es vielleicht andere bequeme Methoden zur Fehlerbehandlung?


Und danke an alle, die bis zum Ende gelesen haben!

Source: https://habr.com/ru/post/de471766/


All Articles