1. 程式人生 > >使用C#開發HTTP伺服器系列之Hello World

使用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

/* 訊息體 */
  • 響應報文:響應報文是指在服務端接收並處理了客戶端的請求資訊以後,服務端傳送給客戶端的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

/* 訊息體 */

  這裡需要說明的是,實際的請求報文和響應報文會因為服務端設計的不同,和這裡的報文示例略有不同,報文中頭部資訊引數種類比較多,我不打算在這裡詳細解釋每個引數的含義,我們只需要對報文格式有一個基本的認識即可,想了解這些內容的朋友可以閱讀這裡。在請求報文中我們注意到第一行,即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.js和React,從本質上來講是在糾結控制器的這部分工作該有前端來完成還是該有後端來完成而已。

  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();
    }
}

這裡我們使用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();
    }
}

接下來我們可以注意到我們這裡根據客戶端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);
        }
    }
}

可以注意到我們這裡構造了一個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編寫表格時表格內容過多是寫作者的無盡痛苦。關於這個問題,大家可以從這裡找到答案。下期再見!