Contents

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

详细配置如下

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

  • parent pom.xml

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

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

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

遇到的问题

1
2
3
ERROR 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)

ERROR 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的源码写的有点随意,风格各种都有…)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public Configuration(Environment env,Settings settings) {
  this.environment = env;
  this.settings=settings;

  this.useSmart = settings.get("use_smart", "false").equals("true");
  this.enableLowercase = settings.get("enable_lowercase", "true").equals("true");
  this.enableRemoteDict = settings.get("enable_remote_dict", "true").equals("true");

  Dictionary.initial(this);
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public Environment(Settings settings, Path configPath) {
        this(settings, configPath, PathUtils.get(System.getProperty("java.io.tmpdir"), new String[0]));
    }

    Environment(Settings settings, Path configPath, Path tmpPath) {
        if (PATH_HOME_SETTING.exists(settings)) {
            Path homeFile = PathUtils.get((String)PATH_HOME_SETTING.get(settings), new String[0]).toAbsolutePath().normalize();
            if (configPath != null) {
                this.configFile = configPath.toAbsolutePath().normalize();
            } else {
                this.configFile = homeFile.resolve("config");
            }
//... 其他已省略          

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

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

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

1
String 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里实时调试就有问题了,于是有了下面的兼容处理

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

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

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

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

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

使用jar-cp方式(推荐)

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/**
 * Kit
 *
 * @author Qicz
 */
public final class Kit {

    public static boolean runningAsJar = false;

    /**
     * 拷贝SpringBoot配置&ik配置
     * @param appClass spring boot 启动类
     * @throws IOException io exception
     */
    public static void copyConfigInJar(Class<?> appClass) throws IOException {
        ApplicationHome applicationHome = new ApplicationHome(appClass);
        File source = applicationHome.getSource();
        if (source != null) {
            String absolutePath = source.getAbsolutePath();
            Kit.runningAsJar = absolutePath.endsWith("jar");
            if (!Kit.runningAsJar) {
                return;
            }

            final String configPath = "config/";
            File config = new File(System.getProperty("user.dir") + "/" + configPath);
            if (config.exists() || !config.mkdir()) {
                return;
            }

            JarFile jarFile = new JarFile(source);
            final Set<String> configFiles = new HashSet<String>(){{
                add(".properties");
                add(".yaml");
                add(".yml");
                add(".xml");
            }};
            for (Enumeration<? extends ZipEntry> entries = jarFile.entries(); entries.hasMoreElements(); ) {
                ZipEntry entry = entries.nextElement();
                String entryName = entry.getName();
                // 仅拷贝config目录或properties,yaml。
                boolean isConfig = false;
                int lastIndexOf = entryName.indexOf(configPath);
                String outPath = "";
                // has config dir
                if (lastIndexOf != -1) {
                    outPath = entryName.substring(lastIndexOf);
                    isConfig = true;
                } else {
                    lastIndexOf = entryName.lastIndexOf(".");
                    if (lastIndexOf != -1) {
                        String file = entryName.substring(entryName.lastIndexOf("/") + 1);
                        boolean pomFile = "pom.properties".equals(file) || "pom.xml".equals(file);
                        isConfig = configFiles.contains(entryName.substring(lastIndexOf)) && !pomFile;
                        if (isConfig) {
                            outPath = String.join("", configPath, file);
                        }
                    }
                }

                if (!isConfig) {
                    continue;
                }
                InputStream jarFileInputStream = jarFile.getInputStream(entry);
                File currentFile = new File(outPath.substring(0, outPath.lastIndexOf('/')));;
                if (!currentFile.exists() && !currentFile.mkdirs()) {
                    continue;
                }
                // 判断文件全路径是否为文件夹,如果是上面已经上传,不需要解压
                if (new File(outPath).isDirectory()) {
                    continue;
                }

                FileOutputStream out = new FileOutputStream(outPath);
                byte[] bytes = new byte[1024];
                int len;
                while ((len = jarFileInputStream.read(bytes)) > 0) {
                    out.write(bytes, 0, len);
                }
                jarFileInputStream.close();
                out.close();
            }
        }
    }
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/**
 * App
 *
 * @author Qicz
 */
@SpringBootApplication
public class App {

    public static void main(String[] args) throws IOException {
        Kit.copyConfigInJar(App.class);
        SpringApplication.run(App.class, args);
    }
}

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

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

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

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

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

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

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

需要注意的是,在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
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
├── ik-demo-config
│   ├── resources
│   │   └── config
│   │       ├── analysis-ik
│   │       │   ├── IKAnalyzer.cfg.xml
│   │       │   ├── extra_main.dic
│   │       │   ├── extra_single_word.dic
│   │       │   ├── extra_single_word_full.dic
│   │       │   ├── extra_single_word_low_freq.dic
│   │       │   ├── extra_stopword.dic
│   │       │   ├── main.dic
│   │       │   ├── preposition.dic
│   │       │   ├── quantifier.dic
│   │       │   ├── stopword.dic
│   │       │   ├── suffix.dic
│   │       │   └── surname.dic
│   │       ├── application-dev.yml
│   │       ├── application-prod.yml
│   │       ├── application-test.yml
│   │       └── 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
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/**
 * App
 *
 * @author Qicz
 */
@SpringBootApplication
@EnableExtension
public class App {

    public static void main(String[] args) throws InterruptedException {
        SpringApplicationX.run(App.class, args);
    }
}