С момента своего появления аннотации Java стали неотъемлемой частью API-интерфейсов более крупных платформ приложений. Хорошими примерами таких API-интерфейсов являются Spring или Hibernate, где добавление нескольких строк кода аннотации реализует довольно сложную программную логику. И хотя можно спорить о недостатках этих конкретных API, большинство разработчиков согласятся, что эта форма декларативного программирования весьма выразительна при правильном использовании. Однако лишь немногие разработчики предпочитают реализовывать API-интерфейсы на основе аннотаций для своих собственных платформ или промежуточного программного обеспечения приложений, главным образом потому, что их считают сложными для реализации. В следующей статье я хочу убедить вас, что такие API, напротив, довольно тривиальны для реализации и, используя правильные инструменты, не требуют каких-либо специальных знаний о Java.
Одна проблема, которая становится совершенно очевидной при реализации API на основе аннотаций, состоит в том, что аннотации не обрабатываются исполняющей средой выполнения Java. Как следствие, невозможно присвоить конкретное значение данной пользовательской аннотации. Например, предположим, что мы хотели определить @Log
аннотацию, которую мы хотим предоставить для простой регистрации каждого вызова аннотированного метода:
class Service { @Log void doSomething() { // do something ... } }
Поскольку @Log
аннотация не способна выполнять программную логику одним своим существованием, пользователь аннотации может выполнить запрошенную запись в журнал. Очевидно, что это делает аннотацию практически бесполезной, поскольку мы не можем вызвать doSomething
метод и ожидать, что в нашем журнале появится соответствующее утверждение. Пока что аннотация служит только маркером, не внося никакой логики в программу.
Преодоление разрыва
Чтобы преодолеть это явное ограничение, многие платформы, основанные на аннотациях, используют подклассы в сочетании с переопределением методов для реализации логики, связанной с конкретной аннотацией. Это обычно называют инструментальными средствами подкласса. Для предлагаемой @Log
аннотации инструментарий подкласса приведет к созданию класса, подобного следующему LoggingService
:
class LoggingService extends Service { @Override void doSomething() { Logger.log("doSomething() was called"); super.doSomething(); } }
Конечно, вышеупомянутый класс обычно не нужно реализовывать явно. Вместо этого это популярный подход к генерации таких классов только во время выполнения с использованием библиотеки генерации кода, такой как cglib или Javassist . Обе эти библиотеки предлагают простые API для создания подклассов, улучшающих программы. В качестве приятного побочного эффекта задержки создания класса до времени выполнения, предлагаемая среда ведения журналов будет использоваться без какой-либо специальной подготовки и всегда будет синхронизироваться с кодом пользователя. Также не будет случая, если класс будет создан более явным образом, например, путем записи исходного файла Java во время процесса сборки.
Но это масштабируется?
Однако это решение имеет еще один недостаток. Поместив логику аннотации в сгенерированный подкласс, больше не нужно создавать экземпляр Service
класса класса своим конструктором. В противном случае вызовы аннотированных методов все равно не будут регистрироваться: очевидно, что вызов конструктора не создает экземпляр требуемого подкласса. И что еще хуже — при использовании предложенного подхода генерации во время выполнения — LoggingService
нельзя создать экземпляр непосредственно, так как компилятор Java не знает о сгенерированном во время выполнения классе.
По этой причине фреймворки, такие как Spring или Hibernate, используют фабрики объектов и не допускают прямого создания экземпляров объектов, которые считаются частью их логики фреймворка. В Spring создание объектов фабрикой происходит естественным образом, поскольку все объекты Spring уже являются управляемыми компонентами, которые в первую очередь должны быть созданы фреймворком. Точно так же большинство объектов Hibernate создаются в результате запроса и, следовательно, не создаются явно. Однако, например, при сохранении экземпляра объекта, который еще не представлен в базе данных, пользователь Hibernate должен заменить недавно сохраненный экземпляр экземпляром, который возвращается из Hibernate после хранения. Если посмотреть на вопросы о Hibernate, игнорирование этой замены уже приводит к общей ошибке новичка. Кроме этого,Благодаря этим фабрикам инструментарий подкласса в основном прозрачен для пользователя фреймворка, потому что система типов Java подразумевает, что подкласс может заменить любой из его суперклассов. Следовательно, случай LoggingService
может использоваться везде, где пользователь ожидает экземпляр пользовательского Service
класса.
К сожалению, этот утвержденный метод фабрик экземпляров оказывается трудным для реализации предложенной @Log
аннотации, поскольку это повлечет за собой использование фабрики для каждого отдельного экземпляра потенциально аннотированного класса. Очевидно, это добавило бы огромное количество стандартного кода. Вероятно, мы бы даже создали больше шаблонов, чем избегали, если бы не жестко закодировали инструкцию регистрации в методах. Кроме того, случайное использование конструктора внесло бы незначительные ошибки в Java-программу, потому что аннотации в таких экземплярах больше не обрабатывались бы так, как мы ожидаем. В качестве еще одной проблемы, фабрики нелегко компонуются. Что если мы хотим добавить @Log
аннотации к классу, который уже является компонентом Hibernate? Это звучит тривиально, но для объединения обеих фреймворков потребуется обширная конфигурация. И, наконец, получившийся в результате фабричный код не получится слишком красивым для чтения, а переход на использование инфраструктуры будет дорогостоящим для реализации. Это где инструментарий с агентами Java вступает в действие. Эта недооцененная форма контрольно-измерительных приборов предлагает отличную альтернативу обсуждаемым контрольно-измерительным приборам подкласса.
Простой агент
Агент Java представлен простым файлом jar. Подобно обычным программам Java, агенты Java определяют некоторый класс как точку входа. Затем ожидается, что этот класс определит статический метод, который вызывается до вызова метода фактической программы Java main
:
class MyAgent { public static void premain(String args, Instrumentation inst) { // implement agent here ... } }
The most interesting part when dealing with Java agents is the premain
method’s second argument which represents an instance of the Instrumentation
interface. This interface offers a way of hooking into Java’s class loading process by defining aClassFileTransformer
. With such transformers, we are able to enhance any class of a Java program before its first use.
While using this API might sound straight forward at first, it imposes a new challenge. Class file transformations are executed by altering compiled Java classes which are represented as Java byte code. As a matter of fact, the Java virtual machine has no notion of what Java, the programming language is. Instead, it only deals with this byte code. And it is also thanks to this byte code abstraction that the JVM is easily capable of running other languages such as Scala or Groovy. As a consequence, a registered class file transformer only offers to transform a given byte (code) array into another one.
Even though libraries such as ASM or BCEL offer an easy API for manipulating compiled Java classes, only few developers are experienced in working with raw byte code. To make things worse, getting byte code manipulation right is often cumbersome and even small mistakes are redeemed by the virtual machine with throwing a nasty and unrecoverable VerifierError
. Fortunately, there are better, easier ways to manipulate byte code.
Byte Buddy, a library that I wrote and maintain, provides a simple API both for manipulating compiled Java classes and for creating Java agents. In some aspects, Byte Buddy is a code generation library similar to cglib and Javassist. However, other than those libraries, Byte Buddy offers a unified API for implementing subclasses and for redefining existing classes. For this article, we do however only want to look into redefining a class using a Java agent. Curious readers are referred to Byte Buddy’s webpage which offers a detailed tutorial on its full feature set.
Using Byte Buddy for a simple agent
One way that Byte Buddy offers for defining an instrumentation, is using dependency injection. Doing so, an interceptor class — which is represented by any plain old Java object — simply requests any required information by annotations on its parameters. For example, by using Byte Buddy’s @Origin
annotation on a parameter of the Method
type, Byte Buddy deducts that the interceptor wants to know about the method that is being intercepted. This way, we can define a generic interceptor that is always aware of the method that is being intercepted:
class LogInterceptor { static void log(@Origin Method method) { Logger.log(method + " was called"); } }
Of course, Byte Buddy ships with many more annotations.
But how does this interceptor represent the logic that we intended for the proposed logging framework? So far, we only defined an interceptor that is logging the method call. What we miss is the subsequent invocation of the original code of the method. Fortunately, Byte Buddy’s instrumentations are composable. First, we define a MethodDelegation
to the recently definedLogInterceptor
which by default invokes the interceptor’s static method on every call of a method. Starting from this, we can then compose the delegation with a subsequent call of the original method’s code which is represented by SuperMethodCall
:
MethodDelegation.to(LogInterceptor.class) .andThen(SuperMethodCall.INSTANCE)
Finally, we need to inform Byte Buddy on the methods that are to be intercepted by the specified instrumentation. As we explained before, we want this instrumentation to apply for any method that is annotated with @Log
. Within Byte Buddy, such a property of a method can be identified using an ElementMatcher
which is similar to a Java 8 predicate. In the static utility classElementMatchers
, we can already find a suitable matcher for identifying methods with a given annotation:ElementMatchers.isAnnotatedWith(Log.class)
.
With all this, we can now define an agent that implements the suggested logging framework. For Java agents, Byte Buddy provides a utility API that builds on the class modification API that we just discussed. Similarly to this latter API, it is designed as a domain specific language such that its meaning should be easily understood only by looking at the implementation. As we can see, defining such an agent only requires a few lines of code:
class LogAgent { public static void premain(String args, Instrumentation inst) { new AgentBuilder.Default() .rebase(ElementMatchers.any()) .transform( builder -> return builder .method(ElementMatchers.isAnnotatedWith(Log.class)) .intercept(MethodDelegation.to(LogInterceptor.class) .andThen(SuperMethodCall.INSTANCE)) ) .installOn(inst); } }
Note that this minimal Java agent would not interfere with the remainder of the application as any executing code observes the instrumented Java classes just as if the logging statement was hard-coded into any annotated method.
What about real life?
Of course the presented agent-based logger is a trivial example. And often, broadly-scoped frameworks that offer similar features out-of-the-box such for example Spring or Dropwizard are great. However, such frameworks are equally often opinionated about how to approach programming problems. For a large number of software applications, this might not be a problem. And yet, sometimes these opinions are in the way of something bigger. Then, working around a framework’s assumption on how to do things can cause more than just a few problems, often causes leaky abstractions and might just result in exploding costs for software maintenance. This is true especially when applications grow and change over time and diverge in their needs from what an underlying framework offers.
In contrast, when composing more specialized frameworks or libraries in a pic n mix fashion, one simply replaces problematic components with another one. And if this does not work either, one can even implement a custom solution without interfering with the rest of the application. As we learned, this seems difficult to realize on the JVM, mainly as a consequence of Java’s strict type system. Using Java agents, it is however very much possible to overcome these typing constraints.
I came to the point where I believe that at least any cross-cutting concern should be covered by an agent-driven, specialized library instead of by a built-in module of a monolithic framework. And I really wish more applications would consider this approach. In the most trivial case, it is enough to use an agent to register listeners on methods of interest and to take it from there. This indirect approach of composing code modules avoids the strong cohesion that I observe in a large fraction of the Java applications I come across. As a nice side effect, It also makes testing very easy. And similarly to running tests, not adding an agent when starting up an application, allows to pointedly disable a certain application feature like for example logging. All this without changing a line of code and without crashing the application as the JVM simply ignores annotations that it cannot resolve at runtime. Security, logging, caching, there are many reasons that these topics and more should be taken care of in the suggested manner. Therefore, sometimes, make agents, not frameworks.