欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

DotNetCore Web应用程序中的Cookie管理

程序员文章站 2022-07-03 23:20:08
...

DotNetCore Web应用程序中的Cookie管理

原文来自互联网,由长沙DotNET技术社区编译。如译文侵犯您的署名权或版权,请联系小编,小编将在24小时内删除。限于译者的能力有限,个别语句翻译略显生硬,还请见谅。

作者简介:Jon(Jonathan)Seeley,一位资深.NET开发者,主要从事Asp.NET/Asp.NET CORE/WPF等技术栈的开发,他的博客地址为https://www.seeleycoder.com/。

原文链接[1]

对于那些习惯于在传统ASP.NET中使用Cookie的人来说,改用ASP.NET Core可能会让我们抓狂。在旧系统中,我们能够直接从请求和响应对象中添加和删除cookie(无论好坏)。这可能导致我们在请求期间多次写入和覆盖相同的cookie,因为不同部分的代码会影响它。DotNetCore改变了游戏规则,这是一件好事,相信我。今天,我们将学习DotNetCore Web应用程序中的cookie管理技术。

这篇文章的所有代码都可以在我的GitHub上找到[2]

了解过去

为了论证,我想介绍一下传统的ASP.NET MVC中用于加载Cookie的“通用”代码。当然,问题在于,如果代码中的某处设置了cookie值,而我们稍后又在寻找它,我们想确保我们始终获得最新的副本,而不必一定是请求中包含的内容。下面的代码看起来是否响应中首先匹配。

public static System.Web.HttpCookie GetCookie(this System.Web.HttpContextBase context, string keyName)
{
    System.Web.HttpCookieCollection cookies = new System.Web.HttpCookieCollection();
    System.Web.HttpCookie cookie = null;
    // check for response value first...
    if (context.Response.Cookies.AllKeys.Any(key => string.Equals(key, keyName, StringComparison.OrdinalIgnoreCase)))
        cookie = context.Response.Cookies.Get(keyName);
    else if (context.Request.Cookies.AllKeys.Any(key => string.Equals(key, keyName, StringComparison.OrdinalIgnoreCase)))
        cookie = context.Request.Cookies.Get(keyName);
    return cookie;
}

因此,这就是我们可能访问Cookie进行消费的方式,我们在修改过程中会不会无意中把这个流程搞乱了?我敢肯定,大家也许有很多方式,以下这是我可能做过的一个例子:

public static void SetCookie(this System.Web.HttpContextBase context, string keyName, string value, DateTime? expiry = null)
{
    if (context.Response.HeadersWritten)
        return;
    // a null value is equivalent to deletion
    if (value == null)
    {
        context.Request.Cookies.Remove(keyName);
        context.Response.Cookies.Add(new System.Web.HttpCookie(keyName, "") { Expires = DateTime.Today.AddYears(-1) });
        return;
    }
    System.Web.HttpCookie newCookie = new System.Web.HttpCookie(keyName, value);
    if (expiry.HasValue)
        newCookie.Expires = expiry.Value;
    context.Response.Cookies.Add(newCookie);
}

在上面的代码中,我们试图确保删除cookie也可以防止在未找到同一请求的情况下尝试使用它。如果已经发送了标头,我们也将阻止编写cookie(因为它将引发异常)。该代码“不做”的一件事是防止重复,我是故意这样做的。一旦将其写到浏览器中,响应中的最后一个将调用,因此它仍将按预期“工作”,但同样,我们还有一个错误。如果您想知道,您不想随意,context.Response.Cookies.Add但是应该检查它是否已经存在,如果存在,请调用context.Response.SetCookie。

尽管编写一个cookie管理器并确保您所有的cookie代码都能通过它并不困难,但对于菜鸟和经验丰富的开发人员来说,普遍认为“它可以正常工作”是很常见的。从这个角度来说,如果您确实了解了Asp.NET中Cookie的设置方法并习惯了它,DotNetCore会让您失望。

DotNetCore的差异

既然我们已经介绍了一些您可能期望在传统的ASP.NET MVC中执行操作的方式,那么强调DotNetCore中的差异非常重要。

