Статьи

Инструментарий веб-приложений Java без изменения их исходного кода

Большинство веб-приложений Java используют стандартные интерфейсы Java при взаимодействии с другими системами. HTTP-сервисы, такие как Web-страницы или REST-серверы, реализованы с использованием интерфейса javax.servlet.Servlet . Взаимодействие с базой данных осуществляется с использованием интерфейсов JDBC java.sql.Statement и java.sql.Connection . Эти стандарты используются практически повсеместно, независимо от базовой структуры (Spring или Java EE) и контейнера сервлетов (Tomcat, Wildfly и т. Д.).

В этой статье показано, как реализовать агент Java, который подключается к этим интерфейсам с помощью манипулирования байт-кодом и собирает метрики о частоте и продолжительности вызовов HTTP и базы данных. Демонстрационный код доступен по адресу https://github.com/fstab/promagent , который является агентом, использующим веб-приложения Java для системы мониторинга Prometheus . Однако эта статья не относится к Prometheus, она фокусируется на базовых технологиях, таких как Java-агенты, манипуляции с байт-кодом и загрузчики классов.

1. Агенты Java

Агенты Java — это программы Java, которые можно подключить к JVM для манипулирования байт-кодом Java. Например, агенты Java могут использоваться для изменения всех реализаций интерфейса javax.servlet.Servlet для получения статистики о количестве и продолжительности HTTP-вызовов.

Агенты Java поставляются в виде файлов JAR. В то время как обычные Java-программы имеют метод main() в качестве точки входа приложения, Java-агенты имеют метод premain() который будет вызываться перед методом main() :

Схема агента Java

1
2
3
4
5
public class MyAgent {
    public static void premain(String agentArgs, Instrumentation inst) throws Exception {
        // ...
    }
}

В то время как исполняемые файлы JAR содержат файл MANIFEST.MF котором Premain-Class Main-Class , файлы JAR агентов содержат файл MANIFEST.MF котором Premain-Class . Агент может быть подключен во время запуска приложения с помощью параметра командной строки -javaagent: ::

Командная строка агента Java

1
java -javaagent:myagent.jar -jar myapp.jar

Затем premain() может вызвать inst.addTransformer() для регистрации ClassFileTransformer . Преобразователь файла класса реализует метод transform() который будет вызываться всякий раз, когда загружается класс Java. Он может проверять и изменять байт-код любого Java-класса для добавления дополнительных функций.

2. Манипуляция байт-кодом

Существует несколько доступных библиотек, помогающих разработчикам Java реализовывать манипуляции с байт-кодом. Самый низкий уровень — это ASM . Другие библиотеки, такие как cglib и javassist, предоставляют API более высокого уровня. Самая новая и самая простая в использовании библиотека — Byte Buddy . Он предоставляет легко читаемый свободно распространяемый Java API для создания ClassFileTransformer и регистрации его в Instrumentation :

Byte Buddy Agent Пример

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
package io.promagent;
 
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.agent.builder.AgentBuilder.Transformer;
import net.bytebuddy.matcher.ElementMatchers;
 
import java.lang.instrument.Instrumentation;
 
import static net.bytebuddy.matcher.ElementMatchers.hasSuperType;
import static net.bytebuddy.matcher.ElementMatchers.named;
 
public class MyAgent {
 
    public static void premain(String agentArgs, Instrumentation inst) throws Exception {
        new AgentBuilder.Default()
                .type(hasSuperType(named("javax.servlet.Servlet")))
                .transform(new Transformer.ForAdvice()
                        .include(MyAgent.class.getClassLoader())
                        .advice(ElementMatchers.named("service"), "io.promagent.MyAdvice"))
                .installOn(inst);
    }

В приведенном выше примере показан полный код, необходимый для инструментирования метода service() всех реализаций javax.servlet.Servlet . Метод service() вызывается всякий раз, когда сервлет обрабатывает веб-запрос. Класс MyAdvice определяет код, который будет MyAdvice в метод service() сервлета. Этот код аннотируется с помощью @Advice.OnMethodEnter и @Advice.OnMethodExit :

Byte Buddy Advice Пример

01
02
03
04
05
06
07
08
09
10
11
12
public class MyAdvice {
 
