Расширения GridView

60

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

Итоговые значения в GridView

Хотя основное назначение GridView — показывать наборы записей, можно также добавлять некоторую более интересную информацию, подобную итоговым данным. Первый шаг состоит в добавлении строки нижнего колонтитула, для чего нужно установить свойство GridView.ShowFooter в true. Это приведет к отображению в нижней части GridView затененной строки (которую можно свободно настроить по своему вкусу), но никакие данные в ней выводиться не будут. Чтобы решить эту задачу, понадобится добавить содержимое в GridView.FooterRow.

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

Первое, что нужно сделать — решить, когда следует вычислять эту информацию. Если вы применяете ручную привязку, то можете извлечь объект данных и использовать его для выполнения вычислений до привязки к GridView. Однако если вы применяете декларативную привязку, необходим другой подход. На выбор доступны два варианта — извлекать данные из объекта данных перед привязкой сетки или же извлекать их непосредственно из самой сетки после того, как она уже привязана.

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

В данном примере разделенная на страницы таблица товаров предоставляет итоговую информацию, указывающую общую цену всех товаров, отображаемых в данный момент на странице:

<asp:SqlDataSource ID="getProductsSDS" runat="server"
            ConnectionString="<%$ ConnectionStrings:Northwind %>"
            SelectCommand="SELECT * FROM Products" />

<asp:GridView ID="gridProducts" runat="server" DataSourceID="getProductsSDS"
	AllowPaging="true" OnDataBound="gridProducts_DataBound" ShowFooter="true"
    ...>

	<Columns>
	    <asp:BoundField HeaderText="Id" DataField="ProductID" />
	    <asp:BoundField HeaderText="Название" DataField="ProductName" />
	    <asp:BoundField HeaderText="Цена" DataField="UnitPrice" DataFormatString="{0:C}" />
	    <asp:BoundField HeaderText="На складе" DataField="UnitsInStock">
	        <ItemStyle HorizontalAlign="Right"></ItemStyle>
	    </asp:BoundField>
	</Columns>

	...
</asp:GridView>
GridView с итоговой суммой е нижнем колонтитуле

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

Ниже показан полный код:

protected void gridProducts_DataBound(object sender, EventArgs e)
{
        decimal valueInStock = 0;

        // Коллекция Rows включает только строки текущей 
        // страницы (не "виртуальные" строки)
        foreach (GridViewRow row in gridProducts.Rows)
        {
            decimal price = Decimal.Parse(row.Cells[2].Text.Replace(" р.",""));
            int unitsInStock = Int32.Parse(row.Cells[3].Text);
            valueInStock += price * unitsInStock;
        }

        // Обновить нижний колонтитул
        GridViewRow footer = gridProducts.FooterRow;

        // Установить, чтобы первая ячейка распространялась на всю строку
        footer.Cells[0].ColumnSpan = 3;
        footer.Cells[0].HorizontalAlign = HorizontalAlign.Center;

        // Удалить ненужные ячейки
        footer.Cells.RemoveAt(2);
        footer.Cells.RemoveAt(1);

        // Добавить текст
        footer.Cells[0].Text = "Общая стоимость товаров (на этой странице): " +
          valueInStock.ToString("C");
}

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

Представление "родительский-дочерний" в одной таблице

Ранее вы уже видели страницу "главная-детальная", в которой использовались элементы GridView и DetailsView. Она дает возможность отображать дочерние записи только для одной родительской записи, выбранной в текущий момент. Однако иногда может понадобиться создать отчет в стиле "родительский-дочерний", который показывает все записи дочерней таблицы, упорядоченные по родительской таблице. Например, это можно применять для создания полного списка товаров, организованного по категориям. Ниже приведен пример, в котором демонстрируется, как отобразить полный, разбитый на подгруппы список товаров в одной сетке:

Родительская сетка со встроенными дочерними сетками

Базовый прием предполагает создание GridView для родительской таблицы, содержащей встроенные элементы GridView для каждой строки. Эти дочерние элементы управления GridView помещаются в родительский GridView с применением TemplateField. Единственная сложность заключается в том, что вы не можете привязать дочерние элементы управления GridView одновременно с привязкой родительского элемента, поскольку родительские строки еще не созданы. Вместо этого необходимо подождать возникновения события GridView.DataBound в родительском элементе.

В рассматриваемом примере родительский элемент GridView определяет два столбца, оба имеющие тип TemplateField. Первый столбец комбинирует наименование категории и ее описание, второй столбец содержит встроенный GridView товаров с двумя привязанными столбцами. Ниже показан листинг, в котором опущены атрибуты, имеющие отношение к стилю:

