php + MongoDB + Sphinx 實(shí)現(xiàn)全文檢索

現(xiàn)狀:

Sphinx 目前的穩(wěn)定版本為 2.2.11.
Sphinx 目前對(duì)英文等字母語(yǔ)言采用空格分詞,故其對(duì)中文分詞支持不好,目前官方中文分詞方案僅支持按單字分詞.
在 Sphinx 基礎(chǔ)上,目前國(guó)內(nèi)有兩個(gè)中文分詞解決方案,一個(gè)是 sphinx-for-chinese, 一個(gè)是 coreseek.
sphinx-for-chinese 沒(méi)有官網(wǎng),文檔較少,可查到的最新版本可支持 sphinx 1.10 .
coreseek 官方還在維護(hù),但貌似不打算將最新版作為開(kāi)源方案釋出了.
coreseek 最后的開(kāi)源穩(wěn)定版本為 3.2.14, 更新時(shí)間為2010年中, 基于 sphinx 0.9.9, 不支持string類(lèi)型的屬性.
coreseek 最后的開(kāi)源beta版本為 4.1, 更新時(shí)間為2011年底, 基于 sphinx 2.0.2, 已可支持string類(lèi)型的屬性.
相比而言, coreseek 文檔較多,網(wǎng)上用的也更為廣泛,因此使用 coreseek 方案.
目前暫時(shí)用了 coreseek 3.2.14 穩(wěn)定版,在后續(xù)了解中,發(fā)現(xiàn)使用 4.1 beta版更為合適.后續(xù)需更換.
注: 如果要使用 coreseek, 要注意其 sphinx 版本.看文檔時(shí),不要去看 sphinx 最新文檔,而要看對(duì)應(yīng)版本的.

搭建:

基于 CentOS 6.5 . 安裝 coreseek:
Coreseek 官網(wǎng)下載地址已失效 (-_- !!!), 需要自己在網(wǎng)上找一個(gè).
Coreseek 官方給出的 安裝文檔 已非常詳實(shí).
因?yàn)槲覀儾皇菫榱颂鎿Q mysql 的全文檢索,因此不需要安裝 mysql 的 sphinx 插件.

安裝 php 的 sphinx 擴(kuò)展:
Sphinx 官方文檔中直接包含了 php 調(diào)用 sphinx 的文檔,因此還是相當(dāng)方便的.
擴(kuò)展安裝方法,當(dāng)時(shí)沒(méi)記錄下來(lái),也不難,網(wǎng)上一大堆.這里就不展開(kāi)了...
擴(kuò)展需要編譯兩個(gè) so 文件 (當(dāng)然路徑不一定是我這個(gè)路徑.):

/usr/local/php/lib/php/extensions/no-debug-non-zts-20131226/sphinx.so
/usr/local/lib/libsphinxclient-0.0.1.so

需要在 php.ini 中增加擴(kuò)展:

extension=sphinx.so

附: 重啟 php-fpm 信號(hào)量含義:

INT, TERM:立刻終止
QUIT :平滑終止
USR1:重新打開(kāi)日志文件
USR2:平滑重載所有worker進(jìn)程并重新載入配置和二進(jìn)制模塊

示例:
1)php-fpm 關(guān)閉:

# kill -INT `cat /usr/local/php/var/run/php-fpm.pid`

2)php-fpm 重啟:

# kill -USR2 `cat /usr/local/php/var/run/php-fpm.pid`

將 MongoDB 作為數(shù)據(jù)源:

sphinx 最常見(jiàn)搭配是 mysql + php. 非mysql數(shù)據(jù)源需要解決數(shù)據(jù)導(dǎo)入問(wèn)題.
用 Sphinx 全文索引 MongoDB 主要有兩個(gè)問(wèn)題需要解決:
一是導(dǎo)入數(shù)據(jù)到 sphinx 索引, 二是 mongo objectId 到 sphinx document id 的映射.

第 一個(gè)問(wèn)題還算好解決,因?yàn)槌?mysql, sphinx 還支持 xml 和 python 數(shù)據(jù)源.但這里還是建議用 mysql 作為 mongo 數(shù)據(jù)的中轉(zhuǎn),因?yàn)?xml 數(shù)據(jù)源不支持步進(jìn)取數(shù)據(jù),性能會(huì)是個(gè)大問(wèn)題. python 數(shù)據(jù)源需要額外增加編譯項(xiàng)目,搞了半天沒(méi)有編譯過(guò)去,又查不到幾篇文檔,就放棄了.

