Статьи

Руководство для начинающих по Java-агентам

В то время как новички Java быстро учатся печатать public static void main для запуска своих приложений, даже опытные разработчики часто не знают о поддержке JVM двух дополнительных точек входа в Java-процесс: premain и agentmain . Оба метода позволяют так называемым агентам Java вносить свой вклад в существующую программу Java, находясь в их собственном jar-файле, даже не будучи явно связанными с основным приложением. Таким образом, можно разрабатывать, выпускать и публиковать Java-агенты, полностью отделенные от приложения, в котором они размещаются, и в то же время запускать их в том же Java-процессе.

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

1
2
3
4
5
6
<pre class="wp-block-syntaxhighlighter-code">package sample;
public class SimpleAgent<?> {
  public static void premain(String argument) {
    System.out.println("Hello " + argument);
  }
}</pre>

Чтобы использовать этот класс в качестве агента Java, он должен быть упакован в файл JAR. За исключением обычных программ на Java, невозможно загрузить классы агента Java из папки. Кроме того, необходимо указать запись манифеста, которая ссылается на класс, содержащий метод premain :

1
Premain-Class: sample.SimpleAgent

При такой настройке агент Java теперь может быть добавлен в командную строку, указав на местоположение файловой системы связанного агента и при необходимости добавив один аргумент после знака равенства, как в:

java -javaagent: /location/of/agent.jar=World some.random.Program

Выполнению метода main в some.random.Program теперь будет предшествовать вывод из Hello World, где второе слово является предоставленным аргументом.

Инструментарий Api

Если бы упреждающее выполнение кода было единственной возможностью Java-агентов, они, конечно, были бы бесполезны. В действительности, большинство агентов Java полезны только из-за API инструментария, который может быть запрошен агентом Java путем добавления второго параметра типа Instrumentation в метод точки входа агента. API инструментария предлагает доступ к функциональности более низкого уровня, предоставляемой JVM, которая является эксклюзивной для агентов Java и никогда не предоставляется обычным программам Java. Как центральный элемент, API инструментария позволяет модифицировать классы Java до или даже после их загрузки.

Любой скомпилированный класс Java хранится в виде файла .class, который представляется агенту Java в виде байтового массива всякий раз, когда класс загружается в первый раз. Агент уведомляется путем регистрации одного или нескольких ClassFileTransformer в API инструментария, которые уведомляются для любого класса, который загружается ClassLoader текущего процесса JVM:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
package sample;
public class ClassLoadingAgent {
  public static void premain(String argument,
                             Instrumentation instrumentation) {
    instrumentation.addTransformer(new ClassFileTransformer() {
      @Override
       public byte[] transform(Module module,
                               ClassLoader loader,
                               String name,
                               Class<?> typeIfLoaded,
                               ProtectionDomain domain,
                               byte[] buffer) {
         System.out.println("Class was loaded: " + name);
         return null;
       }
    });
  }
}

В приведенном выше примере агент остается неработоспособным, возвращая null из преобразователя, который прерывает процесс преобразования, но выводит на консоль только сообщение с именем самого последнего загруженного класса. Но путем преобразования байтового массива, предоставленного параметром buffer , агент может изменить поведение любого класса до его загрузки.

Преобразование скомпилированного Java-класса может показаться сложной задачей. Но, к счастью, спецификация виртуальной машины Java (JVMS) детализирует значение каждого байта, который представляет файл класса. Чтобы изменить поведение метода, необходимо определить смещение кода метода, а затем добавить в этот метод так называемые инструкции байтового кода Java для представления желаемого измененного поведения. Обычно такое преобразование применяется не вручную, а с использованием процессора байт-кода, наиболее известной из которого является библиотека ASM, которая разбивает файл класса на его компоненты. Таким образом, становится возможным рассматривать поля, методы и аннотации изолированно, что позволяет применять более целенаправленные преобразования и экономит некоторую бухгалтерию.

Разработка без отвлечения агента

Хотя ASM делает преобразование файлов классов более безопасным и менее сложным, он все же полагается на хорошее понимание байт-кода и его характеристик пользователем библиотеки. Однако другие библиотеки, часто основанные на ASM, позволяют выражать преобразования байт-кода на более высоком уровне, что делает такое понимание косвенным. Примером такой библиотеки является Byte Buddy, который разработан и поддерживается автором этой статьи. Byte Buddy стремится отобразить преобразования байт-кода в концепции, которые уже известны большинству разработчиков Java, чтобы сделать разработку агентов более доступной.

