민서네집

[mybatis] mapper xml 파일을 서버 재시작 없이 reloading 하기. 본문

Spring

[mybatis] mapper xml 파일을 서버 재시작 없이 reloading 하기.

브라이언7 2013. 7. 22. 19:06

[참고] http://sbcoba.tistory.com/entry/Spring-mybats-%EC%82%AC%EC%9A%A9%EC%8B%9C-%EC%9E%AC%EC%8B%9C%EC%9E%91-%EC%97%86%EC%9D%B4-%EC%84%9C%EB%B2%84-%EB%B0%98%EC%98%81


위 블로그를 참조했는데,

SqlSessionFactoryBean 을 설정할 때, mapperLocations 를 주지 않고, 아래처럼 configLocation 을 설정하는 경우 xml 파일이 relaoding 되지 않았다.


<!-- define the SqlSessionFactory -->

<!-- <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> -->

<bean id="sqlSessionFactory" class="com.example.util.RefreshableSqlSessionFactoryBean"> <!-- 서버 재시작 없이 mybatis mapper xml 파일을 reloading 하는 클래스 -->

<property name="dataSource" ref="dataSource" />

<property name="typeAliasesPackage" value="com.example.dao" />

<property name="configLocation" value="classpath:mapper/MapperConfig.xml" />

<property name="interval" value="2000" /> <!-- mapper xml 파일을 재로딩 하는 간격 -->

</bean>


그래서 위 블로그를 참고로 해서 좀 수정했다.


configLocation 에 지정된 MapperConfig.xml 파일 안에 지정된 다른 mapper 파일을 불러오기 위해서, xml 파일을 parsing 했고, protected field 값을 가져오기 위해 reflection 을 사용했다.

그리고, mybatis-spring-1.1.0.jar 파일 안의 SqlSessionFactoryBean.java 를 참고했다.

RefreshableSqlSessionFactoryBean.java 의 소스를 보면 알겠지만, SqlSessionFactoryBean 클래스를 상속받아 만들므로 mybatis-spring 의 버전과는 상관없이 사용할 수 있을 듯 하다. - refresh() 할때마다 afterPropertiesSet()을 호출하고, SqlSessionFactoryBean 클래스의 소스를 보면 buildSqlSessionFactory() 메서드가 호출되서 sqlSessionFactory 의 값을 바꾼다.


내가 위 블로그를 참조해서 약간 수정한 소스는 다음과 같다.



package com.example.util;

import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.ibatis.builder.xml.XMLConfigBuilder;
import org.apache.ibatis.executor.ErrorContext;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;

/**
 * mybatis mapper 자동 감지 후 자동으로 서버 재시작이 필요 없이 반영
 *
 * [참고] http://sbcoba.tistory.com/entry/Spring-mybats-%EC%82%AC%EC%9A%A9%EC%8B%9C-%EC%9E%AC%EC%8B%9C%EC%9E%91-%EC%97%86%EC%9D%B4-%EC%84%9C%EB%B2%84-%EB%B0%98%EC%98%81
 */
public class RefreshableSqlSessionFactoryBean extends SqlSessionFactoryBean implements DisposableBean
{
	private static final Log log = LogFactory.getLog(RefreshableSqlSessionFactoryBean.class);

	private SqlSessionFactory proxy;

	private int interval = 500;

	private Timer timer;

	private TimerTask task;

	private Resource configLocation;

	private Resource[] mapperLocations;

	private Properties configurationProperties;

	/**
	 * Set optional properties to be passed into the SqlSession configuration, as alternative to a
	 * {@code &lt;properties&gt;} tag in the configuration xml file. This will be used to
	 * resolve placeholders in the config file.
	 */
	public void setConfigurationProperties(Properties sqlSessionFactoryProperties) {
		super.setConfigurationProperties(sqlSessionFactoryProperties);
		this.configurationProperties = sqlSessionFactoryProperties;
	}

	/**
	 * 파일 감시 쓰레드가 실행중인지 여부.
	 */
	private boolean running = false;

	private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

