熱更新概述
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加關注。
