はじめに
ゼロから動く商品検索アプリを作る初心者の記録 — ECE試験対策のポイントも随所に紹介します。
このプロジェクトは、Node.jsもElasticsearchも全くの初心者の私が2日間のセッションで作り上げたものです。目標は「Elastic認定エンジニア(ECE)試験の対策をしながら、両方を同時に学ぶこと」でした。コード・実行結果・ミス・重要概念をすべてこの記事にまとめます。
目次
- プロジェクトのセットアップ
- Elasticsearchへの接続
- インデックスとマッピングの作成
- CRUD操作
- バルクインデックス
- 検索クエリ
- Boolクエリ
- アグリゲーション
- ソートとページネーション
- Express.js REST API
- フロントエンド
- 練習問題
1. プロジェクトのセットアップ
コードを書く前に、Node.js v20以上がインストールされていることを確認してください。
# プロジェクトの作成
mkdir elastic-node-app
cd elastic-node-app
npm init -y
# 依存パッケージのインストール
npm install @elastic/elasticsearch dotenv express cors
ベストプラクティス: 認証情報をコードに直接書かないでください。.envファイルを使い、.gitignoreに追加してGitHubにプッシュされないようにしましょう。
.envファイルの例:
ELASTIC_CLOUD_ID="your_cloud_id_here"
ELASTIC_API_KEY="your_api_key_here"
.gitignoreの例:
node_modules
.env
2. Elasticsearchへの接続
すべてのファイルで使う基本パターンです:
require('dotenv').config()
const { Client } = require('@elastic/elasticsearch')
const client = new Client({
cloud: { id: process.env.ELASTIC_CLOUD_ID },
auth: { apiKey: process.env.ELASTIC_API_KEY }
})
async function run() {
const info = await client.info()
console.log(info)
}
run().catch(console.error)
Node.jsの概念 — async/await:
サーバーやデータベースとの通信には時間がかかります。Node.jsではasync関数とawaitを使うことで、プログラムを止めずに非同期処理を行います。これはNode.jsで最初に理解すべき最重要パターンです。
node index.jsを実行すると、こんな出力が見られるはずです:
tagline: 'You Know, for Search'
3. インデックスとマッピングの作成
Elasticsearchのインデックスはデータベースのテーブルに相当します。マッピングは各フィールドのデータ型を定義します。
| マッピング型 | 用途 | 例 |
|---|---|---|
text |
全文検索(アナライズあり) | 商品説明 |
keyword |
完全一致・ソート・アグリゲーション | カテゴリ名 |
float |
小数を含む数値 | 価格 |
boolean |
真偽値 | 在庫あり/なし |
date |
日付・時刻 | 作成日時 |
async function createIndex() {
const response = await client.indices.create({
index: 'products',
body: {
mappings: {
properties: {
name: { type: 'text' },
description: { type: 'text' },
category: { type: 'keyword' },
price: { type: 'float' },
in_stock: { type: 'boolean' },
created_at: { type: 'date' }
}
}
}
})
console.log('インデックス作成完了:', response)
}
実行結果:
インデックス作成完了: { acknowledged: true, shards_acknowledged: true, index: 'products' }
ECE試験のポイント: インデックス作成後にシャード数を変更することはできません — これは試験でよく出る問題です!レプリカ数はいつでも変更できます。
Dev Toolsでインデックスを確認:
GET products
4. CRUD操作
// CREATE(作成)
await client.index({
index: 'products',
document: {
name: 'MacBook Pro',
description: 'プロ向けの高性能ノートパソコン',
category: 'Electronics',
price: 1999.99,
in_stock: true,
created_at: new Date()
}
})
// READ(取得)
await client.get({ index: 'products', id: 'your_doc_id' })
// UPDATE(更新)
await client.update({
index: 'products',
id: 'your_doc_id',
doc: { price: 1799.99, in_stock: false }
})
// DELETE(削除)
await client.delete({ index: 'products', id: 'your_doc_id' })
ECEの概念 — ドキュメントのバージョン管理:
すべてのドキュメントには、操作のたびにインクリメントされる_versionがあります。_seq_noと_primary_termを組み合わせることで、モダンな楽観的同時実行制御が実現されます。これにより、複数ユーザーが同時に更新しても互いの変更を上書きしてしまうことを防ぎます。
5. バルクインデックス
Bulk APIを使うと、1回のリクエストで大量のドキュメントをインデックスできます。1件ずつ処理するよりはるかに高速です。
const operations = products.flatMap(product => [
{ index: { _index: 'products', _id: product.id } },
product // ドキュメント本体
])
const response = await client.bulk({ operations })
実行結果:
10件のドキュメントを正常にインデックスしました!
処理時間: 200 ms
ECE試験のポイント: Bulk APIは特定のフォーマットが必要です。ドキュメントごとに(1)アクション行と(2)ドキュメントデータの2行が必要です。試験でよく出る問題です!
6. 検索クエリ
// MATCH - 全文検索(アナライズあり)
{ match: { description: 'laptop' } }
// TERM - 完全一致(アナライズなし)
{ term: { category: 'Electronics' } }
// TERMS - 複数の値に一致
{ terms: { category: ['Electronics', 'Audio'] } }
// RANGE - 範囲検索
{ range: { price: { gte: 300, lte: 1000 } } }
// MATCH PHRASE - フレーズの完全一致
{ match_phrase: { description: 'noise cancelling' } }
// MULTI MATCH - 複数フィールドを横断して検索
{ multi_match: { query: 'professional', fields: ['name', 'description'] } }
// EXISTS - フィールドが存在するドキュメントを検索
{ exists: { field: 'price' } }
ECE試験のポイント — matchとtermの違い:
matchはtextフィールドに使います。アナライズ(小文字化・トークン化など)を経て検索されます。
termはkeywordフィールドに使います。アナライズなしで、文字通りの完全一致が必要です。
この使い分けを間違えると試験で失点します!
7. Boolクエリ
BoolクエリはECE試験で最も重要かつ頻出のトピックです。4つの句を組み合わせて複合クエリを作ります:
| 句 | マッチ必須? | スコアに影響? | キャッシュ? |
|---|---|---|---|
must |
✅ はい | ✅ はい | ❌ いいえ |
should |
❌ いいえ | ✅ はい | ❌ いいえ |
filter |
✅ はい | ❌ いいえ | ✅ はい |
must_not |
✅ はい(除外) | ❌ いいえ | ✅ はい |
覚え方(採用面接に例えると):
-
must→ 必須要件(満たさなければ不採用) -
filter→ バックグラウンドチェック(合否のみ、スコアに影響しない) -
should→ あれば加点のスキル(なくても落とされない) -
must_not→ 絶対NG条件(即不採用)
await client.search({
index: 'products',
query: {
bool: {
must: [
{ match: { description: 'wireless' } } // スコアに影響
],
should: [
{ match: { description: 'noise cancelling' } } // マッチでスコアUP
],
must_not: [
{ term: { in_stock: false } } // 在庫なしを除外
],
filter: [
{ range: { price: { lte: 400 } } }, // スコア影響なし・キャッシュあり
{ terms: { category: ['Audio', 'Electronics'] } }
]
}
}
})
黄金ルール:
- テキストの関連度で検索したい →
must - 絞り込み条件(yes/no)を指定したい →
filter - マッチしたものを上位に出したい →
should - 特定の条件を除外したい →
must_not
8. アグリゲーション
アグリゲーションはデータを集計・分析する機能です。SQLのGROUP BYに相当します。
| SQL | Elasticsearch |
|---|---|
AVG(price) |
avgアグリゲーション |
MIN(price) |
minアグリゲーション |
MAX(price) |
maxアグリゲーション |
SUM(price) |
sumアグリゲーション |
GROUP BY category |
termsバケットアグリゲーション |
await client.search({
index: 'products',
size: 0, // ドキュメントは不要、集計結果のみ取得
aggs: {
avg_price: { avg: { field: 'price' } },
price_stats: { stats: { field: 'price' } },
by_category: {
terms: { field: 'category', size: 10 },
aggs: {
avg_price_per_category: { avg: { field: 'price' } }
}
},
price_ranges: {
range: {
field: 'price',
ranges: [
{ key: 'バジェット', from: 0, to: 300 },
{ key: 'ミドルレンジ', from: 300, to: 1000 },
{ key: 'プレミアム', from: 1000 }
]
}
}
}
})
実行結果:
========== メトリクスアグリゲーション ==========
平均価格: $822.99
最低価格: $99.99
最高価格: $1999.99
========== カテゴリ別 ==========
Electronics | 件数: 5 | 平均: $1299.99
Audio | 件数: 2 | 平均: $314.99
Accessories | 件数: 1 | 平均: $99.99
========== 価格帯別 ==========
バジェット | 件数: 2
ミドルレンジ | 件数: 5
プレミアム | 件数: 3
ECE試験のポイント:
- アグリゲーション結果だけ必要な場合は必ず
size: 0を使う -
statsアグリゲーションはcount・min・max・avg・sumをまとめて返してくれるので効率的 - Rangeバケットの
fromは以上(含む)、toは未満(含まない) -
termsアグリゲーションはkeyword・boolean・数値フィールドに使える。textフィールドには使えない!
9. ソートとページネーション
// 価格の安い順にソート
{ sort: [{ price: { order: 'asc' } }] }
// 複数フィールドでソート
{ sort: [{ category: { order: 'asc' } }, { price: { order: 'asc' } }] }
// 基本ページネーション - 1ページ目
{ sort: [...], from: 0, size: 3 }
// 基本ページネーション - 2ページ目
{ sort: [...], from: 3, size: 3 }
// 大規模データ向けカーソルベースページネーション
{ sort: [...], size: 3, search_after: [349.99, 4] }
| from + size | search_after | |
|---|---|---|
| 最大深度 | 10,000件 | 無制限 |
| ランダムページアクセス | ✅ 可能 | ❌ 不可(前進のみ) |
| 深いページでの性能 | 低下する | 常に高速 |
| 適したデータ量 | 小規模 | 大規模 |
ECE試験のポイント: from + sizeのデフォルト上限は10,000件です。深いページネーションには必ずsearch_afterを使ってください。また、search_after使用時は必ず一意のフィールドをタイブレーカーとして追加してください!
10. Express.js REST API
const express = require('express')
const app = express()
app.use(cors())
app.use(express.json())
// 商品一覧の取得
app.get('/products', async (req, res) => {
try {
const { from = 0, size = 10, sort = 'price', order = 'asc' } = req.query
const response = await client.search({
index: 'products',
from: parseInt(from),
size: parseInt(size),
sort: [{ [sort]: { order } }]
})
res.json({
total: response.hits.total.value,
products: response.hits.hits.map(hit => ({ id: hit._id, ...hit._source }))
})
} catch (err) {
res.status(500).json({ error: err.message })
}
})
app.listen(3000, () => console.log('サーバー起動中: http://localhost:3000'))
Expressでユーザー入力を受け取る3つの方法:
-
req.query→ URLパラメータ:/products?sort=price&order=asc -
req.params→ URLパス:/products/:id -
req.body→ POST/PUTのJSONボディ
| メソッド | エンドポイント | 説明 |
|---|---|---|
| GET | /products | 商品一覧取得 |
| GET | /products/:id | 商品1件取得 |
| POST | /products | 商品作成 |
| PUT | /products/:id | 商品更新 |
| DELETE | /products/:id | 商品削除 |
| POST | /products/search | Boolクエリで検索 |
| GET | /products/stats/aggregations | 集計データ取得 |
11. フロントエンド
async function search() {
const body = {}
if (searchTerm) body.searchTerm = searchTerm
if (category) body.category = category
const response = await fetch('http://localhost:3000/products/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) // JavaScriptオブジェクトをJSON文字列に変換
})
const data = await response.json() // JSONレスポンスをオブジェクトに変換
renderProducts(data)
}
セキュリティノート: 本番環境では、XSS攻撃を防ぐために必ずユーザー入力をサニタイズしてください。ユーザーデータをそのままinnerHTMLに渡さず、textContentを使うか、DOMPurifyなどのライブラリを活用しましょう。
12. 練習問題
学習内容を定着させるには、VS CodeのThunder ClientでCRUD操作を手動で試してみましょう:
ステップ1 — 新しい商品を追加する
- メソッド:POST | URL:
http://localhost:3000/products - ボディ:
{ "name": "商品名", "description": "...", "category": "Electronics", "price": 299.99, "in_stock": true } - レスポンスに自動生成された
_idが含まれていることを確認しましょう!
ステップ2 — 価格を更新する
- メソッド:PUT | URL:
http://localhost:3000/products/YOUR_ID - ボディ:
{ "price": 249.99 }
ステップ3 — 商品を削除する
- メソッド:DELETE | URL:
http://localhost:3000/products/YOUR_ID
ステップ4 — 全商品を再インデックス
- ターミナルで
node bulkIndex.jsを実行して、元の10件の商品データを復元します
学んだこと まとめ
| トピック | Node.js | Elasticsearch / ECE |
|---|---|---|
| セットアップ | npm, dotenv, require() | Cloud ID, APIキー, クライアント |
| データモデリング | JSオブジェクト | インデックス・マッピング・フィールド型 |
| CRUD | async/awaitパターン | index, get, update, delete |
| バルク操作 | flatMap() | Bulk APIのフォーマット |
| 検索 | — | match, term, range, bool, BM25スコアリング |
| 集計 | toFixed(), forEach() | メトリクス・バケットアグリゲーション |
| ページネーション | — | from+size vs search_after, 1万件上限 |
| API | Express, req/res, REST | — |
| フロントエンド | fetch(), JSON.stringify() | — |
Node.js・Express・Elasticsearchで構築 · Elastic認定エンジニア試験対策として執筆 · 2026年5月

