Программный дебаг Java-приложений посредством JDI

в 20:28, , рубрики: debug, java, метки:
Введение

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

Например, иногда для того чтоб «мониторить» состояние какой-то переменной, меняющейся в цикле, я использовал условный брейкпойнт, условием к которому был код вроде «System.out.println(theVariable); return false». Этот хак позволял получить лог значений переменной практически не прерывая работы приложения (она, я подозреваю, всё-таки прерывалась на время выполнения кода условия, но не более). Плюс, нередко при просмотре каких-нибудь данных через вид Display порядком раздражало то, что результат евалюейшна кода в Display введённого добавлялся тут же после него.

В общем хотелось получить возможность делать всё то же самое например через Bean Shell или Groovy Shell, что в принципе аналогично программному дебагу. По логике это не должно было быть сложно — ведь делает же это как-то сам Eclipse, верно?

Проведя некоторый рисёрч я смог получить доступ к отладочной информации JVM программно, и спешу поделится примером.

О JPDA и JDI

Для отладки JVM придуманы специальные стандарты, собранные вместе под «зонтичным» термином JPDA — Java Platform Debugger Architecture. В них входят JVMTI — нативный интерфейс для отладки приложений в JVM посредством вызова сишных функций, JDWP — протокол передачи данных между дебаггером и JVM, приложения в которой отлаживают и т.д.

Всё это выглядело не особо релевантно. Но сверх этого всего в JPDA входит некий JDI — Java Debug Interface. Это Java API для отладки JVM приложений — то, что доктор прописал. Официальная страница о JPDA подтвердила наличие referene имплементации JDI от Sun/Oracle. Значит, оставалось только начать ней пользоватся

Пример

В качестве proof of concept я решил попробовать запустить два Groovy Shell-а — один в отладочном режиме в качестве «подопытного», второй в качестве отладчика. В подопытном шелле была задана строчная переменная, значение которой требовалось получить из шелла-«отладчика».

Подопытный был запущен с следующими парметрами:
-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=7896
Т.е. JVM была запущена в режиме удалённой отладки через TCP/IP, и ожидала соединения от отладчика на порту 7896.

Также в подопытном Groovy Shell была выполнена следующая команда:

myVar = “Some special value”;

Соответственно значение “Some special value” должно было быть получено в отладчике.

Т.к. это не просто значение поля какого-нибудь объекта, для того чтоб его получить надо было немного знать внутренности Groovy Shell (или как минимум подглядывать в исходники), но тем интереснее и реалистичнее мне показалась задача.

Далее дело было за «отладчиком»:

Рассмотрим всё пошагово:

Соединение с JVM

При помощи JDI соединяемся с JVM которую задумали отлаживать (хост == локалхост т.к. я всё делал на одной машине, но сработает аналогично и с удалённой; порт же тот, что был выставлен в debug-параметрах «подопытной» JVM).
JDI позволяет присоединятся к JVM как через сокеты, так и напрямую к локальному процессу. Поэтому VirtualMachineManager возвращает больше одного AttachingConnector-а. Мы выбираем нужный коннектор по имени транспорта («st_socket»)

vmm = com.sun.jdi.Bootstrap.virtualMachineManager();
vmm.attachingConnectors().each{ if("dt_socket".equalsIgnoreCase(it.transport().name())) { atconn = it; } }
args = atconn.defaultArguments();
args.get("port").setValue(7896);
args.get("hostname").setValue("127.0.0.1");
vm = atconn.attach(prm);
Получение стактрейса потока main

Полученный интерфейс к удалённой JVM позволяет посмотреть запущенный в ней потоки, приостановить их и т.п. Но для того, чтоб иметь возможность делать вызовы методов в удалённой JVM нам нужен поток в ней, который был бы остановлен именно брейкпойнтом. О чём собственно гласит следующий пункт JDI javadoc:
«Method invocation can occur only if the specified thread has been suspended by an event which occurred in that thread. Method invocation is not supported when the target VM has been suspended through VirtualMachine.suspend() or when the specified thread is suspended through ThreadReference.suspend().»

Для установки брейкпойнта я пошёл несколько специфическим путём — не заглядывать в сорцы Groovy Shell а просто посмотреть что сейчас происходит в JVM и выставить брейкпойнт прямо в том, что происходит.

В потоках подопытной JVM был обнаружен поток main, и в его стактрейс я и заглянул. Поток был предварительно остановлен — чтоб стактрейс оставался актуален во время последующих манипуляций.

// Find thread by name "main"
vm.allThreads().each{ if(it.name().equals("main")) mainThread = it }
// Suspend it
mainThread.suspend()
// Look what's in it's stack trace
i=0; mainThread.frames().each{ println String.valueOf(i++)+": "+it; }; println "";

В результате получил такое:

0: java.io.FileInputStream.readBytes(byte[], int, int)+-1 in thread instance of java.lang.Thread(name='main', id=1)
1: java.io.FileInputStream:220 in thread instance of java.lang.Thread(name='main', id=1)
2: java.io.BufferedInputStream:218 in thread instance of java.lang.Thread(name='main', id=1)
3: java.io.BufferedInputStream:237 in thread instance of java.lang.Thread(name='main', id=1)
4: jline.Terminal:99 in thread instance of java.lang.Thread(name='main', id=1)
5: jline.UnixTerminal:128 in thread instance of java.lang.Thread(name='main', id=1)
6: jline.ConsoleReader:1453 in thread instance of java.lang.Thread(name='main', id=1)
7: jline.ConsoleReader:654 in thread instance of java.lang.Thread(name='main', id=1)
8: jline.ConsoleReader:494 in thread instance of java.lang.Thread(name='main', id=1)
9: jline.ConsoleReader:448 in thread instance of java.lang.Thread(name='main', id=1)
10: jline.ConsoleReader$readLine.call(java.lang.Object, java.lang.Object)+17 in thread instance of java.lang.Thread(name='main', id=1)
11: org.codehaus.groovy.tools.shell.InteractiveShellRunner:89 in thread instance of java.lang.Thread(name='main', id=1)
12: org.codehaus.groovy.tools.shell.ShellRunner:75 in thread instance of java.lang.Thread(name='main', id=1)
13: org.codehaus.groovy.tools.shell.InteractiveShellRunner.super$2$work()+1 in thread instance of java.lang.Thread(name='main', id=1)
.... и т.д., 65 строк суммарно
Установка брейкпойнта

Итак, мы имеем стактрейс остановленного потока main. API JDI возвращает для потоков так называемые StackFrame, из которых можно получить их Location. Собственно сей Location и требуется для установки брейкпойнта.
Не долго думая, локейшн я взял из «jline.ConsoleReader$readLine.call», и в него установил брейкпойнт, после чего запустил поток main работать дальше:

evReqMan = vm.eventRequestManager();
frame = mainThread.frames().get(10);
bpReq = evReqMan.createBreakpointRequest(frame.location());
mainThread.resume();
bpReq.enable();

Теперь брейкпойнт установлен. Переключившись в подопытный Groovy Shell и нажав ввод я увидел что он действительно остановился. У нас есть остановка потока на брейкпойнте — всё готово к вмешательству в работу подопытной JVM.

Получение ссылки на объект Groovy Shell

API JDI позволяет из StackFrame получать видимые в них переменные. Чтоб получить значение переменной из контекста Groovy Shell надо было сначала вытянуть ссылку на сам шелл. Но где он?

Подсматриваем все видимые переменные во всех стек фреймах:

i=0; mainThread.frames().each{ println String.valueOf(i++)+": "+it; try{ it.visibleVariables().each{var-> println " - "+var; }} catch(Exception e) {} }; println;

Обнаружился стек фрейм в объекте «org.codehaus.groovy.tools.shell.Main» с видимой переменной shell:
«48: org.codehaus.groovy.tools.shell.Main:131 in thread instance of java.lang.Thread(name='main', id=1)».

Получение искомого значения из Groovy Shell

У shell.Main имеется поле interpreter. Зная немного внутренности Groovy Shell я заранее знал что переменные GroovyShell контекста хранятся в объекте типа groovy.lang.Binding, который можно получить вызвав getContext() у Interpreter (вызов метода необходим т.к. соответствующего поля со ссылкой на groovy.lang.Binding в Interpreter нет).

Из Binding значение переменной можно получить вызовом метода getVariable(String varName).

frame = mainThread.frames().get(48);
vShell = frame.getValue(frame.visibleVariableByName("shell"));
vInterp = vShell.getValue(vShell.referenceType().fieldByName("interp"));
vContext = vInterp.invokeMethod(mainThread, vInterp.referenceType().methodsByName("getContext").get(0), [], 0) 
varVal = vContext.invokeMethod(mainThread, vContext.referenceType().methodsByName("getVariable").get(0), [vm.mirrorOf("myVar")], 0)

Последняя строка скрипта вернула нам ожидаемое значение «Some special value» — всё работает!

Последний штрих

Шутки ради я решил еще и поменять значение этой переменной из отладчика — для этого достаточно было вызвать у Binding метод setVariable(String varName, Object varValue). Что может быть проще?

varVal = vContext.invokeMethod(mainThread, vContext.referenceType().methodsByName("setVariable").get(0), [vm.mirrorOf("myVar"), vm.mirrorOf("Surprise!")], 0);
bpReq.disable();
mainThread.resume();

Чтоб убедится что всё сработало я также задизейблил брейкпойнт и запустил обратно приостановленный ранее по брейкпойнту поток main.

Переключившись в последний раз в подопытный Groovy Shell я проверил значиение переменной myVar, и оно оказалось равно «Surprise!».

Выводы

Быть Java программистом это счастье, ибо Sun подарил нам мощные инструменты — а значит большие возможности (-:
А если еще дописать к Groovy удобные врапперы для JDI можно сделать программную отладку из Groovy Shell вполне приятной. К сожалению пока-что она выглядит где-то так же, как, например, доступ к полям и методам через reflection API.

Автор: Sauron

Поделиться

* - обязательные к заполнению поля