本記事でやること
- go-elasticsearchを使ってElasticsearchからのレスポンスをmockする
- レスポンスをmockした単体テストを書く
- 内部実装のコードリーディングを通して何をmockしているのかを理解する
対象読者
- これからgo-elasticsearchを使って実装を始める方
使用言語
- Go 1.21.0
実装
前提
Elasticsearchにあるインデックスに対して検索クエリを実行し結果を返すSearch
メソッドを実装しました。
今回は、このSearch
メソッドが返すレスポンスをmockします。
Search
メソッドは、検索対象のインデックス名と実行するクエリを文字列として受け取り、検索結果をバイト配列で返します。
また、Search
メソッドはesapi.SearchRequest
構造体が持つDo
メソッドをラップすることで、Elasticsearchに対して検索リクエストを送信しています。
type esHandler struct {
client *elasticsearch.Client
}
func NewEsHandler(client *elasticsearch.Client) *esHandler {
return &esHandler{
client: client,
}
}
func (e *esHandler) Search(ctx context.Context, index, query string) ([]byte, error) {
req := esapi.SearchRequest{
Index: []string{index},
Body: strings.NewReader(query),
}
res, err := req.Do(ctx, e.client)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.IsError() {
var e errResponse
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("faild to read err response body: %w", err)
}
if err := json.Unmarshal(body, &e); err != nil {
return nil, fmt.Errorf("faild to unmarsal err response body: %w", err)
}
return nil, fmt.Errorf("failt to search: [%d] %s", e.Status, e.Error.Cause[0].Reason)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
return body, nil
}
type errResponse struct {
Status int `json:"status"`
Error errCause `json:"error"`
}
type errCause struct {
Cause []errReason `json:"root_cause"`
}
type errReason struct {
Reason string `json:"reason"`
}
mockの実装
簡単なテストコードを通して、Search
メソッドのレスポンスをmockします。
実装は以下の通りです。
mockするために行っていることは単純でelasticsearch.Config
構造体のTransport
フィールドにhttp.RoundTripper
型を満たす構造体(mockTransport
)を渡すだけです。
このmockTransport
構造体は、RoundTrip
メソッドを実装しており、http.RoundTripper
インターフェースを満たしています。このRoundTrip
メソッドでは、mockTransport
構造体が持つ、http.Response
を返すようにしています。
つまり、http.RoundTripper
インターフェースが持つ、RoundTrip
メソッドが返す値(http.Response
型)をmockすることで、Elasitcsearchからのレスポンスをmockすることができます。
type mockTransport struct {
response *http.Response
}
func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return m.response, nil
}
func TestSearch(t *testing.T) {
body := `{
"took" : 64,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 1,
"relation" : "eq"
},
"max_score" : 1.0,
"hits" : [
{
"_index" : "test_index",
"_id" : "1",
"_score" : 1.0,
"_source" : {
"title" : "hogehoge"
}
}
]
}
}`
mockTrans := &mockTransport{
response: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"X-Elastic-Product": []string{"Elasticsearch"}},
},
}
cfg := elasticsearch.Config{
Transport: mockTrans,
}
client, err := elasticsearch.NewClient(cfg)
if err != nil {
t.Fatal(err)
}
es := NewEsHandler(client)
res, err := es.Search(context.Background(), "test", "")
if err != nil {
t.Fatal(err)
}
var r esResponse
if err := json.Unmarshal(res, &r); err != nil {
t.Fatal(err)
}
if r.Hits.Hits[0].Source.Title != "hogehoge" {
t.Errorf("unexpected result: %s", r.Hits.Hits[0].Source.Title)
}
}
コードリーディング
以下ではgo-elasticsearchライブラリーの内部実装を読み進めていくため、記事の内容が少し長くなります。気になる方だけ読んでください。
なぜRoundTrip
メソッドをmockすることでElasticsearchのレスポンスをmockできるのかをgo-elasticsearchライブラリーのコードリーディングを通して理解します。
結論
先に結論だけを順序立てて書きます。
-
Do
メソッドの内部で実行されているesapi.Transport
インターフェースのPerform
メソッドの具象はelasticsearch.BaseClient
構造体のPerform
メソッドである -
elasticsearch.BaseClient
構造体が持つPeform
メソッドの内部で実行されているelastictransport.Interface
インターフェースのPerform
メソッドの具象はelastictransport.Client
構造体のPerform
メソッドである -
elastictransport.Interface
はelastic-transport-goライブラリーで定義されており、Elasticsearchとデータの送受信を行うためのインターフェースを提供している -
elastictransport.Client
構造体が持つPerform
メソッドの内部で実行されているのはhttp.RoundTripper
インターフェースのRoundTrip
メソッドである -
RoundTrip
メソッドの具象は、ユーザーがelasticsearch.Config
で定義したhttp.RoundTripper
インターフェースの実装を満たす構造体のRoundTrip
メソッドである
Do
メソッドの内部処理
先で実装したSearch
メソッドは、esapi.SearchRequest
構造体が持つDo
メソッドをラップしています。
まず、Do
メソッドの実装を見てみます。以下にDo
メソッドの実装を抜粋します。
Do
メソッドのL389でレスポンスを受け取り、L400-L404でResponse
構造体に詰め替えていることがわかります。
つまり、L389で実行されているTransport
型のPerform
メソッドからElasticsearchからのレスポンスが渡って来ていることがわかります。
func (r SearchRequest) Do(providedCtx context.Context, transport Transport) (*Response, error) {
...省略
// Elasticsearchからのレスポンスが渡ってきている
res, err := transport.Perform(req)
...省略
response := Response{
StatusCode: res.StatusCode,
Body: res.Body,
Header: res.Header,
}
return &response, nil
Transport
型のPerform
メソッドの具象
次に、Transport
型のPerform
メソッドの実装がどこで定義されているのかを探します。
Do
メソッドが呼ばれているSearch
メソッドに戻ります。以下のコードからDo
メソッドのtransport
引数(Transport
型)にはelasticsearch.Client
構造体が渡されていることがわかります。
つまり、elasticsearch.Client
構造体がTransport
インターフェイスの実装を満たすようにPerform
メソッドを持っていることがわかります。
type esHandler struct {
client *elasticsearch.Client
}
func NewEsHandler(client *elasticsearch.Client) *esHandler {
return &esHandler{
client: client,
}
}
func (e *esHandler) Search(ctx context.Context, index, query string) ([]byte, error) {
req := esapi.SearchRequest{
Index: []string{index},
Body: strings.NewReader(query),
}
res, err := req.Do(ctx, e.client)
elasticsearch.Client
構造体が持つPeform
メソッドを探します。
まず、elasticsearch.Client
構造体が定義されているelasticsearch
パッケージのelasticsearch.goをみてみます。
elasticsearch.Client
構造体のPerform
メソッドは確認できませんでしたが、その代わりBaseClient
構造体が持つPerform
メソッドを確認することができました。(L322)
以下に実装を抜粋します。
そして、BaseClient
構造体はClient
構造体に埋め込まれていることがわかります。つまり、Client
構造体はBaseClient
構造体の埋め込みによってTransport
インターフェイスの実装を満たしています。
...省略
// Client represents the Functional Options API.
type Client struct {
BaseClient
*esapi.API
}
...省略
// Perform delegates to Transport to execute a request and return a response.
func (c *BaseClient) Perform(req *http.Request) (*http.Response, error) {
...省略
// Retrieve the original request.
res, err := c.Transport.Perform(req)
...省略
}
elastictransport.Interface
のPerform
メソッドの具象
BaseClient
構造体のPerform
メソッドの中でさらにPeform
メソッドが呼ばれていることがわかります。
これはBaseClient
構造体のTransport
フィールド(elasticsearch.Interface
型)が持つ、Perform
メソッドを呼び出しています。
まず、BaseClient
構造体のTransport
フィールドがどのように定義されているのかを探すためにBaseClient
構造体がイニシャライズされるNewClient
関数の実装を見てみます。
NewClient
関数のL189で定義されているTransport
フィールド(elastictransport.Interface
型)の値はnewTransport
関数で初期化されています。(L179)
以下にNewClient
関数の実装を抜粋します。
...省略
// BaseClient represents the Elasticsearch client.
type BaseClient struct {
Transport elastictransport.Interface
metaHeader string
compatibilityHeader bool
disableMetaHeader bool
productCheckMu sync.RWMutex
productCheckSuccess bool
}
...省略
func NewClient(cfg Config) (*Client, error) {
tp, err := newTransport(cfg)
if err != nil {
return nil, err
}
compatHeaderEnv := os.Getenv(esCompatHeader)
compatibilityHeader, _ := strconv.ParseBool(compatHeaderEnv)
client := &Client{
BaseClient: BaseClient{
Transport: tp,
disableMetaHeader: cfg.DisableMetaHeader,
metaHeader: initMetaHeader(tp),
compatibilityHeader: cfg.EnableCompatibilityMode || compatibilityHeader,
},
}
client.API = esapi.New(client)
if cfg.DiscoverNodesOnStart {
go client.DiscoverNodes()
}
return client, nil
}
elastictransport.Interface
インターフェースはelastic-transport-goライブラリーで定義されています。このライブラリーはREADMEにあるようにgo-elasticsearch
で使用されるトランスポートインターフェースを提供しています。
つまり、Elasticsearchとデータの送受信を行うためのインターフェースを提供していることがわかります。
It provides the Transport interface used by go-elasticsearch, connection pool, cluster discovery, and multiple loggers.
newTransport
関数の実装に話を戻すと、newTransport
関数はElasticsearchとの通信を行うためのHTTPクライアントであるelastictransport.Client
構造体を初期化しています。
そして、elastictransport.Client
構造体はelastictransport.Interface
インターフェースの実装を満たしているので、Perform
メソッドを持っていることがわかります。
以下にnewTransport
関数の実装を抜粋します。
...省略
func newTransport(cfg Config) (*elastictransport.Client, error) {
...省略
tp, err := elastictransport.New(tpConfig)
...省略
return tp, nil
}
elastictransport.Client
構造体のPerform
メソッドの実装を見てみます。
以下にPerform
メソッドの実装を抜粋します。
やっとここで、RoundTrip
メソッドが出てきました。このRoundTrip
メソッドはelastictransport.Client
構造体がもつtransport
フィールド(http.RoundTripper
型)のRoundTrip
メソッドを呼び出しています。
また、elastictransport.Client
構造体のtransport
フィールドは、elastictransport.Config
構造体のTransport
フィールドの値から渡ってきていることがわかります。
func New(cfg Config) (*Client, error) {
...省略
client := Client{
...
transport: cfg.Transport,
...
}
...
return &client, nil
}
// Perform executes the request and returns a response or error.
func (c *Client) Perform(req *http.Request) (*http.Response, error) {
...省略
res, err = c.transport.RoundTrip(req)
...省略
return res, err
elastictransport.Config
構造体のTransport
フィールドは、NewClinet
関数のcfg
引数(elasitcsearch.Config
型)のTransport
フィールドの値から渡ってきています。
ここでやっと、ユーザーが入力したhttp.RoundTripper
インターフェースの実装を満たす構造体が影響してくることがわかります。
よって、http.RoundTripper
インターフェースの実装を満たす構造体をelasticsearch.Config
構造体のTransport
フィールドに渡すことで、Elasticsearchとデータの送受信を行うelastictransport.Client
構造体のRoundTrip
メソッドをmockすることができます。
このRoundTrip
メソッドはHTTP通信の実態を担っているため、このメソッドをmockすることでElasticsearchからのレスポンスをmockすることができるということがわかります。
まとめ
今回は、go-elasticsearchライブラリーを使ってElasticsearchからのレスポンスをmockする方法を紹介しました。
また、内部実装のコードリーディングを通して、なぜRoundTrip
メソッドをmockすることでレスポンスをmockできるのかを順を追って説明しました。
この記事がgo-elasticsearchの内部実装を理解する手助けになれば幸いです。