Способы отладки в IDEA

Отладка программ является неотъемлемой частью процесса разработки. Используется для исправления ошибок как в процессе написания нового кода, так и для внезапно обнаружившихся проблем в готовом приложении. Особенно увлекательно с помощью отладки чинить код, который написан очень давно, и автор уже недоступен.

Часто начинающие разработчики при исправлении багов пользуются следующими приемами:

Вчитываются в код больше и больше
Где-то тут в коде ошибка, сейчас найдём, надо только внимательнее отследить. Иногда ошибку удаётся найти, но можно остаться в тупике на неопределённое время. При отладке видно не только код, но и значение переменных, последовательность исполнения и можно даже этим манипулировать.
Используют отладочную печать
Расставляем строки наподобие "001", "aaaaa", "WTFFF" и т.д. в случайные места программы и запускаем. По сути эта та же отладка, только крайне неэффективная - чтобы получить новую информацию надо повторить процесс заново, кроме того отладочный вывод разрастается и запутывается. Использовать логи приложения для отладочной печати не намного лучше, потому что тогда со временем их объём растёт, а качество падает вплоть до нуля.
Используют только стандартныe точки останова (брейкпоинты)
Такой процесс обычно выглядит так: ставим брейкпоинт в начале и аккуратно последовательностью F7, F8 (Step Into, Step Over) добираемся до проблемного места, изучая значения переменных и сравнивая с тем, как должно быть. В общем, неплохо, но бывает что проблема слишком сложная, и приходится повторять процедуру раз за разом. Возможности современных отладчиков ушли далеко вперёд и стоит ими пользоваться.

Ниже опишу приёмы, которые считаю более эффективными, и в большинстве случаев приводящими к результату в случае, если ошибку удаётся воспроизвести.

Условные брейкпоинты
Программа прерывается только если в момент выполнения условие выполняется, например, user.name.toUpperCase().contains("PRAVIN"). Условие указывается в IDEA нажатием правой кнопки мыши (ПКМ) на брейпоинте.
Брейкпоинты на исключения
Программа прерывается в тот момент, когда выбрасывается исключение. Часто намного удобнее, чем отлавливать его уже в конструкции catch. Устанавливается так: ПКМ на брейкпоинте -> More / Ctrl+Shift+F8 -> + -> Java Exception Breakpoints -> Указываем имя класса исключения (например, JsonProcessingException). Иногда можно просто отметить "Any Exception", но тогда в сложных приложениях без остановки из разных библиотек как из ведра сыпятся исключения ClassNotFoundException, NullPointerException и ещё прорва всего, к делу не относящегося.
Удалённая отладка (JPDA)
Когда приложение запускается не из IDE (например, другая машина, либо отдельный веб сервер на локальной машине), то к нему можно подключиться c помощью технологии JPDA (Java Platform Debugger Architecture). Для этого к параметрам java машины сервера нужно добавить, например, -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005. После чего в Run/Debug Configurations добавить конфигурацию Remote c тем же портом и IP сервера, если это не localhost. Нажимаем Debug и дальнейший процесс не отличается от стандартной отладки прямо из IDEA. Если нужно перехватить что-то в первые секунды работы приложения, то в параметрах меняем на suspend=y, и тогда сервер будет ожидать подключения отладчика. Следует отметить, что если подключиться удалённо к серверу, которым кто-то пользуется, то сервер буквально остановится: даже без точек останова производительность падает в разы, а в противном случае все перехваченные запросы будут ожидать нажатия F9 (продолжить до следующей точки останова) в отладчике.
Изменение значения переменных из отладчика
Например, проблема возникает при работе на аккаунте пользователя, но не разработчика. Система аутентификации может быть сложной, и механизма подмены аккаунта для тестирования может не быть. В режиме отладчика выставляем точку останова после аутентификации, в окне переменных (Variables) нажимаем правой кнопкой на переменную -> Set Value (или F2). Вводим новое значение и нажимаем Enter. Далее отпускаем отладчик (F9) и продолжаем работу под подменённым пользователем. Также очень полезно при проверке тех частей алгоритма, к которым никак не удаётся добраться входными параметрами.
Вычисление выражений (Evaluate Expression)
Этот метод дополняет все остальные. В момент, когда программа на точке останова, нужно изучить значение переменных и понять, как исправить код. По комбинации Alt+F8 открывается окно Evaluate Expression, позволяющее вычислять выражения на лету с использованием всех тех переменных и контекста, которые есть у нас в программе в момент остановки. Даже больше, можно написать целую небольшую программу, выполнить несколько запросов к базе данных, несколько вариантов поиска ресурса в classpath и т.д.

Далее разберу всё то же подробнее. Код примера можно найти на github.

Проблема, с которой пришёл пользователь формулируется так: пользователь 'john.doe@company.com' может зайти на корпоративный сайт, но не имеет доступа к административным страницам (проще, к админке).

