环境:
https://github.com/medcl/elasticsearch-analysis-ik
为了避免在容器内下载过慢,教主选择了提前用迅雷下载下来,并以容器卷挂载的方式放到容器里去。大致的目录如下:
elasticsearch/
|--compose-elasticsearch.yml # docker-compose.yml
|--es0/ # es0 节点
|--plugins/ # 插件卷
|--ik/ # 分词器插件
|--data/ # 数据卷好东西就要分享出来:
另外容器卷挂载的方式还有个好处就是可以更加方便的添加用户词典:
GitHub代下地址 http://gitd.cc/
docker network create local-net
version: '3.8'
services:
es0:
image: elasticsearch:7.8.0
restart: always
ports:
- 9200:9200
volumes:
- ./es0/data/:/usr/share/elasticsearch/data/
- ./es0/plugins/:/usr/share/elasticsearch/plugins/
environment:
- node.name=es0
- cluster.name=es-cluster
- cluster.initial_master_nodes=es0
- bootstrap.memory_lock=true
- 'ES_JAVA_OPTS=-Xms512m -Xmx512m'
- xpack.security.enabled=true
- xpack.security.transport.ssl.enabled=true
ulimits:
memlock:
soft: -1
hard: -1
networks:
default:
external:
name: local-net
容器的工作目录已经在/usr/share/elasticsearch/
目录下,进入容器后直接输入以下命令即可:
bin/elasticsearch-setup-passwords interactive
注意事项:
elastic
用户相当于root
用户Head 插件感觉起来可有可无(不是太好看),有现成的 chrome 插件可用:ElasticSearch Head
version: '3.8'
services:
kibana:
image: kibana:7.8.0
privileged: true
ports:
- 5601:5601
volumes:
- ./kibana/conf/kibana.yml:/usr/share/kibana/config/kibana.yml
networks:
default:
external:
name: local-net
server.host: "0"
elasticsearch.hosts: ['http://es0:9200']
elasticsearch.username: 'elastic'
elasticsearch.password: '123456'
i18n.locale: 'zh-CN'
获取完整的配置文件可以从官网下载一份 Windows 版的进行参考。
environment
中配置,但是结果不生效。0.0.0.0
容器外映射端口才能进行访问。spring.data
节点下的配置大部分已经@Deprecated
了,使用 RestHighLevelClient 的elasticsearch
节点直接在spring
节点下:
spring:
elasticsearch:
rest:
uris: http://127.0.0.1:9200
username: elastic
password: 123456
需要注意的是,就好比直接使用 REST API 插入数据并创建索引一样不会创建规则一样,尽管在实体属性的@Field
注解上注明了字符串字段具体类型,也同样不会创建索引规则。例如如下方式不会自动创建规则:
PUT /hero/_doc/1
{
"id": 1,
"name": "黑暗之女",
"alias": "Annie",
"title": "安妮"
}
因此需要提前创建好规则(keyword
不会被分词器分词,适合term
查询):
PUT /hero
{
"mappings": {
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "text",
"analyzer": "ik_smart"
},
"alias": {
"type": "keyword"
},
"title": {
"type": "text",
"analyzer": "ik_smart"
},
"short_bio": {
"type": "text",
"analyzer": "ik_max_word"
},
"avatar_src": {
"type": "keyword"
}
}
}
}
@Data
@Accessors(chain = true)
@AllArgsConstructor
@NoArgsConstructor
@Document(indexName = "hero")
public class Hero {
@Id
@Field(type = FieldType.Integer)
private Integer id;
@Field(type = FieldType.Text, analyzer = "ik_smart")
private String name;
@Field(type = FieldType.Keyword)
private String alias;
@Field(type = FieldType.Text, analyzer = "ik_smart")
private String title;
@Field(value = "short_bio", type = FieldType.Text, analyzer = "ik_max_word")
private String shortBio;
@Field(value = "avatar_src", type = FieldType.Keyword)
private String avatarSrc;
}
第一个英雄的接口为 黑暗之女 / Annie / 安妮 ,目前从官网可见最大的 ID 为 856,但是实际上只有 148 条有效记录。对于不存在的接口会返回 404 ,Jsoup 会直接抛出异常,因此需要设置ignoreHttpErrors(true)
。
请求到的 JSON 除了英雄本身的信息外,还有皮肤信息、技能信息等,即代码中的wrapper / rootNode
,而我们暂时只需要hero
节点,将其读取为JsonNode
树状结构。
@Slf4j
@SpringBootTest
public class ImportDataTest {
@Autowired private ObjectMapper objectMapper;
@Autowired private ElasticsearchRestTemplate elasticsearchRestTemplate;
// @Autowired private MongoTemplate mongoTemplate;
// 导入数据
@Test
void importDataTest() {
final int start = 1;
final int end = 856;
List<Hero> list = new ArrayList<>();
// List
for (int i = start; i <= end; i++) {
try {
Connection.Response response = Jsoup
.connect("https://game.gtimg.cn/images/lol/act/img/js/hero/" + i + ".js")
.timeout(30000)
// 忽略请求类型, 请求 JSON
.ignoreContentType(true)
// 忽略 404 等, 使用 statusCode 判断
.ignoreHttpErrors(true)
.execute();
if (response.statusCode() == 200) {
// wrapper = { hero: { name: '' } }
String body = response.body();
JsonNode rootNode = objectMapper.readTree(body);
// 向 mongodb 存一份
// Object o = objectMapper.readValue(body, Object.class);
// rootList.add(o);
// hero = { name: '' }
JsonNode heroNode = rootNode.get("hero");
Hero hero = objectMapper.readValue(heroNode.toPrettyString(), Hero.class);
// hero = { id: i, name: '', avatarSrc: '' }
String avatarSrc = "https://game.gtimg.cn/images/lol/act/img/champion/" + hero.getAlias() + ".png";
hero.setAvatarSrc(avatarSrc);
hero.setId(i);
log.info(hero.toString());
list.add(hero);
}
} catch (IOException e) {
e.printStackTrace();
}
}
// mongoTemplate.insert(rootList, "hero_detail");
elasticsearchRestTemplate.save(list);
}
}
GET /hero/_search
{
"query": {
"multi_match": {
"query": "影流之主",
"fields": ["name", "title", "short_bio"]
}
},
"highlight": {
"pre_tags": "",
"post_tags": "",
"fields": {
"name": {},
"title": {},
"short_bio": {}
}
},
"from": 0,
"size": 10
}
需要注意的是 PageRequest 的当前页(page
)是从0开始的。默认按照命中率(_score
)降序排序。
@Slf4j
@Service
public class HeroService {
@Autowired private ElasticsearchRestTemplate elasticsearchRestTemplate;
public Map<String, Object> hightlightSearch(String keyword) {
Map<String, Object> map = new HashMap<>();
Query query = new NativeSearchQueryBuilder()
.withQuery(
new MultiMatchQueryBuilder(keyword, "name", "title", "short_bio")
)
.withHighlightBuilder(
new HighlightBuilder()
.preTags("")
.postTags("")
.field("short_bio")
.field("title")
.field("name")
)
.withSort(
SortBuilders.scoreSort().order(SortOrder.DESC)
)
.withPageable(
PageRequest.of(0, 10)
)
.build();
SearchHits<Hero> hits = elasticsearchRestTemplate.search(query, Hero.class);
// 总命中数
int total = (int) hits.getTotalHits();
map.put("total", total);
List<SearchHit<Hero>> hitList = hits.getSearchHits();
// 本页命中数
int size = hitList.size();
map.put("size", size);
List<Hero> list = new ArrayList<>();
// 使用高亮字段替换实体字段
for (SearchHit<Hero> hit : hitList) {
Hero hero = hit.getContent();
//
List<String> highlightShortBioList = hit.getHighlightField("shortBio");
if (!highlightShortBioList.isEmpty()) {
String highlightShortBio = String.join("", highlightShortBioList);
hero.setShortBio(highlightShortBio);
}
//
List<String> highlightTitleList = hit.getHighlightField("title");
if (!highlightTitleList.isEmpty()) {
String highlightTitle = String.join("", highlightTitleList);
hero.setTitle(highlightTitle);
}
//
List<String> highlightNameList = hit.getHighlightField("name");
if (!highlightNameList.isEmpty()) {
String highlightName = String.join("", highlightNameList);
hero.setName(highlightName);
}
list.add(hero);
}
// 命中单位集合
map.put("list", list);
return map;
}
}
@Controller
public class PageController {
@GetMapping({"/", "/index", "index.html"})
public String index() {
return "index";
}
}
@RestController
public class HeroController {
@Autowired private HeroService heroService;
@GetMapping(value = "/highlight-search/{keyword}", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> highlightSearch(@PathVariable("keyword") String keyword) {
return heroService.hightlightSearch(keyword);
}
}
直接使用v-html
指令显示带有样式的字段
<html lang="en">
<head>
<meta charset="UTF-8">
<title>elasticsearch-demotitle>
<script src="https://cdn.jsdelivr.net/npm/vue">script>
<script src="https://unpkg.com/axios/dist/axios.min.js">script>
<style>
.width-45 {width: 45px}
.width-60 {width: 60px}
.width-120 {width: 120px;}
.width-600 {width: 600px;}
.font-12 {font-size: 12px;}
style>
head>
<body>
<div id="app">
<label><input type="text" v-model="input">label>
<button @click="request">搜索button>
<table v-if="showList">
<tr v-for="(item, index) of list">
<td class="width-60"><img class="width-45" :src="item.avatarSrc" alt="">td>
<td class="width-120" v-html="item.name">td>
<td class="width-120" v-html="item.alias">td>
<td class="width-120" v-html="item.title">td>
<td class="width-600 font-12 text-indent-cn" v-html="item.shortBio">td>
tr>
table>
div>
<script>
new Vue({
el: '#app',
data () {
return {
input: '',
showList: false,
list: []
}
},
methods: {
request () {
axios({
url: `/highlight-search/${this.input}`
})
.then(response => {
const { total, size, list } = response.data
this.list = list
this.showList = true
})
}
}
})
script>
body>
html>
效果:
SpringBoot 访问/resources/static/
下的静态资源的方式为:http://127.0.0.1:8080/js/vue.min.js
,在 thymleaf 中引用的方式为:
<script th:src="@{/js/vue.min.js}">script>
但在 IDEA2020.2 版本中,将文件复制进/resources/static/
目录后,需要重启 IDEA 才能正常访问到。