第五章 建構分布式搜尋引擎
Elasticsearch 簡介
- 一個分布式的、Restful風格的搜尋引擎
- 支援對各種類型的資料的檢索
- 搜尋速度快,可以提供實時的搜尋服務
- 便于水準擴充,每秒可以處理PB級海量資料
Elasticsearch 術語
- 索引、類型、文檔、字段
-
對應資料庫的一張表索引
-
對應資料庫的一張表裡面的一條資料文檔
-
對應資料庫的一張表裡面的一個字段字段
- 類型逐漸地在es中進行廢棄
- 叢集、節點、分片、副本
中文分詞插件
https://github.com/medcl/elasticsearch-analysis-ik
測試es
查詢es中一共有多少個索引
(GET)localhost:9200/_cat/indices?v
建立一條索引
删除一條索引
往es裡面插入一條資料
- 送出資料,用
請求PUT
- test表示索引,相當于資料庫中的表名
- _doc表示類型,在6.0版本已經逐漸被廢棄,新版本中完全廢棄,這裡使用的是6.0版本的es,是以使用
充當占位符_doc
- 1表示id,相當于資料庫中的id字段
查詢資料
删除一條資料
分詞搜尋
- 搜尋指定索引下的全部資料
- 搜尋test索引下title包含網際網路的資料,注意預設格式都是
_search?q=
Spring整合Elasticsearch
導入相關依賴
<!-- elasticsearch依賴 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
配置es
spring:
# 配置Elasticsearch
data:
elasticsearch:
cluster-name: nowcoder
# es有兩個端口:9200用于http連接配接,9300用于spring連接配接
cluster-nodes: localhost:9300
配置實體類
// indexName表示存儲在es中的索引名字
@Document(indexName = "discusspost", shards = 6, replicas = 3)
public class DiscussPost {
/**
* 文章id
*/
@Id
private int id;
/**
* 建立文章的使用者id
*/
@Field(type = FieldType.Integer)
private int userId;
/**
* 文章标題
* analyzer:存儲資料的時候采用這個分詞器,可以拆分出更多的詞彙,增加搜尋範圍
* searchAnalyzer:搜尋的時候采用這個分詞器,拆分出較少的詞彙,滿足搜尋需求即可
*/
@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;
/**
* 文章類型,0表示普通,1表示置頂
*/
@Field(type = FieldType.Integer)
private int type;
/**
* 文章狀态,0表示正常,1表示精華,2表示拉黑
*/
@Field(type = FieldType.Integer)
private int status;
/**
* 文章建立時間
*/
@Field(type = FieldType.Date)
private Date createTime;
/**
* 文章評論數量
*/
@Field(type = FieldType.Integer)
private int commentCount;
/**
* 文章評分
*/
@Field(type = FieldType.Double)
private double score;
}
資料層
/**
* ElasticsearchRepository<DiscussPost, Integer>:
* 第一個參數表示該接口需要處理的實體類是誰
* 第二個參數表示該實體類的主鍵的類型
*
* @author xiexu
* @create 2022-07-10 18:07
*/
@Repository
public interface DiscussPostRepository extends ElasticsearchRepository<DiscussPost, Integer> {
}
開發社群搜尋功能
搜尋服務
- 将文章儲存至Elasticsearch伺服器。
- 從Elasticsearch伺服器删除文章。
- 從Elasticsearch伺服器搜尋文章。
釋出事件
- 釋出文章時,将文章異步的送出到Elasticsearch伺服器。
- 增加評論時,将文章異步的送出到Elasticsearch伺服器。
- 在消費元件中增加一個方法,消費文章釋出事件。
顯示結果
- 在控制器中處理搜尋請求,在HTML上顯示搜尋結果。
業務層
@Service
public class ElasticsearchService {
@Autowired
private DiscussPostRepository discussRepository;
@Autowired
private ElasticsearchTemplate elasticTemplate;
/**
* 将文章資訊儲存到es
*
* @param post
*/
public void saveDiscussPost(DiscussPost post) {
discussRepository.save(post);
}
/**
* 在es中删除對應id的文章
*
* @param id
*/
public void deleteDiscussPost(int id) {
discussRepository.deleteById(id);
}
/**
* 根據關鍵字搜尋文章
*
* @param keyword 關鍵字
* @param current 目前顯示第幾頁,從0開始
* @param limit 每頁顯示多少條資料
* @return
*/
public Page<DiscussPost> searchDiscussPost(String keyword, int current, int limit) {
SearchQuery searchQuery = new NativeSearchQueryBuilder()
// 隻在title和content字段進行搜尋
.withQuery(QueryBuilders.multiMatchQuery(keyword, "title", "content"))
// 優先根據type字段進行倒序排序
.withSort(SortBuilders.fieldSort("type").order(SortOrder.DESC))
// 然後根據score字段進行倒序排序
.withSort(SortBuilders.fieldSort("score").order(SortOrder.DESC))
// 最後根據createTime字段進行倒序排序
.withSort(SortBuilders.fieldSort("createTime").order(SortOrder.DESC))
// 從第幾頁開始,每頁顯示多少條資料
.withPageable(PageRequest.of(current, limit))
// 高亮顯示字段
.withHighlightFields(
// 對title字段進行高亮顯示,在前端頁面顯示<em>title資訊</em>
new HighlightBuilder.Field("title").preTags("<em>").postTags("</em>"),
// 對content字段進行高亮顯示,在前端頁面顯示<em>content資訊</em>
new HighlightBuilder.Field("content").preTags("<em>").postTags("</em>")).build();
Page<DiscussPost> page = elasticTemplate.queryForPage(searchQuery, DiscussPost.class, new SearchResultMapper() {
@Override
public <T> AggregatedPage<T> mapResults(SearchResponse response, Class<T> aClass, Pageable pageable) {
// 先擷取這次搜尋命中的資料
SearchHits hits = response.getHits();
// 如果沒查到資料就直接傳回null
if (hits.getTotalHits() <= 0) {
return null;
}
List<DiscussPost> list = new ArrayList<>();
// 周遊搜尋命中的資料
for (SearchHit hit : hits) {
DiscussPost post = new DiscussPost();
String id = hit.getSourceAsMap().get("id").toString();
post.setId(Integer.valueOf(id));
String userId = hit.getSourceAsMap().get("userId").toString();
post.setUserId(Integer.valueOf(userId));
// 這是原始的title,并不是高亮顯示的title,高亮顯示的下面單獨處理
String title = hit.getSourceAsMap().get("title").toString();
post.setTitle(title);
String content = hit.getSourceAsMap().get("content").toString();
post.setContent(content);
String status = hit.getSourceAsMap().get("status").toString();
post.setStatus(Integer.valueOf(status));
String createTime = hit.getSourceAsMap().get("createTime").toString();
post.setCreateTime(new Date(Long.valueOf(createTime)));
String commentCount = hit.getSourceAsMap().get("commentCount").toString();
post.setCommentCount(Integer.valueOf(commentCount));
// 處理高亮顯示的結果
// 擷取與title有關的高亮顯示的内容
HighlightField titleField = hit.getHighlightFields().get("title");
if (titleField != null) {
// 高亮有可能是多個資料,是以我們這裡隻設定第一個資料高亮即可
post.setTitle(titleField.getFragments()[0].toString());
}
// 擷取與content有關的高亮顯示的内容
HighlightField contentField = hit.getHighlightFields().get("content");
if (contentField != null) {
// 高亮有可能是多個資料,是以我們這裡隻設定第一個資料高亮即可
post.setContent(contentField.getFragments()[0].toString());
}
list.add(post);
}
return new AggregatedPageImpl(list, pageable, hits.getTotalHits(), response.getAggregations(), response.getScrollId(), hits.getMaxScore());
}
});
return page;
}
}
控制層
/**
* 釋出文章
*
* @param title
* @param content
* @return
*/
@RequestMapping(path = "/add", method = RequestMethod.POST)
@ResponseBody
public String addDiscussPost(String title, String content) {
User user = hostHolder.getUser();
if (user == null) {
return CommunityUtil.getJSONString(403, "您還沒有登入哦!");
}
DiscussPost post = new DiscussPost();
post.setUserId(user.getId());
post.setTitle(title);
post.setContent(content);
post.setCreateTime(new Date());
discussPostService.addDiscussPost(post);
// 觸發發帖事件,把新釋出的文章存到es中
Event event = new Event()
// 事件主題
.setTopic(TOPIC_PUBLISH)
// 事件的觸發人
.setUserId(user.getId())
// 事件發生的實體類型
.setEntityType(ENTITY_TYPE_POST)
// 實體id
.setEntityId(post.getId());
// 釋出事件
eventProducer.fireEvent(event);
// 報錯的情況以後統一處理
return CommunityUtil.getJSONString(0, "釋出成功!");
}
// 添加評論
@RequestMapping(path = "/add/{discussPostId}", method = RequestMethod.POST)
public String addComment(@PathVariable("discussPostId") int discussPostId, Comment comment) {
comment.setUserId(hostHolder.getUser().getId());
comment.setStatus(0);
comment.setCreateTime(new Date());
commentService.addComment(comment);
// 添加評論以後觸發評論事件
Event event = new Event().setTopic(TOPIC_COMMENT)
// 目前使用者去評論
.setUserId(hostHolder.getUser().getId()).setEntityType(comment.getEntityType()).setEntityId(comment.getEntityId())
// 文章id
.setData("postId", discussPostId);
if (comment.getEntityType() == ENTITY_TYPE_POST) { // 如果評論的是文章
DiscussPost target = discussPostService.findDiscusspostById(comment.getEntityId());
event.setEntityUserId(target.getUserId());
// 計算文章分數
String redisKey = RedisKeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey, discussPostId);
} else if (comment.getEntityType() == ENTITY_TYPE_COMMENT) { // 評論的是評論
Comment target = commentService.findCommentById(comment.getEntityId());
event.setEntityUserId(target.getUserId());
}
// 釋出事件
eventProducer.fireEvent(event);
// 隻有評論的是文章,才觸發發帖事件
if (comment.getEntityType() == ENTITY_TYPE_POST) {
// 觸發發帖事件,把新釋出的文章存到es中
event = new Event()
// 事件主題
.setTopic(TOPIC_PUBLISH)
// 事件的觸發人
.setUserId(comment.getUserId())
// 事件發生的實體類型
.setEntityType(ENTITY_TYPE_POST)
// 實體id
.setEntityId(discussPostId);
// 釋出事件
eventProducer.fireEvent(event);
}
// 重定向到文章詳情頁
return "redirect:/discuss/detail/" + discussPostId;
}
消費事件類
// 消費者
@Component
public class EventConsumer implements CommunityConstant {
private static final Logger logger = LoggerFactory.getLogger(EventConsumer.class);
@Autowired
private MessageService messageService;
@Autowired
private DiscussPostService discussPostService;
@Autowired
private ElasticsearchService elasticsearchService;
// 消費發帖事件
@KafkaListener(topics = {TOPIC_PUBLISH})
public void handlePublishMessage(ConsumerRecord record) {
if (record == null || record.value() == null) {
logger.error("消息的内容為空!");
return;
}
Event event = JSONObject.parseObject(record.value().toString(), Event.class);
if (event == null) {
logger.error("消息格式錯誤!");
return;
}
// 根據文章id查詢文章資訊
DiscussPost post = discussPostService.findDiscusspostById(event.getEntityId());
// 将文章資訊添加到es中
elasticsearchService.saveDiscussPost(post);
}
}
控制層
@Controller
public class SearchController implements CommunityConstant {
@Autowired
private ElasticsearchService elasticsearchService;
@Autowired
private UserService userService;
@Autowired
private LikeService likeService;
// search?keyword=xxx
@RequestMapping(path = "/search", method = RequestMethod.GET)
public String search(String keyword, Page page, Model model) {
// 搜尋文章
org.springframework.data.domain.Page<DiscussPost> searchResult = elasticsearchService.searchDiscussPost(keyword, page.getCurrent() - 1, page.getLimit());
// 聚合資料
List<Map<String, Object>> discussPosts = new ArrayList<>();
if (searchResult != null) {
for (DiscussPost post : searchResult) {
Map<String, Object> map = new HashMap<>();
// 把文章存進去
map.put("post", post);
// 查詢搜尋到的資料對應的使用者資訊
User user = userService.findUserById(post.getUserId());
map.put("user", user);
// 查詢搜尋到的資料對應的文章點贊數
long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, post.getId());
map.put("likeCount", likeCount);
discussPosts.add(map);
}
}
model.addAttribute("discussPosts", discussPosts);
model.addAttribute("keyword", keyword);
// 分頁資訊
page.setPath("/search?keyword=" + keyword);
page.setRows(searchResult == null ? 0 : (int) searchResult.getTotalElements());
return "/site/search";
}
}