熱更新概述
ik分詞器本身可以從配置檔案載入擴張詞庫,也可以從遠端HTTP伺服器載入。
從本地載入,則需要重啟ES生效,影響比較大。所以,一般我們都會把詞庫放在遠端伺服器上。這裡主要有2種方式:
- 藉助Nginx,在其某個目錄結構下放一個dic.txt,我們只要更新這個檔案,不需要重啟ES也能達到熱更新的目的。優點是簡單,無需開發,缺點就是不夠靈活。
- 自己開發一個HTTP介面,返回詞庫。注意:一行代表一個詞,http body中,自己追加\n換行。
這裡主要介紹第2種介面方式。
熱更新原理
檢視ik分詞器原始碼(org.wltea.analyzer.dic.Monitor):
/** * 監控流程: * ①向詞庫伺服器傳送Head請求 * ②從響應中獲取Last-Modify、ETags欄位值,判斷是否變化 * ③如果未變化,休眠1min,返回第①步 * ④如果有變化,重新載入詞典 * ⑤休眠1min,返回第①步 */ public void runUnprivileged() { //超時設定 RequestConfig rc = RequestConfig.custom().setConnectionRequestTimeout(10*1000) .setConnectTimeout(10*1000).setSocketTimeout(15*1000).build(); HttpHead head = new HttpHead(location); head.setConfig(rc); //設定請求頭 if (last_modified != null) { head.setHeader("If-Modified-Since", last_modified); } if (eTags != null) { head.setHeader("If-None-Match", eTags); } CloseableHttpResponse response = null; try { response = httpclient.execute(head); //返回200 才做操作 if(response.getStatusLine().getStatusCode()==200){ if (((response.getLastHeader("Last-Modified")!=null) && !response.getLastHeader("Last-Modified").getValue().equalsIgnoreCase(last_modified)) ||((response.getLastHeader("ETag")!=null) && !response.getLastHeader("ETag").getValue().equalsIgnoreCase(eTags))) { // 遠端詞庫有更新,需要重新載入詞典,並修改last_modified,eTags Dictionary.getSingleton().reLoadMainDict(); last_modified = response.getLastHeader("Last-Modified")==null?null:response.getLastHeader("Last-Modified").getValue(); eTags = response.getLastHeader("ETag")==null?null:response.getLastHeader("ETag").getValue(); } }else if (response.getStatusLine().getStatusCode()==304) { //沒有修改,不做操作 //noop }else{ logger.info("remote_ext_dict {} return bad code {}" , location , response.getStatusLine().getStatusCode() ); } } catch (Exception e) { logger.error("remote_ext_dict {} error!",e , location); }finally{ try { if (response != null) { response.close(); } } catch (IOException e) { logger.error(e.getMessage(), e); } } }
我們看到,每隔1分鐘:
- 先傳送Http HEAD請求,獲取Last-Modified、ETag(裡面都是字串)
- 如果其中有一個變化,則繼續傳送Get請求,獲取詞庫內容。
所以,Golang裡面 同一個URL 要同時處理 HEAD 請求 和 Get請求。
HEAD 格式
HEAD方法跟GET方法相同,只不過伺服器響應時不會返回訊息體。一個HEAD請求的響應中,HTTP頭中包含的元資訊應該和一個GET請求的響應訊息相同。這種方法可以用來獲取請求中隱含的元資訊,而不用傳輸實體本身。也經常用來測試超連結的有效性、可用性和最近的修改。
一個HEAD請求的響應可被快取,也就是說,響應中的資訊可能用來更新之前快取的實體。如果當前實體跟快取實體的閾值不同(可通過Content-Length、Content-MD5、ETag或Last-Modified的變化來表明),那麼這個快取就被視為過期了。
在ik分詞器中,服務端返回的一個示例如下:
$ curl --head http://127.0.0.1:9800/es/steelDict HTTP/1.1 200 OK Etag: DefaultTags Last-Modified: 2021-10-15 14:49:35 Date: Fri, 15 Oct 2021 07:23:15 GMT
GET 格式
- 返回詞庫時,Content-Length、charset=UTF-8一定要有。
- Last-Modified和Etag 只需要1個有變化即可。只有當HEAD請求返回時,這2個其中一個欄位的值變了,才會傳送GET請求獲取內容,請注意!
- 一行代表一個詞,自己追加\n換行
$ curl -i http://127.0.0.1:9800/es/steelDict HTTP/1.1 200 OK Content-Length: 130 Content-Type: text/html;charset=UTF-8 Etag: DefaultTags Last-Modified: 2021-10-15 14:49:35 Date: Fri, 15 Oct 2021 07:37:47 GMT 裝飾管 裝飾板 圓鋼 無縫管 無縫方管 衛生級無縫管 衛生級焊管 熱軋中厚板 熱軋平板 熱軋卷平板
實現
配置ES IK分詞器
# 這裡以centos 7為例,通過rpm安裝 $ vim /usr/share/elasticsearch/plugins/ik/config/IKAnalyzer.cfg.xml # 改這一行,換成我們的地址 <entry key="remote_ext_dict">http://10.16.52.52:9800/es/steelDict</entry> $ systemctl restart elasticsearch # 重啟es # 這裡還可以實時看到日誌,比較方便 $ tail -f /var/log/elasticsearch/my-application.log [2021-10-15T15:02:31,448][INFO ][o.w.a.d.Monitor ] [node-1] 獲取遠端詞典成功,總數為:0 [2021-10-15T15:02:31,952][INFO ][o.e.l.LicenseService ] [node-1] license [3ca1dc7b-3722-40e5-916e-3b2093980b75] mode [basic] - valid [2021-10-15T15:02:31,962][INFO ][o.e.g.GatewayService ] [node-1] recovered [1] indices into cluster_state [2021-10-15T15:02:32,812][INFO ][o.e.c.r.a.AllocationService] [node-1] Cluster health status changed from [RED] to [YELLOW] (reason: [shards started [[steel-category-mapping][2]] ...]). [2021-10-15T15:02:41,630][INFO ][o.w.a.d.Monitor ] [node-1] 重新載入詞典... [2021-10-15T15:02:41,631][INFO ][o.w.a.d.Monitor ] [node-1] try load config from /etc/elasticsearch/analysis-ik/IKAnalyzer.cfg.xml [2021-10-15T15:02:41,631][INFO ][o.w.a.d.Monitor ] [node-1] try load config from /usr/share/elasticsearch/plugins/ik/config/IKAnalyzer.cfg.xml [2021-10-15T15:02:41,886][INFO ][o.w.a.d.Monitor ] [node-1] [Dict Loading] http://10.16.52.52:9800/es/steelDict [2021-10-15T15:02:43,958][INFO ][o.w.a.d.Monitor ] [node-1] 獲取遠端詞典成功,總數為:0 [2021-10-15T15:02:43,959][INFO ][o.w.a.d.Monitor ] [node-1] 重新載入詞典完畢...
Golang介面
假設使用gin框架,初始化路由:
const ( kUrlSyncESIndex = "/syncESIndex" // 同步鋼材品名、材質、規格、產地、倉庫到ES索引中 kUrlGetSteelHotDict = "/steelDict" // 獲取鋼材字典(品材規產倉) ) func InitRouter(router *gin.Engine) { // ... esRouter := router.Group("es") // 同一個介面,根據head/get來決定是否返回資料部,避免寬頻浪費 esRouter.HEAD(kUrlGetSteelHotDict, onHttpGetSteelHotDictHead) esRouter.GET(kUrlGetSteelHotDict, onHttpGetSteelHotDict) // ... }
head請求處理:
// onHttpGetSteelHotDictHead 處理head請求,只有當Last-Modified 或 ETag 其中1個值改變時,才會出發GET請求獲取詞庫列表 func onHttpGetSteelHotDictHead(ctx *gin.Context) { t, err := biz.QueryEsLastSyncTime() if err != nil { ctx.JSON(http.StatusOK, gin.H{ "code": biz.StatusError, "msg": "server internal error", }) logger.Warn(err) return } ctx.Header("Last-Modified", t) ctx.Header("ETag", kDefaultTags) }
Get請求處理:
// onHttpGetSteelHotDict 處理GET請求,返回真正的詞庫,每一行一個詞 func onHttpGetSteelHotDict(ctx *gin.Context) { // 這裡從mysql查詢詞庫,dic是一個[]string切片 dic, err := biz.QuerySteelHotDic() if err != nil { ctx.JSON(http.StatusOK, gin.H{ "code": biz.StatusError, "msg": "server internal error", }) logger.Warn(err) return } // 這裡查詢最後一次更新時間,作為判斷詞庫需要更新的標準 t, err := biz.QueryEsLastSyncTime() if err != nil { ctx.JSON(http.StatusOK, gin.H{ "code": biz.StatusError, "msg": "server internal error", }) logger.Warn(err) return } ctx.Header("Last-Modified", t) ctx.Header("ETag", kDefaultTags) body := "" for _, v := range dic { if v != "" { body += v + "\n" } } logger.Infof("%s query steel dict success, count = %d", ctx.Request.URL, len(dic)) buffer := []byte(body) ctx.Header("Content-Length", strconv.Itoa(len(buffer))) ctx.Data(http.StatusOK, "text/html;charset=UTF-8", buffer) }
效果
分詞效果:
POST http://10.0.56.153:9200/_analyze { "analyzer": "ik_smart", "text": "武鋼 Q235B 3*1500*3000 6780 佰隆庫 在途整件出" } { "tokens": [ { "token": "武鋼", "start_offset": 0, "end_offset": 2, "type": "CN_WORD", "position": 0 }, { "token": "q235b", "start_offset": 3, "end_offset": 8, "type": "CN_WORD", "position": 1 }, { "token": "3*1500*3000", "start_offset": 9, "end_offset": 20, "type": "ARABIC", "position": 2 }, { "token": "6780", "start_offset": 21, "end_offset": 25, "type": "ARABIC", "position": 3 }, { "token": "佰隆庫", "start_offset": 26, "end_offset": 29, "type": "CN_WORD", "position": 4 }, { "token": "在途", "start_offset": 30, "end_offset": 32, "type": "CN_WORD", "position": 5 }, { "token": "整件", "start_offset": 32, "end_offset": 34, "type": "CN_WORD", "position": 6 }, { "token": "出", "start_offset": 34, "end_offset": 35, "type": "CN_CHAR", "position": 7 } ] }
重新載入後,每個詞都會列印,如果嫌棄可以把程式碼註釋掉:
/** * 載入遠端擴充套件詞典到主詞庫表 */ private void loadRemoteExtDict() { // ... for (String theWord : lists) { if (theWord != null && !"".equals(theWord.trim())) { // 載入擴充套件詞典資料到主記憶體詞典中 // 註釋這一行: // logger.info(theWord); _MainDict.fillSegment(theWord.trim().toLowerCase().toCharArray()); } } // ... }
然後執行:
mvn package
生成zip目標包,拷貝到es目錄或者替換 elasticsearch-analysis-ik-6.8.4.jar 即可。
PS:如果要改ik原始碼,maven同步的時候,有些外掛會找不到,直接刪除即可,只需要保留下面一個:
後記
除錯介面不生效
因為我們需要改ik分詞器原始碼,當時做熱更新的時候發現沒有效果,於是在其程式碼中增加了一句日誌:
/** * 載入遠端擴充套件詞典到主詞庫表 */ private void loadRemoteExtDict() { List<String> remoteExtDictFiles = getRemoteExtDictionarys(); for (String location : remoteExtDictFiles) { logger.info("[Dict Loading] " + location); List<String> lists = getRemoteWords(location); // 如果找不到擴充套件的字典,則忽略 if (lists == null) { logger.error("[Dict Loading] " + location + "載入失敗"); continue; } else { logger.info("獲取遠端詞典成功,總數為:" + lists.size()); } for (String theWord : lists) { if (theWord != null && !"".equals(theWord.trim())) { // 載入擴充套件詞典資料到主記憶體詞典中 logger.info(theWord); _MainDict.fillSegment(theWord.trim().toLowerCase().toCharArray()); } } } }
發現輸出了0:
[2021-10-15T15:02:41,886][INFO ][o.w.a.d.Monitor] [node-1] [Dict Loading] http://10.16.52.52:9800/es/steelDict [2021-10-15T15:02:43,958][INFO ][o.w.a.d.Monitor] [node-1] 獲取遠端詞典成功,總數為:0 [2021-10-15T15:02:43,959][INFO ][o.w.a.d.Monitor] [node-1] 重新載入詞典完畢...
後面通過執行(Dictionary.java):
public static void main(String[] args) { List<String> words = getRemoteWordsUnprivileged("http://127.0.0.1:9800/es/steelDict"); System.out.println(words.size()); }
單點除錯,發現HEADER中沒有設定 Content-Length 導致解析失敗。
數字分詞如何把*號不過濾
原生分詞會把 3*1500*3000 分成:3 1500 3000。如果有特殊需要,希望不分開呢(在鋼貿行業,這是一個規格,所以有這個需求)?
修改程式碼,把識別數字的邏輯加一個 “*”即可。
/** * 英文字元及阿拉伯數字子分詞器 */ class LetterSegmenter implements ISegmenter { // ... //連結符號(這裡追加*號) private static final char[] Letter_Connector = new char[]{'#', '&', '+', '-', '.', '@', '_', '*'}; //數字符號(這裡追加*號) private static final char[] Num_Connector = new char[]{',', '.', '*'}; // ... }
關於作者
推薦下自己的開源IM,純Golang編寫:
CoffeeChat:https://github.com/xmcy0011/CoffeeChat
opensource im with server(go) and client(flutter+swift)
參考了TeamTalk、瓜子IM等知名專案,包含服務端(go)和客戶端(flutter+swift),單聊和機器人(小微、圖靈、思知)聊天功能已完成,目前正在研發群聊功能,歡迎對golang感興趣的小夥伴Star加關注。