Доступ к данным

Как вдохнуть новую жизнь в приложение ASP.NET Web Forms десятилетней давности

Джули Лерман

Исходный код можно скачать по ссылке.

Джули ЛерманУстаревший код: мириться с ним нельзя и обойтись без него невозможно. И чем больше работы выполняет устаревшее приложение, тем дольше оно используется. Мое самое первое приложение ASP.NET Web Forms эксплуатируется чуть больше десяти лет. Сейчас оно наконец заменяется планшетным приложением, которое пишет какой-то другой разработчик. Но тем временем заказчик попросил меня добавить в старое приложение новую функциональность, позволяющую его компании прямо сейчас собирать часть тех данных, которые будут собираться в новой версии.

Здесь простым добавлением одного-двух полей не обойдешься. В существующем приложении (весьма сложном табеле учета времени на выполнение работ по каждому сотруднику) имеется динамический набор флажков, определяемый списком задач. Заказчик ведет этот список в отдельном приложении. В веб-приложении пользователь может отметить любое количество элементов в списке, чтобы указать выполненные им задачи. Список содержит свыше 100 элементов и со временем постепенно расширяется.

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

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

Какое-то время я размышляла над возможными вариантами. Цель номер 2 означала, что CheckBoxList должен остаться нетронутым. Я решила включить часы в отдельную сетку, но цель номер 1 подразумевала, что ASP.NET-элемент управления GridView не используется (и слава богу). Я предпочла применить таблицу и JavaScript для получения и сохранения данных по часам, затраченным на конкретные задачи, и исследовала несколько способов достижения этого. Вызовы AJAX-методов PageMethod для обращения к отделенному коду не годились, так как моя страница извлекалась через Server.Transfer с другой страницы. Подставляемые вызовы (inline calls), такие как <%MyCodeBehindMethod()%>, работали бы, пока мне не понадобилась бы некая сложная проверка данных (слишком трудно реализуемая на JavaScript), которая потребовала бы операций над смесью клиентских и серверных объектов. Ситуация также осложнялась тем, что необходимость расстановки по всему коду подставляемых вызовов делала его статическим. Так что мне не удалось обойтись «минимумом жертв».

Наконец, я осознала, что должна полностью отделить новую логику и поместить ее в Web API, к которому было бы легко обращаться из JavaScript. Это поможет обеспечить четкое разделение между новой и старой логикой.

Тем не менее, проблем хватало. Мой прежний опыт работы с Web API заключался в создании нового MVC-проекта. Я начала с него, но вызов методов в Web API из существующего приложения приводил к проблемам CORS (Cross Origin Resource Sharing), противоречащие всем шаблонам, которые я смогла найти, чтобы избежать CORS. Наконец, я наткнулась на статью Майка Уоссона (Mike Wasson) о добавлении Web API непосредственно в проект Web Forms (bit.ly/1jNZKzI) и нащупала нужный путь, хотя мне оставалось пересечь еще немало мостов. Не буду больше вдаваться в подробности того, как я все это преодолела, а вместо этого расскажу о решении, к которому я пришла в конечном счете.

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

Как только я перешла на Web API, добавление метода проверки не составило никакого труда. Поскольку я создавала новый пример, я задействовала Microsoft .NET Framework 4.5 и Entity Framework 6 (EF) вместо .NET Framework 2.0 и чистой ADO.NET. На рис. 1 приведена отправная точка для приложения-примера: веб-форма ASP.NET с именем пользователя и редактируемым CheckBoxList возможных действий. Это страница, к которой я добавлю возможность отслеживать комментарии для каждого помеченного элемента, показываемого в изменяемой сетке.

Отправная точка: простая веб-форма ASP.NET с планируемым добавлением
Рис. 1. Отправная точка: простая веб-форма ASP.NET с планируемым добавлением

Этап 1: добавление нового класса

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

namespace DomainTypes{
  public class FunStuffComment{
    [Key, Column(Order = 0)]
    public int UserId { get; set; }
    [Key, Column(Order = 1)]
    public int FunStuffId { get; set; }
    public string FunStuffName { get; set; }
    public string Comment { get; set; }
  }
}

