Сессии в ASP.NET или как создать собственный провайдер

в 11:05, , рубрики: .net, ASP.NET, asp.net mvc 3, sql server, Веб-разработка, метки: , , ,

Сессии в ASP.NET или как создать собственный провайдер

ASP.NET предлагает множество вариантов для работы с сессиями из коробки:

  • Хранение информации о сессиях в памяти сервера, внутри процесса ASP.NET
  • Хранение информации о сессиях на сервере состояний
  • Хранение информации о сессиях в базе данных SQL Server в заранее предопределенной схеме

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

В качестве хранилища сессий будет выступать SQL Server. Работать с базой данных мы будем через EntityFramework.

Оглавление

  1. Почему я пишу эту статью?
  2. Причины реализации собственного провайдера

  3. Кем и как управляются сессии в ASP.NET

  4. Реализация провайдера сессий

  5. Тестирование провайдера
  6. Заключение

Почему я пишу эту статью?

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

Теперь, несколько лет спустя, я решил создать цикл статей не просто для того, чтобы объяснить как это работает, а для того, чтобы показать, что в ASP.NET нет ничего того, что нельзя было бы изменить и сделать по своему.

Причины реализации собственного провайдера

Использование не поддерживаемого хранилища

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

С другой стороны, вы можете захотеть использовать Key-Value хранилище, чтобы повысить производительность распределенного приложения. Или может быть у вас просто у вас есть уже купленные лицензии на продукты других компаний.

Нестандартная схема таблиц базы данных

Эта причина является наиболее частой.

Стандартные возможности — это хорошо, но чем более сложным становится приложение, тем более нестандартных решений оно требует для своей работы.

Пример:
На сайте требуется, сделать возможность закрывать сессии (делать принудительных выход — logout) для конкретных пользователей. Стандартная схема базы данных для SQL Server не поддерживает этой функциональности, так как в сессиях не хранится информация о принадлежности к пользователям.

Кем и как управляются сессии в ASP.NET

За обработку состояния (сессий) отвечает SessionStateModule, он является обработчиком по умолчанию. При желании можно реализовать собственный http-модуль, отвечающий за обработку сессий.

Объект SessionStateModule взаимодействует с провайдером сессий, вызывая определенные методы провайдера в течении своей работы. Какой провайдер сессий использовать модуль определяет на основе конфигурации веб приложения. Любой провайдер сессий должен наследоваться от класса SessionStateStoreProviderBase, который и определяет необходимые для SessionStateModule методы.

Схема работы сессий

Ниже представлена краткая схеме вызова методов провайдера, для того, чтобы лучше понять, как работают сессии в ASP.NET (кликабельна).

Сессии в ASP.NET или как создать собственный провайдер

Рис. Последовательность вызова методов для работы с сессиями

Сначала SessionStateModule определяем режим работы с сессией для данной страницы (ASP.NET WebForms) или контроллера (ASP.NET MVC).

Если для страницы установлен атрибут:
<%@ Page EnableSessionState=«true|false|ReadOnly» %>
(или атрибут SessionState для для ASP.NET MVC)

То работа с сессией будет происходить в режиме Read Only (только для чтения), что несколько повышает общую производительность]. В противном случае модуль SessionStateModule запрашивает эксклюзивный доступ и блокирует содержимое сессии. Блокировка снимается только в завершающей стадии выполнения запроса.

Зачем нужны блокировки сессий?

Приложения ASP.NET — это многопотоковые приложения, а ajax технологии очень популярны. Может сложится ситуация, что к сессии одного и того же пользователя обратится сразу несколько потоков, для того, чтобы избежать конфликтов, перезаписи сохраненных значений или извлечения устаревших значений, используются блокировки.

Блокировки происходят только при обращении к сессии одного и того же пользователя со стороны нескольких потоков.

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

Мы можем реализовать провайдер без поддержки блокировки, если у нас есть причины полагать, что конфликтов между потоками не будет, либо они не приведут к значимым последствиям.

Реализация провайдера сессий

Создание таблицы для хранения данных сессии

Я буду использовать следующую структуру таблицы для хранения данных о состоянии сессий:

Сессии в ASP.NET или как создать собственный провайдер

Она поддерживает блокировки. Так же я добавил поле UserId, для своих нужд, чтобы хранить информацию о пользователе, которому принадлежит сессия (например, чтобы сделать принудительный logout пользователя из панели администратора).

SessionId Уникальная строковая метка, генерируется не нами. Это случайно число, закодированное в строку, сотоящую из букв латинского алфавита и цифр, достигает в длину максимум 24 символа.
Created Время создание сессии.
Expires Время, когда истекает сессия.
LookDate Момент когда была заблокирована сессия.
LookId Номер блокировки сессии.
Looked Есть ли в данным момент блокировка.
ItemContent Содержимое сессии в [wiki label=«сериализация»]сериализованном[/wiki] виде.
UserId Id пользователя к которому относится сессия (у меня гость имеет id = 1)

SQL запрос на создание, описанной выше таблицы (SQL Server):

CREATE TABLE [dbo].[Sessions] (
  [SessionId] varchar(24) COLLATE Cyrillic_General_CI_AS NOT NULL,
  [Created] smalldatetime NOT NULL,
  [Expires] smalldatetime NOT NULL,
  [LockDate] smalldatetime NOT NULL,
  [LockId] int NOT NULL,
  [Locked] bit CONSTRAINT [DF_Sessions_Locked] DEFAULT 0 NOT NULL,
  [ItemContent] varbinary(max) NULL,
  [UserId] int NOT NULL,
  CONSTRAINT [PK_Sessions] PRIMARY KEY CLUSTERED ([SessionId])
)
ON [PRIMARY]
GO

Создание модели данных EntityFramework

Я хочу избавить себя от написания SQL запросов вручную и сэкономить время, поэтому я буду использовать ADO.NET EntityFramework. При этом я потеряю немного в производительности кода, по сравнению с ручным созданием SQL запросов.

Для этого я воспользуюсь мастером ADO.NET Entity Data Model, чтобы создать нужную мне модель.

Сессии в ASP.NET или как создать собственный провайдер
Рис. Выбор мастера ADO.NET Entity Data Model для создания модели данных

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

Сессии в ASP.NET или как создать собственный провайдер
Рис. Выбор меню для применения шаблонов генерации кода

Мне по душе DbContext API, который доступен начиная с 4.1 версии EntityFramework, именно его я и выберу.

Сессии в ASP.NET или как создать собственный провайдер
Рис. Выбор DbContext в качестве шаблона генерации кода

Готово, теперь у меня есть контекст с именем CommonEntities и класс DbSession. Можно приступать к реализации провайдера.

Реализация провайдера

Для начала нам необходимо создать класс, который будет наследоваться от базового класса SessionStateStoreProviderBase.

using QueryHunter.WebDomain.Layouts.Session;

public class SessionStateProvider : SessionStateStoreProviderBase
{
    // ...
}

Далее необходимо реализовать ряд методов, о которых лучше расскажет документация или нижеприведенный код с комментариями:

/// <summary>
/// Наш собственный провайдер.
/// </summary>
public class SessionStateProvider : SessionStateStoreProviderBase
{
    CommonEntities _dataContext;
    int _timeout;

    /// <summary>
    /// Инициализация провайдера, читаем конфигурацию, устаналиваем переменные...
    /// </summary>
    public override void Initialize(string name, NameValueCollection config)
    {
        if (config == null) throw new ArgumentNullException("config");
        base.Initialize(name, config);

        var applicationName = System.Web.Hosting.HostingEnvironment.ApplicationVirtualPath;
        var configuration = WebConfigurationManager.OpenWebConfiguration(applicationName);

        var configSection = (SessionStateSection)configuration.GetSection("system.web/sessionState");
        _timeout = (int)configSection.Timeout.TotalMinutes;

        // Контекст, который мы получили с помощью EntityFramework для удобной работы с базой данных.
        // Здесь можно использовать Dependency Injection для создания объекта и передачи строки подключения.

        _dataContext = new CommonEntities();
    }

    public override void Dispose()
    {
        _dataContext.Dispose();
    }

    /// <summary>
    /// Получаем сессию для режима "только для чтения" без необходимости блокировки.
    /// </summary>
    public override SessionStateStoreData GetItem(HttpContext context, string id, out bool locked, out TimeSpan lockAge, out object lockId, out SessionStateActions actions)
    {
        return GetSessionItem(context, id, false, out locked, out lockAge, out lockId, out actions);
    }

    /// <summary>
    /// Получаем сессию в режиме эксклюзивного доступа с необходимостью блокировки.
    /// </summary>
    public override SessionStateStoreData GetItemExclusive(HttpContext context, string id, out bool locked, out TimeSpan lockAge, out object lockId, out SessionStateActions actions)
    {
        return GetSessionItem(context, id, true, out locked, out lockAge, out lockId, out actions);
    }

    /// <summary>
    /// Обобщенный вспомогательный метод для получения доступа к сессии в базе данных.
    /// Используется как GetItem, так и GetItemExclusive.
    /// </summary>
    private SessionStateStoreData GetSessionItem(HttpContext context, string id, bool exclusive, out bool locked, out TimeSpan lockAge, out object lockId, out SessionStateActions actions)
    {
        locked = false;
        lockAge = new TimeSpan();
        lockId = null;
        actions = 0;

        var sessionItem = _dataContext.DbSessions.Find(id);

        // Сессия не найдена
        if (sessionItem == null) return null;

        // Сессия найдена, но заблокирована
        if (sessionItem.Locked)
        {
            locked = true;
            lockAge = DateTime.UtcNow - sessionItem.LockDate;
            lockId = sessionItem.LockId;
            return null;
        }

        // Сессия найдена, но она истекла
        if (DateTime.UtcNow > sessionItem.Expires)
        {
            _dataContext.Entry(sessionItem).State = EntityState.Deleted;
            _dataContext.SaveChanges();
            return null;
        }

        // Сессия найдена, требуется эксклюзинвый доступ.
        if (exclusive)
        {
            sessionItem.LockId += 1;
            sessionItem.Locked = true;
            sessionItem.LockDate = DateTime.UtcNow;
            _dataContext.SaveChanges();
        }

        locked = exclusive;
        lockAge = DateTime.UtcNow - sessionItem.LockDate;
        lockId = sessionItem.LockId;

        var data = (sessionItem.ItemContent == null)
            ? CreateNewStoreData(context, _timeout)
            : Deserialize(context, sessionItem.ItemContent, _timeout);

        data.Items["UserId"] = sessionItem.UserId;

        return data;
    }

    /// <summary>
    /// Удаляем блокировку сессии, освобождаем ее для других потоков.
    /// </summary>
    public override void ReleaseItemExclusive(HttpContext context, string id, object lockId)
    {
        var sessionItem = _dataContext.DbSessions.Find(id);
        if (sessionItem.LockId != (int)lockId) return;

        sessionItem.Locked = false;
        sessionItem.Expires = DateTime.UtcNow.AddMinutes(_timeout);
        _dataContext.SaveChanges();
    }

    /// <summary>
    /// Сохраняем состояние сессии и снимаем блокировку.
    /// </summary>
    public override void SetAndReleaseItemExclusive(HttpContext context,
                                                    string id,
                                                    SessionStateStoreData item,
                                                    object lockId,
                                                    bool newItem)
    {
        var intLockId = lockId == null ? 0 : (int)lockId;
        var userId = (int)item.Items["UserId"];

        var data = ((SessionStateItemCollection)item.Items);
        data.Remove("UserId");

        // Сериализуем переменные
        var itemContent = Serialize(data);

        // Если это новая сессия, которой еще нет в базе данных.
        if (newItem)
        {
            var session = new DbSession
            {
                SessionId = id,
                UserId = userId,
                Created = DateTime.UtcNow,
                Expires = DateTime.UtcNow.AddMinutes(_timeout),
                LockDate = DateTime.UtcNow,
                Locked = false,
                ItemContent = itemContent,
                LockId = 0,
            };

            _dataContext.DbSessions.Add(session);
            _dataContext.SaveChanges();
            return;
        }

        // Если это старая сессия, проверяем совпадает ли ключ блокировки, 
        // а после сохраняем состояние и снимаем блокировку.
        var state = _dataContext.DbSessions.Find(id);
        if (state.LockId == (int)lockId)
        {
            state.UserId = userId;
            state.ItemContent = itemContent;
            state.Expires = DateTime.UtcNow.AddMinutes(_timeout);
            state.Locked = false;
            _dataContext.SaveChanges();
        }
    }

    /// <summary>
    /// Удаляет запись о состоянии сессии.
    /// </summary>
    public override void RemoveItem(HttpContext context, string id, object lockId, SessionStateStoreData item)
    {
        var state = _dataContext.DbSessions.Find(id);
        if (state.LockId != (int)lockId) return;

        _dataContext.Entry(state).State = EntityState.Deleted;
        _dataContext.SaveChanges();
    }

    /// <summary>
    /// Сбрасывает счетчик жизни сессии.
    /// </summary>
    public override void ResetItemTimeout(HttpContext context, string id)
    {
        var sessionItem = _dataContext.DbSessions.Find(id);
        if (sessionItem == null) return;

        sessionItem.Expires = DateTime.UtcNow.AddMinutes(_timeout);
        _dataContext.SaveChanges();
    }

