Elasticsearchクエリフレームワーク: MyBatis-Plusライクな簡潔なデータアクセス

ElasticsearchのクエリAPIは非常に強力で柔軟ですが、複雑な検索条件や多岐にわたるフィルタリング、集計が必要な場合、その記述は冗長になりがちです。特に、複数の条件を組み合わせたクエリや、頻繁に条件が変更されるようなシナリオでは、コードの可読性や保守性が低下することが開発者の課題となることがあります。

本記事で紹介するフレームワークは、このようなElasticsearchのデータアクセスに関する課題を解決するために開発されました。これは、MyBatis-Plusのように宣言的なアプローチでElasticsearchクエリを定義できることが最大の特徴であり、クエリの記述を大幅に簡素化し、開発効率の向上に貢献します。

目的と適用シナリオ

このフレームワークは、主にElasticsearchを主要なデータソースとして利用し、多数のクエリ条件が存在するシステムに最適です。クエリ定義の簡素化と開発効率の向上を主要な目的としています。現在のバージョンでは、CRUD操作の一部はMyBatis-Plusの基底クラスに似た形で提供されますが、フレームワークの主要な焦点はクエリ機能の強化にあります。

主要コンポーネント

  • EsQueryEngine: クエリオブジェクトを受け取り、それに基づいてElasticsearchのクエリDSLを生成します。インデックスは@EsQueryIndexアノテーションで定義され、各フィールドのクエリ動作は@Match, @MultiMatch, @Termといったアノテーションで宣言的に記述されます。
  • RequestHook: クエリ実行前のリクエストをカスタマイズするための拡張インターフェースです。
  • ResponseHook: ElasticsearchからのSearchResponseをカスタムロジックで解釈・処理するための拡張インターフェースです。

利用開始

フレームワークの有効化

Spring Bootアプリケーションのメインクラスに@EnableEsHelperアノテーションを追加することで、フレームワークを有効化できます。

@EnableEsHelper // EsHelperフレームワークを有効化
@SpringBootApplication(scanBasePackages = "com.example")
public class SampleApplication {
    public static void main(String[] args) {
        SpringApplication.run(SampleApplication.class, args);
    }
}

設定

application.propertiesまたはapplication.ymlファイルで、フレームワークの動作を細かく設定できます。

es:
  helper:
    queryLogOut:
      enable: true # Elasticsearchのクエリログ出力を有効化
    ext:
      handle-packages: com.example.handlers # カスタムハンドラパッケージの指定

クエリの記述方法

方法1: MyBatis-PlusライクなAPI(v2.0.0以降)

この方法は、MyBatis-PlusのLambdaQueryWrapperに似たスタイルでElasticsearchのクエリを記述します。

ESインデックスエンティティの定義

ElasticsearchのインデックスにマッピングされるJavaエンティティクラスを定義し、EsEntityを継承して@EsIndexアノテーションでインデックス名を指定します。

@Data
@EsIndex("user_accounts") // Elasticsearchのインデックス名
public class UserAccountEntity extends EsEntity implements Serializable {

    private Integer accountNumber;
    private Integer balance;
    private String firstName;
    private String lastName;
    private Integer age;
    private String gender;
    private String address;
    private String employer;
    private String email;
    private String city;
    private String state;
}
Mapperインターフェースの定義

org.pippi.elasticsearch.helper.model.annotations.mapper.EsMapperを継承し、@EsMapperアノテーションを付けたインターフェースを定義します。

@EsMapper
public interface UserAccountMapper extends EsBaseMapper<UserAccountEntity> {
}
Mapperを使用したESクエリの実行
@RunWith(SpringRunner.class)
@SpringBootTest(classes = SampleApplication.class)
public class UserAccountQueryTest {

    @Resource
    private UserAccountMapper userAccountMapper;

    @Test
    public void fetchAggregations() {
        AggRes aggResult = userAccountMapper.selectAgg(EsWrappers.lambdaQuery(UserAccountEntity.class)
                .term(UserAccountEntity::getGender, "F")
                .agg(AggregationBuilders.terms("_city_dist").field("city").size(1000)
                        .subAggregation(AggregationBuilders.count("_person_count").field("city"))));
        System.out.println(JacksonUtils.parseObjToJsonPretty(aggResult.fetchByPath("$._city_dist.Nogal")));
    }

    @Test
    public void findByTermQuery() {
        List<UserAccountEntity> userList = userAccountMapper.selectList(EsWrappers.lambdaQuery(UserAccountEntity.class)
                .term(UserAccountEntity::getFirstName, "Dena"));
        System.out.println(JacksonUtils.parseObjToJsonPretty(userList));
    }
}

方法2: クエリBeanによるESクエリの記述

このアプローチでは、クエリの動作をJava Beanのフィールドにアノテーションで記述します。

クエリパラメータオブジェクトの定義
@EsQueryIndex(index = "article_data", model = QueryModel.BOOL)
@HighLight(fields = {"title", "description"}) // ハイライトを有効化
public class ArticleSearchParam {
    // 範囲検索(指定された範囲内)
    @Range(value = @Base, tag = Range.LE_GE)
    private RangeParam viewCountRange;
    
    // 複数フィールドに対するマルチマッチ検索
    @MultiMatch(value = @Base, fields = {"title", "content"})
    private String keyword;
}
結果オブジェクトの定義

デフォルトのクエリ結果処理クラスはBaseResp.BaseHitを継承する必要があります。

public class ArticleContent extends BaseResp.BaseHit {

    private int viewCount;
    private String title;
    private String description;
}
代理インターフェースの定義
@EsHelperProxy
public interface ArticleQueryService {
    BaseResp<ArticleContent> queryArticles(ArticleSearchParam params);
}
呼び出し例
@RunWith(SpringRunner.class)
@SpringBootTest(classes = SampleApplication.class)
public class ArticleQueryServiceTest {

    @Resource
    private ArticleQueryService articleQueryService;

    @Test
    public void performArticleSearch() {
        ArticleSearchParam searchParams = new ArticleSearchParam();
        RangeParam range = new RangeParam();
        range.setLeft(100); // 最小閲覧数
        range.setRight(200); // 最大閲覧数
        searchParams.setViewCountRange(range);
        searchParams.setKeyword("テスト記事"); // 検索キーワード

        BaseResp<ArticleContent> response = articleQueryService.queryArticles(searchParams);
        System.out.println(SerializerUtils.parseObjToJson(response));
    }
}

方法3: メソッドパラメータによるESクエリのアノテーション

シンプルなクエリの場合、クエリBeanを定義せずに、メソッドパラメータに直接Elasticsearchのクエリアノテーションを付与することで、クエリを記述できます。

@EsMapper
public interface AnnotationQueryMapper {

    @EsAnnQueryIndex(index = "user_accounts")
    List<UserAccountEntity> searchUsersByAgeAndNames(@Gte Integer minAge, // 年齢がminAge以上
                                                     @Terms(@Base("firstName.keyword")) String ... userNames); // 氏名でのTermクエリ
}

この方法は、クエリBeanを定義する手間を省き、より簡潔にESクエリを記述するための拡張として利用できます。

拡張性

特殊なパラメータ型

フレームワークは、特定のクエリタイプに対応する特殊なパラメータ型を提供しています。例えば:

  • MoreLikeThisParam: @MoreLikeThisアノテーションで修飾されるプロパティに使用します。
  • PageParam: @PageAndOrderアノテーションでページングとソートを定義する際に使用します。
  • RangeParam: @Rangeアノテーションで範囲クエリを定義する際に使用します。

フック機能

フレームワークはすべてのクエリシナリオをカバーできないため、RequestHookとResponseHookインターフェースを通じて、カスタムのクエリ処理や結果処理を定義できる拡張ポイントを提供します。

  • クエリオブジェクト内でフックを実装する場合: クエリオブジェクトがHookQuery抽象クラスを継承し、setRequestHookおよびsetResponseHookメソッドをオーバーライドします。
    public class MyCustomQueryParam extends HookQuery {
        
        @Match(value = @Base)
        private String targetField;
        
        @Override
        public void setRequestHook() {
            super.requestHook = (requestBuilder, params) -> {
                // ここでElasticsearchのリクエストビルダーをカスタマイズ
                return requestBuilder;
            };
        }
        @Override
        public void setResponseHook() {
            super.responseHook = resp -> {
                // ここでElasticsearchのSearchResponseをカスタム処理し、任意のオブジェクトを返す
                return null;
            };
        }
    }
    
  • グローバルなフックを定義し、アノテーションで適用する場合: 共通のフックをインターフェースとして定義し、@UseRequestHookおよび@UseResponseHookアノテーションを使ってメソッドに適用できます。
    public interface CommonSearchHooks {
        RequestHook exampleReqHook = (requestBuilder, params) -> {
            // 全てのクエリに共通のリクエスト前処理を記述
            return requestBuilder;
        };
        ResponseHook exampleRespHook = resp -> {
            // 全てのクエリに共通のレスポンス後処理を記述
            return null; // またはカスタム結果オブジェクト
        };
    }
    
    @EsHelperProxy
    public interface CustomHookService {
        // クエリ処理後、RequestHookが実行され、その後結果処理Hookが適用される
        @UseRequestHook("exampleReqHook")
        @UseResponseHook("exampleRespHook")
        BaseResp<ArticleContent> queryWithHooks(ArticleSearchParam params);
    }
    

ハイライト機能のカスタマイズ

ハイライト設定はシステム全体で統一されていることが多いです。GlobalEsQueryConfigクラスを利用して、異なるハイライト設定を定義し、@HighLightアノテーションで適用する設定キーを指定できます。

class EsConfiguration { // Springの@Configurationクラスで設定
    void configureHighlights() {
        // デフォルトのハイライト設定を定義
        GlobalEsQueryConfig.configHighLight("DEFAULT_HL_KEY" ,() -> SearchSourceBuilder.highlight());
        // HTML形式のハイライト設定を定義(フラグメントサイズ、断片数、前後のタグを指定)
        GlobalEsQueryConfig.configHighLight("html_format" ,() ->
                SearchSourceBuilder.highlight().fragmentSize(10).numOfFragments(5)
                                .preTags("<span class='highlight'>").postTags("</span>")
        );
    }
}

クエリBeanでは、@HighLight(fields = {"title", "description"}, highLightKey = "html_format")のようにhighLightKeyを指定することで、定義されたハイライト設定を適用できます。

集計クエリのサポート

集計クエリはその複雑な特性から、結果の取得も工夫が必要です。フレームワークでは、集計結果をorg.pippi.elasticsearch.helper.model.resp.AggResクラスでラップし、JSON-Pathを利用して集計結果の特定データに迅速にアクセスする機能を提供します。

    @Test
    public void aggregationResultsAccess() {
        AggRes aggResult = userAccountMapper.selectAgg(EsWrappers.lambdaQuery(UserAccountEntity.class)
                .term(UserAccountEntity::getGender, "F")
                .agg(AggregationBuilders.terms("_city_dist").field("city").size(1000)
                        .subAggregation(AggregationBuilders.count("_person_count").field("city"))));
        // JSON-Pathで集計結果の特定データを取得
        System.out.println(JacksonUtils.parseObjToJsonPretty(aggResult.fetchByPath("$._city_dist.Nogal")));
    }

上記のように、aggResult.fetchByPath("$._city_dist.Nogal")を使用することで、集計ツリー内の任意のパスにあるデータを簡単に抽出できます。

現状と展望

このフレームワークはElasticsearch 7.13.3での動作が確認されており、理論的には7.x系のElasticsearchサーバーをサポートします。集計クエリは多岐にわたる複雑なシナリオを持つため、アノテーションのみでの完璧な表現は難しい場合がありますが、その際はフック機能を利用することで柔軟な集計動作を定義することが可能です。

タグ: Elasticsearch Spring Boot Java Query Framework MyBatis-Plus

9月21日 19:55 投稿