首先,HttpContext.Request.CookiesDotNetCore中的集合不能被修改。希望您在以前的示例中注意到,当我们删除传统版本的cookie时,我们也删除了请求副本,以确保以后不再使用无效的cookie。同样,HttpContext.Response.Cookies不允许您删除附加到该项目的项目。当然,您可以要求“删除” cookie,但这只是修改了到期时间,因此浏览器将其删除。一旦请求来了,就会调用这个方法。

当我用DotNetCore重写大型应用程序并从旧系统“复制”代码时,这些差异是我很早就遇到的,并导致了对ASP.NET Core中cookie管理的了解。

这些差异是一件好事,因为它们迫使您对正在做的事情多加思考,而不是仅仅假设一切正常。如果使用传统ASP.NET MVC的示例代码来设置Cookie,除非小心,否则最终可能会在响应中获得cookie的多个副本。

如果发生这种情况,并且您稍后尝试在同一请求中读取该值,则可能实际上并没有获得您希望的结果。这样的操作很糟糕。

介绍Cookie Service

鉴于我们之间的差异,再加上DotNetCore确实尽力让您使用依赖项注入这一事实,那么您将如何进行cookie管理?我个人认为,您所有的cookie管理都应通过服务进行分配,然后由中间件负责将最终状态写回到响应中。让我们开始吧:

public class CachedCookie
{
    public string Name { get; set; }
    public string Value { get; set; }
    public CookieOptions Options { get; set; }
    public bool IsDeleted { get; set; }
}
public interface ICookieService
{
    void Delete(string cookieName);
    T Get<T>(string cookieName, bool isBase64 = false) where T : class;
    T GetOrSet<T>(string cookieName, Func<T> setFunc, DateTimeOffset? expiry = null, bool isBase64 = false) where T : class;
    void Set<T>(string cookieName, T data, DateTimeOffset? expiry = null, bool base64Encode = false) where T : class;
    void WriteToResponse(HttpContext context);
}
public class CookieService : ICookieService
{
    private readonly HttpContext _httpContext;
    private Dictionary<string, CachedCookie> _pendingCookies = null;
    public CookieService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContext = httpContextAccessor.HttpContext;
        _pendingCookies = new Dictionary<string, CachedCookie>();
    }


    public void Delete(string cookieName)
    {
    }


    public T Get<T>(string cookieName, bool isBase64 = false) where T : class
    {
        throw new NotImplementedException();
    }


    public T GetOrSet<T>(string cookieName, Func<T> setFunc, DateTimeOffset? expiry = null, bool isBase64 = false) where T : class
    {
        throw new NotImplementedException();
    }


    public void Set<T>(string cookieName, T data, DateTimeOffset? expiry = null, bool base64Encode = false) where T : class
    {
    }


    public void WriteToResponse(HttpContext context)
    {
    }
}

在上面的代码块中,我添加了一个CachedCookie类, 对我们的接口进行了存根CookieService,并为我们的服务设置了框架。

我们早应了解的一件事是,由于某种原因,该服务基于泛型。我希望能够将几乎所有的价值写到我的cookie中。在这种情况下,我选择将泛型限制在一个类中(该类string可以限定,但所有基本值类型都将失败)。为了使这种魔术起作用,我将使用JSON将我的值序列化为字符串。

为了弄清楚所有部分如何组合在一起,我认为我们将一次迈出这一步。

我们的构造函数正在注入,IHttpContextAccessor这使我们能够访问HttpContext请求的当前值。这类似于我们曾经使用过的旧ASP.NET HttpContext.Current。但是,要使此方法起作用,我们需要将其注册,因此请跳至Startup.cs您的ConfigureServices方法并将这些行添加到您的方法中:

services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();

services.AddScoped<ICookieService, CookieService>();

您还会在构造函数中注意到的另一件事是,我们正在为的实例设置一个空字典CachedCookie。在中间件将它们转储到响应之前,这是我们在请求期间跟踪cookie状态的地方。

中间件

我们需要照顾的下一件事是创建我们的中间件并将其放入我们的管道中。让我们添加CookieServiceMiddleware.cs并编写下列代码:

internal class CookieServiceMiddleware
{
    private readonly RequestDelegate _next;
    public CookieServiceMiddleware(RequestDelegate next)
    {
        _next = next;
    }
    public async Task Invoke(HttpContext context, ICookieService cookieService)
    {
        // write cookies to response right before it starts writing out from MVC/api responses...
        context.Response.OnStarting(() =>
        {
            // cookie service should not write out cookies on 500, possibly others as well
            if (!context.Response.StatusCode.IsInRange(500, 599))
            {
                cookieService.WriteToResponse(context);
            }
            return Task.CompletedTask;
        });
        await _next(context);
    }
}

无法在构造函数级别将范围服务注入中间件。您会注意到,我在Invoke方法中[3]注入了它,这似乎有点像魔术。在DotNetCore底层的某个地方的IServiceProvider组件知道如何进行注入。要注意的另一件事是,我检测到响应何时开始,然后检查状态码是否不在特定范围内。如果超出该范围,那么我们将继续通过服务将Cookie写入响应中。该IsInRange扩展方法是一个我已经添加的话,事不宜迟,这里是一个基本的IntExtensions.cs添加到项目中我:

public static class IntExtensions
{
    public static bool IsInRange(this int checkVal, int value1, int value2)
    {
        // First check to see if the passed in values are in order. If so, then check to see if checkVal is between them
        if (value1 <= value2)
            return checkVal >= value1 && checkVal <= value2;
        // Otherwise invert them and check the checkVal to see if it is between them
        return checkVal >= value2 && checkVal <= value1;
    }
}

注册中间件

好的 最后,我们需要添加到中间件代码中并进行连接。中间件的约定是创建一个静态类和扩展方法来处理中间件的注册。让我们添加CookieServiceMiddlewareExtensions:

public static class CookieServiceMiddlewareExtensions
{
    public static IApplicationBuilder UseCookieService(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<CookieServiceMiddleware>();
    }
}

让我们进入Startup.cs进入我们的Configure方法中,并添加app.UseCookieService();到链的某处。这里的窍门是您希望它在您的app.UseMvc呼叫之前以及可能影响您响应的任何其他内容之前显示,但不要太高以至于过早地向响应写出cookie。在这种情况下,我选择在app.UseCookiePolicy通话后添加它。如果您有很多其他中间件,则您自己的工作量可能会有所不同。补充一下。如果我的中间件稍微复杂一点,并且有多个服务需要注册,那么我可能还创建了一个扩展方法来从我的ConfigureServices方法中调用。如果我正在创建一个用于分发的中间件,那么即使只有一个服务,我也绝对可以做到。我不想强迫某人必须了解一切,才能为DI配置我的中间件,他们应该能够简单地要求添加它并继续前进。

该扩展方法可能具有这样的签名public static IServiceCollection ConfigureCookieService(this IServiceCollection services, IConfiguration configuration)。(这里的IConfiguration是可选的……在某些方面我需要它,但是显然在这种情况下我们不需要它)。

实现

太好了,我们现在已经注册了我们的服务和中间件,但是它什么也没做。让我们继续,一次开始实现一种方法。由于我们实际上要尝试做的第一件事是加载cookie以供消费,也许我们应该从那里开始。进入CookieService.cs并将以下代码添加到public T Get方法中:

Get 

public T Get<T>(string cookieName, bool isBase64 = false)
    where T : class
{
    return ExceptionHandler.SwallowOnException(() =>
        {
            // check local cache first...
            if (_pendingCookies.TryGetValue(cookieName, out CachedCookie cookie))
            {
                // don't retrieve a "deleted" cookie
                if (cookie.IsDeleted)
                return default(T);
                return isBase64 ? Newtonsoft.Json.JsonConvert.DeserializeObject<T>(cookie.Value.FromBase64String())
                    : Newtonsoft.Json.JsonConvert.DeserializeObject<T>(cookie.Value);
            }
            if (_httpContext.Request.Cookies.TryGetValue(cookieName, out string cookieValue))
            return isBase64 ? Newtonsoft.Json.JsonConvert.DeserializeObject<T>(cookieValue.FromBase64String())
                : Newtonsoft.Json.JsonConvert.DeserializeObject<T>(cookieValue);
            return default(T);
        });
}

