前言
本文记录在开发过程中遇到的问题和想法,可能前一天的想法在后两天会全部被改掉,所以仅仅是记录学习过程。
关于ES,7月初刚学,这不碰巧被我赶上了(但还不太熟练)
老师需要有个熟悉Java的,个人感觉Java还行,遂接。
小任务需要用到ES,要求如下:
- 给定一组关键词和需要检索的条数topN,返回检索到的结果
- 做一个通用小工具,能通过配置文件将数据库中的数据索引到ES中(初始化时做),并且后续能用es对其进行检索。
看了要求,第一感觉就是要用SpringBoot了,于是说干就干。
日志
7.13
目前是手动映射数据库表字段到es中,存在很多硬编码
-
包括Document实体类、索引库名document都是硬编码
-
es有自动映射,可以直接mybatis查到数据库数据建立索引,不过效果不太好
-
提前写一个映射配置模板 ?比如字段中出现name、title默认映射为text,category默认keyword
-
我们不用中间实体类,直接将查询的到的数据添加到映射好的索引中
-
还有很多其他小问题:
1. 分词不够智能
现在标题是这样:”中共中国移动通信集团有限公司党组关于二十届中央第一轮巡视整改进展情况的通报”
搜索关键词:“二十”,使用ik_smart分词结果成这样了:
解决办法:
-
使用ik_max_word以最小粒度切分词组,会把二十、十届全部切分出来
缺点:粒度分太细了,比如想搜索口红,会把进口红酒也搜出来
-
依然使用ik_smart,在自定义词典里增加词组“二十”、“二十届”类似的词组
-
索引时最大化的将文章内容分词,搜索时更精确的搜索到想要的结果。即索引时用ik_max_word,在搜索时用ik_smart。(没试过)
2. 词典热更新
词典放入远程服务器中,词典有更新就请求对应接口
-
7.14
目前的方案:访问专门的新建映射索引接口,先根据数据库表生成对应的实体类,获取数据库表名列表,依次遍历获取对应的表内数据,转换成对应实体类,将表名作为索引名插入到es中。es索引映射采用自动映射。其中表名通过反射对应具体的实体类
-
查询数据库数据使用的service泛型问题
- 定义好泛型()
- 使用sql语句查询结果。表名列表是已经获取到的,用select。。from tablename获取数据吧,不过怎么映射成实体类呢,es可以从String类型的字符串自动识别字段吗
-
反射需要有字节码文件。现在如果项目目录里没有某张表的实体类的话,可以通过工具生成一个,但是这个是在运行过程中生成的java文件,不是编译好的class文件,会导致反射获取不到新增表的实体类,最后索引生成失败。
-
动态加载java文件(不知行不行)
-
将新建索引并插入数据的几个类分离出去,单独作为一个工具项目
-
-
自动生成的索引映射很烂,得提前配置模板或者手动映射(手动的话还要我自动做了干嘛)
7.15
暂时不用做小工具了,先把检索功能搞好
-
根据document库生成对应的document索引,并插入数据。
-
基本实现关键词搜索接口
Path:
localhost:8080/search/{keywords}/{topN}
Method:GET
路径参数:
参数名称 示例 是否必须 备注 keywords 中国移动 是 搜索关键词 topN 10 否 搜索条数,不传默认10 返回数据:
参数名称 类型 是否必须 备注 code number 是 msg string 否 data object 是 ├─ total number 是 搜索到的总记录条数 ├─ records object[] 是 ├─├─ id number 是 文档id ├─├─ title string 是 标题 ├─├─ content string 是 文档内容 ├─├─ category string 是 分类 ├─├─ createTime string 是 文档创建时间(时间戳) ├─├─ url string 是 文档文件链接 ├─├─ status number 是 状态
(不是我说,csdn这个表格好奇怪啊)
-
创建了document_template索引模板,以后只要和这个索引结构相似的都可以用这个模板(新建索引库名为doc*的默认用这个模板)
-
分词和检索稍稍优化了一下,现在匹配标题的优先度更高,但是有的词如果拆开就搜不到了。例如想搜索”二十“相关的标题,如果只搜一个”二“,可能就搜不着,可以向自定义词库添加词组解决。
-
自定义词库热更新。现在词库存储在云端(服务器),只要修改词库文件内容就行了,es会每分钟更新一回。
7.16
-
分词检索再次优化,现在新增了一个单字词典,单字也能进行搜索了。
-
优化了搜索的优先级,顺序是:精确匹配标题最高,然后是分词顺序匹配标题(关键词必须在标题中全部出现),分词匹配标题,匹配全文。
-
优化搜索代码,不使用实体类封装搜索结果,直接返回。坏处就是时间字段会变成时间戳,需要前端处理一下。
-
做了一个不那么通用的工具()。只需要配置数据库,用生成器生成实体类,就可以直接将数据库数据索引到es中。一个表对应一个es索引库,索引库名就是表名。表结构映射默认用Document实体类,如果有生成表的实体类就会用对应的类进行映射。每次执行都会把库里所有表的数据全部索引过去,多次执行会出现数据重复的情况,所以只要在一开始初始化一下就好。
-
业务需要,要把分词器从ik换成HanLP(无语了)需要重做:分词、检索、索引模板、词典热更新;新增:词性分析
-
因为hanlp对es的插件等级比较低,es降级到7.10.2
7.17
-
在es中搞hanlp分词,最好用
hanlp_index
进行分词,但是文档说是这么说的,es插件版应该也支持设置的。- 索引分词
IndexTokenizer
是面向搜索引擎的分词器,能够对长词全切分,另外通过term.offset
可以获取单词在文本中的偏移量。 - 任何分词器都可以通过基类
Segment
的enableIndexMode
方法激活索引模式。
- 索引分词
-
关于词典
追加词典
CustomDictionary
主词典文本路径是data/dictionary/custom/CustomDictionary.txt
,用户可以在此增加自己的词语(不推荐);也可以单独新建一个文本文件,通过配置文件CustomDictionaryPath=data/dictionary/custom/CustomDictionary.txt; 我的词典.txt;
来追加词典(推荐)。- 始终建议将相同词性的词语放到同一个词典文件里,便于维护和分享。
词典格式
- 每一行代表一个单词,格式遵从
[单词] [词性A] [A的频次] [词性B] [B的频次] ...
如果不填词性则表示采用词典的默认词性。 - 词典的默认词性默认是名词n,可以通过配置文件修改:
全国地名大全.txt ns;
如果词典路径后面空格紧接着词性,则该词典默认是该词性。 - 在统计分词中,并不保证自定义词典中的词一定被切分出来。用户可在理解后果的情况下通过
Segment#enableCustomDictionaryForcing
强制生效。
说明
- 目前标准分词器都默认关闭了地名识别,用户需要手动开启;这是因为消耗性能,其实多数地名都收录在核心词典和用户自定义词典中。
- 目前分词器默认关闭了机构名识别,用户需要手动开启;这是因为消耗性能,其实常用机构名都收录在核心词典和用户自定义词典中。
- 词典修改后要将对应的bin缓存文件删除,否则可能无效
-
现在的词典位置是在本地,可以热更新,但是怎么变成可以远程修改的呢
-
基本实现分词接口
Path:
localhost:8080/text/handle/textSegment
Method:POST
JSON参数:
参数名称 示例 是否必须 备注 text 中国移动 是 待分词文本 返回数据:
参数名称 类型 是否必须 备注 code number 是 msg string 否 data object[] 是 ├─ word string 是 分词 ├─ nature string 是 词性 ├─ offset number 是 偏移量 ├─ frequency number 是 词频 -
**(通用工具有很多修改,见7.19)**通用工具起个头:
-
提前配置es索引模板
# 动态模板(字段名匹配) PUT _index_template/document_template { "index_patterns": [ "doc*" ], "priority": 500, "template": { "settings": { "number_of_shards": 1 }, "mappings": { "_source": { "enabled": true }, "dynamic_date_formats": [ "yyyy-MM-dd" ], "dynamic_templates": [ { "id": { "match": "*id*", "mapping": { "type": "keyword" } } }, { "title": { "match": "*title*", "mapping": { "type": "text", "analyzer": "hanlp_index", "search_analyzer": "hanlp" } } }, { "name": { "match": "*name*", "mapping": { "type": "text", "analyzer": "hanlp_index", "search_analyzer": "hanlp" } } }, { "content": { "match": "*content*", "mapping": { "type": "text", "analyzer": "hanlp_index", "search_analyzer": "hanlp", "fields": { "keyword": { "type": "keyword" } } } } }, { "category": { "match": "*category*", "mapping": { "type": "keyword" } } }, { "Time": { "match": "*Time*", "mapping": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis" } } }, { "url": { "match": "*url*", "mapping": { "type": "text", "index": false } } }, { "status": { "match": "*status*", "mapping": { "type": "text" } } } ] } } }
-
(使用顺序已修改,详见日志7.19)
(7.21补充,现在反射部分已经不需要了)
使用顺序:填写配置文件 -> 启动程序 -> 访问接口,使用代码生成器生成实体类 -> 停止运行 -> 重新启动程序编译 -> 访问接口,自动创建索引并插入数据
先填写好配置文件,然后使用代码生成器,mybatisplus会自动读取数据库,将配置文件配置的数据库中所有的表都分别生成实体类。由于生成的实体类是Java文件,为了接下来能正确读取到新生成的类,停止运行,重新启动编译文件。最后访问索引生成接口,会根据表名表结构自动索引到es中,并插入数据。
-
配置以下字段:
application-dev.yml
包括数据库驱动、数据库url、端口、数据库名、用户名密码、esurl、端口
-
(已修改)索引生成接口:
(已修改)GET:
localhost:8081/utils/index/{tableName}
GET:
localhost:8081/utils/index?table=tableName
表名可选,填了就只生成这一个表的数据到es,不填就是遍历数据库,全部索引到es里,es中索引名默认为:doc_表名
从数据库查数据需要有一个实体类做字段映射,有一个默认实体类Document,字段如下
默认会先在项目目录下(com.edu.just.esutils.domain.po)去寻找和表同名的实体类(这个一般会用mybatisplus的生成器提前生成,接口在下面。最好是提前生成!),如果找不到就默认用Document类做映射,如果数据库表有其他字段就会被丢弃。(不太智能,后面再看能不能改) -
代码生成接口(7.18已删除,整合到索引数据接口中了,自动调用)
GET:localhost:8081/utils/code
只会生成实体类
-
7.18
-
搞通用工具,一开始用的jdk是17版本,做成jar包直接运行发现环境变量里是jdk8,想到甲方那边大概率也是8,只好降级。SpringBoot降级到2.7.12。
-
因为jdk降级引发了另一个问题。项目中用到运行时动态编译,生成实体类编译时会报错
com.sun.tools.javac.processing.JavacProcessingEnvironment
,找不到tools.jar。解决办法就是自己导包<!-- 修复NoClassDefFoundError: com/sun/tools/javac/processing/JavacProcessingEnvironment --> <dependency> <groupId>com.sun</groupId> <artifactId>tools</artifactId> <version>1.8</version> <scope>system</scope> <systemPath>${java.home}/../lib/tools.jar</systemPath> </dependency>
tools.jar,默认在jdk的lib下,而不在jre的lib下,而java运行时环境就是jre,所以一般自己在ide配置的jre环境中,把tools.jar依赖加上就好
-
做成jar包后直接在cmd运行会乱码(而且报错,后面搞)
解决办法:用这个命令运行:
java -jar -Dfile.encoding=utf-8 ESUtils-0.0.1-SNAPSHOT.jar
就没问题了:
-
接下来解决找实体类的路径问题(真是一波又一折啊)
-
一开始我这里把要生成的class文件位置写成:
System.getProperty("user.dir") + "/target/classes/"
,也就是当前jar包运行的目录,也就是图中的F:\Download/target/classes/
(斜杠方向要打架了,Linux命令打多了是这样的),在idea中没问题,但是在jar包运行时,当前目录是不存在这个位置的,就算新建文件夹把class文件放在这里,后面也很难通过反射得到这个类。 -
其实目的就是通过mybatis生成实体类.java文件,动态编译成class文件,最后导入es的时候用反射获取到每个表对应的实体类,以建立好字段映射关系(不然es怎么知道有哪几个字段,哪个字段是什么类型,而且mybatis查数据库数据必须有一个实体类泛型做接收)实际上要的就是这个class字节码文件。
-
最后想起来反射其实是找JVM中类加载器加载好的类,我这里是通过全限定名找到的对应的类,也就是说实际上只要把我们编译好的class文件用自定义类加载器加载到JVM里就好了,后面其他方法也可通过全限定名找到这个类了。
引用AI的话():
Q:也就是说这里反射找的是加载好的类的全类名,而不用管类的实际位置,只要他是类加载器加载好的就行了对吧
A:是的,您的理解是正确的。在Java中,当使用反射通过Class.forName()方法加载一个类时,关键是提供类的全限定名(即包括包名的类名)。这个方法会通过当前线程的类加载器去查找并加载指定的类。如果这个类已经被相同的类加载器加载到JVM中,那么它就可以被找到,无论这个类的.class文件实际上位于文件系统的哪个位置。这意味着,只要类加载器能够访问到这个类的字节码,并且已经将其加载,您就可以通过反射来使用这个类,而不需要关心这个类的物理存储位置。
-
7.19
(7.21补充,现在反射部分已经不需要了)
-
昨天试着把类加载进JVM,结过因为路径问题烦了半天没好,今天试试。
在一个类中new一个CustomClassLoader实例,完成类的加载,再在另一个类里new一个实例用来做反射(需要是同一个类加载器,不然反射找不到)结果死活找不到,反射不成功,明明用的是同一个自定义类加载器啊。
询问AI,给出回答:
在Java中,每个类加载器实例都维护了自己独立的命名空间。这意味着,即使两个类加载器是相同类型的实例,它们也各自拥有独立的类命名空间。因此,一个类加载器实例加载的类对于另一个类加载器实例来说是不可见的。这就是为什么即使是相同的CustomClassLoader类,不同的实例之间也不能互相访问对方加载的类。 当您在一个类中使用CustomClassLoader的一个实例加载了类之后,这个类只在该实例的命名空间中注册和存在。如果您在另一个类中创建了CustomClassLoader的另一个新实例,并尝试用它来反射获取之前加载的类,将会失败,因为新实例的命名空间中没有这个类的信息。 为了确保能够反射访问到通过CustomClassLoader加载的类,您需要使用加载该类的同一个CustomClassLoader实例。这通常意味着需要在应用中共享CustomClassLoader的实例,或者以某种方式传递这个实例到需要反射访问类的地方。
简单来说就是:加载类时用的自定义类加载器的实例要和反射时用的是同一个。
行吧,看来两个类中的实例对象得是同一个了。这就得用到单例设计模式给出部分代码:
/** * 自定义类加载器 */ public class CustomClassLoader extends ClassLoader { private static CustomClassLoader instance; private String filePath; public CustomClassLoader(String filePath) { this.filePath = filePath; } public CustomClassLoader() { } // 懒汉式单例,需要保证类加载器的唯一性 public static CustomClassLoader getInstance(String filePath) { if (instance == null) { instance = new CustomClassLoader(filePath); } // 重新设置文件路径 instance.filePath = filePath; return instance; } public static CustomClassLoader getInstance() { if (instance == null) { instance = new CustomClassLoader(); } return instance; } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { // 先从已加载的类中查找 Class<?> loadedClass = findLoadedClass(name); if (loadedClass != null) { return loadedClass; } try { byte[] classData = Files.readAllBytes(new File(filePath).toPath()); return defineClass(name, classData, 0, classData.length); } catch (IOException e) { throw new ClassNotFoundException("无法加载字节码:" + name, e); } } }
调用:
// 在加载类的方法中: CustomClassLoader loader = CustomClassLoader.getInstance(filePath); // class文件的路径 Class<?> clazz = loader.loadClass("com.edu.just.esutils.domain.po.Class"); // 想要加载的类的全限定名 // 在另一个类用同样的方法: CustomClassLoader loader = CustomClassLoader.getInstance(); // 和上面是同一个对象 Class<?> clazz = loader.loadClass("com.edu.just.esutils.domain.po.Class"); // 想要反射获取的类的全限定名
终于能反射获取了!
-
修修改改了其他部分东西,新增一个删除所有doc_*开头的索引的接口
GET:
localhost:8081/utils/index/deleteAll
-
现在工具的顺序很简单了,就是填好配置文件,直接访问索引数据接口,会自动从数据库查数据,然后索引到es中。
GET:
localhost:8081/utils/index/table?=tableName
-
又有新问题了!真是层出不穷啊!!!
打包成jar包后运行,再把生成的java实体类文件编译成class文件时报错,说找不到lombok、mybatisplus的类(生成的实体类中有这些注解),我想怎么可能,运行jar的时候都显示mp的图标了,愣了一会想到可能是因为生成的java文件是在jar包之外的原因…
AI回答:
当你在运行已经打包好的 JAR 文件时,如果在动态编译生成的实体类时遇到找不到依赖的问题,尽管这些依赖已经包含在 JAR 文件中,原因可能是因为动态编译过程没有正确地引用到这些内嵌的依赖。通常,JAR 文件内的依赖对于动态编译的类来说是不直接可见的,因为它们不在标准的类加载器路径上。 要解决这个问题,你可以使用自定义的类加载器来加载 JAR 文件内的依赖,或者在动态编译时指定额外的类路径,这个类路径应该指向包含了所有必要依赖的外部目录。但是,如果依赖已经包含在你的 JAR 文件中,使用自定义类加载器可能是更合适的解决方案。
-
试了才发现烦死我了
最后解决方案:将所有依赖包复制出来单独放和jar包同级的lib目录下,运行jar包时指定lib目录,最后打一个没有依赖的jar包
java -Dloader.path="lib/" -jar -Dfile.encoding=utf-8 ESUtils-0.0.1-SNAPSHOT.jar
java -jar -Dfile.encoding=utf-8 ES-0.0.1-SNAPSHOT.jar
最后的结构图:
-
还剩问题就是如果表里没有name、title、content字段怎么办。怎么将表里的对应的字段直接映射到实体类中对应的字段去。比如表中字段为doc_name、caption、head、contents,怎么映射到name、title、content字段上去。(es搜索必须指定明确的字段,这个暂时写死成搜name、title、content三个字段,如果在读取数据库插入es时不是这三个字段的话后面就搜不到了)想到的办法就是修改那个自动生成实体类代码的模板,不知可不可行。
-
指定启动文件:
java -Dloader.path="lib/" -jar -Dfile.encoding=utf-8 ESUtils-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod --es.datasource.database=mall
7.20
-
修改了实体类模板,表中第一个包含name、title、content三个字段都会被直接修改成name、title、content,保证插入导出es中就是这三个。
-
基本搞完了。
-
可恶啊!!!!要配置表中字段的映射关系,而且经过老哥提点,发现为啥要有一个实体类,直接从头到尾Map接收发送不就行了!!!!!
-
动态模板更新:
# 动态模板(字段名匹配) PUT _index_template/document_template { "index_patterns": [ "doc*" ], "priority": 500, "template": { "settings": { "number_of_shards": 1 }, "mappings": { "_source": { "enabled": true }, "dynamic_date_formats": [ "yyyy-MM-dd" ], "dynamic_templates": [ { "id": { "match": "*id*", "mapping": { "type": "keyword" } } }, { "title": { "match": "*title*", "mapping": { "type": "text", "analyzer": "hanlp_index", "search_analyzer": "hanlp" } } }, { "name": { "match": "*name*", "mapping": { "type": "text", "analyzer": "hanlp_index", "search_analyzer": "hanlp" } } }, { "content": { "match": "*content*", "mapping": { "type": "text", "analyzer": "hanlp_index", "search_analyzer": "hanlp", "fields": { "keyword": { "type": "keyword" } } } } }, { "text": { "match": "*text*", "mapping": { "type": "text", "analyzer": "hanlp_index", "search_analyzer": "hanlp", "fields": { "keyword": { "type": "keyword" } } } } }, { "category": { "match": "*category*", "mapping": { "type": "keyword" } } }, { "Time": { "match": "*Time*", "mapping": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis" } } }, { "url": { "match": "*url*", "mapping": { "type": "text", "index": false } } }, { "status": { "match": "*status*", "mapping": { "type": "text" } } } ] } } }
-
新增一个表,用来存放表字段和es字段映射,es包括四个固定字段:name、title、content、text,搜索优先级前两个一样,后两个一样。
-
这个字段映射关系需要使用方通过接口提供
-
去除中间实体类反射部分,直接返回map。拿到数据库数据,遍历将表字段修改为映射的es字段,未映射的不动,插入到es中。
7.21
-
指定默认分词器为hanlp_index,默认搜索分词器为hanlp,省去动态模板。
-
添加自定义字段映射功能,新增15个自定义字段,命名规则:ext_field1、ext_field2。
新增的映射表表结构:
-
基本完成了,中间过程折腾的生成实体类反射部分根本没必要,一个map接收就行
7.22 总结
这次小功能虽然最终结果很简单,但从中间还是学到不少东西的,比如之前很不熟悉的反射部分。正是因为中间的绕弯子,复习了JVM相关的知识,也算是加强巩固了(在此感谢任老哥的提点,不然我就要在这泥潭里wo死了------)。总的来说,感觉es检索的部分占比倒不是很大,烦的就是那个小工具,确实也是挑战自己了。