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 が流れてしまう結果になります。
かといって、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