在讨论之前,让我们先看一下这些内容ExceptionHandler。我们的Get方法首先询问我们的pendingCookies字典是否有与键匹配的东西。如果有,它将询问我们是否已对其进行标记IsDeleted。如果我们有一个并且未被删除,那么我们继续将其反序列化为请求的对象类型,并且可选地,我们需要首先从base64对其进行解码。如果我们在缓存中没有它的本地副本,那么我们继续看是否HttpContext.Request.Cookies具有它,并且像我们的本地缓存一样,可以选择在最终反序列化之前从base64解码。

现在,为什么我要对它进行base64编码?从本质上讲,我并不是要“保护”我的cookie免受窥视,但是,如果我有一个非常复杂的对象,我要写出一个cookie,我想对其进行分解。对象的JSON字符串表示形式可能非常笨拙。

说到base64编码…这些是我在StringExtensions.cs文件中添加的几个扩展方法。干得好:

public static class StringExtensions
{
    public static string FromBase64String(this string value, bool throwException = true)
    {
        try
        {
            byte[] decodedBytes = System.Convert.FromBase64String(value);
            string decoded = System.Text.Encoding.UTF8.GetString(decodedBytes);
            return decoded;
        }
        catch (Exception ex)
        {
            if (throwException)
                throw new Exception(ex.Message, ex);
            else
                return value;
        }
    }
    public static string ToBase64String(this string value)
    {
        byte[] bytes = System.Text.ASCIIEncoding.UTF8.GetBytes(value);
        string encoded = System.Convert.ToBase64String(bytes);
        return encoded;
    }
}

好吧,这是什么ExceptionHandler.SwallowOnException法术?我本可以使用该try {} catch {}块,但这是一个用例,其中我100%可以接受,失败只是因为存在cookie而已,因为cookie根本就不存在。现在……如果您深入研究该处理程序的代码,您会发现它仍在执行try / catch块,我只是对其进行了抽象。让我向您证明这一点。

异常处理程序

public static class ExceptionHandler
{
    public static T SwallowOnException<T>(Func<T> func)
    {
        try
        {
            return func();
        }
        catch
        {
            return default(T);
        }
    }
}

Set

嘿,我们很酷,可以加载cookie,但是如果我们不能创建cookie,那不是很有用,对吧?让那部分起作用。

public void Set<T>(string cookieName, T data, DateTimeOffset? expiry = null, bool base64Encode = false)
    where T : class
{
    // info about cookieoptions
    CookieOptions options = new CookieOptions()
        {
            Secure = _httpContext.Request.IsHttps
        };
    if (expiry.HasValue)
        options.Expires = expiry.Value;
    if (!_pendingCookies.TryGetValue(cookieName, out CachedCookie cookie))
        cookie = Add(cookieName);
    // always set options and value;
    cookie.Options = options;
    cookie.Value = base64Encode
                ? Newtonsoft.Json.JsonConvert.SerializeObject(data).ToBase64String()
                : Newtonsoft.Json.JsonConvert.SerializeObject(data);
}

创建Cookie时,我们需要设置一些信息。我在这里几乎没有内容,但我强烈建议您阅读CookieOptions[4]。不设置Expires将默认为“会话” cookie。如果您将Google Chrome浏览器用于“始终打开”模式(或所谓的“笨拙”),则它们将无法正常工作。在这里的代码中,我们将查看是否已经有一个待处理的Cookie实例,如果没有,则添加一个实例。一分钟后,我将介绍该方法。在获得cookie实例之后,我们将附加选项并编写可选的以base64编码的值。Add现在让我们看一下该方法。

protected CachedCookie Add(string cookieName)
{
    var cookie = new CachedCookie
        {
            Name = cookieName
        };
    _pendingCookies.Add(cookieName, cookie);
    return cookie;
}

很基本的东西。我们只是放宽信任,我们可以添加它并添加它。该方法没有公开,所以我相信我不必先检查字典。如果您对此不满意,请随时进行修改。

删除Cookie

