👷‍♂️

OpenSearch へのリクエストビルダーを Builder パターンで実装した

2024/08/01に公開

はじめに

レバテック開発部の瀬尾です。
OpenSearch への検索を担うシステムの運用保守をしています。

今回は、Search API へのリクエストで、そのシステムがこれまで対応していなかった Aggregations(集計)に追加対応させるにあたり、既存のリクエスト生成処理を Builder パターンでの実装に変更しました。
大したことはしていないのですが、スッキリ実装になってよかったので記事にしてみます。

背景

レバテックには案件検索ページがあります。
「案件」とは、サービス利用者のITフリーランスの方々が契約するためのものです。

このページの検索は、案件データを格納してある OpenSearch に「案件マイクロサービス」を通して行われます。案件マイクロサービスへの通信は gRPC で行われ、その検索エンドポイントは下記のように定義してあります。

案件検索のproto定義
service SearchProject {
  rpc Search(SearchRequest) returns (SearchResponse);
}

message SearchRequest {
  // 検索条件
}

message SearchResponse {
  repeated Project projects = 1;
  int32 total_size = 2;
}

Aggregations の結果をクライアントに返すにあたり、検索結果を任意の項目によって集計した数が返せればよかったため、結果の詳細を返す検索のエンドポイントとは rpc を分けることにしました。

なぜ Builder パターンか

既存のリクエストビルダーは、OpenSearch が対応している Request body のうち、query, sort, size, from の4つに対応しており、このようにリクエストを作っていました。

リクエストを作るcreateメソッド
class OpenSearchRequestFactory {
  create(req: ReqQuery): SearchRequestBody {
    const searchBody: SearchRequestBody = {
      query: this.convertConditionToSearchBody(req.condition),
      sort: this.convertSortListToSearchBody(req.sortList),
            size: req.limit,
      from: req.offset,
    }
    return searchBody
  }
  ...
}

create メソッドは、その4つの要素を必ず生成するようにハードコーディングされています。

新しくつくる集計エンドポイントで必要な Request Body は query + aggs(検索条件と集計条件)であるため、query の生成部分はほとんど使い回すことになりそうでした。しかし、上の create メソッドをそのまま流用しようとすると、別の実装部分を含め aggs が存在するか否かでさまざまな分岐が発生しそうでした。だからといって単純に create メソッドをわけても、同じようなリクエスト生成処理が2つ存在する状態になってしまうのもイケてないなと思いました。

そこで、このリクエスト生成処理を Builder パターン で実装することで、検索か集計かでこの create メソッドを分けても、実装の重複を少なく済ませることができると考えました。
Builder パターンは、規則のある複雑なオブジェクトの生成をステップごとに分解することで、動的な生成を可能にするもので、ORM の実装でよく見かけます。

実装

このような感じでリクエストビルダーを実装しました。

Builder
/**
 * OpenSearch Search API リクエスト
 */
export interface OpenSearchRequestBody {
  query: OpenSearchRequestQueryBody
  sort: OpenSearchRequestSortBody[]
  size?: number
  from?: number
  aggs?: OpenSearchRequestAggsBody
}

/**
 * リクエストのビルダー
 */
export class OpenSearchRequestBuilder {
  private readonly request: OpenSearchRequestBody = {
    query: {},
    sort: [],
  }

  build(): OpenSearchRequestBody {
    return this.request
  }

  query(raw: RawQuery) {
    // query の加工処理(既存)
    const query = convertCondition(raw)
    this.request.query = query
    return this
  }

  sort(raw: RawSort[]) { 
    // sort の加工処理(既存)
    const sort = convertSort(raw)
    this.request.sort = sort
    return this
  }

  size(size?: number) {
    this.request.size = size
    return this
  }

  from(from?: number) {
    this.request.from = from
    return this
  }

  aggs(rawAggs: RawAggs) {
    // aggs の加工処理(新規)
    const aggs = convertAggs(rawAggs)
    this.request.aggs = aggs
    return this
  }
}

この Builder を用いて、既存の create メソッドをそれぞれ検索用、集計用のものとして実装しなおします。Builder パターンでは、この組み立ての指示を出す側を Director といいます。

Director
class OpenSearchRequestDirector {
  createSearchBody(query: Query): OpenSearchRequestBody {
    const builder = new OpenSearchRequestBuilder()
    const searchBody = builder
      .query(query.condition)
      .sort(query.sortList)
      .size(query.size)
      .from(query.from)
      .build()
    return searchBody
  }

  createAggregateBody(query: Query, fields: string[]): OpenSearchRequestBody {
    const builder = new OpenSearchRequestBuilder()
    const searchBody = builder
      .query(query.condition)
      .aggs(fields)
      .size(0) // 結果の詳細を受け取らない
      .build()
    return searchBody
  }
}

Builder パターンを使うことで、既存実装の都合に合わせつつも重複の少ない実装にすることができました。

おわりに

上の実装例では省略しましたが、Builder 内の query 加工処理は、Visitor パターンで書かれていたりします。デザインパターンは油断した頃に使える場面が来るもんだなと思い、気が抜けないなと思いました。
いつもスマートな実装ができるように心がけていきたいです!

OpenSearch は今まで検索用にしか使われておらず、あまり他の機能には注目していなかったのですが、Aggregations のドキュメントを読むと思いの外いろんな集計ができるみたいで面白いなと思いました!
いつか試してみたいです。

レバテック開発部

Discussion