前言
最近在自己的分布式博客项目中,实现了一个简单的基于es的文章推荐功能,逻辑就是在用户进行像 阅读、点赞、评论、收藏 某文章时,记录其行为数据到es,具体的数据内容有 用户id、行为类型、文章分类id、文章标签id集合(文章可有多标签)、目标用户id(文章作者),然后 在首页查询推荐文章列表时,聚合查询出当前登录用户 最常看的作者、文章分类、文章标签,然后使用should关键字,查询推荐文章,也就是说 如果存在被推荐文章 就排到前面 没有被推荐的 也应该在后边被查询出来。逻辑比较简单 分享一下代码
代码
项目代码:
行为日志
首先定义了一个索引 behavior_log ,用来记录 某用户的行为数据。定义了一个dto类,用来封装对应数据,该数据由文章服务通过openfeign调用检索服务的接口 来保存行为数据。
BehaviorLogDto
/**
* 用户行为记录
*/
@Data
public class BehaviorLogDto {
/**
* 用户id
*/
private String userId;
/**
* 操作类型:阅读、点赞、评论、收藏、关注
*/
private String behaviorType;
/**
* 目标用户id:文章作者id或被关注用户id
*/
private String targetUserId;
/**
* 文章分类id
*/
private String categoryId;
/**
* 文章标签id
*/
private List<String> tagIds;
/**
* 行为时间
*/
private LocalDateTime behaviorTime = LocalDateTime.now();
}
BehaviorLogController
@RestController
@RequestMapping("/behaviorLog")
public class BehaviorLogController {
@Autowired
private RestHighLevelClient client;
@PostMapping("/save")
public Result save(@RequestBody BehaviorLogDto dto) {
IndexRequest indexRequest = new IndexRequest("behavior_log");
indexRequest.source(JSONUtil.toJsonStr(dto), XContentType.JSON);
try {
client.index(indexRequest, RequestOptions.DEFAULT);
return Result.success("保存成功!");
} catch (Exception e) {
return Result.error("保存失败!" + e.getMessage());
}
}
}
文章服务通过openfeign来调用该接口,如下
@FeignClient("blog-search")
public interface SearchFeignService {
@Async
@PostMapping("/publish")
Result publish(@RequestBody ArticleDocument doc);
@PostMapping("/publish")
Result publishSync(@RequestBody ArticleDocument doc);
@Async
@PostMapping("/behaviorLog/save")
Result saveBehaviorLog(@RequestBody BehaviorLogDto dto);
}
推荐文章查询
在首页查询推荐文章时 直接调用检索服务的查询接口,从es查询数据。
按照上面说的:先聚合出用户常看的作者、文章分类、文章标签、然后通过should关键字过滤。
@Override
public List<ArticleDocument> list(Long page, String categoryId, String orderType) {
if (page == null) page = 1L;
page = (page - 1L) * 10L;
SearchRequest searchRequest = new SearchRequest("article_list");
SearchSourceBuilder builder = new SearchSourceBuilder();
//构建复杂查询条件
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
//设置文章分类检索参数
if (StrUtil.isNotBlank(categoryId)) {
boolQueryBuilder.must(QueryBuilders.matchQuery("categoryId", categoryId));
}
//如果查看推荐文章,则需要根据用户点赞、评论、收藏过的文章的作者id、分类id、标签 来做过滤
if ("recommend".equals(orderType)) {
String userId = UserUtil.getUserId();
if (StrUtil.isNotBlank(userId)) {
//用户已登录 按照用户的行为日志来分析
buildBoolQueryBuilder(boolQueryBuilder);
} else {
//用户未登录 按照点击量推荐
builder.sort("clickCount", SortOrder.DESC);
}
builder.query(boolQueryBuilder);
}
//如果查看最新文章,只需要根据发布时间倒序排列
if ("newest".equals(orderType)) {
builder.sort("publishTime", SortOrder.DESC);
}
//设置分页参数
builder.from(page.intValue());
builder.size(10);
try {
searchRequest.source(builder);
SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
SearchHits hits = searchResponse.getHits();
if (hits.getHits() != null) {
return Arrays.stream(hits.getHits())
.map(hit -> JSONUtil.toBean(hit.getSourceAsString(), ArticleDocument.class))
.collect(Collectors.toList());
}
} catch (Exception e) {
log.error("查询es中的文章列表时报错:{}", e.getMessage(), e);
}
return new ArrayList<>();
}
由于首页上有一个文章分类的切换,并且我采用了分页查询简单的做了懒加载,所以还需要处理分类id和分页参数。注意这里过滤分类id使用了must关键字 也就是说 如果查询当前分类,就不允许查询出其他分类数据,这里如果不使用must,是有可能查出其他分类数据的。
buildBoolQueryBuilder是一个构建查询条件的方法 代码如下
/**
* 构建推荐文章查询条件
*
* @return
*/
private void buildBoolQueryBuilder(BoolQueryBuilder boolQueryBuilder) {
//创建聚合查询,查询出用户最常关注的 作者、文章分类、文章标签
SearchSourceBuilder builder = new SearchSourceBuilder();
builder.query(QueryBuilders.matchPhraseQuery("userId", UserUtil.getUserId()));
//对常看的作者进行聚合 取前100个最常看的作者
builder.aggregation(getAggregationBuilder("authorAgg", "targetUserId.keyword", 100));
//对常看的文章分类进行聚合 取前20个最常看的分类
builder.aggregation(getAggregationBuilder("categoryAgg", "categoryId.keyword", 20));
//对常看的文章标签进行聚合 取前50最常看的标签
builder.aggregation(getAggregationBuilder("tagAgg", "tagIds.keyword", 50));
SearchRequest searchRequest = new SearchRequest("behavior_log");
searchRequest.source(builder);
try {
SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT);
//解析作者id
boolQueryBuilder.should(parseResultToQueryBuilder(result, "authorAgg", "authorId.keyword"));
//解析分类id
boolQueryBuilder.should(parseResultToQueryBuilder(result, "categoryAgg", "categoryId.keyword"));
//解析标签id
boolQueryBuilder.should(parseResultToQueryBuilder(result, "tagAgg", "tagIdList.keyword"));
} catch (Exception e) {
log.error("构建复杂查询条件时报错:{}", e.getMessage(), e);
}
}
/**
* 根据传入的聚合名称 聚合字段 数据条数 创建 聚合构建器
*
* @param aggName
* @param fieldName
* @param size
* @return
*/
private AggregationBuilder getAggregationBuilder(String aggName, String fieldName, int size) {
TermsAggregationBuilder aggregationBuilder = AggregationBuilders.terms(aggName);
return aggregationBuilder.field(fieldName).size(size).order(BucketOrder.count(false));
}
/**
* 解析查询结果 封装为QueryBuilder
*
* @param result
* @param aggName
* @param fieldName
* @return
*/
private QueryBuilder parseResultToQueryBuilder(SearchResponse result, String aggName, String fieldName) {
List<String> list = ((ParsedStringTerms) result.getAggregations().get(aggName)).getBuckets().stream()
.map(bucket -> bucket.getKey().toString()).collect(Collectors.toList());
return list.isEmpty() ? QueryBuilders.matchAllQuery() : QueryBuilders.termsQuery(fieldName, list);
}
可以看到就是构建了三个聚合查询,查出了作者id、文章分类id、文章标签id 三个集合,然后使用should关键字过滤。使得最终查询 会尽量的查询满足条件的数据,如果没有 也能够正常查询出数据。
总结
以上 完成了一个简单的 基于es的文章推荐。