    @Advice.OnMethodEnter
    public static void before(ServletRequest request, ServletResponse response) {
        System.out.println("before serving the request...");
    }
 
    @Advice.OnMethodExit
    public static void after(ServletRequest request, ServletResponse response) {
        System.out.println("after serving the request...");
    }
}

Byte Buddy предлагает два способа инструментирования: советы (как показано выше) и перехватчики. Разница @Advice.OnMethodEnter : в случае с советами байт-код методов @Advice.OnMethodEnter и @Advice.OnMethodExit копируется в начало и в блок finally перехваченного метода. Эффект такой же, как если бы вы копировали и вставляли код в реализацию service() вы хотите перехватить. В результате класс MyAdvice больше не используется после выполнения инструментария. Перехваченному методу service() не требуется доступ к классу MyAdvice , его можно выполнить в контексте загрузчика классов, где класс MyAdvice недоступен.

Перехватчики, с другой стороны, являются обычными вызовами методов, которые выполняются в начале и в блоке finally перехваченных методов. Это означает, что перехваченный метод должен выполняться в контексте, где доступен класс перехватчика.

В следующих разделах мы увидим, что видимость классов в средах сервера приложений может быть ограничена, поэтому Promagent использует Advices вместо Interceptors.

3. Добавление зависимостей

Чтобы превратить приведенный выше пример во что-то полезное, нам нужно заменить сообщения System.out.println() кодом, поддерживающим метрики и предоставляющим метрики для системы мониторинга. Например, Promagent использует клиентскую библиотеку Prometheus для поддержки и предоставления метрик Prometheus .

JVM автоматически добавляет файл JAR, указанный в -javaagent: командной строки -javaagent: к загрузчику системных классов приложения. Следовательно, теоретически должно быть возможно создать JAR-javaagent: Uber, содержащий агент и все его зависимости, и использовать его в -javaagent: командной строки -javaagent: .

Однако сделать все зависимости доступными для системного загрузчика классов проблематично в среде сервера приложений по двум причинам:

  • Некоторые из зависимостей агента могут конфликтовать с библиотеками, используемыми внутри сервера приложений, или с библиотеками, поставляемыми в файле WAR как часть развернутого приложения.
  • Чтобы предотвратить конфликты, серверы приложений ограничивают доступ к классам из системного загрузчика классов. Например, модули Wildfly не могут получить доступ к классам из системного загрузчика классов, если jboss.modules.system.pkgs пакет явно не открыт с jboss.modules.system.pkgs системного свойства jboss.modules.system.pkgs . Нетривиально отслеживать все зависимости и настраивать систему модулей соответствующим образом.

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

4. Загрузка хуков из загрузчика пользовательских классов

Реализовать пользовательский загрузчик классов в Java легко, так как мы можем просто использовать java.net.URLClassLoader и инициализировать его путем к файлу JAR, в котором находятся наши классы. Чтобы сделать агент простым в использовании, Promagent поставляется в виде файла JAR, содержащего другие файлы JAR. Внутренние JAR-файлы копируются во временный каталог при запуске, а пользовательский загрузчик классов настраивается с использованием временных путей. Таким образом, пользователь получает один агент JAR, а внутри агент различает классы в системном загрузчике классов (эти классы содержатся непосредственно в агенте JAR) и классы в пользовательском загрузчике классов (эти классы загружаются из JAR-файлов в временный каталог).

Фактическая аппаратура реализована в классе под названием hook . Хук загружается из пользовательского загрузчика классов. Таким образом, ловушка может ссылаться на любые необходимые ей зависимости, если загрузчик пользовательских классов может предоставить эти зависимости. В качестве примера, ServletHook выглядит так:

Пример пользовательского класса Hook

01
02
03
04
05
06
07
08
09
10
public class ServletHook {
 