Для написания Java-агентов Byte Buddy предлагает API AgentBuilder который создает и регистрирует ClassFileTransformer под прикрытием. Вместо прямой регистрации ClassFileTransformer , Byte Buddy позволяет указать ElementMatcher чтобы сначала идентифицировать типы, которые представляют интерес. Для каждого сопоставленного типа можно указать одно или несколько преобразований. Затем Byte Buddy преобразует эти инструкции в эффективную реализацию преобразователя, который можно установить в API инструментария. Например, следующий код воссоздает предыдущий неработающий преобразователь в API Байта Бадди:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
package sample;
public class ByteBuddySampleAgent {
  public static void premain(String argument,
                             Instrumentation instrumentation) {
    new AgentBuilder.Default()
      .type(ElementMatchers.any())
      .transform((DynamicType.Builder<?> builder,
                  TypeDescription type,
                  ClassLoader loader,
                  JavaModule module) -> {
         System.out.println("Class was loaded: " + name);
         return builder;
      }).installOn(instrumentation);
  }
}

Следует отметить, что в отличие от предыдущего примера, Byte Buddy преобразует все обнаруженные типы без применения изменений, которые менее эффективны, чем полное игнорирование этих нежелательных типов. Кроме того, он будет игнорировать классы базовой библиотеки Java по умолчанию, если не указано иное. Но, по сути, тот же эффект достигается так, что простой агент, использующий Byte Buddy, может быть продемонстрирован с помощью приведенного выше кода.

Измерение времени выполнения с помощью Byte Buddy advice

Вместо того, чтобы выставлять файлы классов в виде байтовых массивов, Byte Buddy пытается переплетать или связывать обычный Java-код с инструментальными классами. Таким образом, разработчики агентов Java не должны напрямую создавать байт-код, а могут полагаться на язык программирования Java и его существующие инструменты, с которыми они уже связаны. Для агентов Java, написанных с использованием Byte Buddy, поведение часто выражается классами рекомендаций, где аннотированные методы описывают поведение, добавляемое в начало и конец существующих методов. Например, следующий класс рекомендации служит шаблоном, в котором время выполнения метода выводится на консоль:

01
02
03
04
05
06
07
08
09
10
11
12
13
public class TimeMeasurementAdvice {
  @Advice.OnMethodEnter
  public static long enter() {
    return System.currentTimeMillis();
  }
  @Advice.OnMethodExit(onThrowable = Throwable.class)
  public static void exit(@Advice.Enter long start,
                          @Advice.Origin String origin) {
     long executionTime = System.currentTimeMillis() - start;
    System.out.println(origin + " took " + executionTime
                           + " to execute");
  }
}