第二個(gè)問(wèn)題,起因是 sphinx 有一條重要限制,就是其索引的每條數(shù)據(jù)都需要一個(gè) "唯一,非零,32位以下 的 整數(shù)" 作為 id. 而 mongo 的 objectId 是一個(gè) 24位16進(jìn)制字符串, 這串16進(jìn)制轉(zhuǎn)為10進(jìn)制是一個(gè) 64-bit int 都存不下的大數(shù).
在 sphinx 1.10 后也算好解決. mongo 的 objectId 可以作為 sphinx 索引中的一個(gè) string 類(lèi)型的屬性值存起來(lái) . 但目前 sphinx 的最新版本,官方文檔中也是寫(xiě)明 string 屬性會(huì)被保存在內(nèi)存而非索引文件中,數(shù)據(jù)集較大時(shí)則需要考慮這方面的性能. 總之如果可以用 int 類(lèi)型的 sphinx 屬性,就盡量不要用 string 類(lèi)型的 sphinx 屬性.
在 sphinx 0.9.9 中,不支持 string 作為屬性,只能用 int, bigint, bool 等作為屬性. 而我采用的是 coreseek 3.2.14 - sphinx 0.9.9. 因此肯定需要再想辦法.

最 終的辦法是,將 24 個(gè)字母的 16 進(jìn)制 objectId 分為 4 段,每段 6 個(gè)字母.每段轉(zhuǎn)換為10進(jìn)制數(shù)就可以落在一個(gè) 32-bit uint 范圍內(nèi)了.這4個(gè) objectId 的片段作為屬性被 sphinx 索引,拿到查詢(xún)結(jié)果后,再將其還原為 mongo 的 objectId. Sphinx 的 document id 則采用無(wú)具體意義的自增主鍵.

將全文檢索作為系統(tǒng)服務(wù):

將全文檢索服務(wù)獨(dú)立出來(lái),作為單獨(dú)項(xiàng)目,向外暴露ip或端口來(lái)提供服務(wù).需實(shí)現(xiàn)以下功能:

  1. 新增或修改索引,由單一文件(下稱(chēng) driver file)驅(qū)動(dòng)如下功能:

    • data source -> mysql : 由數(shù)據(jù)源(mongo)向mysql中轉(zhuǎn)數(shù)據(jù)

    • generate sphinx index conf : 生成sphinx索引配置文件

    • mysql -> sphinx (create index) : 由mysql數(shù)據(jù)及sphinx配置文件生成索引

  2. 單一 bash 腳本實(shí)現(xiàn)更新索引,重建索引,以便 crontab 引用

  3. 查詢(xún)時(shí)自動(dòng)返回 driver file 中描述的字段,并包括數(shù)據(jù)在mongo中的庫(kù)名及表名,以便反向查詢(xún)

難點(diǎn)及核心在于 driver file 的策略.

Plan A:

mongo -> mysql -> sphinx , 三者間有兩重轉(zhuǎn)換:

  • 字段類(lèi)型轉(zhuǎn)換
  • 字段值轉(zhuǎn)移

因此第一想法是將字段含義抽象出來(lái),溝通三者.
字段抽象類(lèi)提供接口,分別返回 mongo, mysql, sphinx 對(duì)應(yīng)字段類(lèi)型,并編寫(xiě)接口將字段值在三者間映射.
初步定下三種字段類(lèi)型:

attr_object_id : 用以映射 mongo 中的 ObjectId
attr_field : 用以將 string 類(lèi)型字段映射為 sphinx 全文檢索項(xiàng)
attr_int : 用以將 int 類(lèi)型字段映射為 sphinx 屬性 (可用作排序,過(guò)濾,分組)

driver file 則選取 json, xml 等通用數(shù)據(jù)格式 (最終選擇了 json).
因?yàn)橐粋€(gè)index的數(shù)據(jù)源有可能有多個(gè),因此要求 driver file 中可配置多個(gè)數(shù)據(jù)源 (json 數(shù)組)
如下為一個(gè)具體索引對(duì)應(yīng)的 driver file:

{
  "name": "example_index",
  "source": [
    {
      "database": "db_name",
      "table": "table_name",
      "attrs": [
          { "mongo": "text1", "type": "field" },
          { "mongo": "text2", "type": "field" },
          { "mongo": "_id", "type": "objectId" },
          { "mongo": "type", "type": "int" },
          { "mongo": "someId", "type": "int" },
          { "mongo": "createTime", "type": "int" },
          { "mongo": "status", "type": "int" }
        ]
    }
  ]
}

