使用C#開發HTTP服務器系列之Hello World


各位朋友大家好,我是秦元培,歡迎大家關注我的博客。從今天起,我將開始撰寫一組關於HTTP服務器開發的系列文章。我為什么會有這樣的想法呢?因為人們對Web技術存在誤解,認為網站開發是Web技術的全部。其實在今天這樣一個時代,Web技術可謂是無處不在,無論是傳統軟件開發還是移動應用開發都離不開Web技術,所以在我的認識中,任何使用了HTTP協議實現數據交互都可以認為是Web技術的一種體現,而且當我們提及服務器開發的時候,我們常常提及Java或者PHP。可是這些重要嗎?不,在我看來服務器開發和語言無關,和IIS、Tomcat、Apache、Ngnix等等我們熟知的服務器軟件無關。Web技術可以像一個網站一樣通過瀏覽器來訪問,同樣可以像一個服務一樣通過程序來調用,所以在接下來的時間里,我將和大家一起見證如何使用C#開發一個基本的HTTP服務器,希望通過這些能夠讓大家更好的認識Web技術。

至繁至簡的HTTP

  我們對HTTP協議最直觀的認識應該是來自瀏覽器,因為在互聯網時代我們都是通過瀏覽器這個入口來接觸互聯網的,而到了移動互聯網時代我們開始思考新的互聯網入口。在這個過程中我們有創新的模式不斷涌現出來,同樣有並購、捆綁、壟斷等形式的惡性競爭此起彼伏,所謂“痛並快樂着”。我想說的是,HTTP是一個簡單與復雜並存的東西,那么什么是HTTP呢?我們在瀏覽器中輸入URL的時候,早已任性地連“http”和“www”都省略了吧,所以我相信HTTP對人們來說依然是一個陌生的東西。

  HTTP是超文本傳輸協議(HyperText Transfer Protocol)的簡稱,它建立在C/S架構的應用層協議,熟悉這部分內容的朋友應該清楚,TCP/IP協議是協議層的內容,它定義了計算機間通信的基礎協議,我們熟悉的HTTP、FTP、Telnet等協議都是建立在TCP/IP協議基礎上的。在HTTP協議中,客戶端負責發起一個Request,該Request中含有請求方法、URL、協議版本等信息,服務端在接受到該Request后會返回一個Response,該Response中含有狀態碼、響應內容等信息,這一模型稱為請求/響應模型。HTTP協議迄今為止發展出3個版本:

  • 0.9版本:已過時。該版本僅支持GET一種請求方法,不支持請求頭。因為不支持POST方法,所以客戶端無法向服務器傳遞太多信息。
  • HTTP/1.0版本:這是第一個在通訊中指定版本號的HTTP協議版本,至今依然被廣泛采用,特別是在代理服務器中。
  • HTTP/1.1版本:目前采用的版本。持久連接被默認采用,並能很好地配合代理服務器工作。相對1.0版本,該版本在緩存處理、帶寬優化及網絡連接地使用、錯誤通知地管理、消息在網絡中的發送等方面都有顯著的區別。

  HTTP協議通信的核心是HTTP報文,根據報文發送者的不同,我們將其分為請求報文和響應報文。其中,由客戶端發出的HTTP報文稱為請求報文,由服務端發出的報文稱為響應報文。下面我們來着重了解和認識這兩種不同的報文:

  • 請求報文:請求報文通常由瀏覽器來發起,當我們訪問一個網頁或者請求一個資源的時候都會產生請求報文。請求報文通常由HTTP請求行、請求頭、消息體(可選)三部分組成,服務端在接收到請求報文后根據請求報文請求返回數據給客戶端,所以我們通常講的服務端開發實際上是指在服務端接收到信息以后處理的這個階段。下面是一個基本的請求報文示例:
