035.3 レッスン 1
Certificate: |
Web開発の要点 |
---|---|
Version: |
1.0 |
Topic: |
035 NodeJSサーバプログラミング |
Objective: |
035.3 SQLの基本 |
Lesson: |
1 of 1 |
はじめに
独自の関数を書いて永続的なストレージを実装することもできますが、データベース管理システムを使用した方が、開発のスピードアップやテーブル形式のデータのセキュリティと安定性の確保につながる場合があります。相互に関連したテーブルで構成されたデータを保存するための最も一般的な戦略は、特にそれらのテーブルが頻繁にクエリおよび更新される場合には、 Structured Query Language (SQL)をサポートするリレーショナルデータベースをインストールすることです。Node.jsは様々なSQLデータベース管理システムをサポートしています。Node.js Expressで採用されている移植性とユーザースペースでの実行という原則に従うと、この種のHTTPサーバで使用されるデータの永続的な保存にはSQLiteが適切な選択肢となります。
SQL
データベースに特化した言語として、Structured Query Languageがあります。書き込みや読み出しの操作は、 ステートメント や クエリ と呼ばれる文で表現されます。ステートメントもクエリも、操作を実行するための条件を定義した 句 で構成されています。
例えば、名前やメールアドレスは、 name
と email
のフィールドを含むデータベースのテーブルに格納することができます。データベースには複数のテーブルを含めることができるので、各テーブルには固有の名前を付ける必要があります。名前とメールアドレスのテーブルに contacts
という名前を使うと、以下の ステートメント で新しいレコードを挿入することができます。
INSERT INTO contacts (name, email) VALUES ("Carol", "carol@example.com");
この挿入文は、データが挿入されるテーブルとフィールドを定義する INSERT INTO
句で構成されます。2番目の句である VALUES
は、挿入される値を設定します。句を大文字にする必要はありませんが、ステートメントやクエリ内のSQLキーワードを認識しやすくするために、一般的には大文字にします。
contactsテーブルへのクエリは、 SELECT
句を使って同様に行います。
SELECT email FROM contacts;
dave@example.com
carol@example.com
この例では、 SELECT email
句は、 contacts
テーブルのエントリから1つのフィールドを選択します。また、 WHERE
句はクエリを特定の行に制限します。
SELECT email FROM contacts WHERE name = "Dave";
dave@example.com
SQLには他にも多くの句があり、後のセクションでそのいくつかを見ていきます。しかしその前に、SQLデータベースをNode.jsと統合する方法を確認する必要があります。
SQLite
SQLiteは、SQLデータベースの機能をアプリケーションに組み込むための、おそらく最もシンプルなソリューションです。他の一般的なデータベース管理システムとは異なり、SQLiteはクライアントが接続するデータベースサーバではありません。代わりに、SQLite は、開発者が従来のファイルのようにデータベースを作成するための一連の関数を提供します。Node.js Expressで実装されたHTTPサーバの場合、このファイルは通常、サーバスクリプトと同じディレクトリに置かれます。
Node.jsでSQLiteを使う前に、 sqlite3
モジュールをインストールする必要があります。サーバのインストールディレクトリ、つまり実行するNode.jsスクリプトがあるディレクトリで、以下のコマンドを実行します。
$ npm install sqlite3
SQLiteをサポートするモジュールには、 better-sqlite3
などいくつかありますが、その使い方は sqlite3
とは微妙に異なることに注意してください。このレッスンの例は sqlite3
モジュールを対象としていますので、他のモジュールを選択した場合は期待通りに動作しない可能性があります。
データベースを開く
Node.js ExpressサーバがSQLデータベースとどのように連携するかを示すために、クッキーで識別されたクライアントが送信したメッセージを保存・表示するスクリプトを書いてみましょう。メッセージはHTTP POSTメソッドでクライアントから送信され、サーバのレスポンスは、クライアントから要求されたフォーマットに応じて、JSONまたはHTML(テンプレートから)としてフォーマットされます。このレッスンでは、HTTPメソッド、クッキー、テンプレートの使い方については詳しく説明しません。ここで紹介するコードスニペットは、これらの機能が設定され、利用可能なNode.js Expressサーバがすでにあることを前提としています。
クライアントが送信したメッセージを保存する最も簡単な方法は、以前に送信した各メッセージを、各クライアントに固有の識別キーと関連付けて、グローバル配列に保存することです。このキーは、クッキーとしてクライアントに送信することができ、今後、以前のメッセージを取得するためのリクエストの際に、サーバに提示されます。
しかし、この方法には弱点があります。メッセージはグローバル配列にのみ保存されるため、現在のサーバセッションが終了すると、すべてのメッセージが失われてしまいます。これは、データが永続的に保存され、サーバが再起動されても失われないという、データベースを使用する利点のひとつです。
index.js
ファイルをメインのサーバスクリプトとして、 sqlite3
モジュールを組み込んで、データベースとなるファイルを以下のように指示します。
const sqlite3 = require('sqlite3')
const db = new sqlite3.Database('messages.sqlite3');
まだ存在していない場合は、 index.js
ファイルと同じディレクトリに messages.sqlite3
ファイルが作成されます。この1つのファイルの中に、すべての構造体とそれぞれのデータが格納されます。スクリプトで実行されるすべてのデータベース操作は、 messages.sqlite3
ファイルを開く新しい sqlite3
オブジェクトに与えられる名前である db
定数によって仲介されます。
テーブルの構造
少なくとも1つのテーブルが作成されるまでは、データベースにデータを挿入することはできません。テーブルは CREATE TABLE
というステートメントで作成されます。
db.run('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid CHAR(36), message TEXT)')
データベース内でSQL文を実行するには、 db.run()
メソッドを使います。ステートメント自体はメソッドのパラメータとして書かれます。SQL文をコマンドラインプロセッサで入力する際にはセミコロンで終わらせなければなりませんが、プログラム内でパラメータとして渡されるステートメントではセミコロンは省略可能です。
run
メソッドは、スクリプトが node index.js
で実行されるたびに実行されるので、SQL文には条件句 IF NOT EXISTS
が含まれており、将来の実行時に messages
テーブルがすでに存在している場合にエラーが発生しないようになっています。
messages
テーブルを構成するフィールドは、 id
、 uuid
、および message
です。 id
フィールドはテーブルの各エントリを識別するためのユニークな整数なので、 PRIMARY KEY
として作成されます。主キーはNULLにすることはできませんし、同じテーブルに同じ主キーが2つ存在することもありません。したがって、ほとんどすべてのSQLテーブルは、テーブルの内容を追跡するために主キーを持っています。新しいレコードの主キーの値を明示的に選択することは可能ですが(テーブル内にまだ存在していない場合に限ります)、キーは自動的に生成される方が便利です。この目的のためには、 id
フィールドの AUTOINCREMENT
フラグを使用します。
Note
|
SQLiteで主キーを明示的に設定することはオプションです。なぜなら、SQLite自身が主キーを自動的に作成するからです。SQLiteのドキュメントには次のように書かれています。: “SQLiteでは、テーブルの行は通常、64ビットの符号付き整数 |
uuid
と message
のフィールドには、それぞれクライアントの識別情報とメッセージの内容が格納されます。 CHAR(36)
型のフィールドには36文字の固定長のテキストが格納され、 TEXT
型のフィールドには任意の長さのテキストが格納されます。
データ入力
このサンプルサーバの主な機能は、メッセージを送信したクライアントにリンクしたメッセージを保存することです。クライアントは、HTTP POSTメソッドで送信したリクエストのボディにある message
フィールドにメッセージを送信します。クライアントの識別情報は、 uuid
というクッキーに入っています。この情報をもとに、データベースに新しいメッセージを挿入するためのExpressルートを記述します。
app.post('/', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined )
uuid = uuidv4()
// Insert new message into the database
db.run('INSERT INTO messages (uuid, message) VALUES (?, ?)', uuid, req.body.message)
// If an error occurrs, err object contains the error message.
db.all('SELECT id, message FROM messages WHERE uuid = ?', uuid, (err, rows) => {
let expires = new Date(Date.now());
expires.setDate(expires.getDate() + 30);
res.cookie('uuid', uuid, { expires: expires })
if ( req.headers.accept == "application/json" )
res.json(rows)
else
res.render('index', {title: "My messages", rows: rows})
})
})
今回、 db.run()
メソッドは挿入文を実行しますが、 uuid
と req.body.message
はステートメントの行に直接書かれていないことに注意してください。その代わり、クエスチョンマークが値の代わりになっています。各クエスチョンマークは、 db.run()
メソッド内のSQL文に続くパラメータに対応しています。
データベースで実行されるステートメントの中でプレースホルダーとしてクエスチョンマークを使うと、SQLiteはステートメントの静的な要素とその変数データを簡単に区別することができます。この戦略により、SQLiteはステートメントの一部である変数の内容を エスケープ または サニタイズ することができ、 SQLインジェクション と呼ばれる一般的なセキュリティ侵害を防ぐことができます。この攻撃では、悪意のあるユーザーがSQL文を変数データに挿入し、その文が不用意に実行されることを期待します。サニタイズは、データ内の危険な文字を無効にすることで、この攻撃を阻止します。
クエリ
サンプルコードに示されているように、私たちの意図は、新しいメッセージをデータベースに挿入し、以前に送信されたメッセージのリストを生成するために、同じルートを使用することです。 db.all()
メソッドは、クエリで定義された基準にマッチするテーブルのすべてのエントリのコレクションを返します。
db.run()
とは異なり、 db.all()
では、最後のパラメータで指定された矢印の関数で処理されるレコードのリストが生成されます。
(err, rows) => {}
この関数は、2つのパラメータを受け取ります。 err
と rows
です。 err
パラメータは、クエリの実行を妨げるようなエラーが発生した場合に使用されます。成功すると、すべてのレコードが rows
の配列に格納されます。この配列の各要素は、テーブルの1つのレコードに対応するオブジェクトです。このオブジェクトのプロパティは、クエリで指定されたフィールド名に対応しています。 uuid
と message
です。
rows
の配列は、JavaScriptのデータ構造です。そのため、Expressが提供するメソッド( res.json()
や res.render()
など)を使ってレスポンスを生成する際に使用できます。EJSのテンプレート内でレンダリングすると、従来のループですべてのレコードをリストアップすることができます。
<ul>
<% rows.forEach( (row) => { %>
<li><strong><%= row.id %></strong>: <%= row.message %></li>
<% }) %>
</ul>
クエリが返したすべてのレコードを rows
配列に入れる代わりに、 db.each()
メソッドで各レコードを個別に処理したほうが便利な場合もあります。 db.each()
メソッドの構文は db.all()
メソッドと似ていますが、 (err, row) ⇒ {}
の row
パラメータは一度にひとつのレコードにマッチします。
データベースの内容の変更
これまでのところ、クライアントはサーバ上のメッセージを追加したり、問い合わせたりすることしかできません。クライアントは以前に送信したメッセージの id
を知っているので、特定のレコードを修正する関数を実装することができます。修正したメッセージは、HTTP POSTメソッドのルートに送ることもできますが、今回はリクエストパスにクライアントが与えた id
をキャッチするルートパラメータを付けます。
app.post('/:id', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined ){
uuid = uuidv4()
// 401 Unauthorized
res.sendStatus(401)
}
else {
// Update the stored message
// using named parameters
let param = {
$message: req.body.message,
$id: req.params.id,
$uuid: uuid
}
db.run('UPDATE messages SET message = $message WHERE id = $id AND uuid = $uuid', param, function(err){
if ( this.changes > 0 )
{
// A 204 (No Content) status code means the action has
// been enacted and no further information is to be supplied.
res.sendStatus(204)
}
else
res.sendStatus(404)
})
}
})
このルートでは、既存のレコードを修正するために UPDATE
と WHERE
句を使用する方法を説明します。これまでの例との重要な違いは、 名前付きパラメータ を使用していることです。ここでは、値をひとつのオブジェクト ( param
) にまとめて、それぞれの値を指定する代わりに db.run()
メソッドに渡しています。この場合、フィールド名(前に $
が付いている)がオブジェクトのプロパティになります。名前付きパラメータでは、クエスチョンマークの代わりにフィールド名(前に $
が付いている)をプレースホルダーとして使用できます。
例題のようなステートメントは、 WHERE
句によって課された条件がテーブルのいくつかのレコードにマッチしなかった場合、データベースに修正を加えることはありません。ステートメントによってレコードが変更されたかどうかを評価するには、 db.run()
メソッドの最後のパラメータとしてコールバック関数を使用します。この関数内では、変更されたレコードの数を this.changes
から取得することができます。この場合、アロー関数は使用できないことに注意してください。なぜなら、 function(){}
という形式の通常の関数のみが この
オブジェクトを定義するからです。
レコードを削除する方法は、修正する方法と非常によく似ています。例えば、削除するメッセージを特定するために、引き続き :id
ルートパラメータを使用することができますが、今回は、クライアントの HTTP DELETEメソッドによって呼び出されるルートで次のようになります。
app.delete('/:id', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined ){
uuid = uuidv4()
res.sendStatus(401)
}
else {
// Named parameters
let param = {
$id: req.params.id,
$uuid: uuid
}
db.run('DELETE FROM messages WHERE id = $id AND uuid = $uuid', param, function(err){
if ( this.changes > 0 )
res.sendStatus(204)
else
res.sendStatus(404)
})
}
})
テーブルからレコードを削除するには、 DELETE FROM
句を使用します。今回もコールバック関数を使って、テーブルから何件のエントリーが削除されたかを評価しました。
データベースを閉じる
一度定義すると、スクリプトの実行中にいつでも db
オブジェクトを参照することができます。なぜなら、データベースファイルは現在のセッション中ずっと開いたままだからです。スクリプトの実行中にデータベースを閉じることは一般的ではありません。
しかし、サーバプロセスの終了時に突然データベースを閉じてしまわないように、データベースを閉じる機能があると便利です。可能性は低いですが、データベースを突然シャットダウンすると、インメモリデータがまだファイルにコミットされていない場合、不整合が発生する可能性があります。たとえば、ユーザーが Ctrl+C キーボードショートカットを押してスクリプトを終了させた場合、データ損失を伴う突然のデータベースシャットダウンが発生する可能性があります。
先ほどのCtrl+Cのシナリオでは、 process.on()
メソッドがオペレーティングシステムから送られてくるシグナルを傍受し、データベースとサーバの両方を秩序立ててシャットダウンすることができます。
process.on('SIGINT', () => {
db.close()
server.close()
console.log('HTTP server closed')
})
Ctrl+Cショートカットは、OSの SIGINT
シグナルを呼び出し、ターミナルのフォアグラウンドプログラムを終了させます。 SIGINT
シグナルを受信してプロセスを終了する前に、システムはコールバック関数( process.on()
メソッドの最後のパラメータ)を呼び出します。コールバック関数の内部には、データベースを閉じるための db.close()
メソッドや、Expressのインスタンス自体を潔く閉じるための server.close()
など、任意のクリーンアップコードを配置することができます。
演習
-
SQLデータベースのテーブルにおける主キーの目的は何ですか?
-
db.all()
とdb.each()
の違いは何ですか? -
プレースホルダーを使用し、クライアントから送られてきたデータをSQL文やクエリに直接含めないことが重要なのはなぜですか?
発展演習
-
sqlite3
モジュールのどのメソッドを使えば、クエリが複数のエントリにマッチしても、テーブルのエントリを1つだけ返すことができますか? -
コールバック関数のパラメータとして渡された
rows
配列に、db.all()
で作成したクエリの結果が含まれているとします。rows
の最初の位置にあるprice
というフィールドをコールバック関数内で参照するにはどうすればよいでしょうか? -
db.run()
メソッドは、INSERT INTO
などのデータベース修正文を実行します。テーブルに新しいレコードを挿入した後、新しく挿入されたレコードの主キーを取得するにはどうすればよいでしょうか。
まとめ
このレッスンでは、Node.js Expressアプリケーション内でのSQLデータベースの基本的な使用方法について説明しました。 sqlite3
モジュールは、SQLiteデータベースに永続的なデータを保存するシンプルな方法を提供します。この方法では、1つのファイルにデータベース全体が格納され、専用のデータベースサーバを必要としません。このレッスンでは、以下の概念と手順を説明しました。
-
Node.jsからデータベース接続を確立する方法
-
シンプルなテーブルを作成する方法と主キーの役割
-
スクリプトの中で新しいデータを追加するための
INSERT INTO
SQL 文の使用 -
標準的なSQLiteのメソッドとコールバック関数を使用したSQLクエリ
-
UPDATE
およびDELETE
SQLステートメントを使用したデータベース内のデータの変更
演習の回答
-
SQLデータベースのテーブルにおける主キーの目的は何ですか?
主キーとは、データベーステーブル内の各レコードを一意に識別するためのフィールドです。
-
db.all()
とdb.each()
の違いは何ですか?db.all()
メソッドは、クエリに対応するすべてのエントリを含む単一の配列を用いてコールバック関数を呼び出します。db.each()
メソッドは、各結果行に対してコールバック関数を呼び出します。 -
プレースホルダーを使用し、クライアントから送られてきたデータをSQL文やクエリに直接含めないことが重要なのはなぜですか?
プレースホルダーを使用すると、ユーザーが送信したデータは、クエリやステートメントに含まれる前にエスケープされます。これにより、データベース上で任意の操作を行おうとして変数データの中にSQL文を入れる、SQLインジェクション攻撃を防ぐことができます。
発展演習の回答
-
sqlite3
モジュールのどのメソッドを使えば、クエリが複数のエントリにマッチしても、テーブルのエントリを1つだけ返すことができますか?db.get()
メソッドは、db.all()
と同じ構文ですが、クエリに対応する最初のエントリのみを返します。 -
コールバック関数のパラメータとして渡された
rows
配列に、db.all()
で作成したクエリの結果が含まれているとします。rows
の最初の位置にあるprice
というフィールドをコールバック関数内で参照するにはどうすればよいでしょうか?rows
の各項目はオブジェクトで、そのプロパティはデータベースのフィールド名に対応しています。ですから、最初の結果のprice
フィールドの値はrows[0].price
になります。 -
db.run()
メソッドは、INSERT INTO
などのデータベース修正文を実行します。テーブルに新しいレコードを挿入した後、新しく挿入されたレコードの主キーを取得するにはどうすればよいでしょうか。db.run()
メソッドのコールバック関数としては、function(){}
という形式の正規関数が使えます。この関数の内部では、this.lastID
プロパティに、最後に挿入されたレコードの主キーの値が格納されます。