Поскольку я планировала использовать EF для сохранения данных, мне нужно было задать свойства, которые стали бы моим составным ключом. В EF фокус с сопоставлением составных ключей заключается в добавлении атрибута Column Order наряду с атрибутом Key. Я также хотела указать свойство FunStuffName. Хотя я могла бы использовать перекрестные ссылки на свою таблицу FunStuff, чтобы получать имя конкретной записи, я сочла, что проще будет вывести FunStuffName «на поверхность» в этом классе. Это может показаться лишним, но учитывайте, что моя цель — избежать смешивания с существующей логикой.

Этап 2: добавление Web API в проект на основе Web Forms

Благодаря статье Уоссона я поняла, что можно добавить контроллер Web API непосредственно в существующий проект. Просто щелкните правой кнопкой мыши проект в Solution Explorer и вы увидите Web API Controller Class как вариант в команде Add контекстного меню. Создаваемый таким образом контроллер предназначен для работы с MVC, поэтому первое, что надо сделать, — удалить все методы и добавить свой метод Comments для получения существующих комментариев от конкретного пользователя. Так как я буду использовать JavaScript-библиотеку Breeze и уже установила ее в проект с помощью NuGet, я применяю соглашения по именованию, принятые в Breeze, для своего класса Web API Controller, как видно на рис. 2. Я пока не подключила Comments к уровню доступа к данным, так что начну с возврата некоторых данных из памяти.

Рис. 2. BreezeController Web API

namespace April2014SampleWebForms{
[BreezeController] 
public class BreezeController: ApiController  {
  [HttpGet]
  public IQueryable<FunStuffComment> Comments(int userId = 0)
    if (userId == 0){ // новый пользователь
      return new List<FunStuffComment>().AsQueryable();
    }
      return new List<FunStuffComment>{
        new FunStuffComment{FunStuffName = "Bike Ride",
          Comment = "Can't wait for spring!",FunStuffId = 1,UserId = 1},
        new FunStuffComment{FunStuffName = "Play in Snow",
          Comment = "Will we ever get snow?",FunStuffId = 2,UserId = 1},
        new FunStuffComment{FunStuffName = "Ski",
          Comment = "Also depends on that snow",FunStuffId = 3,UserId = 1}
      }.AsQueryable();    }
  }
}

В статье Уоссона рекомендуется добавлять маршрутизацию (routing) в файл global.asax. Но добавление Breeze через NuGet создает файл .config с уже определенной маршрутизацией. Вот почему я использую рекомендуемое в Breeze соглашение по именованию для контроллера на рис. 2.

Теперь я могу легко вызывать метод Comments с клиентской стороны моей формы FunStuffForm. Я предпочла протестировать свой Web API в браузере, чтобы убедиться, что все работает; для этого достаточно запустить приложение, а затем перейти на адрес http://localhost:1378/breeze/Breeze/Comments?UserId=1. Удостоверьтесь, что ваше приложение использует правильное сочетание «хост:порт».

Этап 3: добавление связывания с данными на клиентской стороне

Но я еще не закончила. Мне нужно что-то делать с данными, поэтому я вернулась к своим предыдущим статьям по Knockout.js (msdn.microsoft.com/magazine/jj133816), обеспечивающей связывание с данными в JavaScript, и Breeze (msdn.microsoft.com/magazine/jj863129), которая еще больше упрощает связывание с данными. Breeze автоматически преобразует результаты моего Web API в связываемые (bindable) объекты, которые Knockout (и другие API) могут использовать напрямую, исключая необходимость в создании дополнительных моделей представлений и логики сопоставления. Добавление связывания с данными — самая трудозатратная часть процесса, осложняемая моим все еще очень ограниченным знанием JavaScript и jQuery. Но я проявила упорство — и попутно стала полупрофессионалом в отладке JavaScript-кода в Chrome. Основная часть нового кода находится в отдельном JavaScript-файле, связанном с моей исходной веб-формой FunStuffForm.aspx.

Когда я уже заканчивала эту статью, кто-то заметил, что Knockout сейчас слегка устарел («Это 2012 год» — сказал он) и многие разработчики на JavaScript перешли на более простые и функциональные инфраструктуры вроде AngularJS или DurandalJS. Что ж, это будет моим уроком на будущее. Уверена, что мое приложение десятилетней давности не будет против инфраструктуры двухлетней давности. Но в следующих статьях я обязательно присмотрюсь к новому инструментарию.

