はじめに 見出しへのリンク
Smart data structures and dumb code works a lot better than the other way around."(賢いデータ構造と愚かなコードは、その逆よりもずっとうまく機能する)
という言葉があるように、優れたデータ構造を設計することはソフトウェア開発において重要である。ところが実務で様々な現場を経験すると、手続き型・関数型・オブジェクト指向といったプログラミングパラダイムやオニオンアーキテクチャなどのアーキテクチャにばかり注目が集まり、データ構造をいかにしてアプリケーションサーバに適用するかという視点が欠けていることが多いように思う。個人的な備忘録としてどのようにデータ構造を設計し、アプリケーションに適用するか一考する。
今回扱う範囲について 見出しへのリンク
- 一般的なアプリケーションサーバを対象とする
- データの取得はRDBを前提とする
- TypeScript/JavaScriptを使用する
- フレームワークは特に問わない
参考文献 見出しへのリンク
プログラマのためのSQL 第4版
集合指向的なデータ構造設計 見出しへのリンク
アプリケーションサーバにおけるデータ構造設計において重要なのは、データを「集合」として捉えることである。例えば、ユーザ情報を扱う場合、単一のユーザオブジェクトではなく、ユーザの集合を表現するデータ構造を設計する。これにより、データの一貫性を保ちやすくなり、操作も効率的になる。プログラミングとは手続きとデータの組み合わせであるが、データを集合として捉えることで、手続きも集合操作に基づいたものとなり、コードの可読性と保守性が向上する。
その良い例としてSQLを例に挙げる。SQLはデータ宣言言語(Data Declaration Language)・DML:データ操作言語(Data Manipulation Language)・DCL:データ制御言語(Data Control Language)の3つの要素から成り立っている。 SQLにおいては、データは常に集合として扱われ、単一のレコードを操作する場合でも、集合操作の一部として捉えられる。このように、データを集合として捉えることで、データの整合性を保ちつつ、効率的な操作が可能となる。
例えばSQLでユーザーに対するアクセスを手続き的に記述する場合、以下のようになる。
-- 手続き型: 1行ずつ処理(遅い、複雑)
CREATE PROCEDURE GetActiveUsers()
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE user_id INT;
DECLARE user_name VARCHAR(100);
DECLARE is_active BOOLEAN;
-- カーソル宣言
DECLARE user_cursor CURSOR FOR
SELECT id, name, is_active FROM users;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
-- 結果格納用の一時テーブル
CREATE TEMPORARY TABLE active_users (
id INT,
name VARCHAR(100)
);
OPEN user_cursor;
read_loop: LOOP
FETCH user_cursor INTO user_id, user_name, is_active;
IF done THEN
LEAVE read_loop;
END IF;
-- 1行ずつ条件チェック
IF is_active = TRUE THEN
INSERT INTO active_users (id, name)
VALUES (user_id, user_name);
END IF;
END LOOP;
CLOSE user_cursor;
-- 結果を返す
SELECT * FROM active_users;
DROP TEMPORARY TABLE active_users;
END;
集合指向的に記述する場合、以下のようになる。
SELECT id, name
FROM users
WHERE is_active = TRUE;
また、これはTypeScript/JavaScriptのアプリケーションコードにおいても同様である。
// 手続き型
function getActiveUsers(users: User[]): User[] {
const activeUsers: User[] = [];
for (const user of users) {
if (user.isActive) {
activeUsers.push(user);
}
}
return activeUsers;
}
// 集合指向的
function getActiveUsers(users: User[]): User[] {
return users.filter(user => user.isActive);
}
つまりこれは、アクティブユーザーの定義をActiveUsers = { u ∈ Users | isActive(u) = true }と集合として捉え、filterメソッドを用いてその集合を取得している。これにより、Usersに対する演算結果は明確となり、二値論理に基づく集合操作として、例外処理を不要とすることができる。
しかし、この辺りの説明だと、宣言的プログラミングという概念で十分説明可能であるし、わざわざ集合指向的という言葉を使う必要はないと思われる。ただ、なぜあえて集合指向的という言葉を使うかというと、実際の業務アプリケーションでは、RDBMSを用いてデータを扱うことが多く、その場合SQLの集合指向的な性質を理解し、アプリケーションコードに適用することが重要だからである。RDBMSは本質的に集合指向的なデータモデルを採用しており、SQLはその操作言語として設計されているため、アプリケーションコードも同様の視点で設計することで、データ構造を適切に扱い、実務的な問題をシンプルに解決することができると思う。
集合指向的なAPIサーバーの設計の一検討 見出しへのリンク
ここまでは集合指向的なデータ構造設計について述べてきたが、では実際のアプリケーションサーバーにおいてどのように適用するかを考える。
なお、実務を考慮して以下のようにアプリケーションのレイヤーを設計する
- プレゼンテーション層(例:GraphQL、REST API)… クライアントとの通信を担当・バリデーション
- サービス層(ビジネスロジック)… ビジネスルールの実装
- データアクセス層(データアクセス)… データベースとのやり取り(ここを集合指向的に設計する)
また、CRUD操作を以下のように定義する
- 作成(Create):新しいエンティティを集合に追加する操作
- 読取(Read):集合からエンティティを取得する操作
- 更新(Update):集合内のエンティティを変更する操作
- 削除(Delete):集合からエンティティを削除する操作
プレゼンテーション層
app.get('/users', async (req, res) => {
zod.object({`something validation rule`}).parse(req.query);
if (!isValid) {
return res.status(400).json({ error: 'Invalid request' });
}
const users = await getUsers();
res.json(users);
});
app.get('/users/:id', async (req, res) => {
zod.object({`something validation rule`}).parse(req.params);
if (!isValid) {
return res.status(400).json({ error: 'Invalid request' });
}
const user = await getUser(req.params.id);
res.json(user);
});
app.post('/users/search', async (req, res) => {
zod.object({`something validation rule`}).parse(req.body);
if (!isValid) {
return res.status(400).json({ error: 'Invalid request' });
}
const newUser = await createUser(req.body);
res.status(201).json(newUser);
});
app.post('/users', async (req, res) => {
zod.object({`something validation rule`}).parse(req.body);
if (!isValid) {
return res.status(400).json({ error: 'Invalid request' });
}
const newUser = await createUser(req.body);
res.status(201).json(newUser);
});
app.put('/users/:id', async (req, res) => {
zod.object({`something validation rule`}).parse(req.body);
if (!isValid) {
return res.status(400).json({ error: 'Invalid request' });
}
const updatedUser = await updateUser(req.params.id, req.body);
res.json(updatedUser);
});
app.delete('/users/:id', async (req, res) => {
await deleteUser(req.params.id);
res.status(204).send();
});
サービス層
async function getUsers(): Promise<User[]> {
const users = await db.select.from('users').all();
return dataAccess.getUsers(tx, users.map(u => u.id));
}
async function getUser(): Promise<User[]> {
return dataAccess.getUsers(tx, [id], needsDetails);
}
async function searchUsers(somethingOpts: {
needsSomething?: boolean;
needsMore?: boolean;
dateRange?: { start: Date; end: Date };
}): Promise<User[]> {
const query = await db.select.from('users')
if (somethingOpts.needsSomething) {
query.where('something1', true);
}
if (somethingOpts.needsMore) {
query.innerJoin('something_table', 'users.id', 'something_table.user_id');
}
if (somethingOpts.dateRange) {
query.whereBetween('created_at', [somethingOpts.dateRange.start, somethingOpts.dateRange.end]);
}
const users = await query.all();
return dataAccess.getUsers(tx, userIds);
}
async function createUser(data: any): Promise<User> {
const userId = await db.insert.into('users').values(data).returning('id');
return dataAccess.getUsers(tx, [userId])[0];
}
async function updateUser(id: string, data: any): Promise<User> {
const user = await dataAccess.getUsers(tx, [id]);
if (!user) {
throw new Error('User not found');
}
await db.update('users').set(data).where('id', id);
return dataAccess.getUsers(tx, [id])[0];
}
async function deleteUser(id: string): Promise<void> {
const user = await dataAccess.getUsers(tx, [id]);
if (!user) {
throw new Error('User not found');
}
await db.delete.from('users').where('id', id);
return {};
}
データアクセス層
async function getUsers(tx, userIds: string[], needsDetails = false): Promise<User[]> {
const query = tx.select.from('users').whereIn('id', userIds);
if (needsDetails) {
query.leftJoin('user_details', 'users.id', 'user_details.user_id');
}
const users = await query.all();
return users;
}
プレゼンテーション層で適切な値がサービス層に渡されることを前提とすることで、契約プログラミングを実現し、サービス層ではビジネスロジックに集中できるようになる。 サービス層では、ビジネスロジックに集中し、手続き的にコードを書くことができる。ビジネスロジックは例外処理を含むためエラーハンドリングを行いつつ、適宜データアクセス層を呼び出して必要なデータを取得・操作する。 そして、データアクセス層では、集合指向的にデータを扱うことで、効率的かつ一貫性のあるデータ操作を実現する。宣言的にデータをそうしているため、エラーハンドリングの必要がなく、コードの可読性と保守性が向上する。さらに、データのアクセスでは、ID指定により集合の範囲を限定し、パラメータにより指定した集合とその集合の関連エンティティを結合するかどうかを制御することで、必要なデータのみを効率的に取得できるようにする。
整理 見出しへのリンク
再度アプリケーションのレイヤーを抽象的なレベルで整理すると以下のようになる。
プレゼンテーション層
- 契約プログラミングにおける事前条件を保証する
サービス層
- ビジネスルールを実装する
- ビジネスルールに関する手続きを記述し、違反行為を例外として扱う
- トランザクション境界の管理
- 複数のデータアクセス層の呼び出しを調整
データアクセス層
- データベースとのやり取りを集合指向的に設計する
- データを集合として捉え、宣言的にデータ操作を行う
- SQLレベルでの最適化
このようにすることで、各レイヤーの責務が明確になり、コードの可読性と保守性が向上する。
テスタビリティの向上 見出しへのリンク
集合指向的な設計は、テストのしやすさにも貢献する。
// データアクセス層のテスト: 純粋な集合操作なので予測可能
describe('dataAccess.getUsers', () => {
it('should return users for given IDs', async () => {
const users = await dataAccess.getUsers(['user1', 'user2']);
expect(users).toHaveLength(2);
expect(users.map(u => u.id)).toEqual(['user1', 'user2']);
});
it('should return empty array for non-existent IDs', async () => {
const users = await dataAccess.getUsers(['non-existent']);
expect(users).toEqual([]); // エラーではなく空集合
});
});
// サービス層のテスト: ビジネスロジックに集中できる
describe('userService.createUser', () => {
it('should throw error when email already exists', async () => {
// データアクセス層をモック
jest.spyOn(dataAccess, 'getUsersByEmail')
.mockResolvedValue([{ id: 'existing', email: 'test@example.com' }]);
await expect(
userService.createUser({ email: 'test@example.com' })
).rejects.toThrow(BusinessRuleViolationError);
});
});
このように、各レイヤーの責務が明確になることで、テストも書きやすくなり、コードの品質が向上する。
おわりに 見出しへのリンク
今回は集合指向的なデータ構造設計とそのアプリケーションサーバーへの適用について考察した。データを集合として捉えることで、データの一貫性を保ちやすくなり、操作も効率的になる。アプリケーションサーバーにおいても、各レイヤーの責務を明確にし、集合指向的なデータ操作を取り入れることで、コードの可読性と保守性が向上するだろう。