Tiles2 и Sping: Перезагрузка

May 1, 2008

Введение в суть проблемы



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

Одним из моментов, которые мне никогда не нравились в java (точнее в серверном программировании под нее), были большие затраты на перезапуск приложения после изменений. В то время как для других скрипт-основанных языков я просто изменял текст кода и жал в браузере кнопку "F5", то в java все было хуже. Хотя изменение кода сосредоточенного в jsp слое является наиболее безболезненным (просто замени текст файла и нажми "F5"), с изменением бизнес-логики все хуже. В случае небольших правок без изменения сигнатур методов, добавления новых классов и интенсивного рефакторинга, больших проблем нет - работает hotswap и изменения в коде проявляются мгновенно. В обратном случае вам приходится перезапускать контекст веб-приложения, либо набрав в адресной строке браузера сакраментальное.

http://center:8080/manager/reload?path=/НАЗВАНИЕ_МОЕГО_ВЕБ_ПРИЛОЖЕНИЯ

Либо использовать ant-задачи, например (спасибо товарищу Thomas Risberg за вырезанный из его книжки пример кода):

Собственно данный кусочек кода не будет слишком полезным продвинутым в java товарищам, но для начинающих почему бы и не скопировать еще раз классические строки.
  1. <!-- ============================================================== -->
  2. <!-- Tomcat tasks - remove these if you don't have Tomcat installed -->
  3. <!-- ============================================================== -->
  4. <!-- определять таким образом переменные не красиво, но путь будет так  -->
  5.     <property name="appserver.home" location="E:\Program_Files_2\apache-tomcat-6.0.14" />
  6.     <property name="tomcat.manager.url" value="http://localhost:8080/manager" />
  7.     <property name="tomcat.manager.username" value="tomcat" />
  8.     <property name="tomcat.manager.password" value="tomcat" />
  9.     <property name="deploy.path" location="${appserver.home}/webapps" />
  10.     <property name="name-web-app" value="Название-Моего-Мега-Приложения" />
  11.     <property name="path-to-war-file" location="То-Самое-Место-Где-Находится-Архив-Нашего-Приложения" />
  12.  
  13.  
  14.  
  15.     <taskdef name="install" classname="org.apache.catalina.ant.InstallTask">
  16.         <classpath>
  17.             <path location="${appserver.home}/lib/catalina-ant.jar"/>
  18.         </classpath>
  19.     </taskdef>
  20.     <taskdef name="reload" classname="org.apache.catalina.ant.ReloadTask">
  21.         <classpath>
  22.             <path location="${appserver.home}/lib/catalina-ant.jar"/>
  23.         </classpath>
  24.     </taskdef>
  25.     <taskdef name="list" classname="org.apache.catalina.ant.ListTask">
  26.         <classpath>
  27.             <path location="${appserver.home}/lib/catalina-ant.jar"/>
  28.         </classpath>
  29.     </taskdef>
  30.     <taskdef name="start" classname="org.apache.catalina.ant.StartTask">
  31.         <classpath>
  32.             <path location="${appserver.home}/lib/catalina-ant.jar"/>
  33.         </classpath>
  34.     </taskdef>
  35.     <taskdef name="stop" classname="org.apache.catalina.ant.StopTask">
  36.         <classpath>
  37.             <path location="${appserver.home}/lib/catalina-ant.jar"/>
  38.         </classpath>
  39.     </taskdef>
  40.  
  41.     <target name="install" description="Install application in Tomcat">
  42.         <install url="${tomcat.manager.url}"
  43.                  username="${tomcat.manager.username}"
  44.                  password="${tomcat.manager.password}"
  45.                  path="/${name-web-app}"
  46.                  war="${path-to-war-file}"/>
  47.     </target>
  48.  
  49.     <target name="reload" description="Reload application in Tomcat">
  50.         <echo message="${path-to-war-file}" />
  51.  
  52.         <reload url="${tomcat.manager.url}"
  53.                 username="${tomcat.manager.username}"
  54.                 password="${tomcat.manager.password}"
  55.                 path="/${name-web-app}"/>
  56.     </target>
  57.  
  58.     <target name="start" description="Start Tomcat application">
  59.         <start url="${tomcat.manager.url}"
  60.                username="${tomcat.manager.username}"
  61.                password="${tomcat.manager.password}"
  62.                path="/${name-web-app}"/>
  63.     </target>
  64.  
  65.     <target name="stop" description="Stop Tomcat application">
  66.         <stop url="${tomcat.manager.url}"
  67.               username="${tomcat.manager.username}"
  68.               password="${tomcat.manager.password}"
  69.               path="/${name-web-app}"/>
  70.     </target>
  71.  
  72.     <target name="list" description="List Tomcat applications">
  73.         <list url="${tomcat.manager.url}"
  74.               username="${tomcat.manager.username}"
  75.               password="${tomcat.manager.password}"/>
  76.     </target>
  77.  
  78. <!-- End Tomcat tasks -->