В веб-форме я определила таблицу comments со столбцами, заполняемыми полями данных, которые я буду связывать с ними через (рис. 3). Кроме того, я связываю поля UserId и FunStuffId, которые понадобятся мне позже, но буду держать их скрытыми.

Рис. 3. Подготовка HTML-таблицы для связывания через Knockout

<table id="comments">
  <thead>
    <tr>
      <th></th>
      <th></th>
      <th>Fun Stuff</th>
      <th>Comment</th>
    </tr>
  </thead>
  <tbody data-bind="foreach: comments">
    <tr>
      <td style="visibility: hidden" data-bind="text: UserId"></td>
      <td style="visibility: hidden" data-bind="text: FunStuffId"></td>
      <td data-bind="text: FunStuffName"></td>
      <td><input data-bind="value: Comment" /></td>
    </tr>
  </tbody>
</table>

Первая порция логики в JavaScript-файле, который я назвала FunStuff.js, — это то, что называют функцией ready; она запускается, как только готов визуализируемый документ. В своей функции я определяю тип viewModel, показанный на рис. 4, чье свойство comments я буду использовать для привязки к таблице comments в веб-форме.

Рис. 4. Начало FunStuff.js

var viewModel;
$(function() {
  viewModel = {
    comments: ko.observableArray(),
    addRange: addRange,
    add: add,
    remove: remove,
    exists: exists,
    errorMessage: ko.observable(""),
  };
  var serviceName = 'breeze/Comments';
  var vm = viewModel;
  var manager = new breeze.EntityManager(serviceName);
  getComments();
  ko.applyBindings(viewModel, 
    document.getElementById('comments'));
 // Прочие функции
});

Функция ready также определяет некоторый стартовый код:

  • serviceName описывает Web API uri;
  • vm — краткий псевдоним viewModel;
  • manager подготавливает Breeze EntityManager для Web API;
  • getComments — метод, который вызывает API и возвращает данные;
  • ko.applyBinding — Knockout-метод, который связывает viewModel с таблицей comments.

Заметьте, что я объявила viewModel вне функции. Мне потребуется доступ к нему из скрипта в странице .aspx, поэтому такое объявление должно быть вынесено для видимости извне.

Самое важное свойство в viewModel — observableArray с именем comments. Knockout будет отслеживать содержимое этого массива и обновлять связанную таблицу при его изменении. Остальные свойства просто предоставляют дополнительные функции, определенные мной вслед за этим стартовым кодом в viewModel.

Начнем с функции getComments, приведенной на рис. 5.

Рис. 5. Запрос данных через Web API с помощью Breeze

function getComments () {
  var query = breeze.EntityQuery.from("Comments")
    .withParameters({ UserId: document.getElementById('hiddenId').value });
  return manager.executeQuery(query)
    .then(saveSucceeded).fail(failed);
}
function saveSucceeded (data) {
  var count = data.results.length;
  log("Retrieved Comments: " + count);
  if (!count) {
    log("No Comments");
    return;
  }
  vm.comments(data.results);
}
function failed(error) {
  vm.errorMessage(error);
}

В функции getComments я использую Breeze для выполнения метода Comments из своего Web API, передавая ему текущий UserId из скрытого поля в веб-странице. Вспомните, что я уже определила uri для Breeze и Comments в переменной manager. При успешном запросе выполняется функция saveSucceeded, на экран выводится некоторая информация, а результаты запроса помещаются в свойство comments в viewModel. На моем лэптопе я вижу пустую таблицу до окончания выполнения асинхронной задачи, после чего эта таблица заполняется результатами (рис. 6). И помните, что все это происходит на клиентской стороне. Никаких обратных передач нет, что значительно ускоряет работу.

Комментарии, полученные от Web API и связанные с помощью Knockout.js
Рис. 6. Комментарии, полученные от Web API и связанные с помощью Knockout.js

Этап 4: реагирование на установку и сброс флажков

Следующая задача — сделать так, чтобы список реагировал на выбор пользователя в Fun Stuff List. Когда какой-то элемент отмечен, его нужно добавить или удалить из массива viewModel.comments и связанной таблицы в зависимости от того, что сделал пользователь — установил или сбросил флажок. Логика обновления массива содержится в JavaScript-файле, но логика для оповещения модели о действии находится в одном из скриптов в файле .aspx. Можно связать функции, например onclick для флажка, с Knockout, но я не пошла по этому пути.

