Do-it-yourself dynamic compilation of Java code

    In this article, I’ll talk about our implementation of hot deploy — the fast delivery of Java code changes to a running application.

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

    At some point, we realized that developing a user interface by constantly rebooting the server is extremely tedious. Using Hot Swap greatly limits (you cannot add and 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 a full hot deploy. Under the cut - our solution to the problem with the code and demo application.

    Background


    The development of screens in the CUBA platform involves the creation of a declarative XML-descriptor of the screen, which indicates the name of the controller class. Thus, the screen controller class is always obtained by its 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 just classes (this happens, but not often).

    At first we tried to use Groovy to solve the hot deploy problem. We began to upload the source Groovy code to the server and get the classes of screen controllers through GroovyClassLoader. This solved the problem with the speed of delivery of changes to the server, but created a lot of new problems: at that time Groovy supported IDE relatively weakly, dynamic typing made it possible to write uncompiled code unnoticed by inexperienced developers, regularly inexperienced developers tried to write 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 code delivery to the server (without rebooting) and at the same time not to risk the quality of the code much. The feature that appeared in Java 1.6 - ToolProvider.getSystemJavaCompiler () ( description on IBM.com ) came to the rescue . This object allows you to get objects of type java.lang.Class from the source code. We decided to give it a try.

    Implementation


    We decided to make our classloader similar to GroovyClassLoader. It caches compiled classes and each time the class is accessed, it checks to see if the source code for the class has been updated on the file system. If it is updated, compilation starts and the results go to the cache.

    You can see a detailed implementation of the classloader 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 compiled = new ConcurrentHashMap<>();
        protected final ConcurrentHashMap 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 errs = new DiagnosticCollector<>();
                    SourcesAndDependencies sourcesAndDependencies = new SourcesAndDependencies(rootDir, this);
                    sourcesAndDependencies.putSource(containerClassName, src);
                    sourcesAndDependencies.collectDependencies(containerClassName);
                    Map sourcesForCompilation = sourcesAndDependencies.collectSourcesForCompilation(containerClassName);
                    @SuppressWarnings("unchecked")
                    Map compiledClasses = createCompiler().compile(sourcesForCompilation, errs);
                    Map 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 compiledTimestampClasses, Multimap dependecies) {
            for (Map.Entry entry : compiledTimestampClasses.entrySet()) {
                String className = entry.getKey();
                TimestampClass timestampClass = entry.getValue();
                Collection 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:
    • We check if the source code of this class is in the file system, if not, we call the inherited loadClass
    • Check if compilation is necessary - for example, the file with the class source code has been changed. Here you need to remember that we monitor not only the change of 1 file with the class, but also all the dependencies
    • We collect dependencies - everything that depends on the class that we are going to compile, as well as everything on which it depends
    • We check each dependency for compilation, throw out those that do not need to be compiled
    • We compile source codes
    • Put the results in the cache
    • Update, if necessary, Spring context
    • Return the requested class

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

    Someone may wonder - 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.

    Dependency collection code.
    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 sources = new HashMap<>();
        final Multimap 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 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 collectSourcesForCompilation(String rootClassName) throws ClassNotFoundException, IOException {
            Map 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 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 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 getDynamicallyLoadedImports(CharSequence src) {
            List importedClassNames = new ArrayList<>();
            List 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 getMatchedStrings(CharSequence source, String pattern, int groupNumber) {
            ArrayList 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 her code.

    CharSequenceCompiler Short Code
    public class CharSequenceCompiler {
        .....
        // The compiler instance that this facade uses.
        private final JavaCompiler compiler;
        public CharSequenceCompiler(ProxyClassLoader loader, Iterable 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> compile(
                final Map classes,
                final DiagnosticCollector diagnosticsList)
                throws CharSequenceCompilerException {
            List sources = new ArrayList();
            for (Map.Entry 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> compiled = new HashMap>();
                for (String qualifiedClassName : classLoader.classNames()) {
                    final Class 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 that used our classloader.
    This app demonstrates how you can benefit from dynamic compilation.

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

    Now I will demonstrate how, with the help of our classloader, we can change the implementation of SomeBeanImpl and WelcomeController without stopping the application. First, deploy the application (you will need gradle to build ) and switch 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
        }
    }
    


    We put the file on the server in the folder tomcat / conf / com / haulmont / mvcclassloader (the folder in which the classloader searches for the source code is configured in the mvc-dispatcher-servlet.xml file). Now you need to call the loading of classes. To do this, I created a separate controller - ReloadController. In reality, changes can be detected in many ways, but this will be suitable for demonstration. ReloadController reloads 2 classes in our application. You can call the controller by clicking on the link localhost : 8080 / mvcclassloader / reload.

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

    But that is not all. We can also change the code of WebController. 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;
        }
    }
    


    Having called the class reload 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 compilation of classes. For large applications, this can take considerable time, so there is another way - you can change only those classes in the context that were compiled. This opportunity is provided by DefaultListableBeanFactory. For example, in our CUBA platform, class replacement in the Spring context is implemented as follows:

    private void updateSpringContext(Collection 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 by default does not overload dependent beans, 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 server-side Java 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 structure of the class (add, change methods and fields), it is very problematic to use it on "battle" servers.

    The second way 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. Firstly, you stop the entire application, which means it will not be available for some time (the deployment time of the application depends on its contents and may take a significant amount of time). Secondly, assembling a project as a whole can take considerable time on its own. Thirdly, you do not have the ability to precisely control changes; if you make a mistake somewhere, you will 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 possibility of creating binary incompatibility with existing classes, and then part of the application may stop working.

    The fourth way is JRebel. I heard that some use it even on the customer’s servers, but I wouldn’t do it myself. At the same time, it is great for development. He has one drawback - 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 also does not allow changing class hierarchies, constructors, etc.

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

    What are the strengths of our approach


    • Delivery of changes is very fast, there is no reboot, no application inaccessibility period
    • You can arbitrarily change the structure of dynamically compiled classes (change class hierarchies, interfaces, etc.)
    • You can always see what exactly was changed (for example, using diff), since the source code lies on the server in the clear.
    • We fully control the process of replacing the class, and if the new source code, for example, does not compile, we can return the old version of the class.
    • You can easily fix the bug directly on the server (there are such cases)
    • It is very simple to implement in the IDE the ability to deliver changes to the developer's server (just by copying the source code)
    • You will not spend a penny of money


    Of course, there are also disadvantages. The mechanism for setting changes is becoming more complicated. In the general case, it is necessary to build the application architecture in such a way that it allows you to change the implementation on the fly (for example, do not use constructors, but get classes by name and create objects using reflection). The time to get classes from the classloader is slightly increased (due to 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. He saved us a lot of time during development and a lot of nerves in fixing errors on battle servers.

    Also popular now: