你的浏览器还没开启 Javascript 功能!

ASP.NET Core MVC 階層化アーキテクチャ Chap1 (Repository Patternを実装する)

最近プライベートでバタバタしていて、ブログの更新を疎かになってしまいました。久しぶりにASP.NET Core MVC のシリーズの続きを書きます。

前回まで、ASP.NET Core MVC のScaffoldの機能を用いてCRUD操作のソースコードを自動生成し、仕組みや動きについて解説までしました。

今回は前回のソースコードに対して本格的に階層化アーキテクチャの実装をしていきたいと思います。

前提

このコンテンツで扱うこと

  • 階層化アーキテクチャの説明
  • 関心の分離の原則
  • Repository Patternの実装方法

開発環境

環境/ソフトウェア内容
オペレーティングシステムWindows 10 1903
.NET Core SDK3.0.100
IDEVisual Studio Code 1.39.1
BrowserGoogle Chrome 78.0.3904.70

なぜ階層化アーキテクチャ

階層化アーキテクチャは決してASP.NET Core MVCだけ適しているわけではありません。
どんなフレームワーク、アプリケーションでも階層化は作り込むべきです。

階層化アーキテクチャの主たる目的は「責任を明確」「関心の分離」「再利用可能」です。

そして、階層化アーキテクチャによって各モジュールそれぞれの機能と責務が明確になり、比較的にマシなコードを目指せます。

なお、人それぞれに階層化アーキテクチャの考え方があり、このコンテンツのやり方は決して絶対的のものではありません。

前回のおさらい

前回のプロジェクトフォルダの構成です。

また、Constorollerの実装は以下通りになっています。

  • CustomersController.cs

      public class CustomersController : Controller
      {
          private readonly NorthwindContext _context;
    
          public CustomersController(NorthwindContext context)
          {
              _context = context;
          }
    
          // GET: Customers
          public async Task<IActionResult> Index()
          {
              return View(await _context.Customers.ToListAsync());
          }
    
      // 省略
      }
  • SuppliersController.cs

      public class SuppliersController : Controller
      {
          private readonly NorthwindContext _context;
    
          public SuppliersController(NorthwindContext context)
          {
              _context = context;
          }
    
          // GET: Suppliers
          public async Task<IActionResult> Index()
          {
              return View(await _context.Suppliers.ToListAsync());
          }
    
      // 省略
      }

見てわかる通り、CustomersControllerとSuppliersControllerの処理ロジックがぼとんど同じです。
外部から注入されたNorthwindContextオブジェクトを用いてデータ操作を行います。

しかし、Controllerの中で直接DbContextを操作するのは決して良い実装ではありません。

まず、DbContex自体はすべてのテーブルに対して操作出来るため、セキュリティ的に好ましくないうえ、Controllerに余計な責務を課すことでSOLID原則の単一責務の原則に反します。

また、Controllerにデータ操作の責務を課す結果として、関心の分離の原則にも反します。

関心の分離

データ操作、バリデーションチェック、ビジネスロジックはいずれも関心であり、それらの処理をすべてControllerの中に実装してしまうと、一つのControllerクラスの中で考慮しなければいけないことが多くなります。

簡単なシステムは問題がそこまで顕在化しませんが、システム機能や規模が増大に従い、プログラムがどんどんスパゲッティコード化し。メンテナンスしにくくなります。

それでデータ操作する機能を抜き出し、デザインパターンの一つであるRepository Patternを用いてコードを改修していきたいと思います。

Repository Patternの実装

Repositoryを作成する目的として、関心の分離と単一責務もありますが、もうひとつ大事な目的はプログラムの再利用です。

データ操作の処理は頻繁に利用することが想定されるため、Controllerに毎回同じデータ操作するコードを書くのではなく、Repositoryにデータ操作処理を集約します。そうすると、仕様変更で修正が発生してもRepositoryクラスのみ改修で済みます。

Interfaceの作成

Modelsのフォルダ配下にInterfaceのフォルダを作成し、Interfaceフォルダの中に以下2つのInterfaceを作成します。

  • ICustomerRepository.cs

      using System.Collections.Generic;
      using System.Threading.Tasks;
    
      namespace ds.NorthwindApp.Web.Models.Interface {
          public interface ICustomerRepository {
              Task CreateAsync (Customers customer);
    
              Task UpdateAsync (Customers customer);
    
              Task DeleteAsync (Customers customer);
    
              Task<Customers> GetOneAsync (string id);
    
              Task<IEnumerable<Customers>> GetAllAsync ();
    
              Task<bool> ExistsAsync (string id);
          }
      }
  • ISupplierRepository.cs

      using System.Collections.Generic;
      using System.Threading.Tasks;
    
      namespace ds.NorthwindApp.Web.Models.Interface {
          public interface ISupplierRepository {
              Task CreateAsync (Suppliers supplier);
    
              Task UpdateAsync (Suppliers supplier);
    
              Task DeleteAsync (Suppliers supplier);
    
              Task<Suppliers> GetOneAsync (int id);
    
              Task<IEnumerable<Suppliers>> GetAllAsync ();
    
              Task<bool> ExistsAsync (int id);
          }
      }

Repositoryの実装

Modelsのフォルダ配下にRepositoryのフォルダを作成します。

Repositoryフォルダの中に以下2つのクラスを作成し、先ほどの作成済みのInterfaceを実装します。

  • CustomerRepository.cs

      using System;
      using System.Collections.Generic;
      using System.Threading.Tasks;
      using ds.NorthwindApp.Web.Models.Interface;
      using Microsoft.EntityFrameworkCore;
    
      namespace ds.NorthwindApp.Web.Models.Repository {
          public class CustomerRepository : ICustomerRepository {
    
              private readonly NorthwindContext db;
    
              public CustomerRepository (NorthwindContext context) {
                  db = context;
              }
    
              public async Task CreateAsync (Customers customer) {
                  if (customer == null) {
                      throw new ArgumentNullException ("customer");
                  } else {
                      await db.Customers.AddAsync (customer);
                      await db.SaveChangesAsync ();
                  }
              }
    
              public async Task UpdateAsync (Customers customer) {
                  if (customer == null) {
                      throw new ArgumentNullException ("customer");
                  } else {
                      db.Customers.Update (customer);
                      await db.SaveChangesAsync ();
                  }
              }
    
              public async Task DeleteAsync (Customers customer) {
                  if (customer == null) {
                      throw new ArgumentNullException ("customer");
                  } else {
                      db.Customers.Remove (customer);
                      await db.SaveChangesAsync ();
                  }
              }
    
              public async Task<Customers> GetOneAsync (string id) {
                  return await db.Customers.FirstOrDefaultAsync (x => x.CustomerId == id);
              }
    
              public async Task<IEnumerable<Customers>> GetAllAsync () {
                  return await db.Customers.ToListAsync ();
              }
    
              public async Task<bool> ExistsAsync (string id) {
                  return await db.Customers.AnyAsync (x => x.CustomerId == id);
              }
    
          }
      }
  • SupplierRepository.cs

      using System;
      using System.Collections.Generic;
      using System.Threading.Tasks;
      using ds.NorthwindApp.Web.Models.Interface;
      using Microsoft.EntityFrameworkCore;
    
      namespace ds.NorthwindApp.Web.Models.Repository {
          public class SupplierRepository : ISupplierRepository {
    
              private readonly NorthwindContext db;
    
              public SupplierRepository (NorthwindContext context) {
                  db = context;
              }
    
              public async Task CreateAsync (Suppliers supplier) {
                  if (supplier == null) {
                      throw new ArgumentNullException ("supplier");
                  } else {
                      await db.Suppliers.AddAsync (supplier);
                      await db.SaveChangesAsync ();
                  }
              }
    
              public async Task UpdateAsync (Suppliers supplier) {
                  if (supplier == null) {
                      throw new ArgumentNullException ("supplier");
                  } else {
                      db.Suppliers.Update (supplier);
                      await db.SaveChangesAsync ();
                  }
              }
    
              public async Task DeleteAsync (Suppliers supplier) {
                  if (supplier == null) {
                      throw new ArgumentNullException ("supplier");
                  } else {
                      db.Suppliers.Remove (supplier);
                      await db.SaveChangesAsync ();
                  }
              }
    
              public async Task<Suppliers> GetOneAsync (int id) {
                  return await db.Suppliers.FirstOrDefaultAsync (x => x.SupplierId == id);
              }
    
              public async Task<IEnumerable<Suppliers>> GetAllAsync () {
                  return await db.Suppliers.ToListAsync ();
              }
    
              public async Task<bool> ExistsAsync (int id) {
                  return await db.Suppliers.AnyAsync (x => x.SupplierId == id);
              }
    
          }
      }

