からめもぶろぐ。

俺たちは雰囲気で OAuth をやっている

ASP.NET MVC の Repository パターン再考

(2018/05/22 追記)
こちらの記事にまとめました。

blog.karamem0.jp

ASP.NET MVC が出始めた頃、Repository パターンが話題になりました。その後、あまり Repository パターンについての議論がされていないようなので、改めて Repository パターンについて考えてみたいと思います。

Repository パターンの基本

Repository パターンの目的はビジネス ロジックとデータ アクセス ロジックを分離することです。具体的には以下のようなコードになります。

まずはデータ アクセス ロジックを抽象化するためのインターフェイスを定義します。

public interface IRepository<T>
{

    T GetOne(int id);

    IEnumerable<T> GetAll();

    void Add(T item);

    void Update(T item);

    void Delete(T item);

}

IRepository インターフェイスを実装し Entity Framework にアクセスするクラスを作成します。

public class PersonRepository : IRepository<Person>
{

    private DbContext dbContext;

    public PersonRepository()
    {
        this.dbContext = new SampleDbContext();
    }

    public Person GetOne(int id)
    {
        return this.dbContext.Set<Person>().Find(id);
    }

    public IEnumerable<Person> GetAll()
    {
        return this.dbContext.Set<Person>().AsEnumerable();
    }

    public void Add(Person item)
    {
        this.dbContext.Set<Person>.Add(item);
        this.dbContext.SubmitChanges();
    }

    public void Update(Person item)
    {
        this.dbContext.SubmitChanges();
    }

    public void Delete(Person item)
    {
        this.dbContext.Set<Person>.Remove(item);
        this.dbContext.SubmitChanges();
    }

}

Repository を操作するビジネス ロジックを作成します。

public class SalaryService
{

    private IRepository<Person> personRepository;

    public class SomeService()
    {
        this.personRepository = new PersonRepository();
    }

    public class SomeService(IRepository<Person> personRepository)
    {
        this.personRepository = personRepository;
    }

    public IEnumerable<Salary> GetSalaries()
    {
        // 何かしらのビジネス ロジック
    }

}

このクラスでは、抽象化された IRepository インターフェイスでアクセスするため、モックをインジェクションすることで、データベースに依存しない単体テスト ロジックを実施することができます。

[TestClass()]
public class SalaryServiceTest
{

    [TestMethod()]
    public void GetSalariesTest()
    {
        var data = new [] { new Person() };
        var mock = new Mock<IRepository<Person>>();
        mock.Setup(repos => repos.GetAll())
            .Returns(data);
        var target = new SalaryService(mock.Object);
        var actual = target.GetSalaries();
    }

}

IRepository インターフェイスの必要性

そもそもとして「IRepository インターフェイスは必要か?」という話があります。データ アクセス ロジックをモックしたい、つまり単体テストからデータベースを分離したい理由としては、以下の理由が挙げられると思います。

  • 接続文字列に固有の情報が含まれるため、ビルド サーバーに持っていったときに自動テストが実施できない
  • 連続実行した場合など、データベースのデータによって、テスト結果が左右される可能性がある

SQL Server LocalDB を使えば、単体の mdf ファイルとして動作するので、環境による依存性を考慮する必要がありません。また CI サービスである AppVeyor でもサポートしていますので、ビルドサーバーでも自動テストを通すことができます。

<connectionStrings>
    <add name="DefaultConnection"
         connectionString="Data Source=(localdb)\mssqllocaldb; Initial Catalog=sampledb; AttachDbFilename=|DataDirectory|\sampledb.mdf; Integrated Security=True;"
         providerName="System.Data.SqlClient" />
</connectionStrings>
[ClassInitialize()]
public static void ClassInitialize(TestContext testContext)
{
    AppDomain.CurrentDomain.SetData("DataDirectory", testContext.TestDeploymentDir);
}

App.config に DataDirectory を指定し、テスト クラスで単体テストの TestDeploymentDir に書き換えてあげれば、単体テストの実施ごとに異なる mdf が使われます。あとは Code First であれば Database.Create() するだけです。わざわざモックを使わなくても、ほとんどの場合は、こちらのほうが簡単に単体テストを実施できるはずです。

どうしてもデータベースを使いたくない場合でも、Entity Framework の DbContext クラスをモックしてしまうという方法があります。というのも、DbContext クラスはすでに Repository パターンを実装しているからです。

DbContext クラスと DbSet クラスをモックすればデータベースにアクセスしない単体テストを実施することができます。

[TestClass()]
public class SalaryServiceTest
{

    [TestMethod()]
    public void GetSalariesTest()
    {
        var mockData = new List<Person>() { new Person() };
        var mockDbSet = new Mock<DbSet<Person>>();
        mockDbSet.As<IEnumerable<Person>>()
            .Setup(dbSet => dbSet.GetEnumerator())
            .Returns(mockData.GetEnumerator());
        var mockDbContext = new Mock<DbContext>();
        mockDbContext.Setup(dbContext => dbContext.Set<Person>())
            .Returns(mockDbSet);
        var target = new SalaryService(mockDbContext.Object);
        var actual = target.GetSalaries();
    }

}

それでも IRepository インターフェイスを使う理由

データ アクセス ロジックが必ずしもデータベースであるとは限りません。REST や OData な Web API の可能性もあります。特定のデータ ストアに限定されない場合、IRepository インターフェイスでデータ アクセスを抽象化しておくと、ビジネス ロジックからはデータ ストアの種類を意識する必要なくアクセスができます。

public class OfferRepository : IRepository<Offer>
{

    private const string BaseUrl = "http://example.com/Offers";

    public OfferRepository() { }

    public Offer GetOne(int id)
    {
        var webRequest = WebRequest.Create(BaseUrl + "/" + item.Id);
        webRequest.Method = "GET";
        var webResponse = webRequest.GetResponse();
        using (var stream = webResponse.GetResponseStream())
        {
            var serializer = new JsonSerializer();
            using (var reader = new JsonTextReader(new StreamReader(stream)))
            {
                return serializer.Deserialize<Offer>(stream);
            }
        }
    }

    public IEnumerable<Offer> GetAll()
    {
        var webRequest = WebRequest.Create(BaseUrl);
        webRequest.Method = "GET";
        var webResponse = webRequest.GetResponse();
        using (var stream = webResponse.GetResponseStream())
        {
            var serializer = new JsonSerializer();
            using (var reader = new JsonTextReader(new StreamReader(stream)))
            {
                return serializer.Deserialize<IEmumerable<Offer>>(stream);
            }
        }
    }

    public void Add(Offer item)
    {
        var webRequest = WebRequest.Create(BaseUrl);
        webRequest.Method = "POST";
        webRequest.ContentType = "application/json";
        using (var stream = webRequest.GetRequestStream())
        {
            var serializer = new JsonSerializer();
            using (var writer = new JsonTextWriter(new StreamWriter(stream)))
            {
                serializer.Serialize(writer, item);
            }
        }
        webRequest.GetResponse();
    }

    public void Update(Offer item)
    {
        var webRequest = WebRequest.Create(BaseUrl + "/" + item.Id);
        webRequest.Method = "PUT";
        webRequest.ContentType = "application/json";
        using (var stream = webRequest.GetRequestStream()) {
            var serializer = new JsonSerializer();
            using (var writer = new JsonTextWriter(new StreamWriter(stream)))
            {
                serializer.Serialize(writer, item);
            }
        }
        webRequest.GetResponse();
    }

    public void Delete(Offer item)
    {
        var webRequest = WebRequest.Create(BaseUrl + "/" + item.Id);
        webRequest.Method = "DELETE";
        webRequest.GetResponse();
    }

}

Repository パターンを適切に実装するために

汎用の IRepository インターフェイス実装では、クエリ処理が実装されていないため、ビジネス ロジックでクエリを書いてしまいがちです。しかし、これは間違いです。

public class SalaryService
{

    private IRepository<Person> personRepository;

    public class SomeService()
    {
        this.personRepository = new PersonRepository();
    }

    public class SomeService(IRepository<Person> personRepository)
    {
        this.personRepository = personRepository;
    }

    public decimal GetSalary(string name)
    {
        // この例のようにビジネス ロジックでクエリを書いたら駄目
        return this.personRepository.GetAll()
            .Where(person => person.Name == name)
            .Select(person => person.Salary)
            .Single();
    }

}

GetAll メソッドは IEnumerable を返しますので、Entity Framework では意図しない SQL が流れてしまう結果になります。

itkaeru.blogspot.jp

かといって、GetAll メソッドを IQueryable にしてしまうと、今度はせっかく分離したデータベースとの依存性が復活してしまいます。
できるだけクエリ処理は Repository の中に閉じ込めておき、汎用の IRepository で定義されているメソッドのうち、必要ないものは実装しないようにする必要があります。

public interface IPersonRepository
{

    decimal GetSalary(string name);

}

public class PersonRepository : IRepository<Person>, IPersonRepository
{

    private DbContext dbContext;

    public PersonRepository()
    {
        this.dbContext = new SampleDbContext();
    }

    Person IRepository<Person>.GetOne(int id)
    {
        throw new NotSupportedException();
    }

    IEnumerable<Person> IRepository<Person>.GetAll()
    {
        throw new NotSupportedException();
    }

    void IRepository<Person>.Add(Person item)
    {
        throw new NotSupportedException();
    }

    void IRepository<Person>.Update(Person item)
    {
        throw new NotSupportedException();
    }

    void IRepository<Person>.Delete(Person item)
    {
        throw new NotSupportedException();
    }

    public decimal GetSalary(string name)
    {
        // クエリ処理
        return this.dbContext.Set<Person>()
            .Where(person => person.Name == name)
            .Select(person => person.Salary)
            .Single();
    }

}