В этой статье я расскажу о нашей реализации 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.
public class JavaClassLoader extends URLClassLoader implements ApplicationContextAware {
.....
protected final Map<String, TimestampClass> compiled = new ConcurrentHashMap<>();
protected final ConcurrentHashMap<String, Lock> locks = new ConcurrentHashMap<>();
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<JavaFileObject> errs = new DiagnosticCollector<>();
SourcesAndDependencies sourcesAndDependencies = new SourcesAndDependencies(rootDir, this);
sourcesAndDependencies.putSource(containerClassName, src);
sourcesAndDependencies.collectDependencies(containerClassName);
Map<String, CharSequence> sourcesForCompilation = sourcesAndDependencies.collectSourcesForCompilation(containerClassName);
@SuppressWarnings("unchecked")
Map<String, Class> compiledClasses = createCompiler().compile(sourcesForCompilation, errs);
Map<String, TimestampClass> compiledTimestampClasses = wrapCompiledClasses(compiledClasses);
compiled.putAll(compiledTimestampClasses);
linkDependencies(compiledTimestampClasses, sourcesAndDependencies.dependencies);
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<String, TimestampClass> compiledTimestampClasses, Multimap<String, String> dependecies) {
for (Map.Entry<String, TimestampClass> entry : compiledTimestampClasses.entrySet()) {
String className = entry.getKey();
TimestampClass timestampClass = entry.getValue();
Collection<String> dependencyClasses = dependecies.get(className);
timestampClass.dependencies.addAll(dependencyClasses);
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<String, CharSequence> sources = new HashMap<>();
final Multimap<String, String> dependencies = HashMultimap.create();
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<String> importedClassesNames = getDynamicallyLoadedImports(src);
String currentPackageName = className.substring(0, className.lastIndexOf('.'));
importedClassesNames.addAll(sourceProvider.getAllClassesFromPackage(currentPackageName));//all src from current package
for (String importedClassName : importedClassesNames) {
if (!sources.containsKey(importedClassName)) {
addSource(importedClassName);
addDependency(className, importedClassName);
collectDependencies(importedClassName);
} else {
addDependency(className, importedClassName);
}
}
}
/**
* 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<String, CharSequence> collectSourcesForCompilation(String rootClassName) throws ClassNotFoundException, IOException {
Map<String, CharSequence> dependentSources = new HashMap<>();
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<String, CharSequence> dependentSources) throws IOException {
TimestampClass removedClass = javaClassLoader.proxyClassLoader.removeFromCache(dependencyClassName);
if (removedClass != null) {
for (String dependentName : removedClass.dependent) {
dependentSources.put(dependentName, sourceProvider.getSourceString(dependentName));
addDependency(dependentName, dependencyClassName);
collectDependent(dependentName, dependentSources);
}
}
}
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<String> unwrapImportValue(String importValue) {
if (importValue.endsWith(WHOLE_PACKAGE_PLACEHOLDER)) {
String packageName = importValue.replace(WHOLE_PACKAGE_PLACEHOLDER, "");
if (sourceProvider.directoryExistsInFileSystem(packageName)) {
return sourceProvider.getAllClassesFromPackage(packageName);
}
} else if (sourceProvider.sourceExistsInFileSystem(importValue)) {
return Collections.singletonList(importValue);
}
return Collections.emptyList();
}
private List<String> getDynamicallyLoadedImports(CharSequence src) {
List<String> importedClassNames = new ArrayList<>();
List<String> importValues = getMatchedStrings(src, IMPORT_PATTERN, 1);
for (String importValue : importValues) {
importedClassNames.addAll(unwrapImportValue(importValue));
}
importValues = getMatchedStrings(src, IMPORT_STATIC_PATTERN, 1);
for (String importValue : importValues) {
importedClassNames.addAll(unwrapImportValue(importValue));
}
return importedClassNames;
}
private List<String> getMatchedStrings(CharSequence source, String pattern, int groupNumber) {
ArrayList<String> result = new ArrayList<>();
Pattern importPattern = Pattern.compile(pattern, Pattern.CASE_INSENSITIVE);
Matcher matcher = importPattern.matcher(source);
while (matcher.find()) {
result.add(matcher.group(groupNumber));
}
return result;
}
}
Внимательный читатель спросит — а где же сама компиляция? Ниже приведен ее код.
public class CharSequenceCompiler<T> {
.....
// The compiler instance that this facade uses.
private final JavaCompiler compiler;
public CharSequenceCompiler(ProxyClassLoader loader, Iterable<String> options) {
compiler = ToolProvider.getSystemJavaCompiler();
if (compiler == null) {
throw new IllegalStateException("Cannot find the system Java compiler. "
+ "Check that your class path includes tools.jar");
}
.....
}
.....
public synchronized Map<String, Class<T>> compile(
final Map<String, CharSequence> classes,
final DiagnosticCollector<JavaFileObject> diagnosticsList)
throws CharSequenceCompilerException {
List<JavaFileObject> sources = new ArrayList<JavaFileObject>();
for (Map.Entry<String, CharSequence> entry : classes.entrySet()) {
String qualifiedClassName = entry.getKey();
CharSequence javaSource = entry.getValue();
if (javaSource != null) {
final int dotPos = qualifiedClassName.lastIndexOf('.');
final String className = dotPos == -1 ? qualifiedClassName
: qualifiedClassName.substring(dotPos + 1);
final String packageName = dotPos == -1 ? "" : qualifiedClassName
.substring(0, dotPos);
final JavaFileObjectImpl source = new JavaFileObjectImpl(className,
javaSource);
sources.add(source);
// Store the source file in the FileManager via package/class
// name.
// For source files, we add a .java extension
javaFileManager.putFileForInput(StandardLocation.SOURCE_PATH, packageName,
className + JAVA_EXTENSION, source);
}
}
// Get a CompliationTask from the compiler and compile the sources
final JavaCompiler.CompilationTask task = compiler.getTask(null, javaFileManager, diagnostics,
options, null, sources);
final Boolean result = task.call();
if (result == null || !result) {
StringBuilder cause = new StringBuilder("n");
for (Diagnostic d : diagnostics.getDiagnostics()) {
cause.append(d).append(" ");
}
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<String, Class<T>> compiled = new HashMap<String, Class<T>>();
for (String qualifiedClassName : classLoader.classNames()) {
final Class<T> newClass = loadClass(qualifiedClassName);
compiled.put(qualifiedClassName, newClass);
}
return compiled;
} catch (ClassNotFoundException e) {
throw new CharSequenceCompilerException(classes.keySet(), e, diagnostics);
} catch (IllegalArgumentException e) {
throw new CharSequenceCompilerException(classes.keySet(), e, diagnostics);
} catch (SecurityException e) {
throw new CharSequenceCompilerException(classes.keySet(), e, diagnostics);
}
}
......
}
Как это может быть полезно
Для данной статьи я написал маленькое приложение на Spring MVC, в котором использовал наш класслоадер.
Это приложение демонстрирует, как можно получить пользу от динамической компиляции.
В приложении объявлен контроллер WelcomeController и Spring-bean SomeBean. Контроллер использует метод SomeBean.get() и отдает результат на уровень представления, где он и отображается.
Сейчас я продемонстрирую, как с помощью нашего класслоадера мы можем поменять реализацию 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<Class> classes) {
if (beanFactory != null) {
for (Class clazz : classes) {
Service serviceAnnotation = (Service) clazz.getAnnotation(Service.class);
ManagedBean managedBeanAnnotation = (ManagedBean) clazz.getAnnotation(ManagedBean.class);
Component componentAnnotation = (Component) clazz.getAnnotation(Component.class);
Controller controllerAnnotation = (Controller) clazz.getAnnotation(Controller.class);
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 лет. Он сэкономил нам много времени при разработке и много нервов при исправлении ошибок на боевых серверах.
Автор: tinhol