В любом случае перезапуск веб-приложения может занять десяток-другой секунд (Боже, неужели прошли те времена когда я запускал сборку c++ приложения, откидывался на спинку стула и блаженно засыпал на минут 15). И если есть какой-то способ ускорить итерацию разработки, то ее нужно использовать. Собственно, когда я пишу с помощью spring, то есть особый вариант изменений, промежуточный между полным перезапуском веб-приложения и просто-поменяй-пару-файлов-jsp. Это ситуация обновления (перечитать все определения в контексте spring) и все определения из файла конфигурации tiles2. Надо сказать, что и в том, и в том инструментах предусмотрены стандартные механизмы для перезапуска конфигурации. Однако, я не нашел каких-то средств явно указать правила по которым будет выполняться перегрузка контекстов (если вы оказались зорче меня и знаете такой прием, то сообщите - буду благодарен).

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

Сначала пример использования:
  1. <bean id="tiles2ReloadBean" class="blz.server.utils.Tiles2ReloadBean">
  2.         <property name="logOnlyIfErrorsOrReloadOccuried" value="true"/>
  3.         <property name="interval" value="1000"/>
  4.     </bean>
  5.  
  6.     <bean id="springReloadBean" class="blz.server.utils.SpringReloadBean">
  7.         <property name="logOnlyIfErrorsOrReloadOccuried" value="true"/>
  8.         <property name="intervalForScan" value="200"/>
  9.         <property name="delayForRefresh" value="1000"/>
  10.     </bean>
В первом случае, с перегрузкой tiles2 определений я должен указать только один конфигурационный параметр: интервал через которое будет выполняться сканирование ресурсов на предмет их обновления. Время как это и принято везде задается в миллисекундах. Второй параметр (logOnlyIfErrorsOrReloadOccuried - необязательный и играет нужен в основном для целей отладки, чтобы сообщать об каждом шаге в жизненном цикле "Бина-Нео").

Второй бин (SpringReloadBean) имеет два параметра: время для сканирования intervalForScan - его я устанавливаю как можно меньше - управляет тем как часто будут выполняться проверки были ли изменены файлы spring. Как только такое изменение было обнаружено, то срабатывает отложенное на некоторое время задание перезагрузить контекст spring. Величина такой задержки задается с помощью delayForRefresh. Дело в том, что я не решился запускать обновление одновременно, т.к. скорее всего изменение файлов контекста выполняется не атомарно, и лучше подождать секунду, до того как все файлы придут (будут скопированы) в актуальное состояние.

Могу сказать, что в код приложенного далее бина "перезагружающего spring", можно добавить поддержку перезагрузки не только тогда, когда изменился какой-то из xml-файлов, составляющих контекст, но и файлов настроек. Но я считаю такую функциональность не настолько важной и решил не усложнять.

Исходники бинов



Сначала код бина SpringReloadBean:
  1. package blz.server.utils;
  2.  
  3. import org.springframework.beans.BeansException;
  4. import org.springframework.context.ApplicationContext;
  5. import org.springframework.context.ApplicationContextAware;
  6. import org.springframework.context.ApplicationEvent;
  7. import org.springframework.context.ApplicationListener;
  8. import org.springframework.context.event.ContextClosedEvent;
  9. import org.springframework.context.event.ContextRefreshedEvent;
  10. import org.springframework.web.context.support.AbstractRefreshableWebApplicationContext;
  11.  
  12. import java.io.BufferedReader;
  13. import java.io.File;
  14. import java.io.FileReader;
  15. import java.io.IOException;
  16. import java.util.*;
  17. import java.util.logging.Logger;
  18.  
  19.  
  20. /**
  21.  * Класс обеспечивающий перезагрузку контекста spring при изменении xml-файлов с его определениями
  22.  */
  23. public class SpringReloadBean implements ApplicationContextAware, ApplicationListener {
  24.  
  25.     AbstractRefreshableWebApplicationContext applicationContext;
  26.  
  27.     public synchronized void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
  28.         logger.info("SpringReloadBean.setApplicationContext got ApplicationContext");
  29.         this.applicationContext = (AbstractRefreshableWebApplicationContext) applicationContext;
  30.     }
  31.  
  32.     public synchronized void onApplicationEvent(ApplicationEvent event) {
  33.         if (event instanceof ContextRefreshedEvent) {
  34.             applicationContext = (AbstractRefreshableWebApplicationContext) event.getSource();
  35.             logger.info("SpringReloadBean.onApplicationEvent on ContextRefreshedEvent");
  36.             timer.schedule(taskScanFiles, new Date(new Date().getTime() + 
  37.               delayForRefresh + delayForRefresh), intervalForScan);
  38.         }
  39.         if (event instanceof ContextClosedEvent) {
  40.             applicationContext = null;
  41.             logger.info("SpringReloadBean.onApplicationEvent on ContextClosedEvent");
  42.             taskScanFiles.cancel();
  43.             timer.purge();
  44.         }
  45.  
  46.     }
  47.  
  48.  
  49.     Logger logger = Logger.getLogger(SpringReloadBean.class.getName());
  50.     /**
  51.      * Интервал, через который нужно выполнять сканирование файлов контекста на предмет их изменений (в ms.)
  52.      */
  53.     Integer intervalForScan = 500;
  54.  
  55.     public synchronized void setIntervalForScan(Integer intervalForScan) {
  56.         if (intervalForScan < 100)
  57.             throw new IllegalArgumentException("too low delay for SpringReloadBean.interval (min value 100 ms.)");
  58.         this.intervalForScan = intervalForScan;
  59.     }
  60.  
  61.     /**
  62.      * Интервал, через который после обнаружения изменений в файлах контекста нужно выполнить его перезагрузку
  63.      */
  64.     Integer delayForRefresh = 1000;
  65.  
  66.     public synchronized void setDelayForRefresh(Integer delayForRefresh) {
  67.         if (delayForRefresh < 0)
  68.             throw new IllegalArgumentException("too low delay for SpringReloadBean.delayForRefresh (min value 0 ms.)");
  69.         this.delayForRefresh = delayForRefresh;
  70.     }
  71.  
  72.  
  73.     /**
  74.      * Свойство признак того, что сообщения в log будут выводиться только в тех случаях когда произошли ошибки или
  75.      * контекст был действительно перезагружен
  76.      */
  77.     Boolean logOnlyIfErrorsOrReloadOccuried = true;
  78.  
  79.     public void setLogOnlyIfErrorsOrReloadOccuried(Boolean logOnlyIfErrorsOrReloadOccuried) {
  80.         this.logOnlyIfErrorsOrReloadOccuried = logOnlyIfErrorsOrReloadOccuried;
  81.     }
  82.  
  83.     /**
  84.      * Переменная признак того, что задание на обновление контекста уже было задано
  85.      */
  86.     Boolean refreshTaskWasPlanned = false;
  87.  
  88.     /**
  89.      * Создаем объект Timer и TimerTask, но запуск задачи будет выполнен только
  90.      * после получения извещения о том, что контекст был запущен
  91.      */
  92.     public SpringReloadBean() {
  93.         timer = new Timer(true);
  94.  
  95.         taskRefreshContext = new TimerTask() {
  96.             public void run() {
  97.                 executeRefresh();
  98.                 timer.purge();
  99.                 // помечаем то что задача перезагрузки контекста была успешно выполнена
  100.                 // и теперь можно снова сканировать файлы контекста на предмет  их изменения 
  101.                 refreshTaskWasPlanned = false;
  102.             }
  103.         };
  104.  
  105.         taskScanFiles = new TimerTask() {
  106.             public void run() {
  107.                 if (!logOnlyIfErrorsOrReloadOccuried)
  108.                     logger.info("SpringReloadBean -- prepare execute taskScanFiles");
  109.                 if (applicationContext == null) {
  110.                     logger.severe("SpringReloadBean -- skip execute taskScanFiles,
  111.                             because no applicationContext available");
  112.                     return;
  113.                 }
  114.                 if (!logOnlyIfErrorsOrReloadOccuried)
  115.                     logger.info("SpringReloadBean -- ready execute taskScanFiles");
  116.  
  117.                 if ((!refreshTaskWasPlanned) && isContextFilesWereModified()) {
  118.                     logger.info("SpringReloadBean -- context files were modified");
  119.                     if (delayForRefresh == 0) {
  120.                         // если значение параметра времени задержки перед обновлением контекста равно нулю,
  121.                         // то нет никакого смысла том чтобы добавлять новое задание таймеру
  122.                         logger.info("SpringReloadBean -- execute immediately refresh, no delay");
  123.                         executeRefresh();
  124.                     } else {
  125.                         // а если время задержки перед обновлением контекста указано, то добавляем новое задание таймеру
  126.                         logger.info("SpringReloadBean -- shedule refresh after " + delayForRefresh + " ms.");
  127.                         // помечаем то что задание на обновление контекста было выдано
  128.                         refreshTaskWasPlanned = true;
  129.                         timer.schedule(taskRefreshContext, delayForRefresh);
  130.                     }
  131.                 }// if files were modified
  132.             }
  133.         };
  134.     }
  135.  
  136.     private void executeRefresh() {
  137.         long now = System.currentTimeMillis();
  138.         // а теперь перезапускаем контекст, к счастью данный метод выполняется в отдельном потоке
  139.         // а иначе бы я даже не знал что может произойти
  140.         final long contextStarted = applicationContext.getStartupDate();
  141.         Date contextDat = new Date(contextStarted);
  142.         logger.info("context was loaded last time at " + contextDat);
  143.         applicationContext.refresh();
  144.         // очищаем список найденных файлов образующих контекст, т.к. после перезагрузки контекста могли
  145.         // появиться новые файлы и нужно выполнить пересканирование их содержимого
  146.         scannedFiles.clear();
  147.         long now2 = System.currentTimeMillis();
  148.         logger.info("time used to refresh context = " + (now2 - now) + " ms.");
  149.     }
  150.  
  151.  
  152.     private TimerTask taskScanFiles;
  153.     private TimerTask taskRefreshContext;
  154.     private Timer timer = new Timer(true);
  155.  
  156.     /**
  157.      * Сведения об файлах, которые образуют данные для контекста
  158.      */
  159.     private HashSet<String> scannedFiles = new HashSet<String>();
  160.  
  161.     public synchronized boolean isContextFilesWereModified() {
  162.  
  163.         if (scannedFiles.size() == 0) {
  164.             ArrayList<File> tasks = new ArrayList<File>();
  165.             final String[] locations = applicationContext.getConfigLocations();
  166.             for (String location : locations) {
  167.                 File fullFile = new File(applicationContext.getServletContext().getRealPath(location));
  168.                 tasks.add(fullFile);
  169.             }
  170.             for (int i = 0; i < tasks.size(); i++) {
  171.                 File f = tasks.get(i);
  172.                 if (scannedFiles.contains(f.getAbsolutePath())) continue;
  173.                 scannedFiles.add(f.getAbsolutePath());
  174.  
  175.                 try {
  176.                     BufferedReader brin = new BufferedReader(new FileReader(f));
  177.                     StringBuffer buf = new StringBuffer();
  178.                     String line = null;
  179.  
  180.                     while ((line = brin.readLine()) != null)
  181.                         buf.append(line).append("\n");
  182.                     brin.close();
  183.  
  184.                     //<import resource="tmspring-mappings.xml" />
  185.                     //"<(\\w+:)?import\\s+resource=[\"']([^\"']+)[\"']"
  186.                     List<List<String>> arr_got = new ArrayList<List<String>>();
  187.                     final String fileContent = buf.toString();
  188.                     if (!RegexpUtils.preg_match_all("/<(\\w+:)?import\\s+resource=[\"']([^\"']+)[\"']/im", 
  189.                             fileContent, arr_got))
  190.                         continue;
  191.  
  192.                     for (List<String> stringList : arr_got) {
  193.                         tasks.add(new File(f.getParentFile(), stringList.get(2)));
  194.                     }
  195.                 } catch (IOException e) {
  196.                     e.printStackTrace();
  197.                 }
  198.             }// scan all tasks
  199.         }// if scanned files arrays is empty
  200.  
  201.         final long contextStarted = applicationContext.getStartupDate();
  202.         for (String scannedFile : scannedFiles) {
  203.             File f = new File(scannedFile);
  204.             final long lastModified = f.lastModified();
  205.             if (lastModified > contextStarted) {
  206.                 logger.info("SpringReloadBean -- found newer file '" + scannedFile + 
  207.                       "' modified at '" + new Date(lastModified) + "' vs. context startupDate '" +
  208.                       new Date(contextStarted) + "'");
  209.                 return true;
  210.             }
  211.         }
  212.  
  213.         return false;
  214.     }
  215. }
Теперь код бина Tiles2ReloadBean:
  1. package blz.server.utils;
  2.  
  3. import org.apache.tiles.TilesContainer;
  4. import org.apache.tiles.access.TilesAccess;
  5. import org.apache.tiles.definition.DefinitionsFactory;
  6. import org.apache.tiles.definition.DefinitionsFactoryException;
  7. import org.apache.tiles.definition.ReloadableDefinitionsFactory;
  8. import org.apache.tiles.impl.BasicTilesContainer;
  9. import org.springframework.beans.BeansException;
  10. import org.springframework.context.ApplicationContext;
  11. import org.springframework.context.ApplicationContextAware;
  12. import org.springframework.context.ApplicationEvent;
  13. import org.springframework.context.ApplicationListener;
  14. import org.springframework.context.event.ContextClosedEvent;
  15. import org.springframework.context.event.ContextRefreshedEvent;
  16. import org.springframework.web.context.ServletContextAware;
  17. import org.springframework.web.context.support.AbstractRefreshableWebApplicationContext;
  18.  
  19. import javax.servlet.ServletContext;
  20. import java.util.Date;
  21. import java.util.Timer;
  22. import java.util.TimerTask;
  23. import java.util.logging.Logger;
  24.  
  25. /**
  26.  * Сервисный бин. Назначением которого является автоматическая перегрузка контекста tiles2
  27.  */
  28. public class Tiles2ReloadBean implements ApplicationContextAware, 
  29.                 ApplicationListener, ServletContextAware {
  30.  
  31.     Logger logger = Logger.getLogger(Tiles2ReloadBean.class.getName());
  32.     /**
  33.      * Интервал, через который нужно выполнять перегрузку контекста (в ms.)
  34.      */
  35.     Integer interval = 2000;
  36.  
  37.     public void setInterval(Integer interval) {
  38.         if (interval <= 100)
  39.             throw new IllegalArgumentException("too low delay for Tiles2ReloadBean.interval (min value 100 ms.)");
  40.         this.interval = interval;
  41.     }
  42.  
  43.     /**
  44.      * Свойство признак того, что сообщения в log будут выводиться только в тех случаях когда произошли ошибки или
  45.      * контекст был действительно перезагружен
  46.      */
  47.     Boolean logOnlyIfErrorsOrReloadOccuried = true;
  48.  
  49.     public void setLogOnlyIfErrorsOrReloadOccuried(Boolean logOnlyIfErrorsOrReloadOccuried) {
  50.         this.logOnlyIfErrorsOrReloadOccuried = logOnlyIfErrorsOrReloadOccuried;
  51.     }
  52.  
  53.     ServletContext servletContext;
  54.  
  55.     public void setServletContext(ServletContext servletContext) {
  56.         this.servletContext = servletContext;
  57.     }
  58.  
  59.     public Tiles2ReloadBean() {
  60.         timer = new Timer(true);
  61.         task = new TimerTask() {
  62.             public void run() {
  63.                 ServletContext contextFromWebContext = applicationContext.getServletContext();
  64.                 if (!logOnlyIfErrorsOrReloadOccuried)
  65.                     logger.info("Tiles2ReloadBean -- prepare execute task");
  66.                 // такой странный код обусловлен не менее странным багом, внутри объекта ApplicationContext
  67.                 // вполне может не оказаться ссылки на контекст сервлетов ?
  68.                 if (contextFromWebContext == null && servletContext == null) {
  69.                     logger.severe("Tiles2ReloadBean -- skip execute task, because no servlet context available");
  70.                     return;
  71.                 }
  72.                 if (!logOnlyIfErrorsOrReloadOccuried)
  73.                     logger.info("Tiles2ReloadBean -- ready execute task");
  74.                 TilesContainer container = TilesAccess.getContainer(contextFromWebContext);
  75.                 if (container instanceof BasicTilesContainer) {
  76.                     if (!logOnlyIfErrorsOrReloadOccuried)
  77.                         logger.info("Tiles2ReloadBean -- yes. container instanceof BasicTilesContainer");
  78.                     BasicTilesContainer basic = (BasicTilesContainer) container;
  79.                     DefinitionsFactory factory = basic.getDefinitionsFactory();
  80.                     if (factory instanceof ReloadableDefinitionsFactory) {
  81.                         if (!logOnlyIfErrorsOrReloadOccuried)
  82.                             logger.info("Tiles2ReloadBean -- yes. factory instanceof ReloadableDefinitionsFactory");
  83.                         ReloadableDefinitionsFactory rFactory = (ReloadableDefinitionsFactory) factory;
  84.                         if (rFactory.refreshRequired()) {
  85.                             logger.info("Tiles2ReloadBean -- yes. context refreshed");
  86.                             try {
  87.                                 rFactory.refresh();
  88.                             } catch (DefinitionsFactoryException e) {
  89.                                 e.printStackTrace();
  90.                             }
  91.                         } else {
  92.                             if (!logOnlyIfErrorsOrReloadOccuried)
  93.                                 logger.info("Tiles2ReloadBean -- skip. no refresh is required");
  94.                         }
  95.                     }
  96.                 }
  97.             }
  98.         };
  99.     }
  100.  
  101.     private TimerTask task;
  102.     private Timer timer = new Timer(true);
  103.  
  104.  
  105.     AbstractRefreshableWebApplicationContext applicationContext;
  106.  
  107.     public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
  108.         logger.info("Tiles2ReloadBean.setApplicationContext got ApplicationContext");
  109.         this.applicationContext = (AbstractRefreshableWebApplicationContext) applicationContext;
  110.     }
  111.  
  112.     /**
  113.      * А так нас извещают о том, что контекст spring был запущен или остановлен
  114.      *
  115.      * @param event
  116.      */
  117.     public void onApplicationEvent(ApplicationEvent event) {
  118.         if (event instanceof ContextRefreshedEvent) {
  119.             applicationContext = (AbstractRefreshableWebApplicationContext) event.getSource();
  120.             logger.info("Tiles2ReloadBean.onApplicationEvent on ContextRefreshedEvent");
  121.             timer.schedule(task, new Date(new Date().getTime() + interval), interval);
  122.         }
  123.         if (event instanceof ContextClosedEvent) {
  124.             applicationContext = null;
  125.             logger.info("Tiles2ReloadBean.onApplicationEvent on ContextClosedEvent");
  126.             task.cancel();
  127.             timer.purge();
  128.         }
  129.     }
  130.  
  131.  
  132. }
Для того чтобы выполнять сканирование xml-файлов spring на предмет подключенных ресурсов с помощью regexp-ов я использую свою небольшую библиотечку-надстройку над java regexp-ами (я ее описал недавно в статье Regexp-ы для java точь в точь как для php). Так что, скопируйте код оттуда, или перепишите regexp-ы под java classic.