Реализация оптимистического параллелизма с помощью элемента управления SqlDataSource (C#)
В этом руководстве мы рассмотрим основы управления оптимистичным параллелизмом, а затем рассмотрим, как реализовать его с помощью элемента управления SqlDataSource.
Введение
В предыдущем руководстве мы рассмотрели, как добавить возможности вставки, обновления и удаления в элемент управления SqlDataSource. Короче говоря, чтобы предоставить эти функции, необходимо указать соответствующую INSERT
инструкцию , UPDATE
или DELETE
SQL в свойствах элемента управления s InsertCommand
, UpdateCommand
или DeleteCommand
, а также соответствующие параметры в InsertParameters
коллекциях , UpdateParameters
и DeleteParameters
. Хотя эти свойства и коллекции можно указать вручную, кнопка Дополнительно мастера настройки источника данных предлагает флажок Создать INSERT
инструкции , UPDATE
и DELETE
, который будет автоматически создавать эти инструкции на основе инструкции SELECT
.
Наряду с флажком Создать INSERT
инструкции , UPDATE
и DELETE
диалоговое окно Расширенные параметры создания SQL включает параметр Использовать оптимистичный параллелизм (см. рис. 1). Если этот флажок установлен, WHERE
предложения в автоматически созданных UPDATE
инструкциях и DELETE
изменяются для выполнения обновления или удаления, только если базовые данные базы данных не были изменены с момента последней загрузки данных пользователем в сетку.
Рис. 1. Вы можете добавить поддержку оптимистичного параллелизма из диалогового окна Расширенные параметры создания SQL
В учебнике Реализация оптимистического параллелизма мы рассмотрели основы управления оптимистическим параллелизмом и способы его добавления в ObjectDataSource. В этом руководстве мы ретушируем основы управления оптимистичным параллелизмом, а затем рассмотрим, как реализовать его с помощью SqlDataSource.
Краткое описание оптимистичного параллелизма
Для веб-приложений, которые позволяют нескольким одновременным пользователям изменять или удалять одни и те же данные, существует вероятность того, что один пользователь может случайно перезаписать другие изменения. В учебнике Реализация оптимистического параллелизма я предоставил следующий пример:
Представьте, что два пользователя, Jisun и Сэм, посещали страницу в приложении, которая позволяла посетителям обновлять и удалять продукты с помощью элемента управления GridView. Оба нажатия кнопки Изменить для Chai примерно в одно и то же время. Jisun изменяет название продукта на Чай Чай и нажимает кнопку Обновить. Результатом UPDATE
является инструкция, которая отправляется в базу данных, которая задает все обновляемые поля продукта (даже если Jisun обновил только одно поле , ProductName
). На данный момент времени в базе данных есть значения Чай Чай, категория Напитки, поставщик экзотических жидкостей и т. д. для данного конкретного продукта. Однако gridView на экране Sam по-прежнему отображает название продукта в редактируемой строке GridView как Chai. Через несколько секунд после фиксации изменений Jisun Сэм обновляет категорию на Condiments и нажимает кнопку Обновить. В результате в UPDATE
базу данных отправляется инструкция, которая задает для имени продукта значение Chai, для CategoryID
— соответствующий идентификатор категории Condiments и т. д. Изменения jisun в названии продукта были перезаписаны.
На рисунке 2 показано это взаимодействие.
Рис. 2. При одновременном обновлении записи двумя пользователями возможны изменения для перезаписи других пользователей (щелкните для просмотра полноразмерного изображения)
Чтобы предотвратить развертывание этого сценария, необходимо реализовать форму управления параллелизмом . Оптимистичный параллелизм . В этом руководстве основное внимание уделяется предположению о том, что, хотя время от времени могут возникать конфликты параллелизма, в подавляющем большинстве случаев такие конфликты не возникают. Таким образом, в случае возникновения конфликта элемент управления оптимистическим параллелизмом просто информирует пользователя о том, что его изменения не могут быть сохранены, так как другой пользователь изменил те же данные.
Примечание
Для приложений, в которых предполагается, что будет много конфликтов параллелизма или если такие конфликты недопустимы, вместо этого можно использовать пессимистичное управление параллелизмом. Более подробное обсуждение пессимистичного управления параллелизмом см. в руководстве по реализации оптимистичного параллелизма.
Функция управления оптимистичным параллелизмом обеспечивает то, что обновляемая или удаляемая запись имеет те же значения, что и при запуске процесса обновления или удаления. Например, при нажатии кнопки Изменить в редактируемом элементе GridView значения записей считываются из базы данных и отображаются в TextBoxes и других веб-элементах управления. Эти исходные значения сохраняются GridView. Позже, когда пользователь вновит изменения и нажмет кнопку Обновить, UPDATE
используемая инструкция должна учитывать исходные значения плюс новые значения и обновлять базовую запись базы данных, только если исходные значения, которые пользователь начал редактировать, идентичны значениям, которые все еще находятся в базе данных. На рисунке 3 показана эта последовательность событий.
Рис. 3. Для обновления или удаления для успешного выполнения исходные значения должны быть равны значениям текущей базы данных (щелкните для просмотра полноразмерного изображения)
Существуют различные подходы к реализации оптимистичного параллелизма (см. раздел Питер А. Бромберг в разделе Оптимистическая логика обновления параллелизма , чтобы кратко ознакомиться с рядом вариантов). Метод, используемый SqlDataSource (а также ADO.NET typed DataSets, используемый в нашем уровне доступа к данным), дополняет WHERE
предложение, чтобы включить сравнение всех исходных значений. UPDATE
Следующая инструкция, например, обновляет имя и цену продукта, только если текущие значения базы данных равны значениям, которые были получены при обновлении записи в GridView. Параметры @ProductName
и @UnitPrice
содержат новые значения, введенные пользователем, тогда как @original_ProductName
и @original_UnitPrice
содержат значения, которые изначально были загружены в GridView при нажатии кнопки Изменить:
UPDATE Products SET
ProductName = @ProductName,
UnitPrice = @UnitPrice
WHERE
ProductID = @original_ProductID AND
ProductName = @original_ProductName AND
UnitPrice = @original_UnitPrice
Как мы увидим в этом руководстве, включить управление оптимистическим параллелизмом с помощью SqlDataSource так же просто, как установить флажок.
Шаг 1. Создание SqlDataSource, поддерживающего оптимистичный параллелизм
Начните с открытия страницы OptimisticConcurrency.aspx
из SqlDataSource
папки. Перетащите элемент управления SqlDataSource из панели элементов в Designer, задав для его ID
свойства значение ProductsDataSourceWithOptimisticConcurrency
. Затем щелкните ссылку Настройка источника данных в смарт-теге элемента управления. На первом экране мастера выберите для работы с и нажмите кнопку NORTHWINDConnectionString
Далее.
Рис. 4. Выберите для работы с NORTHWINDConnectionString
(Щелкните для просмотра полноразмерного изображения)
В этом примере мы добавим Элемент GridView, который позволяет пользователям редактировать таблицу Products
. Поэтому на экране Настройка инструкции select выберите таблицу Products
из раскрывающегося списка и столбцы ProductID
, ProductName
, UnitPrice
и Discontinued
, как показано на рисунке 5.
Рис. 5. Из Products
таблицы возвращает ProductID
столбцы , ProductName
, UnitPrice
и Discontinued
(щелкните для просмотра полноразмерного изображения)
Выбрав столбцы, нажмите кнопку Дополнительно, чтобы открыть диалоговое окно Расширенные параметры создания SQL. Установите флажки Создать INSERT
операторы , UPDATE
и DELETE
и Использовать оптимистичный параллелизм и нажмите кнопку ОК (см. снимок экрана на рис. 1). Завершите работу мастера, нажав кнопку Далее, а затем — Готово.
После завершения работы мастера настройки источника данных изучите результирующие DeleteCommand
свойства и UpdateCommand
, а также DeleteParameters
коллекции и UpdateParameters
. Самый простой способ сделать это — щелкнуть вкладку Источник в левом нижнем углу, чтобы увидеть декларативный синтаксис страницы. Здесь вы найдете значение UpdateCommand
:
UPDATE [Products] SET
[ProductName] = @ProductName,
[UnitPrice] = @UnitPrice,
[Discontinued] = @Discontinued
WHERE
[ProductID] = @original_ProductID AND
[ProductName] = @original_ProductName AND
[UnitPrice] = @original_UnitPrice AND
[Discontinued] = @original_Discontinued
С семью параметрами в UpdateParameters
коллекции:
<asp:SqlDataSource ID="ProductsDataSourceWithOptimisticConcurrency"
runat="server" ...>
<DeleteParameters>
...
</DeleteParameters>
<UpdateParameters>
<asp:Parameter Name="ProductName" Type="String" />
<asp:Parameter Name="UnitPrice" Type="Decimal" />
<asp:Parameter Name="Discontinued" Type="Boolean" />
<asp:Parameter Name="original_ProductID" Type="Int32" />
<asp:Parameter Name="original_ProductName" Type="String" />
<asp:Parameter Name="original_UnitPrice" Type="Decimal" />
<asp:Parameter Name="original_Discontinued" Type="Boolean" />
</UpdateParameters>
...
</asp:SqlDataSource>
Аналогичным образом свойство DeleteCommand
и DeleteParameters
коллекция должны выглядеть следующим образом:
DELETE FROM [Products]
WHERE
[ProductID] = @original_ProductID AND
[ProductName] = @original_ProductName AND
[UnitPrice] = @original_UnitPrice AND
[Discontinued] = @original_Discontinued
<asp:SqlDataSource ID="ProductsDataSourceWithOptimisticConcurrency"
runat="server" ...>
<DeleteParameters>
<asp:Parameter Name="original_ProductID" Type="Int32" />
<asp:Parameter Name="original_ProductName" Type="String" />
<asp:Parameter Name="original_UnitPrice" Type="Decimal" />
<asp:Parameter Name="original_Discontinued" Type="Boolean" />
</DeleteParameters>
<UpdateParameters>
...
</UpdateParameters>
...
</asp:SqlDataSource>
Помимо расширения WHERE
предложений свойств и DeleteCommand
(и добавления дополнительных UpdateCommand
параметров в соответствующие коллекции параметров), при выборе параметра Использовать оптимистичный параллелизм корректируются два других свойства:
ConflictDetection
Изменяет свойство сOverwriteChanges
(по умолчанию) наCompareAllValues
OldValuesParameterFormatString
Изменяет свойство с {0} (по умолчанию) на original_{0} .
Когда веб-элемент управления данными вызывает метод Или SqlDataSource Update()
Delete()
, он передает исходные значения. Если свойство SqlDataSource ConflictDetection
имеет значение CompareAllValues
, эти исходные значения добавляются в команду . Свойство OldValuesParameterFormatString
предоставляет шаблон именования, используемый для этих исходных параметров значений. Мастер настройки источника данных использует original_{0} и присваивает имена каждому исходному параметру UpdateCommand
в свойствах и DeleteCommand
и UpdateParameters
DeleteParameters
коллекциях соответственно.
Примечание
Так как мы не используем возможности вставки элемента управления SqlDataSource, вы можете удалить InsertCommand
свойство и его InsertParameters
коллекцию.
Правильная обработкаNULL
значений
К сожалению, дополненные UPDATE
инструкции и DELETE
, автоматически созданные мастером настройки источника данных при использовании оптимистичного параллелизма , не работают с записями, содержащими NULL
значения. Чтобы понять причину, рассмотрим наши sqlDataSource :UpdateCommand
UPDATE [Products] SET
[ProductName] = @ProductName,
[UnitPrice] = @UnitPrice,
[Discontinued] = @Discontinued
WHERE
[ProductID] = @original_ProductID AND
[ProductName] = @original_ProductName AND
[UnitPrice] = @original_UnitPrice AND
[Discontinued] = @original_Discontinued
Столбец UnitPrice
в Products
таблице может иметь NULL
значения. Если определенная запись имеет NULL
значение , UnitPrice
WHERE
часть [UnitPrice] = @original_UnitPrice
предложения всегда будет иметь значение False, так как NULL = NULL
всегда возвращает значение False. Таким образом, записи, содержащие NULL
значения, не могут быть изменены или удаленыWHERE
, так как UPDATE
предложения инструкций и DELETE
не возвращают строки для обновления или удаления.
Примечание
Эта ошибка была впервые зарегистрирована в корпорации Майкрософт в июне 2004 г. в sqlDataSource Generates Incorrect SQL Statements и, как сообщается, будет исправлена в следующей версии ASP.NET.
Чтобы исправить это, необходимо вручную обновить WHERE
предложения в UpdateCommand
свойствах и DeleteCommand
для всех столбцов, которые могут иметь NULL
значения. Как правило, измените на [ColumnName] = @original_ColumnName
:
(
([ColumnName] IS NULL AND @original_ColumnName IS NULL)
OR
([ColumnName] = @original_ColumnName)
)
Это изменение можно внести непосредственно с помощью декларативной разметки, параметров UpdateQuery или DeleteQuery из окно свойств или на вкладках UPDATE и DELETE в параметре Указать настраиваемую инструкцию SQL или хранимую процедуру в мастере настройки источника данных. Опять же, это изменение необходимо внести для каждого столбца в предложении UpdateCommand
и DeleteCommand
, WHERE
который может содержать NULL
значения.
Применение этого к нашему примеру приводит к следующим измененным UpdateCommand
значениям и DeleteCommand
:
UPDATE [Products] SET
[ProductName] = @ProductName,
[UnitPrice] = @UnitPrice,
[Discontinued] = @Discontinued
WHERE
[ProductID] = @original_ProductID AND
[ProductName] = @original_ProductName AND
(([UnitPrice] IS NULL AND @original_UnitPrice IS NULL)
OR ([UnitPrice] = @original_UnitPrice)) AND
[Discontinued] = @original_Discontinued
DELETE FROM [Products]
WHERE
[ProductID] = @original_ProductID AND
[ProductName] = @original_ProductName AND
(([UnitPrice] IS NULL AND @original_UnitPrice IS NULL)
OR ([UnitPrice] = @original_UnitPrice)) AND
[Discontinued] = @original_Discontinued
Шаг 2. Добавление GridView с параметрами "Изменить" и "Удалить"
Если sqlDataSource настроен для поддержки оптимистичного параллелизма, остается только добавить веб-элемент управления данными на страницу, которая использует этот элемент управления параллелизмом. В этом руководстве мы добавим Элемент GridView, который предоставляет функции редактирования и удаления. Для этого перетащите Элемент GridView из панели элементов на Designer и задайте для нее ID
значение Products
. Из смарт-тега GridView привяжите его к элементу ProductsDataSourceWithOptimisticConcurrency
управления SqlDataSource, добавленного на шаге 1. Наконец, проверка параметры Включить редактирование и Включить удаление из смарт-тега.
Рис. 6. Привязка GridView к SqlDataSource и включение редактирования и удаления (щелкните для просмотра полноразмерного изображения)
После добавления GridView настройте его внешний вид, удалив ProductID
BoundField, изменив ProductName
свойство BoundField HeaderText
на Product и обновив UnitPrice
BoundField, чтобы его HeaderText
свойство было просто Price. В идеале мы должны улучшить интерфейс редактирования, включив RequiredFieldValidator для ProductName
значения и CompareValidator для UnitPrice
значения (чтобы обеспечить правильное форматирование числовых значений). Дополнительные сведения о настройке интерфейса редактирования GridView см. в руководстве По настройке интерфейса изменения данных.
Примечание
Состояние представления GridView должно быть включено, так как исходные значения, передаваемые из GridView в SqlDataSource, хранятся в состоянии представления.
После внесения этих изменений в GridView декларативная разметка GridView и SqlDataSource должна выглядеть примерно так:
<asp:SqlDataSource ID="ProductsDataSourceWithOptimisticConcurrency"
runat="server" ConflictDetection="CompareAllValues"
ConnectionString="<%$ ConnectionStrings:NORTHWNDConnectionString %>"
DeleteCommand=
"DELETE FROM [Products]
WHERE [ProductID] = @original_ProductID
AND [ProductName] = @original_ProductName
AND (([UnitPrice] IS NULL AND @original_UnitPrice IS NULL)
OR ([UnitPrice] = @original_UnitPrice))
AND [Discontinued] = @original_Discontinued"
OldValuesParameterFormatString=
"original_{0}"
SelectCommand=
"SELECT [ProductID], [ProductName], [UnitPrice], [Discontinued]
FROM [Products]"
UpdateCommand=
"UPDATE [Products]
SET [ProductName] = @ProductName, [UnitPrice] = @UnitPrice,
[Discontinued] = @Discontinued
WHERE [ProductID] = @original_ProductID
AND [ProductName] = @original_ProductName
AND (([UnitPrice] IS NULL AND @original_UnitPrice IS NULL)
OR ([UnitPrice] = @original_UnitPrice))
AND [Discontinued] = @original_Discontinued">
<DeleteParameters>
<asp:Parameter Name="original_ProductID" Type="Int32" />
<asp:Parameter Name="original_ProductName" Type="String" />
<asp:Parameter Name="original_UnitPrice" Type="Decimal" />
<asp:Parameter Name="original_Discontinued" Type="Boolean" />
</DeleteParameters>
<UpdateParameters>
<asp:Parameter Name="ProductName" Type="String" />
<asp:Parameter Name="UnitPrice" Type="Decimal" />
<asp:Parameter Name="Discontinued" Type="Boolean" />
<asp:Parameter Name="original_ProductID" Type="Int32" />
<asp:Parameter Name="original_ProductName" Type="String" />
<asp:Parameter Name="original_UnitPrice" Type="Decimal" />
<asp:Parameter Name="original_Discontinued" Type="Boolean" />
</UpdateParameters>
</asp:SqlDataSource>
<asp:GridView ID="Products" runat="server"
AutoGenerateColumns="False" DataKeyNames="ProductID"
DataSourceID="ProductsDataSourceWithOptimisticConcurrency">
<Columns>
<asp:CommandField ShowDeleteButton="True" ShowEditButton="True" />
<asp:BoundField DataField="ProductName" HeaderText="Product"
SortExpression="ProductName" />
<asp:BoundField DataField="UnitPrice" HeaderText="Price"
SortExpression="UnitPrice" />
<asp:CheckBoxField DataField="Discontinued" HeaderText="Discontinued"
SortExpression="Discontinued" />
</Columns>
</asp:GridView>
Чтобы увидеть элемент управления оптимистическим параллелизмом в действии, откройте два окна браузера и загрузите страницу OptimisticConcurrency.aspx
в обоих. Нажмите кнопки Изменить для первого продукта в обоих браузерах. В одном браузере измените название продукта и нажмите кнопку Обновить. Браузер выполнит обратную передачу, и GridView вернется в режим предварительного редактирования, отображая новое название продукта для только что измененной записи.
Во втором окне браузера измените цену (но оставьте название продукта в качестве исходного значения) и нажмите кнопку Обновить. При обратной отправке сетка возвращается в режим предварительного редактирования, но изменение цены не записывается. Во втором браузере отображается то же значение, что и имя нового продукта со старой ценой. Изменения, внесенные во втором окне браузера, были потеряны. Кроме того, изменения были потеряны довольно тихо, так как не было никаких исключений или сообщений, указывающих на то, что нарушение параллелизма только что произошло.
Рис. 7. Изменения во втором окне браузера были потеряны автоматически (щелкните для просмотра полноразмерного изображения)
Причина, по которой изменения второго браузера не были зафиксированы, заключалась в UPDATE
том, что предложение оператора WHERE
отфильтровывало все записи и, следовательно, не влияло ни на какие строки. Давайте еще раз рассмотрим UPDATE
это утверждение:
UPDATE [Products] SET
[ProductName] = @ProductName,
[UnitPrice] = @UnitPrice,
[Discontinued] = @Discontinued
WHERE
[ProductID] = @original_ProductID AND
[ProductName] = @original_ProductName AND
(([UnitPrice] IS NULL AND @original_UnitPrice IS NULL) OR
([UnitPrice] = @original_UnitPrice)) AND
[Discontinued] = @original_Discontinued
Когда во втором окне браузера обновляется запись, исходное имя продукта, указанное WHERE
в предложении , не совпадает с существующим названием продукта (так как оно было изменено первым браузером). Таким образом, оператор [ProductName] = @original_ProductName
возвращает значение False, а UPDATE
не влияет на записи.
Примечание
Удаление работает таким же образом. Открыв два окна браузера, начните с редактирования данного продукта с одним, а затем сохраните его изменения. После сохранения изменений в одном браузере нажмите кнопку Удалить для того же продукта в другом. Так как исходные значения не совпадают в предложении инструкции DELETE
WHERE
, удаление автоматически завершается ошибкой.
С точки зрения пользователя во втором окне браузера после нажатия кнопки Обновить сетка возвращается в режим предварительного редактирования, но изменения были потеряны. Однако нет визуальной обратной связи, которая не была бы закреплена в изменениях. В идеале, если изменения, внесенные пользователем, теряются из-за нарушения параллелизма, мы должны уведомить его и, возможно, сохранить сетку в режиме редактирования. Давайте посмотрим, как это сделать.
Шаг 3. Определение того, когда произошло нарушение параллелизма
Так как нарушение параллелизма отклоняет внесенные изменения, было бы неплохо оповещать пользователя о нарушении параллелизма. Чтобы предупредить пользователя, добавьте веб-элемент управления Label в верхнюю часть страницы с именем ConcurrencyViolationMessage
, свойство которого Text
отображает следующее сообщение: Вы попытались обновить или удалить запись, которая была одновременно обновлена другим пользователем. Просмотрите изменения другого пользователя, а затем повторите обновление или удалите его. Присвойте свойству Label control s CssClass
значение Warning— класс CSS, определенный в Styles.css
, который отображает текст красным, курсивом, полужирным шрифтом и крупным шрифтом. Наконец, присвойте свойствам Label и Visible
EnableViewState
значение false
. При этом метка будет скрыта, за исключением только тех обратных передач, для которых мы явно задали его Visible
свойству значение true
.
Рис. 8. Добавление элемента управления "Метка" на страницу для отображения предупреждения (щелкните для просмотра полноразмерного изображения)
При выполнении обновления или удаления обработчики событий GridView RowUpdated
и RowDeleted
срабит после того, как его элемент управления источником данных выполнил запрошенное обновление или удаление. Мы можем определить, сколько строк было затронуто операцией, из этих обработчиков событий. Если были затронуты нулевые строки, необходимо отобразить метку ConcurrencyViolationMessage
.
Создайте обработчик событий и для RowUpdated
событий и RowDeleted
и добавьте следующий код:
protected void Products_RowUpdated(object sender, GridViewUpdatedEventArgs e)
{
if (e.AffectedRows == 0)
{
ConcurrencyViolationMessage.Visible = true;
e.KeepInEditMode = true;
// Rebind the data to the GridView to show the latest changes
Products.DataBind();
}
}
protected void Products_RowDeleted(object sender, GridViewDeletedEventArgs e)
{
if (e.AffectedRows == 0)
ConcurrencyViolationMessage.Visible = true;
}
В обоих обработчиках событий мы проверка e.AffectedRows
свойство и, если оно равно 0, присвойте свойству ConcurrencyViolationMessage
Label s Visible
значение true
. В обработчике RowUpdated
событий мы также предписываем GridView оставаться в режиме KeepInEditMode
редактирования, задав для свойства значение true. При этом необходимо повторно привязать данные к сетке, чтобы данные другого пользователя загружались в интерфейс редактирования. Это достигается путем вызова метода GridView.DataBind()
Как показано на рисунке 9, при использовании этих двух обработчиков событий при каждом нарушении параллелизма отображается очень заметное сообщение.
Рис. 9. Сообщение отображается в лице нарушения параллелизма (щелкните для просмотра полноразмерного изображения)
Сводка
При создании веб-приложения, в котором несколько одновременных пользователей могут редактировать одни и те же данные, важно учитывать параметры управления параллелизмом. По умолчанию ASP.NET веб-элементы управления данными и элементы управления источником данных не используют управление параллелизмом. Как мы видели в этом руководстве, реализация управления оптимистическим параллелизмом с помощью SqlDataSource является относительно быстрой и простой. SqlDataSource обрабатывает большую часть работы по добавлению дополненных WHERE
предложений в автоматически созданные UPDATE
операторы и и DELETE
, но есть несколько тонкостей в обработке NULL
столбцов значений, как описано в разделе Правильная обработка NULL
значений.
В этом руководстве мы завершаем изучение SqlDataSource. Оставшиеся руководства вернутся к работе с данными с использованием ObjectDataSource и многоуровневой архитектуры.
Счастливое программирование!
Об авторе
Скотт Митчелл (Scott Mitchell), автор семи книг ASP/ASP.NET и основатель 4GuysFromRolla.com, работает с Веб-технологиями Майкрософт с 1998 года. Скотт работает независимым консультантом, тренером и писателем. Его последняя книга Sams Teach Yourself ASP.NET 2.0 в 24 часа. Его можно связать по адресу mitchell@4GuysFromRolla.com. или через его блог, который можно найти по адресу http://ScottOnWriting.NET.