wtorek, 17 maja 2011

Virtual Mode - Windows Forms DataGridView

Pisząc ostatnio aplikację w WindowsForms musiałem wyświetlić wyniki w Gridzie. Oczywiście pisząc programy operujące na danych bardzo często używamy komponentu DataGridView do wyświetlania danych tabelarycznych.
Wyświetlanie oczywiście nie jest trudne: tworzymy źródło danych, podpinamy pod Grida i już możemy zobaczyć efekty na ekranie.
Rozwiązanie szybkie, proste i właściwie przez pewien czas będzie bardzo dobrze działało. Jak wiadomo co przychodzi łatwo i przyjemnie szybko się kończy. Tworząc aplikację najczęściej testujemy ją na kilku rekordach. Aplikacja działa wtedy bardzo szybko i nie widać żadnego działania niepożądanego. 5 tys rekordów? Dalej wszystko działa sprawnie i bez przeszkód. Przenosimy się w przyszłość - 50 tys rekordów. Ładowanie danych zaczyna zajmować trochę więcej czasu niż normalnie - niekiedy jest to 10 sek w zależności od ilości kolumn.
Teraz skaczemy do odległej przyszłości która może wyglądać następująco:


Jeżeli ktoś nie dowierza - to tak, mamy do czynienia z tabelą przechowującą ponad 6 mln rekordów.

Przejdźmy na chwilę do kodu. Na początek zastanówmy się w co można by było załadować taką ilość danych. Wydawało by się, że DataTable będzie najlepszym i najszybszym wyborem - od razu zaznaczam nie jest. Zużycie pamięci podczas ładowania danych sięgnęło gigantycznych wartości rzędu 1,6 GB - zaznaczam że te dane jeszcze nie trafiły do Grida. Dane ładowały się bardzo długo - właściwie nie jest to dopuszczalne zachowanie aplikacji.
Tak więc następujący kod nie spełni swojego zadania:


Code:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Data;
using System.Data.SqlClient;
using System.Configuration;
using System.Diagnostics;

namespace WindowsFormsApplication3
{
    public class TestTable
    {
        private string ConnectionString;

        public TestTable()
        {
            ConnectionString = ConfigurationManager.ConnectionStrings["db"].ConnectionString;
        }

        public DataTable GetData()
        {
            Stopwatch time = new Stopwatch();
            time.Start();
            DataTable data = new DataTable();
            using (SqlConnection conn = new SqlConnection(ConnectionString))
            {
                string sql = @"SELECT [Id],[FirstName],[LastName],[BirthYear],[ChildernAmount] FROM [test4].[dbo].[test]";
                using (SqlCommand cmd = new SqlCommand(sql, conn))
                {
                    try
                    {
                        conn.Open();
                        using (SqlDataReader dr = cmd.ExecuteReader())
                        {
                            data.Load(dr);
                            dr.Close();
                        }
                    }
                    catch
                    {

                    }
                    finally
                    {
                        conn.Close();
                    }
                }
            }
            time.Stop();
            Console.WriteLine(time.Elapsed.Seconds + " " + time.Elapsed.Milliseconds);

            return data;
        }
    }
}

Czas zastanowić się w co można innego "włożyć nasze dane". Spróbujmy z listą:


Code:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Data;
using System.Data.SqlClient;
using System.Configuration;
using System.Diagnostics;

namespace WindowsFormsApplication3
{
    public class TestTable
    {
        private string ConnectionString;

        public TestTable()
        {
            ConnectionString = ConfigurationManager.ConnectionStrings["db"].ConnectionString;
        }

        public List<Test> GetData()
        {
            Stopwatch time = new Stopwatch();
            time.Start();
            List<Test> data = null;
            using (SqlConnection conn = new SqlConnection(ConnectionString))
            {
                string sql = @"SELECT Count(1) FROM [test4].[dbo].[test]";
                using (SqlCommand cmd = new SqlCommand(sql, conn))
                {
                    try
                    {
                        conn.Open();
                        int recordsCount = int.Parse(cmd.ExecuteScalar().ToString());
                        data = new List<Test>(recordsCount);
                        sql = @"SELECT [Id],[FirstName],[LastName],[BirthYear],[ChildernAmount] FROM [test4].[dbo].[test]";
                        cmd.CommandText = sql;
                        using (SqlDataReader dr = cmd.ExecuteReader())
                        {
                            while (dr.Read())
                            {
                                Test t = new Test();
                                t.Id = int.Parse(dr["Id"].ToString());
                                t.FirstName = dr["FirstName"].ToString();
                                t.LastName = dr["LastName"].ToString();
                                t.BirthYear = int.Parse(dr["BirthYear"].ToString());
                                t.ChildernAmount = int.Parse(dr["ChildernAmount"].ToString());
                                data.Add(t);
                            }
                            dr.Close();
                        }
                    }
                    catch
                    {

                    }
                    finally
                    {
                        conn.Close();
                    }
                }
            }
            time.Stop();
            Console.WriteLine(time.Elapsed.Seconds + " " + time.Elapsed.Milliseconds);

            return data;
        }
    }
}

