用 C# 自己动手编写一个 Web 服务器,第四部分——Session

版权声明:所有博客文章除特殊声明外均为原创,允许转载,但要求注明出处。

Session

上一篇文章 中,我们实现了 Web 服务器的路由功能,并实现了控制器的基本支持。本来,我们应该高高兴兴的继续向其中添加功能,不过马上就发现一个尴尬的问题————我们还没有 Session。更具体的说,我们一直在使用的 HttpListenerContext 只提供了 Request/Response,却没有 Session 属性。这意味着我们的服务器毫无记性,只能把每次请求都当作新的用户。

出现这种情况也是情理之中的。基础类库之中的 HttpListenerXXX 系列类为我们创建 Web 应用提供了一个很好的起点,但实现 Sesssion 则是 Web 框架的事情,并不是 Web 服务器的职责。有的同学可能会问,Web 服务器和 Web 框架的区别在哪?嗯,其实这个问题也没有严格的定义,不过一般来讲,Web 服务器通常是独立于编程语言和框架的,比如 Apache/Nginx 都有支持多种语言/框架的能力;IIS 通过插件也可以运行 PHP,并且 Web 服务器通常更关心基础设施方面的问题,包括站点管理、HTTP 压缩、证书、性能和吞吐量等。而 Web 框架一般是和具体的语言或平台绑定的,希望充分利用语言本身的特性来更好的支持业务逻辑,例如 Express(Nodejs)、Django(Python)、ASP.NET MVC(.Net)等。Session 这个东西,对于后端业务是非常必要的(区分用户是绝大多数后台系统的基本要求),但对于 Web 服务器却不是绝对必需的,而且会在一定程度上影响服务器的吞吐量,所以一般会把它放到 Web Framework 的层面去实现它。

Session 要求服务器有一定的机制去记住当前请求的用户。目前绝大多数的 Session 实现都是基于 Cookie 的。在具体实现层面,又需要考虑把多少内容放在 cookie 里的问题。主流的实现会把绝大多数内容放在服务器端,客户端只记录一个用于鉴别的 key,这种实现在网络流量以及安全性方面都是极好的,缺点是会占据较多的服务器空间。也有一些实现为了减轻服务器压力以及方便客户端处理,会把部分数据放到客户端,但这样又需要考虑安全性和数据丢失的问题。我们这里不讨论方案的优劣问题,为了示例的目的,采用第一种方案——即将所有内容保存在服务端。

此外,请允许我再多说一句:Session 是一种机制,没有什么规定要求它一定是位于内存中的。许多同学似乎误解了这一点,他们似乎认为只要 Session 就一定是使用内存的。事实当然不是这样,用其他的存储机制来保存 Session 是完全合法的。之所以有这样的误会,可能是因为大多数 Session 实现默认使用内存——因为这是最简单的方式。但许多 Web 框架都提供了诸如 Session Storage 或 Session Provider 这样的扩展点,以便将 Session 保存在其他地方,比如数据库或远程 Redis/Memcached。如果要实现跨多个服务器的分布式 Session,那么内存肯定不是一个好的选择。我们在这里的实现为了简化问题也使用了内存,但请务必清楚这一点:即 Session 并非一定要保存在内存中。

代码

本文的示例代码已经全部放到 Github,每篇文章关联的代码放在独立的分支,方便读者参考。因此,要获取本文示例代码,请使用如下命令:

git clone https://github.com/shuhari/web-server-succinctly-example.git
git checkout -b  04-session origin/04-session 

实现

在开头部分我们说过,HttpListenerContext 并没有提供给我们一个 Session 接口,所以我们必须在它之上再封装一层,提供 Web 框架所需的功能。

首先声明 Session 接口。对于大多数典型使用场景,Session 可以当作一个字典:

public interface ISession
{
    object this[string name] { get; set; }

    void Remove(string name);
}

接下来,对 HttpListenerContext 进行再次封装(为了避免和 ASP.NET MVC 混淆,这里我们称为 HttpServerContext):

public class HttpServerContext
{
    public HttpServerContext(HttpListenerContext context)
    {
        _innerContext = context;
    }

