読者です 読者をやめる 読者になる 読者になる

からめもぶろぐ。

ワタシ SharePoint チョット デキル

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

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 サービスである AppVayor でもサポートしていますので、ビルドサーバーでも自動テストを通すことができます。

<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 クラス (System.Data.Entity)

DbContext インスタンスは、データベースに照会してすべての変更をグループ化し、1 つの単位としてストアに書き戻すことができるような、作業単位パターンとリポジトリ パターンの組み合わせを表します。

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();
    }

}