前回の記事
はじめに
こんにちは、梅雨です。
前回の記事では、データベースの CRUD 操作、REST API の概要、そして実際にエンドポイントの実装までを行いました。
今回は、ソフトウェア開発を行う上で重要となる 抽象化 について解説し、このシリーズで書いてきたコードに抽象化を適用する方法について書いていこうと思います。
抽象化とは?
ソフトウェアにおける抽象化とは、ソースコードを簡潔でわかりやすくすることで本質的な部分により集中できるようにすることを指します。
例えば、自動車の「アクセル」は、ペダルを踏むだけで車が進む仕組みですが、その裏ではエンジンや燃料などに対し複雑な処理が行われています。しかし、ドライバが運転に集中できるよう、それらの処理はドライバから見えないようになっています。
同じように、プログラミングでも複雑な操作を隠して、「使う人にとってシンプルな操作方法」を提供するのが抽象化です。これによって、難しいことを気にせず、システムを使ったり作ったりできるようになります。
ここでいう "使う人" にはチーム内のメンバーのみでなく、もちろん自身も含まれます。適切な抽象化を行うことでそれ以降の開発で余計なことに気を使う必要がなくなり、より効率的に開発を行うことができます。
tsx による抽象化
まずは tsx によって、トランスパイル → Node.js でファイルを実行、という一連の流れを抽象化してみましょう。
tsx は TypeScript で書かれたソースコードを直接実行してくれるパッケージです。
これを用いることで、毎回 tsc server.ts
でトランスパイルを行なってから node server.js
でサーバを起動、と2段階でコマンドを実行する必要がなくなり、1回で TypeScript のソースコードからサーバを起動できるようになります。
それでは早速、tsx を用いてサーバを起動してみましょう。コマンドの初めに npx
を付けることで、npm で提供されているパッケージをグローバルインストールせずに使用することができます。
$ npx tsx server.ts
実行してみると、エラーが起きてしまうはずです。
/Users/meiyu/Documents/express-typescript-handson/server.ts:4
const app = express();
^
TypeError: express is not a function
at <anonymous> (/Users/meiyu/Documents/express-typescript-handson/server.ts:4:13)
at Object.<anonymous> (/Users/meiyu/Documents/express-typescript-handson/server.ts:64:2)
at Module._compile (node:internal/modules/cjs/loader:1572:14)
at Object.transformer (/Users/meiyu/Documents/express-typescript-handson/node_modules/tsx/dist/register-DCnOAxY2.cjs:2:1186)
at Module.load (node:internal/modules/cjs/loader:1315:32)
at Function._load (node:internal/modules/cjs/loader:1125:12)
at TracingChannel.traceSync (node:diagnostics_channel:322:14)
at wrapModuleLoad (node:internal/modules/cjs/loader:216:24)
at cjsLoader (node:internal/modules/esm/translators:267:5)
at ModuleWrap.<anonymous> (node:internal/modules/esm/translators:200:7)
Node.js v23.1.0
これは TypeScript と JavaScript のモジュール仕様によるもので、server.ts
のインポート文を以下のように直すことで解決できます。
- import * as express from "express";
- import * as sqlite3 from "sqlite3";
+ import express from "express";
+ import sqlite3 from "sqlite3";
// 以下略
再度コマンドを実行すると、サーバの起動が確認できます。
$ npx tsx server.ts
Server is running on http://localhost:3000
また、tsx には watchモード というものが存在し、これを用いるとソースコードを変更するたびにファイルを再実行してくれます。
一旦サーバを停止し、watch モードでサーバを起動しなおしましょう。
$ npx tsx watch server.ts
ここまでは先ほどと変わりませんが、試しに GET /
の返却する文字列を以下のように変更してみてみます。
import express from "express";
import sqlite3 from "sqlite3";
const app = express();
app.use(express.json());
const db = new sqlite3.Database("./database.db");
app.get("/", (req, res) => {
- res.send("Hello World!");
+ res.send("Marry Christmas!");
});
すると、ファイルを保存するのと同時にコードが再実行され、http://localhost:3000 で表示される文字列が Marry Christmas!
に変わっているはずです。
これによって、開発者はトランスパイルやソースコードの実行など、開発の本質ではない部分に意識を向ける必要がなくなり、より本質的な部分に集中することができます。
tsx の説明において TypeScript で書かれたソースコードを直接実行 と書きましたが、内部的には esbuild によってトランスパイルが行われており、JavaScript に変換されたコードが Node.js で実行されています。
npm-scripts による抽象化
今度は、npm-scripts によるシェルコマンドの抽象化を行なっていきます。
今まで使用したシェルコマンドには以下のようなものがありました。
npx tsx watch server.ts
tsc server.ts
node server.js
これらのコードは決して長くないため、毎回打つのはそれほど大変ではないと思います。
しかし、例えばサーバのソースファイルを src
ディレクトリ内に移動し、トランスパイルのコマンドが tsc server.ts
から tsc src/server.ts
になったらどうでしょうか?
コマンドの長さとしては依然として短いままですが、サーバのソースファイルが配置されている場所を毎回意識する必要があることは、開発の妨げになってしまうと言えます。
また、慣れてしまえばなんてことはないですが、 npx tsx
、tsc
、node
などそれぞれ使用するコマンドを覚えるのも大変だったりすることもあります。
この問題を解決するために、npm-scripts でシェルコマンドの抽象化を行いましょう。
npm-scripts とは、一連のコマンドに対しエイリアスを設定して実行するようにできる npm の機能です。これを用いると、npm run *
の形式で色々なコマンドを実行できます。
tsx
コマンドの抽象化
まずは tsx をインストールします。このパッケージは開発環境でしか使わないため、-D
オプションを指定します。
$ npm i -D tsx
npm-scripts で呼び出すコマンドは、package.json に追加されている必要があります。パソコンにグローバルインストールされているパッケージを呼び出したい場合は、別途 npm i
コマンドでプロジェクトにインストールしてあげましょう。
次に、server.ts
を src
ディレクトリ配下に移動します。TypeScript を用いる開発では、多くの場合ソースファイルを src
ディレクトリにまとめて管理します。
$ mkdir src
$ mv server.ts src/server.ts
最後に、package.json
を以下のように変更します。
tsx
コマンドのような開発環境でサーバを起動するために実行されるコマンドは、通常 dev
コマンドに抽象化されます。
{
"name": "express-typescript-handson",
"version": "1.0.0",
"scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
+ "dev": "tsx watch src/server.ts"
},
"dependencies": {
"express": "^4.21.1",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/sqlite3": "^3.1.11",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
これにより、ターミナルで npm run dev
を実行することで開発用のサーバを起動できるようになりました。
$ npm run dev
> express-typescript-handson@1.0.0 dev
> tsx watch src/server.ts
Server is running on http://localhost:3000
tsc
コマンドの抽象化
tsc
コマンドによるトランスパイルは、通常 npm run build
コマンドに抽象化されます。
ここで、今まではファイル名を指定してトランスパイルを行なってきましたが、今後ファイルを複数に分割した際はそれら全てのファイルをトランスパイルする必要があります。
まずは以下のコードを実行し、ルートディレクトリに tsconfig.json
を生成しましょう。
$ tsc --init
tsconfig.json
にはさまざまなコンパイラオプションが記述されていますが、現時点では以下のようにしてしまって大丈夫です。
{
"compilerOptions": {
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true
}
}
続いて、package.json を以下のように変更します。
{
"name": "express-typescript-handson",
"version": "1.0.0",
"scripts": {
+ "build": "tsc",
"dev": "tsx watch src/server.ts"
},
"dependencies": {
"express": "^4.21.1",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/sqlite3": "^3.1.11",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
これにより、ターミナルで npm run build
を実行することで TypeScript の トランスパイルができるようになりました。トランスパイルされたコードは dist
ディレクトリ配下に出力されます。
$ npm run build
> express-typescript-handson@1.0.0 build
> tsc
node
コマンドの抽象化
node
コマンドによる本番環境でのサーバの起動は、通常 npm run start
コマンドに抽象化されます。
実行するファイルは dist/server.js
です。
package.json
は以下のように変更しましょう。
{
"name": "express-typescript-handson",
"version": "1.0.0",
"scripts": {
"build": "tsc",
- "dev": "tsx watch src/server.ts"
+ "dev": "tsx watch src/server.ts",
+ "start": "node dist/server.js"
},
"dependencies": {
"express": "^4.21.1",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/sqlite3": "^3.1.11",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
これによって、ターミナルで npm run start
を実行することで本番環境のサーバが起動できるようになりました。
$ npm run start
> express-typescript-handson@1.0.0 start
> node dist/server.js
Server is running on http://localhost:3000
SQL の抽象化
この記事では最後に、SQL の抽象化を行っていきます。
前回の記事では、以下のようにデータベースの CRUD 操作を記述しました。
app.get("/cats", (req, res) => {
db.all("SELECT * FROM cats", (err, rows) => {
res.send(rows);
});
});
app.get("/cats/:id", (req, res) => {
const { id } = req.params;
db.get("SELECT * FROM cats WHERE id = ?", id, (err, row) => {
if (row) {
res.send(row);
} else {
res.sendStatus(404);
}
});
});
app.post("/cats", (req, res) => {
const { name, feature } = req.body;
db.run(
"INSERT INTO cats (name, feature) VALUES (?, ?)",
[name, feature],
function () {
res
.setHeader("Location", `http://localhost:3000/cats/${this.lastID}`)
.sendStatus(201);
}
);
});
app.put("/cats/:id", (req, res) => {
const { id } = req.params;
const { name, feature } = req.body;
db.run(
"UPDATE cats SET name = ?, feature = ? WHERE id = ?",
[name, feature, id],
function () {
res.sendStatus(204);
}
);
});
app.delete("/cats/:id", (req, res) => {
const { id } = req.params;
db.run("DELETE FROM cats WHERE id = ?", id, function () {
res.sendStatus(204);
});
});
SQL でデータベースの操作を記述するのは最も純粋で直接的な方法です。
一方で、単純な処理に対して毎回 SQL を記述するのは面倒です。また、SQL は文字列であるため Linter の静的解析の対象にならず書き間違えに気づくことができないといったデメリットもあります。
このような理由から、多くのプロジェクトではデータベースの操作に ORM(Object Relation Mapping) というものが導入されています。
ORM とは?
ORMとは、日本語ではオブジェクト関係マッピングと訳されるオブジェクトとデータベースを繋ぐ手法のことを指します。
ORM を用いることで、リレーショナルデータベース(Relational Database, RDB)で取得されたデータをオブジェクトとして扱うことができるようになります。また、オブジェクトのメソッドによって CRUD 操作を行うこともできます。
この記事では、Prisma という Node.js 環境で使える ORM を用いて SQL の抽象化を行っていきましょう。
Node.js でよく使用される ORM には、Prisma の他に TypeORM や Drizzle ORM のようなものがあります。いずれを用いてもテーブルとオブジェクトのマッピングを行うことはできますが、マッピングの方法や型へのアプローチには若干の違いがあるため、気になる方はぜひ調べてみてください。
Prisma のセットアップ
まずは Prisma のセットアップを行います。以下のコードを実行しましょう。
$ npx prisma init
上記のコマンドは Node.js の v23 以上では上手く動かないことが報告されています。もしパソコンに v23 以上の Node.js がインストールされている場合は、nodebrew などのバージョン管理ツールを用いて v22.12.0 を使用してください。
セットアップが完了すると、prisma/schema.prisma
と .env
の2つのファイルが作成されます。
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
今回は RDBMS として SQLite を使用しているため、プロバイダを sqlite
に変更しましょう。
generator client {
provider = "prisma-client-js"
}
datasource db {
- provider = "postgresql"
+ provider = "sqlite"
url = env("DATABASE_URL")
}
また、DATABASE_URL
にはデータベースファイルのあるパスを記述します。
- DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
+ DATABASE_URL="file:./database.db"
このパスは prisma/schema.prisma
ファイルからの相対パスとして記述されます。そのため、database.db
を prisma/database.db
に移動しましょう。
$ mv database.db prisma/database.db
モデルの記述
今度は、モデルの記述を行いましょう。モデルは1つのテーブルに対応します。
以前の記事で、SQL では以下のスキーマ定義によってテーブルを作成しました。
sqlite> CREATE TABLE cats (id INTEGER PRIMARY KEY, name TEXT, feature TEXT);
Prisma では、npx prisma db pull
コマンドでこのスキーマをモデルに流し込むことができます。
$ npx prisma db pull
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
+ model cats {
+ id Int @id @default(autoincrement())
+ name String?
+ feature String?
+ }
Prisma クライアントの作成
最後に、Prisma クライアントを作成しましょう。Prisma クライアントは Prisma の ORM としてのコア部分であり、このクライアントを用いることでデータベースのデータをオブジェクトとして扱うことができます。
$ npx prisma generate
Need to install the following packages:
prisma@6.1.0
Ok to proceed? (y)
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
✔ Installed the @prisma/client and prisma packages in your project
✔ Generated Prisma Client (v6.1.0) to ./node_modules/@prisma/client in 27ms
Start by importing your Prisma Client (See: https://pris.ly/d/importing-client)
Help us improve the Prisma ORM for everyone. Share your feedback in a short 2-min survey: https://pris.ly/orm/survey/release-5-22
import express from "express";
- import sqlite3 from "sqlite3";
+ import { PrismaClient } from "@prisma/client";
const app = express();
app.use(express.json());
- const db = new sqlite3.Database("./database.db");
+ const db = new PrismaClient();
データベースの CRUD 操作は、以下のように書き換えることできるようになります。データの取得は非同期で行われます。
// Create
await db.cats.create({
data: { name, feature },
});
// Read
const cats = await db.cats.findMany();
const cat = await db.cats.findUnique({
where: { id },
});
// Update
await db.cats.update({
where: { id },
data: { name, feature },
});
// Delete
await db.cats.delete({
where: { id },
});
エンドポイントの修正
最終的なエンドポイントのコードは以下のようになります。
import express from "express";
import { PrismaClient } from "@prisma/client";
import {
PrismaClientKnownRequestError,
PrismaClientValidationError,
} from "@prisma/client/runtime/library";
const app = express();
app.use(express.json());
const db = new PrismaClient();
app.get("/", (req, res) => {
res.send("Marry Christmas!");
});
app.get("/cats", async (req, res) => {
const cats = await db.cats.findMany();
res.send(cats);
});
app.get("/cats/:id", async (req, res) => {
const { id } = req.params;
try {
const cat = await db.cats.findUnique({
where: { id: Number(id) },
});
if (!cat) {
res.sendStatus(404);
return;
}
res.send(cat);
} catch (err) {
if (err instanceof PrismaClientValidationError) {
res.sendStatus(400);
return;
}
res.sendStatus(500);
}
});
app.post("/cats", async (req, res) => {
const { name, feature } = req.body;
try {
const cat = await db.cats.create({
data: { name, feature },
});
res.setHeader("Location", `http://localhost:3000/cats/${cat.id}`);
res.sendStatus(201);
} catch (err) {
if (err instanceof PrismaClientValidationError) {
res.sendStatus(400);
return;
}
res.sendStatus(500);
}
});
app.put("/cats/:id", async (req, res) => {
const { id } = req.params;
const { name, feature } = req.body;
try {
await db.cats.update({
where: { id: Number(id) },
data: { name, feature },
});
res.sendStatus(204);
} catch (err) {
if (err instanceof PrismaClientValidationError) {
res.sendStatus(400);
return;
}
if (err instanceof PrismaClientKnownRequestError) {
res.sendStatus(404);
return;
}
res.sendStatus(500);
}
});
app.delete("/cats/:id", async (req, res) => {
const { id } = req.params;
try {
await db.cats.delete({
where: { id: Number(id) },
});
res.sendStatus(204);
} catch (err) {
if (err instanceof PrismaClientValidationError) {
res.sendStatus(400);
return;
}
if (err instanceof PrismaClientKnownRequestError) {
res.sendStatus(404);
return;
}
res.sendStatus(500);
}
});
app.listen(3000, () => {
console.log("Server is running on http://localhost:3000");
});
これで SQL の抽象化が完了しました。
おわりに
今回の記事では、ソフトウェア開発を行う上で重要となる抽象化について解説し、このシリーズで書いてきたコードに抽象化を適用する方法について学びました。
抽象化はソフトウェア開発において欠かせない考え方であるため、ぜひこの記事を参考に学びを深めてみてください。
最後までお読みいただきありがとうございました。