Съдържание на курса
Урок 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 си ги писал ти, в същия файл като кода. Ако нещо беше грешно, го узнаваше веднага, защото компилаторът не те оставяше да минеш. Конфигурационният файл променя тази ситуация напълно: пише го друг човек, в друг момент, с друг редактор, а програмата научава за грешките му при изпълнение. Един файл отвън може да се провали по четири начина и четирите са нормални, не изключителни:
- Не съществува или съществува в друга папка, различна от тази, която мислиш.
- Съществува, но не може да се прочете, защото е в друго кодиране или някой го е оставил без права.
- Формално е пълен, но казва нещо абсурдно: адрес, който не започва с
http, отрицателен таймаут, две услуги с едно и също име. - Няма полезно съдържание: само коментари.
Сериозната програма различава тези случаи и казва на човека кой от тях му се е случил и как да го поправи, в един ред, а не с отчет от трийсет реда, написан за програмисти. Това прави този урок.
Но 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, което може да я хвърли. За да се компилира програмата, имаш две възможности и едната от двете е задължителна:
- Да я прихванеш там, където можеш да я обработиш, с
tryиcatch(следващият раздел). - Да я декларираш: да предупредиш в сигнатурата на метода, че може да я хвърли, с
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 ще направи нещо по-добро.
Има три правила за добро прихващане и трите се нарушават толкова често, че е добре да бъдат записани:
- Прихващай конкретното изключение, което умееш да обработиш. Един
catch (Exception e)прихваща иNullPointerException, получено от правописна грешка, и програмата ще продължи да работи с погрешни данни. По-добре да спре със своята следа на стека. - Ако не знаеш какво да правиш с една грешка, не я прихващай. Остави я да се издигне. Програма, която спира с ясна следа на стека, е по-добра от такава, която продължава на сляпо.
- Един-единствен
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“), означава, че останалите шест реда са същите, които вече си видял по-горе.
Това дава стойността на запазването на причината. Съобщението е за човека, който използва програмата: кратко, на испански, без жаргон. Причината е за този, който трябва да оправи проблема: първоначалното изключение с пълната му следа на стека. Да загубиш причината значи да загубиш следата. Две правила на курса, които произтичат оттук:
- Когато превръщаш едно изключение в друго, подай първоначалното като причина, винаги.
- Съобщението казва какво е станало и какво да се направи; причината казва защо. В съобщението на
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 го казва така: „състоянието на прекъсване на текущата нишка се изчиства, когато това изключение бъде хвърлено“. Това създава проблем: ако прихванеш изключението и продължиш като че ли нищо не е станало, молбата да спреш изчезва и никой друг (например кодът, който е извикал твоя) не научава, че някой е искал да спре. Оттук двете правила и няма трето:
- Предай я нагоре: декларирай
throws InterruptedExceptionи я остави да се издигне. Това правиrevisor. - Ако трябва да я прихванеш, възстанови флага, преди да продължиш:
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 в текущата папка, не ти се налага да пипаш кода.
catalogo https://catalogo.exampleи на следващия ред самоpagos.- Коментар (
# x) на ред 1 и на ред 2catalogo ftp://catalogo.example. catalogo https://c.exampleиpagos http://pa|gos.example.catalogo https://c.example 5000иpagos https://p.example abc.- Два реда, и двата с името
catalogo. - Файл само с коментари и празни редове.
catalogo https://c.example 0.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 дават осемте съобщения от решението и си ги предсказал, преди да стартираш.
- Записа в дневника си едно проверявано изключение, което те принуди да решиш нещо.
Обобщение
Отговори, без да гледаш урока:
- Каква е разликата между проверявано и непроверявано изключение? Кое от тях компилаторът задължава да бъде обработено?
- Какво значи
unreported exception IOException; must be caught or declared to be thrownи кои са двете поправки? - Какво гарантира
finally? И защо не бива да съдържаreturn? - Защо редът на
catchвърви от частното към общото? - Какво гарантира
try-with-resourcesв сравнение със затварянето на ръка? В какъв ред затваря? - Защо проверявано изключение не може да се хвърли отвътре в ламбдата на
mapи какво използваrevisorвместо това? - Каква е причината на едно изключение и какво става, ако не я подадеш, когато го превръщаш в друго?
- Защо паднала услуга е стойност, а нечетим конфигурационен файл е изключение?
- Какво прави
Thread.sleep, когато някой прекъсне нишката, и какво става с флага за прекъсване? - Кои са двата правилни начина да обработиш
InterruptedException?
За допълнително четене
- Oracle, „Lesson: Exceptions“ (ръководство за Java): цялата тема, от това какво е изключение до свързването им във верига, с дискусията за непроверяваните. Посетено на 7 октомври 2026 г.
- The Java Language Specification, глава 11, „Exceptions“: точното определение на проверявано и непроверявано и на това какво трябва да декларира всеки метод. Посетено на 7 октомври 2026 г.
- Oracle, „The try-with-resources Statement“: редът на затваряне и потиснатите изключения, с примери. Посетено на 7 октомври 2026 г.
- Oracle, „Interrupts“ (ръководство за конкурентността): как се спира една нишка любезно и защо
InterruptedExceptionне се игнорира. Посетено на 7 октомври 2026 г.
Предпочитате имейл? Пишете ни на hola@habil.mx