Большинство прикладных приложений, которые приходится разрабатывать на практике, сводятся к примитивному шаблону: есть некая предметная область, в которой выделены объекты и связи между ними. Все это легко представляется в виде таблиц в базе данных, а базовый функционал приложения состоит в том, чтобы выполнять над этими таблицами четыре основных действия: создание, модификацию, просмотр и удаление объектов. Далее, обычно, на эту основу прикручивают дополнительную бизнес-логику, модуль отчетов и остальной необходимый функционал.
Естественной реакцией организма разработчика на присутствие определенного шаблона является желание автоматизировать его применение, например, используя кодогенерацию. Шутка. Кодогенерация – это тот же метод copy-paste, только за программиста его делает специально написанный инструмент. Иногда это оправдано, но перед тем, как решится на генерацию кода, лучше хорошо подумать, а нельзя ли здесь обойтись средствами ООП, к примеру?
Недавно мне пришлось помочь знакомым в написании подобного приложения для, ну скажем, курсового проекта команды студентов. Вкратце, задача состояла в том, чтобы написать web-приложение, которое позволяло генерировать отчеты по базе данных из, примерно, двадцати таблиц. Сложность состояла в том, что базы еще не было, ее нужно было спроектировать и ввести порядочный объем данных вручную, причем на создание приложения и наполнение базы была приблизительно неделя времени, после чего надо было показать работающий прототип «заказчику». После демонстрации планировалось заняться украшением интерфейса, расширением бизнес-логики, поэтому архитектура приложения должна была быть максимально гибкой. Положение усложнялось тем, что только я имел реальный опыт программирования, остальные участники проекта на первом этапе помочь только с наполнением базы или, как максимум с дизайном и версткой. Прежде чем броситься грудью на амбразуру, я сел и пару часов поразмыслил над тем, как же за минимально возможное время написать CRUD-основу для приложения, с которой уже смогут работать остальные. В этой статье я попытаюсь озвучить некоторые мысли, которые мне сильно помогли в решении задачи и, возможно, будут полезными для относительно неопытных разработчиков. Опытные, скорее всего, сами не раз применяли подобные паттерны на практике.
В силу исторических обстоятельств (два года опыта работы .Net-разработчиком), приложение было основано на связке MS SQL Server 2008/ADO.Net Entity Framework/ASP.Net MVC. Я сознательно пропускаю в статье некоторые моменты вроде «как создать таблицу в базе данных» или «как добавить ADO.NET Entity Data Model в проект», об этом можно почитать в других статьях. Эта о том, как, правильно применяя нужные инструменты, можно быстро создать CRUD-основу для приложения, с которой потом можно будет легко работать.
Итак, как я уже говорил выше, для всех объектов в базе данных нужно предусмотреть выполнение четырех базовых операций: create, read, update, delete. Соответственно, это нужно учитывать с самого начала, когда проектируется база данных. В базе данных это выражалось в том, что каждая таблица содержала первичный ключ «Id»:
Такой общий подход сильно упростит нам жизнь в будущем, скоро увидите почему. Далее садимся и аккуратно и внимательно создаем таблицы в базе данных. Здесь и далее я буду показывать все на примере двух из них:
Остальные таблицы не отличаются чем-либо существенным.
Итак, таблицы и связи между ними созданы. Добавляем в проект ADO.NET Entity Data Model, предварительно указав ей нашу базу данных, и EF генерирует ORM-код. Теперь вспомним, что все наши объекты имеют поле Id, которое идентифицирует объект. Добавляем в код такой интерфейс:
Все объекты модели в EF по умолчанию помечены как partial, поэтому мы можем добавить к ним весь необходимый функционал (например, унаследовать их от нужного интерфейса), не затрагивая сгенерированный код:
Свойство DisplayName пригодится нам, когда дело дойдет до UI.
Итак, следующий шаг – для каждого объекта модели нужно создать контроллер, который будет поддерживать пять операций – выдача списка объектов, просмотр информации об одном объекте, создание, редактирование и удаление объекта. Все наши объекты унаследованы от интерфейса IDataObject и внутреннего класса EF System.Data.Objects.DataClasses.EntityObject. Попытаемся вынести как можно больше логики в базовый класс контроллера, от которого будем наследовать контроллеры для каждого объекта модели:
В каждом контроллере будет использоваться контекст данных. Реализуем его в виде защищенного свойства с применением ленивой инициализации (в некоторых action-ах он не используется, зачем создавать лишний раз?):
Для работы с конкретным объектом типа T добавим два абстрактных защищенных свойства:
К сожалению, я не нашел нормального способа, как определить эти два свойства на уровне базового класса, поэтому все контроллеры возвращали их в зависимости от типа объекта, с которым они работают.
Далее нужно реализовать основные операции CRUD. Вот конечный вариант кода базового контроллера, который позволяет выполнять основные операции:
Большинство методов помечены как виртуальные, чтобы на уровне реализации контроллера для конкретного типа объекта не возникало труда модифицировать их поведение. Вот пример контроллера-потомка для простого объекта, который не связан с другими в базе данных (таблица не содержит внешних ключей):
С контроллерами объектов, чьи таблицы содержат внешние ключи, все немножко сложнее. При их отображении на UI нужно вместо идентификаторов связанных объектов показывать их DisplayName, который предполагает загрузку связанного объекта из базы, а при создании и редактировании – давать пользователю выбирать связанный объект из списка существующих. Для того, чтобы разобраться с проблемой связанных объектов и были созданы виртуальные методы:
Первый подгружает все объекты из связанных таблиц для отображения списка на интерфейсе, а второй запускает ленивую инициализацию для связанных объектов конкретного объекта.
Еще одна проблема – базовая реализация методов Edit и Create не умеет связывать объекты, поэтому для сложных объектов придется делать эти методы вручную. Реализация контроллера для объекта с внешними ключами выглядит следующим образом:
Теперь дело осталось за малым – сгенерировать представления (Views) для каждого из созданных контроллеров. О том, как сделать этот процесс более быстрым и приятным я расскажу в следующей статье.
Спасибо за внимание.
P.S. В статье намеренно пропущены вопросы безопасности, быстродействия и т.п., несомненно, важные вещи, которые не имеют прямого отношения к предмету разговора.
Естественной реакцией организма разработчика на присутствие определенного шаблона является желание автоматизировать его применение, например, используя кодогенерацию. Шутка. Кодогенерация – это тот же метод copy-paste, только за программиста его делает специально написанный инструмент. Иногда это оправдано, но перед тем, как решится на генерацию кода, лучше хорошо подумать, а нельзя ли здесь обойтись средствами ООП, к примеру?
Предыстория
Недавно мне пришлось помочь знакомым в написании подобного приложения для, ну скажем, курсового проекта команды студентов. Вкратце, задача состояла в том, чтобы написать web-приложение, которое позволяло генерировать отчеты по базе данных из, примерно, двадцати таблиц. Сложность состояла в том, что базы еще не было, ее нужно было спроектировать и ввести порядочный объем данных вручную, причем на создание приложения и наполнение базы была приблизительно неделя времени, после чего надо было показать работающий прототип «заказчику». После демонстрации планировалось заняться украшением интерфейса, расширением бизнес-логики, поэтому архитектура приложения должна была быть максимально гибкой. Положение усложнялось тем, что только я имел реальный опыт программирования, остальные участники проекта на первом этапе помочь только с наполнением базы или, как максимум с дизайном и версткой. Прежде чем броситься грудью на амбразуру, я сел и пару часов поразмыслил над тем, как же за минимально возможное время написать CRUD-основу для приложения, с которой уже смогут работать остальные. В этой статье я попытаюсь озвучить некоторые мысли, которые мне сильно помогли в решении задачи и, возможно, будут полезными для относительно неопытных разработчиков. Опытные, скорее всего, сами не раз применяли подобные паттерны на практике.
В силу исторических обстоятельств (два года опыта работы .Net-разработчиком), приложение было основано на связке MS SQL Server 2008/ADO.Net Entity Framework/ASP.Net MVC. Я сознательно пропускаю в статье некоторые моменты вроде «как создать таблицу в базе данных» или «как добавить ADO.NET Entity Data Model в проект», об этом можно почитать в других статьях. Эта о том, как, правильно применяя нужные инструменты, можно быстро создать CRUD-основу для приложения, с которой потом можно будет легко работать.
База данных
Итак, как я уже говорил выше, для всех объектов в базе данных нужно предусмотреть выполнение четырех базовых операций: create, read, update, delete. Соответственно, это нужно учитывать с самого начала, когда проектируется база данных. В базе данных это выражалось в том, что каждая таблица содержала первичный ключ «Id»:
Id int not null identity(1,1)
* This source code was highlighted with Source Code Highlighter.
Такой общий подход сильно упростит нам жизнь в будущем, скоро увидите почему. Далее садимся и аккуратно и внимательно создаем таблицы в базе данных. Здесь и далее я буду показывать все на примере двух из них:
Остальные таблицы не отличаются чем-либо существенным.
ORM
Итак, таблицы и связи между ними созданы. Добавляем в проект ADO.NET Entity Data Model, предварительно указав ей нашу базу данных, и EF генерирует ORM-код. Теперь вспомним, что все наши объекты имеют поле Id, которое идентифицирует объект. Добавляем в код такой интерфейс:
public interface IDataObject
{
int Id { get; }
string DisplayName { get; }
}
* This source code was highlighted with Source Code Highlighter.
Все объекты модели в EF по умолчанию помечены как partial, поэтому мы можем добавить к ним весь необходимый функционал (например, унаследовать их от нужного интерфейса), не затрагивая сгенерированный код:
public partial class HomeWorkUnitType : IDataObject
{
public string DisplayName
{
get { return Name; }
}
}
public partial class HomeWorkUnit : IDataObject
{
public string DisplayName
{
get { return string.Format("[{0}] {1}", Id, Theme); }
}
}
* This source code was highlighted with Source Code Highlighter.
Свойство DisplayName пригодится нам, когда дело дойдет до UI.
Контроллеры
Итак, следующий шаг – для каждого объекта модели нужно создать контроллер, который будет поддерживать пять операций – выдача списка объектов, просмотр информации об одном объекте, создание, редактирование и удаление объекта. Все наши объекты унаследованы от интерфейса IDataObject и внутреннего класса EF System.Data.Objects.DataClasses.EntityObject. Попытаемся вынести как можно больше логики в базовый класс контроллера, от которого будем наследовать контроллеры для каждого объекта модели:
public abstract class DataObjectController<T> : Controller
where T : EntityObject, IDataObject
{
}
* This source code was highlighted with Source Code Highlighter.
В каждом контроллере будет использоваться контекст данных. Реализуем его в виде защищенного свойства с применением ленивой инициализации (в некоторых action-ах он не используется, зачем создавать лишний раз?):
private DataModelEntities m_DataContext;
protected DataModelEntities DataContext
{
get
{
if(m_DataContext == null)
{
m_DataContext = new DataModelEntities();
}
return m_DataContext;
}
}
* This source code was highlighted with Source Code Highlighter.
Для работы с конкретным объектом типа T добавим два абстрактных защищенных свойства:
protected abstract IQueryable<T> Table { get; }
protected abstract Action<T> AddObject { get; }
* This source code was highlighted with Source Code Highlighter.
К сожалению, я не нашел нормального способа, как определить эти два свойства на уровне базового класса, поэтому все контроллеры возвращали их в зависимости от типа объекта, с которым они работают.
Далее нужно реализовать основные операции CRUD. Вот конечный вариант кода базового контроллера, который позволяет выполнять основные операции:
public abstract class DataObjectController<T> : Controller
where T : EntityObject, IDataObject
{
private DataModelEntities m_DataContext;
protected DataModelEntities DataContext
{
get
{
if(m_DataContext == null)
{
m_DataContext = new DataModelEntities();
}
return m_DataContext;
}
}
protected override void OnActionExecuted(ActionExecutedContext filterContext)
{
base.OnActionExecuted(filterContext);
if (ViewData["Error"] == null)
{
foreach (var entry in DataContext.ObjectStateManager.GetObjectStateEntries(EntityState.Modified | EntityState.Deleted | EntityState.Added))
{
throw new InvalidOperationException("Unsaved entries at data context!");
}
}
}
protected abstract IQueryable<T> Table { get; }
protected abstract Action<T> AddObject { get; }
protected virtual IEnumerable<T> GetAll()
{
foreach (var t in Table.AsEnumerable())
{
LoadAllDependedObjects(t);
yield return t;
}
}
protected virtual T GetById(int id)
{
var t = Table.First(obj => obj.Id == id);
LoadAllDependedObjects(t);
return t;
}
protected virtual void CreateItem(T data)
{
AddObject(data);
}
protected virtual T EditItem(T data)
{
T existing = GetById(data.Id);
DataContext.ApplyPropertyChanges(existing.EntityKey.EntitySetName, data);
return existing;
}
protected virtual void DeleteItem(int id)
{
DataContext.DeleteObject(GetById(id));
}
public virtual ActionResult Index()
{
return View(GetAll());
}
public virtual ActionResult Details(int id)
{
return View(GetById(id));
}
public virtual ActionResult Create()
{
LoadAllDependedCollections();
return View();
}
[AcceptVerbs(HttpVerbs.Post)]
public virtual ActionResult Create(T data)
{
try
{
ValidateIdOnCreate();
ValidateModel();
CreateItem(data);
DataContext.SaveChanges();
return RedirectToAction("Index");
}
catch (Exception ex)
{
LoadAllDependedCollections();
ViewData["Error"] = ex.JoinMessages();
return View(data);
}
}
public virtual ActionResult Edit(int id)
{
LoadAllDependedCollections();
return View(GetById(id));
}
[AcceptVerbs(HttpVerbs.Post)]
public virtual ActionResult Edit(T data)
{
try
{
ValidateModel();
data = EditItem(data);
DataContext.SaveChanges();
return RedirectToAction("Index");
}
catch (Exception ex)
{
LoadAllDependedCollections();
ViewData["Error"] = ex.JoinMessages();
return View(data);
}
}
public virtual ActionResult Delete(int id)
{
try
{
ValidateModel();
DeleteItem(id);
DataContext.SaveChanges();
}
catch (Exception ex)
{
ViewData["Error"] = ex.JoinMessages();
}
return RedirectToAction("Index");
}
protected void ValidateModel()
{
if(!ModelState.IsValid)
{
throw new Exception("Model contains errors.");
}
}
protected virtual void LoadAllDependedCollections()
{
}
protected virtual void LoadAllDependedObjects(T obj)
{
}
protected virtual void ValidateIdOnCreate()
{
ModelState["Id"].Errors.Clear();
}
}
* This source code was highlighted with Source Code Highlighter.
Контроллеры простых объектов
Большинство методов помечены как виртуальные, чтобы на уровне реализации контроллера для конкретного типа объекта не возникало труда модифицировать их поведение. Вот пример контроллера-потомка для простого объекта, который не связан с другими в базе данных (таблица не содержит внешних ключей):
public class HomeWorkUnitTypeController : DataObjectController<HomeWorkUnitType>
{
protected override IQueryable<HomeWorkUnitType> Table
{
get { return DataContext.HomeWorkUnitType; }
}
protected override Action<HomeWorkUnitType> AddObject
{
get { return DataContext.AddToHomeWorkUnitType; }
}
}
* This source code was highlighted with Source Code Highlighter.
Контроллеры сложных объектов
С контроллерами объектов, чьи таблицы содержат внешние ключи, все немножко сложнее. При их отображении на UI нужно вместо идентификаторов связанных объектов показывать их DisplayName, который предполагает загрузку связанного объекта из базы, а при создании и редактировании – давать пользователю выбирать связанный объект из списка существующих. Для того, чтобы разобраться с проблемой связанных объектов и были созданы виртуальные методы:
protected virtual void LoadAllDependedCollections()
{
}
protected virtual void LoadAllDependedObjects(T obj)
{
}
* This source code was highlighted with Source Code Highlighter.
Первый подгружает все объекты из связанных таблиц для отображения списка на интерфейсе, а второй запускает ленивую инициализацию для связанных объектов конкретного объекта.
Еще одна проблема – базовая реализация методов Edit и Create не умеет связывать объекты, поэтому для сложных объектов придется делать эти методы вручную. Реализация контроллера для объекта с внешними ключами выглядит следующим образом:
public class HomeWorkUnitController : DataObjectController<HomeWorkUnit>
{
protected override IQueryable<HomeWorkUnit> Table
{
get { return DataContext.HomeWorkUnit; }
}
protected override Action<HomeWorkUnit> AddObject
{
get { return DataContext.AddToHomeWorkUnit; }
}
protected override void LoadAllDependedCollections()
{
ViewData["DisciplinePlan"] = DataContext.DisciplinePlan.AsEnumerable();
ViewData["HomeWorkUnitType"] = DataContext.HomeWorkUnitType.AsEnumerable();
base.LoadAllDependedCollections();
}
protected override void LoadAllDependedObjects(HomeWorkUnit obj)
{
obj.DisciplinePlanReference.Load();
obj.HomeWorkUnitTypeReference.Load();
base.LoadAllDependedObjects(obj);
}
[AcceptVerbs(HttpVerbs.Post)]
public ActionResult CreateHomeWorkUnit(HomeWorkUnit data, int disciplinePlanId, int workTypeId)
{
try
{
ValidateIdOnCreate();
ValidateModel();
CreateItem(data);
data.DisciplinePlan = DataContext.DisciplinePlan.First(d => d.Id == disciplinePlanId);
data.HomeWorkUnitType = DataContext.HomeWorkUnitType.First(c => c.Id == workTypeId);
DataContext.SaveChanges();
return RedirectToAction("Index");
}
catch (Exception ex)
{
LoadAllDependedCollections();
ViewData["Error"] = ex.JoinMessages();
return View("Create", data);
}
}
[AcceptVerbs(HttpVerbs.Post)]
public ActionResult EditHomeWorkUnit(HomeWorkUnit data, int disciplinePlanId, int workTypeId)
{
try
{
ValidateModel();
data = EditItem(data);
data.DisciplinePlan = DataContext.DisciplinePlan.First(d => d.Id == disciplinePlanId);
data.HomeWorkUnitType = DataContext.HomeWorkUnitType.First(c => c.Id == workTypeId);
DataContext.SaveChanges();
return RedirectToAction("Index");
}
catch (Exception ex)
{
LoadAllDependedCollections();
ViewData["Error"] = ex.JoinMessages();
return View("Edit", data);
}
}
}
* This source code was highlighted with Source Code Highlighter.
Теперь дело осталось за малым – сгенерировать представления (Views) для каждого из созданных контроллеров. О том, как сделать этот процесс более быстрым и приятным я расскажу в следующей статье.
Спасибо за внимание.
P.S. В статье намеренно пропущены вопросы безопасности, быстродействия и т.п., несомненно, важные вещи, которые не имеют прямого отношения к предмету разговора.