    private readonly HttpListenerContext _innerContext;

    public HttpListenerRequest Request => _innerContext.Request;

    public HttpListenerResponse Response => _innerContext.Response;

    public IPrincipal User { get; internal set; }

    public ISession Session { get; internal set; }
}

对于已有的属性,我们可以直接委托过去。Session 则是需要我们声明的。另外,我们也重新声明了 User,这是因为默认的实现是只读的,并没有设置用户的方法(后续的用户验证部分我们还会用到它)。

接下来,我们需要把所有对 HttpListenerContext 的引用替换为 HttpServerContext。这涉及了大多数代码文件,但只是简单的替换动作,相信你可以自己完成。

在 MiddlewarePipeline 中的代码也需要稍作改动:

    internal class MiddlewarePipeline
    {
        internal void Execute(HttpListenerContext context)
        {
            var serverContext = new HttpServerContext(context);

            try
            {
                foreach (var middleware in _middlewares)
                {
                    var result = middleware.Execute(serverContext);
                    ... // 下同
                }
            }
            catch (Exception ex)
            {
                ...
            }
        }
    }

控制器增加几个辅助方法,方便访问 Session:

public abstract class Controller
{
    public HttpServerContext HttpContext { get; internal set; }

    protected ISession Session => HttpContext.Session;

    protected IPrincipal User => HttpContext.User;
}

一切就绪,我们实现一个处理 Session 的中间件:

public class SessionManager : IMiddleware
{
    public SessionManager()
    {
        _sessions = new ConcurrentDictionary<string, Session>();            
    }

    private const string _cookieName = "__sessionid__";

    private ConcurrentDictionary<string, Session> _sessions;

    public MiddlewareResult Execute(HttpServerContext context)
    {
        var cookie = context.Request.Cookies[_cookieName];
        Session session = null;
        if (cookie != null)
        {
            _sessions.TryGetValue(cookie.Value, out session);
        }
        if (session == null)
        {
            session = new Session();
            var sessionId = GenerateSessionId();
            _sessions[sessionId] = session;
            cookie = new Cookie(_cookieName, sessionId);
            context.Response.SetCookie(cookie);
        }
        context.Session = session;
        return MiddlewareResult.Continue;
    }

    private string GenerateSessionId()
    {
        return Guid.NewGuid().ToString();
    }
}

Session 的实现原理非常简单:用 Cookie 记录一个 key,对应服务器端中的数据,如果没有的话就新建一个。如果用于生产服务器的话,Cookie 是必须加密的,并且还要其他一些保护手段。由于实现加密需要引入很多代码,这里就不去实现了。郑重声明:虽然自己实现一个 Session 从原理上来讲并不复杂,要实现真正安全、正确且健壮的 Session 并非易事,并且 Session 也是很多黑客的攻击点。但除非你自认是安全方面的高手,请勿试图手造轮子,否则很容易引入未知的缺陷。

Session 中间件已经实现,我们可以把它加入处理管线中去:

class Program
{
    static void RegisterMiddlewares(IWebServerBuilder builder)
    {
        builder.Use(new HttpLog());
        // builder.Use(new BlockIp("::1", "127.0.0.1"));
        builder.Use(new SessionManager());

        // ... 下同
    }
}

最后,对控制器代码稍作修改,看看是否真的生效了:

public class HomeController : Controller
{ 
    public ActionResult Index()
    {
        int counter = (Session["counter"] != null) ? (int)Session["counter"] : 0;
        counter++;
        Session["counter"] = counter;
        return "counter=" + counter;
    }
}

打开浏览器,多刷新几次,你会看到计数器确实在增长,说明 Session 生效了。

我们已经实现了 Session,让服务器不再患有记忆丧失症。不过你或许没有意识到的是,这里为 HttpListenerContext 的封装也为后续的其他功能提供了一个很好的起点。在下一篇文章中,我们将引入视图引擎(View Engine)的支持,从而让框架能够输出真正的 HTML 页面,而不是硬编码的字符串。

系列文章