ElasticSearch实践:在分布式博客项目中实现简单的文章推荐

14 阅读4分钟

前言

最近在自己的分布式博客项目中,实现了一个简单的基于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的文章推荐。