10

I'd like to allow users to add/refresh/update/remove modules in the main project without the need of restart or redeploy. Users will be able to code their own modules and add them in the main project.

Technicaly, a module will be a JAR which may be "hot-started" and may contain :

  • spring controllers
  • services, ejbs...
  • resources (jsps, css, images, javascripts...)

So, when the user adds a module, the application have to register controllers, services, ejbs and map resources as intend. When he removes, the application unloads them.

Easy to say. Actually seems a lot more difficult to do.

Currently, I did it using Servlet 3.0 and web-fragment.xml. The main issue is that I have to redeploy everytime I update a module. I need to avoid that.

I read some docs about OSGi but I don't understand how I can link it with my project neither how It can load/unload on demand.

Can someone lead me to a solution or an idea?

What I use :

  • Glassfish 3.1.2
  • Spring MVC 3.1.3
  • Spring Security 3.1.3

Thanks.


EDIT:

I can now say that it is possible. Here's the way I will do :

Add module :

  1. Upload the module.jar
  2. Handle the file, expand in a module folder
  3. Close Spring application context
  4. Load JAR in a custom classloader where parent is WebappClassLoader
  5. Copy resources in the main project (maybe it will be possible to find alternative, I hope but currently, this should work)
  6. Refresh Spring application context

Remove module :

  1. Close Spring application context
  2. Unbind custom classloader and let it go to GC
  3. Remove resources
  4. Remove files from the module folder + jar if kept
  5. Refresh Spring application context

For each, Spring have to scan another folder than

domains/domain1/project/WEB-INF/classes
domains/domain1/project/WEB-INF/lib
domains/domain1/lib/classes

And that's actually my current issue.

Technicaly, I found PathMatchingResourcePatternResolver and ClassPathScanningCandidateComponentProvider was involved. Now I need to tell them to scan specific folder/classes.

For the rest, I already did some tests and it should work as intended.

One point which will not be possible : ejbs in the jar.

I'll post some sources when I'd have done something usable.