為每個(gè)索引配置一個(gè)此格式的json文件,解析所有json文件,則可完成 mongo -> mysql -> sphinx 的流程.

已編碼完成字段抽象, mongo -> mysql 部分.
編寫(xiě)過(guò)程及后續(xù)思考中,發(fā)現(xiàn)這種抽象方式有如下缺點(diǎn):

  • 編碼復(fù)雜: int 類(lèi)型的映射規(guī)則尚簡(jiǎn)單,object_id這樣的字段需要將mongo中的一個(gè)字段映射為mysql中的四個(gè)字段,則要求統(tǒng)一將字段抽象接口都定義為一對(duì)多的映射,復(fù)雜度增加.以字段為基本單元,編碼需要多次遍歷,多層遍歷,復(fù)雜度增加.
  • 字段接口的共同屬性不足: 除了上述一個(gè)一對(duì)多字段將所有字段都抽象為一對(duì)多外,當(dāng)操作最新的mongo維權(quán)表時(shí)意識(shí)到,即使只限定將一個(gè)mongo表映射到一個(gè)sphinx索引中,也會(huì)遇到全文索引字段被保存在其他表中的情況.比如維權(quán)表中的tag是以id數(shù)組的形式存儲(chǔ)的,因此在轉(zhuǎn)儲(chǔ)數(shù)據(jù)時(shí)需要查詢(xún)tag表.這種行為只能單獨(dú)為字段抽象接口編寫(xiě)一個(gè)實(shí)現(xiàn)類(lèi),而這個(gè)實(shí)現(xiàn)類(lèi)也只能用于tag這一個(gè)字段而已.這種抽象方式會(huì)導(dǎo)致具體實(shí)現(xiàn)類(lèi)過(guò)多,且關(guān)聯(lián)不大.
  • 只能支持 mongo -> mysql -> sphinx 這樣的數(shù)據(jù)源配置.如果有其他數(shù)據(jù)源,則不能采用這種抽象方式.

基于以上缺陷,決定放棄此方案(在此方案上已耗費(fèi)了三天的工作量 T_T)

Plan B:

再次思考應(yīng)用場(chǎng)景,可將模型簡(jiǎn)化:

  • 規(guī)劃功能中的第三點(diǎn), "查詢(xún)時(shí)自動(dòng)返回 driver file 中描述的字段,并包括數(shù)據(jù)在mongo中的庫(kù)名及表名,以便反向查詢(xún)",是希望做到對(duì)調(diào)用者完全透明:
    調(diào)用者不需要知道具體索引了哪些字段,就可以根據(jù)查詢(xún)結(jié)果在mongo數(shù)據(jù)庫(kù)中檢索到相應(yīng)數(shù)據(jù). 但為了實(shí)現(xiàn)完全黑箱化,需要的工作量太大,比如 driver file 內(nèi)需要添加描述搜索返回?cái)?shù)據(jù)的接口,以及反向映射某些字段的接口(比如mongo的objectId).
    將此功能簡(jiǎn)化為:

    1. 根據(jù) driver file 為每個(gè)索引生成一個(gè)靜態(tài)的幫助頁(yè)面(manual),在此頁(yè)面中列出索引字段.這樣功能實(shí)現(xiàn)尚可接受,而 driver file 將可減少很多職能: 只關(guān)注索引建立,不關(guān)注索引查詢(xún).
    2. 編寫(xiě)索引查詢(xún)接口,定義一個(gè)字段轉(zhuǎn)換的interface,用于將查詢(xún)出的 sphinx 屬性反向映射到希望得到的數(shù)據(jù).
  • 既然不需要為每個(gè)字段建立反指向數(shù)據(jù)源的映射,就更沒(méi)有必要以字段作為抽象依據(jù). driver file 只關(guān)注索引建立,因此可以將建立索引的各個(gè)步驟作為抽象依據(jù).
    以步驟作為抽象依據(jù),相比于以字段作為抽象依據(jù),
    缺點(diǎn)是:

    • driver file 將不再是靜態(tài)的, driver file 內(nèi)必須包含代碼羅輯,且每增加一個(gè) driver file (對(duì)應(yīng)一個(gè)索引),都要寫(xiě)新的代碼羅輯;
    • 因?yàn)樗饕木S護(hù)和索引的查詢(xún)被分開(kāi),則在一個(gè)索引有屬性改動(dòng)時(shí),需要更改兩個(gè)文件: driver file 和 查詢(xún)字段映射規(guī)則;
    • 抽象程度較低,各 driver file 之間可公用的部分較少.
      優(yōu)點(diǎn)是:
    • 實(shí)現(xiàn)簡(jiǎn)單(do not over design);
    • 可以靈活適配其他類(lèi)型數(shù)據(jù)源;
  • 為了可以支持一個(gè) sphinx 索引的數(shù)據(jù)來(lái)自 mongo 的多個(gè)庫(kù)和多個(gè)表的情況, Plan A 引入了json數(shù)組.但其實(shí)可以將 index 與數(shù)據(jù)庫(kù)表 一對(duì)多 的關(guān)系,放在 mongo -> mysql 數(shù)據(jù)中轉(zhuǎn)時(shí)實(shí)現(xiàn),sphinx 永遠(yuǎn)只索引來(lái)自同一張 mysql 數(shù)據(jù)庫(kù)表的數(shù)據(jù).即由 "mongo 多對(duì)一 mysql + sphinx" 改為 "mongo 多對(duì)一 mysql, mysql 一對(duì)一 sphinx". 這種做法下,將 mongo -> mysql 的實(shí)現(xiàn)方式自由度放的大些,其他步驟就可以統(tǒng)一實(shí)現(xiàn)了.