Pierwsza sprawa to duży spadek zużycia pamięci - użycie listy zajęło 564 MB. Czas ładowania jest jednak nadal duży i wynosi ponad 40 sek. Zbindowanie danych z DataGridView kosztuje już tylko 110 MB.
Oczywiście przechodzenie po gridzie jest bardzo powolne i nie wydajne. Sprawia wrażenie chodzenia po polu minowym - naciskając strzałkę w dół czekamy 1 sek na reakcję aby selektor wiersza przeszedł na następny rekord.

Jak więc poradzić sobie z dużą ilością danych w naszej aplikacji? Istnieje kilka sposobów na to zadanie.
Jednym z rozwiązań jest zastosowanie VirtualMode - czyli trybu wirtualnego. Czym zajmuje się ten tryb. Otóż jego zadanie jest bardzo proste - pobiera i wyświetla tylko te rekordy które aktualnie są potrzebne do tego aby użytkownik mógł widzieć ich zawartość. Łatwo to zobrazować na diagramie. W przypadku zwykłego bindowania mamy do czynienia z następującą sytuacją:


W przypadku trybu wirtualnego pobieramy tylko część danych z bazy:


Wiemy już w jaki sposób działa tryb wirtualny, teraz przejdźmy do tego co jest potrzebne aby go zaimplementować do
1. Ustawiamy atrybut VirtualMode kontrolki DataGridView na true
2. Kodujemy zdarzene trybu wirtualnego: CellValueNeeded
3. Ustawiamy właściwość RowCount kontrolki DataGridView

Ilość rekordów potrzebna jest, aby DataGridView wiedział ile pustych rekordów ma wstawić do kontrolki.
Przejdźmy więc do sedna rozwiązania:

Na początek przygotujemy klasę, która posłuży jako bufor wczytywania potrzebnych danych:


Code:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Data;
using System.Data.SqlClient;
using System.Configuration;
using System.Diagnostics;

namespace WindowsFormsApplication3
{
    public class TestTable
    {
        private string ConnectionString;
        public Test[] Items { get; set; }

        public int PageSize { get; set; }
        public int TotalRecordCount { get; set; }
        private int currentPage;
        private int loadedPages;
        private int lastPage;

        public TestTable(int pageSize = 500)
        {
            ConnectionString = ConfigurationManager.ConnectionStrings["db"].ConnectionString;
            TotalRecordCount = RowCount();
            lastPage = TotalRecordCount / pageSize;
            loadedPages = -1;
            currentPage = -1;
            PageSize = pageSize;
            Items = new Test[TotalRecordCount];
        }

        public List<Test> GetData()
        {
            Stopwatch time = new Stopwatch();
            time.Start();
            List<Test> data = null;
            using (SqlConnection conn = new SqlConnection(ConnectionString))
            {
                string sql = @"SELECT Count(1) FROM [test4].[dbo].[test]";
                using (SqlCommand cmd = new SqlCommand(sql, conn))
                {
                    try
                    {
                        conn.Open();
                        int recordsCount = int.Parse(cmd.ExecuteScalar().ToString());
                        data = new List<Test>(recordsCount);
                        sql = @"SELECT [Id],[FirstName],[LastName],[BirthYear],[ChildernAmount] FROM [test4].[dbo].[test]";
                        cmd.CommandText = sql;
                        using (SqlDataReader dr = cmd.ExecuteReader())
                        {
                            while (dr.Read())
                            {
                                Test t = new Test();
                                t.Id = int.Parse(dr["Id"].ToString());
                                t.FirstName = dr["FirstName"].ToString();
                                t.LastName = dr["LastName"].ToString();
                                t.BirthYear = int.Parse(dr["BirthYear"].ToString());
                                t.ChildernAmount = int.Parse(dr["ChildernAmount"].ToString());
                                data.Add(t);
                            }
                            dr.Close();
                        }
                    }
                    catch
                    {

                    }
                    finally
                    {
                        conn.Close();
                    }
                }
            }
            time.Stop();
            Console.WriteLine(time.Elapsed.Seconds + " " + time.Elapsed.Milliseconds);

            return data;
        }

        public int RowCount()
        {
            int count = 0;
            using (SqlConnection conn = new SqlConnection(ConnectionString))
            {
                string sql = @"SELECT Count(1) FROM [test4].[dbo].[test]";
                using (SqlCommand cmd = new SqlCommand(sql, conn))
                {
                    try
                    {
                        conn.Open();
                        count = int.Parse(cmd.ExecuteScalar().ToString());
                    }
                    catch
                    {

                    }
                    finally
                    {
                        conn.Close();
                    }
                }
            }
            return count;
        }