ContorollerからRepositoryの呼び出し

Repositoryの実装が完了したら、ControllerからDbContext直接呼出しではなく、Repository経由でデータ操作をするように修正します。

  • CustomersController.cs
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ds.NorthwindApp.Web.Models;
using ds.NorthwindApp.Web.Models.Interface;

namespace ds.NorthwindApp.Web.Controllers
{
    public class CustomersController : Controller
    {

        private readonly ICustomerRepository _customerRepository;

        public CustomersController(ICustomerRepository customerRepository)
        {
            _customerRepository = customerRepository;
        }

        // GET: Customers
        public async Task<IActionResult> Index()
        {
            return View(await _customerRepository.GetAllAsync());
        }

        // GET: Customers/Details/5
        public async Task<IActionResult> Details(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var customers = await _customerRepository.GetOneAsync(id);
            if (customers == null)
            {
                return NotFound();
            }

            return View(customers);
        }

        // GET: Customers/Create
        public IActionResult Create()
        {
            return View();
        }

        // POST: Customers/Create
        // To protect from overposting attacks, please enable the specific properties you want to bind to, for 
        // more details see http://go.microsoft.com/fwlink/?LinkId=317598.
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Create([Bind("CustomerId,CompanyName,ContactName,ContactTitle,Address,City,Region,PostalCode,Country,Phone,Fax")] Customers customers)
        {
            if (ModelState.IsValid)
            {
                await _customerRepository.CreateAsync(customers);
                return RedirectToAction(nameof(Index));
            }
            return View(customers);
        }

        // GET: Customers/Edit/5
        public async Task<IActionResult> Edit(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var customers = await _customerRepository.GetOneAsync(id);
            if (customers == null)
            {
                return NotFound();
            }
            return View(customers);
        }

        // POST: Customers/Edit/5
        // To protect from overposting attacks, please enable the specific properties you want to bind to, for 
        // more details see http://go.microsoft.com/fwlink/?LinkId=317598.
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Edit(string id, [Bind("CustomerId,CompanyName,ContactName,ContactTitle,Address,City,Region,PostalCode,Country,Phone,Fax")] Customers customers)
        {
            if (id != customers.CustomerId)
            {
                return NotFound();
            }

            if (ModelState.IsValid)
            {
                try
                {
                    await _customerRepository.UpdateAsync(customers);
                }
                catch (DbUpdateConcurrencyException)
                {
                    if (!await _customerRepository
                        .ExistsAsync(customers.CustomerId))
                    {
                        return NotFound();
                    }
                    else
                    {
                        throw;
                    }
                }
                return RedirectToAction(nameof(Index));
            }
            return View(customers);
        }

        // GET: Customers/Delete/5
        public async Task<IActionResult> Delete(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var customers = await _customerRepository.GetOneAsync(id);
            if (customers == null)
            {
                return NotFound();
            }

            return View(customers);
        }

        // POST: Customers/Delete/5
        [HttpPost, ActionName("Delete")]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> DeleteConfirmed(string id)
        {
            var customers = await _customerRepository.GetOneAsync(id);
            await _customerRepository.DeleteAsync(customers);
            return RedirectToAction(nameof(Index));
        }
    }
}
  • SuppliersController.cs
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ds.NorthwindApp.Web.Models;
using ds.NorthwindApp.Web.Models.Interface;

namespace ds.NorthwindApp.Web.Controllers
{
    public class SuppliersController : Controller
    {
        private readonly ISupplierRepository _supplierRepository;

        public SuppliersController(ISupplierRepository supplierRepository)
        {
            _supplierRepository = supplierRepository;
        }

        // GET: Suppliers
        public async Task<IActionResult> Index()
        {
            return View(await _supplierRepository.GetAllAsync());
        }

        // GET: Suppliers/Details/5
        public async Task<IActionResult> Details(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var suppliers = await _supplierRepository.GetOneAsync(id.Value);


            if (suppliers == null)
            {
                return NotFound();
            }

            return View(suppliers);
        }

        // GET: Suppliers/Create
        public IActionResult Create()
        {
            return View();
        }

        // POST: Suppliers/Create
        // To protect from overposting attacks, please enable the specific properties you want to bind to, for 
        // more details see http://go.microsoft.com/fwlink/?LinkId=317598.
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Create([Bind("SupplierId,CompanyName,ContactName,ContactTitle,Address,City,Region,PostalCode,Country,Phone,Fax,HomePage")] Suppliers suppliers)
        {
            if (ModelState.IsValid)
            {
                await _supplierRepository.CreateAsync(suppliers);
                return RedirectToAction(nameof(Index));
            }
            return View(suppliers);
        }

        // GET: Suppliers/Edit/5
        public async Task<IActionResult> Edit(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var suppliers = await _supplierRepository.GetOneAsync(id.Value);
            if (suppliers == null)
            {
                return NotFound();
            }
            return View(suppliers);
        }

        // POST: Suppliers/Edit/5
        // To protect from overposting attacks, please enable the specific properties you want to bind to, for 
        // more details see http://go.microsoft.com/fwlink/?LinkId=317598.
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Edit(int id, [Bind("SupplierId,CompanyName,ContactName,ContactTitle,Address,City,Region,PostalCode,Country,Phone,Fax,HomePage")] Suppliers suppliers)
        {
            if (id != suppliers.SupplierId)
            {
                return NotFound();
            }

            if (ModelState.IsValid)
            {
                try
                {
                    await _supplierRepository.UpdateAsync(suppliers);
                }
                catch (DbUpdateConcurrencyException)
                {
                    if (!await _supplierRepository.ExistsAsync(suppliers.SupplierId))
                    {
                        return NotFound();
                    }
                    else
                    {
                        throw;
                    }
                }
                return RedirectToAction(nameof(Index));
            }
            return View(suppliers);
        }

        // GET: Suppliers/Delete/5
        public async Task<IActionResult> Delete(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }
            var suppliers = await _supplierRepository.GetOneAsync(id.Value);

            if (suppliers == null)
            {
                return NotFound();
            }

            return View(suppliers);
        }

        // POST: Suppliers/Delete/5
        [HttpPost, ActionName("Delete")]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> DeleteConfirmed(int id)
        {

            var suppliers = await _supplierRepository.GetOneAsync(id);
            await _supplierRepository.DeleteAsync(suppliers);
            return RedirectToAction(nameof(Index));
        }

    }
}

依存性注入

最後に、Startup.csのConfigureServicesメソッドの中にRepositoryとInterfaceの依存性注入の設定を追加します。

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllersWithViews();

    // DB Context
    services.AddDbContext<NorthwindContext>(options =>
        options.UseSqlServer(Configuration.GetConnectionString("northwind"))
    );

    // Repository
    services.AddTransient<ICustomerRepository, CustomerRepository>();
    services.AddTransient<ISupplierRepository, SupplierRepository>();
}

まとめ

Microsoftの公式リファレンスのチュートリアルを見ても、データ操作はControllerの中で行っています。

それには理由がありまして、書籍やリファレンスの解説はあくまでのフレームワークの特性や使い方に焦点を当てているためです。

プログラミング初心者にとって、フレームワークの特性を理解できても、コードの構造や階層化アーキテクチャは中々とっつきにくいものです。

つきまして、今後もこのASP.NET Core MVC のシリーズでなるべく階層化アーキテクチャをわかりやすく説明するように心がけていきます。

備考

今回作成したソースコードです。

GitHubリポジトリ

では!!( `ー´)ノ