1. 安装elasticsearch+ik分词器插件
sudo wget https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.13.4.zip
sudo mkdir -p ./es_plugins/analysis-ik
sudo mkdir ./es_data
sudo unzip elasticsearch-analysis-ik-8.13.4.zip -d ./es_plugins/analysis-ik
sudo chown liber:liber es_data/ es_plugins/
1.1 docker-compose
version: '3.8'
services:elasticsearch:image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4container_name: elasticsearchenvironment:- discovery.type=single-node- xpack.security.enabled=true - bootstrap.memory_lock=true- "ES_JAVA_OPTS=-Xms512m -Xmx512m"ulimits:memlock:soft: -1hard: -1mem_limit: 1g ports:- "9200:9200"- "9300:9300"networks:- esnetvolumes:- ./es_data:/usr/share/elasticsearch/data- ./es_plugins:/usr/share/elasticsearch/plugins
networks:esnet:
启动容器命令:
docker-compose up -d
1.2 修改密码
进入容器:
docker exec -it elasticsearch bash
验证分词器的安装:
bin/elasticsearch-plugin list
手动更改密码: 使用 Elasticsearch 提供的 elasticsearch-reset-password
工具重置 elastic
用户的密码:
bin/elasticsearch-reset-password -u elastic
1.3 可能会用到的指令
删除索引:
curl -u elastic:dxOHCIBIWu+2djY6qtF1 -X DELETE "http://127.0.0.1:9200/articles"
测试ik分词器:
curl -X GET "http://127.0.0.1:9200/_analyze" -u elastic:dxOHCIBIWu+2djY6qtF1 -H 'Content-Type: application/json' -d'{"analyzer": "ik_max_word","text": "Spring Boot 使用入门"}'
说明:dxOHCIBIWu+2djY6qtF1是密码。
2. 项目结构
2.1 完整的项目
说明:全局控制版本的依赖可以参考上一篇文章,本文只介绍artice-service模块。
2.2 artice-service模块
3. 数据库操作
create database if not exists blog;
use blog;
CREATE TABLE if not exists users
(id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 用户的唯一标识符,自动递增的主键email VARCHAR(100) NOT NULL UNIQUE, -- 电子邮件,不能为空且唯一,长度限制为100个字符username VARCHAR(12) NOT NULL UNIQUE, -- 用户名,不能为空且唯一,长度限制为12个字符password VARCHAR(255) NOT NULL, -- 用户密码,不能为空,存储为加密后的字符串name VARCHAR(50), -- 用户显示名称,非必填,长度限制为50个字符avatar_url VARCHAR(255), -- 用户头像的URL或文件路径,非必填,长度限制为255个字符role VARCHAR(20) NOT NULL DEFAULT 'USER', -- 用户角色,不能为空,默认值为 'USER'enabled BOOLEAN NOT NULL DEFAULT TRUE, -- 账户启用状态,不能为空,默认值为TRUE(启用)created_at DATETIME DEFAULT CURRENT_TIMESTAMP, -- 记录创建时间,默认值为当前时间戳updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -- 记录最后更新时间,自动更新为当前时间戳
) ENGINE = InnoDBDEFAULT CHARSET = utf8mb4COLLATE = utf8mb4_unicode_ci;CREATE TABLE articles
(id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 主键,自增IDtitle VARCHAR(255) NOT NULL, -- 文章标题,非空content TEXT NOT NULL, -- 文章内容 (Markdown格式),非空author VARCHAR(100) NOT NULL, -- 作者名称,非空user_id BIGINT NOT NULL, -- 关联用户ID,外键created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间,默认当前时间updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, -- 更新时间,默认当前时间,更新时自动更新FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE -- 外键约束,用户删除时级联删除文章
) ENGINE = InnoDBDEFAULT CHARSET = utf8mb4COLLATE = utf8mb4_unicode_ci; -- 使用utf8mb4字符集和Unicode排序规则INSERT INTO articles (title, content, author, user_id)
VALUES ('Spring Boot 入门教程', '本文介绍如何使用 Spring Boot 快速创建一个应用程序。', '张三', 1),('MyBatis 使用指南', '这篇文章将讲解 MyBatis 的基本使用方法。', '李四', 1),('Elasticsearch 搜索引擎', '深入了解 Elasticsearch 的功能和用法。', '王五', 1),('Java 并发编程', '本文详细讨论了 Java 中的并发编程技术。', '赵六', 1),('Docker 容器化技术', '这篇文章介绍了如何使用 Docker 进行应用容器化。', '孙七', 1),('微服务架构设计', '讨论了微服务架构的设计原则与实践。', '周八', 1),('Redis 数据库应用', '本文详细介绍了 Redis 的常用操作与应用场景。', '吴九', 1),('Spring Cloud 微服务', '使用 Spring Cloud 构建分布式系统。', '郑十', 1),('RabbitMQ 消息队列', '探讨了 RabbitMQ 在消息队列中的应用。', '张三', 1),('Kubernetes 集群管理', '介绍 Kubernetes 的基本概念与使用。', '李四', 1),('RESTful API 设计', '探讨如何设计和实现 RESTful API。', '王五', 1),('CI/CD 持续集成与部署', '本文介绍了 CI/CD 的概念和工具。', '赵六', 1),('分布式系统概念', '讨论了分布式系统的核心概念。', '孙七', 1),('大数据处理技术', '探讨了 Hadoop 和 Spark 在大数据处理中的应用。', '周八', 1),('NoSQL 数据库应用', '介绍了 NoSQL 数据库的特点与应用场景。', '吴九', 1),('前端开发框架 React', '详细介绍了 React 的基本概念与用法。', '郑十', 1),('Vue.js 框架使用', '讨论了如何使用 Vue.js 构建用户界面。', '张三', 1),('Angular 开发指南', '本文讲解了如何使用 Angular 进行开发。', '李四', 1),('Git 版本控制系统', '介绍了 Git 的基本操作与分支管理。', '王五', 1)('Agile 敏捷开发实践', '探讨了敏捷开发的原则与实践。', '赵六', 1);
4. pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>info.liberx</groupId><artifactId>blog</artifactId><version>0.0.1-SNAPSHOT</version></parent><artifactId>article-service</artifactId><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><version>8.0.33</version></dependency><dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>3.0.3</version></dependency><dependency><groupId>com.github.pagehelper</groupId><artifactId>pagehelper-spring-boot-starter</artifactId><version>1.4.6</version></dependency><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-netflix-eureka-client</artifactId></dependency><!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind --><dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId><version>2.17.2</version></dependency><dependency><groupId>com.fasterxml.jackson.datatype</groupId><artifactId>jackson-datatype-jsr310</artifactId><version>2.17.2</version></dependency><dependency><groupId>com.github.ben-manes.caffeine</groupId><artifactId>caffeine</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-elasticsearch</artifactId></dependency></dependencies>
</project>
5. application.yml
spring:application:name: article-servicedatasource:url: jdbc:mysql://localhost:3306/blog?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8username: rootpassword: 123456driver-class-name: com.mysql.cj.jdbc.Driverdata:redis:port: 6379host: 192.168.186.77password: 123456timeout: 10000jackson:serialization:write-dates-as-timestamps: false
elasticsearch:url: 192.168.186.77 # Elasticsearch 地址port: 9200username: elastic # 如果有设置认证,提供用户名password: dxOHCIBIWu+2djY6qtF1 # 如果有设置认证,提供密码
mybatis:configuration:map-underscore-to-camel-case: truecache-enabled: true
eureka:client:service-url:defaultZone: http://localhost:8761/eureka/register-with-eureka: truefetch-registry: true
server:port: 8002
说明:Eurka中心,Redis在本案例暂时没有演示,需要Eurka中心是为了注册到服务中心通过Gateway网关进行转发,读者可以自行去掉一些不必要的依赖项还有配置项。
6. ElasticsearchConfig.java
package info.liberx.articleservice.config;import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;@Configuration
public class ElasticsearchConfig {@Value("${elasticsearch.url}")private String url;@Value("${elasticsearch.username}")private String username;@Value("${elasticsearch.password}")private String password;@Value("${elasticsearch.port}")private int port;@Beanpublic ElasticsearchClient elasticsearchClient() {// 配置 Jackson 的 ObjectMapper 并注册 JavaTimeModuleObjectMapper objectMapper = new ObjectMapper();objectMapper.registerModule(new JavaTimeModule());// 创建自定义的 JacksonJsonpMapperJacksonJsonpMapper jsonpMapper = new JacksonJsonpMapper(objectMapper);// 配置身份验证final BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();credentialsProvider.setCredentials(AuthScope.ANY,new UsernamePasswordCredentials(username, password));// 配置 RestClientRestClientBuilder builder = RestClient.builder(new HttpHost(url, port)).setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));RestClient restClient = builder.build();// 使用 RestClientTransport 创建 ElasticsearchClientRestClientTransport transport = new RestClientTransport(restClient, jsonpMapper);return new ElasticsearchClient(transport);}
}
说明:注册了一个 JavaTimeModule,它使得 Jackson 能够正确处理 Java 8 的日期和时间 API。
7. ArticleController.java
package info.liberx.articleservice.controller;import com.github.pagehelper.PageInfo;
import info.liberx.articleservice.model.Article;
import info.liberx.articleservice.service.ArticleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;import java.io.IOException;@RestController
@RequestMapping("/articles")
public class ArticleController {private final ArticleService articleService;@Autowiredpublic ArticleController(ArticleService articleService) {this.articleService = articleService;}// 搜索文章@GetMapping("/search")public ResponseEntity<Page<Article>> searchArticles(@RequestParam String keyword,@RequestParam(defaultValue = "0") int page,@RequestParam(defaultValue = "10") int size) {Pageable pageable = PageRequest.of(page, size);try {Page<Article> articles = articleService.searchArticles(keyword, pageable);return new ResponseEntity<>(articles, HttpStatus.OK);} catch (IOException e) {return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);}}// 创建新文章@PostMappingpublic ResponseEntity<Article> createArticle(@RequestBody Article article) {int result = articleService.createArticle(article);if (result > 0) {return new ResponseEntity<>(article, HttpStatus.CREATED);} else {return new ResponseEntity<>(HttpStatus.BAD_REQUEST);}}// 根据文章ID获取文章@GetMapping("/{id}")public ResponseEntity<Article> getArticleById(@PathVariable Long id) {Article article = articleService.getArticleById(id);if (article != null) {return new ResponseEntity<>(article, HttpStatus.OK);} else {return new ResponseEntity<>(HttpStatus.NOT_FOUND);}}// 更新文章@PutMapping("/{id}")public ResponseEntity<Article> updateArticle(@PathVariable Long id, @RequestBody Article article) {article.setId(id); // 确保更新的是指定ID的文章int result = articleService.updateArticle(article);if (result > 0) {return new ResponseEntity<>(article, HttpStatus.OK);} else {return new ResponseEntity<>(HttpStatus.BAD_REQUEST);}}// 删除文章@DeleteMapping("/{id}")public ResponseEntity<String> deleteArticle(@PathVariable Long id) {int result = articleService.deleteArticle(id);if (result > 0) {return new ResponseEntity<>("文章删除成功", HttpStatus.OK);} else {return new ResponseEntity<>("文章删除失败", HttpStatus.NOT_FOUND);}}// 获取所有文章// 获取所有文章,支持分页@GetMapping("/get/{userid}")public ResponseEntity<PageInfo<Article>> getAllArticles(@PathVariable Long userid,@RequestParam(defaultValue = "1") int page, // 注意:PageHelper 的页码从1开始@RequestParam(defaultValue = "10") int size) {// 获取分页结果PageInfo<Article> articles = articleService.getArticlesByUserId(userid, page, size);return new ResponseEntity<>(articles, HttpStatus.OK);}
}
8. ArticleMapper.java
package info.liberx.articleservice.mapper;import info.liberx.articleservice.model.Article;
import org.apache.ibatis.annotations.*;import java.util.List;@Mapper
public interface ArticleMapper {@Insert("INSERT INTO articles(title, content, author, user_id) VALUES(#{title}, #{content}, #{author}, #{userId})")@Options(useGeneratedKeys = true, keyProperty = "id")int insertArticle(Article article);@Select("SELECT * FROM articles WHERE id = #{id}")Article findArticleById(Long id);@Select("SELECT * FROM articles WHERE user_id = #{userId}")List<Article> findArticlesByUserId(Long userId);@Update("UPDATE articles SET title = #{title}, content = #{content}, author = #{author} WHERE id = #{id}")int updateArticle(Article article);@Delete("DELETE FROM articles WHERE id = #{id}")int deleteArticle(Long id);@Select("SELECT * FROM articles")List<Article> findAllArticles();
}
9. Article.java
package info.liberx.articleservice.model;import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;import java.time.OffsetDateTime;@Data
@Document(indexName = "articles") // 定义为 Elasticsearch 索引
@JsonIgnoreProperties(ignoreUnknown = true) // 忽略未识别的字
public class Article {@Id // 标记为 Elasticsearch 的文档IDprivate Long id;@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")private String title;@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")private String content; // Markdown 格式内容@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")private String author; // 使用 IK 分词器进行分词和搜索@Field(type = FieldType.Long)private Long userId; // 关联的用户ID@Field(type = FieldType.Date, format = {}, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSZ")private OffsetDateTime createdAt;@Field(type = FieldType.Date, format = {}, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSZ")private OffsetDateTime updatedAt;
}
10. ArticleRepository.java
package info.liberx.articleservice.repository;import info.liberx.articleservice.model.Article;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;@Repository
public interface ArticleRepository extends ElasticsearchRepository<Article, Long> {}
11. ArticleService.java
package info.liberx.articleservice.service;import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
import co.elastic.clients.elasticsearch.core.SearchRequest;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.HitsMetadata;
import co.elastic.clients.elasticsearch._types.query_dsl.QueryBuilders;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import info.liberx.articleservice.mapper.ArticleMapper;
import info.liberx.articleservice.model.Article;
import info.liberx.articleservice.repository.ArticleRepository;
import jakarta.annotation.PostConstruct;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;import java.io.IOException;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;@Service
public class ArticleService {private final ArticleMapper articleMapper;private final ArticleRepository articleRepository;private final ElasticsearchClient elasticsearchClient;public ArticleService(ElasticsearchClient elasticsearchClient, ArticleMapper articleMapper, ArticleRepository articleRepository) {this.elasticsearchClient = elasticsearchClient;this.articleMapper = articleMapper;this.articleRepository = articleRepository;}@PostConstruct// 同步数据库中的所有文章到 Elasticsearchpublic void indexAllArticles() {List<Article> articles = articleMapper.findAllArticles();articleRepository.saveAll(articles);}public int createArticle(Article article) {int result = articleMapper.insertArticle(article);if (result > 0) {articleRepository.save(article);}return result;}public Article getArticleById(Long id) {return articleMapper.findArticleById(id);}// 分页查询文章public PageInfo<Article> getArticlesByUserId(Long userId, int page, int size) {// 使用 PageHelper 设置分页参数PageHelper.startPage(page, size);// 查询结果List<Article> articles = articleMapper.findArticlesByUserId(userId);// 包装查询结果为 PageInfo 对象return new PageInfo<>(articles);}public int updateArticle(Article article) {int result = articleMapper.updateArticle(article);if (result > 0) {articleRepository.save(article);}return result;}public int deleteArticle(Long id) {int result = articleMapper.deleteArticle(id);if (result > 0) {articleRepository.deleteById(id);}return result;}public Page<Article> searchArticles(String keyword, Pageable pageable) throws IOException {// 构建布尔查询,首先匹配完全符合的记录,然后匹配部分符合的记录Query query = QueryBuilders.bool().should(QueryBuilders.multiMatch().fields("title", "content", "author").query(keyword).type(co.elastic.clients.elasticsearch._types.query_dsl.TextQueryType.Phrase) // 完全匹配.boost(2.0f) // 提高完全匹配的权重.build()._toQuery()).should(QueryBuilders.multiMatch().fields("title", "content", "author").query(keyword).build()._toQuery()) // 部分匹配.build()._toQuery();// 构建搜索请求,设置索引、查询条件、高亮设置、分页参数SearchRequest searchRequest = new SearchRequest.Builder().index("articles").query(query).highlight(h -> h.fields("title", f -> f.preTags("<strong>").postTags("</strong>")).fields("content", f -> f.preTags("<strong>").postTags("</strong>")).fields("author", f -> f.preTags("<strong>").postTags("</strong>"))).from((int) pageable.getOffset()).size(pageable.getPageSize()).build();// 执行搜索请求SearchResponse<Article> searchResponse = elasticsearchClient.search(searchRequest, Article.class);HitsMetadata<Article> hitsMetadata = searchResponse.hits();// 处理搜索结果,提取高亮信息并设置到对应的Article对象中List<Article> articles = hitsMetadata.hits().stream().map(hit -> {Article article = hit.source();if (article != null && hit.highlight() != null) {hit.highlight().forEach((field, highlights) -> {switch (field) {case "title":article.setTitle(String.join(" ", highlights));break;case "content":article.setContent(String.join(" ", highlights));break;case "author":article.setAuthor(String.join(" ", highlights));break;}});}return article;}).collect(Collectors.toList());// 返回包含分页和高亮结果的Page对象return new PageImpl<>(articles, pageable, Objects.requireNonNull(hitsMetadata.total()).value());}}
12. ArticleServiceApplication.java
package info.liberx.articleservice;import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.client.loadbalancer.LoadBalancerAutoConfiguration;
import org.springframework.cloud.client.loadbalancer.reactive.LoadBalancerBeanPostProcessorAutoConfiguration;
import org.springframework.data.web.config.EnableSpringDataWebSupport;@SpringBootApplication(exclude = {LoadBalancerAutoConfiguration.class, LoadBalancerBeanPostProcessorAutoConfiguration.class})
@EnableDiscoveryClient
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
public class ArticleServiceApplication {public static void main(String[] args) {SpringApplication.run(ArticleServiceApplication.class, args);}
}
13. 测试接口(postman)
13.1 测试创建文章 (POST /articles)
POST http://localhost:8002/articles
Body:
选择 raw
-> JSON
格式,示例如下:
{"title": "Spring Boot 保姆级教程","content": "本文介绍如何使用 Spring Boot 快速创建一个应用程序。","author": "张三","userId": 1
}
13.2 测试根据文章 ID 获取文章 (GET /articles/{id})
GET http://localhost:8002/articles/1
13.3 测试更新文章 (PUT /articles/{id})
PUT http://localhost:8002/articles/1
Body:
选择 raw
-> JSON
格式:
{"title": "Spring Boot 入门教程 - 更新","content": "本文详细介绍了如何使用 Spring Boot 创建应用程序,并提供更新。","author": "张三","userId": 1
}
13.4 测试删除文章 (DELETE /articles/{id})
DELETE http://localhost:8002/articles/1
13.5 测试搜索文章 (GET /articles/search)
GET http://localhost:8002/articles/search?keyword=Spring&page=0&size=10
13.6 测试获取某用户的所有文章并分页 (GET /articles/get/{userid})
GET http://localhost:8002/articles/get/1?page=1&size=10