项目托管地址:https://github.com/hooow-does-it-work/http
前面的文章,我们处理请求都是在一个连接上的,请求处理完,就关闭Stream
,同时关闭了底层的Socket
。
如果一个网页包含的资源比较多,总共可能会需要发起几十上百个Socket
连接,造成不必要的网络消耗(TCP的握手、挥手)。
同时,浏览器对同一个网站发起的连接数是有限制的,所以会导致页面资源加载缓慢的问题。
我们来一步步解决这个问问,使一个连接可以处理多个请求。
0、需要解决的问题
需要解决的问题就一个:如何开始“下一个”请求。
如下代码,循环从stream读取Request,然后响应数据到客户端。
HttpResponser
我们没有设置KeepAlive
属性,客户端默认当做长链,会继续在连接上发送下一个请求。
可以说我们已经实现了在一个连接里面处理多个请求了。
protected override void NewClient(Socket client)
{
Stream stream = new BufferedNetworkStream(client, true);
//设置每个链接能处理的请求数
int processedRequest = 0;
while (processedRequest < MaxRequestPerConnection)
{
HttpRequest request = HttpRequest.Capture(stream);
HttpResponser responser = new ChunkedResponser(400);
responser.ContentType = "text/html; charset=utf-8";
responser.Write(stream,"Hello World!");
responser.End(stream);
}
stream.Close();
}
对于没有请求实体数据的请求来说,这代码可以实现我们的需求。
但是一旦客户端在请求中发送了实体数据,我们必须确保服务器已经从基础流中将实体数据全部读完了。
否则,在下一个请求Capture
的时候,可能会从上一个请求实体内读取了数据,造成“脏读”,无法正确处理请求。
在上篇文章中我们在HttpRequest
中实现了OpenRead
打开一个数据流来读取请求体的数据。
现在我们再实现一个方法,来确保请求体的数据被读完。
原理很简单,数据读出来,直接丢弃即可。如果没有实体数据或者实体数据已经被读了,直接退出逻辑。
/// <summary>
/// 关于请求实体,RFC2616有一句‘A server SHOULD read and forward a message-body on any request;’
/// 对于任何request,服务端应该将请求实体“读完”
/// 我们必须得这么做,也就是将OpenRead打开的流读完
/// 否则在KeepAlive保持的长链里,极有可能造成“脏读”,导致下一个request没法被正常解析
/// </summary>
public void EnsureEntityBodyRead()
{
//没有请求实体或者数据已经被读了,忽略
if (!HasEntityBody || _entityReadStream != null) return;
using(Stream forward = OpenRead())
{
byte[] forwardBytes = new byte[32768];
//读取,丢弃
while (forward.Read(forwardBytes, 0, 32768) > 0) ;
}
}
只要在下一个Request被读取前,调用EnsureEntityBodyRead
“清空”数据流即可。
至此,我们确保请求体数据被读完的需求解决了。
可以在这里https://github.com/hooow-does-it-work/http/blob/main/src/Http/HttpRequest.cs查看我们重写的HttpRequest
类。
为了方便读取数据,我们还增加了Form
和RequestBody
属性,来读取表单数据和请求实体的二进制数据。
1、如何测试我们的需求
由于后面我们会写更多的逻辑和页面来做演示,我们写个简单的“路由”功能,方便不同的请求执行不同的逻辑、渲染不同的页面。
按照如下扩展HttpServer
,我们定义了一个私有变量保存路由。
定义了*
去匹配所有未被匹配到的路由,展示一个404页面。
对于请求处理过程中的异常,我们也定义了OnServerError
和OnBadRequest
来处理服务器错误和客户端错误。
任何404页面和错误页面,我们都会直接断开连接,不再保持长链。
还定义了一个OnFavicon
方法来给浏览器发送一个图标,请随便找个ico文件,跟编译好的程序放在相同目录。
或者,我也准备了一个ico文件:https://ssl.cookcode.cc/favicon.ico
public class HttpServer : TcpIocpServer
{
private static int MaxRequestPerConnection = 20;
//后面的代码可能会越来越复杂,我们做个简单的路由功能
//可以开发功能更强大的路由
private Dictionary<string, Func<HttpRequest, Stream, bool>> _routes = new Dictionary<string, Func<HttpRequest, Stream, bool>>();
public HttpServer() : base()
{
//注册一些路由
_routes["/"] = new Func<HttpRequest, Stream, bool>(OnIndex);
_routes["/favicon.ico"] = new Func<HttpRequest, Stream, bool>(OnFavicon);
_routes["/post"] = new Func<HttpRequest, Stream, bool>(OnReceivedPost);
_routes["*"] = new Func<HttpRequest, Stream, bool>(OnNotFound);
}
protected override void NewClient(Socket client)
{
//控制台输出下,跟踪下新连接
Console.WriteLine($"New Client: {client.RemoteEndPoint}");
Stream stream = new BufferedNetworkStream(client, true);
//设置每个链接能处理的请求数
int processedRequest = 0;
while (processedRequest < MaxRequestPerConnection)
{
HttpRequest request = null;
try
{
//捕获一个HttpRequest
request = HttpRequest.Capture(stream);
//控制台输出,跟踪下新请求
Console.WriteLine($"New Request: {request.Method} {request.Url}");
//尝试查找路由,不存在的话使用NotFound路由
if (!_routes.TryGetValue(request.Path, out Func<HttpRequest, Stream, bool> handler))
{
handler = _routes["*"];
}
//如果处理程序返回false,那么我们退出循环,关掉连接。
if (!handler(request, stream)) break;
//确保当前请求的请求实体读取完毕
request.EnsureEntityBodyRead();
//释放掉当前请求,准备下一次请求
processedRequest++;
}
catch (HttpRequestException e)
{
if (e.Error == HttpRequestError.ConnectionLost) break;
//客户端发送的请求异常
OnBadRequest(stream, $"请求异常:{e.Error}");
break;
}
catch (Exception e)
{
//其他异常
OnServerError(stream, $"请求异常:{e}");
break;
}
finally
{
//始终释放请求
request?.Dispose();
}
}
stream.Close();
}
/// <summary>
/// 首页路由处理程序
/// </summary>
/// <param name="request"></param>
/// <param name="stream"></param>
private bool OnIndex(HttpRequest request, Stream stream)
{
//展示下客户端请求的一些东西
HttpResponser responser = new ChunkedResponser();
responser.ContentType = "text/html; charset=utf-8";
responser.Write(stream,
@"<form method=""POST"" action=""/post?action=save"" >
姓名:<input type=text name=name value=""测试hello world!"" /> <br />
年龄:<input type=text name=age value=31 /> <br />
<input type=submit value=提交 />
</form>");
responser.End(stream);
return true;
}
/// <summary>
/// 处理POST数据
/// </summary>
/// <param name="request"></param>
/// <param name="stream"></param>
/// <returns></returns>
private bool OnReceivedPost(HttpRequest request, Stream stream)
{
//展示下客户端请求的一些东西
HttpResponser responser = new ChunkedResponser();
responser.ContentType = "text/html; charset=utf-8";
responser.Write(stream, "<style type=\"text/css\">body{font-size:14px;}</style>");
responser.Write(stream, "<h4>Hello World!</h4>");
responser.Write(stream, $"<a href=\"/\">返回</a><br />");
responser.Write(stream, $"Host Name: {request.Headers["host"]} <br />");
responser.Write(stream, $"Method: {request.Method} <br />");
responser.Write(stream, $"Request Url: {request.Url} <br />");
responser.Write(stream, $"HttpPrototol: {request.HttpProtocol} <br />");
responser.Write(stream, $"Time Now: {DateTime.Now: yyyy-MM-dd HH:mm:ss} <br /><br />");
//输出解析后的查询字符串数据
responser.Write(stream, $"查询参数解析结果:<br />");
var queryString = request.QueryString;
foreach (string name in queryString.Keys)
{
responser.Write(stream, $" {name} = {queryString[name]}<br />");
}
//输出解析后的form表单数据
var form = request.Form;
responser.Write(stream, $"POST解析结果:<br />");
foreach (string name in form.Keys)
{
responser.Write(stream, $" {name} = {form[name]}<br />");
}
responser.End(stream);
return true;
}
/// <summary>
/// 输出favicon.ico给浏览器用
/// </summary>
/// <param name="request"></param>
/// <param name="stream"></param>
private bool OnFavicon(HttpRequest request, Stream stream)
{
HttpResponser responser = new HttpResponser();
string iconPath = AppDomain.CurrentDomain.BaseDirectory + "favicon.ico";
if (!File.Exists(iconPath))
{
return OnNotFound(request, stream);
}
byte[] iconData = File.ReadAllBytes(iconPath);
responser.ContentType = "image/vnd.microsoft.icon";
responser.ContentLength = iconData.Length;
responser.Write(stream, iconData);
return true;
}
/// <summary>
/// 响应404错误
/// </summary>
/// <param name="request"></param>
/// <param name="stream"></param>
/// <returns></returns>
private bool OnNotFound(HttpRequest request, Stream stream)
{
HttpResponser responser = new ChunkedResponser(404);
responser.KeepAlive = false;
responser.ContentType = "text/html; charset=utf-8";
responser.Write(stream, $"请求的资源'{request.Path}'不存在。");
responser.End(stream);
return false;
}
/// <summary>
/// 请求异常
/// </summary>
/// <param name="request"></param>
/// <param name="stream"></param>
/// <param name="message"></param>
private void OnBadRequest(Stream stream, string message)
{
HttpResponser responser = new ChunkedResponser(400);
responser.KeepAlive = false;
responser.ContentType = "text/html; charset=utf-8";
responser.Write(stream, message);
responser.End(stream);
}
/// <summary>
/// 服务器异常
/// </summary>
/// <param name="request"></param>
/// <param name="stream"></param>
/// <param name="message"></param>
private void OnServerError(Stream stream, string message)
{
HttpResponser responser = new ChunkedResponser(500);
responser.KeepAlive = false;
responser.ContentType = "text/html; charset=utf-8";
responser.Write(stream, message);
responser.End(stream);
}
}
运行服务,浏览器访问:http://127.0.0.1:4189
查看效果
HttpServer server = new HttpServer();
try
{
server.Start("0.0.0.0", 4189);
Console.WriteLine("服务器启动成功,监听地址:" + server.LocalEndPoint.ToString());
}catch(Exception e)
{
Console.WriteLine(e.ToString());
}
Console.ReadLine();
可以看到,服务器只有一个连接进来,处理了两次请求。继续点击提交
按钮。
同样没有新连接产生,并且新的POST请求被处理了。
2、总结
1、响应给客户端数据的时候,不设置KeepAlive
属性,此时客户端不会收到Connection
头,浏览器在收到Connection: keep-alive
或者没收到Connection
标头时,会认为连接是一个长链,可以在连接上发送新的请求。
2、服务端必须确保上一个请求
的请求实体被完整读取,防止新请求
读取到脏数据。
3、测试的时候,一定要放一个favicon.ico在程序目录,不然会因为404断开连接,无法达到测试效果。