Socket写的Web服务器——带详细图解
——闲扯:
Socket是大家都很熟悉的.NET处理底层硬件通信的类。比如:物联网中的一个器件要与其他器件相通信,那就必须使用到Socket来实现。但是我对Socket的中文翻译很不满意:Socket的中文翻译是“套接字”。我请问一下各位读者朋友,我如果只告诉你“套接字”你会知道这是什么吗? Socket的英文含义是:“插座、开关”,但你能通过“套接字”知道Socket的原意吗?
Socket就像一根电话线,连接通两端的电话。让电话可以实现通信。我们声明一个Socket对象从实例开始监听的那一刻开始,Socket就像一个电话插座一样,随时监听等待消息的传入,而我们建立连接就像把插头插在这个插座上一样,一插即可通讯。效果和寓意正如英文的原意:插座、开关相符。
很多的外国技术文献翻译过来很难让人想象到它原本的意思,这是最失败的地方。而且直接音译的“套接字”也很难跟读音['sɑːkɪt]的Socket联系起来,反而更像读音['tɑːɡɪt]的target.很多晦涩难懂的专业技术名词,你只要查看其英文原意,往往都会恍然大悟、醍醐灌顶。我不知道“前辈”们为何会这样翻译,我以为一个东西的翻译可以有更好的选择,最起码不能翻译的太偏、太晦涩,以至于我们这些后来人很难接受。
我认为Socket译为“通信插座”更为恰当。我们设置一个Socket对象的实例开始监听,就像设置一个电话插座在那一样,谁拨我这个“IP地址和端口”,我就接通谁。我觉得Socket翻译成“套接字”相对于林语堂大师翻译的“humor:幽默,sofa:沙发”相比,太让人无法接受了。
总结:我推荐大家尽量去读英文原文的技术资料,去英文编程的技术网站和论坛去看。本人英语6级,虽然没有考过托福、雅思之类的,但是感觉看懂这些英文资料还是比较容易。 这或许受益于本人考研究生时对英语系统的复习,英语几乎每一个单词都有它的来历,‘汉字靠形造词,英语靠音造词’这是导致东西方文化、思想的区别的根源,也是我对学习英语最深的体会。
——正文:
我们用过了IIS服务器,也了解了IIS服务器的实现原理和机制(读者如果不清楚,可以跟着我写完这个模拟的服务器,相信你就会明白了)。那么我们能不能手写一个类似于IIS的Web服务器呢?注意哦!我们这里写的是web服务器,而服务器有多种:FTP服务器(文件服务器)、POP3服务器(邮箱服务器)等,不过我想底层也应该大同小异.
开始:
1、首先新建一个空白的解决方案,命名为WebServer.注意图中红色箭头的说明。
2、在解决方案中添加一个WinForm应用程序,命名为“WebServer”,新建一个Winform窗体,并将窗体重命名为:"ServerForm".
3、拖动控件,进行如下布局:
4、对控件进行重命名操作:参考如图中所示。(希望读者养成规范的、良好的重命名的习惯)
5、布局完毕,剩下就是写程序了。写程序之前,我们需要先分析一下我们写Web服务器的思路
我们的思路:
(1)、先建立一个负责监听的“电话插座”——Socket,这个“电话插座”以指定的“IP地址和端口”作为“电话号码”,随时等待接通每一个拨打此“号码”(连接到此IP和端口)的人(在这里是程序进程)的电话。
(2)、因为我们当前的电话插座需要处理很多通信,所以每接通一个"电话"(接收到连接到该IP和端口的请求),我们就复制一个“电话插座”单独为该“电话”服务。(在这里我们会用到多线程的知识。 )
(3)、电话拨通了,但是我们需要懂双方的语言。也就是双方需要说同一门语言,或最起码有一个共同的互相都能懂得的语言约定。这就是HTTP协议。那么我们的浏览器和服务器之间的HTTP协议是什么样子的呢?往下看。
6、HTTP的协议分为:请求报文协议和响应报文协议。而无论是请求报文还是响应报文,其标准格式都是:头(header)、体(content).如:请求头,请求体;响应头,响应体。
(1)、下面来看一下我们的请求协议的报文是什么样子的:我们熟知的网页对服务器的请求分为get请求和post请求。
a、get请求图(没有“请求体”): (那么get请求的请求体到哪里去了呢?请读者思考一下,相信很容易就想出答案)
b、post请求图(请求头和请求体都有):请注意请求头和请求体之间的空行。这是HTTP协议请求报文的约定。
(2)、下面让我们来看一下响应协议的报文是什么样子的
7、了解了请求协议的报文和响应协议的报文整体格式之后,我们需要进一步分析里面的“有用”的内容。回顾上面的请求报文图我们发现:
在第一行中包含了,请求方法、请求资源地址。
好了我们拿到对方请求的报文之后,就可以截取这些“有用”的内容(注意:这里并不是说其他内容没有用,我们只是模拟Web服务器的主要功能),将响应的请求资源,以“响应协议报文”的格式,发送过去。这样浏览器也就会自动解读你发送的数据,我们的Web服务器也就实现了!
8、源代码开始了:
首先是ServerForm窗体的代码:
//************************************************************************* // //File Name: ServerForm.cs // //Tables: Nothing // //Author: GuoHenghai // //Create Date: 6/08/2013 // //************************************************************************* using System; using System.IO; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Windows.Forms; namespace WebServer { public partial class ServerForm : Form { public ServerForm() { InitializeComponent(); CheckForIllegalCrossThreadCalls = false; } private void btnStart_Click(object sender, EventArgs e) { // 第一步,设置顶级的监听端口的Socket对象 Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); // 准备Socket绑定方法的参数对象IPEndPoint IPAddress ipAddress; if (!IPAddress.TryParse(txtIP.Text.Trim(), out ipAddress)) // 判断当前的IP地址栏数据是否可正常转换为IP地址 { return; } int port; if (!int.TryParse(txtPort.Text.Trim(), out port))// 判断当前的Port是否能转换为数字 { return; } IPEndPoint ipEndPoint = new IPEndPoint(ipAddress, port); // 开始顶级Socket的绑定和监听 try { serverSocket.Bind(ipEndPoint); serverSocket.Listen(10); SetLogText("服务器已经开启..."); // 设置线程,进行连接Socket对象的处理 Thread thread=new Thread(Listen); thread.IsBackground = true;// 必须设置成为后线程,后台线程在窗体关闭的时候,会自动结束自己线程运行 thread.Start(serverSocket);// 将监听的的顶级Socket对象作为参数传入线程委托中的函数里面去 } catch (Exception ex) { // 捕获到异常 SetLogText("服务器已经开启,您无需重复开启!"); SetLogText(" >详细信息:\r\n "+ex.Message); } } // 设置处理每一次监听到的连接的方法 private void Listen(object o) { Socket serverSocket = o as Socket; while (true) { // 将服务监听到的连接,转换成一个Socket对象,后面将使用该连接的Socket进行HTTP请求的接收和响应的处理。 Socket connSocket = serverSocket.Accept(); SetLogText(connSocket.RemoteEndPoint+":已建立连接!"); // 尝试进行HTTP请求的接收和处理 try { // 声明接收HTTP请求的二进制字节数组 // 将接收到的二进制字节存放到声明的二进制字节数组中去 byte[] buffer=new byte[1024*1024]; int realLen = connSocket.Receive(buffer); // 如果接收到的HTTP请求是空的,则关闭当前连接的Socket对象,返回进行下一次连接的监听。 if (realLen <= 0) { // 礼貌地关闭该连接Socket对象 connSocket.Shutdown(SocketShutdown.Both); connSocket.Close(); SetLogText(connSocket.RemoteEndPoint + ":0字节请求,当前连接已关闭!"); return; } // 如果接收到的HTTP请求是正常的,则进行HTTP请求报文的分析,并生成HTTP响应报文 string content = Encoding.UTF8.GetString(buffer,0,realLen); // 读取HTTP请求报文 SetLogText(content);// 将该请求报文记录到服务器日志中 // 将有用的报文信息转换成Request(请求)对象; Request request=new Request(content); // 分析请求报文,进行HTTP响应处理 RequestStaticOrDynamicPage(request.RawUrl,connSocket); } catch (Exception) { // 提示异常的发生,并跳出死循环 SetLogText("当前连接发生异常,请重启服务!"); // 一旦接收异常,关闭此次连接的Socket connSocket.Close(); break; } } } /// <summary> /// 判断请求的是动态页面还是静态页面,并分别针对,进行HTTP响应处理 /// </summary> /// <param name="rawUrl"></param> /// <param name="connsocket"></param> private void RequestStaticOrDynamicPage(string rawUrl, Socket connsocket) { // 根据请求文件的后缀名进行判断 string ext = Path.GetExtension(rawUrl); switch (ext) { case ".aspx": case ".asp": case ".php": case ".jsp": // 动态页面的处理 (挖坑,读者自己来把这里补充完整) break; default: // 静态页面的处理 ProcessStaticPageRequest(rawUrl,connsocket); break; } } /// <summary> /// 处理HTTP的静态页面请求 /// </summary> /// <param name="rawUrl"></param> /// <param name="connsocket"></param> private void ProcessStaticPageRequest(string rawUrl,Socket connsocket) { // 拼接物理路径的字符串,检测当前物理路径的文件是否存在 // 注意 Path.Combine()方法中,第二个开始以后的参数,开头的 / 要去掉,否则拼接出来的路径将从后面的 // 以 / 的字符串开始进行拼接,也就是忽略掉, / 前面的拼接路径字符串 rawUrl = rawUrl.TrimStart('/'); string physicalPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory,"web",rawUrl); // 进行检测当前请求的文件是否存在 if (File.Exists(physicalPath)) { // 文件存在,读取到文件流中,拼接到HTTP响应对象——Response中的“响应报文”中的响应体中。 using (FileStream fs=new FileStream(physicalPath,FileMode.Open)) { // 声明存储文件流的二进制字节数组 // 将文件流读取到声明好的二进制字节数组中去 byte[] buffer=new byte[fs.Length]; fs.Read(buffer, 0, buffer.Length); // 准备发送响应报文 string ext = Path.GetExtension(rawUrl); Response response=new Response(200,buffer,ext); // 发送响应报文,关闭当前Socket连接,注意在这里体现了HTTP协议的无状态根本原因 connsocket.Send(response.GetResponse()); SetLogText(connsocket.RemoteEndPoint+":已关闭连接."); connsocket.Close(); } } else { // 404 页面不存在处理 // 埋坑,读者可以在这里设置一个专门提示的页面,提示用户当前访问资源不存在 } } /// <summary> /// 设置日志文本框的记录方法 /// </summary> /// <param name="msg"></param> private void SetLogText(string msg) { txtLog.AppendText(msg + "\r\n"); } } }
Request对象的代码:
//************************************************************************* // //File Name: Request.cs // //Tables: Nothing // //Author: GuoHenghai // //Create Date: 6/08/2013 // //************************************************************************* using System; namespace WebServer { class Request { #region 私有属性 private string _rawUrl; private string _method; public string RawUrl { get { return _rawUrl; } set { _rawUrl = value; } } public string Method { get { return _method; } set { _method = value; } } #endregion #region 构造函数-属性初始化器 public Request(string content) { // 按行分解请求报文 string[] lines = content.Split(new string[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries); // 按空格分解请求报文中的第一行,并初始化该对象的两个属性 this.Method = lines[0].Split(' ')[0]; this.RawUrl = lines[0].Split(' ')[1]; } #endregion } }
Response对象的代码:
using System.Collections.Generic; using System.Text; //************************************************************************* // //File Name: Response.cs // //Tables: Nothing // //Author: GuoHenghai // //Create Date: 6/08/2013 // //************************************************************************* namespace WebServer { class Response { #region 私有字段、属性 private int _codeStatus; private int _contentLength; private string _contentType; private byte[] _buffer; public int CodeStatus { get { return _codeStatus; } set { _codeStatus = value; } } public int ContentLength { get { return _contentLength; } set { _contentLength = value; } } public string ContentType { get { return _contentType; } set { _contentType = value; } } public byte[] Buffer { get { return _buffer; } set { _buffer = value; } } #endregion #region 构造函数——属性初始化器 public Response(int codeStatus,byte[] buffer,string ext) { FillCodeStaDic(); this.Buffer = buffer; this.CodeStatus = codeStatus; this.ContentLength = buffer.Length; GetContentType(ext); } Dictionary<int,string> codeStatusDic=new Dictionary<int, string>(); /// <summary> /// 填充状态码 字典 /// </summary> private void FillCodeStaDic() { codeStatusDic[200] = "OK"; codeStatusDic[404] = "请求页面不存在!"; //...挖坑,读者可以在这里进行详细的补充 } /// <summary> /// 根据请求文件的后缀名,确定响应体的类型 /// </summary> /// <param name="ext"></param> void GetContentType(string ext) { switch (ext) { case ".css": this.ContentType = "text/css"; break; case ".gif": this.ContentType = "image/gif"; break; case ".ico": this.ContentType = "image/x-icon"; break; case ".jpe": case ".jpeg": case ".jpg": this.ContentType = "image/jpeg"; break; case "bmp": this.ContentType = "image/bmp"; break; case ".js": this.ContentType = "application/x-javascript"; break; case "stm": case ".htm": case ".html": this.ContentType = "text/html"; break; // ...挖坑,读者可以在这里进行详细的补充 } } /// <summary> /// 拼接响应报文 /// </summary> public byte[] GetResponse() { // 拼接响应报文头 StringBuilder sb=new StringBuilder(); sb.Append("HTTP/1.0 "+this.CodeStatus+" "+codeStatusDic[this.CodeStatus]+"\r\n"); sb.Append("Content-Type: "+this.ContentType+"\r\n"); sb.Append("Content-Length: "+this.ContentLength+"\r\n"); sb.Append("Server: ghhSever/1.0\r\n"); sb.Append("X-Powered-By: MannyGuo\r\n");// 大家可以模拟下面的响应报文进行添加,注意格式必须要一致(末尾换行) sb.Append("\r\n"); // 构建响应报文头 byte[] header = Encoding.UTF8.GetBytes(sb.ToString()); // 构建响应报文体 byte[] content = this.Buffer; // 装载响应报文 List<byte>bList=new List<byte>(); bList.AddRange(header); bList.AddRange(content); return bList.ToArray(); } #region 响应报文分析 /* HTTP/1.0 200 OK Content-Type: text/html Content-Length: 337 Connection: keep-alive Date: Sun, 09 Jun 2013 04:50:44 GMT Server: Apache X-Powered-By: PHP/5.2.5 Content-Encoding: gzip Vary: Accept-Encoding Age: 37928 Via: 1.0 fe91fd60a17845818d57d903e10536ce.cloudfront.net (CloudFront) X-Cache: Hit from cloudfront X-Amz-Cf-Id: WKYiDsukwM6go6_K9lF207F72tlhGB6Wv1wgRutHWslDdd_7MoUpdw== 50 */ #endregion #endregion } }
9、演示效果:
为了演示效果,我们需要在程序的debug目录下新建一个Web文件夹,里面放一个测试用的1.html
运行我们自己手写的Web服务器,启动服务。在浏览器地址中输入“IP地址:端口号/页面(或者资源)”,就可以看到效果了。
10、上一篇文章,短短3天内浏览量超过了1000。小郭在此感谢大家的支持!我会一如既往的为大家奉献更多的东西。