用 C# 自己动手编写一个 Web 服务器,第一部分——基础

5,901 阅读6分钟

市场上已经有如此之多的 Web 服务器,为什么还要自己写一个?这对真正的黑客来说其实是个无需回答的问题。不过,即便你自认是个小白,也无需被题目吓倒——现代的语言和框架已经为我们提供了非常强大的基础设施,我们用很少的代码就能搭建起一个基础的 Web 服务器。事实上,我们下面要介绍的第一版程序核心代码经过完整的封装、并且提供了静态文件处理,而核心代码也不过 70 行左右,如果你只想要一个静态文件服务器,那么你完全可以把代码压缩到 40 行,而且这些代码非常容易理解。

本系列文章在很大程度上受到来自 Syncfusion Succinctly 系列电子书 中的 Web Servers Succinctly 的启发。但我对于原书中的部分内容并不是很满意,因此我按照自己的理解重新编排了文章内容,代码也是自己从头编写的。原书中的部分内容如线程模型和部署 HTTPS 的章节被我去掉了,因为我认为这些内容对于理解 Web 服务器的原理并非特别重要,或者会对保证代码的连续性造成一定影响。但原书提供的内容也是很有参考价值的资源,如果读者对此感兴趣,并且英文水平尚可的话,推荐从上文的链接自行阅读原书。

Web Servers Succinctly

本系列文章将涵盖下列内容:

  • 创建一个基础的 Web 服务器
  • 创建现代 Web 框架流行的中间件(Middleware)架构
  • 实现路由(Routing)
  • 实现 Session
  • 添加视图引擎(View Engine)支持
  • 实现用户信息和用户验证功能

相信上述内容足以让读者对建立一个 Web 服务器有一个相当深刻的理解。并且我可以保证,这一点都不难!当然,这并不意味着创建一个产品级的 Web 服务器是轻而易举的,因为还有很多相对枯燥、但是必须考虑的细节需要实现。本文意图在于说明实现原理,因而在与核心原理关系不大的细节上做了较多简化,在后续的代码中读者也能看到这一点。我在这里说这些是不希望读者由于示例代码并不复杂,就低估了实现 Web 服务器所需要的辛苦努力。

本文的示例代码也已经全部放到 Github,每篇文章关联的代码放在独立的分支,方便读者参考。因此,要获取本文示例代码,请使用如下命令:

git clone https://github.com/shuhari/web-server-succinctly-example.git
git checkout -b  01-basic-server origin/01-basic-server 

我假定本文读者: * 对 C# 编程语言有一定经验,阅读用 BCL 编写的程序没有障碍; * 对 HTTP 协议有基本的理解。你应该不需要别人向你解释什么是 Request/Response/Session。

除此之外没有了。有 ASP.NET/MVC 的使用经验有助于更好的理解文中的内容,但这并不是必须的。

Talk is cheap, show me the code

我不喜欢上来就讲长篇大论的理论,并且这里的示例代码足够简单,因此让我们直接看代码吧。此外,我个人偏好面向接口的编程风格,因此我在本文和后续的文章中,除特别说明外,一般会先展示接口,然后再转到具体的实现。当然,如果你不喜欢这样的顺序,可以忽略我的介绍,直接打开解决方案。

class Program
{
    private const int concurrentCount = 20;
    private const string serverUrl = "http://localhost:9000/";

    public static void Main(string[] args)
    {
        var server = new WebServer(concurrentCount);
        server.Bind(serverUrl);
        server.Start();

        Console.WriteLine($"Web server started at {serverUrl}. Press any key to exist...");
        Console.ReadKey();
    }
}

