インドカレーファンクラブ

パソコン、カメラ

【C#】List<T>(Tはユーザ定義型)でパラメータを受ける実装を考える(考えるだけ)

はじめに

List<T>(Tはユーザ定義型)でパラメータを受ける実装を考える

その際にどうすれば処理が散らからないようになるのか?とか何がベストプラクティスなのか?とか考える
考えるだけ

VS起動しないでコンパイラ通さず書いたコードなので通らなかったらごめんなさい(暇な時に実際つくる)

長いあらすじ・問題提起

例えばこういうパラメータクラスと、それをパラメータとして受ける(≒モデルバインドする)コントローラ及びメソッドがあるとする

public class Param
{
    List<Person> PersonList {get; set;}
}

public class Person
{
    public string Name {get; set;}
    public int Age {get; set;}
}
public class SampleController : Controller
{
    [Route("/Sample")]
    public IActionResult Index(Param param)
        =>  Content("hoge");
}

ここでいうList<Person> PersonListがユーザ定義型のリストになる

そうしたコントローラに例えばこういうリクエストをすると

# 分かりやすく改行
/sample
?personList[0].Name=john&personList[0].Age=10
&personList[1].Name=tom&personList[1].Age=9 # name は省略
&tom&personList[1].Age=0

PersonListはこんな感じのオブジェクトとしてバインドされる

// param.PersonList =
new List<Person>()
{
    new Person {
        Name = "john",
        Age = 10
    },
    new Person {
        Name = "tom",
        Age = 9
    },
    new Person {
        Name = "",
        Age = 9
    },
}

次にこのList<Person>の中には「必ず1人は名前ありかつ10歳以上のPersonが含まれていないといけない!」というValidationルールを設ける
※例えば体験型施設の申し込みとか、そういうものに応募する時みたいなイメージ
(そうするとPersonという命名がイケてないけど、今回は許して)

そんな条件はアプリケーションサービスの中で弾け!という話かもだけど、今回はモデルバインド時のValidationで対応しよう
例えばこのWebアプリケーションがガッツリ体験型施設申し込みサービス、みたいな感じだったらList<Person>という括りを強固に保有し、そこにValidationをかけることにも違和感は少ない(と思うよね!)

public class Param : IValidatableObject
{
    List<Person> PersonList {get; set;}

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        ValidatePerson().ToList().ForEach(x => yield return x);
    }

    private IEnumerable<ValidationResult> ValidatePerson()
    {
        // error! null or empty
        if (PersonList == null || !PersonList.Any())
        {
            yield return new ValidationResult("", new[] { nameof(PersonList) });
        }

        // rule: at least one person has name and over the age of 10 is required
        var namedOver10AgePerson = GetNamedPersons().Where(person => person.Age >= 10);
        if (!namedOver10AgePerson.Any())
        {
            yield return new ValidationResult("", new[] { nameof(PersonList) });
        }
    }

    // something additional method
    // public: it could be used in other classes
    public IEnumerable<Person> GetNamedPersons()
        => this.PersonList.Where(person => !string.IsNullOrEmpty(person.Name));

}

こんな感じにValidationとか色々なメソッドが増えるとParamクラスがどんどん肥大化していってしまう

大抵の場合はこのまま実装すると思うんだけど、Paramクラスの中に他のフィールドも増えだすと散らかり具合が大変なことになってくる

#regionで臭いものに蓋をせずに問題に向き合おう

対応策1 Listの拡張クラスをつくる

これを防ぐためには方法が2通りあるかな。と思う

まず1つ目の方法は諸々の処理をList<Person>の拡張クラスに逃がすこと

public class Person
{
    public string Name {get; set;}
    public int Age {get; set;}
}

public static class PersonListExtensions
{
    public static IEnumerable<ValidationResult> ValidatePerson(this IEnumerable<Person> value)
    {
        // error! null or empty
        if (value == null || !value.Any())
        {
            yield return new ValidationResult("", new[] { "PersonList" });
        }

        // rule: at least one person has name and over the age of 10 is required
        var namedOver10AgePerson = value.GetNamed().Where(person => person.Age >= 10);
        if (!namedOver10AgePerson.Any())
        {
            yield return new ValidationResult("", new[] { "PersonList" });
        }
    }

    public static IEnumerable<Person> GetNamed(this IEnumerable<Person> value)
        => value.Where(person => !string.IsNullOrEmpty(person.Name));
}

やっていることは簡単かな

でもList<Person>という括りがちょっと弱いかも
List<Person>に名前がついていないから、このリストが即ち何を表したオブジェクトなのかが伝わりにくい?

対応策2 Listを有する値オブジェクトをつくる

2つ目の方法はList<Person>をまるっと一つのクラスのようにすること
DDD文脈だと値オブジェクトとか言われてる気がする

public class Param
{
    Persons PersonList {get; set;}
}

public class Persons
{
    List<Person> Values {get; set;}
    // Maybe I should use Complete Constructor Pattern
}

public class Person
{
    public string Name {get; set;}
    public int Age {get; set;}
}

で、Personsに色々処理をもたせる

public class Persons : IValidatableObject
{
    List<Person> Values {get; set;}

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        // error! null or empty
        if (this.Values == null || !this.Values.Any())
        {
            yield return new ValidationResult("", new[] { nameof(Persons) });
        }

        // rule: at least one person has name and over the age of 10 is required
        var namedOver10AgePerson = GetNamed().Where(person => person.Age >= 10);
        if (!namedOver10AgePerson.Any())
        {
            yield return new ValidationResult("", new[] { nameof(Persons) });
        }
    }

    public IEnumerable<Person> GetNamed()
        => this.Values.Where(person => !string.IsNullOrEmpty(person.Name));
}

実際はPersonsってよりもっといい名前をつけよう

こっちだとより収まりが良い 気がする
気がするけど論理的に説明できない

クラスというかドメインに責務がまとまっていて、
責務というかそうした振る舞いを定義するという点ではDDD的にきれいだと思う

しかし一番の問題は自前でカスタムモデルバインダを定義しないとパラメータをPersonsにモデルバインドできないこと!!
この実装の話はまた別途

まとめ

どっちがいいんでしょうね

List<Person>を値オブジェクトとするパターンはその括り(リスト)に名前をつけられることに強い意味があるように思う

実際運用していくとメリット・デメリットがはっきりしてくるんでしょうけど、まだこうした実装に水をあげ花を咲かせたことはない