<asp:GridView ID="gridMaster" runat="server" DataSourceID="getCategories" DataKeyNames="CategoryID"
            OnRowDataBound="gridMaster_RowDataBound"
            ...>

	<Columns>
		<asp:TemplateField HeaderText="Категория">
		    <ItemStyle VerticalAlign="Top" Width="20%" />
		    <ItemTemplate>
		        <br />
		        <b><%# Eval("CategoryName") %></b>
		        <br /><br />
		        <%# Eval("Description") %>
		        <br />
		    </ItemTemplate>
		</asp:TemplateField>
		<asp:TemplateField HeaderText="Товары">
		    <ItemStyle VerticalAlign="Top" />
		    <ItemTemplate>
		        <asp:GridView ID="gridChild" runat="server" AutoGenerateColumns="false" Width="100%">
		            <Columns>
				<asp:BoundField DataField="ProductName" HeaderText="Название" ItemStyle-Width="80%"/>
				<asp:BoundField DataField="UnitPrice" HeaderText="Цена" DataFormatString="{0:C}" />
		            </Columns>
		        </asp:GridView>
		    </ItemTemplate>
		</asp:TemplateField>
	</Columns>

</asp:GridView>

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

<asp:SqlDataSource ID="getCategories" runat="server"
            ConnectionString="<%$ ConnectionStrings:Northwind %>"
            SelectCommand="SELECT * FROM Categories" />

Второй источник данных содержит запрос, вызываемый многократно для наполнения дочернего GridView. Каждый раз он извлекает товары из другой категории. CategoryID передается как параметр:

<asp:SqlDataSource ID="getProductsSDS" runat="server"
            ConnectionString="<%$ ConnectionStrings:Northwind %>"
            SelectCommand="SELECT * FROM Products WHERE CategoryID=@CategoryID">
	<SelectParameters>
		<asp:Parameter Name="CategoryID" Type="Int32" />
	</SelectParameters>
</asp:SqlDataSource>

Чтобы привязать дочерние элементы управления GridView, необходимо отреагировать на событие GridView.RowDataBound, которое происходит каждый раз, когда генерируется строка и привязывается к родительскому элементу GridView. В этой точке можно извлечь дочерний элемент управления GridView из второго столбца и привязать его к информации о товарах, программно вызывая метод источника данных Select(). Чтобы обеспечить каждый раз отображение только товаров текущей категории, необходимо также извлекать поле CategoryID текущего элемента и передавать его в виде аргумента. Ниже показан необходимый код:

protected void gridMaster_RowDataBound(object sender, GridViewRowEventArgs e)
{
        // Поиск элементов данных
        if (e.Row.RowType == DataControlRowType.DataRow)
        {
            
            // Извлечь элемент управления GridView из второго столбца
            GridView gridChild = (GridView)e.Row.Cells[1].Controls[1]; 
            
            // Установить параметр CategoryID так, чтобы получить 
            // товары только текущей категории
            string catID = gridMaster.DataKeys[e.Row.DataItemIndex].Value.ToString();
            getProductsSDS.SelectParameters[0].DefaultValue = catID; 
            
            // Получить объект данных из источника данных
            object data = getProductsSDS.Select(DataSourceSelectArguments.Empty); 
            
            // Привязать сетку
            gridChild.DataSource = data;
            gridChild.DataBind();
        }
}

Получение изображений из базы данных

В примерах, рассмотренных до сих пор, извлекался текст, числа и даты. Однако базы данных часто хранят и двоичные данные, такие как графические изображения. Например, может существовать таблица Products, которая содержит изображения каждого элемента в двоичном поле. Извлечь эти данные на странице ASP.NET довольно просто, но не так-то просто их отобразить.

Основная проблема состоит в том, что для вывода изображения на HTML-странице необходимо добавлять дескриптор графического изображения, который ссылается на отдельный файл в своем атрибуте src, как показано ниже:

<img src="myfile.gif" alt="My Image" />

К сожалению, это не очень помогает, если нужно динамически отображать графические данные. Хотя можно установить атрибут src в коде, нет способа программно установить содержимое изображения. Можно сначала сохранить данные в графическом файле на жестком диске веб-сервера, но такой подход может оказаться чрезвычайно медленным и расточительным в отношении дискового пространства. Кроме того, он также порождает ошибки, связанные с параллельным доступом, когда одновременно обслуживается множество запросов, и все они пытаются записать один и тот же файл.

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

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

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

Отображение двоичных данных