/* HTTP請求行 */ GET / HTTP/1.1 /* 請求頭部 */ Accept: text/html, application/xhtml+xml, image/jxr, */* Accept-Encoding: gzip, deflate Accept-Language: zh-Hans-CN, zh-Hans; q=0.5 Connection: Keep-Alive Host: localhost:4000 User-Agent: Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko /* 消息體 */
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 響應報文:響應報文是指在服務端接收並處理了客戶端的請求信息以后,服務端發送給客戶端的HTTP報文,服務端開發的重要工作就是處理來自客戶端的請求,所以這是我們開發一個HTTP服務器的核心工作。和請求報文類似,響應報文由HTTP狀態行、響應頭、消息體(可選)三部分組成。例如我們通常熟悉的200和404分別表示連接正常和無法訪問資源這兩種響應狀態。下面是一個基本的響應報文示例:
/* HTTP狀態行 */ HTTP/1.1 200 OK /* 響應頭部 */ Content-Type: text/html;charset=utf-8 Connection: keep-alive Server: Microsoft-IIS/7.0 Date: Sun, 12 Jun 2016 11:00:42 GMT X-Powered-By: Hexo /* 消息體 */
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

  這里需要說明的是,實際的請求報文和響應報文會因為服務端設計的不同,和這里的報文示例略有不同,報文中頭部信息參數種類比較多,我不打算在這里詳細解釋每個參數的含義,我們只需要對報文格式有一個基本的認識即可,想了解這些內容的朋友可以閱讀這里。在請求報文中我們注意到第一行,即HTTP請求行指明當前請求的方法。所以下面我們來說說HTTP協議的基本請求方法。常見的方法有GET、POST、HEAD、DELETE、OPTIONS、TRACE、CONNECT,我們這里選取最常用的兩種方式,即GET和PSOT來講解:

  • GET:最為常見的一種請示方式。當客戶端從服務器讀取文檔或者通過一個鏈接來訪問頁面的時候,都是采用GET方式來請求的。GET請求的一個顯著標志是其請求參數附加在URL后,例如”/index.jsp?id=100&option=bind”這種形式即為GET方式請求。GET方式對用戶而言,傳遞參數過程是透明的,因為用戶可以通過瀏覽器地址欄直接看到參數,所以這種方式更適合用來設計API,即在不需要驗證身份或者對安全性要求不高的場合,需要注意的是GET方式請求對參數長度由一定限制。
  • POST:POST克服了GET方式對參數長度存在限制的缺點,以鍵-值形式將參數封裝在HTTP請求中,所以從理論上講它對參數長度沒有限制(實際上會因為瀏覽器和操作系統的限制而大打折扣),而且對用戶來講參數傳遞過程是不可見的,所以它是一種相對安全的參數傳遞方式。通常用戶登錄都會采取這種方式,我們在編寫爬蟲的時候遇到需要登錄的情況通常都需要使用POST方式進行模擬登錄。

Socket與HTTP的緊密聯系

  到目前為止,我們基本上搞清楚了HTTP是如何運作的,這恰恰符合普通人對技術的認知水平,或許在普通人看起來非常簡單的東西,對技術人員來講永遠都是復雜而深奧的,所以從這個角度來講,我覺的我們更應該向技術人員致敬,因為是技術人員讓這些經過其簡化以后的復雜流程以一種產品的形態走進了你我的生活,感謝有技術和技術人員的存在,讓我們這個世界更加美好。好了,現在我們來思考這樣一個問題,Socket和HTTP有一種怎樣的關聯?這是因為我們目前所有對HTTP的理解都是一種形而上學上的理解,它現在僅僅是一種協議,可是協議離真正的應用很遙遠不是嗎?所以我們需要考慮如何去實現這樣一種協議。我們注意到HTTP是建立在TCP/IP協議上的,所以HTTP的協議應該考慮用TCP/IP協議的實現來實現,考慮到Socket是TCP/IP協議的一種實現,所以我們非常容易地想到應該用Socket來構建一個HTTP服務器,由此我們找到了Socket和HTTP的緊密聯系。

  在找到Socket和HTTP的緊密聯系以后,我們現在就可以開始着手來設計一個HTTP服務器了。我們的思路是這樣的,首先我們在服務端創建一個Socket來負責監聽客戶端連接。每次客戶端發出請求后,我們根據請問報文來判斷客戶端的請求類型,然后根據不同的請求類型進行相應的處理,這樣我們就設計了一個基本的HTTP服務器。

