使用SpringMVC整合SpringSession的問題

海鳥發表於2015-05-17

最近在使用SpringSession時遇到一個問題,錯誤日誌如下:

Exception sending context initialized event to listener instance of class org.springframework.web.context.ContextLoaderListener
java.lang.IllegalStateException: Cannot initialize context because there is already a root application context present - check whether you have multiple ContextLoader* definitions in your web.xml!

先說下專案的配置情況:
SpringSession完全按照官方文件配置如下,

@EnableRedisHttpSession 
public class Config {

        @Bean
        public JedisConnectionFactory connectionFactory(@RedisServerPort int port) {
                JedisConnectionFactory connection = new JedisConnectionFactory(); 
                connection.setPort(port);
                return connection;
        }
}
public class Initializer
                extends AbstractHttpSessionApplicationInitializer { 

        public Initializer() {
                super(Config.class); 
        }
}

web.xml也是標準的配置方法:

   <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>classpath*:config/spring/rpc-service.xml</param-value>
    </context-param>
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>

   <servlet>
        <servlet-name>mvc</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>classpath*:config/spring/spring-mvc-main.xml</param-value>
        </init-param>
        <load-on-startup>0</load-on-startup>
    </servlet>

要想知道這個錯誤產生的原因,先要弄清楚Spring的容器(ApplicationContext)的繼承原理及SpringMVC如何使用這一機制的。

Spring容器的繼承關係

使用SpringMVC整合SpringSession的問題
如上圖那樣,容器之間可以像物件的繼承關係一樣,子容器通過setParent方法來設定自己的父容器。在呼叫容器的getBean查詢例項時,依次從當前容器往父容器查詢,直到找到滿足的物件即返回,如果一直沒有找到則返回Null.

SpringMVC中的容器即它們的關係

在使用SpringMVC時,必需要配置org.springframework.web.servlet.DispatcherServlet這樣的一個servlet。在初始化此例項時,會生成一個WebApplicationContext容器,生成容器後會檢查當前ServletContext環境下是否已經存在"rootContext",如果存在,則通過setParent方法設定為父容器。原始碼在這裡(org.springframework.web.servlet.FrameworkServlet#initWebApplicationContext):

protected WebApplicationContext initWebApplicationContext() {
        WebApplicationContext rootContext =
                WebApplicationContextUtils.getWebApplicationContext(getServletContext());
        WebApplicationContext wac = null;

        if (this.webApplicationContext != null) {
            // A context instance was injected at construction time -> use it
            wac = this.webApplicationContext;
            if (wac instanceof ConfigurableWebApplicationContext) {
                ConfigurableWebApplicationContext cwac = (ConfigurableWebApplicationContext) wac;
                if (!cwac.isActive()) {
                    // The context has not yet been refreshed -> provide services such as
                    // setting the parent context, setting the application context id, etc
                    if (cwac.getParent() == null) {
                        // The context instance was injected without an explicit parent -> set
                        // the root application context (if any; may be null) as the parent
                        cwac.setParent(rootContext);
                    }
                    configureAndRefreshWebApplicationContext(cwac);
                }
            }
        }
        if (wac == null) {
            // No context instance was injected at construction time -> see if one
            // has been registered in the servlet context. If one exists, it is assumed
            // that the parent context (if any) has already been set and that the
            // user has performed any initialization such as setting the context id
            wac = findWebApplicationContext();
        }
        if (wac == null) {
            // No context instance is defined for this servlet -> create a local one
            wac = createWebApplicationContext(rootContext);
        }

        if (!this.refreshEventReceived) {
            // Either the context is not a ConfigurableApplicationContext with refresh
            // support or the context injected at construction time had already been
            // refreshed -> trigger initial onRefresh manually here.
            onRefresh(wac);
        }

        if (this.publishContext) {
            // Publish the context as a servlet context attribute.
            String attrName = getServletContextAttributeName();
            getServletContext().setAttribute(attrName, wac);
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Published WebApplicationContext of servlet '" + getServletName() +
                        "' as ServletContext attribute with name [" + attrName + "]");
            }
        }

        return wac;
    }

那這個rootContext從哪裡來的呢?答案是org.springframework.web.context.ContextLoaderListener,它是一個標準的javax.servlet.ServletContextListener實現,在容器啟動的時候建立一個全域性唯一的rootContext,程式碼在:org.springframework.web.context.ContextLoader#initWebApplicationContext下:

   public WebApplicationContext initWebApplicationContext(ServletContext servletContext) {
        if (servletContext.getAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE) != null) {
            throw new IllegalStateException(
                    "Cannot initialize context because there is already a root application context present - " +
                    "check whether you have multiple ContextLoader* definitions in your web.xml!");
        }

        Log logger = LogFactory.getLog(ContextLoader.class);
        servletContext.log("Initializing Spring root WebApplicationContext");
        if (logger.isInfoEnabled()) {
            logger.info("Root WebApplicationContext: initialization started");
        }
        long startTime = System.currentTimeMillis();

        try {
            // Store context in local instance variable, to guarantee that
            // it is available on ServletContext shutdown.
            if (this.context == null) {
                this.context = createWebApplicationContext(servletContext);
            }
            if (this.context instanceof ConfigurableWebApplicationContext) {
                ConfigurableWebApplicationContext cwac = (ConfigurableWebApplicationContext) this.context;
                if (!cwac.isActive()) {
                    // The context has not yet been refreshed -> provide services such as
                    // setting the parent context, setting the application context id, etc
                    if (cwac.getParent() == null) {
                        // The context instance was injected without an explicit parent ->
                        // determine parent for root web application context, if any.
                        ApplicationContext parent = loadParentContext(servletContext);
                        cwac.setParent(parent);
                    }
                    configureAndRefreshWebApplicationContext(cwac, servletContext);
                }
            }
            servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context);

            ClassLoader ccl = Thread.currentThread().getContextClassLoader();
            if (ccl == ContextLoader.class.getClassLoader()) {
                currentContext = this.context;
            }
            else if (ccl != null) {
                currentContextPerThread.put(ccl, this.context);
            }

            if (logger.isDebugEnabled()) {
                logger.debug("Published root WebApplicationContext as ServletContext attribute with name [" +
                        WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE + "]");
            }
            if (logger.isInfoEnabled()) {
                long elapsedTime = System.currentTimeMillis() - startTime;
                logger.info("Root WebApplicationContext: initialization completed in " + elapsedTime + " ms");
            }

            return this.context;
        }
        catch (RuntimeException ex) {
            logger.error("Context initialization failed", ex);
            servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, ex);
            throw ex;
        }
        catch (Error err) {
            logger.error("Context initialization failed", err);
            servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, err);
            throw err;
        }
    }