ASP.NET не ограничивается возвратом HTML-содержимого. В действительности с помощью метода Response.BinaryWrite() можно возвращать неформатированные байты, полностью обходя модель веб-страниц.

В следующей странице этот прием используется с таблицей pub_info в базе данных pubs (еще одна стандартная база данных, поставляемая с SQL Server. Вы можете ее установить, используя Northwind and pubs Sample Databases for SQL Server 2000). В этой странице извлекается поле logo, содержащее двоичные данные изображения. Затем эти данные непосредственно выводятся на страницу, как показано ниже:

protected void Page_Load(object sender, EventArgs e)
{
        string connectionString = 
            WebConfigurationManager.ConnectionStrings["Pubs"].ConnectionString; 
        SqlConnection con = new SqlConnection(connectionString); 
        string SQL = "SELECT logo FROM pub_info WHERE pub_id='1389'"; 
        SqlCommand cmd = new SqlCommand(SQL, con);
        
        try 
        {
            con.Open();
            SqlDataReader r = cmd.ExecuteReader();
            
            if (r.Read()) 
            {
                byte[] bytes = (byte[])r["logo"]; 
                Response.BinaryWrite(bytes);
            }
            r.Close();
        }
        finally
        {
            con.Close();
        }
}

При использовании BinaryWrite() вы отступаете от модели веб-страницы. Если вы добавите другие элементы управления на страницу, они там не появятся. Аналогично Response.Write() не даст никакого эффекта, поскольку вы более не конструируете HTML-страницу. Вместо этого вы возвращаете двоичные данные. В последующих разделах будет показано, как решить эту проблему и оптимизировать подход.

Эффективное чтение двоичных данных

Двоичные данные могут легко вырастать до огромных размеров. Однако если вы будете иметь дело с большим графическим файлом, то приведенный выше пример продемонстрирует вопиюще низкую производительность. Проблема в том, что в нем используется DataReader, который загружает в память по одной записи за раз. Это лучше, чем DataSet (который загружает сразу весь результирующий набор), но все же не идеально, если размер поля достаточно велик.

Нет ни одной разумной причины для того, чтобы загружать изображение размером 2 Мбайт в память целиком, гораздо лучше было бы читать его по частям и затем выводить каждую часть в выходной поток, используя для этого Response.BinaryWrite(). К счастью, DataReader обладает средством последовательного доступа, которое поддерживает решение подобного рода. Чтобы использовать последовательный доступ, необходимо всего лишь передать значение CommandBehavior.SequentialAccess методу Command.ExecuteDataReader(). После этого можно будет перемещать в строку по одному блоку за раз, используя метод DataReader.GetBytes().

Применяя последовательный доступ, следует помнить о паре ограничений. Во-первых, данные должны читаться как однонаправленный поток. Прочитав блок данных, вы автоматически перемещаетесь вперед в потоке, т.е. путь назад невозможен. Во-вторых, поля должны читаться в том порядке, в каком они возвращаются запросом. Например, если запрос возвращает три столбца, третий из которых — двоичное поле, то перед тем, как можно будет обратиться к двоичным полям в третьем поле, понадобится вернуть значения первого и второго полей. Если обратиться вначале сразу к третьему полю, доступа к первым двум уже не будет.

Вот как переделать предыдущую страницу для использования последовательного доступа:

protected void Page_Load(object sender, EventArgs e)
{
        string connectionString = 
            WebConfigurationManager.ConnectionStrings["Pubs"].ConnectionString; 
        SqlConnection con = new SqlConnection(connectionString); 
        string SQL = "SELECT logo FROM pub_info WHERE pub_id='1389'"; 
        SqlCommand cmd = new SqlCommand(SQL, con);
        
        try 
        {
            con.Open();
            SqlDataReader r = cmd.ExecuteReader(CommandBehavior.SequentialAccess);
            
            if (r.Read()) 
            {
                int bufferSize = 100;	// Размер буфера
                byte[] bytes = new byte[bufferSize];	// Буфер
                long bytesRead;	// Прочитано байт
                long readFrom = 0;	// Начальный индекс
                
                // Читать поле по 100 байт за раз
                do
                {
                    bytesRead = r.GetBytes(0, readFrom, bytes, 0, bufferSize);
                    Response.BinaryWrite(bytes);
                    readFrom += bufferSize; 
                }
                while (bytesRead == bufferSize);
            }
            r.Close();
        }
        finally
        {
            con.Close();
        }
}