8
  • 1
    Why is this requirement so important? This will be extremely hard to implement with Spring. Commented Nov 27, 2013 at 15:01
  • 1
    Actually, the main project is already in production and can't be undeployed when we want. That's why I would like to push modules on-the-fly without redeploy. I just found a way to refresh the Spring application context (which don't need 30sec of init like a deploy). Now I need to find a way to load and inject a JAR to his classloader to get the controllers recognized by Spring (didn't think about resources for now). Thanks for your answer anyway, I also suspected this as hard to implement :(. Commented Nov 27, 2013 at 15:16
  • 3
    this is definitely OSGI. Commented Nov 29, 2013 at 8:58
  • 2
    Spring DM became Eclipse Gemini Blueprint. (IMHO, you shouldn't try to do what you're doing - it'll just be really really complicated) Commented Nov 29, 2013 at 10:24
  • 2
    Hi. I looked at Gemini Blueprint which is application-based only (not web) and found Gemini Web which is ..web-based. BUT I did not get it working (missing OsgiWebXmlApplicationContext) so I gave up. I came back yesterday on my draft code and I just succeed the 'add module' part described in the edit. It's still as draft currently, I'll clean & complete my code before post anything. Commented Dec 3, 2013 at 16:04

1 Answer 1

12

Ok, I did it but I have really too much sources to post it here. I will explain step by step how I did but won't post the classloading part which is simple for an average skilled developper.

One thing is currently not supported by my code is the context config scan.

First, the explanation below depends on your needs and also your application server. I use Glassfish 3.1.2 and I did not find how to configure a custom classpath :

  • classpath prefix/suffix not supported anymore
  • -classpath parameter on the domain's java-config did not work
  • CLASSPATH environment did not work either

So the only available paths in classpath for GF3 are : WEB-INF/classes, WEB-INF/lib... If you find a way to do it on your application server, you can skip the first 4 steps.

I know this is possible with Tomcat.

Step 1 : Create a custom namespace handler

Create a custom NamespaceHandlerSupport with its XSD, spring.handlers and spring.schemas. This namespace handler will contain a redefinition of <context:component-scan/>.

/**
* Redefine {@code component-scan} to scan the module folder in addition to classpath
* @author Ludovic Guillaume
*/
public class ModuleContextNamespaceHandler extends NamespaceHandlerSupport {
    @Override
    public void init() {
        registerBeanDefinitionParser("component-scan", new ModuleComponentScanBeanDefinitionParser());
    }
}

The XSD contains only component-scan element which is a perfect copy of the Spring's one.

spring.handlers

http\://www.yourwebsite.com/schema/context=com.yourpackage.module.spring.context.config.ModuleContextNamespaceHandler

spring.schemas

http\://www.yourwebsite.com/schema/context/module-context.xsd=com/yourpackage/module/xsd/module-context.xsd

N.B.: I didn't override the Spring default namespace handler due to some issues like the name of the project which need to have a letter greater than 'S'. I wanted to avoid that so I made my own namespace.

Step 2 : Create the parser

This will be initialized by the namespace handler created above.

/**
 * Parser for the {@code <module-context:component-scan/>} element.
 * @author Ludovic Guillaume
 */
public class ModuleComponentScanBeanDefinitionParser extends ComponentScanBeanDefinitionParser {
    @Override
    protected ClassPathBeanDefinitionScanner createScanner(XmlReaderContext readerContext, boolean useDefaultFilters) {
        return new ModuleBeanDefinitionScanner(readerContext.getRegistry(), useDefaultFilters);
    }
}

Step 3 : Create the scanner

Here's the custom scanner which uses the same code as ClassPathBeanDefinitionScanner. The only code changed is String packageSearchPath = "file:" + ModuleManager.getExpandedModulesFolder() + "/**/*.class";.

ModuleManager.getExpandedModulesFolder() contains an absolute url. e.g.: C:/<project>/modules/.

/**
 * Custom scanner that detects bean candidates on the classpath (through {@link ClassPathBeanDefinitionScanner} and on the module folder.
 * @author Ludovic Guillaume
 */
public class ModuleBeanDefinitionScanner extends ClassPathBeanDefinitionScanner {
    private ResourcePatternResolver resourcePatternResolver;
    private MetadataReaderFactory metadataReaderFactory;

    /**
     * @see {@link ClassPathBeanDefinitionScanner#ClassPathBeanDefinitionScanner(BeanDefinitionRegistry, boolean)}
     * @param registry
     * @param useDefaultFilters
     */
    public ModuleBeanDefinitionScanner(BeanDefinitionRegistry registry, boolean useDefaultFilters) {
        super(registry, useDefaultFilters);

        try {
            // get parent class variable
            resourcePatternResolver = (ResourcePatternResolver)getResourceLoader();

            // not defined as protected and no getter... so reflection to get it
            Field field = ClassPathScanningCandidateComponentProvider.class.getDeclaredField("metadataReaderFactory");
            field.setAccessible(true);
            metadataReaderFactory = (MetadataReaderFactory)field.get(this);
            field.setAccessible(false);
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * Scan the class path for candidate components.<br/>
     * Include the expanded modules folder {@link ModuleManager#getExpandedModulesFolder()}.
     * @param basePackage the package to check for annotated classes
     * @return a corresponding Set of autodetected bean definitions
     */
    @Override
    public Set<BeanDefinition> findCandidateComponents(String basePackage) {
        Set<BeanDefinition> candidates = new LinkedHashSet<BeanDefinition>(super.findCandidateComponents(basePackage));

        logger.debug("Scanning for candidates in module path");

        try {
            String packageSearchPath = "file:" + ModuleManager.getExpandedModulesFolder() + "/**/*.class";

            Resource[] resources = this.resourcePatternResolver.getResources(packageSearchPath);
            boolean traceEnabled = logger.isTraceEnabled();
            boolean debugEnabled = logger.isDebugEnabled();

            for (Resource resource : resources) {
                if (traceEnabled) {
                    logger.trace("Scanning " + resource);
                }
                if (resource.isReadable()) {
                    try {
                        MetadataReader metadataReader = this.metadataReaderFactory.getMetadataReader(resource);

                        if (isCandidateComponent(metadataReader)) {
                            ScannedGenericBeanDefinition sbd = new ScannedGenericBeanDefinition(metadataReader);
                            sbd.setResource(resource);
                            sbd.setSource(resource);

                            if (isCandidateComponent(sbd)) {
                                if (debugEnabled) {
                                    logger.debug("Identified candidate component class: " + resource);
                                }
                                candidates.add(sbd);
                            }
                            else {
                                if (debugEnabled) {
                                    logger.debug("Ignored because not a concrete top-level class: " + resource);
                                }
                            }
                        }
                        else {
                            if (traceEnabled) {
                                logger.trace("Ignored because not matching any filter: " + resource);
                            }
                        }
                    }
                    catch (Throwable ex) {
                        throw new BeanDefinitionStoreException("Failed to read candidate component class: " + resource, ex);
                    }
                }
                else {
                    if (traceEnabled) {
                        logger.trace("Ignored because not readable: " + resource);
                    }
                }
            }
        }
        catch (IOException ex) {
            throw new BeanDefinitionStoreException("I/O failure during classpath scanning", ex);
        }

        return candidates;
    }
}

Step 4 : Create a custom resource caching implementation

This will allow Spring to resolve your module classes out of the classpath.

public class ModuleCachingMetadataReaderFactory extends CachingMetadataReaderFactory {
    private Log logger = LogFactory.getLog(ModuleCachingMetadataReaderFactory.class);

    @Override
    public MetadataReader getMetadataReader(String className) throws IOException {
        List<Module> modules = ModuleManager.getStartedModules();

        logger.debug("Checking if " + className + " is contained in loaded modules");

        for (Module module : modules) {
            if (className.startsWith(module.getPackageName())) {
                String resourcePath = module.getExpandedJarFolder().getAbsolutePath() + "/" + ClassUtils.convertClassNameToResourcePath(className) + ".class";

                File file = new File(resourcePath);

                if (file.exists()) {
                    logger.debug("Yes it is, returning MetadataReader of this class");

                    return getMetadataReader(getResourceLoader().getResource("file:" + resourcePath));
                }
            }
        }

        return super.getMetadataReader(className);
    }
}

And define it in the bean configuration :

<bean id="customCachingMetadataReaderFactory" class="com.yourpackage.module.spring.core.type.classreading.ModuleCachingMetadataReaderFactory"/>

<bean name="org.springframework.context.annotation.internalConfigurationAnnotationProcessor"
      class="org.springframework.context.annotation.ConfigurationClassPostProcessor">
      <property name="metadataReaderFactory" ref="customCachingMetadataReaderFactory"/>
</bean>

Step 5 : Create a custom root classloader, module classloader and module manager

This is the part I won't post classes. All classloaders extend URLClassLoader.

Root classloader

I did mine as singleton so it can :

  • initialize itself
  • destroy
  • loadClass (modules classes, parent classes, self classes)

The most important part is loadClass which will allow context to load your modules classes after using setCurrentClassLoader(XmlWebApplicationContext) (see bottom of the next step). Concretly, this method will scan the children classloader (which I personaly store in my module manager) and if not found, it will scan parent/self classes.

Module classloader

This classloader simply adds the module.jar and the .jar it contains as url.

Module manager

This class can load/start/stop/unload your modules. I did like this :

  • load : store a Module class which represent the module.jar (contains id, name, description, file...)
  • start : expand the jar, create module classloader and assign it to the Module class
  • stop : remove the expanded jar, dispose classloader
  • unload : dispose Module class

Step 6 : Define a class which will help to do context refreshs

I named this class WebApplicationUtils. It contains a reference to the dispatcher servlet (see step 7). As you will see, refreshContext call methods on AppClassLoader which is actually my root classloader.

/**
 * Refresh {@link DispatcherServlet}
 * @return true if refreshed, false if not
 * @throws RuntimeException
 */
private static boolean refreshDispatcherServlet() throws RuntimeException {
    if (dispatcherServlet != null) {
        dispatcherServlet.refresh();
        return true;
    }

    return false;
}

/**
 * Refresh the given {@link XmlWebApplicationContext}.<br>
 * Call {@link Module#onStarted()} after context refreshed.<br>
 * Unload started modules on {@link RuntimeException}.
 * @param context Application context
 * @param startedModules Started modules
 * @throws RuntimeException
 */
public static void refreshContext(XmlWebApplicationContext context, Module[] startedModules) throws RuntimeException {
    try {
        logger.debug("Closing web application context");
        context.stop();
        context.close();

        AppClassLoader.destroyInstance();

        setCurrentClassLoader(context);

        logger.debug("Refreshing web application context");
        context.refresh();

        setCurrentClassLoader(context);

        AppClassLoader.setThreadsToNewClassLoader();

        refreshDispatcherServlet();

        if (startedModules != null) {
            for (Module module : startedModules) {
                module.onStarted();
            }
        }
    }
    catch (RuntimeException e) {
        for (Module module : startedModules) {
            try {
                ModuleManager.stopModule(module.getId());
            }
            catch (IOException e2) {
                e.printStackTrace();
            }
        }

        throw e;
    }
}

/**
 * Set the current classloader to the {@link XmlWebApplicationContext} and {@link Thread#currentThread()}.
 * @param context ApplicationContext
 */
public static void setCurrentClassLoader(XmlWebApplicationContext context) {
    context.setClassLoader(AppClassLoader.getInstance());
    Thread.currentThread().setContextClassLoader(AppClassLoader.getInstance());
}

Step 7 : Define a custom context loader listener

/**
 * Initialize/destroy ModuleManager on context init/destroy
 * @see {@link ContextLoaderListener}
 * @author Ludovic Guillaume
 */
public class ModuleContextLoaderListener extends ContextLoaderListener {
    public ModuleContextLoaderListener() {
        super();
    }

    @Override
    public void contextInitialized(ServletContextEvent event) {
        // initialize ModuleManager, which will scan the given folder
        // TODO: param in web.xml
        ModuleManager.init(event.getServletContext().getRealPath("WEB-INF"), "/dev/temp/modules");

        super.contextInitialized(event);
    }

    @Override
    protected WebApplicationContext createWebApplicationContext(ServletContext sc) {
        XmlWebApplicationContext context = (XmlWebApplicationContext)super.createWebApplicationContext(sc);

        // set the current classloader
        WebApplicationUtils.setCurrentClassLoader(context);

        return context;
    }

    @Override
    public void contextDestroyed(ServletContextEvent event) {
        super.contextDestroyed(event);

        // destroy ModuleManager, dispose every module classloaders
        ModuleManager.destroy();
    }
}

web.xml

<listener>
    <listener-class>com.yourpackage.module.spring.context.ModuleContextLoaderListener</listener-class>
</listener>

Step 8 : Define a custom dispatcher servlet

/**
 * Only used to keep the {@link DispatcherServlet} easily accessible by {@link WebApplicationUtils}.
 * @author Ludovic Guillaume
 */
public class ModuleDispatcherServlet extends DispatcherServlet {
    private static final long serialVersionUID = 1L;

    public ModuleDispatcherServlet() {
        WebApplicationUtils.setDispatcherServlet(this);
    }
}

web.xml

<servlet>
    <servlet-name>dispatcher</servlet-name>
    <servlet-class>com.yourpackage.module.spring.web.servlet.ModuleDispatcherServlet</servlet-class>

    <init-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/dispatcher-servlet.xml</param-value>
    </init-param>

    <load-on-startup>1</load-on-startup>
</servlet>

Step 9 : Define a custom Jstl view

This part is 'optional' but it brings some clarity and cleanness in the controller implementation.

/**
 * Used to handle module {@link ModelAndView}.<br/><br/>
 * <b>Usage:</b><br/>{@code new ModuleAndView("module:MODULE_NAME.jar:LOCATION");}<br/><br/>
 * <b>Example:</b><br/>{@code new ModuleAndView("module:test-module.jar:views/testModule");}
 * @see JstlView
 * @author Ludovic Guillaume
 */
public class ModuleJstlView extends JstlView {
    @Override
    protected String prepareForRendering(HttpServletRequest request, HttpServletResponse response) throws Exception {
        String beanName = getBeanName();

        // checks if it starts 
        if (beanName.startsWith("module:")) {
            String[] values = beanName.split(":");

            String location = String.format("/%s%s/WEB-INF/%s", ModuleManager.CONTEXT_ROOT_MODULES_FOLDER, values[1], values[2]);

            setUrl(getUrl().replaceAll(beanName, location));
        }

        return super.prepareForRendering(request, response);
    }
}

Define it in the bean config :

<bean id="viewResolver"
      class="org.springframework.web.servlet.view.InternalResourceViewResolver"
      p:viewClass="com.yourpackage.module.spring.web.servlet.view.ModuleJstlView"
      p:prefix="/WEB-INF/"
      p:suffix=".jsp"/>

Final step

Now you just need to create a module, interface it with ModuleManager and add resources in the WEB-INF/ folder.

After that you can call load/start/stop/unload. I personaly refresh the context after each operation except for load.

The code is probably optimizable (ModuleManager as singleton e.g.) and there's maybe a better alternative (though I did not find it).

My next goal is to scan a module context config which shouldn't be so difficult.

Sign up to request clarification or add additional context in comments.

4 Comments

Great job! Nice guide.
Thanks! It tooks me a while but it's working like a charm. I migrated the project to Wildfly 8.1 and updated Spring from 3 to 4 few days ago. It's still working as intended :)
The project still works on Wildfly 9.0.1 / Spring 4.2.0. I left the Jstl part because we don't use JSP anymore. I migrated XML config to Java config. If I have time, I will do a git with all this.
~4 years later, I marked this part as deprecated in the project as we are migrating modules to Spring Boot microservices.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.