CLOVER🍀

That was when it all began.

TypeScriptでElasticsearchにアクセスしてみる

これは、なにをしたくて書いたもの?

ElasticsearchのJavaScriptクライアントのドキュメントを見ると、TypeScriptのサポートがありそうだったので
こちらを使ってElasticsearchにアクセスするコードを書いてみようかなと。

TypeScript support | Elasticsearch JavaScript Client [7.16] | Elastic

Elasticsearch JavaScriptクライアント × TypeScript

ElasticsearchのJavaScriptクライアントに関するドキュメントはこちら。

Elasticsearch JavaScript Client [7.16] | Elastic

TypeScriptサポートに関する記述はあるのですが、こちらには型宣言のインストールについて記載がないのがちょっと
気になります。

TypeScript support | Elasticsearch JavaScript Client [7.16] | Elastic

GitHubリポジトリを見るとJavaScriptクライアント自体がTypeScriptで書かれているようで、型宣言を別途インストールする
必要はなさそうですね。

GitHub - elastic/elasticsearch-js: Official Elasticsearch client library for Node.js

探すと、古い情報が引っかかったりします…。

ところで、現在の型定義情報は「レガシー」扱いらしく、新しい型定義が次のメジャーバージョンで採用されるようです。
※デフォルトは「レガシー」の方です

TypeScript support / New type definitions

TypeScript support / How to migrate to the new type definition

なのですが、今回は現在の型定義を使いたいと思います。

その他、参考にしたドキュメントはこちら。

Introduction | Elasticsearch JavaScript Client [7.16] | Elastic

API Reference | Elasticsearch JavaScript Client [7.16] | Elastic

Configuration | Elasticsearch JavaScript Client [7.16] | Elastic

環境

今回の環境は、こちらです。

$ node --version
v16.13.1


$ nvm --version
0.37.2

Elasticsearchに関しては、192.168.33.12で動作しているものとします。

設定はこんな感じで

$ sudo grep -v '^#' /etc/elasticsearch/elasticsearch.yml
path.data: /var/lib/elasticsearch
path.logs: /var/log/elasticsearch
network.host: 0.0.0.0
discovery.type: "single-node"
xpack.security.enabled: true

ビルトインユーザーのパスワードは自動生成しました。

$ sudo /usr/share/elasticsearch/bin/elasticsearch-setup-passwords auto
Initiating the setup of passwords for reserved users elastic,apm_system,kibana,kibana_system,logstash_system,beats_system,remote_monitoring_user.
The passwords will be randomly generated and printed to the console.
Please confirm that you would like to continue [y/N]y


Changed password for user apm_system
PASSWORD apm_system = LtWE8jjtMgFSnAzLjLOw

Changed password for user kibana_system
PASSWORD kibana_system = E3ClxGwt0D41LVXgYt7q

Changed password for user kibana
PASSWORD kibana = E3ClxGwt0D41LVXgYt7q

Changed password for user logstash_system
PASSWORD logstash_system = fNur7zlk6qSnUseQrpEQ

Changed password for user beats_system
PASSWORD beats_system = 19Am6dZFtY0EzEUlc3tB

Changed password for user remote_monitoring_user
PASSWORD remote_monitoring_user = kbEeEnSDHNe8z6Cn1bQX

Changed password for user elastic
PASSWORD elastic = fFwiUpFAgEnQULb1Kgim

今回は、elasticユーザーを使用することにします。

バージョン情報。

$ curl -u elastic:fFwiUpFAgEnQULb1Kgim localhost:9200
{
  "name" : "elasticsearch-server",
  "cluster_name" : "elasticsearch",
  "cluster_uuid" : "NMxhp1roRYOO5Hi3WpcAVA",
  "version" : {
    "number" : "7.16.2",
    "build_flavor" : "default",
    "build_type" : "deb",
    "build_hash" : "2b937c44140b6559905130a8650c64dbd0879cfb",
    "build_date" : "2021-12-18T19:42:46.604893745Z",
    "build_snapshot" : false,
    "lucene_version" : "8.10.1",
    "minimum_wire_compatibility_version" : "6.8.0",
    "minimum_index_compatibility_version" : "6.0.0-beta1"
  },
  "tagline" : "You Know, for Search"
}

Javaのバージョン。

$ java --version
openjdk 17.0.1 2021-10-19
OpenJDK Runtime Environment (build 17.0.1+12-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.1+12-Ubuntu-120.04, mixed mode, sharing)

プロジェクトを作成する

