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.

niedziela, 15 maja 2011

Własna implementacja MembershipProvider (Custom MembershipProvider)

We wcześniejszym poście wspominałem w jaki sposób korzystać z MembershipProvidera. Provider ten oferuje możliwość korzystania z autentyfikacji przy użyciu kontrolek ASP.NET. O ile dobrze jest umieć wykorzystać ten mechanizm w formie takiej jakiej jest często przydaje się możliwość ingerowania w niego i stworzenia własnego Providera.

Jakie możliwości daje nam własny provider? Otóż daje przede wszystkim wszechstronność i elastyczność w użyciu:
- nadaje się świetnie tam gdzie, mamy schemat już gotowy i chcemy wykorzystać istniejącą strukturę tabel
- nie podoba się nam koncepcja Microsoftu w kwestii pól w tabeli użytkowników
- chcemy zmienić sposób działania mechanizmu 
- mamy zamiar stworzyć Providera dla innego systemu bazodanowego niż MS SQL (choć do wielu popularnych silników bazodanowych są już stworzone providery)

Na początek przyjrzyjmy się przykładowej strukturze tabeli:


Taki schemat może istnieć w np. już istniejącym projekcie do którego chcemy dołożyć możliwość korzystania z zalet MembershipProvidera.

Na początek stworzymy klasę MyUser dziedziczącą po klasie MembershipUser. Klasa ta będzie implementować dodatkowe pola, który znajdują się w tabeli naszej bazy danych. Implementacja jest bardzo prosta:


Code:
public class MyUser : MembershipUser
    {
        private string firstName;

        public string FirstName
        {
            get { return firstName; }
            set { firstName = value; }
        }

        private string lastName;

        public string LastName
        {
            get { return lastName; }
            set { lastName = value; }
        }

        private DateTime birthDate;

        public DateTime BirthDate
        {
            get { return birthDate; }
            set { birthDate = value; }
        }

        private int idUser;

        public int IdUser
        {
            get { return idUser; }
            set { idUser = value; }
        }


        public MyUser(string providerName, string name, object providerUserKey, 
            string email, string passwordQuestion, string comment, bool isApproved, 
            bool isLockedOut, DateTime creationDate, DateTime lastLoginDate, 
            DateTime lastActivityDate, DateTime lastPasswordChangedDate, 
            DateTime lastLockoutDate, int idUser, string firstName, string lastName,
            DateTime birthDate)
            : base(providerName, name, providerUserKey, email, passwordQuestion, 
            comment, isApproved, isLockedOut, creationDate, lastLoginDate, 
            lastActivityDate, lastPasswordChangedDate, lastLockoutDate)
        {
            FirstName = firstName;
            LastName = lastName;
            BirthDate = birthDate;
        }
    }

Mając nową implementację klasy MembershipUser możemy jej użyć podczas implementacji MembershipProvider-a.

Aby zaimplementować własnego MembershipProvidera który używa innej implementacji MembershipUsera niż standardowa należy w klasie dziedziczącej po MembershipProvider wykonać następujące trzy czynności:
1. W metodach (pierwsza przyjmuje jako argumenty string i bool, druga object i bool) GetUser zwracamy instancję naszej klasy MembershipUser (oczywiście metoda nadal musi zwracać jako typ MembershipUser - ale oczywiście każdy zna zasady dziedziczenia i raczej rozumie o co chodzi w tym)
2. UpdateUser - w tej metodzie przyjmujemy obiekt typu MembershipUser. Obiekt ten musimy rzutować na nową implementację naszego MembershipUser.
3. CreateUser - metoda ta to jedna z najważniejszych metod. Pozwala na utworzenie nowego użytkownika w naszej aplikacji. Nasza klasa dziedzicząca po MembershipProvider musi posiadać podstawową wersję metody CreateUser. Aby utworzyć użytkownika z nowymi parametrami musimy stworzyć przeładowanie tej metody zawierające dodatkowe pola. Oprócz tego jako wynik działania klasy zwracamy nasz nowy typ użytkownika.

Tak więc moja implementacja powyższych metod przedstawia się następująco:


