Управление состоянием приложения

Steve Smith

В ASP.NET Core статусом приложения можно управлять разными способами. В этой статье мы рассмотрим некоторые варианты, а также установим и настроим поддержку состояния сессий в приложениях ASP.NET Core.

Просмотрите или скачайте код.

Опции Application State

Application state (состояние приложения) касается всех данных, которые используются для представления текущего состояния приложения. Это касается и глобальных, и конкретных данных. В предыдущих версиях ASP.NET (и даже ASP) имелась встроенная поддержка глобальных хранилищ состояний Application и Session, а также некоторые другие опции.

Примечание

У хранилища Application были почти такие же характеристики, что и у ASP.NET Cache. В ASP.NET Core Application больше не существует; приложения, написанные для предыдущих версий ASP.NET и перемещенные в ASP.NET Core заменяют Application реализацией Caching.

Разработчики приложений вольны использовать различные провайдеры состояний приложений в зависимости от разных факторов:

  • Как долго должны существовать данные?
  • Какова величина данных?
  • Каков формат этих данных?
  • Можно ли их сериализовать?
  • Насколько чувствительны данные? Могут ли они храниться на стороне клиента?

В зависимости от ответов на эти вопросы состояние приложения ASP.NET Core может управляться разными способами.

HttpContext.Items

Коллекция Items - это лучшее место для хранения данных, которые нужны для обработки определенного запроса. Их содержание сбрасывается после каждого запроса. Это что-то вроде связки между компонентами и middleware, которые работают на разных этапах запроса и не имеют прямого отношения к данным, передающим параметры и возвращаемые значения. См. Работа с HttpContext.Items.

Строка запроса и Post

Состояние запроса может быть передано другому запросу, если вы добавите значение строке нового запроса или используете POST. Такие технологии не нужно использовать с чувствительными данными, поскольку здесь данные отправляются клиенту, а затем обратно серверу. Лучше всего использовать такой способ с небольшим количеством данных. Строки запросов особенно полезны для определения состояния постоянным образом, и таким образом создаются ссылки, сохраняющие состояние, а затем они отправляются через имейлы или социальные сети для возможного дальнейшего использования. Поскольку URL со строками запросов можно легко делиться, вы должны позаботиться об избежании CSRF-атак) (например, даже если предположить, что только зарегистрированные пользователи могут выполнять некоторые действия с помощью таких URL, атакующий может попробовать использовать такой URL как зарегистрированный пользователь).

Куки

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

Сессия

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

Кэш

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

Конфигурация

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

Другие постоянные хранилища

Другие постоянные хранилища, например, Entity Framework и база данных или Azure Table Storage также могут служить для сохранения состояния приложения, но ASP.NET напрямую их не поддерживает.

Работа с HttpContext.Items

HttpContext поддерживает коллекцию типа IDictionary<object, object>, которая называется Items. Эта коллекция доступна при запуске HttpRequest` и сбрасывается в конце каждого запроса. Вы можете получить к ней доступ при помощи запроса или присвоения значения ключу.

Например, Связующее ПО (Middleware) может что-то добавить в коллекцию Items:

app.Use(async (context, next) =>
        {
                // perform some verification
                context.Items["isVerified"] = true;
                await next.Invoke();
        });

и далее в потоке другой кусок связующего ПО может добраться до этого:

app.Run(async (context) =>
{
        await context.Response.WriteAsync("Verified request? "
                 context.Items["isVerified"]);
});

Примечание

Ключи в Items являются простыми строками, и если вы разрабатываете связующее ПО, которое должно работать с несколькими приложениями, вам стоит добавить ключам уникальный идентификатор, чтобы избежать проблем с ключами (например, “MyComponent.isVerified” вместо “isVerified”).

Установка и настройка сессий

В ASP.NET Core есть пакет, который предоставляет связующее ПО для управления состоянием приложения. Вы можете установить его, добавив ссылку на пакет в файл project.json.

После установки пакета нужно настроить Session в классе Startup. Session создается вверху IDistributedCache, так что вам нужно настроить все это, иначе вы получите ошибку.

Примечание

Если вы не настроите хотя бы одну реализацию IDistributedCache, у вас появится исключение “Unable to resolve service for type ‘Microsoft.Extensions.Caching.Distributed.IDistributedCache’ из-за попытки активировать ‘Microsoft.AspNet.Session.DistributedSessionStore’.”

ASP.NET поставляется с несколькими реализациями IDistributedCache, включая опцию in-memory (используется только во время разработки и тестирования). Чтобы настроить сессию при помощи этой опции, добавьте Microsoft.Extensions.Caching.Memory в пакет project.json, а следующее в ConfigureServices:

services.AddDistributedMemoryCache();
services.AddSession();

Затем добавьте следующее в Configure перед app.UseMVC(), и вы будете готовы использовать сессию:

app.UseSession();

После установки и настройки вы будете готовы использовать Session из HttpContext.

Примечание

Если вы попытаетесь использовать Session перед вызовом UseSession, у вас появится исключение InvalidOperationException, что будет написано следующее: “Session has not been configured for this application or request.”

Предупреждение

Если вы попытаетесь создать новую Session (пока не было создано куки сессии) после того, как вы начали создавать поток Response, у вас также появится исключение InvalidOperationException, где будет говорится, что “The session cannot be established after the response has started”. Это исключение, возможно, не будет отображено в браузере; вам потребуется просмотреть серверный лог, чтобы найти его:

../_images/session-after-response-error.png

Детали реализации

Сессия использует куки, чтобы отслеживать и устранять неоднозначные моменты между запросами с разных браузеров. По умолчанию этот куки называется ”.AspNet.Session”. Далее, по умолчанию этот куки не указывает домен, и он недоступен для скриптов на клиентской стороне (поскольку CookieHttpOnly установлен на true).

IdleTimeout (используется на сервере независимо от куки) можно переписать при настройке Session при помощи SessionOptions:

services.AddSession(options =>
{
        options.CookieName = ".AdventureWorks.Session";
        options.IdleTimeout = TimeSpan.FromSeconds(10);
});

IdleTimeout используется сервером для определения того, когда сбрасывается сессия. Каждый запрос передается связующему ПО Session (независимо от того, считывается или записывается Session внутри данного ПО), и это сбрасывает таймаут. Обратите внимание, что все это происходит независимо от сброса куки.

Примечание

Если два запроса пытаются изменить контекст сессии, сделает это последний. Кроме того, все виды контекста сессии сохраняются совместно. Это обозначает, что если два запроса меняют различные части сессии (разные ключи), они будут все равно влиять друг на друга.

ISession

После установки и настройки сессии вы ссылаетесь на нее через HttpContext, который предоставляет свойство Session типа ISession. Вы можете использовать этот интерфейс, чтобы получать и устанавливать значения в Session, в качестве byte[].

  public interface ISession
  {
bool IsAvailable { get; }
string Id { get; }
IEnumerable<string> Keys { get; }
Task LoadAsync();
Task CommitAsync();
bool TryGetValue(string key, out byte[] value);
void Set(string key, byte[] value);
void Remove(string key);
void Clear();
  }

Поскольку Session создается вверху IDistributedCache, вы всегда должны сериализовать сохраняемые экземпляры объекта. Интерфейс работает с byte[], а не просто с object. Однако существуют методы расширения, которые упрощают работу с простыми типами, такими как String и Int32, а также упрощают получение значений byte[] из сессии.

// session extension usage examples
context.Session.SetInt32("key1", 123);
int? val = context.Session.GetInt32("key1");
context.Session.SetString("key2", "value");
string stringVal = context.Session.GetString("key2");
byte[] result = context.Session.Get("key3");

Если вы сохраняете более сложные объекты, вам нужно сериализовать объект в byte[], чтобы его сохранить, а при получении десериализовать его.

Рабочий пример использования сессии

В данном примере показано, как работать с Session, включая сохранение и получение простых типов, а также пользовательских объектов. Если вы хотите узнать, что происходит после истечения сессии, то вы увидите это на данном примере, поскольку сессия длится всего 10 секунд:

1
2
3
4
5
6
7
8
{
    services.AddDistributedMemoryCache();

    services.AddSession(options =>
    {
        options.IdleTimeout = TimeSpan.FromSeconds(10);
    });
}

Когда вы впервые переходите на веб сервер, тут представлен скриншот, показывающий, что сессия еще не была установлена:

../_images/no-session-established.png

Такое поведение по умолчанию происходит из-за связующего ПО в Startup.cs, который запускается тогда, когда делается запрос, у которого нет установленной сессии (обратите внимание на выделенные сессии):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// main catchall middleware
app.Run(async context =>
{
    RequestEntryCollection collection = GetOrCreateEntries(context);

    if (collection.TotalCount() == 0)
    {
        await context.Response.WriteAsync("<html><body>");
        await context.Response.WriteAsync("Your session has not been established.<br>");
        await context.Response.WriteAsync(DateTime.Now.ToString() + "<br>");
        await context.Response.WriteAsync("<a href=\"/session\">Establish session</a>.<br>");
    }
    else
    {
        collection.RecordRequest(context.Request.PathBase + context.Request.Path);
        SaveEntries(context, collection);

        // Note: it's best to consistently perform all session access before writing anything to response
        await context.Response.WriteAsync("<html><body>");
        await context.Response.WriteAsync("Session Established At: " + context.Session.GetString("StartTime") + "<br>");
        foreach (var entry in collection.Entries)
        {
            await context.Response.WriteAsync("Request: " + entry.Path + " was requested " + entry.Count + " times.<br />");
        }

        await context.Response.WriteAsync("Your session was located, you've visited the site this many times: " + collection.TotalCount() + "<br />");
    }
    await context.Response.WriteAsync("<a href=\"/untracked\">Visit untracked part of application</a>.<br>");
    await context.Response.WriteAsync("</body></html>");
});

GetOrCreateEntries - это вспомогательный метод, который получает экземпляр RequestEntryCollection из Session, если сессия существует; в ином случае он создает коллекцию и возвращает ее. Коллекция содержит экземпляры RequestEntry, в которых находятся различные запросы, которые сделал пользователь во время текущей сессии.

1
2
3
4
5
public class RequestEntry
{
    public string Path { get; set; }
    public int Count { get; set; }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class RequestEntryCollection
{
    public List<RequestEntry> Entries { get; set; } = new List<RequestEntry>();

    public void RecordRequest(string requestPath)
    {
        var existingEntry = Entries.FirstOrDefault(e => e.Path == requestPath);
        if (existingEntry != null) { existingEntry.Count++; return; }

        var newEntry = new RequestEntry()
        {
            Path = requestPath,
            Count = 1
        };
        Entries.Add(newEntry);
    }

    public int TotalCount()
    {
        return Entries.Sum(e => e.Count);
    }
}

Примечание

Типы, которые хранятся в сессии, помечаются [Serializable].

Извлечение текущего экземпляра RequestEntryCollection происходит при помощи вспомогательного метода GetOrCreateEntries:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
private RequestEntryCollection GetOrCreateEntries(HttpContext context)
{
    RequestEntryCollection collection = null;
    byte[] requestEntriesBytes = context.Session.Get("RequestEntries");

    if (requestEntriesBytes != null && requestEntriesBytes.Length > 0)
    {
        string json = System.Text.Encoding.UTF8.GetString(requestEntriesBytes);
        return JsonConvert.DeserializeObject<RequestEntryCollection>(json);
    }
    if (collection == null)
    {
        collection = new RequestEntryCollection();
    }
    return collection;
}

Если в Session существует объект, он извлекается как тип byte[], а затем десериализуется с помощью MemoryStream и BinaryFormatter. Если объект не находится в Session, метод извлекает новый экземпляр RequestEntryCollection.

В браузере нажатие гиперссылки “Establish session” направляет запрос по пути “/session” и возвращает такой результат:

../_images/session-established.png

Обновление страницы включает счетчик; возврат к корню сайта (после нескольких запросов) имеет вот такой итог, то есть, здесь подсчитаны все запросы, которые были сделаны во время этой сессии:

../_images/session-established-with-request-counts.png

Работа с сессиями происходит с помощью связующего ПО, которое направляет запросы на “/session”:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// establish session
app.Map("/session", subApp =>
{
    subApp.Run(async context =>
    {
        // uncomment the following line and delete session coookie to generate an error due to session access after response has begun
        // await context.Response.WriteAsync("some content");
        RequestEntryCollection collection = GetOrCreateEntries(context);
        collection.RecordRequest(context.Request.PathBase + context.Request.Path);
        SaveEntries(context, collection);
        if (context.Session.GetString("StartTime") == null)
        {
            context.Session.SetString("StartTime", DateTime.Now.ToString());
        }

        await context.Response.WriteAsync("<html><body>");
        await context.Response.WriteAsync($"Counting: You have made {collection.TotalCount()} requests to this application.<br><a href=\"/\">Return</a>");
        await context.Response.WriteAsync("</body></html>");
    });
});

Запросы по этому пути получат или создадут RequestEntryCollection, добавят к ней текущий путь, а затем сохранят сессию с помощью вспомогательного метода SaveEntries:

1
2
3
4
5
6
7
private void SaveEntries(HttpContext context, RequestEntryCollection collection)
{
    string json = JsonConvert.SerializeObject(collection);
    byte[] serializedResult = System.Text.Encoding.UTF8.GetBytes(json);

    context.Session.Set("RequestEntries", serializedResult);
}

SaveEntries показывает, как превратить пользовательский объект в byte[] для сохранения в Session с помощью MemoryStream и BinaryFormatter.

Пример включает в себя некоторое связующее ПО, которое стоит упомянуть, и оно работает с путем “/untracked”. Вот его настройки:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// example middleware that does not reference session at all and is configured before app.UseSession()
app.Map("/untracked", subApp =>
{
    subApp.Run(async context =>
    {
        await context.Response.WriteAsync("<html><body>");
        await context.Response.WriteAsync("Requested at: " + DateTime.Now.ToString("hh:mm:ss.ffff") + "<br>");
        await context.Response.WriteAsync("This part of the application isn't referencing Session...<br><a href=\"/\">Return</a>");
        await context.Response.WriteAsync("</body></html>");
    });
});

app.UseSession();

Обратите внимание, что связующее ПО настроено как before, чтобы можно было вызвать app.UseSession() (строка 13). Session не доступна для этого ПО, а запросы не сбрасывают IdleTimeout. Вы можете увидеть такое поведение, обновив неотслеживаемый путь несколько раз в течение 10 секунд, а затем вернувшись к корню приложения. Вы увидите, что сессия истекла, несмотря на то что вы делали запросы в течение 10 секунд.

Поделись хорошей новостью с друзьями!
Следи за новостями!