總結下結果:servlet容器通過ContextLoaderListener建立一個root容器,並設定為SpringMVC的父容器。 到止應該明白了文章最開始的報錯資訊的來源了,就是在這個方法裡報錯。出錯的原因有且只有一個:就是給servlet容器註冊了兩個ContextLoaderListener。一個是在web.xml配置檔案裡配置的,那另一個在哪裡註冊的呢?接著分析。

SpringSession的載入機制

整合SpringSession是很簡單的,只要實現一個"AbstractHttpSessionApplicationInitializer "的子類即可,然後在子類的構造器中傳一個標註了EnableRedisHttpSession的註解類,此註解繼承了Configuration,所以在類Initializer進行初始化時,會呼叫“org.springframework.session.web.context.AbstractHttpSessionApplicationInitializer#onStartup”方法,程式碼如下:

    public void onStartup(ServletContext servletContext)
            throws ServletException {
        beforeSessionRepositoryFilter(servletContext);
        if(configurationClasses != null) {
            AnnotationConfigWebApplicationContext rootAppContext = new AnnotationConfigWebApplicationContext();
            rootAppContext.register(configurationClasses);
            servletContext.addListener(new ContextLoaderListener(rootAppContext));
        }
        insertSessionRepositoryFilter(servletContext);
        afterSessionRepositoryFilter(servletContext);
    }

現在我們找到了另外一個往servlet容器中註冊ContextLoaderListener的地方了,也就是在這個地方拋錯了。找到了問題的根源,解決問題就很簡單了。

解決問題

其實只要保證ContextLoaderListener只註冊一次就不會有這個問題了,所以有兩個選擇做法:要麼別在web.xml裡配置ContextLoaderListener,要麼在Initializer類的構造方法中,不要呼叫父類的有引數構造器,而是呼叫空參構造器。為了遵守SpringMVC官方的開發規範,最好還是要配置下ContextLoaderListener,把非web層而的物件單獨配置,比如service層物件。而web層的東西配置在dispatcher容器中。但是這樣即使這樣做了,會報別外一個錯誤,說"org.springframework.data.redis.connection.jedis.JedisConnectionFactory"找不到。所以要在spring的配置檔案中加入如下配置:

   <bean class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
        <property name="hostName" value="xxxx"/>
        <property name="port" value="xxxx"/>
        <property name="password" value="xxx"/>
    </bean>

如果你加入這個配置,也還是報相同錯誤的話,那麼就要檢查下這個配置是放在哪個spring的配置檔案下,如果放在ContextLoaderListener的配置檔案下就不會報錯,而放在DispatcherServlet的配置下就會報錯。原因還是從程式碼(org.springframework.web.filter.DelegatingFilterProxy#findWebApplicationContext)裡看:

   protected WebApplicationContext findWebApplicationContext() {
        if (this.webApplicationContext != null) {
            // the user has injected a context at construction time -> use it
            if (this.webApplicationContext instanceof ConfigurableApplicationContext) {
                if (!((ConfigurableApplicationContext)this.webApplicationContext).isActive()) {
                    // the context has not yet been refreshed -> do so before returning it
                    ((ConfigurableApplicationContext)this.webApplicationContext).refresh();
                }
            }
            return this.webApplicationContext;
        }
        String attrName = getContextAttribute();
        if (attrName != null) {
            return WebApplicationContextUtils.getWebApplicationContext(getServletContext(), attrName);
        }
        else {
            return WebApplicationContextUtils.getWebApplicationContext(getServletContext());
        }
    }

這個方法的最後幾行可以看出,SpringSession所需要的所有基礎物件,比如Redis連線物件,Redis模板物件,都是從WebApplicationContext從獲取。而WebApplicationContext根據getContextAttribute()的值不同先獲取的方式也不同。如果getContextAttribute()返回為Null,則取的容器是rootContext,即ContextLoaderListener生成的容器。反之,獲取的是DispatcherServlet容器。知道了原因,解決方式就清晰了。重寫Initializer的getDispatcherWebApplicationContextSuffix方法。Initializer最終的程式碼如下:

@EnableRedisHttpSession
public class Initializer extends AbstractHttpSessionApplicationInitializer
{
    @Override
    protected String getDispatcherWebApplicationContextSuffix()
    {
        return "mvc"; # 這裡返回的字串就是你配置DispatcherServlet的名稱
    

而本文前面提到的Config類可以刪除不用了。

相關文章