В приведенном выше классе рекомендации метод enter просто записывает текущую временную метку и возвращает ее, чтобы сделать ее доступной в конце метода. Как указано, ввод совета выполняется перед фактическим телом метода. В конце метода применяется рекомендация о выходе, где записанное значение вычитается из текущей временной метки для определения времени выполнения метода. Это время выполнения затем выводится на консоль.

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
package sample;
public class ByteBuddyTimeMeasuringAgent {
  public static void premain(String argument,
                             Instrumentation instrumentation) {
    Advice advice = Advice.to(TimeMeasurementAdvice.class);
    new AgentBuilder.Default()
      .type(ElementMatchers.isAnnotatedBy(MeasureTime.class))
      .transform((DynamicType.Builder<?> builder,
                  TypeDescription type,
                  ClassLoader loader,
                  JavaModule module) -> {
         return builder.visit(advice.on(ElementMatchers.isMethod());
      }).installOn(instrumentation);
  }
}

Учитывая применение вышеупомянутого агента, все времена выполнения метода теперь выводятся на консоль, если класс аннотируется MeasureTime . В действительности, конечно, было бы более разумно собирать такие показатели более структурированным образом, но после того, как они уже получили распечатку, это уже не сложная задача.

Динамическое вложение агента и переопределение класса

До Java 8 это было возможно благодаря утилитам, хранящимся в файле tools.jar JDK, который можно найти в папке установки JDK. Начиная с Java 9, этот jar был растворен в модуле jdk.attach, который теперь доступен в любом обычном дистрибутиве JDK. Используя прилагаемый инструментарий API, можно прикрепить файл JAR к JVM с заданным идентификатором процесса, используя следующий код:

1
2
3
4
5
6
VirtualMachine vm = VirtualMachine.attach(processId);
try {
  vm.loadAgent("/location/of/agent.jar");
} finally {
  vm.detach();
}

При вызове вышеуказанного API JVM найдет процесс с заданным идентификатором и выполнит метод agentmain в выделенном потоке на этой удаленной виртуальной машине. Кроме того, такие агенты могут запросить право на повторное преобразование классов в своем манифесте, чтобы изменить код уже загруженных классов:

1
2
Agentmain-Class: sample.SimpleAgent
Can-Retransform-Classes: true

Учитывая эти записи манифеста, агент теперь может запросить, чтобы любой загруженный класс рассматривался для ClassFileTransformer что предыдущий ClassFileTransformer может быть зарегистрирован с дополнительным логическим аргументом, указывающим требование быть уведомленным при попытке ClassFileTransformer :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package sample;
public class ClassReloadingAgent {
  public static void agentmain(String argument,
                               Instrumentation instrumentation) {
    instrumentation.addTransformer(new ClassFileTransformer() {
      @Override
       public byte[] transform(Module module,
                               ClassLoader loader,
                               String name,
                               Class<?> typeIfLoaded,
                               ProtectionDomain domain,
                               byte[] buffer) {
          if (typeIfLoaded == null) {
           System.out.println("Class was loaded: " + name);
         } else {
           System.out.println("Class was re-loaded: " + name);
         }
         return null;
       }
    }, true);
    instrumentation.retransformClasses(
        instrumentation.getAllLoadedClasses());
  }
}

Чтобы указать, что класс уже был загружен, экземпляр загруженного класса теперь представляется преобразователю, который будет null для класса, который не был загружен ранее. В конце вышеприведенного примера API инструментария запрашивается извлечь все загруженные классы, чтобы передать любой такой класс для повторного преобразования, что запускает выполнение преобразователя. Как и прежде, преобразователь файлов классов реализован так, что он не работает с целью демонстрации работы API инструментария.

Конечно, Byte Buddy также охватывает эту форму преобразования в своем API путем регистрации стратегии ретрансформации, и в этом случае Byte Buddy также рассмотрит все классы для ретрансформации. При этом предыдущий агент измерения времени может быть настроен так, чтобы он также учитывал загруженные классы, если он был присоединен динамически:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
package sample;
public class ByteBuddyTimeMeasuringRetransformingAgent {
  public static void agentmain(String argument,
                               Instrumentation instrumentation) {
    Advice advice = Advice.to(TimeMeasurementAdvice.class);
    new AgentBuilder.Default()
       .with(AgentBuilder.RetransformationStrategy.RETRANSFORMATION)
       .disableClassFormatChanges()
      .type(ElementMatchers.isAnnotatedBy(MeasureTime.class))
      .transform((DynamicType.Builder<?> builder,
                  TypeDescription type,
                  ClassLoader loader,
                  JavaModule module) -> {
         return builder.visit(advice.on(ElementMatchers.isMethod());
      }).installOn(instrumentation);
  }
}

В качестве последнего удобства Byte Buddy также предлагает API для подключения к JVM, который абстрагируется от версий и поставщиков JVM, чтобы сделать процесс присоединения максимально простым. Учитывая идентификатор процесса, Byte Buddy может присоединить агент к JVM, выполнив одну строку кода:

1
ByteBuddyAgent.attach(processId, "/location/of/agent.jar");

Кроме того, можно даже подключиться к тому же процессу виртуальной машины, который в настоящее время выполняется, что особенно удобно при тестировании агентов:

1
Instrumentation instrumentation = ByteBuddyAgent.install();

Эта функциональность доступна в виде собственного артефакта byte-buddy-agent, и она должна упростить опробование собственного агента для себя, поскольку благодаря экземпляру Instrumentation можно просто напрямую agentmain метод premain или agentmain , например, из модуля тест, и без каких-либо дополнительных настроек.

Опубликовано на Java Code Geeks с разрешения Рафаэля Винтерхальтера, партнера нашей программы JCG . Смотрите оригинальную статью здесь: руководство для начинающих по Java агентам

Мнения, высказанные участниками Java Code Geeks, являются их собственными.