該方案將整個(gè)項(xiàng)目分為不相關(guān)的兩個(gè)部分:
一部分是由bash腳本驅(qū)動(dòng)的索引操作 (重建 sphinx conf 文件; 更新索引; 導(dǎo)入數(shù)據(jù)等) 工具集;
一部分是由 nginx + phalcon 驅(qū)動(dòng)的索引查詢(xún) restful api 接口.

索引操作工具集:

這個(gè)方案中,所有 driver file 都繼承如下接口:

/**
 * @author lx
 * date: 2016-11-30
 * 
 * 該接口代表一個(gè) sphinx 索引項(xiàng)目.用于完成以下任務(wù):
 * data source => mysql
 * create sphinx searchd.conf
 * refresh sphinx index with searchd.conf
 * create manual (static web page) for each index
 */
interface IndexDriver {

    /**
     * 索引名稱(chēng),需在項(xiàng)目?jī)?nèi)唯一.
     */
    public function getIndexName();

    /**
     * 索引字段數(shù)組: 元素為 IndexField 類(lèi)型的數(shù)組.
     * @see IndexField
     */
    public function getIndexFields();

    /** 
     * 用于在 crontab 調(diào)度中,判斷是否要重建索引
     * @param last_refresh_time 上一次重建索引的時(shí)間, 單位秒
     * @return 需要重建則返回 true; 不需要重建則返回 false
     */
    public function shouldRefreshIndex($last_refresh_time);

    /**
     * 以步進(jìn)方式獲取數(shù)據(jù), 需和 getIndexFields() 對(duì)應(yīng).
     * 數(shù)據(jù)為二維數(shù)組:
     * 第一個(gè)維度為順序數(shù)組,代表將要插入mysql的多行數(shù)據(jù);
     * 第二個(gè)維度為鍵值對(duì)數(shù)組,代表每行數(shù)據(jù)的字段及其值.
     * example:
     * array(
     *     array("id" => "1", "type" => "404", "content" => "I'm not an example"),
     *     array("id" => "2", "type" => "500", "content" => "example sucks"),
     *     array("id" => "3", "type" => "502", "content" => "what's the point /_\"),
     * )
     * 
     * @param int $offset 步進(jìn)偏移量
     * @param int $limit 返回?cái)?shù)據(jù)的最大行數(shù)
     */
    public function getValues($offset, $limit);

    /**
     * 為該索引生成相應(yīng)文檔.
     */
    public function generateDocument();
}

字段以如下類(lèi)表示:

/**
 * @author lx
 * date: 2016-11-30
 * 
 * 該類(lèi)代表一個(gè) sphinx 全文索引字段 或 sphinx 索引屬性.
 */
class IndexField {

    private $name;
    private $mysql_type;
    private $sphinx_type;

