🔍

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 サービスである 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 が発行される場合があります。

http://itkaeru.blogspot.jp/2012/09/c-ienumerable-iqueryable.html

かといって、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();
    }

}

Discussion