Qicz’s Thoughts HUB

The creative and technical writing. Do more, challenge more, know more, be more.

SpringBoot环境的ik应用

2021第一篇黑科技

背景

最近有个项目接入了es,对于检索关键字需要扩大范围检索结果的匹配范围,所以考虑将检索关键字进行分词处理,再交给es处理。(当然这里可以用es的highlevelclient的analyze接口,此处不考虑这种情况)。于是研究ik-analysis,然后有了ik-springboot-demo这个项目,接下来都将以此项目展开描述。

解决的问题

在SpringBoot项目中直接且简单便捷的使用ik。

收获

  • 得到了SpringBoot项目中使用ik的两种方式;
  • 得到了SpringBoot项目的资源管理的更优方案。

重头戏

从ik的项目情况开始

(以下摘录自ik的github README)

IKAnalyzer.cfg.xml can be located at {conf}/analysis-ik/config/IKAnalyzer.cfg.xml or {plugins}/elasticsearch-analysis-ik-*/config/IKAnalyzer.cfg.xml

ik_max_word: 会将文本做最细粒度的拆分,比如会将“中华人民共和国国歌”拆分为“中华人民共和国,中华人民,中华,华人,人民共和国,人民,人,民,共和国,共和,和,国国,国歌”,会穷尽各种可能的组合,适合 Term Query;

ik_smart: 会做最粗粒度的拆分,比如会将“中华人民共和国国歌”拆分为“中华人民共和国,国歌”,适合 Phrase 查询。

项目引入ik

ik似乎已经很久没有将更新push到maven Central了,致使maven Central上的不是最新的。需要最新的版本,需要自行编译。这个是很入门的操作了(下载源码,本地mvn install),就不展开了。此处以最新的7.4.0展开。

项目结构

注意:mvn-cpjar-cp方式的项目,在ik-demo-config目录有差异,以下示例是jar-cp的结构。具体从ik-springboot-demo的mvn-cpjar-cp两个分支查看。(master分支使用的jar-cp方式)

 1.
 2├── README.md
 3├── ik-demo-config
 4│   ├── resources
 5│   │   └── config
 6│   │       ├── analysis-ik
 7│   │       │   ├── IKAnalyzer.cfg.xml
 8│   │       │   ├── extra_main.dic
 9│   │       │   ├── extra_single_word.dic
10│   │       │   ├── extra_single_word_full.dic
11│   │       │   ├── extra_single_word_low_freq.dic
12│   │       │   ├── extra_stopword.dic
13│   │       │   ├── main.dic
14│   │       │   ├── preposition.dic
15│   │       │   ├── quantifier.dic
16│   │       │   ├── stopword.dic
17│   │       │   ├── suffix.dic
18│   │       │   └── surname.dic
19│   │       ├── application-dev.yml
20│   │       ├── application-prod.yml
21│   │       ├── application-test.yml
22│   │       └── application.yml
23│   └── target
24│       └── generated-sources
25│           └── annotations
26├── ik-demo-parent.iml
27├── ik-demo-server
28│   ├── pom.xml
29│   └── src
30│       ├── main
31│       │   ├── java
32│       │   │   └── abc
33│       │   │       ├── App.java
34│       │   │       └── kit
35│       │   │           └── Kit.java
36│       │   └── resources
37│       └── test
38│           └── java
39├── ik-demo-services
40│   ├── pom.xml
41│   └── src
42│       ├── main
43│       │   ├── java
44│       │   │   └── abc
45│       │   │       ├── config
46│       │   │       │   └── IKConfig.java
47│       │   │       └── services
48│       │   │           └── IkJob.java
49│       │   └── resources
50│       └── test
51│           └── java
52└── pom.xml

详细配置如下