Метод GetBytes() возвращает значение, указывающее количество полученных данных. Если нужно определить общее количество байт в поле, при вызове метода GetBytes() просто передайте null-ссылку вместо буфера.

Интеграция изображений с другим содержимым

Метод Response.BinaryWrite() создает некоторую проблему, если нужно интегрировать графические данные с другими элементами управления и HTML-разметкой. Это объясняется тем, что в случае использования BinaryWrite() для возврата низкоуровневых данных изображения теряется возможность добавлять любое дополнительное HTML-содержимое.

Чтобы справиться с этой проблемой, нужно создать другую страницу, которая вызовет код, генерирующий изображение. Лучший способ сделать это — заменить страницу, генерирующую изображение, выделенным обработчиком HTTP, который сгенерирует графический вывод. В результате удастся сэкономить на накладных расходах полной модели веб-форм ASP.NET. которую все равно использовать нельзя.

Создать нужный обработчик HTTP довольно просто. Вы должны реализовать интерфейс IHttpHandler и метод ProcessRequest(). Обработчик HTTP извлечет идентификатор записи, которую нужно отобразить, из строки запроса.

Ниже приведен полный код обработчика HTTP:

using System;
using System.Web;
using System.Web.Configuration;
using System.Data;
using System.Data.SqlClient;

public class ImageFromDB : IHttpHandler
{
    public void ProcessRequest(HttpContext context)
    {
        string connectionString =
          WebConfigurationManager.ConnectionStrings["Pubs"].ConnectionString;

        // Получить GET-идентификатор для данного запроса
        string id = context.Request.QueryString["id"];
        if (id == null) throw new ApplicationException("Должен быть указан идентификатор");

        // Создать параметризованную команду для данной записи
        SqlConnection con = new SqlConnection(connectionString);
        string SQL = "SELECT logo FROM pub_info WHERE pub_id=@ID";
        SqlCommand cmd = new SqlCommand(SQL, con);
        cmd.Parameters.AddWithValue("@ID", id);

        try
        {
            con.Open();
            SqlDataReader r =
              cmd.ExecuteReader(CommandBehavior.SequentialAccess);

            if (r.Read())
            {
                int bufferSize = 100;                   // Размер буфера
                byte[] bytes = new byte[bufferSize];    // Буфер
                long bytesRead;                         // Прочитано байт
                long readFrom = 0;                      // Начальный индекс

                // Читать поле по 100 байт за раз
                do
                {
                    bytesRead = r.GetBytes(0, readFrom, bytes, 0, bufferSize);
                    context.Response.BinaryWrite(bytes);
                    readFrom += bufferSize;
                } while (bytesRead == bufferSize);
            }
            r.Close();
        }
        finally
        {
            con.Close();
        }
    }

    public bool IsReusable
    {
        get { return true; }
    }
}

Созданный обработчик HTTP необходимо добавить в файл типа Generic Handler:

Шаблон Generic Handler

Теперь можно получать графические данные, опрашивая URL обработчика HTTP, указывая идентификатор (ID) строки, которую нужно извлечь. Вот пример:

<img src="ImageFromDB.ashx?ID=1389" alt="logo" />

Ниже показан пример реализации страницы с множеством элементов управления и изображениями логотипов. Она использует в GridView следующий шаблон ItemTemplate:

<asp:GridView ID="GridView" runat="server" DataSourceID="SqlDataSource1">
            <Columns>
                <asp:TemplateField>
                    <ItemTemplate>
                        <table border='1'>
                            <tr>
                                <td>
                                    <img src='ImageFromDB.ashx?ID=<%# DataBinder.Eval(Container.DataItem, "pub_id") %>'
                                     alt="Logo" /></td>
                            </tr>
                        </table>
                        <b>
                            <%# Eval("pub_name") %>
                        </b>
                        <br>
                        <%# Eval("city") %>
					,
					<%# Eval("state") %>
					,
					<%# Eval("country") %>
                        <br>
                        <br>
                    </ItemTemplate>
                </asp:TemplateField>
            </Columns>
</asp:GridView>

<asp:SqlDataSource ID="SqlDataSource1" ConnectionString="<%$ ConnectionStrings:Pubs %>"
            SelectCommand="SELECT * FROM publishers" runat="server" />
Извлечение изображений из базы данных

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

Решить эту проблему можно за счет создания обработчика HTTP, который сначала проверяет наличие изображения в кэше, прежде чем извлекать его из базы данных. Тогда перед привязкой GridView нужно будет выполнить запрос, который вернет все записи с их графическими данными и загрузит их в кэш.

Пройди тесты
Лучший чат для C# программистов