C#实现HTTP服务器:(9)在一个Socket连接内处理多个HTTP请求

项目托管地址: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类。
为了方便读取数据,我们还增加了FormRequestBody属性,来读取表单数据和请求实体的二进制数据。

1、如何测试我们的需求

由于后面我们会写更多的逻辑和页面来做演示,我们写个简单的“路由”功能,方便不同的请求执行不同的逻辑、渲染不同的页面。
按照如下扩展HttpServer,我们定义了一个私有变量保存路由。
定义了*去匹配所有未被匹配到的路由,展示一个404页面。
对于请求处理过程中的异常,我们也定义了OnServerErrorOnBadRequest来处理服务器错误和客户端错误。
任何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, $"&nbsp; &nbsp; {name} = {queryString[name]}<br />");
        }

        //输出解析后的form表单数据
        var form = request.Form;
        responser.Write(stream, $"POST解析结果:<br />");
        foreach (string name in form.Keys)
        {
            responser.Write(stream, $"&nbsp; &nbsp; {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断开连接,无法达到测试效果。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Anlige

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值