完整项目托管地址:https://github.com/hooow-does-it-work/http
准备一个JS文件,直接去尤大大那拿了个。
https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js
跟你编译好的程序放在一起,只要能读到的位置即可。
0、实现一个用于写入Chunked数据的流ChunkedWriteStream
很简单的一个实现,只保留了写入的功能,所有读取的功能均禁用掉了。
/// <summary>
/// 实现同步写的Stream,不支持读取操作
/// </summary>
public class ChunkedWriteStream : Stream
{
private static byte[] _crlf = new byte[] { 13, 10};
private static byte[] _ending = Encoding.ASCII.GetBytes("0\r\n\r\n");
private Stream _innerStream = null;
private bool _leaveInnerStreamOpen = true;
/// <summary>
/// 使用指定基础流和模式创建实例
/// </summary>
/// <param name="stream"></param>
/// <param name="leaveInnerStreamOpen"></param>
public ChunkedWriteStream(Stream stream, bool leaveInnerStreamOpen)
{
_innerStream = stream;
_leaveInnerStreamOpen = leaveInnerStreamOpen;
}
/// <summary>
/// 写入数据块到基础流
/// </summary>
/// <param name="buffer"></param>
/// <param name="offset"></param>
/// <param name="count"></param>
public override void Write(byte[] buffer, int offset, int count)
{
///组装包头,包含长度数据
///数据长度的16进制表示形式+\r\n
///举例:
///如果数据长度是10,那么包头是:a\r\n
///如果数据长度是20,那么包头是:14\r\n
///如果数据长度是256,那么包头是:100\r\n
string length = count.ToString("x") + "\r\n";
byte[] lengthBuffer = Encoding.ASCII.GetBytes(length);
///写入长度数据
_innerStream.Write(lengthBuffer, 0, lengthBuffer.Length);
///写入包数据
_innerStream.Write(buffer, offset, count);
///写入包尾,固定的回车换行
_innerStream.Write(_crlf, 0, 2);
}
protected override void Dispose(bool disposing)
{
if (disposing && !_leaveInnerStreamOpen)
{
_innerStream?.Close();
}
_innerStream = null;
base.Dispose(disposing);
}
public override void Flush() { }
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long length) => throw new NotSupportedException();
public override bool CanRead => false;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
}
1、为HttpResponser增加一个方法OpenWrite
一个虚方法,主要是要在子类实现具体的逻辑。
在这里可以查看HttpResponser类的实现
public virtual Stream OpenWrite(Stream baseStream)
{
WriteHeader(baseStream);
return baseStream;
}
2、为ChunkedResponser增加一个方法,重写HttpResponser的OpenWrite
重写的目的是在子类中把响应标头写入客户端,并返回一个基于基础流的ChunkedWriteStream
。
在这里可以查看ChunkedResponser类的实现
/// <summary>
/// 打开一个流,用于写入数据
/// </summary>
/// <param name="baseStream"></param>
/// <returns>ChunkedWriteStream流</returns>
public override Stream OpenWrite(Stream baseStream)
{
//基类的OpenWrite会写入响应标头,并把baseStream返回,跟下面两行等价
//WriteHeader(baseStream);
//return new ChunkedWriteStream(baseStream, true);
return new ChunkedWriteStream(base.OpenWrite(baseStream), true);
}
3、把JS文件压缩后,写入客户端
这里顺便做了个文件是否存在的判断,文件不存在就响应404给客户端。
代码中的三层using
也不难理解:
1、基于基础流,创建一个Chunked写入流:ChunkedWriteStream
2、创建GzipStream,压缩模式,压缩后写入目标是ChunkedWriteStream
3、打开文件,将未压缩的数据写入GzipStream
4、GzipStream内部将压缩后的数据写入ChunkedWriteStream
5、ChunkedWriteStream
封包后,将封包的数据写入基础流
public class HttpServer : TcpIocpServer
{
protected override void NewClient(Socket client)
{
Stream stream = new NetworkStream(client, true);
//捕获一个HttpRequest
HttpRequest request = HttpRequest.Capture(stream);
//从request拿到Url
string url = request.Url;
//url是以'/'开头的,这里简单处理下,合成新路径
//实际应用中,为了安全我会对url进行一个判断,判断有没有危险字符,例如'..',这两个点可能会导致全盘读取任意文件
string vueAt = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "." + url));
if (!File.Exists(vueAt))
{
//404这里直接关闭了基础流,所以既没有写Content-Length,也没有用Chunked传输
//用完关闭,让客户端读取完所有数据即可。
HttpResponser notFound = new HttpResponser(404);
notFound.ContentType = "text/html; charset=utf-8";
notFound.KeepAlive = false;
notFound.Write(stream, $"文件'{vueAt}'未找到。");
stream.Close();
return;
}
//实例化ChunkedResponser类
//ChunkedResponser会自动写入Transfer-Encoding: chunked头
HttpResponser responser = new ChunkedResponser();
//使用Content-Encoding标头,告诉客户端发送的是经过Gzip压缩的数据。
responser["Content-Encoding"] = "gzip";
//发送的是JS文件,设置Content-Type
responser.ContentType = "text/javascript";
responser.KeepAlive = false;
//使用ChunkedResponser打开一个写入流ChunkedWriteStream
//GzipStream压缩后,直接写入ChunkedWriteStream
//介意套娃的同学可以使用简单写法。
using (Stream output = responser.OpenWrite(stream))
{
//实例化Gzip压缩流,压缩后写入output
using (GZipStream input = new GZipStream(output, CompressionMode.Compress))
{
//打开文件,读取数据
using (FileStream source = File.OpenRead(vueAt))
{
//使用原生方法,把文件数据拷贝到Gzip压缩流
source.CopyTo(input);
}
}
}
//这一句不能忘记,写入结束包
responser.End(stream);
stream.Close();
}
}
浏览器输入:http://127.0.0.1:4189/vue.js
可以看到浏览器正确接收到了压缩后的JS文件,传输大小只有90.9KB,原文件是336KB。
4、后话
其实,文章写到这里,你已经完全可以自己实现一个轻量级的静态文件服务器了。
当然,由于发文都是在讲原理和具体实现方法,代码细节都没有优化。
中除了TcpIocpServer
类使用了IOCP
来接受连接,使用ThreadPool.UnsafeQueueUserWorkItem
去在新线程处理业务逻辑外,其他的代码都是同步的,包括解析请求,写入响应等。
示例中的各种文件IO、网络IO操作都可以使用异步方法实现。
同时,对请求行解析时单个字节读取(ReadByte
)也存在问题,对基础设备读取太多,都有待优化。
后续,会继续实现POST
请求的处理。