	private final Lock r = rwl.readLock();

	private final Lock w = rwl.writeLock();

	public void setConfigLocation(Resource configLocation)
	{
		super.setConfigLocation(configLocation);
	    this.configLocation = configLocation;
	}

	public void setMapperLocations(Resource[] mapperLocations)
	{
		super.setMapperLocations(mapperLocations);
		this.mapperLocations = mapperLocations;
	}

	public void setInterval(int interval)
	{
		this.interval = interval;
	}

	public void refresh() throws Exception
	{
		if (log.isInfoEnabled())
		{
			log.info("refreshing SqlSessionFactory.");
		}

		w.lock();

		try
		{
			super.afterPropertiesSet();

		} finally
		{
			w.unlock();
		}
	}

	/**
	 *
	 * 싱글톤 멤버로 SqlSessionFactory 원본 대신 프록시로 설정하도록 오버라이드.
	 */
	public void afterPropertiesSet() throws Exception
	{
		super.afterPropertiesSet();
		setRefreshable();
	}

	private void setRefreshable()
	{
		proxy = (SqlSessionFactory) Proxy.newProxyInstance(

					SqlSessionFactory.class.getClassLoader(),

					new Class[] { SqlSessionFactory.class },

					new InvocationHandler()
					{
						public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
						{
							// log.debug("method.getName() : " + method.getName());
							return method.invoke(getParentObject(), args);
						}
					});

		task = new TimerTask()
		{
			private Map<Resource, Long> map = new HashMap<Resource, Long>();

			public void run()
			{
				if (isModified())
				{
					try
					{
						refresh();

					} catch (Exception e)
					{
						log.error("caught exception", e);
					}
				}
			}

			private boolean isModified()
			{
				boolean retVal = false;

				if (mapperLocations != null)
				{
					for (int i = 0; i < mapperLocations.length; i++)
					{
						Resource mappingLocation = mapperLocations[i];
						retVal |= findModifiedResource(mappingLocation);
						if( retVal )
							break;
					}
				} 
				else if( configLocation != null )
				{
					Configuration configuration = null;

					XMLConfigBuilder xmlConfigBuilder = null;
					try
					{
						xmlConfigBuilder = new XMLConfigBuilder(configLocation.getInputStream(), null, configurationProperties);
						configuration = xmlConfigBuilder.getConfiguration();
					} catch (IOException e)
					{
						e.printStackTrace();
					}

					if (xmlConfigBuilder != null) {
						try {
							xmlConfigBuilder.parse();

							// Configuration 클래스의 protected member field 인 loadedResources 를 얻기 위해 reflection 을 사용함.

							Field loadedResourcesField = Configuration.class.getDeclaredField("loadedResources");
							loadedResourcesField.setAccessible(true);

							@SuppressWarnings("unchecked")
							Set<String> loadedResources = (Set<String>) loadedResourcesField.get(configuration);

							for (Iterator<String> iterator = loadedResources.iterator(); iterator.hasNext();)
							{
								String resourceStr = (String) iterator.next();
								if( resourceStr.endsWith(".xml") ) {
									Resource mappingLocation = new ClassPathResource(resourceStr);
									retVal |= findModifiedResource(mappingLocation);
									if( retVal ) {
										break;
									}
								}
							}

						} catch (Exception ex) {
							throw new RuntimeException("Failed to parse config resource: " + configLocation, ex);
						} finally {
							ErrorContext.instance().reset();
						}
					}
				}

				return retVal;
			}

			private boolean findModifiedResource(Resource resource)
			{
				boolean retVal = false;
				List<String> modifiedResources = new ArrayList<String>();

				try
				{
					long modified = resource.lastModified();

					if (map.containsKey(resource))
					{
						long lastModified = ((Long) map.get(resource)).longValue();

						if (lastModified != modified)
						{
							map.put(resource, new Long(modified));

							modifiedResources.add(resource.getDescription());

							retVal = true;
						}
					} else
					{
						map.put(resource, new Long(modified));
					}

				} catch (IOException e)
				{
					log.error("caught exception", e);
				}

				if (retVal)
				{
					if (log.isInfoEnabled())
					{
						log.info("modified files : " + modifiedResources);
					}
				}

				return retVal;
			}
		};

		timer = new Timer(true);
		resetInterval();
	}

