понедельник, 30 июля 2012 г.

Smarty и рассылка писем

"Smarty это - движок шаблонов (шаблонизатор) для PHP. Точнее, он предоставляет легко управляемый способ отделения логики и содержания приложения от его внешнего вида." ©документация.

Ещё не встречал случаев, когда в приложениях просто необходимо отделять логику от отображения. Возможно мне не приходилось работать с такими большими задачами. В моей практике я использую Smarty немного иначе, что можно понять из заголовка.

Установка Smarty подробно описана в документации. Только отмечу, что гораздо удобнее использовать продвинутую установку, т.е. создать свой класс, расширяющий класс Smarty и задающий начальные установки:

require_once('Smarty-X.X.XX/libs/Smarty.class.php');


class MySmarty extends Smarty
{
 function __construct()
 {
  parent::__construct();

  $this->template_dir = CWD . '/modules/Smarty-X.X.XX/templates/';
  $this->compile_dir = CWD . '/modules/Smarty-X.X.XX/templates_c/';
  $this->config_dir = CWD . '/modules/Smarty-X.X.XX/configs/';
  $this->cache_dir = CWD . '/modules/Smarty-X.X.XX/cache/';

  //$this->caching  = Smarty::CACHING_OFF;
  //$this->debugging  = true;
  //$this->error_reporting = E_ALL;
 }
}

$Smarty = new MySmarty();

При обычном использовании шаблоны Smarty отображаются функцией display() :

$Smarty->assign('lang_code', 'RU');
$Smarty->assign('curr_date', date('d.m.Y') );

$Smarty->display('test_templ.tpl');

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

Я решил попробовать использовать Smarty для этой цели. Как оказалось функции display() и fetch() используют понятие ресурс: в первом параметре, перед основным значением можно вставить строку "string: " и тогда остальное значение будет воспринято как текст шаблона вместо пути к файлу. И в сочетании с функцией fetch(), возвращающей результат в виде строки, это всё что нужно для моей задачи.

В этом схематичном примере сначала Smarty переменные в тексте заменяются нужными значениями, затем текст вставляется в подготовленный заранее шаблон с HTML:

set_time_limit(0);

require_once(CWD . '/modules/Smarty.php');

while (true)
{
 $res = db_query('...');

 while ($row = db_fetch($res))
 {
  $Smarty->assign('FirstName',  $row['first_name']);
  $Smarty->assign('LastName',  $row['last_name']);

  $text_ready  = $Smarty->fetch('string: ' . $row['text']);
  $Smarty->assign('text',  $text_ready);

  $mail_body  = $Smarty->fetch(CWD . 'mail/generic_templ.tpl');


  mail($row['to'], $row['subj'], $mail_body);

  db_query('...'); // set mail as sent

  $Smarty->clearAllAssign();
 }
}

Помимо "string: " для работы со строками есть ещё "eval: " отличающийся только тем, что шаблон перекомпилируется каждый раз заново.

понедельник, 23 июля 2012 г.

Социальная Flash-игра — Начало

Уже довольно давно я мечтаю о том, чтобы создать игру. Мною было предпринято несколько попыток, которые так и остались наполовину начатыми. Поэтому теперь в приоритете довести проект до завершения.

(скриншот из игры "Game Dev Story")

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

Это будет онлайн-игра для социальных сетей (предположительно ВКонтакте). В качестве платформы я беру Flash, как наиболее распространённую на десктопах.

Сюжет не будет оригинальным и будет напоминать «фермы», потому что сейчас для меня важна техническая сторона и не менее важно довести разработку до завершения.

На первом этапе я хочу выполнить программу-минимум:
  1. игровое поле (скроллинг, зум);
  2. юниты (выделить, направить);
  3. здания (выбрал в меню, построил).
Думаю что потом уже можно будет думать о механике самой игры, хотя допускаю что я ошибаюсь.

Помимо клиентской, есть ещё и серверная часть. Судя по всему PHP для этого не очень подходит, но я буду делать сервер на PHP, потому что хорошо с ним знаком. Позднее, если возникнут проблемы использую что-нибудь другое, например Java или Node.js.

Ах да..., нужно ещё всё красиво нарисовать. По ходу дела буду рисовать (искать арт) сам, потом буду решать этот вопрос отдельно.

Мой опыт с Flash мнимален, поэтому придётся учиться походу дела. В качестве инструментов разработки я выбрал:
  1. as3isolib, просто потому что эта библиотека попалась мне первой, она бесплатна, у неё есть документация и, главное, есть пример с открытым кодом: Anggie Bratadinata's Adobe Camp Jakarta Demo (Thanks Anggie!!!);
  2. FlashDevelop — взятый мною пример использования as3isolib был создан с помощью этого редактора, он бесплатен.
