C#でドメイン駆動開発ValueObjectでプログラムの複雑さを取り除く⑩

当サイトではアフィリエイト広告を利用しています。

C#でドメイン駆動開発

ここまでドメイン駆動開発をいろいろと解説してきましたが今回はValueObject(バリューオブジェクト)というものの解説です。プログラムの複雑さを取り除くという観点からするとこのValueObjectが一番強力で大事な概念だと私は思っています。

ValueObject

ここまでで何かお気付きでしょうか?
現状非常によくないコードが存在しています。
例えばLatestViewModelの次のコード

MeasureValue = Math.Round(entity.MeasureValue, 2) + "m/s";

 

ViewModelはクライアントコードなのに,ビジネスロジックが入り込んでいます。直近値画面以外で計測値を使う場合に確実にコードが重複します。現にMeasureViewModelに同様のコードが存在します。「計測値は小数点以下2桁で丸める」や単位は「m/s」というのはアプリケーション全体で共通の仕様であり,これがまさにドメインロジックになります。

ViewModelにドメインロジックを発見したら,ドメイン層に移します。その移し方の代表がValueObject(以下VO)といわれるものになります。

ValueObjcetの特徴は次の通りです。

  • ・不変クラス
  • ・同じ値のValueObjectは同じものと判断する
  • ・一意性がない

まず不変クラスというのはオブジェクトが生成されてから消滅するまで値が変わらないということです。

今回の計測値を例にValueObjectを作成していきましょう。Domain層に新規フォルダでValueObjectを作成し,MeasureValueという新規クラスを作成します。

public sealed class MeasureValue
{
    public MeasureValue(float value)
    {
        Value = value;
    }

    public float Value { get; }
}

継承させないため「sealed」とし,コンストラクタでFloat値を受けます。受けたFloat値は読み取り専用のValueプロパティに格納しています。これでValueというFloat値はオブジェクトが生成されてから永遠に変更されません。このValueが計測値を表します。

計測値でFloat値以外を表現したい場合はここに関数を作って表現していきます。つまり,計測値にかかわるドメインロジックはすべてこのMeasureValueに記述します。

例えば今回は「小数点以下2桁で丸める」「単位はm/s」という仕様なので次のように表現できます。

class="toolbar:2 lang:c# decode:true" title="">public string DisplayValue => Math.Round(Value, 2) + "m/s";

これで,仕様が表現できます。単位名称や小数点以下桁数は変数にすることもできますが,今回はこのままとし,必要になった時点で変数化します。

ここまで出来たら,一旦ValueObjcetをアプリケーションに適用してみましょう。

書き換える場所はMeasureEntityです。MeasureEntityにfloat型のMeasureValueがありますね。そのfloat型を今作成したMeasureValueに変更します。

public sealed class MeasureEntity
{
    public MeasureEntity(string measureId,DateTime measureDate,float measureValue)
    {
        MeasureId = measureId;
        MeasureDate = measureDate;
        MeasureValue = new MeasureValue(measureValue);
    }
    
    public string MeasureId { get; }
    public DateTime MeasureDate { get; }
    public MeasureValue MeasureValue { get; }
}

これで一度コンパイルしましょう。LatestViewModelでエラーが出ています。

MeasureValue = Math.Round(entity.MeasureValue, 2) + "m/s";

この部分を次のように変更します。

MeasureValue = entity.MeasureValue.DisplayValue;

entity.MeasureValueはValueObjectに変更したため,先ほど作成したDisplayValueが使用できます。そのなかで小数点や単位のことはやってくれますのでここではDisplayValueを選択するのみとなります。

MeasureViewModelでも計測値が使用されているためValueObjectに変更します。

ここではISensorRepositoryのGetDataの戻り値がfloatになっているためその部分をValueObjectに変更します。

public interface ISensorRepository
{
    MeasureValue GetData();
}

コンパイルをしてエラー箇所を修正します。

【SensorFake】

public MeasureValue GetData()
{
    return new MeasureValue(_random.Next(0, 3) + _random.NextDouble().ToSingle());
}

【MeasureViewModel】

public void Measure()
{
    var measureValue = _sensorRepository.GetData();
    MeasureValue = measureValue.DisplayValue;
}

【MeasureViewModelTest】

measureMock.Setup(x => x.GetData()).Returns(new MeasureValue(1.23456f));

コンパイルが通ったら一度テストをしてみましょう。リファクタリングによる影響がないことが確認できます。

それではValueObjectの特性2つ目の「同じ値のValueObjectは同じものと判断する」を見ていきましょう。

現状MeasureValueクラスを2つ生成し,その2つのインスタンスを比較すると「等しくない」という判定になります。

クラスは参照型のため,2つのインスタンスの参照先が異なるため当然という結果になります。

ただ,これではもともとFloatのMeasureValueとして動作させていた時と大きく動作が変わってしまいます。

もともとのFloat値をValueObjectに変更するというリファクタリングを行うと,もともとの動作と大きく変わり,不具合が生じます。ValueObjectはIntやFloatなどの基本の型の代わりに,ドメインロジックを実装した値に変更しているだけなので,1.2でインスタンス化したMeasureValueと1.2でインスタンス化したMeasureValueは同じもとして動作してくれないと困ります。

改造を行う前に,テストコードで現状を確認してみましょう。

[TestMethod]
public void イコールテスト()
{
    var vo1 = new MeasureValue(1.23456f);
    var vo2 = new MeasureValue(1.23456f);
    Assert.AreEqual(true, vo1.Equals(vo2));
}