Code:
public class MyProvider : MembershipProvider
    {
        private string name = "app";
        private string connectionString = ConfigurationManager.ConnectionStrings[1].ConnectionString;

        public override string ApplicationName
        {
            get
            {
                return name;
            }
            set
            {
                name = value;
            }
        }

        public override bool ChangePassword(string username, string oldPassword, string newPassword)
        {
            throw new NotImplementedException();
        }

        public override bool ChangePasswordQuestionAndAnswer(string username, string password, string newPasswordQuestion, string newPasswordAnswer)
        {
            throw new NotImplementedException();
        }

        public override MembershipUser CreateUser(string username, string password, string email, string passwordQuestion, string passwordAnswer, bool isApproved, object providerUserKey, out MembershipCreateStatus status)
        {
            ValidatePasswordEventArgs args = new ValidatePasswordEventArgs(username, password, true);
            OnValidatingPassword(args);
            if (args.Cancel)
            {
                status = MembershipCreateStatus.InvalidPassword;
                return null;
            }

            if (RequiresUniqueEmail && GetUserNameByEmail(email) != "")
            {
                status = MembershipCreateStatus.DuplicateEmail;
                return null;
            }
            MembershipUser user = GetUser(username, false);
            if (user == null)
            {
                DateTime createDate = DateTime.Now;
                using (SqlConnection conn = new SqlConnection(connectionString))
                {
                    using (SqlCommand cmd = new SqlCommand())
                    {
                        byte[] clearPassword = Encoding.ASCII.GetBytes(password);
                        byte[] encryptetPassword = EncryptPassword(clearPassword);
                        cmd.CommandText = @"INSERT INTO Users(UserName, UserNameLowerCase, Password, PasswordSalt, FirstName, LastName, BirthDate, Email)
                                            VALUES(@UserName, @UserNameLowerCase, @Password, @PasswordSalt, @FirstName, @LastName, @BirthDate, @Email)";
                        cmd.Parameters.AddWithValue("@UserName", username);
                        cmd.Parameters.AddWithValue("@UserNameLowerCase", username.ToLower());
                        cmd.Parameters.AddWithValue("@Password", Convert.ToBase64String(encryptetPassword));
                        cmd.Parameters.AddWithValue("@PasswordSalt", password);
                        cmd.Parameters.AddWithValue("@Email", email);
                        try
                        {
                            cmd.Connection = conn;
                            conn.Open();
                            int iRecAdded = cmd.ExecuteNonQuery();
                            if (iRecAdded > 0)
                            {
                                status = MembershipCreateStatus.Success;
                            }
                            else
                            {
                                status = MembershipCreateStatus.UserRejected;
                            }
                        }
                        catch (Exception ex)
                        {
                            status = MembershipCreateStatus.UserRejected;
                        }
                        finally
                        {
                            conn.Close();
                        }
                        return GetUser(username, false);
                    }
                }

            }
            else
            {
                status = MembershipCreateStatus.DuplicateUserName;
            }
            return null;
        }

        public MyUser CreateUser(string username, string password, string email, string passwordQuestion, string passwordAnswer, bool isApproved, object providerUserKey, out MembershipCreateStatus status, string firstName, string lastName, DateTime birthDate)
        {
            ValidatePasswordEventArgs args = new ValidatePasswordEventArgs(username, password, true);
            OnValidatingPassword(args);
            if (args.Cancel)
            {
                status = MembershipCreateStatus.InvalidPassword;
                return null;
            }

            if (RequiresUniqueEmail && GetUserNameByEmail(email) != "")
            {
                status = MembershipCreateStatus.DuplicateEmail;
                return null;
            }
            MembershipUser user = GetUser(username, false);
            if (user == null)
            {
                DateTime createDate = DateTime.Now;
                using (SqlConnection conn = new SqlConnection(connectionString))
                {
                    using (SqlCommand cmd = new SqlCommand())
                    {
                        byte[] clearPassword = Encoding.ASCII.GetBytes(password);
                        byte[] encryptetPassword = EncryptPassword(clearPassword);
                        cmd.CommandText = @"INSERT INTO Users(UserName, UserNameLowerCase, Password, PasswordSalt, FirstName, LastName, BirthDate, Email)
                                            VALUES(@UserName, @UserNameLowerCase, @Password, @PasswordSalt, @FirstName, @LastName, @BirthDate, @Email)";
                        cmd.Parameters.AddWithValue("@UserName", username);
                        cmd.Parameters.AddWithValue("@UserNameLowerCase", username.ToLower());
                        cmd.Parameters.AddWithValue("@Password", Convert.ToBase64String(encryptetPassword));
                        cmd.Parameters.AddWithValue("@PasswordSalt", password);
                        cmd.Parameters.AddWithValue("@FirstName", firstName);
                        cmd.Parameters.AddWithValue("@BirthDate", birthDate);
                        cmd.Parameters.AddWithValue("@Email", email);
                        try
                        {
                            cmd.Connection = conn;
                            conn.Open();
                            int iRecAdded = cmd.ExecuteNonQuery();
                            if (iRecAdded > 0)
                            {
                                status = MembershipCreateStatus.Success;
                            }
                            else
                            {
                                status = MembershipCreateStatus.UserRejected;
                            }
                        }
                        catch (Exception ex)
                        {
                            status = MembershipCreateStatus.UserRejected;
                        }
                        finally
                        {
                            conn.Close();
                        }
                        return (MyUser)GetUser(username, false);
                    }
                }
            }
            else
            {
                status = MembershipCreateStatus.DuplicateUserName;
            }
            return null;
        }

        public override bool DeleteUser(string username, bool deleteAllRelatedData)
        {
            throw new NotImplementedException();
        }

        public override bool EnablePasswordReset
        {
            get { throw new NotImplementedException(); }
        }

        public override bool EnablePasswordRetrieval
        {
            get { throw new NotImplementedException(); }
        }

        public override MembershipUserCollection FindUsersByEmail(string emailToMatch, int pageIndex, int pageSize, out int totalRecords)
        {
            throw new NotImplementedException();
        }

        public override MembershipUserCollection FindUsersByName(string usernameToMatch, int pageIndex, int pageSize, out int totalRecords)
        {
            throw new NotImplementedException();
        }

        public override MembershipUserCollection GetAllUsers(int pageIndex, int pageSize, out int totalRecords)
        {
            throw new NotImplementedException();
        }

        public override int GetNumberOfUsersOnline()
        {
            throw new NotImplementedException();
        }

        public override string GetPassword(string username, string answer)
        {
            throw new NotImplementedException();
        }

        public override MembershipUser GetUser(string username, bool userIsOnline)
        {
            MyUser user = null;
            SqlDataReader reader = null;
            using (SqlConnection conn = new SqlConnection(connectionString))
            {
                using (SqlCommand cmd = new SqlCommand())
                {
                    cmd.CommandText = @"SELECT IdUser, UserName, UserNameLowerCase, Password, PasswordSalt, FirstName, LastName, BirthDate
                                        FROM Users 
                                        WHERE UserNameLowerCase == @UserNameLowerCase";
                    cmd.Connection = conn;
                    cmd.Parameters.AddWithValue("@UserNameLowerCase", username);
                    try
                    {
                        conn.Open();
                        reader = cmd.ExecuteReader();
                        if (reader.HasRows)
                        {
                            reader.Read();
                            user = GetUserFromReader(reader);
                        }

                    }
                    catch (Exception e)
                    {
                    }
                    finally
                    {
                        if (reader != null)
                        {
                            reader.Close();
                        }
                        conn.Close();
                    }
                    return user;
                }
            }
        }

        public override MembershipUser GetUser(object providerUserKey, bool userIsOnline)
        {
            throw new NotImplementedException();
        }

        public override string GetUserNameByEmail(string email)
        {
            throw new NotImplementedException();
        }

        public override int MaxInvalidPasswordAttempts
        {
            get { throw new NotImplementedException(); }
        }

        public override int MinRequiredNonAlphanumericCharacters
        {
            get { throw new NotImplementedException(); }
        }

        public override int MinRequiredPasswordLength
        {
            get { throw new NotImplementedException(); }
        }

        public override int PasswordAttemptWindow
        {
            get { throw new NotImplementedException(); }
        }

        public override MembershipPasswordFormat PasswordFormat
        {
            get { throw new NotImplementedException(); }
        }

        public override string PasswordStrengthRegularExpression
        {
            get { throw new NotImplementedException(); }
        }

        public override bool RequiresQuestionAndAnswer
        {
            get { throw new NotImplementedException(); }
        }

        public override bool RequiresUniqueEmail
        {
            get { throw new NotImplementedException(); }
        }

        public override string ResetPassword(string username, string answer)
        {
            throw new NotImplementedException();
        }

        public override bool UnlockUser(string userName)
        {
            throw new NotImplementedException();
        }

        public override void UpdateUser(MembershipUser user)
        {
            using (SqlConnection conn = new SqlConnection(connectionString))
            {
                using (SqlCommand cmd = new SqlCommand())
                {
                    MyUser myUser = user as MyUser;
                    if (myUser != null)
                    {
                        cmd.Connection = conn;
                        cmd.CommandText = @"UPDATE Users SET FirstName = @FirstName, LastName = @LastName, BirthDate = @BirthDate
                                        WHERE UserNameLowerCase = @UserNameLowerCase";
                        cmd.Parameters.AddWithValue("@FirstName", myUser.FirstName);
                        cmd.Parameters.AddWithValue("@LastName", myUser.LastName);
                        cmd.Parameters.AddWithValue("@BirthDate", myUser.BirthDate);
                        cmd.Parameters.AddWithValue("@UserNameLowerCase", myUser.UserName.ToLower());
                        try
                        {
                            cmd.ExecuteNonQuery();
                        }
                        catch (Exception ex)
                        {
                        }
                        finally
                        {
                            conn.Close();
                        }
                    }
                }
            }
        }

        public override bool ValidateUser(string username, string password)
        {
            throw new NotImplementedException();
        }

        private MyUser GetUserFromReader(SqlDataReader reader)
        {
            string username = reader["UserName"].ToString();
            string email = reader["Email"].ToString();
            int idUser = int.Parse(reader["IdUser"].ToString());
            string firstName = reader["FirstName"].ToString();
            string lastName = reader["LastName"].ToString();
            DateTime birthDate = DateTime.MinValue;
            if (reader["BirthDate"] != DBNull.Value)
            {
                birthDate = (DateTime)reader["BirthDate"];
            }
            MyUser u = new MyUser("", username, null, email, "", "", false, false, DateTime.MinValue, DateTime.MinValue, DateTime.MinValue,
                DateTime.MinValue, DateTime.MinValue, idUser, firstName, lastName, birthDate);
            return u;
        }
    }


