Съдържание на курса

Урок 5 — Грешки и изключения

От Dorian Chávez · основател на Hábil и архитект на интеграции ·

Време: 90 мин (точно: в урока има четири идеи и един проект и е добре да не го избързваш)

Какво изграждаш: четенето на конфигурацията на revisor: списъкът с услуги престава да е написан в кода и се чете от файл servicios.txt, с ясно съобщение за всеки начин, по който файлът може да е грешен

Какво научаваш: проверявани и непроверявани изключения; try, catch и finally; try-with-resources; собствени изключения с причината им; кога резултатът е по-добър от изключението; и какво да правиш с InterruptedException

Откъде идваш. Носиш проекта от Урок 4: revisor със своя Simulador, Resumen, който брои със стриймове (streams), Reporte със защитно копие и таблица с ред за обобщение. Работи, но има две дълбоки слабости. Първата е тази, която ти остави упражнението за проекта от урок 4: за да добавиш услуга, трябва да отвориш кода на Java, да го редактираш и да прекомпилираш; данните и програмата се променят по различни причини и не бива да живеят заедно. Втората е по-тънка: проверката е мигновена, защото симулаторът връща времето, без да го изчаква, и затова нямаш начин да видиш как се държи програмата, когато една проверка се бави. Този урок поправя и двете: данните отиват във файл, който програмата чете и на който не вярва сляпо, а симулацията се учи да изчаква това, което казва нейното време. Работиш в папката си ~/curso-java/revisor/ и изпълняваш всичко с java.

Какво не прави този урок. Не пита никаква мрежа: продължава да използва симулирани данни и това не се променя до Урок 8. Не използва Maven, нито тестове: Maven идва в Урок 6, а тестовете — в Урок 7. И revisor още няма опции на командния ред, нито код на изход (exit code): днес името на файла е написано в програмата и когато нещо се провали, програмата отпечатва причината и завършва с код 0. Това е недостатък, който Урок 10 поправя, и този урок го отбелязва, когато го видиш.

За изпълненията. За да изпълниш една фигура, влез в папката на урока в хранилището (programas/05-errores-excepciones/) и стартирай java fig05_01.java, с номера на фигурата, която искаш. Тези, които живеят в собствена папка (тази от раздел 5.3 и тази на проекта от 5.6), се изпълняват отвътре. Всички изходи са получени с Java 25 (Temurin 25.0.4) и терминал в UTF-8. Следите на грешките съдържат редове от самия JDK (at java.base/...) с номера на редове, които се менят от една актуализация на друга: нормално е твоите да се различават в тези номера.

След урока ще можеш да

  • Кажеш за един тип изключение дали е проверявано, или непроверявано и какво задължава компилаторът във всеки случай.
  • Напишеш try с catch и finally, като прихващаш само изключението, което умееш да обработиш, и в реда, който компилаторът приема.
  • Отвориш текстов файл с try-with-resources и обясниш какво гарантира тази форма в сравнение със затварянето на ръка.
  • Дефинираш собствено изключение, което запазва първоначалната причина, и прочетеш следа на стека с нейното Caused by.
  • Решиш дали една повреда в програмата ти се представя по-добре с изключение, или със стойност, и защитиш решението.
  • Обработиш InterruptedException, без да я гълташ: да я предадеш нагоре или да възстановиш флага за прекъсване.

Защо, преди как

До урок 4 всички данни на revisor си ги писал ти, в същия файл като кода. Ако нещо беше грешно, го узнаваше веднага, защото компилаторът не те оставяше да минеш. Конфигурационният файл променя тази ситуация напълно: пише го друг човек, в друг момент, с друг редактор, а програмата научава за грешките му при изпълнение. Един файл отвън може да се провали по четири начина и четирите са нормални, не изключителни:

  1. Не съществува или съществува в друга папка, различна от тази, която мислиш.
  2. Съществува, но не може да се прочете, защото е в друго кодиране или някой го е оставил без права.
  3. Формално е пълен, но казва нещо абсурдно: адрес, който не започва с http, отрицателен таймаут, две услуги с едно и също име.
  4. Няма полезно съдържание: само коментари.

Сериозната програма различава тези случаи и казва на човека кой от тях му се е случил и как да го поправи, в един ред, а не с отчет от трийсет реда, написан за програмисти. Това прави този урок.

Но revisor има и друг вид повреда, който не прилича на никой от предишните: услуга, която не отговаря. Проверката на паднала услуга не е грешка на програмата; тя съществува тъкмо за това. Ако програмата третираше падналата услуга като извънредна ситуация, една-единствена паднала щеше да събори отчета на останалите деветдесет и девет. Това решение вече го взе в урок 3, без да го наричаш така: SinRespuesta е стойност, а не изключение. Този урок го обяснява и го защитава, защото да разбереш кога нещо е изключение и кога е стойност е най-важното, което Java има да те научи за грешките.

Един коментар за този, който идва от други езици. В Go грешката е стойност, която функциите връщат и която се проверява с if err != nil. В Rust е Result. В Python и TypeScript е изключение, което се издига, докато някой го прихване. Java прави и двете: има изключения и има стойности като Optional и sealed от урок 3. И добавя нещо, което другите нямат: компилаторът различава два вида изключения и един от тях задължава да бъде обработен. Това е частта, която отначало се усеща странна и която има смисъл; първият раздел тръгва оттам.

Пътят е за една сесия. Раздел 5.1 покрива проверяваните и непроверяваните изключения и синтаксиса на try; 5.2 — try-with-resources и четенето на файлове; 5.3 — собствените изключения; 5.4 — кога да предпочетеш стойност; 5.5 — InterruptedException; а 5.6 събира всичко в проекта. Ето крайната цел, за да я имаш пред очите си. Стартиран от друга папка, а не от тази на конфигурационния файл, revisor ще каже:

error de configuración: no existe el archivo servicios.txt

Съобщение от един ред вместо следа на стека. За да стигнем дотам, първо трябва да разберем какво е изключение.

Понятията

5.1 Проверявани и непроверявани изключения

5.1.1 Какво е изключение и какви семейства има

Изключение е обект, който описва, че нещо се е объркало. Когато един ред от програмата не може да направи това, което му е поискано, той хвърля изключение (с думата throw или защото го прави отвътре метод, който си извикал). Изключението се издига през методите, които са били в ход, от най-новия към най-стария, търсейки някого, който да го прихване. Ако никой не го прихване, нишката завършва и JVM отпечатва следата на стека, която вече познаваш. Урок 4 те научи да я четеш отгоре надолу; сега ще видим какво стои зад нея.

Всички изключения наследяват от един клас: Throwable. Оттам излизат два клона. Error е за тежките проблеми на самата JVM (паметта е свършила, повреден файл с класове) и правилото е, че не се прихващат: нямаш как да се възстановиш. Exception е за онова, което една разумна програма може да поиска да обработи. И вътре в Exception има един специален подклон, RuntimeException (изключение по време на изпълнение), с който Java разделя света на две:

  • Непроверявани изключения (unchecked): RuntimeException, Error и всичко, което наследява от тях. Компилаторът не те задължава да правиш нищо с тях. Това са програмистки грешки: NullPointerException, IllegalArgumentException, UnsupportedOperationException от урок 4.
  • Проверявани изключения (checked): всички останали, тоест Exception и нейните наследници, които не са RuntimeException. Компилаторът те задължава да ги обработиш и ако не го направиш, програмата не се компилира. Това е определението от спецификацията на езика: проверяваните са всички, които не са непроверявани. (В останалата част на курса използвам „проверявано“ и „непроверявано“; „checked“ и „unchecked“ са английските имена и ще ги виждаш в цялата документация.)

Програмата fig05_01 отпечатва веригата на наследяване на пет изключения и казва към кой клас принадлежи всяко. За да се напише, е нужен for с условие вместо обхождане (for (начало; условие; стъпка): започва в начало, повтаря, докато условие е вярно, и изпълнява стъпка в края на всяка итерация), един Class<?> (типът, който представлява клас; ? е заместителят от урок 4) и методите getSuperclass(), който дава родителя, и isAssignableFrom, който отговаря на „този тип наследява ли от онзи?“:

// fig05_01.java
void cadena(Class<?> tipo) {
  String texto = tipo.getSimpleName();
  for (Class<?> padre = tipo.getSuperclass(); padre != null; padre = padre.getSuperclass()) {
    texto = texto + " -> " + padre.getSimpleName();
  }
  boolean esExcepcion = Exception.class.isAssignableFrom(tipo);
  boolean esNoVerificada = RuntimeException.class.isAssignableFrom(tipo);
  String clase;
  if (Error.class.isAssignableFrom(tipo)) {
    clase = "error de la JVM";
  } else if (esExcepcion && !esNoVerificada) {
    clase = "verificada";
  } else {
    clase = "no verificada";
  }
  IO.println(texto);
  IO.println("    " + clase);
}