從頭開始設計HTTP服務器

  好了,現在我們要開始從頭設計一個HTTP服務器了,在此之前,我們首先來為整個項目設計下面的基本約束。我一直非常好奇為什么有的開發者會如此強烈地依賴框架。尤其是在Web開發領域,MVC和MVVM基本上是耳熟能詳到爛俗的詞匯。我個人更加認同這是一種思想。什么是思想呢?思想是你知道其絕妙處而絕口不提,卻在潛移默化中心領神會的運行它。可事實上是什么樣呢?無數開發者被框架所禁錮,因為我們缺少了犯錯的機會。所以我在這里不想再提及Java、php、.NET在Web開發領域里那些廣為人知的框架,因為我認為忘掉這些框架可以幫助我們更好的理解框架,下面我就來用我的這種方法告訴大家什么叫做MVC?

  什么叫做MVC?我們都知道MVC由模型、視圖、控制器三部分組成,可是它們的實質是什么呢?我想這個問題可能沒有人想過,因為我們的時間都浪費在配置XML文檔節點上。(我說的就是Java里的配置狂魔)

  首先,模型是什么呢?模型對程序員而言可以是一個實體類,亦可以是一張數據表,而這兩種認知僅僅是因為我們看待問題的角度不同而已,為了讓這兩種認知模型統一,我們想到了ORM、想到了根據數據表生成實體類、想到了在實體類中使用各種語法糖,而這些在我看來非常無聊的東西,竟然可以讓我們不厭其煩地制造出各種框架,對程序員而言我還是喜歡理解為實體類。

  其次,視圖是什么呢?視圖在我看來是一個函數,它返回的是一個HTML結構的文本,而它的參數是一個模型,一個經過我們實例化以后的對象,所以控制器所做的工作無非是從數據庫中獲取數據,然后將其轉化為實體對象,再傳遞給視圖進行綁定而已。這樣聽起來,我們對MVC的理解是不是就清晰了?而現在前端領域興起的Vue.jsReact,從本質上來講是在糾結控制器的這部分工作該有前端來完成還是該有后端來完成而已。

  MVC中有一個路由的概念,這個概念我們可以和HTTP中請求行來對應起來,我們知道發出一個HTTP請求的時候,我們能夠從請求報文中獲得請求方法、請求地址、請求參數等一系列信息,服務器正是根據這些信息來處理客戶端請求的。那么,路由到底是什么呢?路由就是這里的請求地址,它可以是實際的文件目錄、可以是虛擬化的Web API、可以是項目中的文件目錄,而一切的一切都在於我們如何定義路由,例如我們定義的路由是”http://www.zhihu.com/people/vczh“,從某種意義上來講,它和”http://www.zhihu.com/people/?id=vczh“是一樣的,因為服務器總是能股一眼看出這些語法糖的區別。

  雖然我在竭盡全力地避免形成對框架的依賴,可是在設計一個項目的時候,我們依然需要做些宏觀上的規划,我設計的一個原則就是簡單、輕量,我不喜歡重度產品,我喜歡小而美的東西,就像我喜歡C#這門語言而不喜歡ASP.NET一樣,因為我喜歡Nancy這個名字挺起來文藝而使用起來簡單、開心的東西。我不會像某語言一樣喪心病狂地使用接口和抽象類的,在我這里整體設計是非常簡單的: 
* IServer.cs:定義服務器接口,該接口定義了OnGet()、OnPost()、OnDefault()、OnListFiles()四個方法,分別用來響應GET請求、響應POST請求、響應默認請求、列取目錄,我們這里的服務器類HttpServer需要實現該接口。 
* Request.cs:封裝來自客戶端的請求報文繼承自BaseHeader。 
* Response.cs:封裝來自服務端的響應報文繼承自BaseHeader。 
* BaseHeader.cs: 封裝通用頭部和實體頭部。 
* HttpServer.cs: HTTP服務器基類需實現IServer接口。

  因為我這里希望實現的是一種全局上由我來控制,細節上由你來決定的面向開發者的設計思路,這和通常的面向大眾的產品思路是完全不同的。例如委托或者事件的一個重要意義就是,它可以讓程序按照設計者的思路來運行,同時滿足使用着在細節上的控制權。所以,在寫完這個項目以后,我們就可以無需再關注客戶端和服務端如何通信這些細節,而將更多的精力放在服務器接收到了什么、如何處理、怎樣返回這樣的問題上來,這和框架希望我們將精力放在業務上的初衷是一樣的,可是事實上關注業務對開發者來講是趨害的,對公司來講則是趨利的。當你發現你因為熟悉了業務而逐漸淪落為框架填充者的時候,你有足夠的理由來喚起內心想要控制一切的欲望。世界很大、人生很短,這本來就是一個矛盾的存在,當我們習慣在框架中填充代碼的時候,你是否會想到人生本來沒有這樣的一個框架?

  好了,現在我們來開始編寫這個Web服務器中通信的基礎部分。首先我們需要創建一個服務端Socket來監聽客戶端的請求。如果你熟悉Socket開發,你將期望看到下面這樣的代碼:

/// <summary> /// 開啟服務器 /// </summary> public void Start() { if(isRunning) return; //創建服務端Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); serverSocket.Bind(new IPEndPoint(IPAddress.Parse(ServerIP), ServerPort)); serverSocket.Listen(10); isRunning = true; //輸出服務器狀態 Console.WriteLine("Sever is running at http://{0}:{1}/.", ServerIP, ServerPort); //連接客戶端 while(isRunning) { Socket clientSocket = serverSocket.Accept(); Thread requestThread = new Thread(() =>{ ProcessRequest(clientSocket);}); requestThread.Start(); } }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

這里我們使用isRunning來表示服務器是否運行,顯然當服務器處在運行狀態時,它應該返回。我們這里使用ServerIP和ServerPort分別表示服務端IP和端口,創建服務端Socket這里就不再贅述了,因為這是非常簡單而基礎的東西。當服務器處在運行狀態時我們接受一個客戶端請求,並使用一個獨立的線程來處理請求,客戶端請求的處理我們這里提供了一個叫做ProcessRequest的方法,它具體都做了什么工作呢?我們繼續往下看:

/// <summary> /// 處理客戶端請求 /// </summary> /// <param name="handler">客戶端Socket</param> private void ProcessRequest(Socket handler) { //構造請求報文 HttpRequest request = new HttpRequest(handler); //根據請求類型進行處理 if(request.Method == "GET"){ OnGet(request); }else if(request.Method == "POST"){ OnPost(request); }else{ OnDefault(); } }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

接下來我們可以注意到我們這里根據客戶端Soket構造了一個請求報文,其實就是在請求報文的構造函數中通過解析客戶端發來的消息,然后將其和我們這里定義的HttpRequest類對應起來。我們這里可以看到,根據請求方法的不同,我們這里分別采用OnGet、OnPost和OnDefault三個方法進行處理,而這些是定義在IServer接口中並在HttpServer類中聲明為虛方法。嚴格來講,這里應該有更多的請求方法類型,可是因為我這里寫系列文章的關系,我想目前暫時就實現Get和Post兩種方法,所以這里大家如果感興趣的話可以做更深層次的研究。所以,現在我們就明白了,因為這些方法都被聲明為虛方法,所以我們只需要HttpServer類的子類中重寫這些方法就可以了嘛,這好像離我最初的設想越來越近了呢。關於請求報文的構造,大家可以到http://github.com/qinyuanpei/HttpServer/中來了解,實際的工作就是解析字符串而已,這些微小的工作實在不值得在這里單獨來講。

  我們今天的正事兒是什么呢?是Hello World啊,所以我們需要想辦法讓這個服務器給我們返回點什么啊,接下來我們繼承HttpServer類來寫一個具體的類MyServer,和期望的一樣,我們僅僅需要重寫相關方法就可以寫一個基本的Web服務器,需要注意的是子類需要繼承父類的構造函數。我們一起來看代碼:

using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using System.IO; namespace HttpServerLib { public class MyServer : HttpServer { public MyServer(string ipAddress, int port) : base(ipAddress, port) { } public override void OnGet(HttpRequest request) { HttpResponse response = new HttpResponse("<html><body><h1>Hello World</h1></body></html>", Encoding.UTF8); response.StatusCode = "200"; response.Server = "A Simple HTTP Server"; response.Content_Type = "text/html"; ProcessResponse(request.Handler, response); } } }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

可以注意到我們這里構造了一個HttpResponse,這是我這里定義的HTTP響應報文,我們這里響應的內容是一段簡單的HTML采用UTF-8編碼。在構造完HttpResponse以后我們設定了它的相關狀態,熟悉Web開發的朋友應該可以想到這是抓包工具抓包時得到的服務端報文信息,最近博主最喜歡的某個妹子寫真集網站開始反爬蟲了,因此博主以前寫的Python腳本現在執行會被告知403,這是一個禁止訪問的狀態碼。解決方案其實非常簡單地,將HTTP請求偽裝成一個“瀏覽器”即可,思路就是在HTTP請求報文中增加相關字段,這樣就可以“騙”過服務器,當然更深層次的“欺騙”就是Cookie和Session級別的偽裝了,這個話題我們有時間再說。這里我們設定狀態碼為200,這是一個正常的請求,其次ContentType等字段可以自行閱讀HTTP協議中頭部字段的相關資料,最后我們通過ProcessResponse這個方法來處理響應,其內部是一個使用Socket發送消息的基本實現,詳細的設計細節大家可以看項目代碼。

  現在讓我們懷着無比激動的心情運行我們的服務器,此時服務器運行情況是:

服務器運行情況

這樣是不是有一種恍若隔世的感覺啊,每次打開Hexo的時候看到它自帶的本地服務器,感覺非常高大上啊,結果萬萬沒想到有朝一日你就自己實現了它,這叫做“長大以后我就成了你嗎”?哈哈,現在是見證奇跡的時刻:

瀏覽器運行情況

瀏覽器懷着對未來無限的憧憬,自豪地寫下“Hello World”,正如很多年前詩人北島在絕望中寫下的《相信未來》一樣,或許生活中眼前都是苟且,可是只要心中有詩和遠方,我們就永遠不會迷茫。好了,至此這個系列第一篇Hello World終於寫完了,簡直如釋重負啊,第一篇需要理解和學習的東西實在太多了,本來打算在文章后附一份詳細的HTTP頭部字段說明,可是因為這些概念實在太枯燥,而使用Markdown編寫表格時表格內容過多是寫作者的無盡痛苦。關於這個問題,大家可以從這里找到答案。下期再見!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM