Поделиться через


Итерация 4. Создание слабых связей в приложении (C#)

от Майкрософт

Скачивание кода

В этой четвертой итерации мы воспользуемся преимуществами нескольких шаблонов проектирования программного обеспечения, чтобы упростить обслуживание и изменение приложения Диспетчера контактов. Например, мы рефакторинг приложения для использования шаблона репозитория и шаблона внедрения зависимостей.

Создание приложения MVC для управления контактами ASP.NET (C#)

В этой серии учебников мы создадим все приложение управления контактами от начала до конца. Приложение Диспетчер контактов позволяет хранить контактные данные ( имена, номера телефонов и адреса электронной почты) для списка людей.

Мы создаем приложение с помощью нескольких итераций. С каждой итерацией мы постепенно улучшаем приложение. Цель этого подхода с несколькими итерациями — дать возможность понять причину каждого изменения.

  • Итерация 1. Создание приложения. В первой итерации мы создадим диспетчер контактов самым простым способом. Добавлена поддержка базовых операций с базами данных: создание, чтение, обновление и удаление (CRUD).

  • Итерация 2. Сделайте приложение красивым. В этой итерации мы улучшаем внешний вид приложения, изменив ASP.NET по умолчанию представление MVC master страницу и каскадную таблицу стилей.

  • Итерация 3. Добавление проверки формы. В третьей итерации мы добавим базовую проверку формы. Мы запрещаем пользователям отправлять форму без заполнения обязательных полей формы. Мы также проверяем адреса электронной почты и номера телефонов.

  • Итерация 4. Сделайте приложение слабосвязанным. В этой четвертой итерации мы воспользуемся преимуществами нескольких шаблонов проектирования программного обеспечения, чтобы упростить обслуживание и изменение приложения Диспетчера контактов. Например, мы рефакторинг приложения для использования шаблона репозитория и шаблона внедрения зависимостей.

  • Итерация 5. Создание модульных тестов. В пятой итерации мы упростим обслуживание и изменение приложения, добавив модульные тесты. Мы имитируем классы модели данных и создаем модульные тесты для наших контроллеров и логики проверки.

  • Итерация 6. Использование разработки на основе тестирования. В этой шестой итерации мы добавим новые функции в приложение, сначала написав модульные тесты и написав код для модульных тестов. В этой итерации мы добавим группы контактов.

  • Итерация 7. Добавление функциональных возможностей Ajax. В седьмой итерации мы повышаем скорость реагирования и производительность приложения, добавляя поддержку Ajax.

Эта итерация

В этой четвертой итерации приложения Диспетчера контактов мы рефакторинг приложения, чтобы сделать приложение более слабосвязанным. Если приложение слабо связано, вы можете изменить код в одной части приложения, не изменяя код в других частях приложения. Слабосвязанные приложения более устойчивы к изменениям.

В настоящее время вся логика доступа к данным и проверки, используемая приложением Диспетчера контактов, содержится в классах контроллера. Это плохая идея. Всякий раз, когда вам потребуется изменить одну часть приложения, вы рискуете обнаружить ошибки в другой части приложения. Например, при изменении логики проверки вы рискуете ввести новые ошибки в логику доступа к данным или контроллера.

Примечание

(SRP) класс никогда не должен иметь более одной причины для изменения. Смешивание логики контроллера, проверки и базы данных является массовым нарушением принципа единой ответственности.

Существует несколько причин, по которым может потребоваться изменить приложение. Может потребоваться добавить новую функцию в приложение, исправить ошибку в приложении или изменить способ реализации функции приложения. Приложения редко являются статическими. Они, как правило, растут и мутируют с течением времени.

Представьте, например, что вы решили изменить способ реализации уровня доступа к данным. Сейчас приложение Диспетчера контактов использует Microsoft Entity Framework для доступа к базе данных. Однако вы можете перейти на новую или альтернативную технологию доступа к данным, например ADO.NET Data Services или NHibernate. Однако, так как код доступа к данным не изолирован от кода проверки и контроллера, невозможно изменить код доступа к данным в приложении без изменения другого кода, не связанного непосредственно с доступом к данным.

С другой стороны, если приложение слабо связано, можно вносить изменения в одну часть приложения, не затрагивая другие части приложения. Например, можно переключать технологии доступа к данным, не изменяя логику проверки или контроллера.

В этой итерации мы воспользуемся преимуществами нескольких шаблонов проектирования программного обеспечения, которые позволяют нам выполнить рефакторинг приложения Диспетчера контактов в более слабо связанное приложение. Когда мы закончим, диспетчер контактов не будет делать ничего, что он не делал раньше. Однако в будущем мы сможем легко изменить приложение.

Примечание

Рефакторинг — это процесс перезаписи приложения таким образом, чтобы оно не потеряло существующую функциональность.

Использование шаблона разработки программного обеспечения репозитория

Первое изменение заключается в том, чтобы воспользоваться шаблоном проектирования программного обеспечения, который называется шаблоном репозитория. Мы будем использовать шаблон репозитория, чтобы изолировать код доступа к данным от остальной части приложения.

Для реализации шаблона репозитория необходимо выполнить следующие два шага:

  1. Создание интерфейса
  2. Создание конкретного класса, реализующего интерфейс

Во-первых, необходимо создать интерфейс, описывающий все методы доступа к данным, которые необходимо выполнить. Интерфейс IContactManagerRepository содержится в листинге 1. Этот интерфейс описывает пять методов: CreateContact(), DeleteContact(), EditContact(), GetContact и ListContacts().

Листинг 1. Models\IContactManagerRepository.cs

using System;
using System.Collections.Generic;

namespace ContactManager.Models
{
    public interface IContactRepository
    {
        Contact CreateContact(Contact contactToCreate);
        void DeleteContact(Contact contactToDelete);
        Contact EditContact(Contact contactToUpdate);
        Contact GetContact(int id);
        IEnumerable<Contact> ListContacts();

    }
}

Далее необходимо создать конкретный класс, реализующий интерфейс IContactManagerRepository. Так как мы используем Microsoft Entity Framework для доступа к базе данных, мы создадим новый класс с именем EntityContactManagerRepository. Этот класс содержится в листинге 2.

Листинг 2. Models\EntityContactManagerRepository.cs

using System.Collections.Generic;
using System.Linq;

namespace ContactManager.Models
{
    public class EntityContactManagerRepository : ContactManager.Models.IContactManagerRepository
    {
        private ContactManagerDBEntities _entities = new ContactManagerDBEntities();

        public Contact GetContact(int id)
        {
            return (from c in _entities.ContactSet
                    where c.Id == id
                    select c).FirstOrDefault();
        }

        public IEnumerable ListContacts()
        {
            return _entities.ContactSet.ToList();
        }

        public Contact CreateContact(Contact contactToCreate)
        {
            _entities.AddToContactSet(contactToCreate);
            _entities.SaveChanges();
            return contactToCreate;
        }

        public Contact EditContact(Contact contactToEdit)
        {
            var originalContact = GetContact(contactToEdit.Id);
            _entities.ApplyPropertyChanges(originalContact.EntityKey.EntitySetName, contactToEdit);
            _entities.SaveChanges();
            return contactToEdit;
        }

        public void DeleteContact(Contact contactToDelete)
        {
            var originalContact = GetContact(contactToDelete.Id);
            _entities.DeleteObject(originalContact);
            _entities.SaveChanges();
        }

    }
}

Обратите внимание, что класс EntityContactManagerRepository реализует интерфейс IContactManagerRepository. Класс реализует все пять методов, описанных в этом интерфейсе.

Вы можете задаться вопросом, почему нам нужно беспокоиться с интерфейсом. Зачем нужно создавать интерфейс и класс, который его реализует?

За одним исключением, оставшаяся часть приложения будет взаимодействовать с интерфейсом, а не с конкретным классом. Вместо вызова методов, предоставляемых классом EntityContactManagerRepository, мы будем вызывать методы, предоставляемые интерфейсом IContactManagerRepository.

Таким образом, мы сможем реализовать интерфейс с помощью нового класса, не изменяя оставшуюся часть приложения. Например, в будущем может потребоваться реализовать класс DataServicesContactManagerRepository, реализующий интерфейс IContactManagerRepository. Класс DataServicesContactManagerRepository может использовать ADO.NET Data Services для доступа к базе данных вместо Microsoft Entity Framework.

Если код приложения запрограммирован на интерфейс IContactManagerRepository вместо конкретного класса EntityContactManagerRepository, мы можем переключать конкретные классы, не изменяя остальную часть кода. Например, можно переключиться с класса EntityContactManagerRepository на класс DataServicesContactManagerRepository, не изменяя логику доступа к данным или проверки.

Программирование на основе интерфейсов (абстракций) вместо конкретных классов делает наше приложение более устойчивым к изменениям.

Примечание

Вы можете быстро создать интерфейс из конкретного класса в Visual Studio, выбрав пункт меню Рефакторинг, Извлечь интерфейс. Например, можно сначала создать класс EntityContactManagerRepository, а затем использовать extract Interface для автоматического создания интерфейса IContactManagerRepository.

Использование шаблона проектирования программного обеспечения внедрения зависимостей

Теперь, когда мы перенесли код доступа к данным в отдельный класс Repository, необходимо изменить контроллер Contact для использования этого класса. Мы воспользуемся шаблоном проектирования программного обеспечения, который называется внедрением зависимостей, для использования класса Repository в контроллере.

Измененный контроллер контактов содержится в листинге 3.

Листинг 3. Controllers\ContactController.cs

using System.Text.RegularExpressions;
using System.Web.Mvc;
using ContactManager.Models;

namespace ContactManager.Controllers
{
    public class ContactController : Controller
    {
        private IContactManagerRepository _repository;

        public ContactController()
            : this(new EntityContactManagerRepository())
        {}

        public ContactController(IContactManagerRepository repository)
        {
            _repository = repository;
        }

        protected void ValidateContact(Contact contactToValidate)
        {
            if (contactToValidate.FirstName.Trim().Length == 0)
                ModelState.AddModelError("FirstName", "First name is required.");
            if (contactToValidate.LastName.Trim().Length == 0)
                ModelState.AddModelError("LastName", "Last name is required.");
            if (contactToValidate.Phone.Length > 0 && !Regex.IsMatch(contactToValidate.Phone, @"((\(\d{3}\) ?)|(\d{3}-))?\d{3}-\d{4}"))
                ModelState.AddModelError("Phone", "Invalid phone number.");
            if (contactToValidate.Email.Length > 0 && !Regex.IsMatch(contactToValidate.Email, @"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"))
                ModelState.AddModelError("Email", "Invalid email address.");
        }

        public ActionResult Index()
        {
            return View(_repository.ListContacts());
        }

        public ActionResult Create()
        {
            return View();
        } 

        [AcceptVerbs(HttpVerbs.Post)]
        public ActionResult Create([Bind(Exclude = "Id")] Contact contactToCreate)
        {
            // Validation logic
            ValidateContact(contactToCreate);
            if (!ModelState.IsValid)
                return View();

            // Database logic
            try
            {
                _repository.CreateContact(contactToCreate);
                return RedirectToAction("Index");
            }
            catch
            {
                return View();
            }
        }

        public ActionResult Edit(int id)
        {
            return View(_repository.GetContact(id));
        }

        [AcceptVerbs(HttpVerbs.Post)]
        public ActionResult Edit(Contact contactToEdit)
        {
            // Validation logic
            ValidateContact(contactToEdit);
            if (!ModelState.IsValid)
                return View();

            // Database logic
            try
            {
                _repository.EditContact(contactToEdit);
                return RedirectToAction("Index");
            }
            catch
            {
                return View();
            }
        }

        public ActionResult Delete(int id)
        {
            return View(_repository.GetContact(id));
        }

        [AcceptVerbs(HttpVerbs.Post)]
        public ActionResult Delete(Contact contactToDelete)
        {
            try
            {
                _repository.DeleteContact(contactToDelete);
                return RedirectToAction("Index");
            }
            catch
            {
                return View();
            }
        }

    }
}

Обратите внимание, что контроллер Contact в листинге 3 содержит два конструктора. Первый конструктор передает во второй конструктор конкретный экземпляр интерфейса IContactManagerRepository. Класс контроллера Contact использует внедрение зависимостей конструктора.

Единственное место, где используется класс EntityContactManagerRepository, находится в первом конструкторе. Оставшаяся часть класса использует интерфейс IContactManagerRepository вместо конкретного класса EntityContactManagerRepository.

Это упрощает переключение реализаций класса IContactManagerRepository в будущем. Если вы хотите использовать класс DataServicesContactRepository вместо класса EntityContactManagerRepository, просто измените первый конструктор.

Внедрение зависимостей конструктора также делает класс контроллера Contact очень тестируемым. В модульных тестах можно создать экземпляр контроллера Contact, передав макет реализации класса IContactManagerRepository. Эта функция внедрения зависимостей будет очень важна для нас в следующей итерации при создании модульных тестов для приложения Диспетчера контактов.

Примечание

Если вы хотите полностью отделить класс контроллера Contact от определенной реализации интерфейса IContactManagerRepository, вы можете воспользоваться преимуществами платформы, поддерживающей внедрение зависимостей, например StructureMap или Microsoft Entity Framework (MEF). Используя платформу внедрения зависимостей, вам никогда не нужно ссылаться на конкретный класс в коде.

Создание уровня служб

Возможно, вы заметили, что наша логика проверки по-прежнему смешивается с логикой контроллера в измененном классе контроллера в листинге 3. По той же причине, по которой рекомендуется изолировать логику доступа к данным, рекомендуется изолировать логику проверки.

Чтобы устранить эту проблему, можно создать отдельный уровень служб. Уровень служб — это отдельный слой, который можно вставить между классами контроллера и репозитория. Уровень служб содержит нашу бизнес-логику, включая всю логику проверки.

Объект ContactManagerService содержится в листинге 4. Он содержит логику проверки из класса контроллера Contact.

Листинг 4. Models\ContactManagerService.cs

using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Web.Mvc;
using ContactManager.Models.Validation;

namespace ContactManager.Models
{
    public class ContactManagerService : IContactManagerService
    {
        private IValidationDictionary _validationDictionary;
        private IContactManagerRepository _repository;

        public ContactManagerService(IValidationDictionary validationDictionary) 
            : this(validationDictionary, new EntityContactManagerRepository())
        {}

        public ContactManagerService(IValidationDictionary validationDictionary, IContactManagerRepository repository)
        {
            _validationDictionary = validationDictionary;
            _repository = repository;
        }

        public bool ValidateContact(Contact contactToValidate)
        {
            if (contactToValidate.FirstName.Trim().Length == 0)
                _validationDictionary.AddError("FirstName", "First name is required.");
            if (contactToValidate.LastName.Trim().Length == 0)
                _validationDictionary.AddError("LastName", "Last name is required.");
            if (contactToValidate.Phone.Length > 0 && !Regex.IsMatch(contactToValidate.Phone, @"((\(\d{3}\) ?)|(\d{3}-))?\d{3}-\d{4}"))
                _validationDictionary.AddError("Phone", "Invalid phone number.");
            if (contactToValidate.Email.Length > 0 && !Regex.IsMatch(contactToValidate.Email, @"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"))
                _validationDictionary.AddError("Email", "Invalid email address.");
            return _validationDictionary.IsValid;
        }

        #region IContactManagerService Members

        public bool CreateContact(Contact contactToCreate)
        {
            // Validation logic
            if (!ValidateContact(contactToCreate))
                return false;

            // Database logic
            try
            {
                _repository.CreateContact(contactToCreate);
            }
            catch
            {
                return false;
            }
            return true;
        }

        public bool EditContact(Contact contactToEdit)
        {
            // Validation logic
            if (!ValidateContact(contactToEdit))
                return false;

            // Database logic
            try
            {
                _repository.EditContact(contactToEdit);
            }
            catch
            {
                return false;
            }
            return true;
        }

        public bool DeleteContact(Contact contactToDelete)
        {
            try
            {
                _repository.DeleteContact(contactToDelete);
            }
            catch
            {
                return false;
            }
            return true;
        }

        public Contact GetContact(int id)
        {
            return _repository.GetContact(id);
        }

        public IEnumerable<Contact> ListContacts()
        {
            return _repository.ListContacts();
        }

        #endregion
    }
}

Обратите внимание, что конструктору contactManagerService требуется ValidationDictionary. Уровень службы взаимодействует с уровнем контроллера через этот ValidationDictionary. Мы подробно рассмотрим ValidationDictionary в следующем разделе при обсуждении шаблона декоратора.

Обратите внимание, что ContactManagerService реализует интерфейс IContactManagerService. Всегда следует стараться программировать по интерфейсам, а не к конкретным классам. Другие классы в приложении Диспетчера контактов не взаимодействуют с классом ContactManagerService напрямую. Вместо этого, за одним исключением, оставшаяся часть приложения диспетчера контактов программируется на интерфейс IContactManagerService.

Интерфейс IContactManagerService содержится в листинге 5.

Листинг 5. Models\IContactManagerService.cs

using System.Collections.Generic;

namespace ContactManager.Models
{
    public interface IContactManagerService
    {
        bool CreateContact(Contact contactToCreate);
        bool DeleteContact(Contact contactToDelete);
        bool EditContact(Contact contactToEdit);
        Contact GetContact(int id);
        IEnumerable ListContacts();
    }
}

Измененный класс контроллера Contact содержится в листинге 6. Обратите внимание, что контроллер контактов больше не взаимодействует с репозиторием ContactManager. Вместо этого контроллер контактов взаимодействует со службой ContactManager. Каждый слой максимально изолирован от других слоев.

Листинг 6. Controllers\ContactController.cs

using System.Web.Mvc;
using ContactManager.Models;

namespace ContactManager.Controllers
{
    public class ContactController : Controller
    {
        private IContactManagerService _service;

        public ContactController()
        {
            _service = new ContactManagerService(new ModelStateWrapper(this.ModelState));

        }

        public ContactController(IContactManagerService service)
        {
            _service = service;
        }
        
        public ActionResult Index()
        {
            return View(_service.ListContacts());
        }

        public ActionResult Create()
        {
            return View();
        }

        [AcceptVerbs(HttpVerbs.Post)]
        public ActionResult Create([Bind(Exclude = "Id")] Contact contactToCreate)
        {
            if (_service.CreateContact(contactToCreate))
                return RedirectToAction("Index");
            return View();
        }

        public ActionResult Edit(int id)
        {
            return View(_service.GetContact(id));
        }

        [AcceptVerbs(HttpVerbs.Post)]
        public ActionResult Edit(Contact contactToEdit)
        {
            if (_service.EditContact(contactToEdit))
                return RedirectToAction("Index");
            return View();
        }

        public ActionResult Delete(int id)
        {
            return View(_service.GetContact(id));
        }

        [AcceptVerbs(HttpVerbs.Post)]
        public ActionResult Delete(Contact contactToDelete)
        {
            if (_service.DeleteContact(contactToDelete))
                return RedirectToAction("Index");
            return View();
        }

    }
}

Наше приложение больше не работает в соответствие с принципом единой ответственности (SRP). Контроллер контактов, приведенный в листинге 6, был лишен всех обязанностей, кроме управления потоком выполнения приложения. Вся логика проверки была удалена из контроллера контактов и отправлена на уровень служб. Вся логика базы данных была отправлена на уровень репозитория.

Использование шаблона декоратора

Мы хотим иметь возможность полностью отделить уровень служб от уровня контроллера. В принципе, мы должны иметь возможность компилировать уровень служб в отдельной сборке из уровня контроллера без необходимости добавлять ссылку на наше приложение MVC.

Однако наш уровень служб должен иметь возможность передавать сообщения об ошибках проверки обратно на уровень контроллера. Как включить уровень служб для передачи сообщений об ошибках проверки без связывания контроллера и уровня служб? Мы можем воспользоваться шаблоном разработки программного обеспечения, который называется шаблоном декоратора.

Контроллер использует ModelStateDictionary с именем ModelState для представления ошибок проверки. Поэтому может возникнуть соблазн передать ModelState со уровня контроллера на уровень службы. Однако использование ModelState на уровне служб сделает уровень службы зависимым от функции платформы ASP.NET MVC. Это может быть плохо, потому что когда-нибудь вы захотите использовать уровень служб с приложением WPF вместо приложения ASP.NET MVC. В этом случае не требуется ссылаться на ASP.NET платформы MVC для использования класса ModelStateDictionary.

Шаблон Декоратора позволяет упаковать существующий класс в новый класс, чтобы реализовать интерфейс. Наш проект Диспетчера контактов включает класс ModelStateWrapper, содержащийся в листинге 7. Класс ModelStateWrapper реализует интерфейс, приведенный в листинге 8.

Листинг 7. Models\Validation\ModelStateWrapper.cs

using System.Web.Mvc;

namespace ContactManager.Models.Validation
{
    public class ModelStateWrapper : IValidationDictionary
    {
        private ModelStateDictionary _modelState;

        public ModelStateWrapper(ModelStateDictionary modelState)
        {
            _modelState = modelState;
        }

        public void AddError(string key, string errorMessage)
        {
            _modelState.AddModelError(key, errorMessage);
        }

        public bool IsValid
        {
            get { return _modelState.IsValid; }
        }
    }
}

Листинг 8. Models\Validation\IValidationDictionary.cs

namespace ContactManager.Models.Validation
{
    public interface IValidationDictionary
    {
        void AddError(string key, string errorMessage);
        bool IsValid {get;}
    }
}

Если вы внимательно изучите листинг 5, то увидите, что уровень служб ContactManager использует исключительно интерфейс IValidationDictionary. Служба ContactManager не зависит от класса ModelStateDictionary. Когда контроллер Contact создает службу ContactManager, контроллер создает оболочку modelState следующим образом:

_service = new ContactManagerService(new ModelStateWrapper(this.ModelState));

Итоги

В этой итерации мы не добавили никаких новых функций в приложение Диспетчера контактов. Цель этой итерации состояла в рефакторинге приложения Диспетчера контактов, чтобы упростить обслуживание и изменение.

Во-первых, мы реализовали шаблон проектирования программного обеспечения репозитория. Мы перенесли весь код доступа к данным в отдельный класс репозитория ContactManager.

Мы также изолировали логику проверки от логики контроллера. Мы создали отдельный уровень служб, содержащий весь код проверки. Уровень контроллера взаимодействует со службой, а уровень службы — со слоем репозитория.

При создании уровня служб мы воспользовались шаблоном Декоратора, чтобы изолировать ModelState от уровня служб. На уровне служб мы запрограммировали интерфейс IValidationDictionary вместо ModelState.

Наконец, мы воспользовались шаблоном разработки программного обеспечения, который называется шаблоном внедрения зависимостей. Этот шаблон позволяет программировать на основе интерфейсов (абстракций) вместо конкретных классов. Реализация шаблона проектирования внедрения зависимостей также делает наш код более тестируемым. В следующей итерации мы добавим модульные тесты в проект.