void main() {
  cadena(NoSuchFileException.class);
  cadena(InterruptedException.class);
  cadena(NumberFormatException.class);
  cadena(NullPointerException.class);
  cadena(OutOfMemoryError.class);
}
$ java fig05_01.java
NoSuchFileException -> FileSystemException -> IOException -> Exception -> Throwable -> Object
    verificada
InterruptedException -> Exception -> Throwable -> Object
    verificada
NumberFormatException -> IllegalArgumentException -> RuntimeException -> Exception -> Throwable -> Object
    no verificada
NullPointerException -> RuntimeException -> Exception -> Throwable -> Object
    no verificada
OutOfMemoryError -> VirtualMachineError -> Error -> Throwable -> Object
    error de la JVM

Веригата се чете отляво надясно като „е един... който е един...“. NoSuchFileException, която Java хвърля, когато файл не съществува, е FileSystemException, която е IOException, която е Exception: проверявана. InterruptedException е пряк наследник на Exception: проверявана. NumberFormatException минава през IllegalArgumentException и през RuntimeException: непроверявана. NullPointerException също. А OutOfMemoryError е в клона на Error, който е от друго семейство: никога няма да я прихванеш. Забележи и че проверяваната от първия ред е IOException: цялото семейство проблеми на входа и изхода (файлове, мрежа) е проверявано. Това е видът грешка, която програмата не може да избегне, като пише по-добър код, защото зависи от външен свят, който може да се провали.

5.1.2 Какво налага компилаторът: прихвани я или я декларирай

Нека видим какво значи „компилаторът те задължава“. Тази програма чете текстов файл и брои редовете му. Изглежда безобидна:

// fig05_02.java
void main() {
  List<String> renglones = Files.readAllLines(Path.of("servicios.txt"));
  IO.println(renglones.size());
}
$ java fig05_02.java
fig05_02.java:3: error: unreported exception IOException; must be caught or declared to be thrown
  List<String> renglones = Files.readAllLines(Path.of("servicios.txt"));
                                             ^
1 error
error: compilation failed

unreported exception IOException; must be caught or declared to be thrown: „необявено изключение IOException; трябва да бъде прихванато или декларирано“. Стрелката ^ сочи към извикването на readAllLines, което може да я хвърли. За да се компилира програмата, имаш две възможности и едната от двете е задължителна:

  1. Да я прихванеш там, където можеш да я обработиш, с try и catch (следващият раздел).
  2. Да я декларираш: да предупредиш в сигнатурата на метода, че може да я хвърли, с throws. Например void main() throws IOException. Декларирането не обработва грешката; прехвърля я на този, който извиква метода, а той на свой ред ще трябва да я прихване или декларира. Изключението се издига, докато някой го прихване; ако стигне до main и main също го декларира, JVM отпечатва следата на стека и програмата умира.

Идеята зад правилото е, че някои повреди не са грешки на програмиста, а на ситуацията (файлът може да не съществува) и че би било несправедливо кодът да ги игнорира по небрежност. Компилаторът превръща „забравих, че това може да се провали“ в грешка при компилация. Ако ти се струва тежест, запази преценката си до раздел 5.4: това е инструмент с точно определена употреба.

5.1.3 try, catch и finally

Да прихванеш изключение значи да кажеш: „опитай това; ако се провали по този начин, направи онова вместо да умреш“. Пише се с try (опитай), catch (прихвани) и по желание finally (накрая). Програмата fig05_03 превръща текст в число, което е нужно на конфигурацията, за да прочете timeout_ms:

// fig05_03.java
int leerTimeout(String texto) {
  try {
    int valor = Integer.parseInt(texto);
    IO.println("  convertí " + valor);
    return valor;
  } catch (NumberFormatException e) {
    IO.println("  no sirve: " + e.getMessage());
    return -1;
  } finally {
    IO.println("  (fin de la conversión)");
  }
}

void main() {
  IO.println("leyendo 5000");
  IO.println("  resultado: " + leerTimeout("5000"));
  IO.println("leyendo 5 s");
  IO.println("  resultado: " + leerTimeout("5 s"));
}
$ java fig05_03.java
leyendo 5000
  convertí 5000
  (fin de la conversión)
  resultado: 5000
leyendo 5 s
  no sirve: For input string: "5 s"
  (fin de la conversión)
  resultado: -1

Integer.parseInt(texto) превръща текст в int. С „5000“ работи; с „5 s“ хвърля NumberFormatException със съобщение For input string: "5 s" (текстът, който не е могъл да превърне). Трите блока:

  • try { ... } съдържа реда, който може да се провали, за предпочитане само него и тези, които зависят от него. Ако няма грешка, програмата продължава до края на блока. Ако има, Java напуска try на същия ред и скача към catch, който съответства.
  • catch (NumberFormatException e) { ... } се изпълнява, ако грешката е от този тип. e е прихванатото изключение: e.getMessage() дава съобщението му. Всеки друг тип изключение би минал нататък.
  • finally { ... } се изпълнява при излизане от try, по какъвто и начин да се излезе: независимо дали е имало грешка, и дори ако try или catch са направили return. Единственото изключение е, когато JVM спре рязко, докато е вътре в try или в catch: извикване на System.exit (което урок 10 обяснява), срив на процеса или принудително изключване. Погледни изхода: с „5000“ се отпечатва „convertí 5000“ (преобразувах 5000), после finally, и след това резултатът стига до извикващия, макар че return valor стои по-рано в кода. Използва се за почистване. Документацията на Oracle го обобщава така: „блокът finally винаги се изпълнява, когато try завърши“, а същата страница отбелязва това изключение.

Бележка за формата: да върнеш -1, за да кажеш „невалидно“, е лош навик, защото е магическа стойност (нищо не пречи някой да я използва, като че ли е истинско време). Тук е само защото примерът е минимален; в раздел 5.3 catch ще направи нещо по-добро.

Има три правила за добро прихващане и трите се нарушават толкова често, че е добре да бъдат записани:

  1. Прихващай конкретното изключение, което умееш да обработиш. Един catch (Exception e) прихваща и NullPointerException, получено от правописна грешка, и програмата ще продължи да работи с погрешни данни. По-добре да спре със своята следа на стека.
  2. Ако не знаеш какво да правиш с една грешка, не я прихващай. Остави я да се издигне. Програма, която спира с ясна следа на стека, е по-добра от такава, която продължава на сляпо.
  3. Един-единствен catch може да покрие два типа, ако отговорът е един и същ, с вертикална черта: catch (NumberFormatException | NullPointerException e). Типовете не могат да бъдат единият наследник на другия.

Редът на catch има значение и компилаторът го следи. Ако най-общият е първи, останалите никога не биха могли да се изпълнят:

// fig05_04.java
void main() {
  try {
    IO.println(Integer.parseInt("5 s"));
  } catch (Exception e) {
    IO.println("algo falló");
  } catch (NumberFormatException e) {
    IO.println("no es un número");
  }
}
$ java fig05_04.java
fig05_04.java:7: error: exception NumberFormatException has already been caught
  } catch (NumberFormatException e) {
    ^
1 error
error: compilation failed

exception NumberFormatException has already been caught: „вече е била прихваната“. Първият catch (Exception e) изяжда всичко, включително NumberFormatException, и вторият блок би бил мъртъв код. Правилото: от частното към общото, както когато switch от урок 3 изискваше да подредиш случаите.

5.1.4 Защо проверявано изключение не пресича ламбдата на map

Има едно място, където правилото за проверяваните се усеща като пречка, и е добре да го познаваш още сега, защото ще се сблъскаш с него в revisor. Това е вътре в ламбда. Тази програма се опитва да изчака малко вътре в map:

// fig05_05.java
void main() {
  List<String> nombres = List.of("catalogo", "pagos");
  List<String> revisados =
      nombres.stream()
          .map(
              nombre -> {
                Thread.sleep(10);
                return nombre + " revisado";
              })
          .toList();
  IO.println(revisados);
}
$ java fig05_05.java
fig05_05.java:8: error: unreported exception InterruptedException; must be caught or declared to be thrown
                Thread.sleep(10);
                            ^
1 error
error: compilation failed

Същата грешка като преди, но сега вътре в ламбдата: Thread.sleep хвърля InterruptedException (проверявана), а ламбдата на map нито я декларира, нито я прихваща. Причината е, че map очаква Function, функционалния интерфейс от урок 4, а методът apply на една Function не декларира никакво проверявано изключение в сигнатурата си. Една ламбда не може да хвърля повече от това, което интерфейсът ѝ декларира. (С други интерфейси може: методът call на Callable декларира throws Exception и в урок 9 ще се възползваш от това.) Изходите са три и нито един не е приятен: да прихванеш изключението вътре в ламбдата (тялото се пълни с try), да го обвиеш в непроверявано (губи се предимството на проверяваното) или да не използваш стрийм там и да се върнеш към цикъл for, където методът, който го съдържа, може да декларира throws. В днешния проект ще вземем последния: revisarTodos минава от стрийм на for. Това не е крачка назад, а избор на инструмента, който позволява това, което задачата иска.

Ако предпочиташ препратка към метод (.map(Revision::revisar)), съобщението се променя, но причината е същата: incompatible thrown types InterruptedException in functional expression. Упражнение 4 те моли да я предизвикаш.

5.1.5 Кога проверявано и кога не?

Когато проектираш свои изключения, трябва да решиш от кое семейство са, а ръководството на Oracle дава правило от едно изречение: „ако може разумно да се очаква, че извикващият ще се възстанови от изключението, направи го проверявано; ако не може да направи нищо, за да се възстанови, направи го непроверявано“. Конфигурационен файл, който не съществува, е добър пример за първото: извикващият може да направи нещо полезно (да предупреди потребителя как да го поправи). null там, където никога не е трябвало да го има, е примерът за второто: няма какво да се „възстановява“, има дефект, който трябва да се поправи.

Честно е да се каже, че това решение на Java е спорно. Проверяваните налагат полезна дисциплина, но в големи проекти пораждат каскада от throws във всички сигнатури и от catch, които само препращат, а други езици от същото семейство, като C# и Kotlin, са решили да нямат такива. Java ги запазва и твоята работа не е да имаш мнение за тях, а да знаеш, че компилаторът ги третира различно и защо.

5.2 try-with-resources: каквото се отваря, се затваря

5.2.1 Ресурси и формата, която ги затваря сама

Ресурс е нещо, което се отваря и трябва да се затвори: файл, мрежова връзка. Ако не го затвориш, операционната система го държи отворен: при файл това означава дескриптор, който свършва, а при връзка — сървър, който продължава да чака. Старият начин да го затвориш добре беше try с finally, който затваря ресурса, и има семеен недостатък: ако try хвърли изключение и close() във finally също, второто покрива първото и губиш грешката, която наистина е имала значение.

Java 7 добави форма, която го решава: try-with-resources (try с ресурси). Поставяш ресурсите в скоби веднага след try и Java ги затваря сама, в обратен ред на този, в който ги е отворила, независимо дали блокът завършва добре, или с изключение. Ресурс е всеки обект, чийто тип имплементира интерфейса AutoCloseable, който има един-единствен метод, close(). Програмата fig05_06 го демонстрира с измислен ресурс, който съобщава кога се отваря и кога се затваря:

// fig05_06.java
record Recurso(String nombre, boolean falla) implements AutoCloseable {
  Recurso {
    IO.println("abro " + nombre);
  }

  @Override
  public void close() {
    IO.println("cierro " + nombre);
    if (falla) {
      throw new IllegalStateException("no pude cerrar " + nombre);
    }
  }
}

void main() {
  try (Recurso entrada = new Recurso("entrada", false);
      Recurso salida = new Recurso("salida", false)) {
    IO.println("trabajo con " + entrada.nombre() + " y " + salida.nombre());
  }
  IO.println("--");
  try (Recurso fragil = new Recurso("frágil", true)) {
    IO.println("trabajo con " + fragil.nombre());
    throw new IllegalArgumentException("falló el trabajo");
  } catch (IllegalArgumentException e) {
    IO.println("atrapé: " + e.getMessage());
    for (Throwable suprimida : e.getSuppressed()) {
      IO.println("  suprimida: " + suprimida.getMessage());
    }
  }
}
$ java fig05_06.java
abro entrada
abro salida
trabajo con entrada y salida
cierro salida
cierro entrada
--
abro frágil
trabajo con frágil
cierro frágil
atrapé: falló el trabajo
  suprimida: no pude cerrar frágil

Преди да прочетеш изхода, три подробности от декларацията, които още не си виждал. implements AutoCloseable е същото implements от урок 3: record-ът се задължава да изпълнява договора на интерфейса, който изисква метод close(). @Override е анотация: казва на компилатора „този метод заменя този от интерфейса“ и ако сгрешиш името, се проваля при компилация, вместо да създаде различен метод, без да забележиш. А public е дума, която още не ти е трябвала: методите на един интерфейс са винаги публични и затова този, който ги имплементира, трябва да го напише. В урок 6 ще видиш цялата дума, с нейното значение и нейните посестрими.

Сега изходът. В първия блок отваряш два ресурса (entrada и salida) и те се затварят наобратно: първо salida, после entrada. Това е правилният ред: последното, което си отворил, обикновено зависи от предишното. Във втория блок close() на frágil хвърля изключение, както и тялото на try (хвърля го throw new IllegalArgumentException(...), което е начинът да хвърлиш такова на ръка). Резултатът: catch получава изключението от тялото, онова, което е имало значение (falló el trabajo), а това, което е хвърлил close(), нито се губи, нито го покрива: остава прикачено към първото като потиснато изключение и се извлича с e.getSuppressed(). Ръководството на Oracle го обяснява с друг пример: с finally изключението, което излиза, е това от finally, а това от try се потиска; с try-with-resources е обратното и това е редът, който искаш.

5.2.2 Четене на текстов файл

За да прочетеш servicios.txt на revisor, са ти нужни три части, които не си използвал. Path представлява път до файл като стойност и се създава с Path.of("servicios.txt"); ruta.getFileName() дава последната част. Files е клас със статични методи за работа върху Path. А BufferedReader е обект, който чете текст ред по ред. Files.newBufferedReader(ruta) отваря файла и връща BufferedReader, а тъй като е ресурс, влиза в try-with-resources:

try (BufferedReader lector = Files.newBufferedReader(ruta)) {
  String renglon;
  while ((renglon = lector.readLine()) != null) {
    // ...usar el renglón...
  }
}

lector.readLine() връща следващия ред като String или null, когато файлът е свършил. Тук null е начинът, който Java е избрала от първите си версии, за да каже „няма повече“; Optional не е съществувал. while (докато) е цикъл, който повтаря тялото си, докато условието е вярно, а условието има много често срещан трик: (renglon = lector.readLine()) != null присвоява и сравнява едновременно, защото присвояването струва колкото присвоеното. Чете се „прочети ред и го запази в renglon; ако прочетеното не е null, влез в тялото“.

Три неща за Files, които е добре да знаеш, всичките от документацията на класа:

  • Чете в UTF-8. Files.newBufferedReader(ruta) и Files.readAllLines(ruta) декодират файла с UTF-8, което е това, което искаш за файл с ударения. (Други части на Java, като FileReader, използваха кодирането на машината и затова една програма четеше добре на твоя компютър и зле на друг; от Java 18, JEP 400, кодирането по подразбиране на цялата платформа също е UTF-8.)
  • Относителният път се разрешава от папката, в която изпълняваш java, а не оттам, където живее файлът .java. Path.of("servicios.txt") търси servicios.txt в текущата папка на терминала. Това е причината за почти всички „не намира файла“, които ще видиш, и проектът от раздел 5.6 ще го покаже.
  • Файл, който не съществува, хвърля NoSuchFileException, която, както видя във веригата на наследяване, е IOException: проверявана. Файл, който съществува, но има байтове, които не са валиден UTF-8, хвърля при четене MalformedInputException, друга IOException.

Един вариант, който да имаш предвид: Files.readAllLines(ruta) чете целия файл в списък от редове и го затваря вместо теб (не ти трябва try-with-resources) и върши работа за малки файлове. Files.lines(ruta) връща стрийм от редове, но него трябва да го затвориш: потокът държи файла отворен и документацията му го казва с тези думи. За revisor BufferedReader има решаващо предимство: знаеш на кой ред си, без да пишеш отделен индекс, а номерът на реда е онова, което е нужно на едно полезно съобщение за грешка.

5.3 Собствени изключения, с тяхната причина

5.3.1 Защо собствено

Конфигурацията може да се провали по дузина причини и всички кацат на едно и също място: main, който трябва да каже нещо на човек. Ако всяка причина беше различно изключение на JDK, main щеше да трябва да прихваща NoSuchFileException, MalformedInputException, URISyntaxException, NumberFormatException и още половин дузина и да знае подробностите на всяко. По-лошо: ако всички бяха IllegalArgumentException, main нямаше да може да различи „конфигурацията е грешна“ от „моята програма има дефект“. Решението е изключение със собствено име, което значи нещо от домейна: ErrorDeConfiguracion. Ще има едно-единствено значение („конфигурационният файл не може да се използва“), main ще прихваща само него и всяко друго, което се измъкне, ще бъде, с право, повреда на програмата, която трябва да я спре със своята следа на стека.

Собствено изключение се дефинира с ред наследяване. Погледни фигура fig05_07, папка с два файла: дефиницията на изключението и програма, която го използва. Новата дума е extends: клас, който extends друг, наследява всичко, което другият има. Като implements, но за клас вместо за интерфейс: ErrorDeConfiguracion е Exception и носи всички нейни методи (getMessage, getCause, printStackTrace), без да ги пишеш. Единственото, което трябва, е да се каже как се конструира, а за това се извиква конструкторът на родителския клас с super: super(mensaje, causa) означава „конструирай ме като Exception с това съобщение и тази причина“.

// fig05_07/ErrorDeConfiguracion.java
final class ErrorDeConfiguracion extends Exception {
  ErrorDeConfiguracion(String mensaje) {
    super(mensaje);
  }

  ErrorDeConfiguracion(String mensaje, Throwable causa) {
    super(mensaje, causa);
  }
}
// fig05_07/Demo.java
void leer(Path ruta) throws ErrorDeConfiguracion {
  try {
    Files.readAllLines(ruta);
  } catch (NoSuchFileException e) {
    throw new ErrorDeConfiguracion("no existe el archivo " + ruta.getFileName(), e);
  } catch (IOException e) {
    throw new ErrorDeConfiguracion("no se pudo leer el archivo " + ruta.getFileName(), e);
  }
}

void main() {
  try {
    leer(Path.of("servicios-que-no-existe.txt"));
  } catch (ErrorDeConfiguracion e) {
    IO.println("mensaje: " + e.getMessage());
    IO.println("causa:   " + e.getCause());
    e.printStackTrace();
  }
}

Програмата Demo дефинира метод leer, който се опитва да прочете файл, който не съществува, и ако се провали, хвърля ErrorDeConfiguracion с причина: throw new ErrorDeConfiguracion("no existe el archivo " + ruta.getFileName(), e). Вторият аргумент, e, е първоначалното изключение. Двата catch са в правилния ред: първо NoSuchFileException (по-частното), после общото IOException. Стартирай фигурата от нейната папка:

$ cd fig05_07
$ java Demo.java
mensaje: no existe el archivo servicios-que-no-existe.txt
causa:   java.nio.file.NoSuchFileException: servicios-que-no-existe.txt
ErrorDeConfiguracion: no existe el archivo servicios-que-no-existe.txt
	at Demo.leer(Demo.java:6)
	at Demo.main(Demo.java:14)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
	at java.base/java.lang.reflect.Method.invoke(Method.java:565)
	at jdk.compiler/com.sun.tools.javac.launcher.SourceLauncher.execute(SourceLauncher.java:256)
	at jdk.compiler/com.sun.tools.javac.launcher.SourceLauncher.run(SourceLauncher.java:138)
	at jdk.compiler/com.sun.tools.javac.launcher.SourceLauncher.main(SourceLauncher.java:76)
Caused by: java.nio.file.NoSuchFileException: servicios-que-no-existe.txt
	at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:106)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:111)
	at java.base/sun.nio.fs.UnixFileSystemProvider.newFileChannel(UnixFileSystemProvider.java:213)
	at java.base/sun.nio.fs.UnixFileSystemProvider.newByteChannel(UnixFileSystemProvider.java:244)
	at java.base/java.nio.file.Files.newByteChannel(Files.java:357)
	at java.base/java.nio.file.Files.newByteChannel(Files.java:399)
	at java.base/java.nio.file.spi.FileSystemProvider.newInputStream(FileSystemProvider.java:371)
	at java.base/java.nio.file.Files.newInputStream(Files.java:154)
	at java.base/java.nio.file.Files.newBufferedReader(Files.java:2645)
	at java.base/java.nio.file.Files.readAllLines(Files.java:3085)
	at java.base/java.nio.file.Files.readAllLines(Files.java:3122)
	at Demo.leer(Demo.java:4)
	... 6 more

Прочети изхода спокойно, защото е първата следа на стека с две части. Първите два реда са тези, които е отпечатала програмата: mensaje: е това, което би казал на човека („no existe el archivo servicios-que-no-existe.txt“, тоест „файлът servicios-que-no-existe.txt не съществува“), а causa: е онова, което наистина е станало, с пълния му тип: java.nio.file.NoSuchFileException: servicios-que-no-existe.txt. Останалото е това, което отпечатва e.printStackTrace(), който записва пълната следа на стека в изхода за грешки. Има два блока. Първият е изключението, което си прихванал, с неговата следа: Demo.leer(Demo.java:6) е редът на throw, а Demo.main(Demo.java:14) е този, който е извикал. Следващите редове, от jdk.internal.reflect и SourceLauncher, са машинарията, с която java Archivo.java стартира програмата ти. Вторият блок започва с Caused by: („причинено от“): това е причината, с нейна собствена следа, която слиза до операционната система (sun.nio.fs.UnixException...) и се качва през Files.readAllLines до реда Demo.leer(Demo.java:4), който е редът, където наистина е опитано да се отвори файлът. Последният ред, ... 6 more („и още 6“), означава, че останалите шест реда са същите, които вече си видял по-горе.

Това дава стойността на запазването на причината. Съобщението е за човека, който използва програмата: кратко, на испански, без жаргон. Причината е за този, който трябва да оправи проблема: първоначалното изключение с пълната му следа на стека. Да загубиш причината значи да загубиш следата. Две правила на курса, които произтичат оттук:

  1. Когато превръщаш едно изключение в друго, подай първоначалното като причина, винаги.
  2. Съобщението казва какво е станало и какво да се направи; причината казва защо. В съобщението на revisor никога не се появява текстът на първоначалното изключение, защото се променя между системи и версии; той е в причината.

5.3.2 Най-лошият начин да обработиш грешка: да я игнорираш

Ако има нещо, което никога не бива да правиш с едно изключение, то е това:

// fig05_08.java
int leerTimeout(String texto) {
  try {
    return Integer.parseInt(texto);
  } catch (Exception e) {
  }
  return 5000;
}

void main() {
  IO.println("timeout_ms = " + leerTimeout("2000"));
  IO.println("timeout_ms = " + leerTimeout("dos mil"));
}
$ java fig05_08.java
timeout_ms = 2000
timeout_ms = 5000

Вторият ред казва timeout_ms = 5000, когато написаната стойност е била „dos mil“ (две хиляди), без никакъв знак, че нещо се е объркало. Празният catch (Exception e) е прихванал NumberFormatException, не е направил нищо с него и функцията е върнала стойността по подразбиране, като че ли нищо не е станало. Това е еквивалентът в Java на except: pass от Python. Човекът, написал „dos mil“, вярва, че програмата го е послушала, а програмата работи с друга стойност. Когато нещо наистина се провали, причината вече е изчезнала и няма данни за разследване. Празният catch е почти винаги грешка и има три причини да го помниш: изтрива причината, превръща грешка, която компилаторът ти е посочил, в такава, която никой не вижда, и се случва точно когато бързаш да „накараш да се компилира“.

Ако наистина не знаеш какво да правиш с едно изключение, не го прихващай; ако го прихванеш, направи нещо с него: поправи го, преведи го в друго с причина или го запиши и остави следа защо го игнорираш. Една бележка за printStackTrace(): добър е за учене и за дебъгване, но не е начин да обработиш грешка; само я показва.

5.3.3 Два канала на изхода

Един практичен въпрос, който възниква тук: къде пише revisor, когато нещо се провали? Програмата има два канала за текстов изход, които операционната система третира поотделно. System.out е нормалният изход и там пише IO.println. System.err е изходът за грешки, за предупрежденията и повредите. И двата се появяват в терминала ти, но един скрипт или конвейер (pipe) може да ги раздели: java Revisor.java 2> errores.txt праща само грешките във файл, а java Revisor.java > reporte.txt запазва само отчета. Затова конфигурационното съобщение се отпечатва със System.err.println(...). Който използва програмата в скрипт, трябва да може да запази чистия отчет и да види грешката отделно. IO.println няма версия за изхода за грешки и затова оттук нататък се използват изрично System.out и System.err, когато разликата има значение.

5.4 Изключение или стойност?

Вече имаш двата инструмента, с които да представиш, че нещо не е минало, както се е очаквало: изключението и стойността (Optional или запечатан тип като Respuesta). И най-важният въпрос на проектирането в урока: кога кое използвам? Програмата fig05_09 проверява същите три имена по двата начина, със услуга, която не отговаря, по средата:

// fig05_09.java
long tiempoOLanza(String nombre) {
  return switch (nombre) {
    case "catalogo" -> 142;
    case "pagos" -> 87;
    default -> throw new IllegalStateException(nombre + ": sin conexión");
  };
}

OptionalLong tiempoO(String nombre) {
  return switch (nombre) {
    case "catalogo" -> OptionalLong.of(142);
    case "pagos" -> OptionalLong.of(87);
    default -> OptionalLong.empty();
  };
}

void main() {
  List<String> nombres = List.of("catalogo", "reportes", "pagos");

  IO.println("con una excepción:");
  try {
    for (String nombre : nombres) {
      IO.println("  " + nombre + ": " + tiempoOLanza(nombre) + " ms");
    }
  } catch (IllegalStateException e) {
    IO.println("  se cayó el lote: " + e.getMessage());
  }

  IO.println("con un valor:");
  for (String nombre : nombres) {
    OptionalLong tiempo = tiempoO(nombre);
    String texto = tiempo.isPresent() ? tiempo.getAsLong() + " ms" : "sin respuesta";
    IO.println("  " + nombre + ": " + texto);
  }
}
$ java fig05_09.java
con una excepción:
  catalogo: 142 ms
  se cayó el lote: reportes: sin conexión