Представим себя на месте разработчика, отвечающего за сервис авторизации пользователей. Выясняем, что запрос к сервису на проверку доступа к общей части сайта ('User') возвращает true, а к админке ('Admin') - false.

GET /api/employee/is-authorized?mail=john.doe@company.com&authority=User
    true
GET /api/employee/is-authorized?mail=john.doe@company.com&authority=Admin
    false

В процессе исследования этой проблемы постараюсь использовать максимум возможностей отладки, но сначала объясню, что вообще за сервис и как он работает.


Сервис samples-employee-loader - это упрощённый пример интеграции корпоративных систем и реализации внутренних политик безопасности. Такое можно встретить в крупных компаниях, если миграция на готовые решения не завершена или не нужна, и используются внутренние разработки.

На вход к сервису поступает файл в csv формате (расположение: upstream/feed.csv), по сути, простой текстовый файл. В нём содержатся сотрудники компании и права их доступа. В качестве уникального идентификатора выступает почта (email) сотрудника. Роли (Права доступа, authority) это несколько строковых значений, разделённых палочкой (|). Пример содержимого файла:

john.doe@company.com, Admin|User
jane.doe@company.com, Developer|Admin|User
user3@company.com, Manager

Проект собирается с помощью maven, представляет собой стандартный Spring Boot проект. База данных - встроенная H2, работа с ней ведётся обычными SQL запросами с использованием Spring JdbcTemplate, REST сервис - Spring MVC. Далее несколько кусочков кода для наглядности.

Загрузка из файла по расписанию каждые пять минут, путь к расположению файла конфигурируется в application.yml

@Value("${loader.feedresource}")
private Resource feed;

@Scheduled(initialDelay = 1000, fixedDelay = 5 * 60 * 1000)
public void loadAll() throws IOException {
  if(feed.exists() && feed.isFile()) {
    try(BufferedReader reader = new BufferedReader(new InputStreamReader(feed.getInputStream()))) {
      String line;
      while ((line = reader.readLine()) != null) {
        parseLine(line).ifPresent(employeeDao::save);
      }
    }
  }
}

База данных из двух таблиц, схема находится в файле schema.sql, база инициализируется и стартует вместе с приложением (в реальности такие базы используются только в тестах, но для демонстрации очень удобно).

CREATE TABLE IF NOT EXISTS EMPLOYEE (
  EMAIL VARCHAR(250) NOT NULL PRIMARY KEY,
  IS_ACTIVE NUMBER
);

CREATE TABLE IF NOT EXISTS EMPLOYEE_AUTHORITY (
  EMAIL VARCHAR(250) NOT NULL,
  AUTHORITY VARCHAR(100) NOT NULL,
  FOREIGN KEY (EMAIL) REFERENCES EMPLOYEE(EMAIL)
);

CREATE UNIQUE INDEX IF NOT EXISTS EMP_AUTH_IDX ON EMPLOYEE_AUTHORITY(EMAIL, AUTHORITY);

REST сервис - обычный RestController.

@RestController
@RequestMapping("/api/employee")
public class EmployeeService {
    @GetMapping("/is-authorized")
    boolean isAuthorized(@RequestParam("mail") String mail, @RequestParam("authority") String authority) {
       Employee e = employeeDao.find(mail);
       return e != null && e.getAuthorities().contains(authority);
    }
}

Предлагаю читателю скачать проект из репозитория, запустить его (главный класс hipravin.samples.loader.LoaderApplication) и проверить работу сервиса (и попытаться найти источник проблемы!). Сервер стартует на 8085 порту, поменять это можно в файле application.yml. Полные пути к файлам я не указываю, потому что в IDEA очень легко искать файлы (Ctrl+Shift+N). В браузере должен быть результат как показано ниже:

http://localhost:8085/api/employee/is-authorized?mail=john.doe@company.com&authority=User
true
http://localhost:8085/api/employee/is-authorized?mail=john.doe@company.com&authority=Admin
false


Переходим к поиску проблемы в режиме отладки. Для начала установим условный брейкпоинт, срабатывающий только если параметр mail содержит 'john' (Можно и на точное равенство проверять, не принципиально). картинки нет, но вы держитесь

Нажимаем F8, чтобы перейти на следующую строчку, изучаем значения переменных. Вроде всё нормально. картинки нет, но вы держитесь

Нажимаем Alt-F8, вычисляем выражение e.getAuthorities().contains("Admin"). Всё-таки false, непонятно. картинки нет, но вы держитесь

Внимательнее посмотрев на значения переменных, замечаем пробел перед 'Admin'. Странно, ведь при парсинге точно делается .trim(). Для начала проверим, что дело в нём. Поменять строку в массиве почему-то не удаётся, меняем весь массив. Выделяем строчку authorities, F2, вводим Arrays.asList("Admin", "User"). картинки нет, но вы держитесь

Снова вычисляем e.getAuthorities().contains("Admin"), получаем true. Итак, дело почти точно в пробеле.

