C#实现HTTP服务器:(10)处理文件上传

完整项目托管地址:https://github.com/hooow-does-it-work/http

HTTP还有重要的一块:文件上传。
这篇文章将详细讲解下,前面实现了同一个链接处理多个请求,为了方便,我们独立写了一个HTTP基类,专门处理HTTP请求。
https://github.com/hooow-does-it-work/http/blob/main/src/Http/HttpServerBase.cs
本类实现了简单的路由功能,路由功能后续可以使用正则或path2regexp去处理,以处理更复杂的路由请求。
增加了对静态文件的处理,没匹配到的路由都会进入OnResource逻辑。
增加了WebRoot和UploadTempDir的设置,WebRoot目录下的静态文件在HTTP请求时都会自动加载,不需要单独写路由。
UploadTempDir用来临时保存上传的文件。

0、上传简介

上传文件时,使用Content-Type: multipart/form-data; boundary=[BOUNDARY]标头来告诉服务器,请求实体为multipart/form-data编码。
服务器根据编码协议解析multipart/form-data的内容即可,其中[BOUNDARY]为一个请求实体“块”的结束或开始标识,用于解析实体内容。
在浏览器中,为form标签增加enctype="multipart/form-data"属性时,浏览器会自动生成对应的上传标头。
例如下面的标头,为Chrome浏览器生成的:

Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryuHXBXxnxXp0aCz08

1、上传时到底传送了什么格式的数据

话不多说,直接上代码,直观点。
先从这里https://github.com/hooow-does-it-work/http/tree/main/bin/Release把web目录及其内容放到你自己的Debug或Release编译结果目录下。

实现一个测试服务器

注意,继承的是HttpServerBase基类,在OnReceivedPost方法显示下浏览器发送的内容。

public class HttpServer : HttpServerBase
{
    public HttpServer() : base()
    {
        //设置Web根目录
        //方便输出静态文件
        WebRoot = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "web"));
        UplaodTempDir = AppDomain.CurrentDomain.BaseDirectory + "uploads";
        //注册一些路由
        RegisterRoute("/", OnIndex);
        RegisterRoute("/post", OnReceivedPost);
    }
    /// <summary>
    /// 首页路由处理程序,跳转到index.html
    /// </summary>
    /// <param name="request"></param>
    /// <param name="stream"></param>
    private bool OnIndex(HttpRequest request, Stream stream)
    {
        //跳转到页面
        HttpResponser responser = new ChunkedResponser(301);
        responser.ContentType = "text/html; charset=utf-8";
        responser["Location"] = "/index.html";
        responser.Write(stream, "Redirect To '/index.html'");
        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:12px;}</style>");
        responser.Write(stream, "<h4>上传表单演示</h4>");
        responser.Write(stream, $"<a href=\"/index.html\">返回</a><br />");

        responser.Write(stream, $"ContentType:{request.ContentType}<br />");
        responser.Write(stream, $"Boundary:{request.Boundary}<br />");
		
		///这里输出下浏览器发送来的请求
        responser.Write(stream, $"<pre>{Encoding.UTF8.GetString( request.RequestBody)}</pre>");


        responser.End(stream);

        return true;
    }
}

运行服务器,浏览器访问:http://127.0.0.1:4189/index.html,展示如下一个表单。
在这里插入图片描述
我们什么都不上传,直接点提交,看看服务器收到些什么内容。
在这里插入图片描述
绿色部分就是我们收到的请求实体内容。

编码分析

实例中的[BOUNDARY]为:----WebKitFormBoundarydyAItGK9UU5xVhZq
1、首行:--[BOUNDARY]\r\n 在boundary前面补两个中横线(-)。
首行读取完毕后,即开始表单项的读取。

2、非文件表单项:
每行一个标头,直到空行,空行后表单内容开始。这里跟Http请求头的读取方法一致
表单内容结束标识为:\r\n--[BOUNDARY],也是块结束标识。

Content-Disposition: form-data; name="name"

测试hello world!
------WebKitFormBoundarydyAItGK9UU5xVhZq

掩码说明:

Content-Disposition: form-data; name="[表单项名称]"\r\n\r\n[内容]\r\n--[BOUNDARY]