con un valor:
  catalogo: 142 ms
  reportes: sin respuesta
  pagos: 87 ms

С изключението, при втората услуга програмата изостави целия обход: pagos, която беше съвсем наред, никога не бе проверена. Със стойността всяка услуга произведе свой отговор, липсата на едната не засегна останалите и отчетът излезе пълен. Това е причината Respuesta.SinRespuesta от урок 3 да е стойност.

Първата версия би могла да се поправи, като се сложи try вътре в цикъла, за да се прихваща всяка услуга поотделно. Би проработило, но погледни какво направи: превърна изключението в стойност на всяка итерация, тоест преизобрети стойността, с повече код. Разликата между двата инструмента е в намерението и оттам излиза правило за решаване:

Въпрос Ако отговорът е да
Един от нормалните резултати на операцията ли е? („тази услуга е паднала“). Стойност.
Има ли извикващият нормален път да продължи със следващата? (пакет от сто услуги). Стойност.
Операцията не е могла да бъде извършена и продължаването би било измисляне? (конфигурационният файл не съществува). Изключение.
Дефект на програмата ли е, на този, който я е написал? (неочакван null). Непроверявано изключение.

За revisor, значи: падналата услуга е стойност (SinRespuesta), защото въпросът, на който програмата отговаря, е „работи ли?“, а „не“ е отговор. Нечетимият конфигурационен файл е изключение (ErrorDeConfiguracion), защото без списък с услуги програмата няма какво да проверява и продължаването би означавало да си измислиш списък. Това е проектантско решение и го вземаш ти, но не е произволно: целият урок се свежда до това да си задаваш този въпрос всеки път, когато нещо може да се обърка.

5.5 InterruptedException: не се поглъща

Последното изключение в урока е най-неразбираното. Днешният revisor ще чака: за да симулира, че една услуга се бави толкова, колкото казва нейното време, проверката извиква Thread.sleep(ms), който спира нишката за толкова милисекунди. А Thread.sleep декларира throws InterruptedException, проверявано изключение. Защо?

Една програма на Java може да има няколко нишки на изпълнение едновременно (урок 9 е изцяло за това, а днес е достатъчно да знаеш, че нишката е независима линия на изпълнение и че програмата ти вече има една, главната нишка). Една нишка може да поиска от друга да спре: това се нарича прекъсване, и е любезна молба, а не заповед: прекъснатата нишка решава как и кога да спре. Механично, прекъсването на нишка вдига флаг в нея („състоянието ѝ на прекъсване“). Ако нишката спи в sleep, когато това стане, sleep се събужда незабавно и хвърля InterruptedException. Според документацията от ръководството: прекъсването е „указание към нишката, че трябва да остави това, което прави, и да направи нещо друго; зависи от програмиста да реши как реагира, но е много обичайно нишката да завърши“.

Проверявано е, за да не можеш да го забравиш: някой е поискал от кода ти да спре и да го пренебрегнеш почти никога не е правилното. За да видиш механиката без нужда от две нишки, програмата fig05_10 прекъсва сама себе си: Thread.currentThread() връща „нишката, която изпълнява този код“, а interrupt() вдига нейния флаг.

// fig05_10.java
void main() {
  Thread.currentThread().interrupt();
  IO.println("¿marcado como interrumpido? " + Thread.currentThread().isInterrupted());
  try {
    Thread.sleep(1000);
    IO.println("esto no se imprime");
  } catch (InterruptedException e) {
    IO.println("sleep lanzó: " + e.getMessage());
    IO.println("¿sigue marcado? " + Thread.currentThread().isInterrupted());
    Thread.currentThread().interrupt();
    IO.println("¿y después de restaurarlo? " + Thread.currentThread().isInterrupted());
  }
}
$ java fig05_10.java
¿marcado como interrumpido? true
sleep lanzó: sleep interrupted
¿sigue marcado? false
¿y después de restaurarlo? true

Първият ред потвърждава, че флагът е вдигнат. sleep(1000) не поспа нищо: видя флага, хвърли InterruptedException със съобщение sleep interrupted и, това е важното, свали флага при хвърлянето: третият ред казва false. Документацията на Thread го казва така: „състоянието на прекъсване на текущата нишка се изчиства, когато това изключение бъде хвърлено“. Това създава проблем: ако прихванеш изключението и продължиш като че ли нищо не е станало, молбата да спреш изчезва и никой друг (например кодът, който е извикал твоя) не научава, че някой е искал да спре. Оттук двете правила и няма трето:

  1. Предай я нагоре: декларирай throws InterruptedException и я остави да се издигне. Това прави revisor.
  2. Ако трябва да я прихванеш, възстанови флага, преди да продължиш: Thread.currentThread().interrupt(). Това е четвъртият ред на фигурата: остави флага отново вдигнат (true) за този, който идва след теб.

Това, което не се прави, е да я прихванеш и да замълчиш (catch (InterruptedException e) {}): това е версията на раздел 5.3.2 за случай, в който цената е по-лоша, защото програма, която не се подчинява, когато ѝ поискат да спре, не може да бъде изключена елегантно. В урок 9 ще видиш кой прекъсва и защо.

5.6 Стъпката на проекта: revisor чете своята конфигурация

Сега да съберем всичко. revisor се променя така:

  • servicios.txt, текстов файл, замества списъка, написан на ръка. Има по една услуга на ред, с име, адрес и по желание таймаут в милисекунди. Празните редове се игнорират, а тези, които започват с #, са коментари.
  • ErrorDeConfiguracion е изключението от раздел 5.3.
  • Configuracion чете файла и го превръща в списък от Servicio и там живеят съобщенията за грешки.
  • Revision се променя: проверката изчаква това, което казва симулираното време, и затова revisar и revisarTodos декларират throws InterruptedException. revisarTodos минава от стрийм на for, заради видяното в 5.1.4.
  • Revisor.java чете конфигурацията, прихваща ErrorDeConfiguracion и я отпечатва в изхода за грешки.

Девет файла не се променят спрямо урок 4: Estado, Respuesta, Servicio, Resultado, Clasificador, Simulador, Resumen, Reporte и Formato. Те са тук, защото папката на фигурата е цялостният проект. Копирай ги такива, каквито са, от урок 4:

// fig05_11/Estado.java
enum Estado {
  ARRIBA,
  LENTO,
  CAIDO
}
// fig05_11/Respuesta.java
sealed interface Respuesta permits Respuesta.Respondio, Respuesta.SinRespuesta {
  record Respondio(int codigo, long tiempoMs) implements Respuesta {}

  record SinRespuesta(String motivo) implements Respuesta {}
}
// fig05_11/Servicio.java
import java.net.URI;

record Servicio(String nombre, URI url, int timeoutMs) {
  static final int TIMEOUT_POR_OMISION_MS = 5000;

  Servicio(String nombre, URI url) {
    this(nombre, url, TIMEOUT_POR_OMISION_MS);
  }
}
// fig05_11/Resultado.java
import java.util.OptionalLong;

record Resultado(Servicio servicio, Estado estado, Respuesta respuesta) {
  String detalle() {
    return switch (respuesta) {
      case Respuesta.Respondio(int codigo, long _) -> String.valueOf(codigo);
      case Respuesta.SinRespuesta(String motivo) -> motivo;
    };
  }

  OptionalLong tiempoMs() {
    return switch (respuesta) {
      case Respuesta.Respondio(int _, long tiempoMs) -> OptionalLong.of(tiempoMs);
      case Respuesta.SinRespuesta _ -> OptionalLong.empty();
    };
  }
}
// fig05_11/Clasificador.java
final class Clasificador {
  static final long UMBRAL_LENTO_MS = 1000;

  static Estado clasificar(Respuesta respuesta) {
    return switch (respuesta) {
      case Respuesta.Respondio(int codigo, long _) when codigo >= 400 -> Estado.CAIDO;
      case Respuesta.Respondio(int _, long tiempoMs) when tiempoMs >= UMBRAL_LENTO_MS ->
          Estado.LENTO;
      case Respuesta.Respondio _ -> Estado.ARRIBA;
      case Respuesta.SinRespuesta _ -> Estado.CAIDO;
    };
  }
}
// fig05_11/Simulador.java
import java.util.Map;
import java.util.Optional;

final class Simulador {
  static final Map<String, Respuesta> RESPUESTAS =
      Map.of(
          "catalogo", new Respuesta.Respondio(200, 142),
          "inventario", new Respuesta.Respondio(200, 2300),
          "pagos", new Respuesta.Respondio(200, 87),
          "reportes", new Respuesta.SinRespuesta("sin conexión"));