	private Object getParentObject() throws Exception
	{
		r.lock();

		try
		{
			return super.getObject();

		} finally
		{
			r.unlock();
		}
	}

	public SqlSessionFactory getObject()
	{
		return this.proxy;
	}

	public Class<? extends SqlSessionFactory> getObjectType()
	{
		return (this.proxy != null ? this.proxy.getClass() : SqlSessionFactory.class);
	}

	public boolean isSingleton()
	{
		return true;
	}

	public void setCheckInterval(int ms)
	{
		interval = ms;

		if (timer != null)
		{
			resetInterval();
		}
	}

	private void resetInterval()
	{
		if (running)
		{
			timer.cancel();
			running = false;
		}

		if (interval > 0)
		{
			timer.schedule(task, 0, interval);
			running = true;
		}
	}

	public void destroy() throws Exception
	{
		timer.cancel();
	}

}


위 소스에서 SqlSessionFactory 를 proxy 로 생성하면서, invocationHandler 를 등록하였는데, SqlSessionFactory 의 메서드를 호출할 때마다 invocationHandler 가 호출된다. 이것을 이용하면 요청이 없을 때도 주기적으로 xml 파일이 변경되었는지 scan 하지 않고, Spring Framework 의 org.springframework.context.support.ReloadableResourceBundleMessageSource 클래스처럼 요청이 왔을 때만 마지막으로 검사한 시각과 현재 시각과 비교해서 시간 간격이 지정된 시간 이내면 다시 scan 하지 않고, 메모리에 있는 것으로 처리하게 할 수도 있을 것이다.


한가지 주의할 점은 RefreshableSqlSessionFactoryBean 을 설정할 때 mapperLocations 없이 configLocation 만 지정하였는데도, 서버가 시작할 때는 configLocation 으로 지정된 xml 파일 안에 mapper xml 파일을 지정하지 않아도 mapper 파일 안의 sql 이 잘 실행되지만, RefreshableSqlSessionFactoryBean 이 refresh 되고 나서는 이 config 파일 안에 지정되지 않은 mapper xml 파일 안의 sql 은 실행되지 않는다는 점이다. (정확히 말하면, mapper 파일 안의 쿼리 statement 를 가지고 있지 않아서 에러가 난다.)


이유를 잘 모르겠지만, 아마도 서버를 시작할 때 다음과 같이 MapperScannerConfigurer 설정이 되어 있어서 configLocation 에 지정되어 있지 않아도 지정된 package 밑의 모든 mapper 파일을 찾아서 쿼리 statement 를 모두 가지고 있는 듯 하다.


<!-- scan for mappers and let them be autowired -->

<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">

<property name="basePackage" value="com.example.dao" />

</bean>


참고로 configLocation 에 지정된 MapperConfig.xml 파일은 다음과 같다.


<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">

<configuration>

    <settings>
        <setting name="cacheEnabled" value="true"/>
        <setting name="lazyLoadingEnabled" value="true"/>
        <setting name="multipleResultSetsEnabled" value="true"/>
        <setting name="useColumnLabel" value="true"/>
        <setting name="defaultStatementTimeout" value="25000"/>
    </settings>

    <mappers>
        <!-- Apps Mapper File -->
        <mapper resource="com/example/dao/apps/AppsMapper.xml"/>
        
        <!-- Common Mapper File -->
        <mapper resource="com/example/dao/common/CommonMapper.xml"/>
        
        <!-- User Mapper File -->
        <mapper resource="com/example/dao/user/UserMapper.xml"/>
    </mappers>

</configuration>


<bean id="sqlSessionFactory" class="패키지경로.RefreshableSqlSessionFactoryBean" p:mapperLocations="classpath*:패키지경로/**/mapper.xml" p:configLocation="classpath:/MapperConfig.xml" p:dataSource-ref="dataSource" />


