TypeScript を使って、 Node.js で DB アクセスするシンプルな Web アプリケーションを作ってみようと思います。

Node.js: v14.17.0
npm: 6.14.13
TypeScript: 4.3.2
express: 4.17.1
MariaDB: 10.5.10

事前準備

Node.js インストールと package.json 作成

以下のいずれかの方法で Node.js をインストールします。

  • 公式サイト からインストーラーをダウンロードしてインストール
  • nodebrew を使ってインストール(こちら を参考にさせていただくといいと思います)

次に、package.jsonを作成します。

npm init

TypeScript インストールと tsconfig.json 作成

npm を使用して、TypeScript をインストールします。

npm install --save-dev typescript @types/node@14

次に、tsconfig.jsonを作成します。以下で雛形を作成して、

npx tsc --init

プロジェクトに合わせて修正します。

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "sourceMap": true,
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"]
}

色々オプションがあるので迷いますが、とりあえずは上記で進めることにしました。

コンパイルおよび実行テスト

test.tsという TypeScript のファイルを作成し、コンパイルして、Node.js で実行してみます。

mkdir src
touch src/test.ts
echo "console.log('logging test');" > src/test.ts
npx tsc
ls dist
> test.js         test.js.map
node dist/test.js
> logging test

ここもサクッと動きました。

express で Web サーバを立てて Hello World!!

まずは、express モジュールをインストールします。

npm install express
npm install --save-dev @types/express

次に、src/index.tsを作成します。

import express from 'express';

const app = express();
app.get('/', (req: express.Request, res: express.Response) => {
  res.send('Hello World!!');
})
app.listen(3000);

最後に、コンパイルして実行します。

npx tsc
node dist/index.js
curl http://localhost:3000
> Hello World!!

無事,Hello World!!が返ってきました。

DB 構築

今回は、MariaDB という MySQL から派生したオープンソースのデータベースを利用してみます。
DockerHub 上で公開されている公式イメージ を使って、docker-compose で DBを立ち上げる方針で行きます。

まず、mariadbコマンドで DB アクセスしたいので、Mac に MariaDB をインストールします。

brew update
brew info mariadb
brew install mariadb
mariadb --version
> mariadb  Ver 15.1 Distrib 10.5.9-MariaDB, for osx10.16 (x86_64) using readline 5.1

次に、 DB のデータがdocker-compose downで失われないように、/db_dataフォルダを作成します。

mkdir db_data

その上で、api/docker-compose.ymlを作成して、

version: '3.1'

services:
  db:
    image: mariadb:latest
    restart: always
    ports:
        - "3306:3306"
    volumes:
        - "./db_data:/var/lib/mysql"
    environment:
        MARIADB_ROOT_PASSWORD: "root_password" # `root`ユーザのパスワード
        MARIADB_DATABASE: "sampledb" # 初期化時に作成するデータベース
        MARIADB_USER: "appuser" # 初期化時に作成するユーザ
        MARIADB_PASSWORD: "appuser_password" # 初期化時に作成するユーザのパスワード

Docker Compose で MariaDB のコンテナを立ち上げます。

docker-compose up -d

mariadbコマンドで接続してみると、無事接続できました。

mariadb -h 127.0.0.1 -P 3306 -D sampledb -u appuser -p
> Welcome to the MariaDB monitor.  Commands end with ; or \g.
> Your MariaDB connection id is 4
> Server version: 10.5.10-MariaDB-1:10.5.10+maria~focal mariadb.org binary distribution

注) 設定が正しそうなのに、エラーが発生する時は、

  • db_dataに古いDB定義が残ってる
    • ERROR 1045 (28000): Access denied for user 'appuser'@'xx.xx.xx.xx' (using password: YES)
    • フォルダを一旦削除してDB定義を再作成する
  • localhostを指定してしまってる
    • ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock' (2)
    • 127.0.0.1を指定する

などを疑うと良いかもしれません。

最後に、テーブルも作成しておきます。
idcommentの二つのカラムを持つ、commentsテーブルです。

MariaDB [sampledb]> create table comments (
     id int auto_increment
     , comment text not null
     , primary key(id)
   );
Query OK, 0 rows affected (0.011 sec)

MariaDB [sampledb]> show columns from comments;
+---------+---------+------+-----+---------+----------------+
| Field   | Type    | Null | Key | Default | Extra          |
+---------+---------+------+-----+---------+----------------+
| id      | int(11) | NO   | PRI | NULL    | auto_increment |
| comment | text    | NO   |     | NULL    |                |
+---------+---------+------+-----+---------+----------------+
2 rows in set (0.003 sec)

これで、DB の準備が完了しました。
次は、Web アプリから DB にアクセスするところをやりたいと思います。

mariadb クライアントモジュールで DB にアクセス

今回は、mariadb というクライアントモジュールを使って DB にアクセスしようと思います。

npm install --save mariadb

index.tsを以下のように書き換えます。

import express from 'express';
import * as mariadb from 'mariadb';

// DB コネクションプールの初期化
const pool = mariadb.createPool({
    host: process.env.DB_HOST,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_DATABASE,
    connectionLimit: 5
});