在某个时候,我们将要删除Cookie,对吗?我们希望确保对同一cookie的后续查询都知道它已被删除,正如我们在Get调用中所看到的那样。为了使它正常工作,我们需要本地缓存来跟踪它。

void ICookieService.Delete(string cookieName)
{
    Delete(cookieName);
}
protected CachedCookie Delete(string cookieName)
{
    if (_pendingCookies.TryGetValue(cookieName, out CachedCookie cookie))
        cookie.IsDeleted = true;
    else
    {
        cookie = new CachedCookie
            {
                Name = cookieName,
                IsDeleted = true
            };
        _pendingCookies.Add(cookieName, cookie);
    }
    return cookie;
}

在上面的代码中,我们具有接口Delete方法和类Delete方法,它们都具有相同的签名。我可以给他们起个不同的名字,但我真的不想这么做。但是,为了防止编译器报错,我们必须将接口方法设为显式接口调用。我们只需将该调用传递到我们的类实例方法中。进入类实例delete方法后,我们将查看是否已经有一个暂挂实例,如果有,请将其标记为已删除。如果没有,我们将其添加到缓存中并标记为已删除。

GetOrSet 

有时,您希望Cookie不管存在如何,但是如果已经存在,那么您就想获得它的价值。一个用例是如果您要加载cookie(如果存在)或设置默认值。在我工作过的一个站点上,我们有一个适合该用例的“行程计划器”。我想知道他们的详细信息(如果有的话),否则我将设置一些默认值,以便其余的会话体验基于相同的信息。设置非常简单:

public T GetOrSet<T>(string cookieName, Func<T> setFunc, DateTimeOffset? expiry = null, bool isBase64 = false)
    where T : class
{
    T cookie = Get<T>(cookieName, isBase64);
    if (cookie != null)
        return cookie;
    T data = setFunc();
    Set(cookieName, data, expiry, isBase64);
    return data;
}

如果cookie存在,我们得到它。如果没有,我们将其设置。十分简单。

输出

如果我们从不将其写回响应中,那么以上所有代码实际上都没有关系,对吗?还记得在context.Response.OnStarting我们告诉服务期间在中间件中执行的服务WriteToResponse吗?让我们现在实际做点什么:

public void WriteToResponse(HttpContext context)
{
    foreach (var cookie in _pendingCookies.Values)
    {
        if (cookie.IsDeleted)
            context.Response.Cookies.Delete(cookie.Name);
        else
            context.Response.Cookies.Append(cookie.Name, cookie.Value, cookie.Options);
    }
}

我们重复我们的每一个悬而未决的饼干和要么Delete或者Append他们根据我们的缓存值。现在我们只写出每个cookie的一个副本,而不是我们在本文开头介绍的经典ASP.NET崩溃。

与测试代码一起实现

GitHub上的代码在HomeController中有一个相当蹩脚的小演示。接下来是一些单元测试。在发布一些代码之前,我想回顾一下我的BaseTest.cs工作方式。我可以(坦率地说应该有),但是由于我从生产代码中复制了这个代码,而这个代码还有其他问题,所以我没有使用)DotNetCore服务集合。相反,BaseTest依赖于UnityContainer。对于我而言,这是设置依赖项引擎的一种非常简单的方法。你想怎么嘲笑就怎么嘲笑吧。

随之而来的将是CookieServiceTests类的一大堆垃圾。该Initialize方法设置了每个测试将要使用的内容,然后每个单独的测试都设置了自己的场景。如何使用该服务应该变得显而易见,并希望为您提供一些如何在自己的项目中使用该服务的想法。

[TestClass]
public class CookieServiceTests : BaseTest
{
    IHttpContextAccessor _httpContextAccessor;
    HttpContext _httpContext;
    CookieService _target;
    [TestInitialize]
    public void Initialize()
    {
        _httpContextAccessor = Substitute.For<IHttpContextAccessor>();
        _httpContext = new DefaultHttpContext();
        _httpContextAccessor.HttpContext.Returns(_httpContext);
        Container.RegisterInstance(_httpContextAccessor);
        _target = Container.Resolve<CookieService>();
    }
    [TestMethod]
    public void CookieService_SetCookie_Success()
    {
        CookieFake cookie = new CookieFake { TestProperty = 25, TestPropertyString = "blah" };
        _target.Set("fakecookie", cookie);
        CookieFake cachedCookie = _target.Get<CookieFake>("fakecookie");
        Assert.IsNotNull(cachedCookie);
        Assert.AreEqual(cookie.TestProperty, cachedCookie.TestProperty);
        Assert.AreEqual(cookie.TestPropertyString, cachedCookie.TestPropertyString);
    }
    [TestMethod]
    public void CookieService_SetCookie_StringOnly_Success()
    {
        string value = "I'm a cookie value";
        _target.Set("fakecookie", value);
        string result = _target.Get<string>("fakecookie");
        Assert.IsFalse(string.IsNullOrWhiteSpace(result));
        Assert.AreEqual(value, result);
    }
    [TestMethod]
    public void CookieService_SetCookie_Base64_Success()
    {
        CookieFake cookie = new CookieFake { TestProperty = 25, TestPropertyString = "blah" };
        _target.Set("fakecookie", cookie, base64Encode: true);
        CookieFake cachedCookie = _target.Get<CookieFake>("fakecookie", true);
        Assert.IsNotNull(cachedCookie);
        Assert.AreEqual(cookie.TestProperty, cachedCookie.TestProperty);
        Assert.AreEqual(cookie.TestPropertyString, cachedCookie.TestPropertyString);
    }
    [TestMethod]
    public void CookieService_GetOrSetCookie_SetsCookie_Success()
    {
        Func<CookieFake> createCookie = () =>
        {
            return new CookieFake { TestProperty = 25, TestPropertyString = "blah" };
        };
        var cookie = _target.GetOrSet<CookieFake>("fakecookie", createCookie);
        Assert.IsNotNull(cookie);
        Assert.AreEqual(cookie.TestProperty, 25);
    }
    [TestMethod]
    public void CookieService_GetOrSetCookie_GetsCookie_Success()
    {
        CookieFake cookie = new CookieFake { TestProperty = 25, TestPropertyString = "blah" };
        _target.Set("fakecookie", cookie);
        Func<CookieFake> createCookie = () =>
        {
            return new CookieFake { TestProperty = 55, TestPropertyString = "blah2" };
        };
        var retrievedCookie = _target.GetOrSet<CookieFake>("fakecookie", createCookie);
        Assert.IsNotNull(retrievedCookie);
        Assert.AreEqual(retrievedCookie.TestProperty, cookie.TestProperty);
        Assert.AreEqual(retrievedCookie.TestPropertyString, cookie.TestPropertyString);
    }
    [TestMethod]
    public void CookieService_GetCookie_Fail()
    {
        CookieFake cachedCookie = _target.Get<CookieFake>("fakecookie");
        Assert.IsNull(cachedCookie);
    }
    [TestMethod]
    public void CookieService_GetCookie_Base64_Fail()
    {
        CookieFake cookie = new CookieFake { TestProperty = 25, TestPropertyString = "blah" };
        _target.Set("fakecookie", cookie);
        CookieFake cachedCookie = _target.Get<CookieFake>("fakecookie", true);
        Assert.IsNull(cachedCookie);
    }
}
public class CookieFake
{
    public int TestProperty { get; set; }
    public string TestPropertyString { get; set; }
}

结论

DotNetCore Web应用程序中的Cookie管理并不是一件复杂的事情,但是很容易使效率低下。我们通过引入CookieService和中间件,研究了一种确保响应尽可能干净的方法。

今天发布的所有代码都可以在我的GitHub上找到[5]

我鼓励您查看整个项目,查看我在Web应用程序中蹩脚的示例,我相信你能从中学到有用的知识。

References

[1] 原文链接: https://www.seeleycoder.com/blog/cookie-management-asp-net-core/
[2] 我的GitHub上找到: https://github.com/fooberichu150/CookieService
[3] Invoke方法中: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-2.1#service-lifetimes
[4] 您阅读CookieOptions: https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.cookieoptions?view=aspnetcore-2.1
[5] 我的GitHub上找到: https://github.com/fooberichu150/CookieService