init(); $this->initModuleAsComponent('sphinx', $this->app->corePath('db/sphinx')); } public function init() { parent::init(); if (! static::enabled()) { return; } try { $host = $this->config('sphinx.host', '127.0.0.1', TYPE_STR); $port = $this->config('sphinx.port', '9306', TYPE_STR); $this->pdo = new PDO('mysql:host=' . $host . ';port=' . $port); } catch (PDOException $e) { $error = _t('sphinx', 'Error connecting to SphinxQL database: [host], [msg]', [ 'host' => Request::host(), 'msg' => $e->getMessage(), ]); $this->lastError = $error; //$this->errors->set($error); $this->log($error); } $this->minWordLenght = $this->config('sphinx.min.word.length', 2, TYPE_UINT); } /** * Устанавливаем/получаем ID модуля * @param string|bool $id * @return string */ protected function moduleID($id = false) { if ($id !== false) { $this->moduleID = $id; } return $this->moduleID; } /** * Путь к дополнительным файлам компонента поисковой системы Sphinx * @return string */ protected function dir() { $path = bff()->basePath('files/sphinx'); if (! file_exists($path)) { Files::makeDir($path); } return $path; } /** * Подсистема поиска Sphinx включена * @return mixed */ public static function masterEnabled() { return config::sysAdmin('sphinx.enabled', false, TYPE_BOOL); } /** * Поиск Sphinx включен * @return mixed */ public static function enabled() { return static::masterEnabled(); } /** * Проверка включен ли Sphinx * @return bool */ public function isEnabled() { return static::enabled(); } /** * Проверка выполняется ли индексирование * @return bool */ public function isRunning() { if (! static::enabled()) { return false; } if (empty($this->pdo)) { return false; } $indexed = $this->db->select_data(static::TABLE, 'indexed', ['module' => $this->moduleID()]); $indexed = strtotime($indexed); if ($indexed && (time() - $indexed < 90000 /* 25 hours */)) { return true; } return false; } /** * Префикс индексов Sphinx * @return string */ public static function prefix() { return config::sysAdmin('sphinx.prefix', '', TYPE_STR); } /** * Путь к системной директории Sphinx * @return string */ public static function path() { return rtrim(config::sysAdmin('sphinx.path', '/var/lib/sphinx/', TYPE_STR), DS) . DS; } /** * Настройки модуля * @param array $settings исходные настройки: [ * 'path' => '', * 'table' => 'bff_sphinx', * 'prefix' => '', * 'charset' => 'utf8', * 'sources' => [], @ref * 'indexes' => [], @ref * ] */ public function moduleSettings(array $settings) { } /** * Версия сфинкса */ public function version() { return config::get('sphinx.version', '3.3.1'); } /** * Вторая версия сфинкса * @return bool|int */ public function isVersion2() { return version_compare($this->version(), '3.0.0', '<'); } /** * Метод формирующий настройки источников данных требуемые для подстановки в файл sphinx.conf * @param bool $fullVersion полная версия (включает в себя секции indexer и searchd) * @return array */ public function configSettings($fullVersion = false) { $prefix = static::prefix(); $db = config::prefixed('db'); $db = array_merge(array( 'type' => 'mysql', 'host' => 'localhost', 'port' => '3306', 'name' => 'name', 'user' => 'user', 'pass' => 'pass', 'charset' => 'utf8', ), (is_array($db) ? $db : [])); $version = $this->version(); $sources = []; $indexes = []; $indexNames = []; $settings = array( 'path' => static::path(), 'table' => static::TABLE, 'prefix' => $prefix, 'charset' => mb_strtolower($db['charset']), 'sources' => &$sources, 'indexes' => &$indexes, 'index_names' => &$indexNames, ); # base source $sources['baseSource'] = [ ':extends' => false, 'type' => $db['type'], 'sql_host' => $db['host'], 'sql_port' => $db['port'], 'sql_db' => $db['name'], 'sql_user' => $db['user'], 'sql_pass' => strtr($db['pass'], [ '!' => '\\!', '#' => '\\#', ]), 'sql_query_pre' => [ 'SET CHARACTER_SET_RESULTS=' . $settings['charset'], 'SET SESSION query_cache_type=OFF', 'SET NAMES ' . $settings['charset'], ], ]; # index template $indexTemplate = [ ':extends' => false, 'mlock' => '0', # Используемые морфологические движки 'morphology' => 'stem_enru', # Кодировка данных из источника 'charset_type' => 'utf-8', # http://sphinxsearch.com/wiki/doku.php?id=charset_tables 'charset_table' => '0..9, @, A..Z->a..z, _, a..z, U+410..U+42F->U+430..U+44F, U+430..U+44F, U+002D', # Из данных источника HTML-код нужно вырезать 'html_strip' => '1', 'html_remove_elements' => 'style, script, code', # *test* 'enable_star' => '1', # Не индексируем части слова (инфиксы) 'min_infix_len' => '0', # Храним начало слова 'min_prefix_len' => '3', # Минимальный размер слова для индексации 'min_word_len' => strval($this->minWordLenght), # Хранить оригинальное слово в индексе 'index_exact_words' => '1', # running -> ( running | *running* | =running ) 'expand_keywords' => '1', ]; if (version_compare($version, '3.0.0', '<')) { $indexTemplate['docinfo'] = 'extern'; } # append charset_table. example for ukrainian - http://sauron.org.ua/post/1175 $extraChars = []; $morphology = []; foreach ($this->locale->getLanguages() as $v) { $extra = $this->locale->getLanguageSettings($v, 'sphinx_extra_chars', false); if ($extra) { $extraChars[] = $extra; } $m = $this->locale->getLanguageSettings($v, 'sphinx_morphology', false); if ($m) { $morphology[] = $m; } } if (! empty($extraChars)) { $charset = explode(',', $indexTemplate['charset_table']); foreach ($extraChars as $v) { if (is_array($v)) { $charset = array_merge($charset, $v); } else { $charset = array_merge($charset, explode(',', $v)); } } $charset = array_map('trim', $charset); $charset = array_unique($charset); $indexTemplate['charset_table'] = join(', ', $charset); } if (! empty($morphology)) { $morphology = array_merge([$indexTemplate['morphology']], $morphology); $morphology = array_unique($morphology); $indexTemplate['morphology'] = join(', ', $morphology); } if (version_compare($version, '2.2.1', '>=')) { unset($indexTemplate['charset_type']); unset($indexTemplate['enable_star']); } $indexes['indexTemplate'] = bff::filter('sphinx.config.index.template', $indexTemplate); # Настройки модулей, плагинов, тем bff()->callModules('sphinxSettings', [$settings]); unset($settings['indexes']['indexTemplate']); if ($fullVersion) { $settings['indexer'] = [ # Лимит памяти, который может использавать демон-индексатор 'mem_limit' => '64M', ]; $settings['searchd'] = [ # Адрес, на котором будет прослушиваться порт 'listen' => '127.0.0.1:9306:mysql41', 'log' => bff()->basePath('files/logs/sphinx.searchd.log'), 'query_log' => bff()->basePath('files/logs/sphinx.query.log'), 'read_timeout' => '5', 'max_children' => '30', 'pid_file' => '/var/run/sphinx/searchd.pid', #'max_matches' => '100000', 'seamless_rotate' => '1', 'preopen_indexes' => '1', 'unlink_old' => '1', 'workers' => 'threads', # for RT to work 'binlog_path' => '/var/lib/sphinx', ]; } return bff::filter('sphinx.config', $settings); } /** * Отложенное формирование файла конфигурации */ public static function configFileCronRebuild() { bff::cronManager()->executeOnce('site', 'sphinxConfigRebuild'); } /** * Формирование файла конфигурации * @param bool $refresh обновить файл * @param bool $fullVersion полная версия файла * @return mixed */ public function configFile($refresh = true, $fullVersion = false) { $settings = $this->configSettings($fullVersion); $template = $this->template('config', $settings); if ($refresh) { $res = file_put_contents($this->app->basePath('config/sphinx.conf'), $template); if ($res) { $this->createFilesForRotateIndexes($settings); } return $res; } else { return $template; } } /** * Формирование или удаление файлов для шел скрипта sphinx_rotate.sh, запускаемого по крону * @param array $settings */ protected function createFilesForRotateIndexes($settings) { if (empty($settings['index_names'])) { return; } $prefixes = ['main', 'delta']; $dir = $this->dir(); # clean old $files = Files::getFiles($dir, '', false, false); foreach ($files as $v) { foreach ($prefixes as $vv) { $p = $vv . '_'; if (mb_substr($v, 0, mb_strlen($p)) === $p) { @unlink($dir . DS . $v); } } } # create new $cnt = 0; $prefix = $settings['prefix'] ?? ''; foreach ($settings['index_names'] as $k => $v) { foreach ($prefixes as $vv) { if (! isset($v[$vv])) { continue; } file_put_contents($dir . DS . $vv . '_' . $k, $prefix . $v[$vv]); $cnt++; } } if ($cnt) { $this->rotateMainIndexes(); } } /** * Сформировать файл для ротации main индексов */ public function rotateMainIndexes() { file_put_contents($this->fileMainRotate(), 1); } /** * Футь к файлу для ротации всех main индексов * @return string */ protected function fileMainRotate() { return $this->dir() . DS . 'mode_main_rotate'; } /** * Сформировать файл для ротации конкретного индекса * @param string $name */ public function rotateIndex($name) { if (empty($name)) { return; } $prefix = static::prefix(); file_put_contents($this->dir() . DS . 'rotate_' . $name, $prefix . $name); } /** * Проверка подсисемой HH работы скрипта /config/sphinx_rotate.sh * @param array $opt */ public function hhCheckMain($opt = []) { $event = 'sphinx_main_rotate'; bff::hh()->resolve($event); if (! static::masterEnabled()) { return; } $main = $this->fileMainRotate(); if (! file_exists($main)) { return; } $time = filemtime($main); if (time() - $time < 600) { // 10 min return; } bff::hh()->warning( $event, _t('system', 'Error Cron task for Sphinx file rotation. File exist: [main]', ['main' => $main], false), array_merge( [ 'class' => get_class($this), 'method' => __FUNCTION__, ], $opt ) ); } /** * Подготовка строки поиска * @param string $query строка поиска * @param array $options @ref опции поиска * @return string */ protected function prepareSearchQuery(string $query, array &$options = []): string { $exact = (mb_stripos($query, '"') === 0); if ($exact) { // escape specials $query = '"' . $this->escape($query, true) . '"'; } else { // http://sphinxsearch.com/docs/current.html#extended-syntax $empty = ['(', ')', '|', '*', '!', "'", '&', '^']; // '"' $query = str_replace($empty, '', $query); // escape special $query = strtr($query, [ '-' => '\-', '@' => '\@', '/' => '\/', '\\' => '\\\\', '=' => '\=', '~' => '\~', '$' => '\$', ]); } // Unify spaces $query = $this->spaces($query); if ($exact) { # поиск точной фразы // http://sphinxsearch.com/blog/2013/07/23/from-api-to-sphinxql-and-back-again/ $options['ranker'] = 'proximity'; } else { $query = str_replace('"', '', $query); # фомируем запрос поиска каждого слова отдельно $words = explode(' ', $query); $tmp = []; foreach ($words as $word) { if (mb_strlen($word) >= $this->minWordLenght) { $tmp[] = "($word | $word*)"; } } if (count($words) > 1) { if (count($tmp) > 0) { $query = ' ( ' . join(' & ', $tmp) . ' ) | ' . $query; } } else { if (count($tmp) > 0) { $query = join(' & ', $tmp); } else { $query = ''; } } } return $query; } /** * Escape query all special chars * @param string $query * @param bool $trimQuotes * @return string */ protected function escape($query, $trimQuotes = true) { if ($trimQuotes) { $query = trim($query, '"'); } $from = array ('\\','(',')','|','-','!','@','~','"','&','/','^','$','=','<'); $to = array ('\\\\','\(','\)','\|','\-','\!','\@','\~','\"','\&','\/','\^','\$','\=','\<'); return str_replace($from, $to, $query); } /** * Unify query spaces * @param string $query * @return string */ protected function spaces($query) { // unify spaces $space = array ("\x00", "\n", "\r", "\x1a", "\t"); $query = str_replace($space, ' ', $query); // special SENTENCE if (mb_strpos($query, 'SENTENCE') !== false) { $query = str_replace('SENTENCE', 'sentence', $query); } return $query; } /** * Обрабатываем (выполняем) SQL запросы * @param string|array $query запросы * @param array|null $bind аргументы * @param int|bool $fetchType PDO::FETCH_NUM, PDO::FETCH_ASSOC, PDO::FETCH_BOTH, PDO::FETCH_OBJ * @param string $fetchFunc * @param array $prepareOptions * @return array|bool */ protected function exec($query, array $bind = null, $fetchType = false, $fetchFunc = 'fetchAll', array $prepareOptions = []) { if (!$this->pdo) { return false; } if ($this->isDebug()) { $debug = [$query, $bind]; } if (is_array($query)) { if (is_null($bind)) { $bind = []; for ($i = 0; $i < count($query); $i++) { $bind[] = null; } } } else { $query = array($query); $bind = array($bind); } $result = false; foreach (array_combine($query, $bind) as $cmd => $arg) { if (is_null($arg)) { $query = $this->pdo->query($cmd); } else { $query = $this->pdo->prepare($cmd, $prepareOptions); if (is_object($query)) { foreach ($arg as $key => $value) { if ( !(is_array($value) ? $query->bindValue($key, $value[0], $value[1]) : $query->bindValue($key, $value, $this->db->type($value)) ) ) { break; } } $query->execute(); } } # Проверяем SQLSTATE на наличие ошибок foreach (array($this->pdo, $query) as $obj) { if ($obj !== false && $obj->errorCode() != PDO::ERR_NONE) { $error = $obj->errorInfo(); $this->log('[SphinxQL Error] ( ' . (@$error[0] . '.' . (isset($error[1]) ? $error[1] : '?')) . ' : ' . (isset($error[2]) ? $error[2] : '') . ' )'); if ($this->isDebug()) { $this->log($debug); $backtrace = debug_backtrace(false); $message = ''; foreach ($backtrace as $v) { if (!empty($v['file']) && !empty($v['line'])) { $message .= "\n
{$v['file']} [{$v['line']}]"; } } $this->log($message); } $this->lastError = $error; return false; } } if ($fetchType !== false || preg_match('/^\s*(?:SELECT|PRAGMA|SHOW|EXPLAIN)\s/i', $cmd)) { $result = $query->$fetchFunc($fetchType); } else { $result = $query->rowCount(); } } return $result; } /** * Получаем несколько строк из таблицы * @param string $query текст запроса * @param array|null $bindParams параметры запроса или null * @param int $fetchType PDO::FETCH_NUM, PDO::FETCH_ASSOC, PDO::FETCH_BOTH, PDO::FETCH_OBJ * @param string $fetchFunc * @return mixed */ protected function select($query, $bindParams = null, $fetchType = PDO::FETCH_ASSOC, $fetchFunc = 'fetchAll') { return $this->exec($query, $bindParams, $fetchType, $fetchFunc); } /** * Выполняем UPDATE запрос * @param string|array $indexName название индекса * @param array $set массив параметров для обновления * @param array $where условия WHERE * @param array $bind параметры запроса * @param array $options: * string|array 'orderBy' порядок сортировки * string|array|int 'limit' условие LIMIT * @return mixed */ protected function update($indexName, array $set, array $where, array $bind = [], array $options = []) { if (is_array($indexName)) { $indexName = array_shift($indexName); } $options['returnQuery'] = true; $query = $this->db->update($indexName, $set, $where, $bind, [], $options); return $this->exec($query['query'], $query['bind']); } /** * Обновляем атрибуты документов * @param array $data атрибуты с новыми данными * @param array $filter фильтр * @param array $indexes список индексов * @param array $options доп. параметры * @return int кол-во затронутых документов */ public function updateAttributes($data, $filter, array $indexes = [], array $options = []) { if (! $this->isRunning()) { return 0; } if (empty($indexes)) { return 0; } $total = 0; foreach ($indexes as $v) { $total += (int)$this->update($v, $data, $filter, (isset($options['bind']) ? $options['bind'] : []), $options); } return $total; } /** * Сформивать метрики для мониторинга * @param $metrics */ public function metrics($metrics) { if (! static::masterEnabled()) { return; } $mainRotate = $this->app->hh()->get('sphinx_main_rotate', false); $mainRotate = empty($mainRotate) ? 1 : 0; $metrics->add('sphinx_main_rotate_up') ->help('Sphinx Rotating sphinx_rotate.sh') ->value($mainRotate) ; $meta = $this->select('SHOW META'); $connectionUp = ! empty($meta) && is_array($meta) && empty($this->lastError); $metrics->add('sphinx_connection_up') ->help('Whether the searchd daemon is up') ->value((int)$connectionUp) ; } }