    /**
     * 創(chuàng)建作為 sphinx int 類(lèi)型屬性的 IndexField. 該字段必須為一個(gè)正整數(shù).
     * @param string $name 字段名
     */
    public static function createIntField($name) {
        return new IndexField($name, "int", "sql_attr_uint");
    }

    /**
     * 創(chuàng)建作為 sphinx 全文索引字段的 IndexField. 該字段必須為一個(gè)字符串.
     * @param string $name 字段名
     * @param int $char_length 字段值的最大長(zhǎng)度.
     */
    public static function createField($name, $char_length = 255) {
        return new IndexField($name, "varchar($char_length)", null);
    }

    /**
     * @param string $name 字段名
     * @param string $mysql_type 該字段在mysql下的類(lèi)型
     * @param string $sphinx_type 該字段在sphinx配置文件中的類(lèi)型
     */
    public function __construct($name, $mysql_type, $sphinx_type = null) {
        $this->name = $name;
        $this->mysql_type = $mysql_type;
        $this->sphinx_type = $sphinx_type;
    }

    /**
     * 獲取字段名.
     */
    public function getName() {
        return $this->name;
    }

    /**
     * 獲取該字段在 mysql 數(shù)據(jù)庫(kù)中的類(lèi)型.主要用于 mysql create 語(yǔ)句創(chuàng)建數(shù)據(jù)表.
     * 例: 可能返回的值如下:
     * int
     * varchar(255)
     */
    public function getMysqlType() {
        return $this->mysql_type;
    }

    /**
     * 獲取該字段在 sphinx conf 文件中的類(lèi)型.主要用于構(gòu)建全文索引conf文件.
     * 如果該字段為一個(gè)全文索引字段,則該函數(shù)應(yīng)返回 null.
     * 例: 可能返回的值如下:
     * sql_attr_uint
     */
    public function getSphinxType() {
        return $this->sphinx_type;
    }

    /**
     * 判斷該字段是否為全文索引字段.
     * 目前的判斷依據(jù)為 sphinx_type 是否為空.
     */
    public function isSphinxField() {
        return empty($this->sphinx_type);
    }
}

將需要做索引的數(shù)據(jù)源都抽象為上述 driver file, 然后將所有 driver file 統(tǒng)一放在一個(gè)文件夾下.編寫(xiě)腳本掃描該文件夾,根據(jù) driver file 列表實(shí)現(xiàn)重建sphinx索引配置文件,更新索引(全量,增量),crontab排期任務(wù)等操作. 當(dāng)未來(lái)有新的數(shù)據(jù)源要建立索引,或者現(xiàn)有數(shù)據(jù)源調(diào)整時(shí),只需要更新 driver file 即可.

可將索引相關(guān)操作分解到三個(gè)類(lèi)中:
MysqlTransmitter: 用于將數(shù)據(jù)導(dǎo)入 mysql
SphinxConfGenerator: 用于重建 sphinx 配置文件 (只能重建,不能更新.不過(guò)開(kāi)銷(xiāo)很小,不構(gòu)成問(wèn)題)
DocumentGenerator: 用于為每個(gè)索引建立手冊(cè)頁(yè)面

然后再編寫(xiě)統(tǒng)一入口腳本,調(diào)用以上工具類(lèi),接合 sphinx 的內(nèi)建工具 searchd, indexer 等,完成索引相關(guān)操作.
該部分已全部實(shí)現(xiàn),目前運(yùn)行良好.

索引查詢(xún):

上文采用 Plan B 后,需要制定一套索引屬性反向映射規(guī)則.

比如 mongo 的 ObjectId, 其在數(shù)據(jù)源導(dǎo)入時(shí)被拆開(kāi)為4個(gè)int類(lèi)型數(shù)字,現(xiàn)在要將這4個(gè)int類(lèi)型拼接為可用的 ObjectId,以便進(jìn)一步查詢(xún) mongo.
比如有一個(gè)字段 code,需要在其前面補(bǔ)零才可與 mongo 內(nèi)的某個(gè)字段對(duì)應(yīng)起來(lái).

這是一個(gè)多對(duì)多映射問(wèn)題: 將 sphinx 查詢(xún)出的多個(gè)屬性轉(zhuǎn)換為其他的多個(gè)屬性.因此定義如下接口:

/**
 * 將 sphinx 查詢(xún)到的一個(gè)或多個(gè)屬性進(jìn)行轉(zhuǎn)換,并加入到查詢(xún)結(jié)果中去.
 * 被轉(zhuǎn)換的屬性將從結(jié)果集中去掉; 轉(zhuǎn)換結(jié)果將被加入到結(jié)果集中去.
 * @author lx
 */