// express 初期化
const app = express();
app.use(express.json());
app.use(express.urlencoded({
    extended: true
}));

// ルーティングの定義
app.get('/comments/:id', async (req: express.Request, res: express.Response) => {
    let conn: mariadb.PoolConnection | undefined;
    try {
        conn = await pool.getConnection();
        const comment: Comment | null = await getComment(conn, Number(req.params.id));
        res.send(comment);
    } catch (err) {
        throw err;
    } finally {
        if (conn) conn.release();
    }
})
app.post('/comments', async (req: express.Request, res: express.Response) => {
    let conn: mariadb.PoolConnection | undefined;
    try {
        conn = await pool.getConnection();
        const id: number = await insertComment(conn, req.body.comment);
        const comment: Comment | null = await getComment(conn, id);
        res.send(comment);
    } catch (err) {
        throw err;
    } finally {
        if (conn) conn.release();
    }
})
app.listen(3000);

// モデル定義
type Comment = {
    id: number;
    comment: string;
}

// DB アクセス
const getComment = async (conn: mariadb.PoolConnection, id: number): Promise<Comment | null> => {
    const res: Array<Comment> = await conn.query("select * from comments where id = ?", [id]);
    if (res.length === 0) return null
    return res[0];
}
const insertComment = async (conn: mariadb.PoolConnection, comment: string): Promise<number> => {
    const res = await conn.query("insert into comments(comment) value (?)", [comment]);
    const id = res.insertId;
    return id;
}

見ればわかると思いますが、ポイントは以下の通りです。

  • DB コネクションプール定義で、接続定義を環境変数から取得している
  • express 初期化時に、express.json で JSON 形式のペイロードを、express.urlencodedで URL エンコードされたペイロードを扱えるようにする設定している
  • ルーティングの定義の箇所に、トランザクション境界を置いている
  • DB アクセスで、SQL を発行している

以下の環境変数を設定して、コンパイル&実行します。

export DB_HOST=127.0.0.1
export DB_USER=appuser
export DB_PASSWORD=appuser_password
export DB_DATABASE=sampledb
npx tsc
node dist/index.js

curl を使って動作確認すると、以下のように問題なく動いてくれました。

curl -X POST http://localhost:3000/comments -H "Content-type: application/json" -d '{ "comment" : "new comment" }'
> {"id":51,"comment":"new comment"}

curl http://localhost:3000/comments/51
> {"id":51,"comment":"new comment"}

ts-node-dev で開発効率アップ

しばらく開発していると、毎回npx tsc && node dist/index.jsするのが面倒になってきます。
そのような場合は、ファイルが変更されたら自動でコンパイルして Node.js のプロセスを再起動してくれる、ts-node-dev を使うと便利です。

モジュールをインストールして、

npm install --save-dev ts-node-dev

package.jsonscriptsに以下を追加した上で(dev:watchは別の名前でもOK)、

  "scripts": {
    "dev:watch": "ts-node-dev --respawn src/index.ts"
  },

以下のようにプロセスを実行します。

npm run dev:watch

これで、 Node.js のプロセスが立ち上がり、ソースコードに変更が入れば勝手に再起動してくれるようになりました。

ちなみに、環境変数の管理は direnv を使うと便利です。

Dockerコンテナ化

ここで終わっても良かったのですが、最後にアプリをDockerコンテナ化して実行するところもやっておきます。

まずはDockerfileを作成します。

FROM node:14.17.0

WORKDIR /usr/src/app
COPY package.json .
COPY tsconfig.json .
COPY src ./src

RUN npm install
RUN npx tsc

EXPOSE 3000

CMD ["node", "dist/index.js"]

環境変数を管理するための.envファイルを作成します。
host.docker.internalで、コンテナ内からホストOS(Mac)を指すことができます。

DB_HOST=host.docker.internal
DB_USER=appuser
DB_PASSWORD=appuser_password
DB_DATABASE=sampledb

イメージを作成します。タグ名は、sample-api:latestとしておきます。

docker build . -t sample-api:latest

最後に、コンテナを起動します。

docker-compose up -d # DB起動
docker run -d -p 3000:3000  --env-file=.env sample-api:latest # API起動

これでもいいのですが、イメージ作成とDB/APIを個別起動してるのが手間なので、一度で作成+起動するようにしたいと思います。
docker-compose.ymlservicesに以下を追加します。

services:
    api:
        build: .
        image: sample-api:latest
        ports:
            - "3000:3000"
        env_file:
            - .env
    db:
        ...

あとは、docker-composeでDBとAPIをまとめて起動します。

docker-compose up -d

最後に、curl を使って動作確認すると、以下のように問題なく動いてくれました。

curl -X POST http://localhost:3000/comments -H "Content-type: application/json" -d '{ "comment" : "new comment" }'
> {"id":54,"comment":"new comment"}

curl http://localhost:3000/comments/54
> {"id":54,"comment":"new comment"}

さいごに

今回、Node.js + TypeScript + DB でアプリケーションを構築するベースラインに立てたと思います。とはいえ、実際のアプリを作る時は、認証・CSRF・O/Rマッパー・DI なども必要になってくると思うので、もう少し勉強が必要そうです。