*
XS 是 XunSearch 的统一缩写, XS 是解决方案而不仅仅针对搜索, 还包括索引管理等
* XS 运行环境要求 PHP 5.2.0 及以上版本, 带有 SPL 扩展
* 如果您的数据包含 utf-8 以外的编码(如: gbk), 则要求安装 mbstring 或 iconv 以便转换编码
* 对于 bool 类型函数/方法若无特别说明, 均表示成功返回 true, 失败返回 false
* 对于致命的异常情况均抛出类型为 XSException 的异常, 应将 xs 所有操作放入 try/catch 区块
* 这只是 XunSearch 项目客户端的 PHP 实现, 需要配合 xunsearch 服务端协同工作
*
*
* 用法简例:
*
* try {
* // 创建 xs 实例 (包含3个字段 id, title, content)
* $xs = new XS('etc/sample.ini');
*
* // 索引管理
* $doc = new XSDocument('gbk');
*
* // 新增/根据主键更新数据
* $doc->id = 123;
* $doc->title = '您好, 世界!';
* $doc->setFields(array('content' => '英文说法是: Hello, the world!'));
* $xs->index->add($doc);
*
* $doc->title = '世界, 你好!';
* $xs->index->update($doc);
*
* $xs->index->del(124); // 删除单条主键为 124 的数据
* $xs->index->del(array(125, 126, 129)); // 批量删除 3条数据
*
* // 正常检索
* // 快速检索取得结果
* // 快速检索匹配数量(估算)
*
* } catch (XSException $e) {
* echo $e . "
\n";
* }
*
*/
define('XS_LIB_ROOT', dirname(__FILE__));
include_once XS_LIB_ROOT . '/xs_cmd.inc.php';
/**
* XS 异常类定义, XS 所有操作过程发生异常均抛出该实例
*
* @author hightman
* @version 1.0.0
* @package XS
*/
class XSException extends Exception
{
/**
* 将类对象转换成字符串
* @return string 异常的简要描述信息
*/
public function __toString()
{
$string = '[' . __CLASS__ . '] ' . $this->getRelPath($this->getFile()) . '(' . $this->getLine() . '): ';
$string .= $this->getMessage() . ($this->getCode() > 0 ? '(S#' . $this->getCode() . ')' : '');
return $string;
}
/**
* 取得相对当前的文件路径
* @param string $file 需要转换的绝对路径
* @return string 转换后的相对路径
*/
public static function getRelPath($file)
{
$from = getcwd();
$file = realpath($file);
if (is_dir($file)) {
$pos = false;
$to = $file;
} else {
$pos = strrpos($file, '/');
$to = substr($file, 0, $pos);
}
for ($rel = '';; $rel .= '../') {
if ($from === $to) {
break;
}
if ($from === dirname($from)) {
$rel .= substr($to, 1);
break;
}
if (!strncmp($from . '/', $to, strlen($from) + 1)) {
$rel .= substr($to, strlen($from) + 1);
break;
}
$from = dirname($from);
}
if (substr($rel, -1, 1) === '/') {
$rel = substr($rel, 0, -1);
}
if ($pos !== false) {
$rel .= substr($file, $pos);
}
return $rel;
}
}
/**
* XS 错误异常类定义, XS 所有操作过程发生错误均抛出该实例
*
* @author hightman
* @version 1.0.0
* @package XS
*/
class XSErrorException extends XSException
{
private $_file, $_line;
/**
* 构造函数
* 将 $file, $line 记录到私有属性在 __toString 中使用
* @param int $code 出错代码
* @param string $message 出错信息
* @param string $file 出错所在文件
* @param int $line 出错所在的行数
* @param Exception $previous
*/
public function __construct($code, $message, $file, $line, $previous = null)
{
$this->_file = $file;
$this->_line = $line;
if (version_compare(PHP_VERSION, '5.3.0', '>=')) {
parent::__construct($message, $code, $previous);
} else {
parent::__construct($message, $code);
}
}
/**
* 将类对象转换成字符串
* @return string 异常的简要描述信息
*/
public function __toString()
{
$string = '[' . __CLASS__ . '] ' . $this->getRelPath($this->_file) . '(' . $this->_line . '): ';
$string .= $this->getMessage() . '(' . $this->getCode() . ')';
return $string;
}
}
/**
* XS 组件基类
* 封装一些魔术方法, 以实现支持模拟属性
*
* 模拟属性通过定义读取函数, 写入函数来实现, 允许两者缺少其中一个
* 这类属性可以跟正常定义的属性一样存取, 但是这类属性名称不区分大小写. 例:
*
* $a = $obj->text; // $a 值等于 $obj->getText() 的返回值
* $obj->text = $a; // 等同事调用 $obj->setText($a)
*
*
* @author hightman
* @version 1.0.0
* @package XS
*/
class XSComponent
{
/**
* 魔术方法 __get
* 取得模拟属性的值, 内部实际调用 getXxx 方法的返回值
* @param string $name 属性名称
* @return mixed 属性值
* @throw XSException 属性不存在或不可读时抛出异常
*/
public function __get($name)
{
$getter = 'get' . $name;
if (method_exists($this, $getter)) {
return $this->$getter();
}
// throw exception
$msg = method_exists($this, 'set' . $name) ? 'Write-only' : 'Undefined';
$msg .= ' property: ' . get_class($this) . '::$' . $name;
throw new XSException($msg);
}
/**
* 魔术方法 __set
* 设置模拟属性的值, 内部实际是调用 setXxx 方法
* @param string $name 属性名称
* @param mixed $value 属性值
* @throw XSException 属性不存在或不可写入时抛出异常
*/
public function __set($name, $value)
{
$setter = 'set' . $name;
if (method_exists($this, $setter)) {
return $this->$setter($value);
}
// throw exception
$msg = method_exists($this, 'get' . $name) ? 'Read-only' : 'Undefined';
$msg .= ' property: ' . get_class($this) . '::$' . $name;
throw new XSException($msg);
}
/**
* 魔术方法 __isset
* 判断模拟属性是否存在并可读取
* @param string $name 属性名称
* @return bool 若存在为 true, 反之为 false
*/
public function __isset($name)
{
return method_exists($this, 'get' . $name);
}
/**
* 魔术方法 __unset
* 删除、取消模拟属性, 相当于设置属性值为 null
* @param string $name 属性名称
*/
public function __unset($name)
{
$this->__set($name, null);
}
}
/**
* XS 搜索项目主类
*
* @property XSFieldScheme $scheme 当前在用的字段方案
* @property string $defaultCharset 默认字符集编码
* @property-read string $name 项目名称
* @property-read XSIndex $index 索引操作对象
* @property-read XSSearch $search 搜索操作对象
* @property-read XSFieldMeta $idField 主键字段
* @author hightman
* @version 1.0.0
* @package XS
*/
class XS extends XSComponent
{
/**
* @var XSIndex 索引操作对象
*/
private $_index;
/**
* @var XSSearch 搜索操作对象
*/
private $_search;
/**
* @var XSServer scws 分词服务器
*/
private $_scws;
/**
* @var XSFieldScheme 当前字段方案
*/
private $_scheme, $_bindScheme;
private $_config;
/**
* @var XS 最近创建的 XS 对象
*/
private static $_lastXS;
/**
* 构造函数
* 特别说明一个小技巧, 参数 $file 可以直接是配置文件的内容, 还可以是仅仅是文件名,
* 如果只是文件名会自动查找 XS_LIB_ROOT/../app/$file.ini
* @param string $file 要加载的项目配置文件
*/
public function __construct($file)
{
if (strlen($file) < 255 && !is_file($file)) {
$appRoot = getenv('XS_APP_ROOT');
if ($appRoot === false) {
$appRoot = defined('XS_APP_ROOT') ? XS_APP_ROOT : XS_LIB_ROOT . '/../app';
}
$file2 = $appRoot . '/' . $file . '.ini';
if (is_file($file2)) {
$file = $file2;
}
}
$this->loadIniFile($file);
self::$_lastXS = $this;
}
/**
* 析构函数
* 由于对象交叉引用, 如需提前销毁对象, 请强制调用该函数
*/
public function __destruct()
{
$this->_index = null;
$this->_search = null;
}
/**
* 获取最新的 XS 实例
* @return XS 最近创建的 XS 对象
*/
public static function getLastXS()
{
return self::$_lastXS;
}
/**
* 获取当前在用的字段方案
* 通用于搜索结果文档和修改、添加的索引文档
* @return XSFieldScheme 当前字段方案
*/
public function getScheme()
{
return $this->_scheme;
}
/**
* 设置当前在用的字段方案
* @param XSFieldScheme $fs 一个有效的字段方案对象
* @throw XSException 无效方案则直接抛出异常
*/
public function setScheme(XSFieldScheme $fs)
{
$fs->checkValid(true);
$this->_scheme = $fs;
if ($this->_search !== null) {
$this->_search->markResetScheme();
}
}
/**
* 还原字段方案为项目绑定方案
*/
public function restoreScheme()
{
if ($this->_scheme !== $this->_bindScheme) {
$this->_scheme = $this->_bindScheme;
if ($this->_search !== null) {
$this->_search->markResetScheme(true);
}
}
}
/**
* @return array 获取配置原始数据
*/
public function getConfig()
{
return $this->_config;
}
/**
* 获取当前项目名称
* @return string 当前项目名称
*/
public function getName()
{
return $this->_config['project.name'];
}
/**
* 修改当前项目名称
* 注意,必须在 {@link getSearch} 和 {@link getIndex} 前调用才能起作用
* @param string $name 项目名称
*/
public function setName($name)
{
$this->_config['project.name'] = $name;
}
/**
* 获取项目的默认字符集
* @return string 默认字符集(已大写)
*/
public function getDefaultCharset()
{
return isset($this->_config['project.default_charset']) ?
strtoupper($this->_config['project.default_charset']) : 'UTF-8';
}
/**
* 改变项目的默认字符集
* @param string $charset 修改后的字符集
*/
public function setDefaultCharset($charset)
{
$this->_config['project.default_charset'] = strtoupper($charset);
}
/**
* 获取索引操作对象
* @return XSIndex 索引操作对象
*/
public function getIndex()
{
if ($this->_index === null) {
$adds = array();
$conn = isset($this->_config['server.index']) ? $this->_config['server.index'] : 8383;
if (($pos = strpos($conn, ';')) !== false) {
$adds = explode(';', substr($conn, $pos + 1));
$conn = substr($conn, 0, $pos);
}
$this->_index = new XSIndex($conn, $this);
$this->_index->setTimeout(0);
foreach ($adds as $conn) {
$conn = trim($conn);
if ($conn !== '') {
$this->_index->addServer($conn)->setTimeout(0);
}
}
}
return $this->_index;
}
/**
* 获取搜索操作对象
* @return XSSearch 搜索操作对象
*/
public function getSearch()
{
if ($this->_search === null) {
$conns = array();
if (!isset($this->_config['server.search'])) {
$conns[] = 8384;
} else {
foreach (explode(';', $this->_config['server.search']) as $conn) {
$conn = trim($conn);
if ($conn !== '') {
$conns[] = $conn;
}
}
}
if (count($conns) > 1) {
shuffle($conns);
}
for ($i = 0; $i < count($conns); $i++) {
try {
$this->_search = new XSSearch($conns[$i], $this);
$this->_search->setCharset($this->getDefaultCharset());
return $this->_search;
} catch (XSException $e) {
if (($i + 1) === count($conns)) {
throw $e;
}
}
}
}
return $this->_search;
}
/**
* 创建 scws 分词连接
* @return XSServer 分词服务器
*/
public function getScwsServer()
{
if ($this->_scws === null) {
$conn = isset($this->_config['server.search']) ? $this->_config['server.search'] : 8384;
$this->_scws = new XSServer($conn, $this);
}
return $this->_scws;
}
/**
* 获取当前主键字段
* @return XSFieldMeta 类型为 ID 的字段
* @see XSFieldScheme::getFieldId
*/
public function getFieldId()
{
return $this->_scheme->getFieldId();
}
/**
* 获取当前标题字段
* @return XSFieldMeta 类型为 TITLE 的字段
* @see XSFieldScheme::getFieldTitle
*/
public function getFieldTitle()
{
return $this->_scheme->getFieldTitle();
}
/**
* 获取当前内容字段
* @return XSFieldMeta 类型为 BODY 的字段
* @see XSFieldScheme::getFieldBody
*/
public function getFieldBody()
{
return $this->_scheme->getFieldBody();
}
/**
* 获取项目字段元数据
* @param mixed $name 字段名称(string) 或字段序号(vno, int)
* @param bool $throw 当字段不存在时是否抛出异常, 默认为 true
* @return XSFieldMeta 字段元数据对象
* @throw XSException 当字段不存在并且参数 throw 为 true 时抛出异常
* @see XSFieldScheme::getField
*/
public function getField($name, $throw = true)
{
return $this->_scheme->getField($name, $throw);
}
/**
* 获取项目所有字段结构设置
* @return XSFieldMeta[]
*/
public function getAllFields()
{
return $this->_scheme->getAllFields();
}
/**
* 智能加载类库文件
* 要求以 Name.class.php 命名并与本文件存放在同一目录, 如: XSTokenizerXxx.class.php
* @param string $name 类的名称
*/
public static function autoload($name)
{
$file = XS_LIB_ROOT . '/' . $name . '.class.php';
if (file_exists($file)) {
require_once $file;
}
}
/**
* 字符集转换
* 要求安装有 mbstring, iconv 中的一种
* @param mixed $data 需要转换的数据, 支持 string 和 array, 数组会自动递归转换
* @param string $to 转换后的字符集
* @param string $from 转换前的字符集
* @return mixed 转换后的数据
* @throw XSEXception 如果没有合适的转换函数抛出异常
*/
public static function convert($data, $to, $from)
{
// need not convert
if ($to == $from) {
return $data;
}
// array traverse
if (is_array($data)) {
foreach ($data as $key => $value) {
$data[$key] = self::convert($value, $to, $from);
}
return $data;
}
// string contain 8bit characters
if (is_string($data) && preg_match('/[\x81-\xfe]/', $data)) {
// mbstring, iconv, throw ...
if (function_exists('mb_convert_encoding')) {
return mb_convert_encoding($data, $to, $from);
} elseif (function_exists('iconv')) {
return iconv($from, $to . '//TRANSLIT', $data);
} else {
throw new XSException('Cann\'t find the mbstring or iconv extension to convert encoding');
}
}
return $data;
}
/**
* 计算经纬度距离
* @param float $lon1 原点经度
* @param float $lat1 原点纬度
* @param float $lon2 目标点经度
* @param float $lat2 目标点纬度
* @return float 两点大致距离,单位:米
*/
public static function geoDistance($lon1, $lat1, $lon2, $lat2)
{
$dx = $lon1 - $lon2;
$dy = $lat1 - $lat2;
$b = ($lat1 + $lat2) / 2;
$lx = 6367000.0 * deg2rad($dx) * cos(deg2rad($b));
$ly = 6367000.0 * deg2rad($dy);
return sqrt($lx * $lx + $ly * $ly);
}
/**
* 解析INI配置文件
* 由于 PHP 自带的 parse_ini_file 存在一些不兼容,故自行简易实现
* @param string $data 文件内容
* @return array 解析后的结果
*/
private function parseIniData($data)
{
$ret = array();
$cur = &$ret;
$lines = explode("\n", $data);
foreach ($lines as $line) {
if ($line === '' || $line[0] == ';' || $line[0] == '#') {
continue;
}
$line = trim($line);
if ($line === '') {
continue;
}
if ($line[0] === '[' && substr($line, -1, 1) === ']') {
$sec = substr($line, 1, -1);
$ret[$sec] = array();
$cur = &$ret[$sec];
continue;
}
if (($pos = strpos($line, '=')) === false) {
continue;
}
$key = trim(substr($line, 0, $pos));
$value = trim(substr($line, $pos + 1), " '\t\"");
$cur[$key] = $value;
}
return $ret;
}
/**
* 加载项目配置文件
* @param string $file 配置文件路径
* @throw XSException 出错时抛出异常
* @see XSFieldMeta::fromConfig
*/
private function loadIniFile($file)
{
// check cache
$cache = false;
$cache_write = '';
if (strlen($file) < 255 && file_exists($file)) {
$cache_key = md5(__CLASS__ . '::ini::' . realpath($file));
if (function_exists('apc_fetch')) {
$cache = apc_fetch($cache_key);
$cache_write = 'apc_store';
} elseif (function_exists('xcache_get') && php_sapi_name() !== 'cli') {
$cache = xcache_get($cache_key);
$cache_write = 'xcache_set';
} elseif (function_exists('eaccelerator_get')) {
$cache = eaccelerator_get($cache_key);
$cache_write = 'eaccelerator_put';
}
if ($cache && isset($cache['mtime']) && isset($cache['scheme'])
&& filemtime($file) <= $cache['mtime']) {
// cache HIT
$this->_scheme = $this->_bindScheme = unserialize($cache['scheme']);
$this->_config = $cache['config'];
return;
}
$data = file_get_contents($file);
} else {
// parse ini string
$data = $file;
$file = substr(md5($file), 8, 8) . '.ini';
}
// parse ini file
$this->_config = $this->parseIniData($data);
if ($this->_config === false) {
throw new XSException('Failed to parse project config file/string: \'' . substr($file, 0, 10) . '...\'');
}
// create the scheme object
$scheme = new XSFieldScheme;
foreach ($this->_config as $key => $value) {
if (is_array($value)) {
$scheme->addField($key, $value);
}
}
$scheme->checkValid(true);
// load default config
if (!isset($this->_config['project.name'])) {
$this->_config['project.name'] = basename($file, '.ini');
}
// save to cache
$this->_scheme = $this->_bindScheme = $scheme;
if ($cache_write != '') {
$cache['mtime'] = filemtime($file);
$cache['scheme'] = serialize($this->_scheme);
$cache['config'] = $this->_config;
call_user_func($cache_write, $cache_key, $cache);
}
}
}
/**
* Add autoload handler to search classes on current directory
* Class file should be named as Name.class.php
*/
spl_autoload_register('XS::autoload', true, true);
/**
* 修改默认的错误处理函数
* 把发生的错误修改为抛出异常, 方便统一处理
*/
function xsErrorHandler($errno, $error, $file, $line)
{
if (($errno & ini_get('error_reporting')) && !strncmp($file, XS_LIB_ROOT, strlen(XS_LIB_ROOT))) {
throw new XSErrorException($errno, $error, $file, $line);
}
return false;
}
set_error_handler('xsErrorHandler');