        public void LoadData(int rowNumber)
        {
            ++rowNumber;
            currentPage = rowNumber / PageSize;
            if (currentPage > loadedPages && currentPage <= lastPage)
            {
                loadedPages = currentPage;
                GetNextPage();
            }
        }

        public void GetNextPage()
        {
            int startIndex = currentPage * PageSize;
            int endIndex = startIndex + PageSize;
            using (SqlConnection conn = new SqlConnection(ConnectionString))
            {
                string sql = @"SELECT [Id],[FirstName],[LastName],[BirthYear],
                                    [ChildernAmount] FROM [test4].[dbo].[test]
                                WHERE Id BETWEEN @startIndex AND @endIndex";
                using (SqlCommand cmd = new SqlCommand(sql, conn))
                {
                    try
                    {
                        cmd.Parameters.AddWithValue("@startIndex", startIndex);
                        cmd.Parameters.AddWithValue("@endIndex", endIndex);
                        conn.Open();
                        using (SqlDataReader dr = cmd.ExecuteReader())
                        {
                            while (dr.Read())
                            {
                                Test t = new Test();
                                t.Id = int.Parse(dr["Id"].ToString());
                                t.FirstName = dr["FirstName"].ToString();
                                t.LastName = dr["LastName"].ToString();
                                t.BirthYear = int.Parse(dr["BirthYear"].ToString());
                                t.ChildernAmount = int.Parse(dr["ChildernAmount"].ToString());
                                this.Items[startIndex] = t;
                                startIndex++;
                            }
                            dr.Close();
                        }
                    }
                    catch
                    {

                    }
                    finally
                    {
                        conn.Close();
                    }
                }
            }
        }
    }
}

Zasada działania jest prosta. Ustalamy na początku ile rekordów jest w bazie, wielkość bufora wczytywania (domyślnie 500 elementów). Następnie wyliczamy ile będzie wszystkich stron. Jeżeli indeks wiersza który chce otrzymać użytkownik przekracza aktualnie wczytanych, wtedy wczytujemy następną porcję danych. Na formie umieszczamy następujący kod:


Code:
TestTable table = new TestTable();

        public Form1()
        {
            InitializeComponent();
            dataGridView1.RowCount = table.RowCount();
            dataGridView1.ColumnCount = 5;
        }

        private void button1_Click(object sender, EventArgs e)
        {
            List<Test> tab = table.GetData();
            dataGridView1.DataSource = tab;
        }

        private void dataGridView1_CellValueNeeded(object sender, DataGridViewCellValueEventArgs e)
        {
            table.LoadData(e.RowIndex);
            switch (e.ColumnIndex)
            {
                case 0:
                    e.Value = table.Items[e.RowIndex].Id;
                    break;
                case 1:
                    e.Value = table.Items[e.RowIndex].FirstName;
                    break;
                case 2:
                    e.Value = table.Items[e.RowIndex].LastName;
                    break;
                case 3:
                    e.Value = table.Items[e.RowIndex].BirthYear;
                    break;
                case 4:
                    e.Value = table.Items[e.RowIndex].ChildernAmount;
                    break;
            }
        }

Tutaj obsługujemy zdarzenie CellValueNeeded, czyli podajemy dane dla komórki która potrzebuje aktualnie dane (pamiętać należy, że zdarzenie to wywołuje się dla każdej komórki z osobna). Oczywiście aby wszystko działało należy ustawić parametry ilości kolumn w DataGridView oraz ilość całkowitą wierszy (na tej podstawie DataGridView wygeneruje odpowiednią ilość pustych wierszy które następnie będzie zapełniać).

Efektem pracy będzie grid pozwalający na załadowanie 6 mln rekordów bez bardziej widocznego spowolnienia:


Oczywiście należy pamiętać, że nie jest to jedyny a na pewno nie najlepszy sposób rozwiązania problemu dużej ilości informacji. 6 mld rekordów w tabeli nie jest powalającą liczbą. Istnieją systemy posiadające w swoich tabelach miliardy rekordów. Załadowanie takiej ilości danych może jedynie skonsumować ogromną ilość GB ramu.
Aby zapobiec temu zjawisku należy zastosować inną metodę pobierania danych do wyświetlania. Inne metody które można zaimplementować do wyświetlania danych w gridzie to:
- pobieranie i wyświetlanie tylko części danych i przeładowywanie źródła ciągle.
- filtrowanie danych - wyświetlanie tylko części danych np. z ostatniego tygodnia

Danych z dnia na dzień przybywa, odpowiednia ich obróbka oraz przechowywanie to z pewnością jedno z największych wyzwań obecnych czasów.

2 komentarze:

  1. Super, właśnie tego szukałem.

    OdpowiedzUsuń
  2. Dziękuję za ten artykuł. Jeszcze jedno pytanie - czy można jakość filtrować taką listę ?

    OdpowiedzUsuń