今回、テストコードで確認をしようと思います。まずはNode.jsプロジェクトを作成。

$ npm init -y
$ npm i -D typescript
$ npm i -D -E prettier
$ npm i -D jest @types/jest ts-jest
$ npx ts-jest config:init
$ mkdir src test
$ npm i -D @types/node

Jestもインストールしておきます。

ドキュメントに従って、Elasticsearchクライアントのインストール。

Installation | Elasticsearch JavaScript Client [7.16] | Elastic

$ npm i @elastic/elasticsearch

依存関係は、このようになりました。

  "devDependencies": {
    "@types/jest": "^27.0.3",
    "@types/node": "^17.0.5",
    "jest": "^27.4.5",
    "prettier": "2.5.1",
    "ts-jest": "^27.1.2",
    "typescript": "^4.5.4"
  },
  "dependencies": {
    "@elastic/elasticsearch": "^7.16.0"
  }

各種設定ファイルの内容は、こちら。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

.prettierrc.json

{
  "singleQuote": true
}

jest.config.js

/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
};

テストコードを書いて確認する

では、テストコードを使って確認していきましょう。

テストコードは、test/elasticsearchClient.test.tsとして作成します。

今回importするものはこちら。

import { Client, estypes } from '@elastic/elasticsearch';

ここから先は、いくつかピックアップしてAPIを確認していきたいと思います。

Clientを作成する

ElasticsearchにアクセスするためのClientは、このようにして作成します。

test('create Elasticsearch client', async () => {
  const client = new Client({
    node: 'http://192.168.33.12:9200',
    auth: {
      username: 'elastic',
      password: 'fFwiUpFAgEnQULb1Kgim',
    },
  });
});

ドキュメントとしては、このあたりも見るとよいでしょう。

Creating a child client | Elasticsearch JavaScript Client [7.16] | Elastic

Basic configuration | Elasticsearch JavaScript Client [7.16] | Elastic

ただ、この時点では正しく接続できているかはわからないみたいです。

ドキュメントを登録する

では、まずはドキュメントを登録してみましょう。扱うテーマは書籍とします。

TypeScriptサポートのドキュメントを見ていると、型を定義した方が良さそうなので、そのようにしておきます。

TypeScript support / Request & Response types

interface Book {
  isbn: string;
  title: string;
  price: number;
}

登録。

test('register documents', async () => {
  const client = new Client({
    node: 'http://192.168.33.12:9200',
    auth: {
      username: 'elastic',
      password: 'fFwiUpFAgEnQULb1Kgim',
    },
  });

  const indexName = 'myindex';

  await client
    .create<Book>({
      index: indexName,
      id: '978-4295009771',
      body: {
        isbn: '978-4295009771',
        title: 'Elastic Stack実践ガイド[Elasticsearch/Kibana編]',
        price: 3300,
      },
    })
    .then(() =>
      client
        .create<Book>({
          index: indexName,
          id: '978-4844398981',
          body: {
            isbn: '978-4844398981',
            title: 'Elasticsearch NEXT STEP',
            price: 1980,
          },
        })
        .then(() =>
          client.create<Book>({
            index: indexName,
            id: '978-1789956504',
            body: {
              isbn: '978-1789956504',
              title:
                'Elasticsearch 7.0 Cookbook: Over 100 recipes for fast, scalable, and reliable search for your enterprise, 4th Edition',
              price: 6250,
            },
          })
        )
    );

  await client.indices.refresh({ index: indexName });

  await client
    .count<estypes.CountResponse>({ index: indexName })
    .then((res) => expect(res.body.count).toBe(3));
});

ドキュメントを3つ登録していますが、メソッドの呼び出し結果はPromiseになっているので、これをつないでいます。

  await client
    .create<Book>({
      index: indexName,
      id: '978-4295009771',
      body: {
        isbn: '978-4295009771',
        title: 'Elastic Stack実践ガイド[Elasticsearch/Kibana編]',
        price: 3300,
      },
    })
    .then(() =>
      client
        .create<Book>({
          index: indexName,
          id: '978-4844398981',
          body: {
            isbn: '978-4844398981',
            title: 'Elasticsearch NEXT STEP',
            price: 1980,
          },
        })
        .then(() =>
          client.create<Book>({
            index: indexName,
            id: '978-1789956504',
            body: {
              isbn: '978-1789956504',
              title:
                'Elasticsearch 7.0 Cookbook: Over 100 recipes for fast, scalable, and reliable search for your enterprise, 4th Edition',
              price: 6250,
            },
          })
        )
    );