以下配置仅为了描述过程,部分内容实际使用中需要调整,如版本的管理。

  • parent pom.xml

     1<properties>
     2    <elasticsearch.version>7.4.0</elasticsearch.version>
     3    <java.version>1.8</java.version>
     4    <maven.compiler.target>1.8</maven.compiler.target>
     5</properties>
     6  
     7<dependencyManagement>
     8    <dependencies>
     9        <dependency>
    10            <groupId>org.elasticsearch</groupId>
    11            <artifactId>elasticsearch-analysis-ik</artifactId>
    12            <version>${elasticsearch.version}</version>
    13        </dependency>
    14        <dependency>
    15            <groupId>org.example</groupId>
    16            <artifactId>ik-demo-services</artifactId>
    17            <version>${project.version}</version>
    18        </dependency>
    19    </dependencies>
    20</dependencyManagement>
    
  • services module pom.xml

     1<dependencies>
     2    <dependency>
     3        <groupId>org.elasticsearch</groupId>
     4        <artifactId>elasticsearch-analysis-ik</artifactId>
     5    </dependency>
     6    <dependency>
     7        <groupId>org.springframework.boot</groupId>
     8        <artifactId>spring-boot-starter-web</artifactId>
     9        <version>2.4.1</version>
    10    </dependency>
    11</dependencies>
    
  • 示例部分源码(点击这里是完整内容)

     1static String text = "123abc;;.。。。\\l a吃 IK Analyzer是一个结合词典分词和文法分词的中文分词开源工具包。它使用了全新的正向迭代最细粒度切分算法。";
     2  
     3...
     4  
     5IKSegmenter segmenter = new IKSegmenter(new StringReader(text), configuration);
     6Lexeme next;
     7System.out.print("非智能分词结果(ik_max_world):");
     8StringJoiner stringJoiner = new StringJoiner(",");
     9while((next=segmenter.next())!=null){
    10    String lexemeText = next.getLexemeText();
    11    stringJoiner.add(lexemeText);
    12}
    13System.out.println(stringJoiner);
    14System.out.println();
    15System.out.println("----------------------------分割线------------------------------");
    16  
    17this.configuration.setUseSmart(true);
    18IKSegmenter smartSegmenter = new IKSegmenter(new StringReader(text), configuration);
    19System.out.print("智能分词结果(ik_smart):");
    20stringJoiner = new StringJoiner(",");
    21while((next=smartSegmenter.next())!=null) {
    22    String lexemeText = next.getLexemeText();
    23    stringJoiner.add(lexemeText);
    24}
    25System.out.println(stringJoiner);
    

遇到的问题

1ERROR Dictionary ik-analyzer: Main Dict not found java.io.FileNotFoundException: /Users/Qicz/.m2/repository/org/elasticsearch/elasticsearch-analysis-ik/7.4.0/config/main.dic (No such file or directory)
2
3ERROR Dictionary ik-analyzer: Surname not found java.io.FileNotFoundException: /Users/Qicz/.m2/repository/org/elasticsearch/elasticsearch-analysis-ik/7.4.0/config/surname.dic (No such file or directory)

找不到对应的ik的词典。其他博友的解决方案是改写ik。我的考虑是先分析源码,再确定是不是要改写。从ik源码Configuration 入手(不得不说ik的源码写的有点随意,风格各种都有…)

 1public Configuration(Environment env,Settings settings) {
 2  this.environment = env;
 3  this.settings=settings;
 4
 5  this.useSmart = settings.get("use_smart", "false").equals("true");
 6  this.enableLowercase = settings.get("enable_lowercase", "true").equals("true");
 7  this.enableRemoteDict = settings.get("enable_remote_dict", "true").equals("true");
 8
 9  Dictionary.initial(this);
10}