Część metod jak widać nie jest zaimplementowana - nie wszystko jest wymagane. Jeżeli któraś jest nam potrzebna - implementujemy ją. W przeciwnym razie nie ma potrzeby implementowania czegoś, co nie będzie używane.

Kolejnym etapem jest dodanie Providera do Web.configa:


Code:
<?xml version="1.0"?>

<!--
  For more information on how to configure your ASP.NET application, please visit
  http://go.microsoft.com/fwlink/?LinkId=169433
  -->

<configuration>
  <connectionStrings>
    <clear/>
    <add name="Db" providerName="System.Data.SqlClient" connectionString="Data Source=localhost;Initial Catalog=test2;Integrated Security=True"/>
  </connectionStrings>
    <system.web>
        <compilation debug="true" targetFramework="4.0" />
        <membership defaultProvider="MyProvider" userIsOnlineTimeWindow="15">
          <providers>
            <clear/>
            <add name="MyProvider" 
              type="WebApplication7.MyProvider"
              enablePasswordRetrieval="false"
              enablePasswordReset="false"
              requiresQuestionAndAnswer="false"
              applicationName="/"
              requiresUniqueEmail="false"
              passwordFormat="Clear"
              description="Stores and retrieves membership data from SQL Server"
              decryptionKey="68d288624f967bce6d93957b5341f931f73d25fef798ba75"
              validationKey="65a31e547b659a6e35fdc029de3acce43f8914cb1b2
                         4fff3e1aef13be438505b3f5becb5702d15bc7b98cd
                         6fd2b7702b46ff63fdc9ea8979f6508c82638b129a"
          />
          </providers>
        </membership>
      <machineKey
