Динамическая компиляция 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<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;
        }
    }
    


    Внимательный читатель спросит — а где же сама компиляция? Ниже приведен ее код.

    Сокращенный код 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);
            }
        }
      ......
    }
    


    Как это может быть полезно


    Для данной статьи я написал маленькое приложение на 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 лет. Он сэкономил нам много времени при разработке и много нервов при исправлении ошибок на боевых серверах.
    Haulmont
    124,00
    Разработка корпоративного ПО
    Поделиться публикацией

    Комментарии 58

      –6
      А почему бы вместо java не использовать какой-нибудь популярный скриптовый язык, который не требует деплоя, для разработки бизнес приложений? За счёт низкой строгости и динамической типизации дополнительный выигрыш в простоте и времени разработки, выигрыш в стоимости инженерных кадров и т.д. и т.п.?
        +6
        В первую очередь, дело в том, что мы занимаемся разработкой java-приложений.
        Это основная причина того, что мы решаем проблему hot deploy именно таким способом.

        А сравнение Java с другими языками — это тема отдельной статьи (или холивара).
          –5
          А не боитесь, что вашу компанию могут «подвинуть» конкуренты, которые смогут предложить более низкую цену и меньшие сроки разработки решений (за счёт использования узкоспециализированных инструментов, а не java для всего на свете)? Или вы монополисты на своём рынке?
            0
            А можно поинтересоваться, какие вы имеете ввиду узкоспециализированные инструменты?
              0
              Скажем так — мы сами для себя разработали такой инструмент — это наша платформа CUBA. Она дает нам реальные конкурентные преимущества. Если вам интересно — можете ознакомиться подробнее — нам будет приятно.
                0
                Я как раз знакомлюсь, вижу IDE, стандартный код, и, как следствие, автогенераторы CRUD, очень интересно. Как правило, на java и Spring разработка CRUD приложений представляет бОльшую сложность, по сравнению с узкоспециальными средами/инструментами, поэтому и спрашивал как вы с этим боретесь.
                А как в CUBA дела с валидацией обстоят, есть фреймворк? Есть ли стандартные виджеты для работы со списками (фильтры, пагинаторы и т.п) и полями ввода (автокомплит, мультиселеки и т.п.)? Шаблоны представлений?
                  0
                  У нас довольно богатый функционал UI — есть таблицы, деревья, фильтры (задаваемые пользователем в UI и исполняемые в виде преобразованного SQL), много разных полей ввода, поиска. Использование платформы может сильно помочь в проектах с большим количеством форм ввода, таблиц, графиков. Никаких сложностей при создании CRUD в приложениях на платформе мы давно в глаза не видывали.

                  Наши приложения сильно отличаются от классических Java + Spring MVC, и больше тяготеют к большим десктоп-решениям.

                  Список основных возможностей
                  Библиотека визуальных компонентов
            +3
            Вопрос, конечно, задавался автору, но я могу сказать, что по моему опыту, в большом приложении от динамической типизации будет больше проблем из за низкой строгости и отсутствия проверок компилятора. А с учетом современной поддержки java со стороны IDE, почти с полной уверенностью могу сказать, что и удобство будет вполне сравнимое, если не выше для больших поектов.
            0
            в проектах были сотни экранов

            Моя фантазия затрудняется придумать проект с сотней экранов (
              0
              Возьмем простой пример.

              Один из наших проектов — система, управляющая бизнесом коллекторов в Великобритании. В ней 434 экрана (специально посмотрел).

              Подробности можно посмотреть вот здесь AR12
                0
                Эти экраны, насколько я понимаю, состоят из набора каких-то представлений. Тогда считать нужно именно представления, а не экраны, на которых они могут комбинироваться как угодно. Или понятия шаблонов в данной реализации нет?
                  +1
                  В нашем понимании экран — это определенный кусок функциональности. Экраны могут повторно использовать некоторые части. Но в целом каждый экран в какой-то степени уникален.

                  Например, есть экраны просмотра и редактирования сущностей. У нас 150 сущностей и для каждой нужно 2 экрана — экран списка и экран редактирования. Если у нас 100+ сущностей — экранов будет 200+. На каждом экране могут быть доступны действия связанные только с конкретной сущностью, разные специальные поля или колонки.

                  Кроме экранов сущностей существует еще куча других, которые предоставляют доступ к конкретной функциональности, напрямую не связанной с редактированием или просмотром.

                  Так что у нас именно 400+ экранов, потому что система довольно большая и содержит массу функциональности.
                0
                Я думаю, что многие корпоративные приложения имеют и гораздо больше экранов. Это, если так можно выразиться, у них в крови. Очень много пользовательской функциональности.
                  0
                  Это скорее следствие отсутствия стандартизированного CRUD интерфейса, который подразумевает 4-5 стандартных представлений («экранов») для любого объёма функциональности любой системы.
                    +3
                    Взгляните пожалуйста на такой экран.

                    Как вы думаете — возможно ли его реализовать с вашим подходом?

                    Часто бывает так, что каждый экран в системе дорабатывается по желаниям пользователей, а они желают, чтобы было удобно им, а не разработчикам.
                      +2
                      Прям на душе потеплело, что не только у нас такие интерфейсы встречаются!

                      Про доработку под пользователей это точно, а еще бывает, что «в старой системе было не так, мы привыкли, сделайте как было».
                        0
                        Да, заменять старые системы наверное тяжелее, чем писать с нуля. Мы не раз с таким сталкивались.

                        Самое же противное — параллельная работа старой и новой системы.
                        0
                        Да, страшновато. Но очевидно же, что выводятся стандартные представления, а интерфейсных шаблонов (комбинирующих представления на одном экране) действительно много, но конкретных представлений информации 4-5.
                  +1
                  Но в секции импорта же могут быть не все зависимсоти, нет? Например если статическая констата используется с полным путём к ней, то в секции импорта её не будет.
                    0
                    Вы совершенно правы. Full qualified imports действительно не поддерживаются. В последней версии своего класслоадера мы решаем эту проблему.
                    0
                    Т.е. для деплоя на продакшн тоже используется такой подход хот-деплоя?
                      0
                      В какой то степени да. Часть системы может быть реализована так, что задеплоить ее можно исходными кодами.
                      В основном конечно мы стараемся на продакшен деплоить jar-ки и только в особых случаях подкладывать исходники, чтобы поменять поведение системы.
                      0
                      Отличная статья, интересно было почитать!
                      Для разработки я использовал DCEVM (вот статья habrahabr.ru/post/236075/ ) Для прода не подойдёт, т.к. там JVM подменяется, но для локального использования самое то — бесплатен и хорошо работает.
                      Про jRebel согласен — стоит очень дорого. Я даже не представлю такого use-case для себя, чтобы он окупился.
                      P.S. Спасибо за выкладывание classloader'а на GitHub!
                        0
                        Спасибо за ссылку, почитаю про DCEVM, раньше про такое не слышал.
                          –3
                          JRebel не стоит больших денег. У JRebel есть вполне определённый ROI и его легко посчитать. Затраты отбиваются за пару недель. Иногда медленнее, иногда быстрее.
                            0
                            А в остальном, да — JRebel для продакшена использовать не надо. Более того, это запрещается лицензией.
                              +1
                              В первую очередь, спасибо за комментарий (а то я уже думал, что вы не заглянете совсем).

                              Я предлагаю взглянуть на этот вопрос с нашей точки зрения. В нашей компании 100+ человек. Для простоты давайте представим, что нас ровно 100.

                              JRebel по последним данным на сайте стоит $365 в год. Таким образом затраты на компанию в год составят $36500. Это довольно большие деньги. Согласитесь, что если бы у вас была возможность их как то сэкономить — вы бы это сделали. Мы для себя создали такую возможность.

                              Я при этом не утверждаю, что наше решение лучше, чем JRebel. Просто конкретно для нас оно подходит лучше.
                                0
                                Несомненно абсолютные цифры на бумаге выглядят устрашающе. Но ведь ROI от этого хуже не становить, а при таких покупках обычно можно срезать довольно большие суммы при помощи скидок. Если все купленные лицензии будут использованы, и все, кому это в команде надо, получат от этого пользу, то цифры на бумаге — это курам на смех :)

                                Для вас решение подходит несомненно лучше, ибо вы хотите использовать его не только в фазе разработки.

                                P.S. если уж ждали комментария, так стучались бы в личку :)
                                0
                                Ну для меня математика проста — я пользуюсь IDEA, она покрывает процентов 70 моих потребностей на работе — тут и Java, Groovy, Python, Gradle, возможность подключаться к СУБД, редактирование xml, xslt. И при всём этом она обошлась мне в этом году долларов за 60 (случайно при продлении лицензии попал на скидку в 40%). В прошлом году она для меня стоила $200. И тут JRebel, который (конкретно в моём случае) может только чуть чуть ускорить деплой изменений при разработке. Это, в процентном соотношении, не более 2% всей моей работы. И при этом есть бесплатные альтернативы. Да, не такие мощные, как jRebel, но часть его функций могут покрыть. И при этом стоит он $365, а это в 6 раз дороже моего основного инструмента для работы. В общем, для меня нет ни малейшего повода его покупать.
                                P.S. Спасибо за коммент.
                                  +1
                                  Вполне разумное сравнение. Ценообразование — это штука тонкая. И уверяю вас, цена на JRebel не взята «с потолка» :) Вот вы пробовали реально посчитать — откуда цифра 2%?

                                  Бесплатные альтернативы покрывают довольно узкие ниши в технологическом стеке. Если вам повезло, и эти альтернативы покрывают ваши нужды — это здорово. При этом, самая хорошая альтернатива — это просто писать тесты, тогда и костыли не нужны :)
                                    +1
                                    С тестами одна проблема — очень тяжело тестировать пользовательский интерфейс. Даже самый простой.

                                    Поэтому кажется, что тесты для разработки UI — это overkill. Для всего остального — да, тесты сильно ускоряют разработку.
                                      0
                                      Видимо, важна методика. Многие верят что используя Selenium, или тот же Selenide можно легко писать UI тесты. Но я соглашусь — это трудозатраты.
                                        0
                                        А кстати, хороший вопрос! Что вы понимаете под тестированием пользовательского интерфейса? Полную имитацию нажатия на кнопочки, галочки, выбор из списка и т.п.? Или проверку логики работы UI? Я в работе использовал и использую два подхода для тестирования UI — и полная имитация пользователя (например, с помощью того же пресловутого Selenium'а) и тестирование логики UI (MVP в этом оооочень сильно помогает). Так вот, тестирование только логики, на мой взгляд вполне оправдано — при это покрывается очень большая часть функционала. А нетестируемая часть пишется насколько простой, чтобы в ней можно было совершать минимум ошибок. Я тут даже статью написал по этому поводу habrahabr.ru/post/246285/
                                        P.S. Вообще это мысли не мои — в офф. доках GWT пишут про MVP и Мартин Фаулер это говорил.
                                          0
                                          Мы тестируем пользовательский интерфейс платформы при помощи Selenium, но это действительно требует времени. Дополнительно мы тестируем код компонентов и код UI слоя при помощи тестов с Mock-объектами, когда большая часть инфраструктуры мокается. Но всё же это далеко от хорошего покрытия тестами UI.
                                          У нас всё усугубляется тем, что прикладной UI код исполняется на стороне сервера, но код компонентов исполняется и на стороне браузера. Из-за такого размазывания приходится больше полагаться на Selenium тесты, если тестируем компоненты и сложный UI.
                                          MVP к сожалению нам не подошло, у нас больше компонентный подход к UI.
                                            0
                                            а какие UI фреймворки кроме Vaadin вы ещё смотрели?
                                              +1
                                              Этот выбор был сделан нами очень давно (более 5 лет назад) и с тех пор мы смотрели несколько веб фреймворков — ZK, Smart GWT, и даже JS фреймворки. Но ни разу мы не находили необходимой нам функциональности и гибкости. Наработок по Vaadin у нас очень много и в перспективе мы не планируем от него отказываться.
                                +1
                                Возможно, в данном случае проще было бы воспользоваться OSGI, где приложение изначально разрабатывается как набор модулей, которые можно «перезаливать» независимо друг от друга.
                                  +2
                                  Я согласен с вами, это один из вариантов и я его в статье не осветил.

                                  Однако, не думаю, что он проще.

                                  Во-первых, чтобы использовать OSGI, нужно менять архитектуру приложения. Наш подход этого не требует.
                                  Во-вторых, по опыту разработки плагинов для JIRA (там используется OSGI), могу сказать, что с OSGI тоже все не просто. Вы не можете в плагинах использовать версии библиотек, отличные от версий, используемых в ядре (по крайней мере в JIRA это так). Во-вторых, плагины изолированы друг от друга, а нам бы хотелось, чтобы части системы могли кооперировать напрямую.
                                  В-третьих, загрузка плагина ведет к полной перезагрузке всех классов из bundle, и это медленнее, чем скомпилировать 1 класс, который поменялся.
                                  В-четвертых, сама по себе проблема разделения на плагины наших систем довольно сложна. Надо определиться по какому признаку объединять классы (ведь на каждый экран или бин не будешь создавать плагин). Учитывая то, что Spring-бины используют друг друга, задача становится довольно сложной.

                                  Поэтому я считаю, что хотя OSGI и подходит для hot deploy, этот подход внес бы неоправданную сложность в наши системы.

                                  Конечно же у OSGI есть и преимущества перед нашим подходом. Например, в плагин можно добавить библиотеки, которых нет на сервере, в ядре. Я думаю, что это полезно в таких системах как JIRA (продукт + куча сторонних плагинов), но плохо подходит для наших систем (корпоративные системы и продукты вроде Taxi).
                                  0
                                  Идея отличная. Вопрос — а почему компиляцию java исходников переложили на сторону сервера? Почему бы не подсовывать только измененные class файлы?
                                    0
                                    В статье я указал этот способ, как один из вариантов, сейчас попробую развернуть свою мысль.

                                    Во-первых, деплой скомпилированных классов может вызвать бинарную несовместимость. Компиляция на сервере в общем случае избавлена от этой проблемы.
                                    Во-вторых, в случае деплоя классов сложно определить, что именно задеплоено (это актуально для деплоя на продакшен)
                                    В-третьих, как следствие из предыдущего, сложно внести изменения на ходу. Исходный код можно поправить на сервере, классы придется компилировать и заливать на сервер.
                                    В-четвертых, мы лишены возможности управлять загрузкой классов. Положив класс на сервер, мы всегда при загрузке будем получать именно его. В случае с динамической компиляцией у нас есть выбор — отдать новую версию или же старую.

                                    Я думаю, что деплой классов подходит для разработки (в grails например так и делают), но плохо подходит для деплоя на продакшен сервера, а нам хотелось бы и ускорения разработки, и возможности менять поведение системы на продакшене.
                                      0
                                      Спасибо. Второй пункт похоже самый существенный.

                                      А как вы организовываете работу, чтобы не терять изменения которые кто-то внес на ходу?
                                        0
                                        Обычно мы такие изменения вносим в SVN сразу, тестируем и загружаем на сервер.

                                        Есть еще один момент. У нас IT-отдел заказчика тоже может загружать исходники на сервер (в первую очередь новые отчеты), и нам пришлось в итоге им также дать доступ в SVN, чтобы их изменения не терялись.
                                        0
                                        А если для второго пункта использовать serialVersionUID? Это, конечно, определенные требования к написанию кода, но можно автоматизировать как проверки, так и собственно добавление этого поля.
                                          0
                                          В принципе, можно. Но сравнивать plain-code гораздо приятнее — можно видеть именно то, что менялось (буквально diff)
                                      0
                                      Вобщем-то получился хороший костыль. Нивкоем случае не имею в виду что-то плохое. К сожалению, такую задачу в Java можно решить только костылями. И без специальной подгонки архитектуры всего приложения это довольно трудно сделать вообще.

                                      Из описанных плюсов, возникло сомнение: «нет ни перезагрузки, ни периода недоступности приложения».

                                      После обновления, для того, чтобы стал работать новый код, и без потерь для пользователя, нужно чтобы состояние сессии и приложения вообще, а также внутреннее состояние фреймворка, были восстановлены. Поэтому вы и перегружаете спринговый контекст. Это занимает некоторое время в любом случае. Или же имеется в виду, что имеющийся пользователь закончит свою работу на старой версии кода, а все новые пользователи обслуживаются уже новой версией?
                                        0
                                        Из описанных плюсов, возникло сомнение: «нет ни перезагрузки, ни периода недоступности приложения».
                                        Поэтому вы и перегружаете спринговый контекст.

                                        Позвольте не согласиться. Во-первых, spring-контекст не обязательно перезагружать полностью. Я об этом упомянул в статье. Существует возможность перезагрузить определенный бин и тех кто от него зависит (это гораздо быстрее). Во-вторых, если вы разрабатываете UI на платформе CUBA, вы вообще не перезагружаете никакие контексты, просто все вновь открывающиеся экраны будут получать перезагруженный класс, и соответственно — иметь новое поведение.

                                        То есть, если вы построили свое приложение таким образом, чтобы каждый раз запрашивать класс с бизнес-логикой из класслоадера, обновление одного или нескольких классов произойдет практически незаметно.
                                          0
                                          Хорошо, домустим, перегрузка спрингового контекста может занимать бесконечно малое время. Это не сама суть вопроса.

                                          Что тогда со внутренним состоянием, сессией, итп? Это как то предусматривается платформой. Или всё таки имеет место т.е. вымещающая замена, где старый пользователь ещё работает со старой версией функциональности, а новый — уже с новой?
                                            +1
                                            Попробую объяснить.

                                            1) Экраны пользовательского интерфейса. Каждый экран перед открытием получает класс из класслоадера, таким образом замена логики (и верстки) экрана происходит незаметно для пользователя — в следующий раз когда будет открыт экран, пользователь получит новую версию.При этом пользовательская сессия не затрагивается, и повторный логин не требуется.
                                            2) Stateless бины. В большинстве случаев заменяются прозрачно для пользователя и без рестарта контекста, без сброса сессии, и т.д.
                                            3) Stateful бины. В нашей платформе они организованы так, чтобы можно было загрузить состояние по команде через jmx. То есть, даже если возникает необходимость замены такого бина, мы подкладываем исходник на сервер, вызываем компиляцию, заменяем бин в контексте и затем руками даем команду на загрузку состояния.
                                              0
                                              Понятно, спасибо.
                                        0
                                        Только хотел попробовать сделать, а тут такая хрень:
                                        В общем случае необходимо строить архитектуру приложения таким образом, чтобы она позволяла менять реализацию на лету, например не использовать конструкторы, а получать классы по имени и создавать объекты с помощью рефлексии

                                        всё желание сразу пропало.

                                          0
                                          Давайте разберемся.

                                          1) Если вы используете Spring (или другой IOC-контейнер) — специальных действий не требуется. Нужно только научить контейнер пользоваться класслоадером, также как мы научили Spring.
                                          2) Если вы так или иначе загружаете классы самостоятельно (как мы делаем с контроллерами экранов) — специальных действий не требуется. Нужно просто использовать компилирующий класслоадер.
                                          3) Если вы пользуетесь фабрикой для создания объектов — специальных действий не требуется. Просто научите фабрику пользоваться компилирующим класслоадером.

                                          И так далее.

                                          Общий случай — это когда мы заранее про приложение ничего не знаем. Поэтому и посыл такой. В каждом отдельном случае скорее всего можно реализовать все проще и быстрее.

                                          0
                                          1. Не использую
                                          2. Классы грузятся автоматически. Я просто пишу код с их использованием.
                                          3. Не использую.

                                          печалька. Вообще возможно обойти те ограничения, которые вы описали?
                                            0
                                            В теории можно. Есть например свойство -Djava.system.class.loader, которое устанавливает системный класслоадер для всей JVM.

                                            Я так делать пока не пробовал. Возможно это сработает, возможно нет.
                                            0
                                            > У кого-то может возникнуть вопрос — как мы определяем, от чего зависит класс? Ответ простой — мы разбираем секцию импортов.

                                            А если в коде используется полное имя класса? Видимо придется анализировать весь исходник или байт-код… Интересно, что проще?
                                              0
                                              К сожалению разобрать байт код нельзя, потому что, чтобы его получить, нужно скомпилировать код. А зависимости мы ищем как раз для того, чтобы этот самый код скомпилировать.
                                                0
                                                Ах да, ступил.
                                                Поэтому у тех hot-reload либ что я видел, невозможно изменить, скажем иерархию класса.
                                                В импортах есть еще сложности с wildcard, но впрочем, имея список доступных подлежащих компиляции классов это небольшая проблема.
                                                И все-таки как быть с квалифицированными именами…
                                                Наверное реально еще разобрать файл лексически и выделить квалифицированные имена классов, которые не фигурируют в секции импорта. Да, прямым путем (sun tools) этого скорее всего не сделать из-за проблемы курицы и яйца. Но если игнорировать классы из дефолтного пакета, мне кажется это реально сделать даже некоторой эвристикой.
                                                Как вы думаете?
                                                0
                                                Не проблема распарсить код, но это влияет на время компиляции. Особенно если классов много.
                                                Все зависит от задачи. Нам, как оказалось, оно не сильно нужно)
                                                  0
                                                  Ясно, спасибо!

                                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                              Самое читаемое