Думаю, что более осознанный выбор инструментов я сейчас не смогу сделать в виду отсутствия опыта.

Установка инструментов проблем не создаёт. Беру демо Anggie и начинаю. Посмотрим что получится.

О каждом пройденном пунтке я буду писать отчёт.

вторник, 17 июля 2012 г.

PHP модуль Sockets для HTTP запросов

По работе мне потребовалось использовать удалённое API для получения информации об IP клиентов. В PHP я нашёл два варианта как можно выполнить запрос на удалённый сервер: Sockets и cURL. Так как с cURL я был уже знаком, решил узнать что такое Sockets.

Насколько я понял - Сокет это просто точка соединения. Есть разные типы Сокетов, наиболее распространённые (и реализованные в PHP) это AF_INET для интернет-соединений и AF_UNIX для коммуникаций между процессами ОС.

Это мой первый опыть работы с Sockets, поэтому наверняка есть какие-то недочёты в моём решении. Плюс к этому - моя задача, на данный момент, ограничена работой с HTTP-запросами: отправить Get или Post, и считать ответ.

Первым делом, я взял один из примеров с документации по PHP и начал работу с него. Принцип работы с этим модулем следующий:

// создание сокета
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);

// подключение созданного сокета
socket_connect($socket, $address, $service_port);

// запись в сокет запроса
socket_write($socket, $in, strlen($in));

// считывание ответа фиксированными порциями
while ($out = socket_read($socket, 2048))
{
}

// закрытие
socket_close($socket);

Основная сложность в том, что запрос нужно выполнять с помощью HTTP-заголовка и ответ также приходит с HTTP заголовком, который нужно сначала выделить а потом разобрать нужные поля.

Необходимую информацию о заголовках я нашёл тут:
, и официальный ресурс (которым я почти не воспользовался):

У меня получился следующий класс:

if (!defined('NL'))
{
 define('NL', "\r\n");
 define('DNL', NL . NL);
}


class HttpSocket
{
 private $socket;

 private $host_str;
 private $query_str;
 private $ip_addr;

 private $resp_code;


 public function __construct()
 {
  $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);

  if ($this->socket === false)
   trigger_error('socket_create() failed: reason: ' . socket_strerror(socket_last_error()), E_USER_ERROR);
 }


 public function reqGet($addr)
 {
  $this->parseAddr($addr);

  $header = 'GET ' . $this->query_str . ' HTTP/1.1' . NL;
  $header .= 'Host: ' . $this->host_str . NL;
  $header .= 'Connection: Close' . DNL;

  return $this->doQuery($header);
 }


 public function reqPost($addr, $var_arr = array())
 {
  $this->parseAddr($addr);
  $req_str = http_build_query($var_arr);

  $header = 'POST ' . $this->query_str . ' HTTP/1.1' . NL;
  $header .= 'Host: ' . $this->host_str . NL;
  $header .= 'Content-Type: application/x-www-form-urlencoded' . NL;
  $header .= 'Content-Length: ' . strlen($req_str) . NL;
  $header .= 'Connection: Close' . DNL;

  $header .= $req_str;

  return $this->doQuery($header);
 }


 public function getRespCode()
 {
  return $this->resp_code;
 }


 public function __destruct()
 {
  socket_close($this->socket);
 }


 private function parseAddr($addr)
 {
  // http://hostname.com/query/string?v=1
  // http://192.168.241.125/query/string?v=1

  preg_match('/^(?:https?:\/\/)?([^\/]+)(.*)/', $addr, $matches);

  if (filter_var($matches[1], FILTER_VALIDATE_IP))
  {
   $this->ip_addr = $matches[1];

  } else {

   $this->host_str = $matches[1];
   $this->ip_addr = gethostbyname($this->host_str);
  }

  $this->query_str = strlen($matches[2]) ? $matches[2] : '/';
 }


 private function doQuery($header)
 {
  $service_port = getservbyname('www', 'tcp');
  $result   = socket_connect($this->socket, $this->ip_addr, $service_port);

  if ($result === false)
   trigger_error('socket_connect() failed. Reason: (' . $result . ') ' . socket_strerror(socket_last_error($this->socket)), E_USER_ERROR);

  socket_write($this->socket, $header, strlen($header));

  $resp_str = $this->doFetch();
  $resp_bdy = $this->parseResponse($resp_str);

  return $resp_bdy;
 }


 private function doFetch()
 {
  $resp = '';

  while ($out = socket_read($this->socket, 2048))
   $resp .= $out;

  return $resp;
 }


 private function parseResponse($resp_str)
 {
  $pos_cr  = strpos($resp_str, "\n\n");
  $pos_crlf = strpos($resp_str, "\r\n\r\n");

  if ($pos_cr !== false AND $pos_cr < $pos_crlf)
  {
   $sep_pos = $pos_cr;
   $sep_len = 2;

  } else {

   $sep_pos = $pos_crlf;
   $sep_len = 4;
  }

  $head = substr($resp_str, 0, $sep_pos);

  preg_match('/HTTP[^\s]+\s(\d+)/i', $head, $matches);
  $this->resp_code = $matches[1];

  preg_match('/Content-Length:\s(\d+)/i', $head, $matches);
  $length = intval($matches[1]);

  $body = substr($resp_str, $sep_pos + $sep_len, $length);

  return $body;
 }
}