这是一个典型的控制台(Console)类型应用。作为示例,我们声明服务器最多可接受 20 个并发连接,并且服务器侦听端口号为 9000 的本地地址(相信绝大多数机器上的典型 Web 端口 80 已经被 IIS 或者 Apache/Nginx 占用了)。代码没有什么好解释的,接下来看 WebServer 的实现吧。

    public class WebServer
    {
        private readonly Semaphore _sem;

        private readonly HttpListener _listener;

        public WebServer(int concurrentCount)
        {
            _sem = new Semaphore(concurrentCount, concurrentCount);
            _listener = new HttpListener();
        }

        public void Bind(string url)
        {
            _listener.Prefixes.Add(url);
        }

        public void Start()
        {
            _listener.Start();

            Task.Run(async () =>
            {
                while (true)
                {
                    _sem.WaitOne();
                    var context = await _listener.GetContextAsync();
                    _sem.Release();
                    HandleRequest(context);
                }
            });
        }

        private void HandleRequest(HttpListenerContext context)
        {
            var request = context.Request;
            var response = context.Response;
            var urlPath = request.Url.LocalPath.TrimStart('/');
            Console.WriteLine($"url path={urlPath}");

            try
            {
                string filePath = Path.Combine("files", urlPath);
                byte[] data = File.ReadAllBytes(filePath);
                response.ContentType = "text/html";
                response.ContentLength64 = data.Length;
                response.ContentEncoding = Encoding.UTF8;
                response.StatusCode = 200;
                response.OutputStream.Write(data, 0, data.Length);
                response.OutputStream.Close();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex);
                Console.WriteLine(ex.StackTrace);
            }
        }
    }

为简洁起见,我不会在文章代码中显示 using 各个命名空间的声明——我认为你有足够的能力识别出它们来自哪里。唯一值得说明的是 HttpListener, 这是 来自 System.Net 命名空间的一个类,正是它为我们完成了大部分底层的协议处理工作,让我们能够非常简单地创建一个 Web Server。如果我们从 Socket 的层次开始创建服务器也是完全可行的(网上也有这样的例子),但代码可就需要至少数百行了。

这里的代码稍具规模,但核心逻辑还是很容易看懂的:

  • 定义了一个信号量(Semaphore),用来控制可接受的并发请求数量;
  • 创建一个侦听器(HttpListener)用来侦听请求;
  • 定义服务器侦听的地址前缀(Prefixes);
  • 定义一个后台任务不断侦听到来的请求。当接收到请求后,listener 会返回给我们一个 HttpListenerContext(这个 context 和我们平时在 ASP.NET 程序中使用的 HttpContext 或 HttpContextBase 除了接口更加简单之外,定义方式几乎是完全一致的);
  • 我们可以从 context.Request 获取请求信息,向 context.Response 写入应答,和 ASP.NET 程序同样几乎没有分别。

目前我们还没有实现真正的动态服务器功能(这是后续文章的任务)。但为了能看到一些结果,我们先采取简单的方法:写好一些.html 文件,放在执行文件的目录下,直接返回文件内容。如果找不到文件的话,程序就会出错,不过这个错误很容易处理,如果读者有兴趣的话就自己练习一下吧。

添加静态文件最简单的方法是直接在项目中创建 .html 文件,并修改编译方式为 Copy to output directory if newer (如下图所示)。本文的示例都是在 JetBrains Rider 下完成的,大部分用户应该会使用 Visual Studio,它们的操作方法是类似的,我就不再分别说明了。

Copy html file to bin directory

启动程序后,在浏览器中访问 http://localhost:9000/index.html,你就会看到我们添加的 .html 文件内容。我们的第一个服务器程序执行成功!

你一定看出来了,这基本上就是一个极简的静态文件服务器。而且只要稍作修改,也可以返回.html之外的内容,或者其他目录的文件,用来在服务器之间发送/接受一些文件也是不错的小工具。当然了,我们的目标是真正的动态服务器,而不仅仅是伺服静态文件,不过通过这个程序,你已经对理解了服务器的基本运行原理。在下一篇文章中,我们将会把程序重构成具有动态功能的 Web 服务框架。

系列文章