はじめに
- 過去にレガシーコードを保守する中で、技術的負債が多く困っていたことがあった
- 当時、改善を目指した項目が変更容易性だった
- 改めてどんなことができるのか考えたくなった
変更容易性とは
- 変更容易性について調べてみるとどうやらこの言葉自体は訳語的なものでもともとは「ISO/IEC 25010:2011」は、ソフトウェアやシステムの品質を評価するための国際標準規格が出どころらしく、ここでは直訳すると「修正性(Modifiability)」と呼ばれているらしい
- 意味としてはソフトウェアやシステムの設計やコードが、要件の変更や修正、拡張にどれだけ対応しやすいかを示す概念ということらしい
- 要するに実装済みのコードをどれだけ早く修正できるか?というものである
なぜ大切なのか
①要件の変化に対応しやすい
- ビジネスの要件やユーザーのニーズは時間とともに変化することが多く、それに合わせてシステムも頻繁に変更や機能追加を求められます。変更容易性が高ければ、新しい要件にスムーズに対応でき、短期間で修正や拡張を行うことができます
②保守と修正のコストを削減できる
- システムやコードが複雑すぎたり、変更がしにくい設計になっていると、保守に多大なコストがかかります。変更容易性が高ければ、コードの理解や修正が容易であり、保守コストの削減に直結します
- イメージとしては迷路のような配管を持つビルの水漏れを修理する場面を想像してください。シンプルで整理された配管システムであれば、水漏れ場所もすぐに特定でき、修理はスムーズに進みます。しかし、複雑で入り組んだ配管だと、漏れの場所を特定するだけでも大変ですし、修理中に他の配管を壊すリスクもあります
③品質と安定性を保ちやすい
- 変更容易性が低いと、機能追加や修正の際に予期せぬ不具合が発生しやすくなります。変更しやすい設計やコード構造にしておけば、他の部分に悪影響を与えずに安全に変更ができるため、品質と安定性が維持されやすくなります
- この話は車の部品交換に似ています。変更容易性の高い車は、エンジンやタイヤなど各部品が独立しているため、タイヤ交換がエンジンや電気系統に影響を与えることはありません。しかし、変更容易性が低い車だと、部品同士が密接に組み込まれていて、タイヤ交換一つでも他のシステムに影響を与え、予期せぬトラブルが発生することがあります
④開発スピードが向上する
- 変更容易性が高いと、新しい機能の追加やバグ修正が速やかに行えます。これにより、開発のスピードが上がり、リリースまでの時間が短縮され、競争力が向上します。競合他社に追いつくために急いで新機能を実装することが求められる場合、変更容易性が高ければ、短期間で機能を実装し、迅速にリリースすることができます
⑤開発者の生産性とモチベーションを維持できる
- 煩雑で変更が難しいコードは、開発者のストレスを高め、モチベーションを低下させる原因となります。変更容易性が高いコードベースは、開発者が効率よく作業できる環境を提供し、生産性向上とモチベーション維持につながります。例えば、プロジェクトに新しい開発者が参加したとしても、コードが整理されていれば、すぐに理解でき、生産性も高く保てます
⑥長期的なシステム寿命を実現できる
- システムは通常、長期間にわたって使用されるため、変更が難しい設計だと、数年後には対応できないほどの負債を抱えるリスクがあります。変更容易性を確保することで、システムが長期間にわたり価値を提供できるようにし、再構築や大規模なリファクタリングのコストを防ぎやすくなります
どのようにして改善するのか
①モジュール化する
- プログラムを独立したモジュールやコンポーネントに分けることで、各部分がそれぞれ独立して変更可能になります
- 各モジュールが特定の責任(機能や目的)を持つように設計し、他のモジュールからの影響を最小限に抑えます
②疎結合にする
- コンポーネント同士の依存関係を減らし、変更が他に波及しないようにします。依存関係のインジェクション(DI)を利用したり、インターフェースを使って依存関係を緩く保つと効果的です
- 下記サンプルコードではINotificationServiceインターフェースを導入し、通知方法を注入できるようにしました。これで、メール通知以外の方法(例えばSMS通知など)にも柔軟に対応できます
改善前
public class NotificationService
{
public void SendEmail(string message)
{
Console.WriteLine($"Sending email: {message}");
}
}
public class UserNotifier
{
private NotificationService notificationService = new NotificationService();
public void NotifyUser(string message)
{
notificationService.SendEmail(message);
}
}
↓
改善後
public interface INotificationService
{
void Send(string message);
}
public class EmailNotification : INotificationService
{
public void Send(string message)
{
Console.WriteLine($"Sending email: {message}");
}
}
public class UserNotifier
{
private readonly INotificationService notificationService;
public UserNotifier(INotificationService notificationService)
{
this.notificationService = notificationService;
}
public void NotifyUser(string message)
{
notificationService.Send(message);
}
}
③単一責任の原則
- 各クラスや関数が1つのことだけを責任とするように設計する原則です
- SOLID原則のSで単一責任の原則(single-responsibility principle)と呼ばれています
- 役割が混在しているクラスやメソッドを小さなクラスやメソッドに分け、各々が特定の責任を持つようにします
- 下記例では改善前はUserManagerクラスがユーザーの管理とファイル操作の両方を担当しているため、変更が難しくなっています。改善後はUserManagerとUserFileManagerを分け、各クラスが単一の責任を持つようにしました。こうすることで、ファイル操作やユーザー追加の変更がしやすくなります
改善前
public class UserManager
{
public void AddUser(string name)
{
// ユーザーを追加する処理
Console.WriteLine($"Adding user: {name}");
}
public void SaveUserToFile(string name)
{
// ユーザー情報をファイルに保存する処理
Console.WriteLine($"Saving user {name} to file");
}
}
↓
改善後
public class UserManager
{
public void AddUser(string name)
{
// ユーザーを追加する処理
Console.WriteLine($"Adding user: {name}");
}
}
public class UserFileManager
{
public void SaveUserToFile(string name)
{
// ユーザー情報をファイルに保存する処理
Console.WriteLine($"Saving user {name} to file");
}
}
SOLID原則については以下の記事が分かりやすいです。
④リファクタリング
- 既存のコードを改善し、可読性や保守性を高める作業です
- 定期的にリファクタリングを行い、コードの可読性やモジュール性を向上させます
- 下記例では条件分岐が複雑で可読性が低いコードですが、複雑な条件を分離し、メソッドを使って可読性を向上させています
改善前
public class DiscountCalculator
{
public double CalculateDiscount(int age, bool isMember)
{
if (age < 18)
{
return 0.1;
}
else if (age > 60)
{
return 0.15;
}
else if (isMember)
{
return 0.05;
}
return 0;
}
}
↓
改善後
public class DiscountCalculator
{
public double CalculateDiscount(int age, bool isMember)
{
if (IsYouth(age)) return 0.1;
if (IsSenior(age)) return 0.15;
if (isMember) return 0.05;
return 0;
}
private bool IsYouth(int age) => age < 18;
private bool IsSenior(int age) => age > 60;
}
⑤インターフェースと抽象化の活用
- インターフェースを利用して実装を抽象化することで、依存関係を減らし、変更がしやすくなります
- 複数のクラスが同じ機能を持つ場合、インターフェースを定義し、異なるクラスがそのインターフェースを実装するようにします
- 下記例の改善前では各支払い方法がPaymentProcessor内に直接書かれており、新しい支払い方法を追加するたびにクラスを変更する必要があります。一方改善後はIPaymentMethodインターフェースにより、新しい支払い方法が追加されてもPaymentProcessorのコード変更が不要になりました
改善前
public class PaymentProcessor
{
public void ProcessCreditCardPayment(double amount)
{
Console.WriteLine($"Processing credit card payment of {amount}");
}
public void ProcessBankTransferPayment(double amount)
{
Console.WriteLine($"Processing bank transfer payment of {amount}");
}
}
↓
改善後
public interface IPaymentMethod
{
void Pay(double amount);
}
public class CreditCardPayment : IPaymentMethod
{
public void Pay(double amount)
{
Console.WriteLine($"Processing credit card payment of {amount}");
}
}
public class BankTransferPayment : IPaymentMethod
{
public void Pay(double amount)
{
Console.WriteLine($"Processing bank transfer payment of {amount}");
}
}
public class PaymentProcessor
{
private readonly IPaymentMethod paymentMethod;
public PaymentProcessor(IPaymentMethod paymentMethod)
{
this.paymentMethod = paymentMethod;
}
public void ProcessPayment(double amount)
{
paymentMethod.Pay(amount);
}
}
⑥テスト自動化の導入
- 変更後の動作をすぐに確認できるよう、テスト自動化を行います
- ユニットテスト、統合テスト、UIテストなどを導入し、変更による影響をテストでカバーします
⑦コードの可読性を意識する
- コードを見やすく整理し、誰が見ても理解しやすいように保ちます。読みやすいコードは変更が容易です
- 命名規則を統一し、コメントやドキュメントを充実させることでコードの理解がしやすくなります
⑧依存関係の管理
- 外部ライブラリや他のモジュールへの依存を管理し、依存関係が増えすぎないようにします
- 依存するライブラリのバージョンを固定する、または依存を必要最低限に抑えるようにします
⑨設計パターンの活用
- 変更に強い設計パターン(例えば、ファクトリパターン、ストラテジーパターン、オブザーバーパターンなど)を活用します
- 具体的な実装を抽象化したり、機能の追加や変更に対して柔軟に対応できるようにするため、適切な設計パターンを用います
- 下記例では改善前はSortingServiceクラスがソートの種類ごとに条件分岐を持っており、拡張が困難でしたが、改善後はストラテジーパターンを利用し、ISortStrategyインターフェースでソートアルゴリズムを切り替えられるようにすることで、拡張性を高めました
改善前
public class SortingService
{
public void Sort(int[] data, string sortType)
{
if (sortType == "bubble")
{
// バブルソートの実装
}
else if (sortType == "quick")
{
// クイックソートの実装
}
}
}
↓
改善後
public interface ISortStrategy
{
void Sort(int[] data);
}
public class BubbleSort : ISortStrategy
{
public void Sort(int[] data)
{
Console.WriteLine("Performing bubble sort...");
}
}
public class QuickSort : ISortStrategy
{
public void Sort(int[] data)
{
Console.WriteLine("Performing quick sort...");
}
}
public class SortingService
{
private readonly ISortStrategy sortStrategy;
public SortingService(ISortStrategy sortStrategy)
{
this.sortStrategy = sortStrategy;
}
public void Sort(int[] data)
{
sortStrategy.Sort(data);
}
}
さいごに
- 変数や関数・クラスの命名レベルならチームで意識すれば今日からでもできそうだなって思いました。一方でテストコード書いていない文化のところで「テストコード書こう」といっていきなり完璧を目指すのはかなりハードルが高いと感じたので、できることから始めていくのがベストだと感じました
- また、仮にテストコードが書かれていれば、間違ったコードを書いていも動作の確からしさをすぐに確認できることからリファクタリングしやすい→技術的負債が溜まりにくい→変更しやすい→オープン・クローズドの原則観点でテストコードが書きやすい→・・・みたいないいサイクルが回るのかなと感じました