用 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 页面,而不是硬编码的字符串。