インデックスは、ドキュメント登録時の自動作成に任せました。

確認のために、インデックスをリフレッシュ。

  await client.indices.refresh({ index: indexName });

Count APIを使って、ドキュメントが登録されたことを確認。

  await client
    .count<estypes.CountResponse>({ index: indexName })
    .then((res) => expect(res.body.count).toBe(3));

CountResponseを指定しておかないと、body#countが解決できません…。

接続情報を間違った場合

検索に進む前に。

Clientを作成した時に、この時点では正しく接続できるかどうかはわからないようだ、と書きましたが。

いつわかるかというと、実際にElasticsearchにアクセスするオペレーションを行った時に顕在化するみたいです。

以下に、認証情報を設定しなかったり、接続先ポートを誤った場合にどうなるかを記載しています。

test('wrong connection', async () => {
  const noAuthClient = new Client({
    node: 'http://192.168.33.12:9200',
  });

  const indexName = 'myindex';

  await noAuthClient
    .create<Book>({
      index: indexName,
      id: '978-4295009771',
      refresh: true,
      body: {
        isbn: '978-4295009771',
        title: 'Elastic Stack実践ガイド[Elasticsearch/Kibana編]',
        price: 3300,
      },
    })
    .catch((e: Error) => {
      expect(e.name).toBe('ResponseError');
      expect(e.message).toMatch(
        /^security_exception: \[security_exception\] Reason: missing authentication credentials for REST request \[\/myindex\/_create\/978-4295009771\?refresh=true\]/
      );
    });

  const invalidDestinationClient = new Client({
    node: 'http://192.168.33.12:9201',
    auth: {
      username: 'elastic',
      password: 'fFwiUpFAgEnQULb1Kgim',
    },
  });

  await invalidDestinationClient
    .create<Book>({
      index: indexName,
      id: '978-4295009771',
      refresh: true,
      body: {
        isbn: '978-4295009771',
        title: 'Elastic Stack実践ガイド[Elasticsearch/Kibana編]',
        price: 3300,
      },
    })
    .catch((e: Error) => {
      expect(e.name).toBe('ConnectionError');
      expect(e.message).toMatch(/^connect ECONNREFUSED 192.168.33.12:9201/);
    });
});
検索する

検索を行ってみましょう。Client#searchで行います。

test('search documents', async () => {
  const client = new Client({
    node: 'http://192.168.33.12:9200',
    auth: {
      username: 'elastic',
      password: 'fFwiUpFAgEnQULb1Kgim',
    },
  });

  const indexName = 'myindex';

  await client
    .search<estypes.SearchResponse<Book>>({
      index: indexName,
      q: '*',
      sort: ['price:asc'],
    })
    .then((res) => {
      expect(res.body.hits.total).toStrictEqual({ relation: 'eq', value: 3 });
      expect(res.body.hits.hits[0]._source?.title).toBe(
        'Elasticsearch NEXT STEP'
      );
      expect(res.body.hits.hits[1]._source?.title).toBe(
        'Elastic Stack実践ガイド[Elasticsearch/Kibana編]'
      );
      expect(res.body.hits.hits[2]._source?.title).toBe(
        'Elasticsearch 7.0 Cookbook: Over 100 recipes for fast, scalable, and reliable search for your enterprise, 4th Edition'
      );
    });
});

searchの場合は、SearchReponseとドキュメントの型定義を組み合わせて使うと、_sourceの型情報が解決できるように
なります。

  await client
    .search<estypes.SearchResponse<Book>>({
インデックスを削除する

最後に、インデックスを削除してみます。

test('delete index', async () => {
  const client = new Client({
    node: 'http://192.168.33.12:9200',
    auth: {
      username: 'elastic',
      password: 'fFwiUpFAgEnQULb1Kgim',
    },
  });

  const indexName = 'myindex';

  await client.indices.delete({
    index: indexName,
  });

  await client.indices
    .exists({ index: indexName })
    .then((res) => expect(res.statusCode).toBe(404));
});

削除して

  await client.indices.delete({
    index: indexName,
  });

削除されたことを確認。

  await client.indices
    .exists({ index: indexName })
    .then((res) => expect(res.statusCode).toBe(404));

いったん、こんなところでしょうか。

まとめ

ElasticsearchのJavaScriptクライアントを、TypeScriptを使って試してみました。

TypeScriptに慣れていないのも合わせって、だいぶてこずるのですが…こういうのを繰り返して慣れていこうと思います…。