3、文件表单项
标头的读取和结束标志跟上面的一致。
只是Content-Disposition会多一个filename属性,因为我们没选择文件,filename值为空。
同时,会提供一个Content-Type标头来标识文件类型。
不排除序应用程序会提供更多的标头,我们只要读取到空行,关心我们需要的标头即可。

Content-Disposition: form-data; name="image"; filename=""
Content-Type: application/octet-stream


------WebKitFormBoundarydyAItGK9UU5xVhZq

掩码说明:

Content-Disposition: form-data; name="[表单项名称]"; filename=""\r\nContent-Type: application/octet-stream\r\n\r\n[文件内容]\r\n--[BOUNDARY]

4、如何确定结束标识之后是下一个表单项,还是请求实体的结尾。
读取到表单内容结束标识后,再往前读取两个字节。
如果两个字节为\r\n,代表后面还有其他的表单项。
如果两个字节为--,代表所有表单项已读取完毕,请求实体也读完了。

2、实现上传请求实体的解析。

解析使用了两个类。
将请求实体解析为Form和Files:https://github.com/hooow-does-it-work/http/blob/main/src/Http/Utils/HttpMultipartFormDataParser.cs
Multipart数据读取的辅助流:https://github.com/hooow-does-it-work/http/blob/main/src/Http/Streams/MultipartReadStream.cs
辅助流里面实现了核心的数据解析,内部用到了BoyerMoore字符串查找算法。
我们主要是讲解协议原理,协议解析这部分可以不用关心,我写的数据解析也可能不是很严格(我不会告诉你,我写完后就看不懂了)。

修改我们上面实现的服务器中OnReceivePost方法,我们这次把上传的表单和文件列出来。

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:12px;}</style>");
    responser.Write(stream, "<h4>上传表单演示</h4>");
    responser.Write(stream, $"<a href=\"/index.html\">返回</a><br />");

    responser.Write(stream, $"ContentType:{request.ContentType}<br />");
    responser.Write(stream, $"Boundary:{request.Boundary}<br />");


    #region 输出解析后的上传内容
    responser.Write(stream, $"<h5>上传表单数据:</h5>");
    foreach (string formName in request.Form.Keys)
    {
        responser.Write(stream, $"{formName}: {request.Form[formName]}<br />");
    }

    responser.Write(stream, $"<h5>上传文件列表:</h5>");
    foreach (FileItem file in request.Files)
    {
        responser.Write(stream, $"{file.Name}: {file.FileName}, {file.TempFile}<br />");
    }
    #endregion

    #region 输出解析前的上传内容,不能同时与上面代码块运行
    //responser.Write(stream, $"<pre style=\"font-family:'microsoft yahei',arial; color: green\">{Encoding.UTF8.GetString( request.RequestBody)}</pre>");
    #endregion

    responser.End(stream);

    return true;
}

运行服务器,浏览器访问:http://127.0.0.1:4189/index.html,现在我们选择几个文件,为了方便演示,建议选择有少量文本的文本文件。
头像我选择了两个文件,微信选择了一个。
在这里插入图片描述
提交表单。下面可以看到,服务器正确处理了表单数据和三个文件数据。
FileItem对象保存了表单名,文件名,文件类型和文件的临时保存路径,可以将文件移动到应用实际的目录。
在这里插入图片描述

移除对OnReceivedPost中对输出解析前的上传内容代码块的注释,并且把输出解析后的上传内容代码块注释掉,刷新页面,可以查看原始未解析的数据。可以对照我们前面对上传数据的编码分析查看下。
在这里插入图片描述

3、总结

1、文件上传主要是增加了Content-Type的设置,使服务器能正确处理上传的内容。
2、请求实体的解析部分,因为不像HttpRequest一样有Content-Length来标识具体的长度,只能用boundary去分析什么时候开始解析,什么时候结束解析。
3、对于上传的请求,请求实体解析后,ResponseBody就取不到内容了,所以要想看到请求的具体内容,不能调用Form或Files方法,因为这两个方法一旦调用,上传请求就会被自动解析了。

  • 0
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Anlige

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

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

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

打赏作者

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

抵扣说明:

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

余额充值