validationKey= 
"ADD36F43434EFE7750E6E56E3636D33A4D36356FA3A9190691A5BAA6FC5B6FD676C08D82A617ACEFBBCDCF6FC2B14955A66D83E2D1E13E92E617587D8B348355"
decryptionKey=
 "25AB4BB356AFF0E1690F87F824540B03D5EF7903B4134521DAAFB61B29889BF1"
validation="SHA1"
decryption="AES"/>
    </system.web>

</configuration>

Teraz już możemy wypróbować stworzony przez nas provider:


Code:
MyProvider user = (MyProvider)Membership.Provider;
            MembershipCreateStatus status;
            user.CreateUser(TextBox1.Text, TextBox2.Text, TextBox3.Text, "", "", false, null, out status,
                "Patryk", "Osowski", DateTime.Now);

Po wywołaniu kodu do bazy zostanie dodany nowy użytkownik.

W taki oto sposób utworzyliśmy kompletny MembershipProvider który możemy wykorzystać w naszym projekcie.

WebParts

WebParts to jeden z ciekawszych mechanizmów dostępnych w ASP.NET WebParts. Pozwala na uzyskanie bardzo ciekawych efektów podczas projektowania interfejsu użytkownika. Dodatkowo daje możliwość dostosowania go przez samego użytkownika.

Co można zaliczyć do WebPartsów? Ilość możliwości wykorzystania ich przy budowie strony zależy tylko od naszej wyobraźni. Niektóre z możliwych zastosowań:
- lista ostatnio dodanych artykułów na stronie
- kalendarz wyświetlający nadchodzące zdarzenia
- lista linków do ciekawych stron
- wyszukiwarka informacji na naszej stronie
- podgląd np. dwóch losowych zdjęć z naszej galerii
- prognoza pogody na nadchodzący dzień
- kursy walut
- notowania giełdowe
oraz wiele innych.