interface FieldParser {
    /**
     * 聲明要轉(zhuǎn)換的 sphinx 屬性名稱(chēng).
     * 這些被指定的屬性的值將作為參數(shù)傳入 parseValues() 函數(shù)中.
     * @return array 屬性名稱(chēng)的數(shù)組.例: array("id1", "id2", "id3)
     */
    function getRequiredKeys();

    /**
     * 將選定的屬性值進(jìn)行轉(zhuǎn)換.轉(zhuǎn)換結(jié)果以鍵值對(duì)數(shù)組形式返回.
     * @param array $values 選定的屬性值,鍵值對(duì)數(shù)組.
     * @return array 屬性及其值的兼職對(duì). 例: array("id" => "123", "id_ext" => 456)
     */
    function parseValues(array $values);
}

將該接口的具體實(shí)現(xiàn)類(lèi)加入到一個(gè)數(shù)組(隊(duì)列),逐個(gè)遍歷,以對(duì)sphinx的返回結(jié)果集進(jìn)行轉(zhuǎn)換.

以 mongo 的 ObjectId 為例,其具體轉(zhuǎn)換類(lèi)實(shí)現(xiàn)如下:

class MongoIdParser implements FieldParser {

    private $field_name;
    private $required_fields;

    public function __construct($field_name) {
        $this->field_name = $field_name;
        $this->required_fields = array(
            $this->field_name."1", $this->field_name."2",
            $this->field_name."3", $this->field_name."4",
        );
    }

    /**
     * {@inheritDoc}
     * @see FieldParser::getFieldNames()
     */
    public function getRequiredKeys() {
        return $this->required_fields;
    }

    /**
     * {@inheritDoc}
     * @see FieldParser::parseFieldValues()
     */
    public function parseValues(array $values) {
        $mongoId = $this->buildMongoId(
            $values[$this->field_name."1"],
            $values[$this->field_name."2"],
            $values[$this->field_name."3"],
            $values[$this->field_name."4"]);
        return array($this->field_name => $mongoId);
    }

    private function buildMongoId($_id1, $_id2, $_id3, $_id4) {
        $id = $this->toHex($_id1).$this->toHex($_id2).$this->toHex($_id3).$this->toHex($_id4);
        if (strlen($id) != 24) {
            return "";
        } else {
            return $id;
        }
    }

    private function toHex($_id) {
        $hex_str = dechex($_id);
        $count = strlen($hex_str);
        if ($count < 1 || $count > 6) {
            return "";
        }
        if ($count < 6) {
            for ($i = 0; $i < 6 - $count; $i ++) {
                $hex_str = "0".$hex_str;
            }
        }
        return $hex_str;
    }
}

有了以上接口后,定義一個(gè)方便調(diào)用的查詢(xún) sphinx 的類(lèi).

因?yàn)?sphinx 本身對(duì)php支持已經(jīng)極度友好了,其實(shí)除了上面提到的屬性值轉(zhuǎn)換功能,基本沒(méi)什么需要封裝的了.
但因?yàn)榇髳?ài)流式調(diào)用,因此就把調(diào)用sphinx封裝為流式調(diào)用了.如下:

/**
 * @author lx
 * date: 2016-11-25
 * utility class to easy access sphinx search api.
 */
class EcoSearch {

    private $sphinx;
    private $query_index;

    private $field_parsers;

    /**
     * construct with sphinx searchd ip and port
     * @param string $ip  sphinx searchd ip
     * @param int $port  sphinx searchd port
     */
    public function __construct($ip, $port) {
        $this->sphinx = new SphinxClient();
        $this->sphinx->setServer($ip, $port);
        $this->sphinx->SetMatchMode(SPH_MATCH_ANY);
    }

    /**
     * construct with sphinx searchd ip and port
     * @param string $ip  sphinx searchd ip
     * @param int $port  sphinx searchd port
     */
    public static function on($ip = "127.0.0.1", $port = 9312) {
        $search = new EcoSearch($ip, $port);
        return $search;
    }

    public function setMatchAll() {
        $this->sphinx->SetMatchMode(SPH_MATCH_ALL);
        return $this;
    }

    public function setMatchAny() {
        $this->sphinx->SetMatchMode(SPH_MATCH_ANY);
        return $this;
    }