[TestMethod]
public void イコールイコールテスト()
{
    var vo1 = new MeasureValue(1.23456f);
    var vo2 = new MeasureValue(1.23456f);
    Assert.AreEqual(true, vo1 == vo2);
}

この2つのテストを実行すると,2つともNGになります。それではテストが通るように実装していきましょう。

まず「MeasureValue」クラスに次のようにEqualsクラスをオーバライドします。

public override bool Equals(object obj)
{
    var other = obj as MeasureValue;
    if (other == null)
    {
        return false;
    }
     return this.Value == other.Value;
}

Equalsの引数でしていしたオブジェクトがMeasureValueであれば,それぞれのValueプロパティを比較しています。これでテストを実行すると「イコールテスト」が成功します。

「イコールイコールテスト」は引き続きNGのままです。MeasureValueクラスに次のように書きます。

public static bool operator ==(MeasureValue vo1, MeasureValue vo2)
{
    return Equals(vo1, vo2);
}

public static bool operator !=(MeasureValue vo1, MeasureValue vo2)
{
    return !Equals(vo1, vo2);
}

「==」と「!=」が使用されたときの動きを定義しています。これで「イコールイコールテスト」も成功します。

これでValueObjectの比較はValueプロパティで行われるようになりました。

でもこれって書くの面倒くさいですよね。

ValueObjectはドメインロジックの置き場として大量に作成されるので,毎回Equalsのオーバーライドを書くのは面倒です。ですので抽象クラスを使って行います。

ドメイン層の「ValueObjects」フォルダに「ValueObject」というクラスを新規で作成します。

namespace DDD.Domain.ValueObjects
{
    public abstract class ValueObject<T> where T : ValueObject<T>
    {
        public override bool Equals(object obj)
        {
            var vo = obj as T;
            if (vo == null)
            {
                return false;
            }

            return EqualsCore(vo);
        }

        public static bool operator ==(ValueObject<T> vo1, ValueObject<T> vo2)
        {
            return Equals(vo1, vo2);
        }

        public static bool operator !=(ValueObject<T> vo1, ValueObject<T> vo2)
        {
            return !Equals(vo1, vo2);
        }

       public override int GetHashCode()
        {
            return base.GetHashCode();
        }

        protected abstract bool EqualsCore(T other);
    }
}

MeasureValueクラスは次のように変更します。「ValueObject」クラスを継承し,EqualsCoreメソッドの実装を強制されるため,そこでMeasureValue同士を比較するための条件を記述します。

この場合はValueプロパティの比較を書きますが,バリューオブジェクトの中に複数の値を保持している場合は,そのすべての値で比較します。

public sealed class MeasureValue:ValueObject<MeasureValue>
{
    public MeasureValue(float value)
    {
        Value = value;
    }

    public float Value { get; }
    public string DisplayValue => Math.Round(Value, 2) + "m/s";
    protected override bool EqualsCore(MeasureValue other)
    {
        return this.Value == other.Value;
    }
}

これでValueObjectの作成が簡単になりました。ValueObjectの3番目の特性として一意性がないといいましたが,同じ値のVOは同じものと判断するわけですから当然といえば当然です。この特性はEntityとの違いを表すのによく使用されている特性です。Entityは一意性あり,VOは無しというのがこの2つの大きな違いです。

これでValueObjectの解説は以上です。このValueObjectを取り入れることでアプリケーションは抜群に安定し始めます。多くの不具合は値を値のままで扱っているために発生します。例えばデータベースの中に「アナログモード」という項目があり「2」という値が入っていたとしましょう。それを取り出してInt型に格納し,それを画面表示する直前で,アナログモード「2」は「風力」だからラベルに「風力」と表示して,単位は「m/s」などとしていると,あらゆる画面に「if (entity.AnalogMode=2)」みたいなロジックが散乱したり,先述のCommonFuncみたいな共通クラスに誰かが書いたけど使っていない人がいたりという状態になり,アプリケーションに機能追加や不具合修正がされるたびに安定感を失い,保守性の悪いプログラムになっていきます。

VOのポイントは,データベースなどの外界から値を取得したら「できるだけ早く」VOに変換して,ただただ「2」という値に知識を吹き込むのが非常に大事であり。

これがアプリケーション不具合をなくし安定される方法の一つです。

LatestViewModelのMeasureDateもドメインロジックentity.MeasureDate.ToString(“yyyy/MM/dd HH:mm:ss”)」を含んでいるため,VOに変換します。

public sealed class MeasureDate : ValueObject<MeasureDate>
{
    public MeasureDate(DateTime value)
    {
        Value = value;
    }

    public static implicit operator MeasureDate(DateTime value)
    {
        return new MeasureDate(value);
    }
    
    public DateTime Value { get; }
    public string DisplayValue => Value.ToString("yyyy/MM/dd HH:mm:ss");
    protected override bool EqualsCore(MeasureDate other)
    {
        return this.Value == other.Value;
    }
}

使用しているMeasureEntityのMeasureDateの型をMeasureDateに変更して,コンパイルエラー箇所を取り除きます。

public LatestViewModel(IMeasureRepository measureRepository)
{
    _measureRepository = measureRepository;
    var entity = _measureRepository.GetLatest();
    MeasureDate = entity.MeasureDate.DisplayValue;
    MeasureValue = entity.MeasureValue.DisplayValue;
}

テストを実行するとすべて成功しているため,リファクタリングに成功していることが確認できます。これでMeasureViewModelにもLatestViewModelにもドメインロジックをがなくなりました。