📜 ⬆️ ⬇️

Dynamic do-it-yourself java code compilation

In this article I will talk about our implementation of hot deploy - fast delivery of Java code changes to a running application.

First, a little history. We have been doing corporate applications on the CUBA platform for several years. They are very different in size and functionality, but they all look alike in one - they have a lot of user interface.

At some point, we realized that developing the user interface by constantly rebooting the server is extremely tiring. Using Hot Swap severely restricts (you cannot add or rename fields, class methods). Each server reboot took at least 10 seconds of time, plus the need to re-login and go to the screen that you are developing.
')
I had to think about the full hot deploy. Under the cut - our solution to the problem with the code and demo application.

Prerequisites


Designing screens in the CUBA platform involves creating a declarative screen XML descriptor that specifies the name of the controller class. Thus, the screen controller class is always obtained by the full name.

It should also be noted that in most cases the screen controller is a thing in itself, that is, it is not used by other controllers or simply by classes (this happens, but not often).

At first we tried to use Groovy to solve the problem of hot deploy. We started uploading the source Groovy code to the server and getting the classes of screen controllers via GroovyClassLoader. This solved the problem with the speed of delivering changes to the server, but created many new problems: at that time Groovy was relatively poorly supported by IDE, dynamic typing allowed you to write non-compiled code imperceptibly, inexperienced developers regularly tried to write the code as ugly as possible, simply because Groovy allows you to do so.

Given that there were hundreds of screens in the projects, each of which could potentially break at any time, we had to abandon the use of Groovy in screen controllers.

Then we thought hard. We wanted to get the benefits of instant delivery of the code to the server (without rebooting) and at the same time not to risk much the quality of the code. A feature appeared in Java 1.6 - ToolProvider.getSystemJavaCompiler () ( described on IBM.com ). This object allows you to get objects of type java.lang.Class from source code. We decided to try.

Implementation


We decided to make our classifier look like GroovyClassLoader. It caches the compiled classes, and each time the class is accessed it checks whether the source code of the class has been updated in the file system. If updated, the compilation starts and the results are cached.

You can see the detailed implementation of the classifier by clicking on the link .

I will focus on the key points of implementation in the article.

Let's start with the main class - JavaClassLoader.

Abbreviated JavaClassLoader Code
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); } } } } ..... } 


When calling loadClass, we perform the following actions:

If you pay attention to the updateSpringContext () method, you will notice that we update the Spring context after each load of classes. This was done for the demo application; in a real project, this frequent context update is usually not required.

Someone may have a question - how do we determine what the class depends on? The answer is simple - we parse the import section. The following is the code that does this.

Code collection of dependencies.
 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; } } 


The attentive reader will ask - where is the compilation itself? Below is its code.

Abbreviated Code CharSequenceCompiler
 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); } } ...... } 


How can this be useful?


For this article, I wrote a small application on Spring MVC , in which I used our classifier.
This application demonstrates how you can benefit from dynamic compilation.

The application declared controller WelcomeController and Spring-bean SomeBean. The controller uses the SomeBean.get () method and delivers the result to the presentation level, where it is displayed.

Now I will demonstrate how with our classifier we can change the implementation of SomeBeanImpl and WelcomeController without stopping the application. First, let's deploy the application (you will need gradle for the build) and go to localhost : 8080 / mvcclassloader / hello.

The answer is: Hello from WelcomeController. Version: not reloaded.

Now let's slightly change the implementation of SomeBeanImpl.
 @Component("someBean") public class SomeBeanImpl implements SomeBean { @Override public String get() { return "reloaded";//  not reloaded } } 


Put the file on the server in the folder tomcat / conf / com / haulmont / mvcclassloader (the folder in which the classifier looks for source code is configured in the file mvc-dispatcher-servlet.xml). Now you need to call the loading classes. For this, I created a separate controller - ReloadController. In reality, it is possible to detect changes in different ways, but for demonstration this will do. ReloadController reloads 2 classes in our application. You can call the controller by clicking on the link localhost : 8080 / mvcclassloader / reload.

After that, going back to localhost : 8080 / mvcclassloader / hello, we will see:
Hello from WelcomeController. Version: reloaded.

But that is not all. We can also change the WebController code. Let's do that.

 @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; } } 


By calling the reload classes and going to the main controller, we will see:
Hello from WelcomeController. Version: reloaded a bit more.

In this application, the classloader completely reloads the context after each class compilation. For large applications, this may take significant time, so there is another way - you can change in the context only those classes that have been compiled. Such an opportunity gives us DefaultListableBeanFactory. For example, in our CUBA platform, the replacement of classes in Spring-context is implemented as follows:

 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); } } } } 

The key here is the string beanFactory.registerBeanDefinition (beanName, beanDefinition);
There is one subtlety here — DefaultListableBeanFactory does not overload dependent bins by default, so we had to slightly refine it.

 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); } } } } 


How else can you quickly deliver changes to the server


There are several ways to deliver changes to a Java server application without restarting the server.

The first way is of course the Hot Swap provided by the standard Java debugger. It has obvious drawbacks - you cannot change the class structure (add, change methods and fields), it is very problematic to use it on “combat” servers.

The second method is Hot Deploy provided by servlet containers. You simply upload the war file to the server and the application starts again. This method also has disadvantages. First, you stop the application entirely, which means it will be unavailable for a while (the time it takes to deploy an application depends on its content and can take significant time). Second, the entire project build may take significant time by itself. Thirdly, you do not have the opportunity to control the point changes, if you make a mistake somewhere - you have to deploy the application again.

The third method can be considered a variation of the second. You can put class files in the web-inf / classes folder (for web applications) and they will override the classes available on the server. This approach is fraught with the fact that it is possible to create a binary incompatibility with existing classes, and then part of the application may stop working.

The fourth method is JRebel. I heard that some use it even on the customer’s servers, but I myself would not do that. At the same time, it is great for development. He has the only negative - it costs quite a lot of money.

The fifth way is Spring Loaded. It works through javaagent. It is free. But it works only with Spring, and besides it does not allow changing class hierarchies, constructors, etc.

And of course, there are still dynamically compiled languages ​​(for example, Groovy). I wrote about them at the very beginning.

What are the strengths of our approach




Of course, there are drawbacks. Complicated installation mechanism changes. In the general case, it is necessary to build the application architecture in such a way that it allows changing the implementation on the fly (for example, not using constructors, but getting classes by name and creating objects using reflection). The time taken to get classes from the classifier is slightly increased (due to the file system check).

However, with the right approach, the advantages more than cover the disadvantages.

In conclusion, I want to say that we have been using this approach in our applications for about 5 years. It saved us a lot of time in development and a lot of nerves in correcting errors on combat servers.

Source: https://habr.com/ru/post/248981/


All Articles