Динамическая компиляция Java-кода своими руками
В этой статье я расскажу о нашей реализации hot deploy — быстрой доставки изменений Java-кода в работающее приложение.Для начала немного истории. Мы уже несколько лет делаем корпоративные приложения на платформе CUBA. Они очень разные по размеру и функциональности, но все они похожи в одном — в них много пользовательского интерфейса.
В какой-то момент мы поняли, что разрабатывать пользовательский интерфейс, постоянно перезагружая сервер — крайне утомительно. Использование Hot Swap сильно ограничивает (нельзя добавлять и переименовывать поля, методы класса). Каждая перезагрузка сервера отнимала минимум 10 секунд времени, плюс необходимость повторного логина и перехода на тот экран, который ты разрабатываешь.
Пришлось задуматься о полноценном hot deploy. Под катом — наше решение проблемы с кодом и демо-приложением.
ПредпосылкиРазработка экранов в платформе CUBA предполагает создание декларативного XML-описателя экрана, в котором указывается имя класса-контроллера. Таким образом класс-контроллер экрана всегда получается по полному имени.Также следует заметить, что в большинстве случаев контроллер экрана является вещью в себе, то есть не используется другими контроллерами или просто классами (такое бывает, но не часто).
Сначала мы пытались использовать Groovy для решения проблемы hot deploy. Мы стали загружать исходный Groovy-код на сервер и получать классы контроллеров экранов через GroovyClassLoader. Это решило проблему со скоростью доставки изменений на сервер, но создало много новых проблем: на тот момент Groovy относительно слабо поддерживался IDE, динамическая типизация позволяла написать некомпилируемый код незаметно для себя, неопытные разработчики регулярно старались написать код как можно безобразнее, просто потому, что Groovy позволяет так делать.
Учитывая то, что в проектах были сотни экранов, каждый из которых потенциально мог сломаться в любой момент, нам пришлось отказаться от использования Groovy в контроллерах экранов.
Тогда мы крепко задумались. Нам хотелось получить преимущества мгновенной доставки кода на сервер (без перезагрузки) и в то же время не рисковать сильно качеством кода. На помощь пришла фича, появившаяся в Java 1.6 — ToolProvider.getSystemJavaCompiler () (описание на IBM.com). Этот объект позволяет получать объекты типа java.lang.Class из исходного кода. Мы решили попробовать.
Реализация Свой класслоадер мы решили сделать похожим на GroovyClassLoader. Он кэширует скомпилированные классы и при каждом обращении к классу проверяет, не обновился ли исходный код класса в файловой системе. Если обновился — запускается компиляция и результаты попадают в кэш.Детальную реализацию класслоадера вы можете увидеть, перейдя по ссылке.
Я же в статье остановлюсь на ключевых моментах реализации.
Начнем с главного класса — JavaClassLoader.
Сокращенный код JavaClassLoader public class JavaClassLoader extends URLClassLoader implements ApplicationContextAware { …
protected final Map
protected final ProxyClassLoader proxyClassLoader; protected final SourceProvider sourceProvider;
protected XmlWebApplicationContext applicationContext;
private static volatile boolean refreshing = false;
… @Override public void setApplicationContext (ApplicationContext applicationContext) throws BeansException { this.applicationContext = (XmlWebApplicationContext) applicationContext; this.applicationContext.setClassLoader (this); }
public Class loadClass (final String fullClassName, boolean resolve) throws ClassNotFoundException { String containerClassName = StringUtils.substringBefore (fullClassName,»$»);
try { lock (containerClassName); Class clazz;
if (! sourceProvider.getSourceFile (containerClassName).exists ()) { clazz = super.loadClass (fullClassName, resolve); return clazz; }
CompilationScope compilationScope = new CompilationScope (this, containerClassName); if (! compilationScope.compilationNeeded ()) { return getTimestampClass (fullClassName).clazz; }
String src; try { src = sourceProvider.getSourceString (containerClassName); } catch (IOException e) { throw new ClassNotFoundException («Could not load java sources for class » + containerClassName); }
try {
log.debug («Compiling » + containerClassName);
final DiagnosticCollector
SourcesAndDependencies sourcesAndDependencies = new SourcesAndDependencies (rootDir, this);
sourcesAndDependencies.putSource (containerClassName, src);
sourcesAndDependencies.collectDependencies (containerClassName);
Map
@SuppressWarnings («unchecked»)
Map
Map
clazz = compiledClasses.get (fullClassName);
updateSpringContext ();
return clazz; } catch (Exception e) { proxyClassLoader.restoreRemoved (); throw new RuntimeException (e); } finally { proxyClassLoader.cleanupRemoved (); } } finally { unlock (containerClassName); } }
private void updateSpringContext () { if (! refreshing) { refreshing = true; applicationContext.refresh (); refreshing = false; } }
…
/**
* Add dependencies for each class and ALSO add each class to dependent for each dependency
*/
private void linkDependencies (Map
Collection
for (String dependencyClassName: timestampClass.dependencies) { TimestampClass dependencyClass = compiled.get (dependencyClassName); if (dependencyClass!= null) { dependencyClass.dependent.add (className); } } } }
… } При вызове loadClass мы производим следующие действия: Проверяем, есть ли в файловой системе исходный код данного класса, если нет — вызываем унаследованный loadClass Проверяем, нужна ли компиляция — например файл с исходным кодом класса был изменен. Здесь нужно помнить, что мы следим не только за изменением 1 файла с классом, но и за всеми зависимостями Собираем зависимости — все что зависит от класса, который мы собираемся компилировать, а также все, от чего зависит он Проверяем каждую зависимость на необходимость компиляции, выбрасываем те, что компилировать не нужно Компилируем исходники Кладем результаты в кэш Обновляем, если необходимо, Spring-контекст Возвращаем запрошенный класс Если обратить внимание на метод updateSpringContext (), то можно заметить, что мы обновляем Spring-контекст после каждой загрузки классов. Это было сделано для демонстрационного приложения, в реальном проекте такое частое обновление контекста обычно не требуется.У кого-то может возникнуть вопрос — как мы определяем, от чего зависит класс? Ответ простой — мы разбираем секцию импортов. Далее приведен код, который это делает.
Код сбора зависимостей. class SourcesAndDependencies { private static final String IMPORT_PATTERN = «import (.+?);»; private static final String IMPORT_STATIC_PATTERN = «import static (.+)\\…+?;»; public static final String WHOLE_PACKAGE_PLACEHOLDER = ».*»;
final Map
private final SourceProvider sourceProvider; private final JavaClassLoader javaClassLoader;
SourcesAndDependencies (String rootDir, JavaClassLoader javaClassLoader) { this.sourceProvider = new SourceProvider (rootDir); this.javaClassLoader = javaClassLoader; }
public void putSource (String name, CharSequence sourceCode) { sources.put (name, sourceCode); }
/**
* Recursively collects all dependencies for class using imports
*
* @throws java.io.IOException
*/
public void collectDependencies (String className) throws IOException {
CharSequence src = sources.get (className);
List
/**
* Decides what to compile using CompilationScope (hierarchical search)
* Find all classes dependent from those we are going to compile and add them to compilation as well
*/
public Map
collectDependent (rootClassName, dependentSources); for (String dependencyClassName: sources.keySet ()) { CompilationScope dependencyCompilationScope = new CompilationScope (javaClassLoader, dependencyClassName); if (dependencyCompilationScope.compilationNeeded ()) { collectDependent (dependencyClassName, dependentSources); } } sources.putAll (dependentSources); return sources; }
/**
* Find all dependent classes (hierarchical search)
*/
private void collectDependent (String dependencyClassName, Map
private void addDependency (String dependent, String dependency) { if (! dependent.equals (dependency)) { dependencies.put (dependent, dependency); } }
private void addSource (String importedClassName) throws IOException { sources.put (importedClassName, sourceProvider.getSourceString (importedClassName)); }
private List
return Collections.emptyList (); }
private List
List
importValues = getMatchedStrings (src, IMPORT_STATIC_PATTERN, 1); for (String importValue: importValues) { importedClassNames.addAll (unwrapImportValue (importValue)); } return importedClassNames; }
private List
public CharSequenceCompiler (ProxyClassLoader loader, Iterable
…
public synchronized Map
}
throw new CharSequenceCompilerException («Compilation failed. Causes:» + cause, classes
.keySet (), diagnostics);
}
try {
// For each class name in the input map, get its compiled
// class and put it in the output map
Map
Сейчас я продемонстрирую, как с помощью нашего класслоадера мы можем поменять реализацию SomeBeanImpl и WelcomeController без остановки приложения. Для начала развернем приложение (для сборки вам понадобится gradle) и перейдем на localhost:8080/mvcclassloader/hello.
Ответ таков: Hello from WelcomeController. Version: not reloaded.
Теперь давайте слегка поменяем реализацию SomeBeanImpl.
@Component («someBean») public class SomeBeanImpl implements SomeBean { @Override public String get () { return «reloaded»;//здесь было not reloaded } } Положим файл на сервер в папку tomcat/conf/com/haulmont/mvcclassloader (папка, в которой класслоадер ищет исходный код настраивается в файле mvc-dispatcher-servlet.xml). Теперь нужно вызвать загрузку классов. Для этого я создал отдельный контроллер — ReloadController. В реальности обнаруживать изменения можно разными способами, но для демонстрации это подойдет. ReloadController перезагружает 2 класса в нашем приложении. Вызвать контроллер можно перейдя по ссылке localhost:8080/mvcclassloader/reload.
После этого перейдя снова на localhost:8080/mvcclassloader/hello мы увидим: Hello from WelcomeController. Version: reloaded.Но это еще не все. Мы можем также поменять код WebController. Давайте сделаем это.
@Controller («welcomeController») public class WelcomeController { @Autowired protected SomeBean someBean;
@RequestMapping (value = »/hello», method = RequestMethod.GET) public ModelAndView welcome () { ModelAndView model = new ModelAndView (); model.setViewName («index»); model.addObject («version», someBean.get () + » a bit more»);//добавлено a bit more
return model; } } Вызвав перезагрузку классов и перейдя на основной контроллер мы увидим: Hello from WelcomeController. Version: reloaded a bit more.В данном приложении класслоадер полностью перезагружает контекст после каждой компиляции классов. Для больших приложений это может занимать значимое время, поэтому существует другой путь — можно менять в контексте только те классы, которые были скомпилированы. Такую возможность нам предоставляет DefaultListableBeanFactory. Например, в нашей платформе CUBA замена классов в Spring-контексте реализована так:
private void updateSpringContext (Collection
String beanName = null; if (serviceAnnotation!= null) { beanName = serviceAnnotation.value (); } else if (managedBeanAnnotation!= null) { beanName = managedBeanAnnotation.value (); } else if (componentAnnotation!= null) { beanName = componentAnnotation.value (); } else if (controllerAnnotation!= null) { beanName = controllerAnnotation.value (); }
if (StringUtils.isNotBlank (beanName)) { GenericBeanDefinition beanDefinition = new GenericBeanDefinition (); beanDefinition.setBeanClass (clazz); beanFactory.registerBeanDefinition (beanName, beanDefinition); } } } } Ключевой здесь является строка beanFactory.registerBeanDefinition (beanName, beanDefinition); Здесь есть одна тонкость — DefaultListableBeanFactory по умолчанию не перегружает зависимые бины, поэтому нам пришлось слегка доработать ее. public class CubaDefaultListableBeanFactory extends DefaultListableBeanFactory { … /** * Reset all bean definition caches for the given bean, * including the caches of beans that depends on it. * * @param beanName the name of the bean to reset */ protected void resetBeanDefinition (String beanName) { String[] dependentBeans = getDependentBeans (beanName); super.resetBeanDefinition (beanName); if (dependentBeans!= null) { for (String dependentBean: dependentBeans) { resetBeanDefinition (dependentBean); registerDependentBean (beanName, dependentBean); } } } } Как еще можно быстро доставить изменения на сервер Существует несколько способов доставки изменений в серверное Java-приложение без перезапуска сервера.Первый способ — это конечно же Hot Swap, предоставляемый стандартным отладчиком Java. Он имеет очевидные недостатки — нельзя менять структуру класса (добавлять, изменять методы и поля), его очень проблематично использовать на «боевых» серверах.
Второй способ — Hot Deploy предоставляемый контейнерами сервлетов. Вы просто загружает war-файл на сервер, и приложение стартует заново. У этого способа также есть недостатки. Во-первых, вы останавливаете приложение целиком, а значит оно будет недоступно какое-то время (время развертывания приложения зависит от его содержания и может занимать значимое время). Во-вторых, сборка проекта целиком может сама по себе занимать значимое время. В-третьих, у вас нет возможности точечно контролировать изменения, если вы где то ошиблись — вам придется разворачивать приложение заново.
Третий способ можно считать разновидностью второго. Можно положить class-файлы в папку web-inf/classes (для веб приложений) и они переопределят классы имеющиеся на сервере. Этот подход чреват тем, что существует возможность создать бинарную несовместимость с существующими классами, и тогда часть приложения может перестать работать.
Четвертый способ — JRebel. Я слышал, что некоторые используют его даже на серверах заказчика, но сам бы я так делать не стал. В тоже время, для разработки он отлично подходит. У него есть единственный минус — он стоит довольно больших денег.
Пятый способ — Spring Loaded. Он работает через javaagent. Он бесплатен. Но работает только со Spring, и к тому же не позволяет менять иерархии классов, конструкторы, и т.д.
И конечно, есть еще динамически компилируемые языки (например Groovy). Про них я написал в самом начале.
В чем сильные стороны нашего подхода Доставка изменений происходит очень быстро, нет ни перезагрузки, ни периода недоступности приложения Можно произвольным образом менять структуру динамически-компилируемых классов (менять иерархии классов, интерфейсы, и т.д.) Всегда можно видеть, что именно было изменено (например использовав diff), так как исходный код лежит на сервере в открытом виде. Мы полностью контролируем процесс замены класса, и если новый исходный код, например, не компилируется — мы можем вернуть старую версию класса. Можно легко поправить баг прямо на сервере (бывают и такие случаи) Очень просто реализовать в IDE возможность доставки изменений на сервер разработчика (просто скопировав исходный код) Вы не потратите ни копейки денег Конечно, есть и недостатки. Усложняется механизм установки изменений. В общем случае необходимо строить архитектуру приложения таким образом, чтобы она позволяла менять реализацию на лету (например не использовать конструкторы, а получать классы по имени и создавать объекты с помощью рефлексии). Несколько увеличивается время получения классов из класслоадера (за счет проверки файловой системы).
Однако, при правильном подходе преимущества с лихвой перекрывают недостатки.
В заключении хочу сказать, что мы используем данный подход в наших приложениях уже около 5 лет. Он сэкономил нам много времени при разработке и много нервов при исправлении ошибок на боевых серверах.