  static Optional<Respuesta> buscar(String nombre) {
    return Optional.ofNullable(RESPUESTAS.get(nombre));
  }
}
// fig05_11/Resumen.java
import java.util.List;
import java.util.Map;
import java.util.OptionalDouble;
import java.util.OptionalLong;
import java.util.stream.Collectors;

record Resumen(int total, int arriba, int lento, int caido, OptionalLong promedioMs) {
  static Resumen de(List<Resultado> resultados) {
    Map<Estado, Long> porEstado =
        resultados.stream()
            .collect(Collectors.groupingBy(Resultado::estado, Collectors.counting()));
    OptionalDouble promedio =
        resultados.stream()
            .map(Resultado::tiempoMs)
            .filter(OptionalLong::isPresent)
            .mapToLong(OptionalLong::getAsLong)
            .average();
    OptionalLong promedioMs =
        promedio.isPresent()
            ? OptionalLong.of(Math.round(promedio.getAsDouble()))
            : OptionalLong.empty();
    return new Resumen(
        resultados.size(),
        porEstado.getOrDefault(Estado.ARRIBA, 0L).intValue(),
        porEstado.getOrDefault(Estado.LENTO, 0L).intValue(),
        porEstado.getOrDefault(Estado.CAIDO, 0L).intValue(),
        promedioMs);
  }
}
// fig05_11/Reporte.java
import java.util.List;

record Reporte(List<Resultado> resultados, Resumen resumen) {
  Reporte {
    resultados = List.copyOf(resultados);
  }

  static Reporte de(List<Resultado> resultados) {
    return new Reporte(resultados, Resumen.de(resultados));
  }
}
// fig05_11/Formato.java
import java.util.Locale;
import java.util.stream.Collectors;

final class Formato {
  static final String PATRON_LINEA = "%-12s%-10s%6s  %s";

  static String formatearTiempo(long tiempoMs) {
    if (tiempoMs < 1000) {
      return tiempoMs + "ms";
    }
    return String.format(Locale.ROOT, "%.1fs", tiempoMs / 1000.0);
  }

  static String tiempo(Respuesta respuesta) {
    return switch (respuesta) {
      case Respuesta.Respondio(int _, long tiempoMs) -> formatearTiempo(tiempoMs);
      case Respuesta.SinRespuesta _ -> "—";
    };
  }

  static String encabezado() {
    return String.format(Locale.ROOT, PATRON_LINEA, "SERVICIO", "ESTADO", "TIEMPO", "DETALLE");
  }

  static String linea(Resultado resultado) {
    return String.format(
        Locale.ROOT,
        PATRON_LINEA,
        resultado.servicio().nombre(),
        resultado.estado(),
        tiempo(resultado.respuesta()),
        resultado.detalle());
  }

  static String contar(long cantidad, String palabra) {
    return cantidad + " " + palabra + (cantidad == 1 ? "" : "s");
  }

  static String resumen(Resumen resumen) {
    String promedio =
        resumen.promedioMs().isPresent() ? formatearTiempo(resumen.promedioMs().getAsLong()) : "—";
    return contar(resumen.total(), "servicio")
        + " · "
        + resumen.arriba()
        + " arriba · "
        + contar(resumen.lento(), "lento")
        + " · "
        + contar(resumen.caido(), "caído")
        + " · promedio "
        + promedio;
  }

  static String tabla(Reporte reporte) {
    String renglones =
        reporte.resultados().stream().map(Formato::linea).collect(Collectors.joining("\n"));
    return encabezado() + "\n" + renglones + "\n\n" + resumen(reporte.resumen());
  }
}

Файлът с данни е този, който описва началото на този раздел: името, адресът и, ако искаш, таймаутът. Адресите продължават да завършват на .example, запазен домейн, чието име никога не се резолвира: никоя заявка от курса не излиза в интернет.

# nombre      url                           [timeout_ms]
catalogo      https://catalogo.example
inventario    https://inventario.example
pagos         https://pagos.example
reportes      https://reportes.example

Собственото изключение е това от раздел 5.3.1, с двата си конструктора:

// fig05_11/ErrorDeConfiguracion.java
final class ErrorDeConfiguracion extends Exception {
  ErrorDeConfiguracion(String mensaje) {
    super(mensaje);
  }

  ErrorDeConfiguracion(String mensaje, Throwable causa) {
    super(mensaje, causa);
  }
}

Configuracion е голямото парче и си струва да се прочете на части. Виж целия код и после продължаваме:

// fig05_11/Configuracion.java
import java.io.BufferedReader;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

final class Configuracion {
  static List<Servicio> leer(Path ruta) throws ErrorDeConfiguracion {
    String archivo = ruta.getFileName().toString();
    List<Servicio> servicios = new ArrayList<>();
    try (BufferedReader lector = Files.newBufferedReader(ruta)) {
      String renglon;
      int numero = 0;
      while ((renglon = lector.readLine()) != null) {
        numero++;
        String limpio = renglon.strip();
        if (limpio.isEmpty() || limpio.startsWith("#")) {
          continue;
        }
        servicios.add(leerRenglon(limpio, numero));
      }
    } catch (NoSuchFileException e) {
      throw new ErrorDeConfiguracion("no existe el archivo " + archivo, e);
    } catch (IOException e) {
      throw new ErrorDeConfiguracion("no se pudo leer el archivo " + archivo, e);
    }
    verificarRepetidos(servicios);
    if (servicios.isEmpty()) {
      throw new ErrorDeConfiguracion("el archivo no tiene servicios");
    }
    return servicios;
  }

  static Servicio leerRenglon(String renglon, int numero) throws ErrorDeConfiguracion {
    String[] campos = renglon.split("\\s+");
    if (campos.length < 2 || campos.length > 3) {
      throw new ErrorDeConfiguracion(
          "renglón " + numero + ": se esperaban 2 o 3 campos y hay " + campos.length);
    }
    URI url = leerUrl(campos[1], numero);
    if (campos.length == 2) {
      return new Servicio(campos[0], url);
    }
    return new Servicio(campos[0], url, leerTimeout(campos[2], numero));
  }

  static URI leerUrl(String texto, int numero) throws ErrorDeConfiguracion {
    if (!texto.startsWith("http://") && !texto.startsWith("https://")) {
      throw new ErrorDeConfiguracion(
          "renglón " + numero + ": la url debe empezar con http:// o https://");
    }
    try {
      return new URI(texto);
    } catch (URISyntaxException e) {
      throw new ErrorDeConfiguracion("renglón " + numero + ": la url no es válida", e);
    }
  }

  static int leerTimeout(String texto, int numero) throws ErrorDeConfiguracion {
    String mensaje = "renglón " + numero + ": timeout_ms debe ser un entero mayor que 0";
    try {
      int timeoutMs = Integer.parseInt(texto);
      if (timeoutMs > 0) {
        return timeoutMs;
      }
    } catch (NumberFormatException e) {
      throw new ErrorDeConfiguracion(mensaje, e);
    }
    throw new ErrorDeConfiguracion(mensaje);
  }

  static void verificarRepetidos(List<Servicio> servicios) throws ErrorDeConfiguracion {
    Set<String> vistos = new HashSet<>();
    Set<String> repetidos = new LinkedHashSet<>();
    for (Servicio servicio : servicios) {
      if (!vistos.add(servicio.nombre())) {
        repetidos.add(servicio.nombre());
      }
    }
    if (!repetidos.isEmpty()) {
      throw new ErrorDeConfiguracion("nombres repetidos: " + String.join(", ", repetidos));
    }
  }
}

leer е публичният метод. Отваря файла с try-with-resources и обхожда редовете със while от раздел 5.2.2, като води сметка в numero. За всеки ред strip() маха интервалите от краищата му; ако резултатът е празен (isEmpty()) или започва с # (startsWith("#")), continue скача веднага към следващия ред (думата continue значи „завърши тази итерация на цикъла и мини към следващата“). Всеки друг ред се превръща в Servicio с leerRenglon. Двата последни catch превръщат изключенията на JDK в ErrorDeConfiguracion, като подават първоначалното като причина: първо NoSuchFileException, която дава „no existe el archivo“ (файлът не съществува), и после общото IOException, което дава „no se pudo leer el archivo“ (файлът не може да се прочете). Името на файла идва от ruta.getFileName().toString() и това обяснява съобщението от фигурата по-горе: казва servicios.txt, а не пълния път. Забележи и че numero брои редовете на файла, включително коментарите и празните: ако грешката е на ред 4 от файла, съобщението казва 4 и това е номерът, който ще видиш в редактора си.