    /// <summary>
    /// Создается новый объект, который будет использоваться для хранения состояния сессии в течении запроса.
    /// Мы можем установить в него некоторые предопределенные значения, которые нам понадобятся.
    /// </summary>
    public override SessionStateStoreData CreateNewStoreData(HttpContext context, int timeout)
    {
        var data = new SessionStateStoreData(new SessionStateItemCollection(),
                                                SessionStateUtility.GetSessionStaticObjects(context),
                                                timeout);

        data.Items["UserId"] = 1;
        return data;
    }

    /// <summary>
    /// Создание пустой записи о новой сессии в хранилище сессий.
    /// </summary>
    public override void CreateUninitializedItem(HttpContext context, string id, int timeout)
    {
        var session = new DbSession
        {
            SessionId = id,
            UserId = 1,
            Created = DateTime.UtcNow,
            Expires = DateTime.UtcNow.AddMinutes(timeout),
            LockDate = DateTime.UtcNow,
            Locked = false,
            ItemContent = null,
            LockId = 0,
        };

        _dataContext.DbSessions.Add(session);
        _dataContext.SaveChanges();
    }

    #region Ненужые методы в данной реализации

    public override bool SetItemExpireCallback(SessionStateItemExpireCallback expireCallback) { return false; }
    public override void EndRequest(HttpContext context) { }
    public override void InitializeRequest(HttpContext context) { }

    #endregion

    #region Вспомогательные методы сериализации и десериализации

    private byte[] Serialize(SessionStateItemCollection items)
    {
        var ms = new MemoryStream();
        var writer = new BinaryWriter(ms);

        if (items != null) items.Serialize(writer);
        writer.Close();

        return ms.ToArray();
    }

    private SessionStateStoreData Deserialize(HttpContext context, Byte[] serializedItems, int timeout)
    {
        var ms = new MemoryStream(serializedItems);

        var sessionItems = new SessionStateItemCollection();

        if (ms.Length > 0)
        {
            var reader = new BinaryReader(ms);
            sessionItems = SessionStateItemCollection.Deserialize(reader);
        }

        return new SessionStateStoreData(sessionItems, SessionStateUtility.GetSessionStaticObjects(context), timeout);
    }

    #endregion
}

Настройка конфигурации

После того как мы реализовали провайдер, необходимо его зарегистрировать в конфигурации. Для этого нужно добавить нижеприведенный код в раздел <system.web>:

Сессии в ASP.NET или как создать собственный провайдер

При этом CustomSessionStateProvider.Infrastructure.SessionProvider.SessionStateProvider — это полное название класса нашего провайдера, включая пространство имен. У вас оно будет скорее всего свое.

Тестирование провайдера

Для того, чтобы продемонстрировать работу сессий я создал пустое ASP.NET MVC 3 приложение, где создал контроллер HomeController и определил ряд actions, которые отображают и записывают в сессию различные элементы, в том числе список и объект нашего класса.

namespace CustomSessionStateProvider.Controllers
{
    public class HomeController : Controller
    {
        //
        // GET: /Home/

        public ActionResult Index()
        {
            return View();
        }

        // Установка сессии
        public ActionResult SetToSession()
        {
            Session["Foo"] = new List<int>() {1, 2, 3, 4, 5};
            Session["Boo"] = new SomeClass(50);

            return View();
        }

        // Просмотр содержимого сессии
        public ActionResult ViewSession()
        {
            return View();
        }
    }

    // Объект для тестирования.

    [Serializable]
    public class SomeClass
    {
        readonly int _value;

        public SomeClass(int value)
        {
            _value = value;
        }

        public override string ToString()
        {
            return "value = " +  _value.ToString();
        }
    }
}

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

Сессии в ASP.NET или как создать собственный провайдер

Заключение

В заключение я лишь хочу добавить, что не нужно боятся отходить от стандартов, часто собственные решения позволяют создавать более производительные и гибкие приложения.

В следующей статье я рассмотрю как создать собственный механизм членства (membership) в ASP.NET.
Спасибо за внимание, приятный выходных!

P.S. Исходные коды доступны по ссылке: CustomSessionStateStoreProvider.zip

Полезные ссылки

Написание своего Session Store Provider ASP.NET использующего Redis от Кирилла Музыкова (kmuzykov)
Реализация поставщика хранилища состояний сеансов (msdn)
Пример поставщика хранилища состояния сеанса (msdn)
Провайдер для MySQL от Harry Kimpel

Автор: JeanLouis


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js