아니면 위에서처럼 configLocation 과 mapperLocations 를 모두 다 주고, RefreshableSqlSessionFactoryBean 에서는 configLocations 에 지정된 mapper 파일은 무시하고, mapperLocations 에 지정된 mapper 파일만 변경 여부를 검사하는게 더 나을 수도 있겠다. - 파일을 중복해서 검사하지 않도록.


아직 테스트 해 보지는 않았는데, mapper 파일이 수정되는게 아니라 아예 없던 파일이 생성되는 경우도 서버 재시작 없이 적용 가능할까? mapperLocations 를 


p:mapperLocations="classpath*:패키지경로/**/mapper.xml" 


이렇게 설정하면 될까? 안된다면 configLocation 에 지정된 MapperConfig.xml 파일에 mapper 파일을 추가하고, MapperConfig.xml 파일이 변경되는지도 체크하도록 RefreshableSqlSessionFactoryBean 을 약간 변경할 수 있을것 같다.


< 2014-06-05 업데이트 >


sqlSessionFactory bean 설정에서 mapperLocations 속성이 주어지는 경우는 

isModified() 메서드에서 configLocation 속성으로 주어진 파일에서 mapper 파일을 검사하지 않도록 수정함.


이유: configLocation 파일 안의 mapper 파일을 체크하기 위해서는 reflection 을 사용하기 때문에

성능 저하가 일어나지 않도록.


pom.xml 파일에서 다음과 같이 설정하고,


<!-- cache -->

<dependency>

<groupId>net.sf.ehcache</groupId>

<artifactId>ehcache</artifactId>

<version>2.8.2</version>

<type>pom</type>

</dependency>


<!-- myBatis -->

<dependency>

<groupId>org.mybatis</groupId>

<artifactId>mybatis-ehcache</artifactId>

<version>1.0.0</version>

</dependency>

<dependency>

<groupId>org.mybatis</groupId>

<artifactId>mybatis</artifactId>

<version>3.2.7</version>

</dependency>

<dependency>

<groupId>org.mybatis</groupId>

<artifactId>mybatis-spring</artifactId>

<version>1.2.2</version>

</dependency>


spring database 설정


<beans xmlns="http://www.springframework.org/schema/beans"

xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

xmlns:context="http://www.springframework.org/schema/context"

xmlns:tx="http://www.springframework.org/schema/tx"

xmlns:p="http://www.springframework.org/schema/p"

xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd

http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-4.0.xsd

http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.0.xsd">


<bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">

<property name="driverClassName" value="com.mysql.jdbc.Driver" />

<property name="url" value="jdbc:mysql://localhost:3306/test" />

<property name="username" value="user" />

<property name="password" value="password" />

</bean>


<context:annotation-config />


<tx:annotation-driven />


<!-- define the SqlSessionFactory -->

<!-- <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> -->

<bean id="sqlSessionFactory" class="com.example.config.mybatis.RefreshableSqlSessionFactoryBean"> <!-- 서버 재시작 없이 mybatis mapper xml 파일을 reloading 하는 클래스 -->

<property name="dataSource" ref="dataSource" />

<property name="typeAliasesPackage" value="com.example" />

<property name="configLocation" value="classpath:mybatis/mybatis-config.xml" />

<property name="interval" value="5000" /> <!-- mapper xml 파일을 재로딩 하는 간격 -->

<property name="mapperLocations" value="classpath:com/example/**/dao/*Mapper.xml" />

</bean>


<bean id="sqlSessionTemplate" class="org.mybatis.spring.SqlSessionTemplate">

<constructor-arg ref="sqlSessionFactory" />

</bean>


<!-- scan for mappers and let them be autowired -->

<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">

<property name="basePackage" value="com.example" />

</bean>


<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"

p:dataSource-ref="dataSource" />


</beans>


위와 같이 설정해서 정상적으로 mapper xml 파일의 변경사항이 서버 재시작 없이 적용되는 것을 확인하였다.


Comments