    public void before(ServletRequest request, ServletResponse response) {
        // ...
    }
 
    public void after(ServletRequest request, ServletResponse response) {
        // ...
    }
}

Крючок похож на совет Byte Buddy. Разница в том, что рекомендация Byte Buddy — это всего лишь несколько строк кода с минимальными зависимостями, необходимыми для загрузки соответствующего обработчика из загрузчика пользовательского класса и делегирования с помощью отражения в методы обработчика before() и after() . Рекомендация Byte Buddy не имеет никаких зависимостей от библиотеки инструментов, поскольку фактическая библиотека инструментов видна только в загрузчике пользовательских классов.

Однако при загрузке ловушки есть ServletRequest ловушка: параметры ServletRequest и ServletResponse будут переданы из инструментированного Servlet . Это означает, что ServletRequest и ServletResponse в ServletResponse должны быть загружены тем же загрузчиком классов, что и перехваченный сервлет, иначе мы не сможем передать параметры сервлета в метод ловушки before() и after() .

Решением является использование Thread.currentThread().getContextClassLoader() качестве родителя пользовательских загрузчиков классов. Таким образом, все классы, которые можно загрузить из загрузчика класса контекста, будут загружены из загрузчика класса контекста. Это включает в себя ServletRequest и ServletResponse . Только классы, которые не доступны в текущем контексте, такие как сам хук и его зависимости, будут загружены из пользовательских файлов JAR. Это означает, что нам нужен один пользовательский загрузчик классов на контекст, потому что каждый пользовательский загрузчик классов делегирует другому загрузчику класса контекста в качестве своего родителя.

5. Внедрение глобального реестра метрик

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

Один из способов решения этой проблемы — расширить загрузчик пользовательских классов и заставить его делегировать загрузку разделяемой библиотеки метрик в другой общий загрузчик пользовательских классов. Однако JVM также поставляется со встроенным глобальным реестром, который мы можем использовать в качестве хранилища метрик для всей виртуальной машины: сервер MBean платформы JMX. Регистрация метрик как MBeans имеет следующие преимущества:

  • Глобальный реестр: сервер MBean платформы JMX предоставляет реестр для всей виртуальной машины, что позволяет нам поддерживать глобальный набор метрик для инструментирования всех развертываний на сервере приложений.
  • Единый экспортер в систему мониторинга: Легко реализовать небольшое веб-приложение, которое считывает все метрики с сервера MBean и делает их доступными для системы мониторинга. Например, Promagent включает развертывание WAR для экспорта метрик на сервер Prometheus .
  • Инструменты JMX: поскольку все метрики доступны в виде MBean-компонентов, любой клиент JMX может быть использован для изучения состояния метрик.

Сервер MBean платформы JMX является частью Java SE и доступен через статический метод ManagementFactory.getPlatformMBeanServer() . Объекты Java, зарегистрированные на сервере MBean, называются MBean. MBeans должен определять свой общедоступный API в интерфейсе, который по соглашению именуется как класс Java с добавленным суффиксом MBean . Например, чтобы зарегистрировать класс Counter в качестве MBean, класс должен реализовать интерфейс с именем CounterMBean . Каждый MBean адресуется через уникальное имя ObjectName . Методы, определенные в интерфейсе MBean, можно вызывать с помощью MBeanServer.invoke() .

6. Резюме

В этой статье дается обзор того, как использовать Java-приложения на инструментах без изменения их исходного кода. Он основан на Promagent , который оснащает Java-приложения метриками Prometheus . Тем не менее, статья была сфокусирована на базовых технологиях, таких как Java-агенты, библиотека манипулирования байт-кодом Byte Buddy , загрузка классов в среде сервера приложений, такой как Wildfly , и сервер MBean платформы JMX.

Есть несколько слабых сторон, которые лучше всего выбрать из примера кода Promagent, например, как избежать двойного подсчета HTTP-запросов, когда они проходят через цепочку из нескольких сервлетов. Для большего количества примеров, возможно, стоит взглянуть на связанные проекты, такие как inspectIT или stagemonitor .