Привет, Хабр!
Сегодня у нас на повестке — интересная тема: как адекватно обрабатывать ошибки в cmd-файлах (он же — Windows Batch).
В общем, если вам приходилось писать batch-скрипт, который делает чуть больше, чем echo Hello World — добро пожаловать. Рассмотрим как работает %ERRORLEVEL%, когда и как юзать exit /b, как раскладывать try/catch на лейблы и почему всё это ломается, когда в бой идут call, for и if.
%ERRORLEVEL%
Вообще, в cmd-скриптах нет try/catch
как такового. Вместо этого — договорённость: если программа/команда завершилась неудачно, она устанавливает переменную %ERRORLEVEL%
в ненулевое значение.
Пример:
@echo off
mkdir my-folder
if %ERRORLEVEL% NEQ 0 echo "mkdir failed"
Вроде просто., но фокус в том, что %ERRORLEVEL%
— это не системная переменная, а синтаксический сахар. То есть она работает, только если вы её используете правильно. А именно: проверка должна происходить сразу после вызова команды.
@echo off
mkdir my-folder
REM какие-то действия
if %ERRORLEVEL% NEQ 0 echo "mkdir failed" REM это уже может не сработать
Никогда не откладывайте проверку — одна строка между вызовом и проверкой может похоронить контроль ошибок.
exit /b vs exit vs goto :eof
exit
в cmd ведёт себя по-разному в зависимости от того, где он вызывается:
exit
— завершает cmd.exe, т.е. полностью закрывает текущую сессию (в т.ч окно, если скрипт шел через двойной клик).exit /b
— завершает текущую подпрограмму или скрипт, не трогая верхнюю оболочку.goto :eof
— какexit /b
, но менее явный. Служебный ярлык, который просто передаёт управление за пределы текущей метки.
Пример:
call :doSomething
if %ERRORLEVEL% NEQ 0 echo "doSomething failed"
goto :eof
:doSomething
mkdir test-dir
if %ERRORLEVEL% NEQ 0 exit /b 1
REM что-то ещё
exit /b 0
call
тут важен. Без него вы не можете вызывать метки и сохранять поток выполнения.
«Эмуляция try/catch» через метки и errorlevel
Нам не хватает нормального механизма перехвата ошибок, и мы начинаем импровизировать. Самый частый приём — структура из call'ов и if errorlevel:
@echo off
call :step1 || goto :fail
call :step2 || goto :fail
call :step3 || goto :fail
echo All steps complete!
goto :eof
:fail
echo Step failed. Exiting...
exit /b 1
:step1
REM команда, которая может упасть
mkdir build
exit /b %ERRORLEVEL%
:step2
REM ещё одна
xcopy /E /I src\ dist\
exit /b %ERRORLEVEL%
:step3
echo "Finalizing"
exit /b 0
Почти как try-цепочка: шаги вызваны, ошибки отлавливаются, выход по fail. Но || goto :fail
не сработает, если внутри метки ошибка не прокинута наружу через exit /b с ненулевым кодом.
Проблемы в call/for/if
Пример 1 — for
обнуляет errorlevel:
@echo off
mkdir test-dir-1
for %%f in (*.txt) do echo %%f
if %ERRORLEVEL% NEQ 0 echo "FAIL" REM тут уже 0
Пример 2 — call
сбрасывает errorlevel, если не аккуратничать:
call :failFn
if %ERRORLEVEL% NEQ 0 echo "Detected error"
goto :eof
:failFn
exit /b 1
errorlevel
станет 1 — и правильно, он так и сделает. Но если вы между call и if вставите какой-то benign echo
, всё — уехали.
Чтобы избежать: всегда прокидывайте exit /b %ERRORLEVEL% в конце каждой метки, если она — потенциальный источник ошибки.
Каркас батника с шагами и откатом
Рассмотрим шаблон, который можно использовать в полях. Он покрывает:
пошаговое выполнение
откат в случае фейла
осмысленные логи
сохранение промежуточного состояния
@echo off
setlocal EnableDelayedExpansion
set LOG=build.log
set RETCODE=0
call :step1 || set RETCODE=1 & goto :rollback
call :step2 || set RETCODE=2 & goto :rollback
call :step3 || set RETCODE=3 & goto :rollback
echo Success! >> %LOG%
goto :cleanup
:rollback
echo Error in step !RETCODE!, rolling back... >> %LOG%
call :undoStep3
call :undoStep2
call :undoStep1
:cleanup
REM Очистка
endlocal
exit /b %RETCODE%
:step1
mkdir temp
exit /b %ERRORLEVEL%
:step2
xcopy src\ temp\ /E /I
exit /b %ERRORLEVEL%
:step3
call :riskySubstep
exit /b %ERRORLEVEL%
:undoStep3
REM что-то, что откатывает step3
exit /b 0
:undoStep2
REM удаление temp
rmdir /S /Q temp
exit /b 0
:undoStep1
REM если что-то ещё
exit /b 0
:riskySubstep
REM допустим, может упасть
some_nonexistent_command
exit /b %ERRORLEVEL%
Когда ваш .bat скрипт делает больше трёх шагов можно уже логгировать. Не полагайтесь на echo в консоль: он улетит в пустоту при запуске через GUI, планировщик или интерактивный шелл. Можно сохранять лог в файл:
echo [INFO] Копируем файлы... >> %LOG%
xcopy /E /I src\\ dist\\ >> %LOG% 2>&1
>>
добавляет в файл, 2>&1
— перенаправляет stderr туда же. Всё просто.
Возврат кодов ошибок
Можно использовать разные коды возврата для разных типов ошибок. Это позволяет вызывающей стороне понять, что конкретно сломалось. Вместо тупого exit /b 1
можно:
exit /b 101 REM ошибка создания каталога
exit /b 202 REM ошибка копирования
exit /b 303 REM сбой в подпроцессе
А потом в основном скрипте интерпретировать их:
call :buildStuff
if %ERRORLEVEL% EQU 101 echo \"Каталог не создался\"
if %ERRORLEVEL% EQU 202 echo \"Не удалось скопировать файлы\"
if %ERRORLEVEL% GEQ 300 echo \"Что-то сломалось глубоко внутри\"
Итоги
Основные правила:
всегда проверяйте %ERRORLEVEL% немедленно после вызова
используйте exit /b вместо exit, если не хотите выйти из всего cmd
call необходим для вызова подпрограмм (меток), без него цепочка ломается
всегда exit /b %ERRORLEVEL% в конце метки — иначе ловите баг
стройте rollback-подход, если шаги могут ломать состояние
Если вы когда-нибудь ловили баг из-за неудачного обновления Windows или сидели ночью у сервера, потому что упал единственный контроллер домена — вы не одиноки. Инфраструктура должна держать удар, но чтобы это стало реальностью, нужно немного больше, чем просто «всё работает». Чтобы разобраться, рекомендуем два практических вебинара, которые пройдут в рамках курса «Администратор Windows»:
9 июня в 20:00 — «+1 контроллер домена: зачем он?»
Нужен ли дополнительный DC? Как он влияет на отказоустойчивость, репликацию и стабильность AD?23 июня в 20:00 — «Обновления Windows: что должен знать администратор?»
Как управлять апдейтами без сюрпризов: стратегии, инструменты, ошибки и способы избежать автосаботажа внутри своей инфраструктуры.
SlFed
А еще чтобы не городить:
if
%ERRORLEVEL% EQU 1 Goto metka1
if
%ERRORLEVEL% EQU 2 Goto metka2
.......
Проще сделать в одну строку:
Goto metka%ERRORLEVEL%