Pull to refresh

Горячая замена кода (code hot swapping) в РНР

Reading time 10 min
Views 3K
Погода за окном просто требует чего-то горяченького, поэтому воспользовавшись возможностью что-то по исследовать в свободное время, я решил подумать — а можно ли не останавливая скрипт, подменить функцию, которая выполняется? С таким требованием я встретился чуть ранее, при разработке нашего стартапа. У нас был один из внутренних серверов, который заведовал всеми действиями между пользователями в реальном времени. Это обычный РНР-демон-роутер, который обрабатывал запросы от клиентских запросов (внутри сервера), но была одна сложность — в случае, когда я что-либо изменял в коде сервера или обработчиков отдельных команд, демон приходилось перезагружать, что означало отключение текущих клиентов и потеря информации о состоянии сервера (этот вопрос решаемый, конечно). То же самое было в случае ошибки в коде — все подключенные пользователи сразу это чувствовали на себе (хорошо, что все они такие же разработчики, а не реальные клиенты). Можно ли этого избежать?

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

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

Существуют языки, в которых горячая замена программного кода встроена на уровне языка или платформы. Самым известным представителем будет, наверное вы уже догадались — Erlang/ OTP. Такая возможность присутствует и в других языках — Smalltalk, Lisp и Java (хоть и с рядом ограничений). В Visual Studio такое возможно при использовании режима отладки и поддерживается языками C#, VB.NET и C/C++. Однако у нас же РНР…

hotswap


Сразу оговорюсь, что это всего лишь эксперимент, проверка самой возможности такого трюка, хотя не исключаю и реального применение. Конечно, быстродействие в таком режиме снижается, однако если накладные расходы на каждый вызов терпимы, мы получим возможность прозрачно обновлять работающий РНР-демон без затрагивания всего кода. Кроме этого, возможно сначала создать каркас приложения, запустить его, а потом уже постепенно дописывать реализацию каждого метода.

Начнем. Первое, что приходит в голову — а что, если просто ещё раз вызвать подключение файла с текстом функции? К сожалению, напрямую это не пройдёт — могут мешать кеширующие акселераторы, но это меньшее из всех проблем. Основное же — РНР генерирует ошибку при попытке подключить функцию с таким же именем, как у существующей на момент подключения. Вывод? Изменять имя нашей функции каждый раз, когда мы обновляем ее код, например, добавляя суффикс с номером версии: если первоначальная функция имеет вид function _my_test(), то в случае правки новую функцию уже необходимо назвать _my_test_v1(). Но ведь остальной код никак не знает, что мы подключили новую реализацию.

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

А позже пришла в голову мысль как еще более усовершенствовать методику определения нового метода. Пусть он сам сообщает остальному коду, что обновлен — ведь кто, кроме самой функции знает об этом лучше всех?

На помощь пришла возможность задавать возвращаемое значение в файле исходного кода, который подключается при помощи include/require. Через return можно передать и получить в момент подключения любую информацию. В том числе и таблицу определённых функций и их версии, а также алиасы — чтобы скрыть конкретное имя версии мы используем алиас, который внутри уже разрешается в конкретное имя функции, которая вызывается.

Дальше нам поможет еще одно волшебное свойство РНР — а именно, __call() — магический метод у объектов, который вызывается в случае, если мы обратимся к неизвестному методу класса. А теперь делаем так — определяем специальный прокси-класс, который имеет только один метод — этот самый __call(). В окружающем коде все вызовы функций, которые изменяются без перезагрузки скрипта, переписываются на вызовы методов нашего прокси-класса. Конечно, вызываются алиасы реальных методов. Это значит, что сколько бы мы не изменяли название метода, _my_test1, _my_test99, в своем коде просто обращаемся к общему алиасу — _my_test, который скрывает реальный вызов.

Метод __call при вызове проверяет первым делом, обновился ли файл с момента последнего обращения. Для этого сначала очищаем внутренний кеш информации файловой системы (функцией clearstatcache), потом получаем метку времени изменения файла через filemtime. Если файл обновлён, необходимо его подключить через require. Здесь важно — файл возвратит нам таблицу определенных там функций и их версий, поэтому мы сначала проверим, реализован ли там необходимый нам метод. Имя запрашиваемого метода передается в магический метод __call первым параметром, поэтому достаточно просто проверить наличие такого ключа в полученном массиве. Если он есть, тогда проверим номер версии текущего кода и подключённого. Версией у нас служит целое или дробное число — так, чтобы можно было простым сравнением выяснить, какая версия больше. Конечно, версии должны монотонно возрастать при каждой правке исходного кода функции.

Если мы нашли нужную функцию и ее версия больше текущей, выполняем. В переданном массиве содержится строка с реальным именем новой функции, поэтому необходимо вызвать ее через call_user_func, передав в качестве параметра реальное имя новой функции и параметры, с каким вызывался метод прокси-класса. Перед этим обязательно сохраним внутри прокси всю информацию — метку времени подключённого файла, таблицу функций и их версий. Теперь у нас будет таблица о том, какие версии функций актуальны в текущий момент и мы сможем при следующем вызове просто сравнить их для решения, что же вызывать. Заметьте, файл с исходным кодом подключается только если был изменен, но если вы не измените имя функций, будет ошибка (Fatal error: Cannot redeclare _myfn_1() (previously declared in...), так как РНР увидит, что мы пытаемся подключить уже существующую функцию. Поэтому обязательное требование — при каждом изменении файла изменять имена функций и увеличивать номер версий. Проблемой может быть определение нескольких функций в одном файле — придется переименовывать все методы вне зависимости от того, изменялся ли реально их код. Однако если переименовать только функцию, а в таблице методов ничего не трогать, то все ОК, вызываться будет все равно предыдущий метод, который уже есть в памяти интерпретатора. Так что менять имя в таблице функций можно только тогда, когда она реально изменилась.

Вот так можно подменять методы в работающем демоне на РНР без необходимости его остановки. При каждом вызове метода будет проверяться, не было ли изменений кода и если что-то поменялось, пытаемся вызвать новую версию функции.

Хотелось бы на глазок оценить потери производительности на проверки. Это никак не реальный тест, просто ради «оценки на глаз».

Пример обычного вызова:
  1. class Test_1
  2. {
  3.   public function _myFn($mv){
  4.     
  5.     $_rand = rand(0, 99999999);
  6.     $_arr = get_defined_functions();
  7.     
  8.     return sha1($_rand . $mv . implode(',', $_arr['internal']));
  9.   }
  10. }
  11. $i = 0;
  12. $max = 10000;
  13. $_st = microtime(true);
  14. $test = new Test_1();
  15. while ($i < $max)
  16. {
  17.   $test->_myFn($i);  
  18.   $i++;
  19. }
  20. $_ft = microtime(true);
  21. echo "\n Total: " . round(($_ft - $_st), 6) . " sec; \n";
  22. echo "Per call: " . round((($_ft - $_st)/$max), 6) . " sec \n";
  23. /*
  24.  * Total: 28.779473 sec;
  25.  * Per call: 0.002878 sec
  26.  */
* This source code was highlighted with Source Code Highlighter.

Без подмены кода во время выполнения (но через наш прокси-класс, с проверками):
  1. /*
  2.  * Total: 28.914997 sec;
  3.  * Per call: 0.002891 sec
  4.  */
* This source code was highlighted with Source Code Highlighter.

А теперь результат с подменой исходника функции во время выполнения (я просто правлю файл и выкладываю его на сервер параллельно с запущенным в консоли скриптом):
  1.  
  2.  /*
  3.   * Total: 32.493496 sec;
  4.   * Per call: 0.00065 sec
  5.   */
* This source code was highlighted with Source Code Highlighter.

Хоть приведенная оценка времени одного вызова увеличилась почти в два раза, но общее время выполнение того же теста (вызов 10 тыс. раз метода) увеличилось незначительно — на примерно 13 процентов. Вероятно потому, что за время теста метод был подменен только раз, все до и последующие выполнения в цикле были без обращения к файлу с кодом, поэтому основную роль в замедлении будет играть частота обновления исходных кодов и интенсивность их вызова внутри приложения. Но для обычных случаев, когда код выполняется относительно редко (то есть время измеряемое секундами между вызовами), а сами вычисления внутри заменяемого кода более-менее значительны (например, получение файла с удалённого сервера), потери будут почти незаметны, а вот гибкость приложения вырастет.

Конечно, есть много нюансов и возражений — да и в реальной практике отказоустойчивых систем я бы осторожно применял такое, но факт остается фактом — горячая замена кода в РНР-приложениях вполне возможна! Что и хотелось выяснить.

P.S. А вот и сам исходный код: прокси класса и подключаемого файла с функцией:

  1.  
  2.  /*
  3.   * Прокси-класс и тестовый код
  4.   */
  5.  
  6.  
  7.  class HotCode
  8. {
  9.   protected $_current_v = null; //текущая версия кода
  10.   protected $_current_timestamp = 0; //метка времени той функции, котрая исполняется
  11.   protected $_current_fn = null; //имя текущей функции
  12.   public function __call($fnmame, $fparam)
  13.   {
  14.     if (($this->_current_fn != null) && ($this->_current_timestamp != null))
  15.     {
  16.       echo $this->_current_fn . " from " . date('j.m.Y H:i s', $this->_current_timestamp) . " is current function \n";
  17.     }
  18.           
  19.       clearstatcache();
  20.       
  21.       //проверим, может есть текущий код?
  22.       $_file_last = filemtime('/var/www/deamon/hot_swap_fn.php');
  23.       
  24.       if ($_file_last === FALSE)
  25.       {
  26.         //что=то не так
  27.         throw new Exception('Including file does not exist');
  28.       }
  29.       
  30.       echo "Last modif of file: " . date('j.m.Y H:i s', $_file_last) . "\n";
  31.       echo "Now: " . date('j.m.Y H:i s', time()) . "\n";  
  32.       
  33.       if ( ($this->_current_v == null) || ( (is_integer($_file_last)) && ($_file_last > $this->_current_timestamp) ) )
  34.       {
  35.         //принудительно подключаем
  36.         $_xfn = require ('hot_swap_fn.php');
  37.         
  38.         //мы получили массив определенных там функций, их версии и алиасы
  39.         if (array_key_exists($fnmame, $_xfn))
  40.         {
  41.           // метод есть
  42.           if ($this->_current_v == null)
  43.           {
  44.             //первый раз, ага
  45.             $this->_current_v = $_xfn[$fnmame]['v']; //запомним версию
  46.             $this->_current_fn = $_xfn[$fnmame]['fn'];  
  47.             $this->_current_timestamp = $_file_last;
  48.           }
  49.           else
  50.           {
  51.             //сравнить версии
  52.             if ( $_xfn[$fnmame]['v'] > $this->_current_v)
  53.             {
  54.               //версия новее
  55.               $this->_current_v = $_xfn[$fnmame]['v']; //запомним версию
  56.               $this->_current_fn = $_xfn[$fnmame]['fn'];
  57.               $this->_current_timestamp = $_file_last;              
  58.             }
  59.             //иначе использую старую          
  60.           }
  61.           
  62.           //Вызвать с параметрами
  63.           if ($this->_current_fn != null)
  64.             return call_user_func($this->_current_fn, $fparam[0]);  
  65.           else
  66.             return $this->defaultEmptyFnCall($fparam); //иначе дефолтная "заглушка"
  67.         
  68.         }
  69.         else
  70.           return $this->defaultEmptyFnCall($fparam);
  71.     
  72.     }
  73.     else
  74.     if ($this->_current_fn != null)
  75.       return call_user_func($this->_current_fn, $fparam[0]);  
  76.     else
  77.       return $this->defaultEmptyFnCall($fparam); //иначе дефолтная "заглушка"
  78.   }
  79.   
  80.   //вызивать если запросили функцию, которой нет
  81.   private function defaultEmptyFnCall($param = null)
  82.   {
  83.     var_dump($param);
  84.   }
  85. }
  86. $_my_hot_class = new HotCode();
  87.  
  88.  
  89. <?php
  90.  
  91. /* Файл: hot_swap_fn.php
  92.  * Подключаемый файл с функцией для Hot swap-а
  93.  */
  94.  
  95. function _myFn_2($param)
  96. {
  97.   $_rand = rand(0, 99999999);
  98.   $_arr = get_defined_functions();
  99.   
  100.   return sha1($_rand . $param . implode(',', $_arr['internal']));
  101. }
  102. return Array(
  103.   '_myFn' => array('v' => 2,
  104.        'fn' => '_myFn_2')
  105. );
  106. ?>
* This source code was highlighted with Source Code Highlighter.
Tags:
Hubs:
+16
Comments 59
Comments Comments 59

Articles