DIの基礎
Dependency Injection (DI) とは
Dependency Injection (DI) は、メンテナンス性の高いコードを開発するためのアイデアです。ScenarioFlowはDIにもとづいて設計されており、拡張性が高いシステムとなっています。DIについての理解を深めることにより、ScenarioFlowの力をより大きく引き出すことができるでしょう。
一般的に、ソフトウェアは一度リリースされたらそれで終了ではなく、リリース後もバグを修正したり、既存の機能を変更したり、新たな機能を追加したりといったメンテナンスを行います。そのような作業を効率よく行うにあたっては、コードのメンテナンス性が重要です。将来的なコードの改変を想定し、その要求に対応するために良く設計されたコードは変更に対して強く、将来のメンテナンスにかかるコストを減らすことができます。
コードのメンテナンス性を高めるための優れた方法が、疎結合なコードを設計することです。疎結合とは、システム全体におけるモジュール間が弱い依存関係でつながっていることであり、モジュールそれぞれの独立性が高いことからメンテナンス性が高まります。
疎結合な設計を身近なもので例えると、USBが該当します。USBは、PCと周辺機器をつなぐための規格の一つです。USB規格に準拠してさえいれば、キーボードでも、マウスでも、USBメモリでも、同じ端子を使って様々な機器をPCに接続することができるという利点があります。そして、PCとUSBでつながれる周辺機器は、完全に独立しています。
DIは、疎結合なコードを設計するための指針です。「実装に対してではなく、インターフェースに対してプログラムする」ことを原則として、互いに依存性の低いコードを設計することができます。DIをうまく適用できた場合、USBによって独立性が高まるPCとその周辺機器の関係のように、「使うコード」と「使われるコード」が互いに独立して存在し、ある規格に則る限り、「使われるコード」の取り換えがきくようになります。DIに基づいたプログラミングを理解することによって、変更に強いコードを開発することができるでしょう。
密結合なコード
密結合なコードは、ノートPCに搭載されたキーボードのようなものです。
ノートPCのキーボードは、それ専用に設計されているため、小さな領域に対してコンパクトにぴったりと収まります。持ち運びのしやすさという観点では、それが利点となるでしょう。
しかし、このような設計にはいくつかの欠点もあります。
一つは、メンテナンス性が低いということです。このようなキーボードが故障し、キーボードのみを新品に交換する場合を考えると、それは容易ではありません。機器を分解するための道具や機器についての知識が必要ですし、実際に機器を分解し、交換し、元に戻すのには時間と手間がかかります。
もう一つは、互換性が低いということです。例えば、あるノートPCのキーボードを、メーカーが異なる別のノートPCに移植できるでしょうか。それは、ほぼ不可能といっていいでしょう。
疎結合なコード
対して疎結合なコードは、USBでキーボードをPCに接続するようなものです。
この場合、キーボードが故障して交換したければ、古いキーボードのUSB接続を外し、新たなキーボードをUSBで接続するだけです。どのメーカーによって作られたキーボードなのかも関係ありません。LEDの有無に関わらず、マクロ機能、その他便利機能の有無に関わらず、USBコネクタがついてさえいれば、どんなキーボードでも使うことができます。
密結合と疎結合の違い
疎結合なコードと密結合なコードの間にある違いの中で、最も重要なのは「容易に交換できる」ということです。一つ目の例では、キーボードはある型のノートPC専用に設計されていたために交換が困難ですが、二つ目の例では、USBという、デバイスが従うべき共通の規格があるおかげで交換が容易にできます。
DIを適用するコードでは、USBの役割を担うのはインターフェースです。設計の中で、もしクラスが外部の機能を必要とするのであれば、別のクラスを直接使用するのではなく、必要な機能を保証するインターフェースを定義してそれを使用するようにします。そして、必要な機能を持つクラスにはそのインターフェースを実装させるようにします。そうすれば、実際に使用するクラスを、同一のインターフェースを実装するものの中から好きに選択し、後から何度でも簡単に交換することができるようになります。
DIとともにHello, world!
DIを理解する第一歩として、プログラミングの入門としてよく使われる”Hello, world!“を表示するためのプログラムを、DIを使って書いてみましょう。
コード
まずは、以下の4つのクラスを用意します。SampleManager
のみ、MonoBehaviour
オブジェクトです。
SampleManager
を適当なオブジェクトにアタッチし、実行してください。コンソールに”Hello, world!“が表示されます。
using UnityEngine;
public class SampleManager : MonoBehaviour
{
private void Start()
{
ITextWriter textWriter = new ConsoleTextWriter();
var someone = new Someone(textWriter);
someone.SayHello();
}
}
C#using System;
public class Someone
{
private readonly ITextWriter textWriter;
public Someone(ITextWriter textWriter)
{
if (textWriter == null)
throw new ArgumentNullException(nameof(textWriter));
this.textWriter = textWriter;
}
public void SayHello()
{
textWriter.WriteText("Hello, world!");
}
}
C#public interface ITextWriter
{
void WriteText(string text);
}
C#using UnityEngine;
public class ConsoleTextWriter : ITextWriter
{
public void WriteText(string text)
{
Debug.Log(text);
}
}
C#DIの正体
作成したクラスは上図のような依存関係となっています。
DIを採用したことによる利益は後に学びますが、ここでは、DIを取り入れたコードの特徴について、以下の2点を確認しておきましょう。
Someone
クラスが、ITextWriter
インターフェースに依存しているSomeone
クラスは、ITextWriter
インターフェースの実装をコンストラクターで受け取っている
これらを抽象化すれば、次のようになります。
- あるクラスが、あるインターフェースに依存している
- インターフェースの実装は、クラスの外側から渡される
ここから、DIとは何なのか、その正体が導かれます。
すでに、「実装に対してではなく、インターフェースに対してプログラムする」ことがDIの原則であると前のセクションで述べました。サンプルコード において、 Someone
クラスは、ConsoleTextWriter
クラスを直接的に使用するのではなく、ITextWriter
インターフェースを介して間接的に使用しています。さらに言えば、Someone
クラスはConsoleTextWriter
クラスの存在を一切知りません。Someone
クラスがコンストラクターを介してConsoleTextWriter
クラスのインスタンスを受け取るとき、そのインスタンスはあくまでITextWriter
インターフェースの実装として渡されるからです。これが、実装(=クラス)に対してではなく、インターフェースに対してプログラムするということです。
DIのポイントは、あるクラスがあるインターフェースと依存関係(Dependency)を持つこと、そして、その依存が後に外部から渡される、つまり注入(Injection)されることです。
コンストラクター・インジェクション
Someone
クラスは、ITextWriter
インターフェースの実装をコンストラクターで受け取っています。実は、DIにおいて、あるクラスが依存を受け取る場所はコンストラクター以外にもあるのですが、特にコンストラクターで渡す手法をコンストラクター・インジェクションと呼んでいます。
コンストラクター・インジェクションでは、コンストラクターでインターフェースの実装を受け取る際、Someone.cs
のようにガード句を置き、受け取った実装がnull
でないことを確認するようにします。そして、受け取った実装を読み取り専用のフィールド変数に保持し、クラス内のメソッドで使用できるようにします。このフィールド変数は読み取り専用とし、後に変更できないようにします。
コンストラクター・インジェクションは、いくつかあるインジェクションの方式の中で最もよく使われ、また最も理想的な方式です。特別な理由がない限りは、基本的にこの方式を使用します。
DIの利点
Hello, world! の例だと、DIから得られる利益が見えにくく、単にコードを冗長にしただけだと思われるかもしれません。実際、この例では完全にDIの適用は無駄です。
しかし、実際のアプリケーションで、DIはその効力を発揮します。ここからのセクションで、より実践的な例とともに、DIの利点を見ていきます。
DIの適用
ここでは、DIにより疎結合なコードを書くことの利点を見ていきます。
まずは、あるシナリオに合わせて、密結合なコードを作成します。そこでそのコードの問題点を指摘した後、コードにDIを適用し、疎結合なコードとして書き直します。最後に、DIを適用したことによる効果を確認します。
ログインボーナスの判定プログラム
今回は、モバイルゲームにおいて、ログインしたプレイヤーに対してある条件を満たすときにボーナスを付与するという想定でプログラムを書いてみたいと思います。要求される処理は次の通りです。
- ログイン時、現在の日付を取得する
- 日付によって以下のアイテムをプレイヤーに配布する
- 5日:コイン10枚
- 15日:ダイヤ1つ
- 25日:コイン20枚、ダイヤ3つ
密結合なコード
まずは以下のコードを用意し、SampleManager.cs
がシーン上のオブジェクトにアタッチされていることを確認して実行します。クラスの依存関係も、合わせて示しておきます。
using UnityEngine;
public class SampleManager : MonoBehaviour
{
private void Start()
{
LoginService loginService = new();
loginService.Login();
}
}
C#using UnityEngine;
public class LoginService
{
public void Login()
{
DateBasedLoginBonusProvider bonusProvider = new();
var loginBonus = bonusProvider.GetLoginBonus();
if (loginBonus.Any())
Debug.Log($"Hello! You got a login bonus: \n{loginBonus}");
else
Debug.Log("Hello!");
}
}
C#using System;
public class DateBasedLoginBonusProvider
{
public LoginBonus GetLoginBonus()
{
var day = DateTime.Now.Date.Day;
return day switch
{
5 => new LoginBonus { Coin = 10, Diamond = 0 },
15 => new LoginBonus { Coin = 0, Diamond = 1 },
25 => new LoginBonus { Coin = 20, Diamond = 3 },
_ => new LoginBonus { Coin = 0, Diamond = 0 },
};
}
}
C#public record LoginBonus()
{
public int Coin { get; set; }
public int Diamond { get; set; }
public bool Any() => Coin > 0 || Diamond > 0;
}
C#実行したその時の日付によって結果は異なり、以下のようになります。
密結合なコードの問題点
ここでは、前のセクションで作成したシステムについて、いくつかの観点から問題点を指摘します。
不確定性と単一テスト
DateBasedLoginBonusProvider
クラスの単一テストを行いたいとしましょう。このテストでは、取得された日付によって、正しくログインボーナスが付与されるかどうかを確かめなければなりません。
しかし、現時点のコードで取得されるのは、そのコードが実行された時点での日付です。これでは、テストを実行する、その時の日付に対するテストしか実施することができません。
ログインボーナスのすべてのパターンに対してテストを行うためには、現在のコードではクラスの一部をテストのたびに書き換える必要があります。
public LoginBonus GetLoginBonus()
{
// var day = DateTime.Now.Date.Day;
var day = 5;
return day switch
{
5 => new LoginBonus { Coin = 10, Diamond = 0 },
15 => new LoginBonus { Coin = 0, Diamond = 1 },
25 => new LoginBonus { Coin = 20, Diamond = 3 },
_ => new LoginBonus { Coin = 0, Diamond = 0 },
};
}
C#この方法は、極めて非効率的です。DateBasedLoginBonusProvider
クラスの単一テストを自動化できず、テストケースごとにコードを少しだけ書き換えるという面倒な作業を、テストをやり直す必要が生じたたびに強いられることになるからです。
密結合なコードに不確定性を孕んだ処理、例えば現在時刻の取得や乱数が絡む処理などがあると、このように単一テストを行うのが困難になります。
並行開発の難しさ
このログインボーナスを付与するシステムを複数人で開発しており、あなたがLoginService
クラスの開発を担当しているとしましょう。LoginService
クラスの開発が早く終了し、何らかの事情でDateBasedLoginBonusProvider
クラスの開発が遅れているとすると、何が起こるでしょうか。
LoginService
クラスは、DateBasedLoginBonusProvider
クラスを使用しています。そのため、前者のテストは後者が完成するまで実施することができません。これは、チームによるシステムの並行開発の難しさを意味します。
DateBasedLoginBonusProvider bonusProvider = new();
var loginBonus = bonusProvider.GetLoginBonus();
C#今回私たちが作成したものは単純なので、それほどこれが問題だとは想像できないかもしれません。その場合は、2つのクラスがもっと複雑な処理を行う場合を考えてみましょう。例えば、LoginService
クラスでは、ログインボーナスを取得した後に取得したアイテムの画像を画面に表示させるための指示を出さなければいけなかったり、DateBasedLoginBonusProvider
クラスでは、プレイヤーの端末から時刻を取得するのではなく、不正を防止するために近くのサーバーにネットワーク経由でアクセスして、日付の情報を取得しなければならなかったり、といったケースです。
重要なのは、それぞれのクラスの開発には時間がかかり、また、何らかのトラブルにより予期しない遅延が起こる可能性も考えられるようなケースを想定しているということです。このようなケースで、一方のクラスのテストを行うためにはそのクラスが使用しているもう一方のクラスの開発が終了している必要があるというのは、非常に非効率的な状況です。テストを行いたいクラスが依存しているクラスの開発が完了していない場合、開発が完了するまで待機を強いられ、その時間が無駄だからです。理想としては、それぞれのクラスは独立に、並行に開発ができるべきです。
実装の置き換えによる影響
現在、ログインボーナスの判定の際には、ログイン時の日付が特定の日付に合致するかどうかを確かめています。モバイルゲームの何らかのイベントで、一時的にどの日付でも一定のログインボーナスを付与したい状況を考えてみましょう。
まず、すべての日付でボーナスを付与するための新たなクラスを実装する必要があります。そして、密結合なコードでは、古いクラスを使用していたクラスを書き換える必要があります。例えば、新たにEventLoginBonusProvider
クラスを作成した場合、LoginService
クラスを書き換えなければなりません。
public class EventLoginBonusProvider
{
public LoginBonus GetLoginBonus()
{
return new LoginBonus { Coin = 30, Diamond = 5 };
}
}
C#using UnityEngine;
public class LoginService
{
public void Login()
{
//DateBasedLoginBonusProvider bonusProvider = new();
EventLoginBonusProvider bonusProvider = new();
var loginBonus = bonusProvider.GetLoginBonus();
if (loginBonus.Any())
Debug.Log($"Hello! You got a login bonus: \n{loginBonus}");
else
Debug.Log("Hello!");
}
}
C#問題なのは、上位のモジュールが、下位のモジュールの変更に振り回されるということです。下位のモジュールに変更が加わるたびに、上位のモジュールにも変更を加えなければならず、再コンパイルが必要になります。
今回は、上位のモジュールがLoginService
クラス、下位のモジュールがDateBasedLoginBonusProvider
クラスとEventLoginBonusProvider
クラスです。各モジュールは異なるプロジェクトに属し、加えて、LoginService
クラスが属するプロジェクトはサイズが非常に大きいと仮定しましょう。すると、ログインボーナスの付与方法を変更するたびに、LoginService
クラスに変更を加えることになり、結果、大きなプロジェクトの再コンパイルとファイルの大規模な置き換えが必要になることになります。特に、モバイルゲームにおけるログインボーナスの付与方法など、頻繁に変更されることが予想される機能に関してこのような状況になることは避けたいところです。
再利用可能性の低下
ログインボーナスについて、あるイベントで一時的に、通常のボーナスに加えて追加のボーナスを配布することを考えましょう。これは、例えば新しいクラスを作成し、LoginService
クラスを書き換えることで実現できます。
using System;
public class ExCoinLoginBonusProvider
{
public LoginBonus GetLoginBonus()
{
var day = DateTime.Now.Date.Day;
return day switch
{
5 => new LoginBonus { Coin = 20, Diamond = 0 },
15 => new LoginBonus { Coin = 10, Diamond = 1 },
25 => new LoginBonus { Coin = 30, Diamond = 3 },
_ => new LoginBonus { Coin = 10, Diamond = 0 },
};
}
}
C#ExCoinLoginBonusProvider
クラスでは、通常通り、日付によるボーナス配布に加え、どの日付でもコインが10枚配布されるようになっています。
問題は、「通常のボーナス配布」をハードコーディングしていることです。通常のボーナス配布に関する処理は、すでにDateBasedLoginBonusProvider
クラスに記述されているわけですが、その論理を再び別のクラスに書いているわけです。
通常配布されるボーナスが何なのかは、将来変わる可能性があります。今の実装方法では、将来変更があったときに、2つのクラスに変更を加える必要があります。より多くの種類の、通常配布ボーナスに依存するクラスがあれば、それらすべてのクラスに変更を加える必要があります。
このように、密結合なコードでは、すでに完成している論理を再利用することが難しくなります。論理の再利用をしないということは、変更に対するコストが大きくなることを意味します。
疎結合なコード
ここまでで、ログインボーナスを付与するシステムを密結合なコードで作成し、その欠点について確認しました。ここからは、システムを疎結合なコードで書き直し、その利点についてみていきます。このセクションでは、まずはコードの書き直しを行います。
以下のクラスを用意しましょう。これらのクラスの依存関係も、合わせて示します。
このプログラムを実行すると、密結合なコード と同様の結果になります。
using UnityEngine;
public class SampleManager : MonoBehaviour
{
private void Start()
{
LoginService loginService = new(
new DateBasedLoginBonusProvider(
new DateTimeProvider())
);
loginService.Login();
}
}
C#using System;
using UnityEngine;
public class LoginService
{
private readonly ILoginBonusProvider loginBonusProvider;
public LoginService(ILoginBonusProvider loginBonusProvider)
{
this.loginBonusProvider = loginBonusProvider ??
throw new ArgumentNullException(nameof(loginBonusProvider));
}
public void Login()
{
var loginBonus = loginBonusProvider.GetLoginBonus();
if (loginBonus.Any())
Debug.Log($"Hello! You got a login bonus: \n{loginBonus}");
else
Debug.Log("Hello!");
}
}
C#public interface ILoginBonusProvider
{
LoginBonus GetLoginBonus();
}
C#using System;
public class DateBasedLoginBonusProvider : ILoginBonusProvider
{
private readonly IDateTimeProvider dateTimeProvider;
public DateBasedLoginBonusProvider(IDateTimeProvider dateTimeProvider)
{
this.dateTimeProvider = dateTimeProvider ??
throw new ArgumentNullException(nameof(dateTimeProvider));
}
public LoginBonus GetLoginBonus()
{
var day = dateTimeProvider.GetCurrentDateTime().Day;
return day switch
{
5 => new LoginBonus { Coin = 10, Diamond = 0 },
15 => new LoginBonus { Coin = 0, Diamond = 1 },
25 => new LoginBonus { Coin = 20, Diamond = 3 },
_ => new LoginBonus { Coin = 0, Diamond = 0 },
};
}
}
C#using System;
public interface IDateTimeProvider
{
DateTime GetCurrentDateTime();
}
C#using System;
public class DateTimeProvider : IDateTimeProvider
{
public DateTime GetCurrentDateTime()
{
return DateTime.Now;
}
}
C#疎結合なコードによる問題の解決
システムを疎結合なコードで書き直したので、密結合なコードで生じていた問題がどのように解決されるのかを見ていきます。
テスト用オブジェクトを用いた単体テスト
DIを適用した疎結合なコードでは、テスト用のオブジェクトを作成することで、時刻や乱数による不確定性や、下位モジュールの開発の遅延を乗り越えて単体テストを実施することができます。
例えば、現在時刻に基づいてログインボーナスを決定する、DateBasedLoginBonusProvider
のテストコードは次のように書けます。ちなみに、テストコードの作成にはUnityのTest Runnerを使用しています。
using NUnit.Framework;
using System;
using System.Linq;
public class DateBasedProviderTest
{
[Test]
public void Day5thPasses()
{
LoginBonus correctBonus = new LoginBonus { Coin = 10, Diamond = 0 };
DateTime dateTime = new DateTime(2024, 1, 5);
foreach (var i in Enumerable.Range(0, 36))
{
DateBasedLoginBonusProvider dateBasedLoginBonusProvider = new(
new DateTimeProviderStub(dateTime.AddMonths(i)));
var loginBonus = dateBasedLoginBonusProvider.GetLoginBonus();
Assert.That(loginBonus == correctBonus);
}
}
private class DateTimeProviderStub : IDateTimeProvider
{
private readonly DateTime dateTime;
public DateTimeProviderStub(DateTime dateTime)
{
this.dateTime = dateTime;
}
public DateTime GetCurrentDateTime()
{
return dateTime;
}
}
}
C#このような単体テストが実施できるのは、DateBasedLoginBonusProvider
クラスが依存しているのが、DateTimeProvider
クラスではなく、IDateTimeProvider
インターフェースだからです。そのインターフェースの実装はコンストラクターで渡すことになっているので、そのインターフェースを実装するクラスであれば、何でも渡すことができます。
本来渡すはずであるDateTimeProvider
クラスは、プログラム実行時の実行時間を返す、不確定性を持った実装なので、単体テストの自動化には向きません。そこで、今回の例ではDateTimeProviderStub
というクラスを作成し、DateBasedLoginBonusProvider
クラスに渡されるDateTime
を自由に設定できるようにしています。このように、要求されているインターフェースを実装する、テスト用のオブジェクトを作成することで、不確定性を持つ下位モジュールを使用するクラスの単体テストを実施することができます。
加えて重要なのは、各クラスがインターフェースを仲介としてつながることで独立性が高まり、完全な並行開発が可能になるということです。今回は、DateBasedLoginBonusProvider
クラスのテストの例を確認しましたが、LoginService
クラスのテストでも同じようなことをします。LoginService
クラスはILoginBonusProvider
インターフェースに依存しているので、DateBasedLoginBonusProvider
クラスに限らず、そのインターフェースを実装するクラスなら何でも渡すことができます。そのため、やはりテスト用のクラスを作成することでDateBasedLoginBonusProvider
クラスと関係なしに、LoginService
クラスのテストを行うことができます。つまり、2つのクラスの間に直接的な関係はなく、各クラスの開発は独立に進めることができます。
まとめると、DIを適用した疎結合なコードでは、クラスどうしをインターフェースを介して間接的につなげることによって、テストをしたいクラスに対して、テストのためだけに作られた都合の良い実装を渡すことができるようになります。結果、下位モジュールの不確定性に関係なく様々なテストケースを自由に試すことができ、また下位モジュールの開発進度に関係なく、上位モジュールのテストを独立して行うことができます。
スタブ
今回の例で作成したような、「一定の値を返すテスト用のオブジェクト」を、スタブと呼びます。テスト用のオブジェクトには、他にモック、スパイ、フェイクなどがあります。
テストダブル
スタブのような、「テスト対象の依存を置き換える、テスト目的にのみ使用されるオブジェクト」のことをテストダブル (Test Double)と呼びます。Doubleは影武者を意味します。
テストダブルは、テスト対象の依存先に不確定性があったり、依存先の開発のコストが高かったりする場合に有効です。
同じインターフェースに対する実装の置き換え
密結合なコードでは、下位モジュールの変更が上位モジュールに影響を及ぼすという問題がありました。あるイベントでログインボーナスの付与に関する論理を変更しようとして新たなクラスを作成すると、その論理を使用するLoginService
クラスにも影響が及びます。
疎結合なコードでは、クラス同士はインターフェースでつながっています。そのため同じインターフェースを実装しているクラスであれば、上位のモジュールに変更を加えることをなく、自由に置き換えることができます。
例えば、あるイベントが開催され、一時的にすべての日付で一定のログインボーナスを付与したいとき、必要なインターフェースを実装する新たなクラスを作成し、一番上位のクラスであるSampleManager
クラスに変更を加えることでそれが実現できます。ILoginBonusProvider
インターフェースの実装としてもともとDataBasedLoginBonusProvider
クラスがLoginService
クラスにインジェクトされていたのに対し、下のコードでは、EventLoginBonusProvider
クラスが代わりにインジェクトされています。
ポイントは、LoginService
クラスが使用する実装を変更したい際に、LoginService
クラス自体には一切の変更を加える必要がないということです。これは、下位のモジュールの変更が上位のモジュールに影響を及ぼさないことを意味します。
public class EventLoginBonusProvider : ILoginBonusProvider
{
public LoginBonus GetLoginBonus()
{
return new LoginBonus { Coin = 30, Diamond = 5 };
}
}
C#public class SampleManager : MonoBehaviour
{
private void Start()
{
//LoginService loginService = new(
// new DateBasedLoginBonusProvider(
// new DateTimeProvider())
// );
LoginService loginService = new(
new EventLoginBonusProvider());
loginService.Login();
}
}
C#コンポジション・ルート
依存関係の図にみられるように、SampleManager
クラスは、自分自身以外のすべてのクラスのインスタンスを作成し、各オブジェクトを結びつける役割を担っています。その処理はStart
メソッドの中で行われていますが、このような場所をコンポジション・ルートと呼びます。コンポジション・ルートは通常、アプリケーションのエントリーポイントに置かれ、Unityなら例えばStart
メソッドがその場所に該当します。コンポジション・ルートでは、システムを構成するすべてのクラスのインスタンス化を行い、主に各コンストラクターに必要なオブジェクトを渡すことで、各クラス間に存在する依存関係を解決します。
DIを適用した疎結合なコードでは、実装の置き換えを行いたいときに変更するのはコンポジション・ルートのみです。その他クラスに影響が及ぶことはありません。
デザインパターンによるコードの再利用
DIを適用した疎結合なコードでは、システムが良く設計されていれば、新たな機能を追加したいときにすでに作成されているコードを再利用することができます。しかも、そのコードに手を加える必要はありません。
あるイベントで、普段のログインボーナスに追加のボーナスを付与したい場合を考えてみましょう。密結合なコードでは、ExCoinLoginBonusProvider
クラスのように、新しく作成したクラスに、すでに作成したクラスの論理を含ませる必要がありました。疎結合なコードでは、新たな論理を追加したいとき、その論理を実装するクラスは、それが付加される対象の論理がどのようなものであるかを気にする必要はありません。
using System;
public class LoginBonusProviderExCoinDecorator : ILoginBonusProvider
{
private ILoginBonusProvider baseLoginBonusProvider;
public LoginBonusProviderExCoinDecorator(ILoginBonusProvider baseLoginBonusProvider)
{
this.baseLoginBonusProvider = baseLoginBonusProvider
?? throw new ArgumentNullException(nameof(baseLoginBonusProvider));
}
public LoginBonus GetLoginBonus()
{
var baseLoginBonus = baseLoginBonusProvider.GetLoginBonus();
return baseLoginBonus with { Coin = baseLoginBonus.Coin + 10 };
}
}
C#using UnityEngine;
public class SampleManager : MonoBehaviour
{
private void Start()
{
//LoginService loginService = new(
// new DateBasedLoginBonusProvider(
// new DateTimeProvider())
// );
LoginService loginService = new(
new LoginBonusProviderExCoinDecorator(
new DateBasedLoginBonusProvider(
new DateTimeProvider())));
loginService.Login();
}
}
C#各日付に対する実行結果も示します。
上のコードは、通常のボーナスに加えて、コイン10枚を配布します。このコードの実行結果と、初めのコードの実行結果を比較してみましょう。
ポイントは、次の二つです。
LoginBonusProviderExCoinDecorator
クラスはILoginBonusProvider
インターフェースの実装を使用しながら、このクラス自身もまたILoginBonusProvider
インターフェースを実装していることLoginService
クラスや、DateBasedLoginBonusProvider
クラスには一切の変更がなされていないこと
まず、LoginBonusProviderExCoinDecorator
クラスについて、インターフェースのメンバーメソッドであるGetLoginBonus
の中では、内部に保持しているインターフェースの実装からログインボーナスを取得し、それにコインを足したうえでその値を返しています。これで、「通常のボーナスに加えて、追加のコインを配布する」論理が実現できます。しかも、通常のボーナスとは何なのかについては、このクラスは一切知りません。
次に、通常のボーナスを付与する論理を持っていたDateBasedLoginBonusProvider
クラスにも、それを使用していたLoginService
クラスにも、一切の変更がなされていません。ただ、コンポジション・ルートが書き換わったのみです。
このように、疎結合なコードでは新たな機能を追加したい際、その機能を持つクラスを他のクラスとは無関係に実装し、もともとあった機能を再利用することができます。
ちなみに、DIによって再利用性を高めるためのデザインパターンがいくつかあり、今回はそのうちの一つであるデコレーター・パターンを使用しています。
デコレーター・パターン
今回の例では、デコレーター・パターンというデザインパターンを使用しました。デコレーター・パターンでは、デコレーターと呼ばれるクラスが、あるインターフェースに依存しつつ、自身もそのインターフェースを実装します。デコレーターは、一つのインターフェースの実装に対して追加の処理を付け加えるために使用されます。
今回の例では、デコレーションされている側の返り値を加工して返すデコレーターを作成しました。ここで、もう一つの例を見ておきましょう。次のデコレーターはボーナスの値をチェックし、あまりにも多いボーナスが与えられていた時に、何かしらのエラーが発生しているとみなして例外を送出します。このデコレーターの、「異常な値を検出して例外を発生させる」という処理も、デコレーションされる側の実装とは独立しています。
using System;
public class LoginBonusProviderValueCheckDecorator : ILoginBonusProvider
{
private readonly ILoginBonusProvider loginBonusProvider;
public LoginBonusProviderValueCheckDecorator(ILoginBonusProvider loginBonusProvider)
{
this.loginBonusProvider = loginBonusProvider
?? throw new ArgumentNullException(nameof(loginBonusProvider));
}
public LoginBonus GetLoginBonus()
{
var loginBonus = loginBonusProvider.GetLoginBonus();
if (loginBonus.Coin > 10000 || loginBonus.Diamond > 100)
{
throw new Exception("Too much bonus!");
}
else
{
return loginBonus;
}
}
}
C#デコレーターはデコレーションされる側とは無関係!
LoginBonusProviderExCoinDecorator
クラスは、DateBasedLoginBonusProvider
クラスではなく、ILoginBonusProvider
インターフェースに依存しています。これは、LoginBonusProviderExCoinDecorator
の論理が、「通常のボーナスに対してコイン10枚を追加する」というものであるよりは、より正確には「渡されたインターフェースの実装が配布するボーナスに対してコイン10枚を追加する」ものであることを意味します。
ILoginBonusProvider
インターフェースを実装するクラスであればなんでも渡すことができるので、例えばEventLoginBonusProvider
クラスを渡して、「日付によらずボーナスがもらえるかつ、さらにそこに追加のコインがもらえる」ような論理を実現することもできます。この場合も、コンポジション・ルートを書き換えるのみで実現できます。
using UnityEngine;
public class SampleManager : MonoBehaviour
{
private void Start()
{
LoginService loginService = new(
new LoginBonusProviderExCoinDecorator(
new EventLoginBonusProvider()));
loginService.Login();
}
}
C#また、テスト用のオブジェクトを用意することができるので、単体テストも簡単に行うことができます。
using NUnit.Framework;
public class ExCoinDecoratorTest
{
[Test]
public void ExCoinDecoratorTestSimplePasses()
{
LoginBonus correctBonus = new LoginBonus { Coin = 25, Diamond = 3 };
LoginBonusProviderExCoinDecorator exCoinDecorator = new(
new LoginBonusProviderStub(new LoginBonus { Coin = 15, Diamond = 3 }));
LoginBonus loginBonus = exCoinDecorator.GetLoginBonus();
Assert.That(correctBonus == loginBonus);
}
private class LoginBonusProviderStub : ILoginBonusProvider
{
private readonly LoginBonus loginBonus;
public LoginBonusProviderStub(LoginBonus loginBonus)
{
this.loginBonus = loginBonus;
}
public LoginBonus GetLoginBonus()
{
return loginBonus;
}
}
}
C#疎結合なコードから得られる一般的な利益
DIを適用して疎結合なコードを作成すると、一般的に以下の5つの利益を得ることができます。
利益 | 概要 |
---|---|
遅延結合性 | 再コンパイルなしに実装を置き換えることができる |
拡張性 | コードの拡張にかかるコストが少ない |
並行開発性 | 独立したコードを複数人で同時に開発することができる |
保守性 | 各クラスが明確な責務を持ちメンテナンスがしやすい |
試験性 | 独立したクラスをクラス単位でテストできる |
遅延結合性
再コンパイルすることなく、実行時にシステムの構成を変えることができる性質です。疎結合なコードでは、依存関係の解決を、コンポジション・ルートの一か所で行います。そのため、設定ファイルを読み込んで、実行時に依存関係を必要に応じて書き換えるといったことが可能です。
例えば、以下のようにして、設定ファイルから読み込んだ値をもとにして、実行時にシステムの構成を変えることができます。isEventGoing
の値はJSONやXMLなどのテキストファイルから読み取った値であると仮定してください。この場合、イベント開催の有無に合わせて設定ファイルの値を書き換えることで、再コンパイルなしでシステムの構成を書き換えることができます。
var isEventGoing = true; // Get this from a configuration file
ILoginBonusProvider loginBonusProvider = isEventGoing ?
new EventLoginBonusProvider() :
new DateBasedLoginBonusProvider(
new DateTimeProvider());
LoginService loginService = new(loginBonusProvider);
loginService.Login();
C#なお、実際に遅延結合性を利用する際にはリフレクションを利用することで、より柔軟に構成を切り替えることができます。
拡張性
新たな機能の追加や、既存の機能の拡張におかかるコストが少ないという性質です。 実装の置き換え と デザインパターンの使用 で学んだ通り、うまく設計された疎結合なコードでは、システムの拡張の際に、既存のコードに手を加える必要がありません。新たなクラスを用意し、コンポジション・ルートを書き換えるのみで済みます。
リスコフ置換の原則
リスコフ置換の原則 (Liskov Substitution Principle: LSP) は、「同一のインターフェースに対する実装は、互いに可換でなければならない」という主張です。
実装の置き換え で学んだように、疎結合なコードでは、クラスが依存するのはインターフェースであるため、同一のインターフェースを実装するクラスであれば、何でもそのクラスに渡すことができます。LSPを踏まえると、この「互いに置き換えることができる」という性質はDIの観点からは非常に重要であり、インターフェースに依存しているクラスは特定の実装を意識してはいけないということも言えます。
開放・閉鎖の原則
開放・閉鎖の原則 (Open/Closed Principle: OCP) は、「クラスは拡張に対して開いていて、変更に対して閉じているべき」という主張です。
デザインパターン で学んだデコレーター・パターンのように、既存のクラスに一切変更を加える必要がなく新たな機能を追加できるというのは、まさにOCPに従っているといえます。
並行開発性
複数人による並行開発を効率的にできる性質です。 テスト用オブジェクトを用いた単体テスト で学んだ通り、疎結合なコードでは、実際に使用される下位モジュールの開発が完了していなくとも、テスト用のオブジェクトを使用することで上位モジュールのテストを行うことができます。
保守性
コードのメンテナンスにかかるコストが少ないという性質です。疎結合なコードでは、各クラスが明確で単純な役割を持つようにシステムを設計することを目指します。その結果、機能拡張の際にどこを変更すればいいのかがわかりやすくなったり、不具合の発生時、トラブルシューティングが容易になったりします。
単一責務の原則
単一責務の原則 (Single Responsibility Principle: SRP) は、「クラスが変更される理由は一つでなくてはならない」という主張です。これは言い換えれば、各クラスは単一の役割を持つべきということです。そうすることで、ここで論じている保守性の向上が期待できます。
DIにおいては、コンストラクター・インジェクションを基本的に用いますが、ここでインジェクトされる依存の数が、SRPに違反しているかどうかの良い指標になります。今回の例では高々1つの依存しかインジェクトしていませんが、インジェクトされる依存の数が3つ以下に収まっているかどうかが、SRPに違反していないかどうかの目安になるでしょう。
試験性
単体テストが可能であるという性質です。 テスト用オブジェクトを用いた単体テスト で学んだ通り、疎結合なコードではテスト用のクラス、テストダブルを作成することで、テスト対象の依存先が不確定性を持っていたとしても、まだ開発が完了していなくても、単体テストを行うことができます。
依存性の逆転
密結合なコードでは、LoginService
クラスがDateBasedLoginBonusProvider
クラスを使用している、つまり依存しているという関係です。言い換えれば、上位のクラスが下位のクラスに直接依存しています。
密結合なコードと疎結合なコードの違いを、クラスの依存関係から見てみましょう。以下に、密結合なコードと疎結合なコード、それぞれの依存関係の一部を示しています。
注目すべきは、LoginService
クラスとDateBasedLoginBonusProvider
クラスの依存関係です。密結合なコードと、疎結合なコードでは何が変化したでしょうか。
疎結合なコードでは、LoginService
クラスがILoginBonusProvider
インターフェースを使用し、DateBasedLoginBonusProvider
クラスがこのインターフェースを実現しています。つまり、上位のクラスも、下位のクラスも、インターフェースに依存している状態です。
結局のところ、密結合なコードと疎結合なコードの一番の違いは、クラスが具体的なクラスに依存押しているか、抽象的なインターフェースに依存しているかです。DIでは、各クラスが直接依存関係を持つのではなく、インターフェースを介して間接的につながることにより、コードの拡張性や、再利用性が高まります。
依存性逆転の原則 (DIP)
DIが主に達成しようとしているのは、依存性逆転の原則 (Dependency Inversion Principle: DIP)です。DIPは、「上位のモジュールは下位のモジュールに依存すべきではなく、代わりに両者は抽象に依存すべきである」ということを主張しています。
今回の例でいえば、LoginService
クラスが上位のモジュール、DateBasedLoginBonusProvider
クラスが下位のモジュールを意味し、インターフェース、つまりILoginBonusProvider
が抽象を意味します。密結合なコードでは、上位のモジュールが下位のモジュールに依存する状態になっており、下位のモジュールの変更が上位のモジュールに影響を受ける状態でした。一方で、疎結合なコードでは上位のモジュールと下位のモジュールはインターフェースに依存しているため、各モジュールは独立して存在することができます。
さらに言えば、DIPでは「抽象に対する制御は、それを使用する側が持つべき」とされています。すなわち、理想的には上位のモジュールがインターフェースに対する制御を持つべきとされます。
実際に今回の例では、ILoginBonusProvider
インターフェースの求められる振る舞いを規定しているのはLoginService
側です。その要求された振る舞いを満たすように、DateBasedLoginBonusProvider
クラスはインターフェースを実装します。これは、クラス同士が直接結びついている状態と比較して、どちらかといえば上位のモジュールに、下位のモジュールが合わせている状態です。この意味で、DIを適用した疎結合なコードでは、下位のモジュールが上位のモジュールに依存しているといえ、依存性が逆転しているといえます。
(DIPでは、理想的にはインターフェースは上位のモジュールに属するべきとされますが、実際には下位のモジュールにインターフェースに属せざるをえない場合もあります。重要なのは、各クラスが直接他クラスに依存するのではなく、インターフェースに依存するということです。)
インターフェースか、クラスか
既に、DIの主要なアイデアが、クラス同士をインターフェースで結びつけることであることを学びました。
では、すべてのクラスは、あらゆる場面でインターフェースに依存する必要があるのでしょうか。言い換えれば、クラスに直接依存することは、DIではありえないのでしょうか。
答えは、ノーです。場面によってもちろん、クラスに直接依存することもあります。むしろ、そうでなければインターフェースによる依存の抽象化が永遠に終わらないでしょう。あるクラスに対する依存をインターフェースで抽象化するべきかどうかは、場面に応じて見極める必要があります。
見極めに関して、一つの指針を以下に示します。以下のいずれかに当てはまれば、クラスへの依存はインターフェースに対する依存に置き換えられるべきです。
- 依存先に求められる要求が、将来的に変化する可能性が高い
- 依存先に対する要求が、実行環境によって変化する
- 依存先に不確定性がある。例えば、現在時刻や乱数の取得など
クラスに求められる要求が変化する可能性が高かったり、実行環境によって異なる要求があったりする場合は、新たにインターフェースを規定することに対するコストよりも、抽象化により要求の変更に対応するためのコストが低くなることへの利点のほうが上回ります。また、不確定性については、抽象化によるテスト容易性がはるかに大きくなります。
実際に、今回作成したシステムでは、配布されるログインボーナスの決定方法は将来的に変わる可能性が高く、日付に基づいてログインボーナスを決定する場合には、現在時刻の取得という不確定性が関わっていました。そのため、それぞれ、ILoginBonusProvider
インターフェース、IDateTimeProvider
インターフェースという形で依存を抽象化し、それによってシステムが変更への耐性と、テスト容易性を獲得しました。実行環境によって依存先への要求が変わることに対処する例については今回は取り上げていませんが、例としてはWindowsやMacなどのOSの差、あるいはMySQLやMicrosoft SQL ServerなどのDBMSの差を吸収するための抽象化が挙げられます。
逆に、System.DateTime
構造体やLoginBonus
レコードなどは、将来的に要求される振る舞いが変わることはありません。DateTime
構造体もLoginBonus
レコードも、単にクラス間のデータのやり取りに使われているだけであり、役割として、変更できる振る舞いをそもそも持っていないからです。
第一に、不確定性を持つ依存なのか、第二に、将来変更されうる、もしくは実行環境によって変更しなければいけない振る舞いを持つ依存なのかを確認しましょう。
まとめ
最後に、これまでに出てきたコードや図を再掲しつつ、DIについて学んだことをまとめます。
DIの目的と利点
DIの目的は、「クラスに対してではなく、インターフェースに対してプログラミングする」ことにより疎結合なコードを設計し、コードの拡張性や再利用性を高めることです。DIの適用により、以下の利益を得ることができます。
利益 | 概要 |
---|---|
遅延結合性 | 再コンパイルなしに実装を置き換えることができる |
拡張性 | コードの拡張にかかるコストが少ない |
並行開発性 | 独立したコードを複数人で同時に開発することができる |
保守性 | 各クラスが明確な責務を持ちメンテナンスがしやすい |
試験性 | 独立したクラスをクラス単位でテストできる |
DIを適用する方法
DIを適用して疎結合なコードを設計する指針は、「インターフェースでクラスをつなげること」です。あるクラスが別のクラスを直接使用するのではなく、インターフェースを使用するようにし、そのインターフェースを他のクラスに実装させるようにします。
依存関係は、たいていの場合はコンストラクター・インジェクションによって実行時に解決します。
using System;
using UnityEngine;
public class LoginService
{
private readonly ILoginBonusProvider loginBonusProvider;
public LoginService(ILoginBonusProvider loginBonusProvider)
{
this.loginBonusProvider = loginBonusProvider ??
throw new ArgumentNullException(nameof(loginBonusProvider));
}
public void Login()
{
var loginBonus = loginBonusProvider.GetLoginBonus();
if (loginBonus.Any())
Debug.Log($"Hello! You got a login bonus: \n{loginBonus}");
else
Debug.Log("Hello!");
}
}
C#依存関係を解決する場所は、コンポジション・ルートと呼ばれます。コンポジション・ルートは、普通はアプリケーションのエントリーポイントに置かれ、UnityではStart()
メソッドが一つの候補になります。
using UnityEngine;
public class SampleManager : MonoBehaviour
{
private void Start()
{
LoginService loginService = new(
new DateBasedLoginBonusProvider(
new DateTimeProvider())
);
loginService.Login();
}
}
C#抽象化の基準
- 依存先に求められる要求が、将来的に変化する可能性が高い
- 依存先に対する要求が、実行環境によって変化する
- 依存先に不確定性がある。例えば、現在時刻や乱数の取得など
上記のいずれかに当てはまる場合は、依存をインターフェースで抽象化したほうが良いと考えられます。すべての依存を抽象化する必要はなく、特にLoginBonus
レコードのような、クラス同士がデータをやり取りするためだけに使用される、振る舞いを持たないクラスは抽象化する必要はありません。
public record LoginBonus()
{
public int Coin { get; set; }
public int Diamond { get; set; }
public bool Any() => Coin > 0 || Diamond > 0;
}
C#不確定性を持つ、もしくは将来的に変更の可能性があったり、実行環境で変更する必要があったりする依存は、インターフェースで抽象化するのが望ましいでしょう。
演習
ログインボーナス表示方法の抽象化
既に作成したLoginService
クラスを改良し、受け取ったログインボーナスの表示方法を抽象化します。LoginBonus
オブジェクトを受け取って、その内容を表示することを要求とするインターフェースを作成し、TextMeshProUGUI
コンポーネントに内容を表示する実装を一つ作成しましょう。
スタブを用意して自由な日時でテストしよう
IDateTimeProvider
のスタブを用意すると、任意の日時でテストを行うことができます。
using System;
using TMPro;
using UnityEngine;
public class SampleManager : MonoBehaviour
{
[SerializeField]
private TextMeshProUGUI textMeshPro;
private void Start()
{
LoginService loginService = new(
new DateBasedLoginBonusProvider(
new DateTimerProviderStub()),
new TMProLoginBonusWriter(textMeshPro));
loginService.Login();
}
private class DateTimerProviderStub : IDateTimeProvider
{
public DateTime GetCurrentDateTime()
{
return new DateTime(2024, 8, 25);
}
}
}
C#取得したログインボーナスのログをとるデコレーター
受け取ったログインボーナスの内容を、そのままデバッグコンソールに出力するデコレーターを作成しましょう。つまり、ログインボーナスの内容が、TMProには加工されてから表示され、コンソールにはそのまま表示されます。
なお、このデコレーターは前問で作成したものと同じインターフェースを実装します。
解答例
ログインボーナスの表示方法の抽象化
using TMPro;
using UnityEngine;
public class SampleManager : MonoBehaviour
{
[SerializeField]
private TextMeshProUGUI textMeshPro;
private void Start()
{
LoginService loginService = new(
new DateBasedLoginBonusProvider(
new DateTimeProvider()),
new TMProLoginBonusWriter(textMeshPro));
loginService.Login();
}
}
C#using System;
public class LoginService
{
private readonly ILoginBonusProvider loginBonusProvider;
private readonly ILoginBonusWriter loginBonusWriter;
public LoginService(ILoginBonusProvider loginBonusProvider, ILoginBonusWriter loginBonusWriter)
{
this.loginBonusProvider = loginBonusProvider ??
throw new ArgumentNullException(nameof(loginBonusProvider));
this.loginBonusWriter = loginBonusWriter ??
throw new ArgumentNullException(nameof(loginBonusWriter));
}
public void Login()
{
var loginBonus = loginBonusProvider.GetLoginBonus();
loginBonusWriter.WriteLoginBonus(loginBonus);
}
}
C#public interface ILoginBonusWriter
{
void WriteLoginBonus(LoginBonus loginBonus);
}
C#using System;
using TMPro;
public class TMProLoginBonusWriter : ILoginBonusWriter
{
private readonly TextMeshProUGUI textMeshPro;
public TMProLoginBonusWriter(TextMeshProUGUI textMeshPro)
{
this.textMeshPro = textMeshPro ??
throw new ArgumentNullException(nameof(textMeshPro));
}
public void WriteLoginBonus(LoginBonus loginBonus)
{
textMeshPro.text = loginBonus.Any() ?
$"Hello! You got a login bonus: \n{loginBonus}" :
"Hello!";
}
}
C#取得したログインボーナスのログをとるデコレーター
using System;
using TMPro;
using UnityEngine;
public class SampleManager : MonoBehaviour
{
[SerializeField]
TextMeshProUGUI textMeshPro;
private void Start()
{
LoginService loginService = new(
new DateBasedLoginBonusProvider(
new DateTimeProvider()),
new LoginBonusWriterLogDecorator(
new TMProLoginBonusWriter(textMeshPro)));
loginService.Login();
}
}
C#using System;
using UnityEngine;
public class LoginBonusWriterLogDecorator : ILoginBonusWriter
{
private readonly ILoginBonusWriter loginBonusWriter;
public LoginBonusWriterLogDecorator(ILoginBonusWriter loginBonusWriter)
{
this.loginBonusWriter = loginBonusWriter
?? throw new ArgumentNullException(nameof(loginBonusWriter));
}
public void WriteLoginBonus(LoginBonus loginBonus)
{
Debug.Log($"From decorator: {loginBonus}");
loginBonusWriter.WriteLoginBonus(loginBonus);
}
}
C#
コメント