Aby korzystać z dobrodziejstw WebPartsów należałoby się najpierw przyjrzeć strukturze tworzącej szkielet mechanizmu:


WebPartManager - kontrolka wymagana na każdej stronie, która zawiera elementy WebParts. Kontrolka ta jest kontrolką niewizualną, tzn. nie pojawia się jako komponent który widać na stronie. Jej zadaniem jest udostępnienie metod oraz zapewnienie interfejsu działania dla WebPartów.

WebPart - klasa bazowa dla wszystkich WebPartów. Dziedzicząc po niej będziemy tworzyli nasze komponenty.

CatalogPart - zapewnia wizualną część nadzorowania komponentów na stronie. Najczęściej ograniczona do całej witrynie a nie pojedynczej strony.

PageCatalogPart - w działaniu komponent podobny do CatalogPart. Zadaniem kontrolki jest grupowanie komponentów przypisanych do pojedynczej strony (nie całej witryny). Dzięki kontrolce, jeżeli użytkownik zamknie jakiś WebPart, może go przywrócić na innej.

EditorPart - pozwala na modyfikowanie właściwości WebParta

DeclarativeCatalogPart - katalog przechowujący możliwe do dodania komponenty na stronę (lub witrynę)

WebPartZone - wyznacza miejsce gdzie można położyć WebParta

EditorZone - wyznacza miejsce gdzie można umieścić EditorPart

CatalogZone - wyznacza miejsce gdzie można umieścić kontrolkę CatalogPart

Aby można było używać WebPartsów wymagane jest zapisanie informacji o nich w bazie danych. Ma to główną zaletę w postaci zapamiętania układu WebPartsów użytych przez konkretnego użytkownika. Informacje te są standardowo zapisywane w bazie ASPNETDB. Możemy jednak w łatwy sposób przenieść zapis na inną bazę danych.



Tworzenie własnego WebParta
Własny WebPart możemy stworzyć na 3 sposoby:
1. Poprzez stworzenie kontrolki użytkownika - User Control
2. Dziedziczenie po istniejącej kontrolce (np. Labelu czy TextBoxie) 
3. Stworzenie własnej kontrolki Custom Control

Największe możliwości oferuje oczywiście 3 metoda. Jest ona jednak najbardziej pracochłonna. Postaram się więc omówić dwie pierwsze metody. Ostatnią możliwość stosujemy podobnie jak dwie pozostałe a nt. tworzenia własnych kontrolek pisałem już wcześniej artykuł na blogu. A więc zacznijmy.
Rozpoczynamy od stworzenia nowego projektu w Visual Studio. Następnie na formatkę kładziemy komponent WebPartManager. Nie ma on wizualnej postaci dla użytkownika, ale jak pisałem jest wymagany dla każdej strony korzystającej z WebPartsów:






Code:
<%@ Page Title="Home Page" Language="C#" MasterPageFile="~/Site.master" AutoEventWireup="true"
    CodeBehind="Default.aspx.cs" Inherits="WebApplication5._Default" %>

<asp:Content ID="HeaderContent" runat="server" ContentPlaceHolderID="HeadContent">
</asp:Content>
<asp:Content ID="BodyContent" runat="server" ContentPlaceHolderID="MainContent">
    <asp:WebPartManager ID="WebPartManager1" runat="server">

    </asp:WebPartManager>
    <h2>
        Welcome to ASP.NET!
    </h2>
    <p>
        To learn more about ASP.NET visit <a href="http://www.asp.net" title="ASP.NET Website">www.asp.net</a>.
    </p>
    <p>
        You can also find <a href="http://go.microsoft.com/fwlink/?LinkID=152368&amp;clcid=0x409"
            title="MSDN ASP.NET Docs">documentation on ASP.NET at MSDN</a>.
    </p>
    
</asp:Content>

Kolejnymi elementami będą odpowiednie strefy do których użytkownik (jak i my) będziemy mogli dodawać WebParty.
Następnie na formę kładziemy WebPartZone czyli kontrolkę która służy do umieszczania w niej WebParta:


Code:
<%@ Page Title="Home Page" Language="C#" MasterPageFile="~/Site.master" AutoEventWireup="true"
    CodeBehind="Default.aspx.cs" Inherits="WebApplication5._Default" %>