Борьба с пробелом. Почему же не помог trim? Повторяем цикл, чтобы вернуть пробел и снова работаем с evaluate expression. Пытаемся побороть пробел trim, strip и на что ещё хватит фантазии. Ничего не помогает, значит пробел у нас не той системы!

картинки нет, но вы держитесь

Видим, что код его -96. Кодировки это отдельная тема, да и разбираться с ней сейчас некогда, тут баг надо чинить. Но понятно, что коды символов бывают только положительные, а кодировка в Java Unicode. Поэтому преобразовываем -96 в беззнаковый байт (((byte)-96) & 0xFF), получаем 160. Дальше в поисковик ("Unicode 160"), и первая ссылка выдаёт что-то интересное: Unicode Character 'NO-BREAK SPACE' (U+00A0). Ну ясно, как говорится.

Далее следует изучение файла, там находим этот пробел как 255 символ ASCII, общаемся с командой, которая отвечает за feed.csv и так далее. На реальном проекте этот символ экспортировался автоматически из системы confluence, где он используется для форматирования (ведь несколько стандартных пробелов подряд в html склеиваются в один при отображении.) В источнике данных починить не получается, поэтому придётся в код вставить удаление этого символа. Такое решение между собой называют "костылем", а заказчику гордо презентуют как workaround. Фикс ниже, в репозитории его нет.

Правим метод parseAuthorities в классе EmployeeAuthoritiesFeedLoader, как показано ниже. Можно перезапустить сервис - теперь пользователь получил доступ к админке.

List<String> parseAuthorities(String authoritiesString) {
    return Arrays.stream(authoritiesString.split("\\|"))
       .map(s -> s.replace((char)0xA0, ' '))//лучше поменять всё на пробелы, чем удалять - вдруг nbsp появится в середине.
       .map(String::trim)
       .filter(s -> !s.isEmpty())
       .collect(Collectors.toList());
    }


С ошибкой nbsp мы разобрались, переходим к брейкпоинтам на исключения. Для этого сначала в файле поменяем имя пользователя в любой строчке на очень длинное (больше 250 символов.). Перезапускаем приложение и видим в логе ошибку.

    Caused by: org.h2.jdbc.JdbcSQLDataException: Значение слишком длинное для поля "EMAIL VARCHAR(250)": "'aaaaaaaaaaaaaaa... (262)"
База H2 очень заботливо указывает данные, на которых случилась ошибюка. В общем случае виден только класс исключения, но не то, из-за каких данных оно произошло. Например, на одном из проектов у пользователя не работало сохранение из-за нарушения внешних ключей. Деталей не помню, но отлаживать пришлось долго и нервно. Сущность ссылалась сама на себя, и ключ был сломан не на ней самой, а на предке, и паззл всё не складывался, пока не пришло озарение. Думаю, если бы я начал изучать данные в момент выпадения исключения, то всё решилось бы сильно быстрее.

Добавляем брейкпоинт на исключение JdbcSQLDataException, перезапускаем приложение в режиме отладки. картинки нет, но вы держитесь

Прерывание программы в момент выбрасывания исключения. Слева можно переходить по стеку вызовов методов и изучать переменные. Можно получить намного больше информации, чем есть в стектрейсе и сообщении исключения. картинки нет, но вы держитесь


Ну и напоследок, удалённая отладка (Remote debug, JPDA). Для начала надо научиться запускать программу не из среды разработки, а из консоли. Зaпускаем Maven->Lyfecycle->Package (либо из консоли mvn clean package). В папке target появляется loader.jar. Для удобства переносим его в корень проекта. Теперь находясь в корне проекта в терминале запускаем приложение. (Java в PATH должна быть не ниже 11 версии).

  java -jar loader.jar
    

Теперь Edit Configurations -> Add -> Remote. Можно ничего не менять, эта конфигурация будет подключаться к localhost:5005. Отсюда же копируем параметры для запуска, добавляем их в команду для запуска, получаем

    java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar loader.jar

картинки нет, но вы держитесь

Если всё правильно, то в консоли первой строчкой будет:

    Listening for transport dt_socket at address: 5005
Теперь запускаем недавно созданную конфигурацию на отладку и работаем как будто приложение стартовало из среды разработки. Если используется веб сервер (Tomcat, Weblogic и т.д.), то придётся поискать в интернете, куда именно добавлять параметры дебага, но суть та же.

Заключение

Никому не нравится сидеть и разбираться в проблемах приложения, чинить баги и пытаться понять чужой код. Поэтому надеюсь, что информация в данном посте поможет делать это эффективнее. Если ошибку можно воспроизвести на тестовой среде, то дальше дело техники найти причину. Опыт показывает, что даже если причина ошибки в сторонних библиотеках, аккуратная отладка довольно быстро это обнаруживает. Спасибо за внимание!

Опубликовано 2020-03-10