leerRenglon разделя реда на полета с renglon.split("\\s+"). split получава регулярен израз (описание на текстов шаблон) и връща масив от текстове: \s+ значи „един или повече интервала или табулации“ и се пише с двойна обратна наклонена черта, защото в текст на Java обратната наклонена черта има собствена употреба (\n, \") и трябва да се напише два пъти, за да стигне една. Масивът (String[]) е последователност с фиксиран размер: достъпва се с campos[0] (първия) и размерът му е campos.length, без скоби, защото е данна, а не метод. Това е същият тип като String[] args, който ще видиш в урок 6. Ако полетата са по-малко от две или повече от три, съобщението казва колко са: renglón 3: se esperaban 2 o 3 campos y hay 1 (ред 3: очаквани са 2 или 3 полета, а има 1).

leerUrl и leerTimeout са двата валидатора. leerUrl отхвърля това, което не започва с http:// или https:// (първи филтър, със своето съобщение) и после се опитва да построи URI с new URI(texto). Тази форма хвърля URISyntaxException, проверявана, когато адресът е грешно написан, и leerUrl я превръща в ErrorDeConfiguracion с URISyntaxException като причина. (Това е причината да се използва new URI(...), а не URI.create(...), което използваше в урок 3: URI.create обвива същото изключение в непроверявано, а тук е добре да го видим такова, каквото е.) leerTimeout превръща текста с Integer.parseInt и отхвърля всичко, което не е цяло число, по-голямо от нула. Обърни внимание как е написано, защото е полезен шаблон: try опитва преобразуването и, ако числото е валидно, връща още там; ако преобразуването се провали, catch хвърля грешката с NumberFormatException като причина; а ако преобразуването сполучи, но числото не е по-голямо от нула, try не връща нищо и изпълнението пада на последния ред, който хвърля същото съобщение без причина. Едно съобщение, два пътя към грешката.

verificarRepetidos използва трика от раздел 4.1.2: vistos.add(nombre) връща false, ако името вече е било там, и тези имена се събират в LinkedHashSet (който запазва реда, в който са намерени), а String.join(", ", repetidos), който съединява елементите с разделител, ги изброява всичките в съобщението: nombres repetidos: catalogo (повтарящи се имена: catalogo). Подробност за това какво прави този метод: програмата спира при първата грешка и не събира всички грешки на файла, за да ги каже наведнъж. По-просто е и е достатъчно за файл от четири реда.

Сега Revision, която се променя по два начина. Първо, revisar изчаква времето на отговора с Thread.sleep(tiempo.getAsLong()), ако го има (OptionalLong от урок 4), и затова декларира throws InterruptedException. Второ, revisarTodos е for, който пълни списък, защото ламбдата на един map не може да хвърли проверяваното изключение (нейната Function не го декларира), както се видя в раздел 5.1.4:

// fig05_11/Revision.java
import java.util.ArrayList;
import java.util.List;
import java.util.OptionalLong;

final class Revision {
  static Resultado revisar(Servicio servicio) throws InterruptedException {
    Respuesta respuesta =
        Simulador.buscar(servicio.nombre()).orElse(new Respuesta.SinRespuesta("sin conexión"));
    Resultado resultado =
        new Resultado(servicio, Clasificador.clasificar(respuesta), respuesta);
    OptionalLong tiempo = resultado.tiempoMs();
    if (tiempo.isPresent()) {
      Thread.sleep(tiempo.getAsLong());
    }
    return resultado;
  }

  static List<Resultado> revisarTodos(List<Servicio> servicios) throws InterruptedException {
    List<Resultado> resultados = new ArrayList<>();
    for (Servicio servicio : servicios) {
      resultados.add(revisar(servicio));
    }
    return resultados;
  }
}

И Revisor.java затваря кръга. Чете конфигурацията, проверява и сглобява отчета и прихваща ErrorDeConfiguracion, за да я отпечата в изхода за грешки с префикса error de configuración: (грешка в конфигурацията:). Неговият main декларира throws InterruptedException, което е правило 1 от раздел 5.5: не я обработва, защото не би знаел какво да прави, само я оставя да се издигне.

// fig05_11/Revisor.java
void main() throws InterruptedException {
  Path ruta = Path.of("servicios.txt");
  try {
    List<Servicio> servicios = Configuracion.leer(ruta);
    IO.println(Formato.tabla(Reporte.de(Revision.revisarTodos(servicios))));
  } catch (ErrorDeConfiguracion e) {
    System.err.println("error de configuración: " + e.getMessage());
  }
}

Стартирай проекта от неговата папка. Проверката вече чака: таблицата се появява след около две секунди и половина (сборът от 142, 2300 и 87 ms; измерих около три секунди общо със стартирането на Java). Това би станало последователно с истински услуги и е проблемът, който ще реши урок 9.

$ cd fig05_11
$ java Revisor.java
SERVICIO    ESTADO    TIEMPO  DETALLE
catalogo    ARRIBA     142ms  200
inventario  LENTO       2.3s  200
pagos       ARRIBA      87ms  200
reportes    CAIDO          —  sin conexión

4 servicios · 2 arriba · 1 lento · 1 caído · promedio 843ms

А сега целта на урока. Стартирай revisor от друга папка, без да минаваш оттам, където е конфигурационният файл. После го изпълни с отхвърлен изход за грешки (2>/dev/null, което значи „прати канал 2, изхода за грешки, в нищото“) и поискай кода на изход на последната команда с $?:

$ java fig05_11/Revisor.java
error de configuración: no existe el archivo servicios.txt
$ java fig05_11/Revisor.java 2>/dev/null
$ echo "código de salida: $?"
código de salida: 0

Станаха три неща. Първо, програмата каза error de configuración: no existe el archivo servicios.txt: Path.of("servicios.txt") е относителен, разреши се спрямо папката, в която стоиш, а там файлът не съществува, макар да съществува в папката на проекта. Второ, с 2>/dev/null не се появи нищо: съобщението вървеше по канала за грешки и като го отхвърли, не остава изход. Това е точно разделението от раздел 5.3.3. Трето, кодът на изход беше 0: програмата се провали, а операционната система смята, че всичко е минало добре. За скрипт, който изпълнява revisor, това е лъжа и урок 10 го поправя със System.exit. Засега го запиши като известен недостатък.

Грешката, която ще видиш

Грешката в този урок е тази от раздел 5.1.2 и почти всички я срещат първия път, когато използват метод за вход и изход:

fig05_02.java:3: error: unreported exception IOException; must be caught or declared to be thrown
  List<String> renglones = Files.readAllLines(Path.of("servicios.txt"));
                                             ^

Първи ред: къде и какво. Носи файла и реда (fig05_02.java:3), думата error и диагнозата: unreported exception IOException. „Необявено“ значи, че методът може да хвърли това изключение и никой не го е прихванал, нито декларирал. Втори и трети: редът на кода и стрелката. ^ сочи към скобата на извикването, което може да го хвърли, и към него трябва да гледаш. Какво да направиш. Има две поправки и изборът е въпрос на проектиране. Ако на това място знаеш какво да правиш, когато се провали, прихвани го (и направи нещо полезно, не празен catch). Ако не знаеш, декларирай го с throws и остави този, който е извикал, да се справи. Лоша поправка, която понякога се вижда: да декларираш throws Exception, за да накараш компилатора да замълчи. Работи и крие от сигнатурите какво може да се обърка.

Втората грешка в урока не е на компилатора: това е revisor от друга папка и вече я видя в раздел 5.6. Съобщението no existe el archivo servicios.txt не ти казва къде го е търсил. Ако се натъкнеш на него и файлът съществува, първото, което трябва да провериш, е от коя папка си стартирал java.

Какво се прави погрешно

  • Празен catch, или само с printStackTrace(). Това е от раздел 5.3.2: грешката изчезва или се отпечатва и програмата продължава като че ли нищо не е станало. Цена: погрешни стойности, които никой не вижда, и когато накрая нещо се провали, следа, която вече не съществува.

  • Прихващане на Exception или Throwable за всеки случай. Прихваща и програмистките дефекти (NullPointerException) и ги превръща в подвеждащо съобщение. Цена: истинският дефект остава погребан. Единственото място за catch (Exception e) е последната граница на програмата, за да запише и да завърши елегантно, а не за да продължи.

  • Превръщане на едно изключение в друго, без да се подаде причината. Това е да напишеш throw new ErrorDeConfiguracion("el archivo no sirve") без втория аргумент. Цена: съобщението казва, че е станало повреда, а причината, която позволяваше да се оправи, вече я няма.

  • Връщане на магическа стойност (-1, null, празен низ) вместо да се хвърли или да се върне типизирана стойност. Цена: този, който извиква, трябва да се сети да провери, а компилаторът не му помага.

  • return вътре във finally. Ако finally върне стойност, отхвърля изключението, което се е издигало, и грешката се губи без нито една следа. Цена: същата като при празен catch, с разликата, че кодът изглежда по-невинен. finally е за почистване, не за да решава какво се връща.

  • Поглъщане на InterruptedException. Цена: програма, която не се подчинява, когато ѝ поискат да спре (раздел 5.5). Предай я нагоре или възстанови флага.

  • Използване на изключение за нормалния поток. Например да извикваш Integer.parseInt в цикъл и да прихващаш NumberFormatException, за да решиш дали един текст „е число“, когато това е очакваното, а не изключителното. Цена: по-бавен код (създаването на следата на стека струва) и проектантското решение от раздел 5.4 наопаки: ако липсата на резултат е нормална, тя е стойност.

Упражнения

Упражнение 1 — По един повреден файл за всяка грешка

Configuracion умее да се проваля по няколко начина, всеки със своето съобщение. За всеки от осемте случая по-долу предскажи съобщението, преди да стартираш, и после го провери. Създай нова папка за всеки случай (например roto-1/), сложи вътре servicios.txt с посоченото съдържание и стартирай revisor от тази папка: java ../revisor/Revisor.java (нагласи пътя към мястото, където е проектът ти). Тъй като програмата търси servicios.txt в текущата папка, не ти се налага да пипаш кода.

  1. catalogo https://catalogo.example и на следващия ред само pagos.
  2. Коментар (# x) на ред 1 и на ред 2 catalogo ftp://catalogo.example.
  3. catalogo https://c.example и pagos http://pa|gos.example.
  4. catalogo https://c.example 5000 и pagos https://p.example abc.
  5. Два реда, и двата с името catalogo.
  6. Файл само с коментари и празни редове.
  7. catalogo https://c.example 0.
  8. catalogo https://c.example 1 2 3.

Упражнение 2 — Накарай причината да изчезне

Промени leerTimeout в Configuracion.java, така че catch (NumberFormatException e) да не хвърля нищо и просто да връща 5000. В нова папка сложи servicios.txt с единствен ред pagos https://p.example abc и стартирай оттам (java ../revisor/Revisor.java). Какво виждаш? После възстанови кода. Запиши в дневника си колко време би ти отнело да откриеш проблема, ако файлът имаше сто реда.

Упражнение 3 — Файл в друго кодиране

В нова папка (mkdir latin1 && cd latin1) създай файл с ударение, записано в старото кодиране ISO-8859-1 (latin1 на старите системи), което не е UTF-8, с тази команда:

printf 'catalogo https://catalogo.example\n# caf\xe9\n' > servicios.txt

Стартирай revisor от тази папка (java ../revisor/Revisor.java) и прочети съобщението. После добави ред в catch на Revisor.java, за да отпечаташ причината (e.getCause()), и открий кое изключение на JDK стои зад съобщението. Възстанови кода.

Упражнение 4 — Превърни revisarTodos отново в стрийм

Замени for в revisarTodos с servicios.stream().map(Revision::revisar).toList(). Предскажи дали се компилира и какво ще каже компилаторът. После провери. И отговори: какво би трябвало да промениш в revisar, за да се компилира тази версия?

Решения

Решение 1

Съдържание на servicios.txt Съобщение
catalogo https://catalogo.example и pagos error de configuración: renglón 2: se esperaban 2 o 3 campos y hay 1
catalogo ftp://catalogo.example (след коментар) error de configuración: renglón 2: la url debe empezar con http:// o https://
pagos http://pa|gos.example (ред 2) error de configuración: renglón 2: la url no es válida
pagos https://p.example abc (ред 2) error de configuración: renglón 2: timeout_ms debe ser un entero mayor que 0
два реда с catalogo error de configuración: nombres repetidos: catalogo
само коментари и празни редове error de configuración: el archivo no tiene servicios
catalogo https://c.example 0 error de configuración: renglón 1: timeout_ms debe ser un entero mayor que 0
catalogo https://c.example 1 2 3 error de configuración: renglón 1: se esperaban 2 o 3 campos y hay 5

Осемте съобщения ги измерих с проекта от раздел 5.6. Има две неща, на които да обърнеш внимание. В случай 2 коментарът на ред 1 се брои: грешката е на ред 2 от файла, а не във втората услуга. А в случаи 4 и 7 съобщението е същото, макар причината да е различна (текст, който не е число, и число, което не е положително): за този, който пише файла, поправката е една и съща. Разликата е в причината, която съществува само в първия (NumberFormatException) и я вижда този, който дебъгва.

Решение 2

static int leerTimeout(String texto, int numero) throws ErrorDeConfiguracion {
  String mensaje = "renglón " + numero + ": timeout_ms debe ser un entero mayor que 0";
  try {
    int timeoutMs = Integer.parseInt(texto);
    if (timeoutMs > 0) {
      return timeoutMs;
    }
  } catch (NumberFormatException e) {
    return 5000;
  }
  throw new ErrorDeConfiguracion(mensaje);
}

С servicios.txt, който съдържа само pagos https://p.example abc, revisor отпечатва таблицата на pagos като ARRIBA, без никакво предупреждение: abc изчезна и услугата остана с таймаута по подразбиране. Със сто реда нямаше да имаш как да узнаеш кой от тях е имал невалидно време: програмата „работи“. Това е проблемът от раздел 5.3.2 в малък мащаб. Първоначалното съобщение за грешка (timeout_ms debe ser un entero mayor que 0, тоест „timeout_ms трябва да е цяло число, по-голямо от 0“) не беше неудобство: беше единственото предупреждение, което човекът, пишещ файла, щеше да получи.

Решение 3

Съобщението е error de configuración: no se pudo leer el archivo servicios.txt (не може да се прочете файлът servicios.txt): не казва защо. Със System.err.println(e.getCause()); в catch на Revisor.java се отпечатва:

java.nio.charset.MalformedInputException: Input length = 1

Байтът 0xE9 е é в ISO-8859-1, но като UTF-8 последователност е невалиден (в UTF-8 é заема два байта). Files.newBufferedReader декодира в UTF-8 и като се натъкне на байта, хвърля MalformedInputException, IOException, която Configuracion.leer превръща в „no se pudo leer“. Забележи, че съобщението за човека е вярно, но малко информативно, и че причината носи данните, които са нужни. В това е ползата да я запазиш: кратко съобщение за потребителя, пълна причина за този, който дебъгва.

Решение 4

Не се компилира. Компилаторът казва (съкратих пътя, който стартерът отпечатва целия):

Revision.java:20: error: incompatible thrown types InterruptedException in functional expression
    return servicios.stream().map(Revision::revisar).toList();
                                  ^

Revision::revisar декларира throws InterruptedException, а Function, която map очаква, не може да я хвърли. За да се компилира, би трябвало да махнеш throws от revisar, а това значи да прихванеш InterruptedException вътре в revisar, където не знаеш какво да правиш с нея; правилното би било да възстановиш флага с Thread.currentThread().interrupt() и да върнеш нещо, а това крие прекъсването от този, който е извикал. Затова курсът запазва for: той е този, който оставя изключението да стигне до някого, който наистина може да реши.

Как разбирам, че съм успял

  • java fig05_01.java отпечатва петте вериги и можеш да кажеш защо NoSuchFileException е проверявана, а NumberFormatException не е.
  • java fig05_02.java се проваля с unreported exception IOException и знаеш двете поправки, които компилаторът приема.
  • java fig05_04.java се проваля с has already been caught и знаеш защо редът на catch върви от частното към общото.
  • java fig05_06.java отпечатва затварянията в обратен ред и намираш в изхода потиснатото изключение.
  • От папката на фигурата със собствените изключения java Demo.java отпечатва Caused by: и знаеш кое изключение на JDK стои зад него.
  • java fig05_10.java отпечатва true, false и true и можеш да обясниш какво е направило всяко.
  • От папката на проекта java Revisor.java отпечатва таблицата с 4 servicios · 2 arriba · 1 lento · 1 caído · promedio 843ms (4 услуги · 2 работят · 1 бавна · 1 паднала · средно 843ms) и отнема около две секунди и половина.
  • От друга папка revisor отпечатва error de configuración: no existe el archivo servicios.txt и можеш да обясниш защо файлът съществува и въпреки това не го намира.
  • Осемте повредени файла от упражнение 1 дават осемте съобщения от решението и си ги предсказал, преди да стартираш.
  • Записа в дневника си едно проверявано изключение, което те принуди да решиш нещо.

Обобщение

Отговори, без да гледаш урока:

  1. Каква е разликата между проверявано и непроверявано изключение? Кое от тях компилаторът задължава да бъде обработено?
  2. Какво значи unreported exception IOException; must be caught or declared to be thrown и кои са двете поправки?
  3. Какво гарантира finally? И защо не бива да съдържа return?
  4. Защо редът на catch върви от частното към общото?
  5. Какво гарантира try-with-resources в сравнение със затварянето на ръка? В какъв ред затваря?
  6. Защо проверявано изключение не може да се хвърли отвътре в ламбдата на map и какво използва revisor вместо това?
  7. Каква е причината на едно изключение и какво става, ако не я подадеш, когато го превръщаш в друго?
  8. Защо паднала услуга е стойност, а нечетим конфигурационен файл е изключение?
  9. Какво прави Thread.sleep, когато някой прекъсне нишката, и какво става с флага за прекъсване?
  10. Кои са двата правилни начина да обработиш InterruptedException?

За допълнително четене

Да обсъдим вашия проект

Предпочитате имейл? Пишете ни на hola@habil.mx