<asp:Content ID="HeaderContent" runat="server" ContentPlaceHolderID="HeadContent">
</asp:Content>
<asp:Content ID="BodyContent" runat="server" ContentPlaceHolderID="MainContent">
    <asp:WebPartManager ID="WebPartManager1" runat="server">

    </asp:WebPartManager>
    <h2>
        Welcome to My Page!
    </h2>
    <asp:WebPartZone ID="WebPartZone1" runat="server" Width="700" Height="500">
    </asp:WebPartZone>
    
</asp:Content>

Po skompilowaniu i uruchomieniu strony możemy otrzymać taki nieprzyjemny widok:



Jest to spowodowane brakiem zainstalowanego SQL Servera w wersji Express na naszej maszynie. Jeżeli tak jak i ja (osoby posiadające SQL Server Express podczas pierwszego uruchomienia mają udogodnienie w postaci automatycznego stworzenia pliku bazy danych) posiadacie inną wersję niż Express (np. Enterprise) należy wcześniej przygotować bazę danych.
W tym celu tworzymy nową pustą bazę danych:






Po stworzeniu bazy należy zintegrować ją z prowiderem odpowiedzialnym za presonalizację. Zrobić możemy to na dwa sposoby: za pomocą narzędzia graficznego jak i tekstowego. Narzędzie tekstowe daje dużo większe możliwości więc użyję go. Samego narzędzia nie będę omawiać, gdyż myślę iż już wystarczająco napisałem o nim w tym artykule. Samo wywołanie tworzące odpowiednią strukturę będzie miało następującą formę:


Następnie w pliku webconfig odpowiednio konfigurujemy sekcję odpowiedzialną za połączenie z bazą danych oraz WebPartami:



Code:
<?xml version="1.0"?>

<!--
  For more information on how to configure your ASP.NET application, please visit
  http://go.microsoft.com/fwlink/?LinkId=169433
  -->

<configuration>
  <connectionStrings>
      <add name="SQLConnString" connectionString="Data Source=localhost;Initial Catalog=test;Integrated Security=True" providerName="System.Data.SqlClient" />
  </connectionStrings>

  <system.web>
    <compilation debug="true" targetFramework="4.0" />

    <authentication mode="Forms">
      <forms loginUrl="~/Account/Login.aspx" timeout="2880" />
    </authentication>

    <membership>
      <providers>
        <clear/>
        <add name="AspNetSqlMembershipProvider" type="System.Web.Security.SqlMembershipProvider" connectionStringName="ApplicationServices"
             enablePasswordRetrieval="false" enablePasswordReset="true" requiresQuestionAndAnswer="false" requiresUniqueEmail="false"
             maxInvalidPasswordAttempts="5" minRequiredPasswordLength="6" minRequiredNonalphanumericCharacters="0" passwordAttemptWindow="10"
             applicationName="/" />
      </providers>
    </membership>

    <profile>
      <providers>
        <clear/>
        <add name="AspNetSqlProfileProvider" type="System.Web.Profile.SqlProfileProvider" connectionStringName="ApplicationServices" applicationName="/"/>
      </providers>
    </profile>

    <roleManager enabled="false">
      <providers>
        <clear/>
        <add name="AspNetSqlRoleProvider" type="System.Web.Security.SqlRoleProvider" connectionStringName="ApplicationServices" applicationName="/" />
        <add name="AspNetWindowsTokenRoleProvider" type="System.Web.Security.WindowsTokenRoleProvider" applicationName="/" />
      </providers>
    </roleManager>

    <webParts>
      <personalization defaultProvider="SqlPersonalizationProvider">
        <providers>
          <add name="SqlPersonalizationProvider" type="System.Web.UI.WebControls.WebParts.SqlPersonalizationProvider"
             connectionStringName="SQLConnString" applicationName="/" />
        </providers>
        <authorization>
          <deny users="*" verbs="enterSharedScope" />
          <allow users="*" verbs="modifyState" />
        </authorization>
      </personalization>
    </webParts>
  </system.web>
  
  <system.webServer>
     <modules runAllManagedModulesForAllRequests="true"/>
  </system.webServer>
</configuration>


Teraz po skompilowaniu i uruchomieniu programu powinniśmy zobaczyć:


Przechodzimy do dalszej części tworzenia naszego WebParta.

Tworzymy Web User Control która będzie mieć za zadanie wyświetlanie kalendarza użytkownikowi:










Następnie przeciągamy z Solution nowo stworzoną kontrolkę do strefy:


Następnie uruchamiamy stworzoną aplikację. Po załadowaniu aplikacji widzimy nasz dodatek:


Dodatek ma opcje takie jak możliwość jego minimalizacji oraz zamknięcia. Daje to możliwość użytkownikowi wybrania czy dany komponent chce widzieć czy też nie.