    public function setSortBy($attr, $asc = true) {
        if (!empty($attr) && is_string($attr)) {
            $mode = $asc ? SPH_SORT_ATTR_ASC : SPH_SORT_ATTR_DESC;
            $this->sphinx->SetSortMode($mode, $attr);
        }
        return $this;
    }

    public function setMongoIdName($mongo_id_name) {
        return $this->addFieldParser(new MongoIdParser($mongo_id_name));
    }

    public function addQueryIndex($index) {
        if (!empty(trim($index))) {
            $this->query_index = $this->query_index." ".$index;
        }
        return $this;
    }

    public function addFilter($attr, $values, $exclude = false) {
        $this->sphinx->SetFilter($attr, $values, $exclude);
        return $this;
    }

    public function addFilterRange($attr, $min, $max, $exclude = false) {
        $this->sphinx->SetFilterRange($attr, $min, $max, $exclude);
        return $this;
    }

    public function setLimits($offset, $limit) {
        $this->sphinx->SetLimits($offset, $limit);
        return $this;
    }

    public function addFieldParser($field_parser) {
        if ($field_parser instanceof FieldParser) {
            if (!$this->field_parsers) {
                $this->field_parsers = array();
            }
            $this->field_parsers[] = $field_parser;
        }
        return $this;
    }

    public function query($str) {
        if (empty(trim($this->query_index))) {
            $this->query_index = "*";
        }
        Logger::dd("search [$str] from index {$this->query_index}");
        $result_set = $this->sphinx->Query($str, $this->query_index);
        $error = $this->sphinx->GetLastError();
        if (!$error) {
            Logger::ww("search [$str] from index {$this->query_index}, last error: $error");
        }
        $ret = array();
        if (is_array($result_set) && isset($result_set['matches'])) {
            foreach ($result_set['matches'] as $result) {
                $ret_values = array();
                $values = $result['attrs'];
                foreach ($this->field_parsers as $parser) {
                    $parsed_values = $this->getParsedValues($parser, $values);
                    $ret_values = array_merge($ret_values, $parsed_values);
                }
                $ret_values = array_merge($ret_values, $values);
                $ret[] = $ret_values;
            }
        } else {
            //echo "sphinx query fail: ".$this->sphinx->GetLastError()."\n";
        }
        return $ret;
    }

    private function getParsedValues($parser, &$values) {
        $ret = null;
        $required_keys = $parser->getRequiredKeys($values);
        if (!empty($required_keys)) {
            $required_values = array();
            foreach ($required_keys as $key) {
                // get required values
                $required_values[$key] = $values[$key];
                // abondon the already parsed keys
                unset($values[$key]);
            }
            if (!empty($required_values)) {
                $ret = $parser->parseValues($required_values);
            }
        }
        return $ret;
    }
}

一個(gè)全文檢索調(diào)用的形式大體如下:

        $offset = ($_POST["page"] - 1) * $_POST["pageSize"];
        $limit = $_POST["pageSize"];
        $search_result = EcoSearch::on()
            ->addQueryIndex("index_name")
            ->setMatchAll()
            ->setSortBy("createTime", false)
            ->setLimits($offset, $limit)
            ->setMongoIdName("_id")
            ->query($search);

        if (empty($search_result)) {
            // response "未搜索到相關(guān)結(jié)果";
        } else {
            $result = array();
            foreach ($search_result as $r) {
                $result[] = query_mongo_by_id(new MongoDB\BSON\ObjectID($r['_id']));
            }
            // response result set
        }

因?yàn)?sphinx 提供的 weight, group, 并行查詢(xún)(AddQuery) 等,目前項(xiàng)目中并沒(méi)有使用場(chǎng)景,因此這個(gè)查詢(xún)輔助類(lèi)就已經(jīng)夠用了.

后記:

按以上思路,整個(gè)項(xiàng)目的大體框架已搭建完成,后續(xù)還需要增加對(duì)各個(gè)接口類(lèi)的實(shí)現(xiàn)等工作.
只寫(xiě)了大體思路,隨想隨寫(xiě)(一大半是在出去浪的飛機(jī)上寫(xiě)的...),肯定比較亂.聊做筆記,各位看客見(jiàn)諒~.

參考:

Sphinx 官網(wǎng)
Coreseek 官網(wǎng)


后后記:

本來(lái)領(lǐng)導(dǎo)讓搭建 sphinx 時(shí)說(shuō)只支持非實(shí)時(shí)索引即可, 后來(lái)又整幺蛾子, 讓做實(shí)時(shí)索引.
實(shí)時(shí)索引就得讓后臺(tái)在數(shù)據(jù)入庫(kù)時(shí)附帶著在 sphinx 這也插入一份, 但領(lǐng)導(dǎo)又要求不能影響主框架, 讓我想辦法異步實(shí)現(xiàn)自己找到差異數(shù)據(jù)往 sphinx 里面插.
但但但... php 不支持異步啊... 殘念...
幾經(jīng)掙扎后, 我決定整體放棄這套 php 代碼, 轉(zhuǎn)而用 python 按上面思路重新寫(xiě)了一遍, 順帶改進(jìn)了下流程, 讓其自動(dòng)化程度更高. 又順帶用 flask 搭建了一個(gè) api 服務(wù)器, 以實(shí)現(xiàn)和主框架解偶.
有空時(shí)再謝謝這個(gè) python 框架吧.

另: 后來(lái)又接觸并搭建了 elasticsearch, 感覺(jué)現(xiàn)在用 sphinx 畢竟是少了, 畢竟其中文分詞器居然還不是外掛插件就可以的, 居然還要改源碼... 但兩個(gè)搜索框架都用了, 會(huì)發(fā)現(xiàn) sphinx 占用資源比 elasticsearch 少的多. 呃... 起碼在我這個(gè)規(guī)模上吧.

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市障簿,隨后出現(xiàn)的幾起案子哗戈,更是在濱河造成了極大的恐慌许溅,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,126評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件懊缺,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)昵观,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,254評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人索昂,你說(shuō)我怎么就攤上這事建车。” “怎么了椒惨?”我有些...
    開(kāi)封第一講書(shū)人閱讀 152,445評(píng)論 0 341
  • 文/不壞的土叔 我叫張陵缤至,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我康谆,道長(zhǎng)领斥,這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 55,185評(píng)論 1 278
  • 正文 為了忘掉前任沃暗,我火速辦了婚禮月洛,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘孽锥。我一直安慰自己嚼黔,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,178評(píng)論 5 371
  • 文/花漫 我一把揭開(kāi)白布惜辑。 她就那樣靜靜地躺著唬涧,像睡著了一般。 火紅的嫁衣襯著肌膚如雪盛撑。 梳的紋絲不亂的頭發(fā)上碎节,一...
    開(kāi)封第一講書(shū)人閱讀 48,970評(píng)論 1 284
  • 那天,我揣著相機(jī)與錄音抵卫,去河邊找鬼狮荔。 笑死,一個(gè)胖子當(dāng)著我的面吹牛介粘,可吹牛的內(nèi)容都是我干的殖氏。 我是一名探鬼主播,決...
    沈念sama閱讀 38,276評(píng)論 3 399
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼姻采,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼受葛!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起偎谁,我...
    開(kāi)封第一講書(shū)人閱讀 36,927評(píng)論 0 259
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤总滩,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后巡雨,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體闰渔,經(jīng)...
    沈念sama閱讀 43,400評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,883評(píng)論 2 323
  • 正文 我和宋清朗相戀三年铐望,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了冈涧。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片茂附。...
    茶點(diǎn)故事閱讀 37,997評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖督弓,靈堂內(nèi)的尸體忽然破棺而出营曼,到底是詐尸還是另有隱情,我是刑警寧澤愚隧,帶...
    沈念sama閱讀 33,646評(píng)論 4 322
  • 正文 年R本政府宣布蒂阱,位于F島的核電站,受9級(jí)特大地震影響狂塘,放射性物質(zhì)發(fā)生泄漏录煤。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,213評(píng)論 3 307
  • 文/蒙蒙 一荞胡、第九天 我趴在偏房一處隱蔽的房頂上張望妈踊。 院中可真熱鬧,春花似錦泪漂、人聲如沸廊营。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,204評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)露筒。三九已至,卻和暖如春纵刘,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背荸哟。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,423評(píng)論 1 260
  • 我被黑心中介騙來(lái)泰國(guó)打工假哎, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人鞍历。 一個(gè)月前我還...
    沈念sama閱讀 45,423評(píng)論 2 352
  • 正文 我出身青樓舵抹,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親劣砍。 傳聞我的和親對(duì)象是個(gè)殘疾皇子惧蛹,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,722評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容