※ こちらの「コード品質向上の重要性:読みやすく、使いやすいシステムを作るために」という記事を読んでから、本記事を読んでいただけると幸いです。
また、「コード品質向上の重要性:読みやすく、使いやすいシステムを作るために」の補足記事として、以下の記事も作成しました。こちらも併せて読んでいただけると幸いです。
はじめに
皆さん、開発中に「あれ、このコード前にも書いたな…」「似たような処理がいろんなところにあるな」と感じたことはありませんか?
そう、それがコピペコードのサインです。
一時的に開発が早く進むように見えても、コピペコードは長期的に見るとプロジェクトに大きな負債をもたらします。私たちは開発において、DRY (Don't Repeat Yourself) 原則というものを強く意識すべきです。これは「同じことを繰り返すな」という意味で、コードの重複を避けることで、可読性や保守性を高め、バグの温床を減らすことができます。
この記事では、なぜコピペコードがいけないのかを具体例とともに解説し、Node.js (TypeScript) 環境で同じ処理を共通化するための「関数」と「クラス」の活用方法について、実際のコードを交えながらご紹介します。
コピペコードが引き起こす問題点
コピペコードは、以下のような問題を引き起こします。
- 保守性の低下: ある処理に変更が必要になった際、コピペしたすべての箇所を探して修正しなければなりません。一箇所でも修正漏れがあれば、それがバグにつながります。
- 可読性の低下: 同じようなコードが散在していると、全体の構造が把握しにくくなり、どこが本当に重要なロジックなのかが分かりにくくなります。
- バグの温床: 修正漏れだけでなく、コピペ元のバグがそのまま複製されるため、デバッグが非常に困難になります。
- テストの複雑化: 同じロジックが複数箇所にあると、それらすべてをテストする必要があり、テストコードの量も増大します。
具体的な例を見てみましょう。例えば、ユーザーのメールアドレスが有効な形式であるかをチェックする処理が、サインアップ時、プロフィール更新時、問い合わせフォーム送信時など、様々な箇所でコピペされているケースを考えてみましょう。
フォルダ構造:
src/
├── forms/
│ └── contact.ts
└── users/
├── create.ts
└── update.ts
function createUser(email: string, password: string) {
// メールアドレスのバリデーション
if (!email.includes('@') || !email.includes('.')) {
throw new Error('Invalid email format');
}
// ユーザー作成ロジック
console.log('User creation logic executed.');
}
function updateUserProfile(userId: string, email: string) {
// メールアドレスのバリデーション
if (!email.includes('@') || !email.includes('.')) {
throw new Error('Invalid email format');
}
// プロフィール更新ロジック
console.log('User profile update logic executed.');
}
function submitContactForm(email: string, message: string) {
// メールアドレスのバリデーション
if (!email.includes('@') || !email.includes('.')) {
throw new Error('Invalid email format');
}
// フォーム送信ロジック
console.log('Contact form submission logic executed.');
}
ここで、メールアドレスのバリデーションロジックを修正したい場合を考えてみましょう。例えば、現在の「@と.が含まれるか」という簡易なチェックから、より厳密な正規表現でのチェックにしたくなったとしましょう。
もし、上記のように変更したい場合、createUser、updateUserProfile、submitContactFormの3箇所すべてを以下のように修正しなければなりません。
const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/;
if (!emailRegex.test(email)) {
throw new Error('Invalid email format');
}
これら他所の修正を一箇所でも忘れると、そこだけ古いロジックが残り、バグの原因になります。
関数による共通化:手軽にDRYを実現
まずは最もシンプルで手軽な共通化の方法、関数の利用です。単一の処理や計算をまとめるのに非常に適しています。
先ほどのメールアドレスバリデーションの例を、関数で共通化してみましょう。
フォルダ構造:
src/
├── forms/
│ └── contact.ts
├── users/
│ ├── create.ts
│ └── update.ts
└── utils/
└── validation.ts
/**
* メールアドレスが有効な形式かチェックします。
* @param email チェックするメールアドレス
* @returns 有効な場合はtrue、そうでない場合はfalse
*/
export function isValidEmail(email: string): boolean {
// メールアドレスのバリデーション
if (!email.includes('@') || !email.includes('.')) {
return false;
}
return true;
}
import { isValidEmail } from '../utils/validation';
function createUser(email: string, password: string) {
if (!isValidEmail(email)) {
throw new Error('Invalid email format');
}
// ユーザー作成ロジック
console.log('User created successfully.');
}
// 使用例
createUser('test@example.com', 'password123');
// createUser('invalid-email', 'password123'); // エラーが発生する
import { isValidEmail } from '../utils/validation';
function updateUserProfile(userId: string, email: string) {
if (!isValidEmail(email)) {
throw new Error('Invalid email format');
}
// プロフィール更新ロジック
console.log('User profile updated successfully.');
}
import { isValidEmail } from '../utils/validation';
function submitContactForm(email: string, message: string) {
if (!isValidEmail(email)) {
throw new Error('Invalid email format');
}
// フォーム送信ロジック
console.log('Contact form submitted.');
}
このようにisValidEmail関数を定義し、それを各所で呼び出すことで、以下のメリットが得られます。
- コード量の削減: 同じロジックを複数回書く必要がなくなります。今回は3行のコードなので、そこまでコード量が削減できていないですが、10行程度のロジックでもコード量は十分に削減できますよね?
- 再利用性の向上: 一度書いた関数を様々な場所で利用できます。
-
保守性の向上: バリデーションロジックの変更は
isValidEmail関数内の一箇所を修正するだけで済みます。 -
テストのしやすさ:
isValidEmail関数単体をテストすれば良いため、テストコードもシンプルになります。
ただし、関数は単一の処理に特化しているため、複数の関連するデータや処理をまとめて管理したい場合には、次に紹介するクラスの利用を検討します。
クラスによる共通化:より複雑な処理や状態管理に
関数が単一の処理に焦点を当てるのに対し、クラスは関連するデータ(プロパティ)と処理(メソッド)をひとつのまとまり(オブジェクト)として管理するのに適しています。特に、状態を持つ処理や、特定の外部サービスとの連携、ビジネスロジックのまとまりなどを共通化したい場合に真価を発揮します。
具体的な共通化の例:UserApiClientクラス
例えば、ユーザーに関するCRUD(Create, Read, Update, Delete)操作を、外部のREST APIと連携して行う場合を考えてみましょう。各APIエンドポイントへのfetch呼び出し、エラーハンドリング、JSONパースといった共通の処理をUserApiClientクラスとしてまとめます。
以下、APIについては、「そういうエンドポイントで所望の処理を行うAPIがある」or「そういうエンドポイントで所望の処理を行うAPIを用意している」という体で読んでください。
フォルダ構造:
src/
├── apiClients/
│ └── UserApiClient.ts
├── models/
│ └── User.ts
└── app.ts
export interface User {
id: string;
name: string;
email: string;
createdAt: string;
updatedAt: string;
}
export interface CreateUserPayload {
name: string;
email: string;
}
export interface UpdateUserPayload {
name?: string;
email?: string;
}
import { User, CreateUserPayload, UpdateUserPayload } from '../models/User';
/**
* ユーザーAPIと連携するためのクライアントクラス
*/
export class UserApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
console.log(`UserApiClient initialized with base URL: ${this.baseUrl}`);
}
/**
* メールアドレスが有効な形式かチェックするプライベートメソッド。
* @param email チェックするメールアドレス
* @returns 有効な場合はtrue、そうでない場合はfalse
*/
private isValidEmailInternal(email: string): boolean {
const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/;
return emailRegex.test(email);
}
/**
* エラーレスポンスを処理し、適切なエラーをスローするプライベートメソッド。
*/
private async handleError(response: Response): Promise<never> {
const errorBody = await response.json();
throw new Error(`API Error: ${response.status} - ${errorBody.message || 'Unknown error'}`);
}
/**
* 全ユーザーを取得します。
*/
public async getAllUsers(): Promise<User[]> {
const response = await fetch(`${this.baseUrl}/users`);
if (!response.ok) {
await this.handleError(response);
}
return await response.json();
}
/**
* 特定のIDのユーザーを取得します。
* @param id ユーザーID
*/
public async getUserById(id: string): Promise<User> {
const response = await fetch(`${this.baseUrl}/users/${id}`);
if (!response.ok) {
await this.handleError(response);
}
return await response.json();
}
/**
* 新しいユーザーを作成します。
* @param payload 作成するユーザーのデータ
* @returns 作成されたユーザーオブジェクト
*/
public async createUser(payload: CreateUserPayload): Promise<User> {
if (!this.isValidEmailInternal(payload.email)) {
throw new Error('Invalid email format for new user');
}
const response = await fetch(`${this.baseUrl}/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
await this.handleError(response);
}
return await response.json();
}
/**
* ユーザー情報を更新します。
* @param id ユーザーID
* @param payload 更新するユーザーのデータ
* @returns 更新されたユーザーオブジェクト
*/
public async updateUser(id: string, payload: UpdateUserPayload): Promise<User> {
if (payload.email && !this.isValidInternal(payload.email)) {
throw new Error('Invalid email format for update');
}
const response = await fetch(`${this.baseUrl}/users/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
await this.handleError(response);
}
return await response.json();
}
/**
* ユーザーを削除します。
* @param id ユーザーID
*/
public async deleteUser(id: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/users/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
await this.handleError(response);
}
// DELETEの場合は通常、ボディがないためjson()は呼び出さない
}
}
(アプリケーションのエントリーポイントやサービス層での利用例)
import { UserApiClient } from './apiClients/UserApiClient';
// APIのベースURL。環境変数などから取得することが多い
const API_BASE_URL = 'http://localhost:3000/api';
const userApiClient = new UserApiClient(API_BASE_URL);
async function runUserOperations() {
try {
// 1. ユーザー作成 (ここでバリデーションエラーが発生する場合もある)
const createdUser = await userApiClient.createUser({ name: 'Alice', email: 'alice@example.com' });
console.log('Created User:', createdUser);
// 2. 全ユーザー取得
const allUsers = await userApiClient.getAllUsers();
console.log('All Users:', allUsers);
// 3. ユーザー更新 (ここでバリデーションエラーが発生する場合もある)
const updatedUser = await userApiClient.updateUser(createdUser.id, { name: 'Alicia Wonderland' });
console.log('Updated User:', updatedUser);
// 4. 特定ユーザー取得
const fetchedUser = await userApiClient.getUserById(createdUser.id);
console.log('Fetched User by ID:', fetchedUser);
// 5. ユーザー削除
await userApiClient.deleteUser(createdUser.id);
console.log('User deleted successfully.');
// 削除後の全ユーザー確認
const remainingUsers = await userApiClient.getAllUsers();
console.log('Remaining Users:', remainingUsers);
// 不正なメール形式でユーザー作成を試みる例
console.log('nAttempting to create user with invalid email:');
await userApiClient.createUser({ name: 'Charlie', email: 'invalid-email' });
} catch (error: any) {
console.error('Operation failed:', error.message);
}
}
runUserOperations();
UserApiClientクラスを導入することで、以下のメリットがあります。
-
API連携ロジックの集中: ユーザーAPIとの通信に関するすべてのロジック(URLの構築、HTTPメソッド、ヘッダー、ボディ、エラーハンドリングなど)が
UserApiClient内にカプセル化されます。 - データの整合性保証: APIにリクエストを送る前に、必要なバリデーション(例:メールアドレス形式)を内部で行うことで、不正なデータがAPIに送られることを防ぎ、API側の負担を軽減しながら、バリデーション処理もカプセル化しているため、サービス層での利用する際に、毎回バリデーション処理を実装せずに済みます。
-
再利用性: アプリケーション内のどこからでも、同じ
UserApiClientインスタンスを使ってユーザーAPIと安全に連携できます。 -
保守性の向上: APIのエンドポイントや通信プロトコルが変更された場合でも、修正が必要なのはこのクラス内だけです。バリデーションロジックの変更も、このクラス内部の
isValidEmailInternalメソッドを修正するだけで済みます。 -
テストのしやすさ:
fetch関数をモック化することで、実際のAPIを叩かずにUserApiClientの動作を単体テストできます。これにより、テストの実行速度が向上し、外部依存によるテストの不安定さがなくなります。 - 型の安全性: TypeScriptのインターフェースを活用することで、APIのリクエストやレスポンスの構造を明確にし、型の恩恵を最大限に受けられます。
クラスを使うべきケース
- 複数の関連するデータと処理をまとめたい場合: ユーザーAPIのCRUD操作のように、一連のまとまりとして扱いたい場合。
- 状態を持つ処理: APIのベースURL、認証トークン、タイムアウト設定など、インスタンスが特定の状態を保持する必要がある場合。
- DI (Dependency Injection) を活用したい場合: クラスを使うことで、依存関係を外部から注入しやすくなり、テスト容易性や拡張性が向上します。
注意点
- 学習コスト: 関数に比べて、クラス、継承、インターフェースなどの概念を理解する必要があり、やや学習コストが高いです。
- 過剰なクラス設計: 全てをクラスにする必要はありません。シンプルな処理であれば関数の方が簡潔で分かりやすい場合もあります。
関数とクラスの使い分けのポイント
結局、関数とクラスどちらを使うべきか、迷うこともあるでしょう。簡単な使い分けのヒントは以下の通りです。
-
単一の処理や計算、特定の入力を受け取って結果を返すだけの場合: 関数を使いましょう。
isValidEmailのように、状態を持たず、副作用が少ないシンプルなロジックに適しています。 -
複数の関連するデータと処理をまとめたい、または状態を管理したい場合: クラスを検討しましょう。
UserApiClientのように、特定のエンティティや外部サービスに対する一連の操作をまとめるのに適しています。
迷った場合は、まずはシンプルな関数から検討し、機能が増えたり、状態管理が必要になったりしたらクラスへの移行を考える、というアプローチも有効です。
まとめ
コピペコードは、開発効率を低下させ、バグの温床となる「悪しき習慣」です。開発を進める上で、私たちはDRY原則を常に意識し、同じ処理の共通化を徹底すべきです。
- 関数は、単一のシンプルな処理を共通化するのに最適です。
- クラスは、関連するデータと複数の処理をまとめたり、状態を持つロジックをカプセル化するのに強力なツールです。
今日からあなたのコードベースを振り返ってみてください。もしコピペが見つかったら、それを関数やクラスにまとめられないか検討してみましょう。小さな改善の積み重ねが、より良いコードとより効率的な開発につながります。