Przedstawione tutaj możliwości to podstawy tworzenia WebPartsów. Istnieje możliwość tworzenia kolekcji WebPartsów z których użytkownik wybierze te, które mu odpowiadają, przenoszenia komponentów pomiędzy odpowiednio wyznaczonymi obszarami strony, jak i możliwość dodawania nowych WebPartsów przez samych użytkowników.

Wilojęzyczność w ASP.NET MVC

Kiedyś pisałem o globalizacji w ASP.NET WebForms (link). Jako, że ostatnimi czasy bardziej interesuje się MVC postanowiłem przenieść tamtą idę do nowego środowiska.

Na początek ustawmy środowisko testowe naszej aplikacji.
Jako przeglądarki użyję IE9. Aby zmienić w niej wyświetlany język wybieramy kolejno opcje internetowe a następnie na zakładce Ogólne w dziale Wygląd opcje Języki. Ja dodałem tutaj język angielski:



Należy pamiętać, że kolejność języków ma znaczenie. Najwyżej powinien się znajdować język preferowany lub jeżeli użytkownik nie ustawił innego, język który jest używany lokalnie w danym rejonie.

Aby odczytać język używany przez przeglądarkę użytkownika wystarczy zastosować prostą konstrukcję:


Code:
string[] languages = Request.UserLanguages;

Warto zauważyć tutaj ciekawą rzecz a mianowicie:


W niektórych przypadkach otrzymujemy tzw. kwalifikator (q=0.5) tak więc próbując przesłać takie dane np. do konstruktora CultureInfo() otrzymamy wyjątek. Dlatego powyższa linijka powinna wyglądać w następujący sposób:


Code:
string mainLanguage = Request.UserLanguages[0].Split(';')[0];

Mając informacje odnośnie preferowanego przez użytkownika języka, możemy przystąpić do projektowania interfejsu użytkownika.

Wielojęzyczność można osiągnąć na kilka sposobów. W zależności czy mamy do czynienia z statyczną stroną w której treść się nie zmienia czy też jest to strona na której często dodajemy newsy wybierzemy odpowiedni sposób. Jeżeli mamy do czynienia z stroną statyczną możemy skorzystać z resourców (zasobów). Jeżeli korzystamy ze strony na której często zmienia się treść (np. blogu) w takim przypadku należy przygotować odpowiednią strukturę w bazie danych, która pozwoli na przechowywanie artykułów w kilku językach.

Przedstawię tutaj prostszą wersję nadającą się świetnie dla prostych, statycznych stron.

W celach demonstracyjnych stworzymy prosty projekt zawierający jedną stronę oraz MasterPage na którym umieścimy powitanie na stronie w odpowiednim języku. Nasz projekt będzie mieć następującą strukturę:


Projekt jest więc bardzo prosty. Aby skorzystać z dobrodziejstw zasobów należy stworzyć strukturę odpowiadającą obecnej strukturze:


Po wypełnieniu wartościami kopiujemy nasz zasób i tworzymy odpowiednie nazwy związane z nazwą języka:


Dla każdego z języków powtarzamy taką samą operację.

Teraz przejdziemy do wyświetlenia odpowiedniej informacji na stronie w odpowiednim języku. W naszym MasterPage-u zmieniamy zawartość z:


Code:
<%@ Master Language="C#" Inherits="System.Web.Mvc.ViewMasterPage" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml" >
<head runat="server">
    <title><asp:ContentPlaceHolder ID="TitleContent" runat="server" /></title>
</head>
<body>
    <div>
        <h1>Witaj na stronie</h1>
    </div>
    <div>
        <asp:ContentPlaceHolder ID="MainContent" runat="server">
        
        </asp:ContentPlaceHolder>
    </div>
</body>
</html>

na:


Code:
<%@ Master Language="C#" Inherits="System.Web.Mvc.ViewMasterPage" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml" >
<head runat="server">
    <title><asp:ContentPlaceHolder ID="TitleContent" runat="server" /></title>
</head>
<body>
    <div>
        <h1><%: MvcApplication3.Resources.Shared.MasterPage.Greatings %></h1>
    </div>
    <div>
        <asp:ContentPlaceHolder ID="MainContent" runat="server">
        
        </asp:ContentPlaceHolder>
    </div>
</body>
</html>

Następnie należy wczytać odpowiedni język ustawiony w przeglądarce użytkownika. W tym celu w pliku Global.asax dodajemy następującą metodę:


Code:
protected void Application_BeginRequest(object sender, EventArgs e)
        {
            if (Request.UserLanguages.Length > 0)
            {
                string language = Request.UserLanguages[0].Split(';')[0];
                if (language.Contains("en"))
                {
                    Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture("en");       
                }
            }
        }

Po tym zabiegu nasza strona w zależności od ustawień przeglądarki użytkownika będzie wyświetlała powitanie w języku polskim bądź też angielskim.
Rozwiązanie nadaje się do wyświetlania stałych elementów strony. Jak wspominałem wcześniej do wyświetlania artykułów należy skorzystać z dobrze zaprojektowanej bazy danych.

Mapa strony w ASP.NET WebForms

Tworząc zaawansowaną witrynę, zawierającą wiele odnośników i form, należy przemyśleć system nawigacji po stronie. Przedzieranie się poprzez wszystkie elementy menu, podmenu itd. może mocno zirytować użytkownika który odwiedza taką stronę.
Każdy nowy użytkownik potrzebuje centralnego miejsca skąd może dotrzeć w łatwy i szybki sposób do zasobów strony. Dodatkowo stworzenie mapy witryny ułatwi pracę robotą wyszukiwarek internetowych dzięki czemu nasza strona znajdzie się wyżej w wynikach wyszukiwarek
Dla naszej wygody ASP.NET WebForms umożliwia wykonanie tej czynności w szybki i łatwy sposób.
Microsoft dla takich celów przygotował mechanizm zwany Site Map. Mapa strony zawiera hierarchiczną strukturę całej witryny. Dane pobierane są z pliku XML.

Stwórzmy więc prosty projekt, który będzie zawierać odgałęzienia, treść jest w tym momencie nie ważna. Moja przykładowa struktura będzie wyglądała następująco:


Dodajemy więc plik Mapy strony:


W środku pliku standardowo znajduje się poniższa treść:


Code:
<?xml version="1.0" encoding="utf-8" ?>
<siteMap xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0" >
    <siteMapNode url="" title=""  description="">
        <siteMapNode url="" title=""  description="" />
        <siteMapNode url="" title=""  description="" />
    </siteMapNode>
</siteMap>

Kolejne elementy mapy definiowane są przez składnię:


Code:
<siteMapNode url="" title=""  description="" />

gdzie:
title - treść wyświetlana użytkownikowi
url - definiuje adres strony na którą ma przenieść użytkownika
description - nieobowiązkowy opis

Tak więc przykładowa implementacja będzie miała postać:


Code:
<?xml version="1.0" encoding="utf-8" ?>
<siteMap xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0" >
    <siteMapNode url="" title=""  description="">
        <siteMapNode url="Account\ChangePassword.aspx " title="Zmiana hasła"  description="" />
        <siteMapNode url="Account\ChangePasswordSuccess.aspx" title="Pomyślna zmiana hasła"  description="" />
        <siteMapNode url="Account\Login.aspx" title="Logowanie" description="" />
        <siteMapNode url="Account\Register.aspx" title="Rejestracja" description="" />
        <siteMapNode url="About.aspx" title="O stronie" description="" />
        <siteMapNode url="Categories.aspx" title="Kategorie" description="" />
        <siteMapNode url="Products.aspx" title="Produkty" description="" />
    </siteMapNode>
</siteMap>

Mając plik XML należy przypisać jego zawartość jakiejś kontrolce. Tutaj Microsoft także postanowił ułatwić sprawę i stworzył odpowiednie kontrolki do bindowania treści mapy strony.
Aby wyświetlić dane z pliku XML musimy użyć kontrolki bindującej SiteMapDataSource:


Code:
<asp:SiteMapDataSource ID="SiteMapDataSource1" runat="server" />

Następnie dane możemy wyświetlić w jednej z kontrolek: Menu, TreeView oraz SiteMapPath. Ja użyję kontrolki Menu:


Kod stojący za wyświetlaniem tego menu przedstawia się następująco:


Code:
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="SiteStructure.aspx.cs" Inherits="WebApplication3.SiteStructure" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
</head>
<body>
    <form id="form1" runat="server">
    <div>
        <asp:SiteMapDataSource ID="SiteMapDataSource1" runat="server" ShowStartingNode="true" />
        <asp:Menu ID="Menu1" runat="server" DataSourceID="SiteMapDataSource1" MaximumDynamicDisplayLevels="5">
        </asp:Menu>
    </div>
    </form>
</body>
</html>

W taki oto sposób osiągnęliśmy zamierzony efekt. ASP.NET webforms oferuje wiele ułatwień dla programistów. Umiejętne wykorzystanie ich może zarówno wzbogacić naszą stronę jak i sprawić że będzie bardziej dostępna w internecie.