В разметку на форме .aspx я добавила в раздел header страницы следующий метод:

$("#checkBoxes").click(function(event) {
  var id = $(event.target)[0].value;
  if (event.target.nodeName == "INPUT") {
    var name = $(event.target)[0].parentElement.textContent;
    // alert('check!' + 'id:' + id + ' text:' + name);
    viewModel.updateCommentsList(id, name);  }
});

Это возможно благодаря тому, что у меня имеется div с именем checkboxes, окружающий все динамически генерируемые элементы управления CheckBox. С помощью jQuery я получаю значение CheckBox, вызвавшего событие, и имя в связанной метке. Затем передаю эти параметры в метод updateCommentsList своего viewModel. Это оповещение предназначено лишь для проверки того, что я правильно подключила функцию.

Теперь рассмотрим updateCommentsList и соответствующие функции в JavaScript-файле. Пользователь может отметить элемент или снять с него отметку, поэтому нужно добавлять или удалять этот элемент. Вместо того чтобы заботиться о состоянии флажка, я просто позволяю в своем методе exists вспомогательным функциям Knockout сообщать мне, находится ли данный элемент в массиве комментариев. Если да, его надо удалить. Поскольку Breeze отслеживает изменения, я удаляю элемент из observableArray, но указываю средству отслеживания изменений Breeze, что его следует считать удаленным. Тем самым я выполняю две задачи. При сохранении Breeze посылает базе данных команду DELETE (через EF в моем случае). Но, если элемент снова отмечен и его нужно добавить обратно в observableArray, Breeze просто восстанавливает его в средстве отслеживания изменений (change tracker). Иначе, поскольку я использую составной ключ для идентификации комментариев, наличие как нового, так и удаленного элемента с одинаковой идентификацией привело бы к конфликту. Заметьте: хотя Knockout реагирует на метод push для добавления элементов, я должна уведомлять его о том, что массив изменился, чтобы он отреагировал на удаление элемента. И вновь из-за связывания с данными таблица динамически изменяется по мере установки и сброса флажков.

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

Этап 5: сохранение комментариев

В моей странице уже есть функциональность Save для сохранения флажков, установленных в true, но теперь я хочу в то же время сохранять и комментарии, используя другой метод Web API. Существующий метод save выполняется, когда страница осуществляет обратную передачу в ответ на щелчок кнопки SaveThatStuff. Его логика находится в отделенном коде страницы. На самом деле сохранить комментарии можно вызовом на клиентской стороне до вызова серверной стороны, задействовав событие click той же кнопки. Я знала, что в Web Forms это возможно при использовании атрибута onClientClick, но в приложении, которое я модифицировала, мне также нужно было выполнять проверку, готовы ли к сохранению значения часов, выделенных на задачи, и временной график. Если проверка заканчивалась неудачей, я должна была не только забыть о методе save в Web API, но и предотвращать обратную передачу и выполнение метода save на серверной стороне. Я потратила немалое время на разгребание всего этого при использовании onClientClick, что подтолкнуло меня к еще одной модернизации с помощью jQuery. По аналогии с реакцией на щелчки CheckBox на клиентской стороне можно реагировать и на щелчки btnSave, причем тоже делать это на клиентской стороне. И это произойдет до обратной передачи и реакции на серверной стороне. Поэтому мне понадобились оба события при одном щелчке кнопки:

$("#btnSave").click(function(event) {
  validationResult = viewModel.validate();
  if (validationResult == false) {
    alert("validation failed");
    event.preventDefault();
  } else {
    viewModel.save();
  }
});

В этом примере у меня имеется метод-заглушка для проверки, который всегда возвращает true, однако я тестировала код и в том случае, если он возвращает false, чтобы убедиться в корректности кода. В том случае я использовала JavaScript event.preventDefault для прекращения дальнейшей обработки. При этом я не только не сохраняла комментарии, но и предотвращала обратную передачу и вызов save на серверной стороне. В ином случае я вызываю viewModel.save, и страница ведет себя, как обычно, сохраняя выбор, сделанный пользователем в FunStuff. Моя функция saveComments вызывается viewModel.save — она запрашивает Breeze entityManager выполнить saveChanges:

function saveComments() {
  manager.saveChanges()
    .then(saveSucceeded)
    .fail(failed);
}

Это в свою очередь приводит к обнаружению метода SaveChanges в моем контроллере и его запуску:

[HttpPost]
  public SaveResult SaveChanges(JObject saveBundle)
  {
    return _contextProvider.SaveChanges(saveBundle);
  }

Чтобы это работало, я добавила Comments в EF6-уровень данных, а затем переключила метод Comments контроллера на выполнение запроса к базе данных, используя серверный компонент Breeze (который вызывает мой EF6-уровень данных). Поэтому клиенту возвращаются данные из базы данных, которые SaveChanges может потом сохранить обратно в этой базе. Все это можно посмотреть в примере исходного кода, сопутствующего этой статье, где используются EF6 и Code First, а затем создается и инициализируется пример базы данных.

Подключив oncheck и модифицировав в ответ observableArray с именем comments, я вижу, например, что переключение флажка Watch Doctor Who приводит либо к отображению строки Watch Doctor Who, либо к ее исчезновению в зависимости от состояния флажка (рис. 7).

Рис. 7. JavaScript для обновления списка комментариев в ответ на щелчки флажков пользователем

function updateCommentsList(selectedValue, selectedText) {
  if (exists(selectedValue)) {
    var comment = remove(selectedValue);
    comment.entityAspect.setDeleted();
  } else {
  var deleted = manager.getChanges().filter(function (e) {
    return e.FunStuffId() == selectedValue
  })[0];  // Примечание: .filter не работает в IE8 и ниже
  var newSelection;
  if (deleted) {
    newSelection = deleted;
    deleted.entityAspect.rejectChanges();
  } else {
    newSelection = manager.createEntity('FunStuffComment', {
      'UserId': document.getElementById('hiddenId').value,
      'FunStuffId': selectedValue,
      'FunStuffName': selectedText,
      'Comment': ""
    });
  }
  viewModel.comments.push(newSelection);    }
  function exists(stuffId) {
    var existingItem = ko.utils.arrayFirst(vm.comments(), function (item) {
      return stuffId == item.FunStuffId();
    });
    return existingItem != null;
  };
  function remove(stuffId) {
    var selected = ko.utils.arrayFirst
    (vm.comments(), function (item) {
    return stuffId == item.FunStuffId;
    });
    ko.utils.arrayRemoveItem(vm.comments(), selected);
    vm.comments.valueHasMutated();
  };

JavaScript с помощью друзей

Работая над этим проектом и над примером для статьи, я написала куда больше кода на JavaScript, чем за все предыдущее время. Это не моя область (о чем я неоднократно говорила в этой рубрике), но я весьма гордилась тем, что мне удалось добиться. Однако, зная, что многие из моих читателей могут впервые видеть некоторые из этих приемов, я попросила Уорда Белла (Ward Bell) из IdeaBlade (создатели Breeze) подробно проанализировать мой код и попутно устроить парное программирование, чтобы помочь мне сделать код с применением Breeze, а также JavaScript и jQuery более понятным. Кроме использования теперь уже устаревшего Knockout.js, пример, который вы сможете скачать, должен дать ряд полезных уроков. Но помните, что основное внимание уделялось расширению старого проекта Web Forms с применением более современных методов, которые значительно улучшают восприятие приложения пользователями.


Джули Лерман *(Julie Lerman) — Microsoft MVP, преподаватель и консультант по .NET, живет в Вермонте. Часто выступает на конференциях по всему миру и в группах пользователей по тематике, связанной с доступом к данным и другими технологиями Microsoft .NET. Ведет блог thedatafarm.com/blog и является автором серии книг «Programming Entity Framework» (O’Reilly Media, 2010), в том числе «Code First Edition» (2011) и «DbContext Edition» (2012), также выпущенных издательством O’Reilly Media. Вы можете читать ее заметки в twitter.com/julielerman и смотреть ее видеокурсы для Pluralsight на juliel.me/PS-Videos. *

Выражаю благодарность за рецензирование статьи экспертам Microsoft Дэмиену Эдвардсу (Damian Edwards) и Скотту Хантеру (Scott Hunter).