Функции __construct() и __destruct() соответственно создают и закрывают сокет. Функции reqGet() и reqPost() получают URL в качестве аргумента и выполняют соответствующие запросы.

Пару слов о HTTP заголовках:
  1. Строка "Host: ..." сообщает на какой именно домен идёт запрос (на случай если на одном IP несколько доменов).
  2. Строка "Connection: Close" сообщает что соединение не должно считаться постоянным.
Ещё немного напишу о процессе разбора ответа в функции parseResponse():
  1. Сначала нужно отделить заголовок от основного сообщения, для этого ищу двойную новую строку. Это может быть как "\r\n\r\n", так и просто "\n\n".
  2. Далее сохраняю код ответа и выбираю информацию о длинне сообщения (Content-Length).
После этого уже выделяю само сообщение.

вторник, 10 июля 2012 г.

PHP скрипт как Windows сервис

"Win32service - это специфическое расширение для Windows, которое позволяет PHP взаимодействовать с Менеджером Контроля Сервисов (Service Control Manager) для запуска, остановки, регистрации и удаления сервисов, и даже позволяет вашему PHP скрипту работать как сервису." документация PHP.

Установка расширения Win32service

Для установки этого расширения предлагается его скомпилировать из исходников. Но в комментариях документации к разделу об установке можно найти ссылки на готовые .dll:


Готовое расширение от stealth35 у меня не заработало. DLL по второй ссылке, хоть и устаревшая (2009 года), но работает и для моих нужд вполне подошла.

Скачиваем нужную библиотеку, в моём случае это "php_win32service-svn-20091011-5.3-vc6-x86.zip",  и подключаем её к PHP.

Построение скрипта

Прежде всего нужно придумать имя сервиса (для команд, без пробелов) и название для отображения в списке сервисов.
$service_name = 'win_service';
$display_name = 'Test windows service';
Создаваемый скрипт будет выполняться через комнадную строку, и будет получать один аргумент для управления.

Код управления сервисом и его полезная часть (та, что выполняет работу) в нашем случае будет находится в одном и том же файле. Наш сервис может получать следующие команды:
  • install
  • uninstall
  • start
  • stop
при выполнении этих команд передаётся сообщение указанному сервису и дальше работа скрипта прерывается.  Есть ещё один аргумент:
  • run
он сообщает что сервис запущен и надо что-то делать. При получении "run" скрипт начинает выполнять свою полезную часть.
define('NL', "\n\n");
define('DNL', NL . NL);


if (!isset($argv[1]))
{
 echo 'no argument' . DNL;
 exit;
}


switch ($argv[1])
{
 case 'install':

  $result = win32_create_service(array(
       'service' => $service_name,
       'display' => $display_name,
       'params' => '-f ' . __FILE__ . ' run',
       'path'  => 'php',
       ));
  var_dump($result);
  echo 'Service Installed' . DNL;
  exit;

 case 'uninstall':

  $result = win32_delete_service($service_name);
  var_dump($result);
  echo 'Service Removed' . DNL;
  exit;

 case 'start':

  $result = win32_start_service($service_name);
  var_dump($result);
  echo 'Service Started' . DNL;
  exit;

 case 'stop':

  $result = win32_stop_service($service_name);
  var_dump($result);
  echo 'Service Stopped' . DNL;
  exit;

 case 'run':

  break;

 default:

  echo 'unknown argument' . DNL;
  exit();
}
Описание функций, параметров и возвращаемых значений можно посмотреть в документации к PHP.

Всё что нужно сделать во второй части это
  1. Зарегистрироваться как сервис.
  2. Выполнять свой код, периодически проверяя поступившие сообщения.
  3. Отвечать на полученные сообщения.
Мой скрипт будет обрабатывать только два сообщения: запрос на статус и остановка.
set_time_limit(0);


if (!win32_start_service_ctrl_dispatcher($service_name))
 exit('Cannot register script as service: "' . $service_name . '"');

win32_set_service_status(WIN32_SERVICE_RUNNING);


// main loop
while (1)
{
 // Handle Windows Service Request
 switch (win32_get_last_control_message())
 {
  case WIN32_SERVICE_CONTROL_INTERROGATE: // Report its current status to the SCM

   win32_set_service_status(WIN32_NO_ERROR);
   break;

  case WIN32_SERVICE_CONTROL_STOP: // Service should stop

   win32_set_service_status(WIN32_SERVICE_STOPPED);
   break(2);

  default:

   win32_set_service_status(WIN32_ERROR_CALL_NOT_IMPLEMENTED);
 }


 // Полезный код сервиса
 // ...
 // ...
}


// set appropriate status
win32_set_service_status(WIN32_SERVICE_STOPPED);
Это весь код. Дополнительно можно обрабатывать сообщения "Пауза", "Продолжение" (снятие паузы), "Выключение" (ОС).

Установка, работа

Добавляю запись в лог для проверки работы. Результат полностью:
$service_name = 'win_service';
$display_name = 'Test windows service';


define('NL', "\n\n");
define('DNL', NL . NL);


function dumpToFile($variable)
{
 $sep = "\r\n";
 $handle = fopen('d:\\Dev\\htdocs\\win_service\\log.log', 'a+');

 fwrite($handle, '[' . date('H:i:s') . '] ');

 ob_start();
 print_r($variable);
 $printed = ob_get_clean();

 fwrite($handle, $printed . $sep);
 fclose($handle);
}


if (!isset($argv[1]))
{
 echo 'no argument' . DNL;
 exit;
}


switch ($argv[1])
{
 case 'install':

  $result = win32_create_service(array(
           'service' => $service_name,
           'display' => $display_name,
           'params' => '-f ' . __FILE__ . ' run',
           'path'  => 'php',
           ));
  var_dump($result);
  echo 'Service Installed' . DNL;
  exit;

 case 'uninstall':

  $result = win32_delete_service($service_name);
  var_dump($result);
  echo 'Service Removed' . DNL;
  exit;

 case 'start':

  $result = win32_start_service($service_name);
  var_dump($result);
  echo 'Service Started' . DNL;
  exit;

 case 'stop':

  $result = win32_stop_service($service_name);
  var_dump($result);
  echo 'Service Stopped' . DNL;
  exit;

 case 'run':

  break;

 default:

  echo 'unknown argument' . DNL;
  exit();
}


set_time_limit(0);


if (!win32_start_service_ctrl_dispatcher($service_name))
 exit('Cannot register script as service: "' . $service_name . '"');

win32_set_service_status(WIN32_SERVICE_RUNNING);


// main loop
while (1)
{
 // Handle Windows Service Request
 switch (win32_get_last_control_message())
 {
  case WIN32_SERVICE_CONTROL_INTERROGATE: // Report its current status to the SCM

   win32_set_service_status(WIN32_NO_ERROR);
   break;

  case WIN32_SERVICE_CONTROL_STOP: // Service should stop

   win32_set_service_status(WIN32_SERVICE_STOPPED);
   break(2);

  default:

   win32_set_service_status(WIN32_ERROR_CALL_NOT_IMPLEMENTED);
 }


 // Полезный код сервиса
 // ...
 // ...

 dumpToFile('working'); sleep(20);
}


// set appropriate status
win32_set_service_status(WIN32_SERVICE_STOPPED);
Команды управления сервисом выглядят так:

1. установка
php -f d:\Dev\htdocs\win_service\service.php install

2. запуск
php -f d:\Dev\htdocs\win_service\service.php start

3. остановка
php -f d:\Dev\htdocs\win_service\service.php stop

4. удаление
php -f d:\Dev\htdocs\win_service\service.php uninstall

Также нашим сервисом можно управлять через Control Panel -> Administrative Tools -> Services. Там же можно установить тип запуска (Автоматический / Вручную).

среда, 4 июля 2012 г.

Как создать постоянно работающий PHP скрипт

Иногда нужно сделать так, чтобы скрипт постоянно был активен, ожидая новых задач (в моём случае это рассылка писем).

Скрипт нельзя запустить один раз и надеяться, что он будет работать без сбоев. Поэтому в планировщике задачь (cron) устанавливается интервал на ежеминутный запуск (или реже, в зависимости от требований), а все проверки остаются на совести программы.

Для того чтобы скрипт мог проверять сам себя (запущен ли процесс, не завис ли), я использую proc-файл - временный файл создаваемый скриптом. Мой proc-файл содержит только одно число - PID (ID процесса), записанное туда при первом запуске программы. Также из этого файла можно узнать время его модификации, что даёт возможность прервать запущенный процесс, если он выполняется слишком долго.

Я уже давно использую этот подход, но теперь, когда тоже самое потребовалось сделать под Windows - я решил написать для этого отдельный класс.

Класс для работы с proc-файлом

class ProcFile
{
 private static $file_name = 'php_job.proc';
 private static $file_ttl = 1200; // (sec) after what time the process considered "hanging"


 public static function setName($file_name_)
 {
  self::$file_name = $file_name_;
 }


 // check if process is already ON
 public static function isCurrent()
 {
  $proc_file = self::getPath();

  if (!file_exists($proc_file))
   return false;

  $pid = intval( file_get_contents($proc_file) );

  if (!self::checkIfRunning($pid))
   return false;

  // if process is hanging - it should be stoped
  if ((time() - filemtime($proc_file)) > self::$file_ttl)
  {
   self::killProcess($pid);
   return false;
  }

  return true;
 }


 // creates / overwrites proc-file
 public static function put()
 {
  file_put_contents(self::getPath(), getmypid());
 }


 // updates file modification time
 public static function update()
 {
  touch(self::getPath());
 }


 // proc-file full path
 private static function getPath()
 {
  return sys_get_temp_dir() . self::$file_name;
 }


 private static function checkIfRunning($pid)
 {
  $os_name = php_uname('s');
  $found  = false;

  switch ($os_name)
  {
   case 'Windows NT':

    $processes = explode("\n", shell_exec('tasklist.exe'));

    foreach ($processes as $process)
    {
     if (!strlen($process) OR !preg_match('/(.*?)(\d+).*$/', $process, $matches))
      continue;

     if ($matches[2] == $pid)
     {
      $found = true;
      break;
     }
    }
    break;

   case 'FreeBSD':

    $output = shell_exec('ps ax | grep \'^[[:space:]]*' . $pid . '\'');
    $found = (bool) strlen($output);
    break;

   default:

    trigger_error('Unknown OS "' . $os_name . '" in "' . __METHOD__ . '"');
  }

  return $found;
 }


 private static function killProcess($pid)
 {
  $os_name = php_uname('s');
  $killed  = false;

  switch ($os_name)
  {
   case 'Windows NT':

    shell_exec('taskkill /F /PID ' . $pid);
    $killed = true;
    break;

   case 'FreeBSD':

    shell_exec('kill ' . $pid);
    $killed = true;
    break;

   default:

    trigger_error('Unknown OS "' . $os_name . '" in "' . __METHOD__ . '"');
  }

  if ($killed)
   trigger_error('Previous sender instance is taking too long, killing...; ' . __FILE__ . ', ' . __LINE__, E_USER_NOTICE);
 }
}

Принцип работы

// Указать имя файла
ProcFile::setName('mail_send.proc');

// Проверить текущее состояния proc-файла, и действовать соотвественно

if (ProcFile::isCurrent())
{
 echo 'let the previous instance to work' . NL;
 exit;

} else {

 echo 'start anew' . NL;
 ProcFile::put();
}

while (true)
{
 $res = db_query(...);

 while ($row = db_fetch($res))
 {
  // работа скрипта

  // обновить время модификации proc-файла
  ProcFile::update();
 }

 // обновить время модификации proc-файла
 ProcFile::update();

Как запускать скрипт в Windows


Можно создать ярлык вида:

"C:\Program Files\PHP\php.exe" -f с:\htdocs\newsletters\send.php

и поместить его в папку Control Panel -> Scheduled Tasks. Затем два раза кликнув по ярлыку, можно настроить время запуска. Но проблема в том, что при запуске скрипта будет появляться окно cmd.exe, и я не нашёл способа от него избавиться.

Есть ещё вариант запускать скрипт как Windows-сервис, но это отдельная тема.

В моём случае скрипты на Windows-сервере запускаются с помощью сторонней программы подобия cron.

Как запускать скрипт в FreeBSD / Linux


Тут процесс отработанный. В crontab записывается строка вида:

*/1 * * * * user php -f /usr/script/send.php

, и скрипт запускается каждую минуту. За более полной информацией можно обратиться в документацию - http://crontab.org/.