可以看到,构造Configuration需要EnvironmentSettings。从字面理解,这二者应该就是与config有关的。先看看Environment的构造

 1public Environment(Settings settings, Path configPath) {
 2        this(settings, configPath, PathUtils.get(System.getProperty("java.io.tmpdir"), new String[0]));
 3    }
 4
 5    Environment(Settings settings, Path configPath, Path tmpPath) {
 6        if (PATH_HOME_SETTING.exists(settings)) {
 7            Path homeFile = PathUtils.get((String)PATH_HOME_SETTING.get(settings), new String[0]).toAbsolutePath().normalize();
 8            if (configPath != null) {
 9                this.configFile = configPath.toAbsolutePath().normalize();
10            } else {
11                this.configFile = homeFile.resolve("config");
12            }
13//... 其他已省略          

可以看到,configFile源自SettingsconfigPath,于是有了下面的构造一个Configuration的代码

1Environment environment = new Environment(Settings.builder().put("path.home", path).build(), null);
2Settings settings = Settings.builder()
3        .put("use_smart", false)
4        .put("enable_lowercase", false)
5        .put("enable_remote_dict", false)
6        .build();
7return new Configuration(environment, settings).setUseSmart(false);

那么接下里的问题就是如果确定这path了。从ik的源码来看,借助了Path需要一个绝对路径,提供一个(InputStream)都不好使,如果真那样就只能改源码了,不是我的初衷,于是从构造path继续深入。

一个绝对的path需要考虑在IDE(如idea)中调试及实际应用运行(jar方式)两个情况,而为了便于应用的部署,配置也必然会与jar一起打包,那么这种情况下就无法拿到绝对的path了(?!)。所以需要把相关的配置拿到jar之外,那么这就自然想到了使用maven的插件,将关联的配置文件拷贝到jar可以读取的地方。

使用maven插件

ik-springboot-demo的mvn-cp分支有完整源码

使用maven插件,有了ik-demo-server的下面pom.xml配置

 1<build>
 2    <plugins>
 3        <plugin>
 4            <groupId>org.springframework.boot</groupId>
 5            <artifactId>spring-boot-maven-plugin</artifactId>
 6            <configuration>
 7                <mainClass>abc.App</mainClass>
 8            </configuration>
 9            <executions>
10                <execution>
11                    <goals>
12                        <goal>repackage</goal>
13                    </goals>
14                </execution>
15            </executions>
16        </plugin>
17        <plugin> <!-- 拷贝ik配置到${project.build.directory}/config下 -->
18            <artifactId>maven-resources-plugin</artifactId>
19            <version>2.6</version>
20            <executions>
21                <execution>
22                    <id>copy-resources</id> <!-- here the phase you need -->
23                    <phase>validate</phase>
24                    <goals>
25                        <goal>copy-resources</goal>
26                    </goals>
27                    <configuration> <!--copyTo的目录-->
28                        <outputDirectory>${project.build.directory}/config</outputDirectory>
29                        <resources>
30                            <resource> <!--被copy的目录-->
31                                <directory>../ik-demo-config/config</directory>
32                                <filtering>true</filtering>
33                            </resource>
34                        </resources>
35                    </configuration>
36                </execution>
37            </executions>
38        </plugin>
39    </plugins>
40    <resources> <!-- 指定SpringBoot资源配置位置 -->
41        <resource>
42            <directory>../ik-demo-config/resources</directory>
43        </resource>
44        <resource>
45            <directory>src/main/resources</directory>
46        </resource>
47    </resources>
48</build>

进行上面的配置之后,当进行mvn clean package时则会将ik-demo-config/config下面的内容配置拷贝到jar同级的config目录中,这样就可以了。那么对应的path也就知道了

1String path = System.getProperty("user.dir")/* + "/config" */;

因为ik做了config目录的配置处理,所以+ "/config"不必添加。也就是String path = System.getProperty("user.dir");

但在IDE(如idea)中,实时编译的class都在target下(并未进行mvn clean package操作),那么在target下就没有config目录,那么在ide里实时调试就有问题了,于是有了下面的兼容处理

1String path = System.getProperty("user.dir");
2// 仅在idea中实时调试需要,与config所在的目录必须一致,此处为ik-demo-config
3if (!Kit.runningAsJar) {
4    path += "/ik-demo-config";
5}

有了上面的前提就得到了完整的Configuration构造(同时将其注入ioc,此处inIdea的处理与jar-cp有略微差异)

 1@Bean
 2public Configuration ikConfiguration() {
 3    String path = System.getProperty("user.dir");
 4    // 仅在idea中实时调试需要,与config所在的目录必须一致,此处为ik-demo-config
 5    if (!Kit.runningAsJar) {
 6        path += "/ik-demo-config";
 7    }
 8    Environment environment = new Environment(Settings.builder().put("path.home", path).build(), null);
 9    Settings settings = Settings.builder()
10            .put("use_smart", false)
11            .put("enable_lowercase", false)
12            .put("enable_remote_dict", false)
13            .build();
14    return new Configuration(environment, settings).setUseSmart(false);
15}

经测试上面的配置可以实现分词。

那么除了maven插件以外,是不是可以使用Java来直接拷贝呢,会不会更便捷呢?于是有了jar-cp的方式

使用jar-cp方式(推荐)

ik-springboot-demo的jar-cp分支有完整源码

使用jar-cp,实质就是Java进行相关资源拷贝的逻辑处理,于是有了下面的代码(拷贝jar中的config目录及其子目录、其他propertiesyamlymlxml配置文件到当前jar所在的config目录中)

 1/**
 2 * Kit
 3 *
 4 * @author Qicz
 5 */
 6public final class Kit {
 7
 8    public static boolean runningAsJar = false;
 9
10    /**
11     * 拷贝SpringBoot配置&ik配置
12     * @param appClass spring boot 启动类
13     * @throws IOException io exception
14     */
15    public static void copyConfigInJar(Class<?> appClass) throws IOException {
16        ApplicationHome applicationHome = new ApplicationHome(appClass);
17        File source = applicationHome.getSource();
18        if (source != null) {
19            String absolutePath = source.getAbsolutePath();
20            Kit.runningAsJar = absolutePath.endsWith("jar");
21            if (!Kit.runningAsJar) {
22                return;
23            }
24
25            final String configPath = "config/";
26            File config = new File(System.getProperty("user.dir") + "/" + configPath);
27            if (config.exists() || !config.mkdir()) {
28                return;
29            }
30
31            JarFile jarFile = new JarFile(source);
32            final Set<String> configFiles = new HashSet<String>(){{
33                add(".properties");
34                add(".yaml");
35                add(".yml");
36                add(".xml");
37            }};
38            for (Enumeration<? extends ZipEntry> entries = jarFile.entries(); entries.hasMoreElements(); ) {
39                ZipEntry entry = entries.nextElement();
40                String entryName = entry.getName();
41                // 仅拷贝config目录或properties,yaml。
42                boolean isConfig = false;
43                int lastIndexOf = entryName.indexOf(configPath);
44                String outPath = "";
45                // has config dir
46                if (lastIndexOf != -1) {
47                    outPath = entryName.substring(lastIndexOf);
48                    isConfig = true;
49                } else {
50                    lastIndexOf = entryName.lastIndexOf(".");
51                    if (lastIndexOf != -1) {
52                        String file = entryName.substring(entryName.lastIndexOf("/") + 1);
53                        boolean pomFile = "pom.properties".equals(file) || "pom.xml".equals(file);
54                        isConfig = configFiles.contains(entryName.substring(lastIndexOf)) && !pomFile;
55                        if (isConfig) {
56                            outPath = String.join("", configPath, file);
57                        }
58                    }
59                }
60
61                if (!isConfig) {
62                    continue;
63                }
64                InputStream jarFileInputStream = jarFile.getInputStream(entry);
65                File currentFile = new File(outPath.substring(0, outPath.lastIndexOf('/')));;
66                if (!currentFile.exists() && !currentFile.mkdirs()) {
67                    continue;
68                }
69                // 判断文件全路径是否为文件夹,如果是上面已经上传,不需要解压
70                if (new File(outPath).isDirectory()) {
71                    continue;
72                }
73
74                FileOutputStream out = new FileOutputStream(outPath);
75                byte[] bytes = new byte[1024];
76                int len;
77                while ((len = jarFileInputStream.read(bytes)) > 0) {
78                    out.write(bytes, 0, len);
79                }
80                jarFileInputStream.close();
81                out.close();
82            }
83        }
84    }
85}

需要注意的时,这个处理必须要在SpringBoot启用起来之前进行,即就是

 1/**
 2 * App
 3 *
 4 * @author Qicz
 5 */
 6@SpringBootApplication
 7public class App {
 8
 9    public static void main(String[] args) throws IOException {
10        Kit.copyConfigInJar(App.class);
11        SpringApplication.run(App.class, args);
12    }
13}

借用了SpringBootApplicationHome来获取当前应用的启动类所在的路径absolutePath

1ApplicationHome applicationHome = new ApplicationHome(App.class);
2File source = applicationHome.getSource();
3if (source != null) {
4    String absolutePath = source.getAbsolutePath();
5 ...

通过检查这个absolutePath是否以jar结尾确定是否以jar方式运行。如果是则开始进行jar文件的解析,将其资源拷贝到jar的同级目录的config中。

采用jar-cp对应的ik-demo-server的pom.xml配置也有相应的差异,无需maven插件的处理。

 1<build>
 2    <plugins>
 3        <plugin>
 4            <groupId>org.springframework.boot</groupId>
 5            <artifactId>spring-boot-maven-plugin</artifactId>
 6            <configuration>
 7                <mainClass>abc.App</mainClass>
 8            </configuration>
 9            <executions>
10                <execution>
11                    <goals>
12                        <goal>repackage</goal>
13                    </goals>
14                </execution>
15            </executions>
16        </plugin>
17    </plugins>
18    <resources>
19        <resource>
20            <directory>../ik-demo-config/resources</directory>
21        </resource>
22        <resource>
23            <directory>src/main/resources</directory>
24        </resource>
25    </resources>
26</build>

对应的Configuration构造,差异在于inIdea中的路径配置

 1@Bean
 2public Configuration ikConfiguration() {
 3    String path = System.getProperty("user.dir");
 4    // 仅在idea中实时调试需要,与config所在的目录必须一致,此处为ik-demo-config/resources
 5    if (!Kit.runningAsJar) {
 6        path += "/ik-demo-config/resources";
 7    }
 8    Environment environment = new Environment(Settings.builder().put("path.home", path).build(), null);
 9    Settings settings = Settings.builder()
10            .put("use_smart", false)
11            .put("enable_lowercase", false)
12            .put("enable_remote_dict", false)
13            .build();
14    return new Configuration(environment, settings).setUseSmart(false);
15}

需要注意的是,在jar-cp方式中,对应的ik-demo-config中的资源的存放方式是将SpringBoot的配置及ik的配置都放到ik-demo-config/resources/config中,并将ik-demo-config/resources使用maven配置成了项目的resources,也就是package时会将resources下面的SpringBoot配置及ik配置都打包进jar,这也是必然的,这是进行jar-cp的基础,使用Java拷贝问题,总得有东西给你拷吧。😝,这样的好处就是:

你拿到一个jar,直接java -jar xx.jar (项目部署)对应的配置(SpringBoot各类profile及ik的配置)都有了。不需要在手动拷贝,也可以避免不必要的失误 。

目录结构是这样的

 1├── ik-demo-config
 2│   ├── resources
 3│   │   └── config
 4│   │       ├── analysis-ik
 5│   │       │   ├── IKAnalyzer.cfg.xml
 6│   │       │   ├── extra_main.dic
 7│   │       │   ├── extra_single_word.dic
 8│   │       │   ├── extra_single_word_full.dic
 9│   │       │   ├── extra_single_word_low_freq.dic
10│   │       │   ├── extra_stopword.dic
11│   │       │   ├── main.dic
12│   │       │   ├── preposition.dic
13│   │       │   ├── quantifier.dic
14│   │       │   ├── stopword.dic
15│   │       │   ├── suffix.dic
16│   │       │   └── surname.dic
17│   │       ├── application-dev.yml
18│   │       ├── application-prod.yml
19│   │       ├── application-test.yml
20│   │       └── application.yml

总结

  • 使用了两种方式来处理ik的配置,mvn-cpjar-cp,推荐jar-cp,便于应用的部署;

  • 两种方式中对应的ik-demo-config的目录结构有差异

    • mvn-cp方式

      mvn-cpmvn package仅拷贝ik的配置(当然可以配置将SpringBoot配置也拷贝,但是实际项目部署,当你是用jar直接部署时,SpringBoot的配置和ik配置都是没有的,所以没有什么区别)

      ik-demo-config/resources/application.yml

      ik-demo-config/config/analysis-ik

    • jar-cp方式

      jar-cpmvn package时会将SpringBoot配置及ik配置(它们都在ik-demo-config/resources/config下)都打包进jar,为后面jar运行执行jar-cp提供基础`

      ik-demo-config/resources/config/analysis-ik…application.yml

  • jar-cp【推荐方式】便于在项目现场的部署,不需要手动拷贝相关的配置,避免不不要的手误;且与SpringBoot的配置机制一致,只需针对性微调关联配置即可(SpringBoot本就会优先扫描当前应用jar所在目录下的config目录的application-{profile}.yml/prperties)参考这里

jar-cp核心的copy处理已合并到spring-boot-xSpringApplicationX中,如下使用即可。

 1/**
 2 * App
 3 *
 4 * @author Qicz
 5 */
 6@SpringBootApplication
 7@EnableExtension
 8public class App {
 9
10    public static void main(String[] args) throws InterruptedException {
11        SpringApplicationX.run(App.class, args);
12    }
13}