是一名程序員,我的主要編程語言是 Java,我更是一名 Web 開發人員,所以我必須要了解 HTTP,所以本篇文章就來帶你從 HTTP 入門到進階,看完讓你有一種恍然大悟、醍醐灌頂的感覺。
最初在有網絡之前,我們的電腦都是單機的,單機系統是孤立的,我還記得 05 年前那會兒家里有個電腦,想打電腦游戲還得兩個人在一個電腦上玩兒,及其不方便。我就想為什么家里人不讓上網,我的同學 xxx 家里有網,每次一提這個就落一通批評:xxx上xxx什xxxx么xxxx網xxxx看xxxx你xxxx考xxxx的xxxx那xxxx點xxxx分。雖然我家里沒有上網,但是此時互聯網已經在高速發展了,HTTP 就是高速發展的一個產物。
首先你聽的最多的應該就是 HTTP 是一種 超文本傳輸協議(Hypertext Transfer Protocol),這你一定能說出來,但是這樣還不夠,假如你是大廠面試官,這不可能是他想要的最終結果,我們在面試的時候往往把自己知道的盡可能多的說出來,才有和面試官談價錢的資本。那么什么是超文本傳輸協議?
超文本傳輸協議可以進行文字分割:超文本(Hypertext)、傳輸(Transfer)、協議(Protocol),它們之間的關系如下
按照范圍的大小 協議 > 傳輸 > 超文本。下面就分別對這三個名次做一個解釋。
在互聯網早期的時候,我們輸入的信息只能保存在本地,無法和其他電腦進行交互。我們保存的信息通常都以文本即簡單字符的形式存在,文本是一種能夠被計算機解析的有意義的二進制數據包。而隨著互聯網的高速發展,兩臺電腦之間能夠進行數據的傳輸后,人們不滿足只能在兩臺電腦之間傳輸文字,還想要傳輸圖片、音頻、視頻,甚至點擊文字或圖片能夠進行超鏈接的跳轉,那么文本的語義就被擴大了,這種語義擴大后的文本就被稱為超文本(Hypertext)。
那么我們上面說到,兩臺計算機之間會形成互聯關系進行通信,我們存儲的超文本會被解析成為二進制數據包,由傳輸載體(例如同軸電纜,電話線,光纜)負責把二進制數據包由計算機終端傳輸到另一個終端的過程(對終端的詳細解釋可以參考 你說你懂互聯網,那這些你知道么?這篇文章)稱為傳輸(transfer)。
通常我們把傳輸數據包的一方稱為請求方,把接到二進制數據包的一方稱為應答方。請求方和應答方可以進行互換,請求方也可以作為應答方接受數據,應答方也可以作為請求方請求數據,它們之間的關系如下
如圖所示,A 和 B 是兩個不同的端系統,它們之間可以作為信息交換的載體存在,剛開始的時候是 A 作為請求方請求與 B 交換信息,B 作為響應的一方提供信息;隨著時間的推移,B 也可以作為請求方請求 A 交換信息,那么 A 也可以作為響應方響應 B 請求的信息。
協議這個名詞不僅局限于互聯網范疇,也體現在日常生活中,比如情侶雙方約定好在哪個地點吃飯,這個約定也是一種協議,比如你應聘成功了,企業會和你簽訂勞動合同,這種雙方的雇傭關系也是一種 協議。注意自己一個人對自己的約定不能成為協議,協議的前提條件必須是多人約定。
那么網絡協議是什么呢?
網絡協議就是網絡中(包括互聯網)傳遞、管理信息的一些規范。如同人與人之間相互交流是需要遵循一定的規矩一樣,計算機之間的相互通信需要共同遵守一定的規則,這些規則就稱為網絡協議。
沒有網絡協議的互聯網是混亂的,就和人類社會一樣,人不能想怎么樣就怎么樣,你的行為約束是受到法律的約束的;那么互聯網中的端系統也不能自己想發什么發什么,也是需要受到通信協議約束的。
那么我們就可以總結一下,什么是 HTTP?可以用下面這個經典的總結回答一下: HTTP 是一個在計算機世界里專門在兩點之間傳輸文字、圖片、音頻、視頻等超文本數據的約定和規范
隨著網絡世界演進,HTTP 協議已經幾乎成為不可替代的一種協議,在了解了 HTTP 的基本組成后,下面再來帶你進一步認識一下 HTTP 協議。
網絡是一個復雜的系統,不僅包括大量的應用程序、端系統、通信鏈路、分組交換機等,還有各種各樣的協議組成,那么現在我們就來聊一下網絡中的協議層次。
為了給網絡協議的設計提供一個結構,網絡設計者以分層(layer)的方式組織協議,每個協議屬于層次模型之一。每一層都是向它的上一層提供服務(service),即所謂的服務模型(service model)。每個分層中所有的協議稱為 協議棧(protocol stack)。因特網的協議棧由五個部分組成:物理層、鏈路層、網絡層、運輸層和應用層。我們采用自上而下的方法研究其原理,也就是應用層 -> 物理層的方式。
應用層是網絡應用程序和網絡協議存放的分層,因特網的應用層包括許多協議,例如我們學 web 離不開的 HTTP,電子郵件傳送協議 SMTP、端系統文件上傳協議 FTP、還有為我們進行域名解析的 DNS 協議。應用層協議分布在多個端系統上,一個端系統應用程序與另外一個端系統應用程序交換信息分組,我們把位于應用層的信息分組稱為 報文(message)。
因特網的運輸層在應用程序斷點之間傳送應用程序報文,在這一層主要有兩種傳輸協議 TCP和 UDP,利用這兩者中的任何一個都能夠傳輸報文,不過這兩種協議有巨大的不同。
TCP 向它的應用程序提供了面向連接的服務,它能夠控制并確認報文是否到達,并提供了擁塞機制來控制網絡傳輸,因此當網絡擁塞時,會抑制其傳輸速率。
UDP 協議向它的應用程序提供了無連接服務。它不具備可靠性的特征,沒有流量控制,也沒有擁塞控制。我們把運輸層的分組稱為 報文段(segment)
因特網的網絡層負責將稱為 數據報(datagram) 的網絡分層從一臺主機移動到另一臺主機。網絡層一個非常重要的協議是 IP 協議,所有具有網絡層的因特網組件都必須運行 IP 協議,IP 協議是一種網際協議,除了 IP 協議外,網絡層還包括一些其他網際協議和路由選擇協議,一般把網絡層就稱為 IP 層,由此可知 IP 協議的重要性。
現在我們有應用程序通信的協議,有了給應用程序提供運輸的協議,還有了用于約定發送位置的 IP 協議,那么如何才能真正的發送數據呢?為了將分組從一個節點(主機或路由器)運輸到另一個節點,網絡層必須依靠鏈路層提供服務。鏈路層的例子包括以太網、WiFi 和電纜接入的 DOCSIS 協議,因為數據從源目的地傳送通常需要經過幾條鏈路,一個數據包可能被沿途不同的鏈路層協議處理,我們把鏈路層的分組稱為 幀(frame)
雖然鏈路層的作用是將幀從一個端系統運輸到另一個端系統,而物理層的作用是將幀中的一個個 比特 從一個節點運輸到另一個節點,物理層的協議仍然使用鏈路層協議,這些協議與實際的物理傳輸介質有關,例如,以太網有很多物理層協議:關于雙絞銅線、關于同軸電纜、關于光纖等等。
五層網絡協議的示意圖如下
我們上面討論的計算網絡協議模型不是唯一的 協議棧,ISO(國際標準化組織)提出來計算機網絡應該按照7層來組織,那么7層網絡協議棧與5層的區別在哪里?
從圖中可以一眼看出,OSI 要比上面的網絡模型多了 表示層 和 會話層,其他層基本一致。表示層主要包括數據壓縮和數據加密以及數據描述,數據描述使得應用程序不必擔心計算機內部存儲格式的問題,而會話層提供了數據交換的定界和同步功能,包括建立檢查點和恢復方案。
就如同各大郵箱使用電子郵件傳送協議 SMTP 一樣,瀏覽器是使用 HTTP 協議的主要載體,說到瀏覽器,你能想起來幾種?是的,隨著網景大戰結束后,瀏覽器迅速發展,至今已經出現過的瀏覽器主要有
瀏覽器正式的名字叫做 Web Broser,顧名思義,就是檢索、查看互聯網上網頁資源的應用程序,名字里的 Web,實際上指的就是 World Wide Web,也就是萬維網。
我們在地址欄輸入URL(即網址),瀏覽器會向DNS(域名服務器,后面會說)提供網址,由它來完成 URL 到 IP 地址的映射。然后將請求你的請求提交給具體的服務器,在由服務器返回我們要的結果(以HTML編碼格式返回給瀏覽器),瀏覽器執行HTML編碼,將結果顯示在瀏覽器的正文。這就是一個瀏覽器發起請求和接受響應的過程。
Web 服務器的正式名稱叫做 Web Server,Web 服務器一般指的是網站服務器,上面說到瀏覽器是 HTTP 請求的發起方,那么 Web 服務器就是 HTTP 請求的應答方,Web 服務器可以向瀏覽器等 Web 客戶端提供文檔,也可以放置網站文件,讓全世界瀏覽;可以放置數據文件,讓全世界下載。目前最主流的三個Web服務器是Apache、 Nginx 、IIS。
CDN的全稱是Content Delivery Network,即內容分發網絡,它應用了 HTTP 協議里的緩存和代理技術,代替源站響應客戶端的請求。CDN 是構建在現有網絡基礎之上的網絡,它依靠部署在各地的邊緣服務器,通過中心平臺的負載均衡、內容分發、調度等功能模塊,使用戶就近獲取所需內容,降低網絡擁塞,提高用戶訪問響應速度和命中率。CDN的關鍵技術主要有內容存儲和分發技術。
打比方說你要去亞馬遜上買書,之前你只能通過購物網站購買后從美國發貨過海關等重重關卡送到你的家里,現在在中國建立一個亞馬遜分基地,你就不用通過美國進行郵寄,從中國就能把書盡快給你送到。
WAF 是一種 Web 應用程序防護系統(Web Application Firewall,簡稱 WAF),它是一種通過執行一系列針對HTTP / HTTPS的安全策略來專門為Web應用提供保護的一款產品,它是應用層面的防火墻,專門檢測 HTTP 流量,是防護 Web 應用的安全技術。
WAF 通常位于 Web 服務器之前,可以阻止如 SQL 注入、跨站腳本等攻擊,目前應用較多的一個開源項目是 ModSecurity,它能夠完全集成進 Apache 或 Nginx。
WebService 是一種 Web 應用程序,WebService是一種跨編程語言和跨操作系統平臺的遠程調用技術。
Web Service 是一種由 W3C 定義的應用服務開發規范,使用 client-server 主從架構,通常使用 WSDL 定義服務接口,使用 HTTP 協議傳輸 XML 或 SOAP 消息,它是一個基于 Web(HTTP)的服務架構技術,既可以運行在內網,也可以在適當保護后運行在外網。
HTML 稱為超文本標記語言,是一種標識性的語言。它包括一系列標簽.通過這些標簽可以將網絡上的文檔格式統一,使分散的 Internet 資源連接為一個邏輯整體。HTML 文本是由 HTML 命令組成的描述性文本,HTML 命令可以說明文字,圖形、動畫、聲音、表格、鏈接等。
Web 頁面(Web page)也叫做文檔,是由一個個對象組成的。一個對象(Objecy) 只是一個文件,比如一個 HTML 文件、一個 JPEG 圖形、一個 Java 小程序或一個視頻片段,它們在網絡中可以通過 URL 地址尋址。多數的 Web 頁面含有一個 HTML 基本文件 以及幾個引用對象。
舉個例子,如果一個 Web 頁面包含 HTML 文件和5個 JPEG 圖形,那么這個 Web 頁面就有6個對象:一個 HTML 文件和5個 JPEG 圖形。HTML 基本文件通過 URL 地址引用頁面中的其他對象。
在互聯網中,任何協議都不會單獨的完成信息交換,HTTP 也一樣。雖然 HTTP 屬于應用層的協議,但是它仍然需要其他層次協議的配合完成信息的交換,那么在完成一次 HTTP 請求和響應的過程中,需要哪些協議的配合呢?一起來看一下
TCP/IP 協議你一定聽過,TCP/IP 我們一般稱之為協議簇,什么意思呢?就是 TCP/IP 協議簇中不僅僅只有 TCP 協議和 IP 協議,它是一系列網絡通信協議的統稱。而其中最核心的兩個協議就是 TCP / IP 協議,其他的還有 UDP、ICMP、ARP 等等,共同構成了一個復雜但有層次的協議棧。
TCP 協議的全稱是 Transmission Control Protocol 的縮寫,意思是傳輸控制協議,HTTP 使用 TCP 作為通信協議,這是因為 TCP 是一種可靠的協議,而可靠能保證數據不丟失。
IP 協議的全稱是 Internet Protocol 的縮寫,它主要解決的是通信雙方尋址的問題。IP 協議使用 IP 地址 來標識互聯網上的每一臺計算機,可以把 IP 地址想象成為你手機的電話號碼,你要與他人通話必須先要知道他人的手機號碼,計算機網絡中信息交換必須先要知道對方的 IP 地址。(關于 TCP 和 IP 更多的討論我們會在后面詳解)
你有沒有想過為什么你可以通過鍵入 www.google.com 就能夠獲取你想要的網站?我們上面說到,計算機網絡中的每個端系統都有一個 IP 地址存在,而把 IP 地址轉換為便于人類記憶的協議就是 DNS 協議。
DNS 的全稱是域名系統(Domain Name System,縮寫:DNS),它作為將域名和 IP 地址相互映射的一個分布式數據庫,能夠使人更方便地訪問互聯網。
我們上面提到,你可以通過輸入 www.google.com 地址來訪問谷歌的官網,那么這個地址有什么規定嗎?我怎么輸都可以?AAA.BBB.CCC 是不是也行?當然不是的,你輸入的地址格式必須要滿足 URI 的規范。
URI的全稱是(Uniform Resource Identifier),中文名稱是統一資源標識符,使用它就能夠唯一地標記互聯網上資源。
URL的全稱是(Uniform Resource Locator),中文名稱是統一資源定位符,也就是我們俗稱的網址,它實際上是 URI 的一個子集。
URI 不僅包括 URL,還包括 URN(統一資源名稱),它們之間的關系如下
HTTP 一般是明文傳輸,很容易被攻擊者竊取重要信息,鑒于此,HTTPS 應運而生。HTTPS 的全稱為 (Hyper Text Transfer Protocol over SecureSocket Layer),全稱有點長,HTTPS 和 HTTP 有很大的不同在于 HTTPS 是以安全為目標的 HTTP 通道,在 HTTP 的基礎上通過傳輸加密和身份認證保證了傳輸過程的安全性。HTTPS 在 HTTP 的基礎上增加了 SSL 層,也就是說 HTTPS=HTTP + SSL。(這塊我們后面也會詳談 HTTPS)
你是不是很好奇,當你在瀏覽器中輸入網址后,到底發生了什么事情?你想要的內容是如何展現出來的?讓我們通過一個例子來探討一下,我們假設訪問的 URL 地址為 http://www.someSchool.edu/someDepartment/home.index,當我們輸入網址并點擊回車時,瀏覽器內部會進行如下操作
至此,鍵入網址再按下回車的全過程就結束了。上述過程描述的是一種簡單的請求-響應全過程,真實的請求-響應情況可能要比上面描述的過程復雜很多。
從上面整個過程中我們可以總結出 HTTP 進行分組傳輸是具有以下特征
我們上面描述了一下 HTTP 的請求響應過程,流程比較簡單,但是凡事就怕認真,你這一認真,就能拓展出很多東西,比如 HTTP 報文是什么樣的,它的組成格式是什么? 下面就來探討一下
HTTP 協議主要由三大部分組成:
其中起始行和頭部字段并成為 請求頭 或者 響應頭,統稱為 Header;消息正文也叫做實體,稱為 body。HTTP 協議規定每次發送的報文必須要有 Header,但是可以沒有 body,也就是說頭信息是必須的,實體信息可以沒有。而且在 header 和 body 之間必須要有一個空行(CRLF),如果用一幅圖來表示一下的話,我覺得應該是下面這樣
我們使用上面的那個例子來看一下 http 的請求報文
如圖,這是 http://www.someSchool.edu/someDepartment/home.index 請求的請求頭,通過觀察這個 HTTP 報文我們就能夠學到很多東西,首先,我們看到報文是用普通 ASCII 文本書寫的,這樣保證人能夠可以看懂。然后,我們可以看到每一行和下一行之間都會有換行,而且最后一行(請求頭部后)再加上一個回車換行符。
每個報文的起始行都是由三個字段組成:方法、URL 字段和 HTTP 版本字段。
HTTP 請求方法一般分為 8 種,它們分別是
我們一般最常用的方法也就是 GET 方法和 POST 方法,其他方法暫時了解即可。下面是 HTTP1.0 和 HTTP1.1 支持的方法清單
HTTP 協議使用 URI 定位互聯網上的資源。正是因為 URI 的特定功能,在互聯網上任意位置的資源都能訪問到。URL 帶有請求對象的標識符。在上面的例子中,瀏覽器正在請求對象 /somedir/page.html 的資源。
我們再通過一個完整的域名解析一下 URL
比如 http://www.example.com:80/path/to/myfile.html?key1=value1&key2=value2#SomewhereInTheDocument 這個 URL 比較繁瑣了吧,你把這個 URL 搞懂了其他的 URL 也就不成問題了。
首先出場的是 http
http://告訴瀏覽器使用何種協議。對于大部分 Web 資源,通常使用 HTTP 協議或其安全版本,HTTPS 協議。另外,瀏覽器也知道如何處理其他協議。例如, mailto: 協議指示瀏覽器打開郵件客戶端;ftp:協議指示瀏覽器處理文件傳輸。
第二個出場的是 主機
www.example.com 既是一個域名,也代表管理該域名的機構。它指示了需要向網絡上的哪一臺主機發起請求。當然,也可以直接向主機的 IP address 地址發起請求。但直接使用 IP 地址的場景并不常見。
第三個出場的是 端口
我們前面說到,兩個主機之間要發起 TCP 連接需要兩個條件,主機 + 端口。它表示用于訪問 Web 服務器上資源的入口。如果訪問的該 Web 服務器使用HTTP協議的標準端口(HTTP為80,HTTPS為443)授予對其資源的訪問權限,則通常省略此部分。否則端口就是 URI 必須的部分。
上面是請求 URL 所必須包含的部分,下面就是 URL 具體請求資源路徑
第四個出場的是 路徑
/path/to/myfile.html 是 Web 服務器上資源的路徑。以端口后面的第一個 / 開始,到 ? 號之前結束,中間的 每一個/ 都代表了層級(上下級)關系。這個 URL 的請求資源是一個 html 頁面。
緊跟著路徑后面的是 查詢參數
?key1=value1&key2=value2 是提供給 Web 服務器的額外參數。如果是 GET 請求,一般帶有請求 URL 參數,如果是 POST 請求,則不會在路徑后面直接加參數。這些參數是用 & 符號分隔的鍵/值對列表。key1=value1 是第一對,key2=value2 是第二對參數
緊跟著參數的是錨點
#SomewhereInTheDocument 是資源本身的某一部分的一個錨點。錨點代表資源內的一種“書簽”,它給予瀏覽器顯示位于該“加書簽”點的內容的指示。 例如,在HTML文檔上,瀏覽器將滾動到定義錨點的那個點上;在視頻或音頻文檔上,瀏覽器將轉到錨點代表的那個時間。值得注意的是 # 號后面的部分,也稱為片段標識符,永遠不會與請求一起發送到服務器。
表示報文使用的 HTTP 協議版本。
這部分內容只是大致介紹一下,內容較多,后面會再以一篇文章詳述
在表述完了起始行之后我們再來看一下請求頭部,現在我們向上找,找到http://www.someSchool.edu/someDepartment/home.index,來看一下它的請求頭部
Host: www.someschool.edu
Connection: close
User-agent: Mozilla/5.0
Accept-language: fr
復制代碼
這個請求頭信息比較少,首先 Host 表示的是對象所在的主機。你也許認為這個 Host 是不需要的,因為 URL 不是已經指明了請求對象的路徑了嗎?這個首部行提供的信息是 Web 代理高速緩存所需要的。Connection: close 表示的是瀏覽器需要告訴服務器使用的是非持久連接。它要求服務器在發送完響應的對象后就關閉連接。User-agent: 這是請求頭用來告訴 Web 服務器,瀏覽器使用的類型是 Mozilla/5.0,即 Firefox 瀏覽器。Accept-language 告訴 Web 服務器,瀏覽器想要得到對象的法語版本,前提是服務器需要支持法語類型,否則將會發送服務器的默認版本。下面我們針對主要的實體字段進行介紹(具體的可以參考 developer.mozilla.org/zh-CN/docs/… MDN 官網學習)
HTTP 的請求標頭分為四種: 通用標頭、請求標頭、響應標頭 和 實體標頭,依次來進行詳解。
通用標頭主要有三個,分別是 Date、Cache-Control 和 Connection
Date
Date 是一個通用標頭,它可以出現在請求標頭和響應標頭中,它的基本表示如下
Date: Wed, 21 Oct 2015 07:28:00 GMT
復制代碼
表示的是格林威治標準時間,這個時間要比北京時間慢八個小時
Cache-Control
Cache-Control 是一個通用標頭,他可以出現在請求標頭和響應標頭中,Cache-Control 的種類比較多,雖然說這是一個通用標頭,但是又一些特性是請求標頭具有的,有一些是響應標頭才有的。主要大類有 可緩存性、閾值性、 重新驗證并重新加載 和其他特性
可緩存性是唯一響應標頭才具有的特性,我們會在響應標頭中詳述。
閾值性,這個我翻譯可能不準確,它的原英文是 Expiration,我是根據它的值來翻譯的,你看到這些值可能會覺得我翻譯的有點道理
Connection
Connection 決定當前事務(一次三次握手和四次揮手)完成后,是否會關閉網絡連接。Connection 有兩種,一種是持久性連接,即一次事務完成后不關閉網絡連接
Connection: keep-alive
復制代碼
另一種是非持久性連接,即一次事務完成后關閉網絡連接
Connection: close
復制代碼
HTTP1.1 其他通用標頭如下
實體標頭是描述消息正文內容的 HTTP 標頭。實體標頭用于 HTTP 請求和響應中。頭部Content-Length、 Content-Language、 Content-Encoding 是實體頭。
Content-Language: de-DE
Content-Language: en-US
Content-Language: de-DE, en-CA
復制代碼
Accept-Encoding: gzip, deflate //請求頭
Content-Encoding: gzip //響應頭
復制代碼
下面是一些實體標頭字段
上面給出的例子請求報文的屬性比較少,下面給出一個 MDN 官網的例子
GET /home.html HTTP/1.1
Host: developer.mozilla.org
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:50.0) Gecko/20100101 Firefox/50.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://developer.mozilla.org/testpage.html
Connection: keep-alive
Upgrade-Insecure-Requests: 1
If-Modified-Since: Mon, 18 Jul 2016 02:36:04 GMT
If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a"
Cache-Control: max-age=0
復制代碼
Host
Host 請求頭指明了服務器的域名(對于虛擬主機來說),以及(可選的)服務器監聽的TCP端口號。如果沒有給定端口號,會自動使用被請求服務的默認端口(比如請求一個 HTTP 的 URL 會自動使用80作為端口)。
Host: developer.mozilla.org
復制代碼
上面的 Accpet、 Accept-Language、Accept-Encoding 都是屬于內容協商的請求標頭,我們會在下面說明
Referer
HTTP Referer 屬性是請求標頭的一部分,當瀏覽器向 web 服務器發送請求的時候,一般會帶上 Referer,告訴服務器該網頁是從哪個頁面鏈接過來的,服務器因此可以獲得一些信息用于處理。
Referer: https://developer.mozilla.org/testpage.html
復制代碼
Upgrade-Insecure-Requests
Upgrade-Insecure-Requests 是一個請求標頭,用來向服務器端發送信號,表示客戶端優先選擇加密及帶有身份驗證的響應。
Upgrade-Insecure-Requests: 1
復制代碼
If-Modified-Since
HTTP 的 If-Modified-Since 使其成為條件請求:
If-Modified-Since 通常會與 If-None-Match 搭配使用,If-Modified-Since 用于確認代理或客戶端擁有的本地資源的有效性。獲取資源的更新日期時間,可通過確認首部字段 Last-Modified 來確定。
大白話說就是如果在 Last-Modified 之后更新了服務器資源,那么服務器會響應200,如果在 Last-Modified 之后沒有更新過資源,則返回 304。
If-Modified-Since: Mon, 18 Jul 2016 02:36:04 GMT
復制代碼
If-None-Match
If-None-Match HTTP請求標頭使請求成為條件請求。 對于 GET 和 HEAD 方法,僅當服務器沒有與給定資源匹配的 ETag 時,服務器才會以200狀態發送回請求的資源。 對于其他方法,僅當最終現有資源的ETag與列出的任何值都不匹配時,才會處理請求。
If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a"
復制代碼
ETag 屬于響應標頭,后面進行介紹。
內容協商機制是指客戶端和服務器端就響應的資源內容進行交涉,然后提供給客戶端最為適合的資源。內容協商會以響應資源的語言、字符集、編碼方式等作為判斷的標準。
內容協商主要有以下3種類型:
這種協商方式是由服務器端進行內容協商。服務器端會根據請求首部字段進行自動處理
這種協商方式是由客戶端來進行內容協商。
是服務器驅動和客戶端驅動的結合體,是由服務器端和客戶端各自進行內容協商的一種方法。
內容協商的分類有很多種,主要的幾種類型是 Accept、Accept-Charset、Accept-Encoding、Accept-Language、Content-Language。
Accept
接受請求 HTTP 標頭會通告客戶端其能夠理解的 MIME 類型
那么什么是 MIME 類型呢?在回答這個問題前你應該先了解一下什么是 MIME
MIME: MIME (Multipurpose Internet Mail Extensions) 是描述消息內容類型的因特網標準。MIME 消息能包含文本、圖像、音頻、視頻以及其他應用程序專用的數據。
也就是說,MIME 類型其實就是一系列消息內容類型的集合。那么 MIME 類型都有哪些呢?
文本文件: text/html、text/plain、text/css、application/xhtml+xml、application/xml
圖片文件: image/jpeg、image/gif、image/png
視頻文件: video/mpeg、video/quicktime
應用程序二進制文件: application/octet-stream、application/zip
比如,如果瀏覽器不支持 PNG 圖片的顯示,那 Accept 就不指定image/png,而指定可處理的 image/gif 和 image/jpeg 等圖片類型。
一般 MIME 類型也會和 q 這個屬性一起使用,q 是什么?q 表示的是權重,來看一個例子
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
復制代碼
這是什么意思呢?若想要給顯示的媒體類型增加優先級,則使用 q=來額外表示權重值,沒有顯示權重的時候默認值是1.0 ,我給你列個表格你就明白了
q MIME 1.0 text/html 1.0 application/xhtml+xml 0.9 application/xml 0.8 * / *
也就是說,這是一個放置順序,權重高的在前,低的在后,application/xml;q=0.9 是不可分割的整體。
Accept-Charset
accept-charset 屬性規定服務器處理表單數據所接受的字符集。
accept-charset 屬性允許您指定一系列字符集,服務器必須支持這些字符集,從而得以正確解釋表單中的數據。
該屬性的值是用引號包含字符集名稱列表。如果可接受字符集與用戶所使用的字符即不相匹配的話,瀏覽器可以選擇忽略表單或是將該表單區別對待。
此屬性的默認值是 unknown,表示表單的字符集與包含表單的文檔的字符集相同。
常用的字符集有: UTF-8 - Unicode 字符編碼 ; ISO-8859-1 - 拉丁字母表的字符編碼
Accept-Language
首部字段 Accept-Language 用來告知服務器用戶代理能夠處理的自然語言集(指中文或英文等),以及自然語言集的相對優先級。可一次指定多種自然語言集。 和 Accept 首部字段一樣,按權重值 q來表示相對優先級。
Accept-Language: en-US,en;q=0.5
復制代碼
請求標頭我們大概就介紹這幾種,后面會有一篇文章詳細深挖所有的響應頭的,下面是一個響應頭的匯總,基于 HTTP 1.1
響應標頭是可以在 HTTP 響應種使用的 HTTP 標頭,這聽起來是像一句廢話,不過確實是這樣解釋。并不是所有出現在響應中的標頭都是響應標頭。還有一些特殊的我們上面說過,有通用標頭和實體標頭也會出現在響應標頭中,比如 Content-Length 就是一個實體標頭,但是,在這種情況下,這些實體請求通常稱為響應頭。下面以一個例子為例和你探討一下響應頭
200 OK
Access-Control-Allow-Origin: *
Connection: Keep-Alive
Content-Encoding: gzip
Content-Type: text/html; charset=utf-8
Date: Mon, 18 Jul 2016 16:06:00 GMT
Etag: "c561c68d0ba92bbeb8b0f612a9199f722e3a621a"
Keep-Alive: timeout=5, max=997
Last-Modified: Mon, 18 Jul 2016 02:36:04 GMT
Server: Apache
Set-Cookie: mykey=myvalue; expires=Mon, 17-Jul-2017 16:06:00 GMT; Max-Age=31449600; Path=/; secure
Transfer-Encoding: chunked
Vary: Cookie, Accept-Encoding
x-frame-options: DENY
復制代碼
響應狀態碼
首先出現的應該就是 200 OK,這是 HTTP 響應標頭的狀態碼,它表示著響應成功完成。HTTP 響應標頭的狀態碼有很多,并做了如下規定
以 2xx 為開頭的都表示請求成功響應。
狀態碼 含義 200 成功響應 204 請求處理成功,但是沒有資源可以返回 206 對資源某一部分進行響應,由Content-Range 指定范圍的實體內容。
以 3xx 為開頭的都表示需要進行附加操作以完成請求
狀態碼 含義 301 永久性重定向,該狀態碼表示請求的資源已經重新分配 URI,以后應該使用資源現有的 URI 302 臨時性重定向。該狀態碼表示請求的資源已被分配了新的 URI,希望用戶(本次)能使用新的 URI 訪問。 303 該狀態碼表示由于請求對應的資源存在著另一個 URI,應使用 GET 方法定向獲取請求的資源。 304 該狀態碼表示客戶端發送附帶條件的請求時,服務器端允許請求訪問資源,但未滿足條件的情況。 307 臨時重定向。該狀態碼與 302 Found 有著相同的含義。
以 4xx 的響應結果表明客戶端是發生錯誤的原因所在。
狀態碼 含義 400 該狀態碼表示請求報文中存在語法錯誤。當錯誤發生時,需修改請求的內容后再次發送請求。 401 該狀態碼表示發送的請求需要有通過 HTTP 認證(BASIC 認證、DIGEST 認證)的認證信息。 403 該狀態碼表明對請求資源的訪問被服務器拒絕了。 404 該狀態碼表明服務器上無法找到請求的資源。
以 5xx 為開頭的響應標頭都表示服務器本身發生錯誤
狀態碼 含義 500 該狀態碼表明服務器端在執行請求時發生了錯誤。 503 該狀態碼表明服務器暫時處于超負載或正在進行停機維護,現在無法處理請求。
Access-Control-Allow-Origin
一個返回的 HTTP 標頭可能會具有 Access-Control-Allow-Origin ,Access-Control-Allow-Origin 指定一個來源,它告訴瀏覽器允許該來源進行資源訪問。 否則-對于沒有憑據的請求 *通配符,告訴瀏覽器允許任何源訪問資源。例如,要允許源 https://mozilla.org 的代碼訪問資源,可以指定:
Access-Control-Allow-Origin: https://mozilla.org
Vary: Origin
復制代碼
如果服務器指定單個來源而不是 *通配符的話 ,則服務器還應在 Vary 響應標頭中包含 Origin ,以向客戶端指示 服務器響應將根據原始請求標頭的值而有所不同。
Keep-Alive
上面我們提到,HTTP 報文標頭會分為四種,這其實是按著上下文來分類的
還有一種分類是根據代理進行分類,根據代理會分為端到端頭 和 逐跳標頭
而 Keep-Alive 表示的是 Connection 非持續連接的存活時間,如下
Connection: Keep-Alive
Keep-Alive: timeout=5, max=997
復制代碼
Keep-Alive 有兩個參數,它們是以逗號分隔的參數列表,每個參數由一個標識符和一個由等號=分隔的值組成。
timeout:指示空閑連接必須保持打開狀態的最短時間(以秒為單位)。
max:指示在關閉連接之前可以在此連接上發送的最大請求數。
上述 HTTP 代碼的意思就是限制最大的超時時間是 5s 和 最大的連接請求是 997 個。
Server
服務器標頭包含有關原始服務器用來處理請求的軟件的信息。
應該避免使用過于冗長和詳細的 Server 值,因為它們可能會泄露內部實施細節,這可能會使攻擊者容易地發現并利用已知的安全漏洞。例如下面這種寫法
Server: Apache/2.4.1 (Unix)
復制代碼
Set-Cookie
Cookie 又是另外一個領域的內容了,我們后面文章會說道 Cookie,這里需要記住 Cookie、Set-Cookie 和 Content-Disposition 等在其他 RFC 中定義的首部字段,它們不是屬于 HTTP 1.1 的首部字段,但是使用率仍然很高。
Transfer-Encoding
首部字段 Transfer-Encoding 規定了傳輸報文主體時采用的編碼方式。
Transfer-Encoding: chunked
復制代碼
HTTP /1.1 的傳輸編碼方式僅對分塊傳輸編碼有效。
X-Frame-Options
HTTP 首部字段是可以自行擴展的。所以在 Web 服務器和瀏覽器的應用上,會出現各種非標準的首部字段。
首部字段 X-Frame-Options 屬于 HTTP 響應首部,用于控制網站內容在其他 Web 網站的 Frame 標簽內的顯示問題。其主要目的是為了防止點擊劫持(clickjacking)攻擊。
下面是一個響應頭的匯總,基于 HTTP 1.1
在 HTTP 協議通信交互中使用到的首部字段,不限于 RFC2616 中定義的 47 種首部字段。還有 Cookie、Set-Cookie 和 Content-Disposition 等在其他 RFC 中定義的首部字段,它們的使用頻率也很高。 這些非正式的首部字段統一歸納在 RFC4229 HTTP Header Field Registrations 中。
HTTP 首部字段將定義成緩存代理和非緩存代理的行為,分成 2 種類型。
一種是 End-to-end 首部 和 Hop-by-hop 首部
這些標頭必須發送給消息的最終接收者 : 請求的服務器,或響應的客戶端。中間代理必須重新傳輸未經修改的標頭,并且緩存必須存儲這些信息
分在此類別中的首部只對單次轉發有效,會因通過緩存或代理而不再轉發。
下面列舉了 HTTP/1.1 中的逐跳首部字段。除這 8 個首部字段之外,其他所有字段都屬于端到端首部。
Connection、Keep-Alive、Proxy-Authenticate、Proxy-Authorization、Trailer、TE、Transfer-Encoding、Upgrade
HTTP 最重要也是最突出的優點是 簡單、靈活、易于擴展。
HTTP 的協議比較簡單,它的主要組成就是 header + body,頭部信息也是簡單的文本格式,而且 HTTP 的請求報文根據英文也能猜出來個大概的意思,降低學習門檻,能夠讓更多的人研究和開發 HTTP 應用。
所以,在簡單的基礎上,HTTP 協議又多了靈活 和 易擴展 的優點。
HTTP 協議里的請求方法、URI、狀態碼、原因短語、頭字段等每一個核心組成要素都沒有被制定死,允許開發者任意定制、擴充或解釋,給予了瀏覽器和服務器最大程度的信任和自由。
因為過于簡單,普及,因此應用很廣泛。因為 HTTP 協議本身不屬于一種語言,它并不限定某種編程語言或者操作系統,所以天然具有跨語言、跨平臺的優越性。而且,因為本身的簡單特性很容易實現,所以幾乎所有的編程語言都有 HTTP 調用庫和外圍的開發測試工具。
隨著移動互聯網的發展, HTTP 的觸角已經延伸到了世界的每一個角落,從簡單的 Web 頁面到復雜的 JSON、XML 數據,從臺式機上的瀏覽器到手機上的各種 APP、新聞、論壇、購物、手機游戲,你很難找到一個沒有使用 HTTP 的地方。
無狀態其實既是優點又是缺點。因為服務器沒有記憶能力,所以就不需要額外的資源來記錄狀態信息,不僅實現上會簡單一些,而且還能減輕服務器的負擔,能夠把更多的 CPU 和內存用來對外提供服務。
既然服務器沒有記憶能力,它就無法支持需要連續多個步驟的事務操作。每次都得問一遍身份信息,不僅麻煩,而且還增加了不必要的數據傳輸量。由此出現了 Cookie 技術。
HTTP 協議里還有一把優缺點一體的雙刃劍,就是明文傳輸。明文意思就是協議里的報文(準確地說是 header 部分)不使用二進制數據,而是用簡單可閱讀的文本形式。
對比 TCP、UDP 這樣的二進制協議,它的優點顯而易見,不需要借助任何外部工具,用瀏覽器、Wireshark 或者 tcpdump 抓包后,直接用肉眼就可以很容易地查看或者修改,為我們的開發調試工作帶來極大的便利。
當然缺點也是顯而易見的,就是不安全,可以被監聽和被窺探。因為無法判斷通信雙方的身份,不能判斷報文是否被更改過。
HTTP 的性能不算差,但不完全適應現在的互聯網,還有很大的提升空間。
作者:cxuan鏈接:https://juejin.im/post/5e1870736fb9a02fef3a5dcb
者 2019 年參加了一次 Gopher 大會,有幸聽探探的架構師分享了他們 2019 年微服務化的過程。
圖片來自 Pexels
本文快速搭建的 IM 系統也是使用 Go 語言來快速實現的,這里先和各位分享一下探探 App 的架構圖:
本文的目的是幫助讀者較為深入的理解 Socket 協議,并快速搭建一個高可用、可拓展的 IM 系統(文章標題純屬引人眼球,不是真的,請讀者不要在意),同時幫助讀者了解 IM 系統后續可以做哪些優化和改進。
麻雀雖小,五臟俱全,該 IM 系統包含基本的注冊、登錄、添加好友基礎功能,另外提供單聊、群聊,并且支持發送文字、表情和圖片,在搭建的系統上,讀者可輕松的拓展語音、視頻聊天、發紅包等業務。
為了幫助讀者更清楚的理解 IM 系統的原理:
深入理解 WebSocket 協議
Web Sockets 的目標是在一個單獨的持久連接上提供全雙工、雙向通信。在 Javascript 創建了 WebSocket 之后,會有一個 HTTP 請求發送到瀏覽器以發起連接。
在取得服務器響應后,建立的連接會將 HTTP 升級從 HTTP 協議交換為 WebSocket 協議。
由于 WebSocket 使用自定義的協議,所以 URL 模式也略有不同。未加密的連接不再是 http://,而是 ws://;加密的連接也不是 https://,而是 wss://。
在使用 WebSocket URL 時,必須帶著這個模式,因為將來還有可能支持其他的模式。
使用自定義協議而非 HTTP 協議的好處是,能夠在客戶端和服務器之間發送非常少量的數據,而不必擔心 HTTP 那樣字節級的開銷。由于傳遞的數據包很小,所以 WebSocket 非常適合移動應用。
上文中只是對 Web Sockets 進行了籠統的描述,接下來的篇幅會對 Web Sockets 的細節實現進行深入的探索。
本文接下來的幾個小節不會涉及到大量的代碼片段,但是會對相關的 API 和技術原理進行分析,相信大家讀完下文之后再來看這段描述,會有一種豁然開朗的感覺。
①WebSocket 復用了 HTTP 的握手通道
“握手通道”是 HTTP 協議中客戶端和服務端通過"TCP 三次握手"建立的通信通道。
客戶端和服務端使用 HTTP 協議進行的每次交互都需要先建立這樣一條“通道”,然后通過這條通道進行通信。
我們熟悉的 Ajax 交互就是在這樣一個通道上完成數據傳輸的,只不過 Ajax 交互是短連接,在一次 Request→Response 之后,“通道”連接就斷開了。
下面是 HTTP 協議中建立“握手通道”的過程示意圖:
上文中我們提到:在 Javascript 創建了 WebSocket 之后,會有一個 HTTP 請求發送到瀏覽器以發起連接,然后服務端響應,這就是“握手“的過程。
在這個握手的過程當中,客戶端和服務端主要做了兩件事情:
建立了一條連接“握手通道”用于通信:這點和 HTTP 協議相同,不同的是 HTTP 協議完成數據交互后就釋放了這條握手通道,這就是所謂的“短連接”,它的生命周期是一次數據交互的時間,通常是毫秒級別的。
將 HTTP 協議升級到 WebSocket 協議,并復用 HTTP 協議的握手通道,從而建立一條持久連接。
說到這里可能有人會問:HTTP 協議為什么不復用自己的“握手通道”,而非要在每次進行數據交互的時候都通過 TCP 三次握手重新建立“握手通道”呢?
答案是這樣的:雖然“長連接”在客戶端和服務端交互的過程中省去了每次都建立“握手通道”的麻煩步驟。
但是維持這樣一條“長連接”是需要消耗服務器資源的,而在大多數情況下,這種資源的消耗又是不必要的,可以說 HTTP 標準的制定經過了深思熟慮的考量。
到我們后邊說到 WebSocket 協議數據幀時,大家可能就會明白,維持一條“長連接”服務端和客戶端需要做的事情太多了。
說完了握手通道,我們再來看 HTTP 協議如何升級到 WebSocket 協議的。
②HTTP 協議升級為 WebSocket 協議
升級協議需要客戶端和服務端交流,服務端怎么知道要將 HTTP 協議升級到 WebSocket 協議呢?它一定是接收到了客戶端發送過來的某種信號。
下面是我從谷歌瀏覽器中截取的“客戶端發起協議升級請求的報文”,通過分析這段報文,我們能夠得到有關 WebSocket 中協議升級的更多細節。
首先,客戶端發起協議升級請求。采用的是標準的 HTTP 報文格式,且只支持 GET 方法。
下面是重點請求的首部的意義:
Connection:Upgrade:表示要升級的協議。
Upgrade: websocket:表示要升級到 WebSocket 協議。
Sec-WebSocket-Version: 13:表示 WebSocket 的版本。
Sec-WebSocket-Key:UdTUf90CC561cQXn4n5XRg==:與 Response Header 中的響應首部 Sec-WebSocket-Accept: GZk41FJZSYY0CmsrZPGpUGRQzkY=是配套的,提供基本的防護,比如惡意的連接或者無意的連接。
其中 Connection 就是我們前邊提到的,客戶端發送給服務端的信號,服務端接受到信號之后,才會對 HTTP 協議進行升級。
那么服務端怎樣確認客戶端發送過來的請求是否是合法的呢?在客戶端每次發起協議升級請求的時候都會產生一個唯一碼:Sec-WebSocket-Key。
服務端拿到這個碼后,通過一個算法進行校驗,然后通過 Sec-WebSocket-Accept 響應給客戶端,客戶端再對 Sec-WebSocket-Accept 進行校驗來完成驗證。
這個算法很簡單:
258EAFA5-E914-47DA-95CA-C5AB0DC85B11 這個字符串又叫“魔串",至于為什么要使用它作為 WebSocket 握手計算中使用的字符串,這點我們無需關心,只需要知道它是 RFC 標準規定就可以了。
官方的解析也只是簡單的說此值不大可能被不明白 WebSocket 協議的網絡終端使用。
我們還是用世界上最好的語言來描述一下這個算法吧:
public function dohandshake($sock, $data, $key) {
if (preg_match("/Sec-WebSocket-Key: (.*)\r\n/", $data, $match)) {
$response=base64_encode(sha1($match[1] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
$upgrade="HTTP/1.1 101 Switching Protocol\r\n" .
"Upgrade: websocket\r\n" .
"Connection: Upgrade\r\n" .
"Sec-WebSocket-Accept: " . $response . "\r\n\r\n";
socket_write($sock, $upgrade, strlen($upgrade));
$this->isHand[$key]=true;
}
}
服務端響應客戶端的頭部信息和 HTTP 協議的格式是相同的,HTTP1.1 協議是以換行符(\r\n)分割的,我們可以通過正則匹配解析出 Sec-WebSocket-Accept 的值,這和我們使用 curl 工具模擬 get 請求是一個道理。
這樣展示結果似乎不太直觀,我們使用命令行 CLI 來根據上圖中的 Sec-WebSocket-Key 和握手算法來計算一下服務端返回的 Sec-WebSocket-Accept 是否正確:
從圖中可以看到,通過算法算出來的 base64 字符串和 Sec-WebSocket-Accept 是一樣的。
那么假如服務端在握手的過程中返回一個錯誤的 Sec-WebSocket-Accept 字符串會怎么樣呢?
當然是客戶端會報錯,連接會建立失敗,大家可以嘗試一下,例如將全局唯一標識符 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 改為 258EAFA5-E914-47DA-95CA-C5AB0DC85B12。
③WebSocket 的幀和數據分片傳輸。
下圖是我做的一個測試:將小說《飄》的第一章內容復制成文本數據,通過客戶端發送到服務端,然后服務端響應相同的信息完成了一次通信
可以看到一篇足足有將近 15000 字節的數據在客戶端和服務端完成通信只用了 150ms 的時間。
我們還可以看到瀏覽器控制臺中 Frame 欄中顯示的客戶端發送和服務端響應的文本數據,你一定驚訝 WebSocket 通信強大的數據傳輸能力。
數據是否真的像 Frame 中展示的那樣客戶端直接將一大篇文本數據發送到服務端,服務端接收到數據之后,再將一大篇文本數據返回給客戶端呢?
這當然是不可能的,我們都知道 HTTP 協議是基于 TCP 實現的,HTTP 發送數據也是分包轉發的,就是將大數據根據報文形式分割成一小塊一小塊發送到服務端,服務端接收到客戶端發送的報文后,再將小塊的數據拼接組裝。
關于 HTTP 的分包策略,大家可以查看相關資料進行研究,WebSocket 協議也是通過分片打包數據進行轉發的,不過策略上和 HTTP 的分包不一樣。
Frame(幀)是 WebSocket 發送數據的基本單位,下邊是它的報文格式:
報文內容中規定了數據標示,操作代碼、掩碼、數據、數據長度等格式。不太理解沒關系,下面我通過講解大家只要理解報文中重要標志的作用就可以了。
首先我們明白了客戶端和服務端進行 WebSocket 消息傳遞是這樣的:
服務端在接收到客戶端發送的幀消息的時候,將這些幀進行組裝,它怎么知道何時數據組裝完成的呢?
這就是報文中左上角 FIN(占一個比特)存儲的信息,1 表示這是消息的最后一個分片(fragment)如果是 0,表示不是消息的最后一個分片。
WebSocket 通信中,客戶端發送數據分片是有序的,這一點和 HTTP 不一樣。
HTTP 將消息分包之后,是并發無序的發送給服務端的,包信息在數據中的位置則在 HTTP 報文中存儲,而 WebSocket 僅僅需要一個 FIN 比特位就能保證將數據完整的發送到服務端。
接下來的 RSV1,RSV2,RSV3 三個比特位的作用又是什么呢?這三個標志位是留給客戶端開發者和服務端開發者開發過程中協商進行拓展的,默認是 0。
拓展如何使用必須在握手的階段就協商好,其實握手本身也是客戶端和服務端的協商。
④WebSocket 連接保持和心跳檢測
WebSocket 是長連接,為了保持客戶端和服務端的實時雙向通信,需要確保客戶端和服務端之間的 TCP 通道保持連接沒有斷開。
但是對于長時間沒有數據往來的連接,如果依舊保持著,可能會浪費服務端資源。
不排除有些場景,客戶端和服務端雖然長時間沒有數據往來,仍然需要保持連接,就比如說你幾個月沒有和一個 QQ 好友聊天了,突然有一天他發 QQ 消息告訴你他要結婚了,你還是能在第一時間收到。
那是因為,客戶端和服務端一直再采用心跳來檢查連接??蛻舳撕头斩说男奶B接檢測就像打乒乓球一樣:
等什么時候沒有 ping、pong 了,那么連接一定是存在問題了。
說了這么多,接下來我使用 Go 語言來實現一個心跳檢測,WebSocket 通信實現細節是一件繁瑣的事情,直接使用開源的類庫是比較不錯的選擇,我使用的是:gorilla/websocket。
這個類庫已經將 WebSocket 的實現細節(握手,數據解碼)封裝的很好啦。下面我就直接貼代碼了:
package main
import (
"net/http"
"time"
"github.com/gorilla/websocket"
)
var (
//完成握手操作
upgrade=websocket.Upgrader{
//允許跨域(一般來講,websocket都是獨立部署的)
CheckOrigin:func(r *http.Request) bool {
return true
},
}
)
func wsHandler(w http.ResponseWriter, r *http.Request) {
var (
conn *websocket.Conn
err error
data []byte
)
//服務端對客戶端的http請求(升級為websocket協議)進行應答,應答之后,協議升級為websocket,http建立連接時的tcp三次握手將保持。
if conn, err=upgrade.Upgrade(w, r, nil); err !=nil {
return
}
//啟動一個協程,每隔1s向客戶端發送一次心跳消息
go func() {
var (
err error
)
for {
if err=conn.WriteMessage(websocket.TextMessage, []byte("heartbeat")); err !=nil {
return
}
time.Sleep(1 * time.Second)
}
}()
//得到websocket的長鏈接之后,就可以對客戶端傳遞的數據進行操作了
for {
//通過websocket長鏈接讀到的數據可以是text文本數據,也可以是二進制Binary
if _, data, err=conn.ReadMessage(); err !=nil {
goto ERR
}
if err=conn.WriteMessage(websocket.TextMessage, data); err !=nil {
goto ERR
}
}
ERR:
//出錯之后,關閉socket連接
conn.Close()
}
func main() {
http.HandleFunc("/ws", wsHandler)
http.ListenAndServe("0.0.0.0:7777", nil)
}
借助 Go 語言很容易搭建協程的特點,我專門開啟了一個協程每秒向客戶端發送一條消息。
打開客戶端瀏覽器可以看到,Frame 中每秒的心跳數據一直在跳動,當長鏈接斷開之后,心跳就沒有了,就像人沒有了心跳一樣:
大家對 WebSocket 協議已經有了了解,接下來就讓我們一起快速搭建一個高性能、可拓展的 IM 系統吧。
快速搭建高性能、可拓展的 IM 系統
①系統架構和代碼文件目錄結構
下圖是一個比較完備的 IM 系統架構:包含了 C 端、接入層(通過協議接入)、S 端處理邏輯和分發消息、存儲層用來持久化數據。
我們本節 C 端使用的是 Webapp, 通過 Go 語言渲染 Vue 模版快速實現功能,接入層使用的是 WebSocket 協議,前邊已經進行了深入的介紹。
S 端是我們實現的重點,其中鑒權、登錄、關系管理、單聊和群聊的功能都已經實現,讀者可以在這部分功能的基礎上再拓展其他的功能,比如:視頻語音聊天、發紅包、朋友圈等業務模塊。
存儲層我們做的比較簡單,只是使用 MySQL 簡單持久化存儲了用戶關系,然后聊天中的圖片資源我們存儲到了本地文件中。
雖然我們的 IM 系統實現的比較簡化,但是讀者可以在次基礎上進行改進、完善、拓展,依然能夠作出高可用的企業級產品。
我們的系統服務使用 Go 語言構建,代碼結構比較簡潔,但是性能比較優秀(這是 Java 和其他語言所無法比擬的),單機支持幾萬人的在線聊天。
下邊是代碼文件的目錄結構:
app
│ ├── args
│ │ ├── contact.go
│ │ └── pagearg.go
│ ├── controller //控制器層,api入口
│ │ ├── chat.go
│ │ ├── contract.go
│ │ ├── upload.go
│ │ └── user.go
│ ├── main.go //程序入口
│ ├── model //數據定義與存儲
│ │ ├── community.go
│ │ ├── contract.go
│ │ ├── init.go
│ │ └── user.go
│ ├── service //邏輯實現
│ │ ├── contract.go
│ │ └── user.go
│ ├── util //幫助函數
│ │ ├── md5.go
│ │ ├── parse.go
│ │ ├── resp.go
│ │ └── string.go
│ └── view //模版資源
│ │ ├── ...
asset //js、css文件
resource //上傳資源,上傳圖片會放到這里
從入口函數 main.go 開始,我們定義了 Controller 層,是客戶端 API 的入口。Service 用來處理主要的用戶邏輯,消息分發、用戶管理都在這里實現。
Model 層定義了一些數據表,主要是用戶注冊和用戶好友關系、群組等信息,存儲到 MySQL。
Util 包下是一些幫助函數,比如加密、請求響應等。View 下邊存儲了模版資源信息,上邊所說的這些都在 App 文件夾下存儲,外層還有 asset 用來存儲 css、js 文件和聊天中會用到的表情圖片等。
Resource 下存儲用戶聊天中的圖片或者視頻等文件??傮w來講,我們的代碼目錄機構還是比較簡潔清晰的。
了解了我們要搭建的 IM 系統架構,我們再來看一下架構重點實現的功能吧。
②10 行代碼萬能模版渲染
Go 語言提供了強大的 HTML 渲染能力,非常簡單的構建 Web 應用,下邊是實現模版渲染的代碼,它太簡單了,以至于可以直接在 main.go 函數中實現:
func registerView() {
tpl, err :=template.ParseGlob("./app/view/**/*")
if err !=nil {
log.Fatal(err.Error())
}
for _, v :=range tpl.Templates() {
tplName :=v.Name()
http.HandleFunc(tplName, func(writer http.ResponseWriter, request *http.Request) {
tpl.ExecuteTemplate(writer, tplName, nil)
})
}
}
...
func main() {
......
http.Handle("/asset/", http.FileServer(http.Dir(".")))
http.Handle("/resource/", http.FileServer(http.Dir(".")))
registerView()
log.Fatal(http.ListenAndServe(":8081", nil))
}
Go 實現靜態資源服務器也很簡單,只需要調用 http.FileServer 就可以了,這樣 HTML 文件就可以很輕松的訪問依賴的 js、css 和圖標文件了。
使用 http/template 包下的 ParseGlob、ExecuteTemplate 又可以很輕松的解析 Web 頁面,這些工作完全不依賴與 Nginx。
現在我們就完成了登錄、注冊、聊天 C 端界面的構建工作:
③注冊、登錄和鑒權
之前我們提到過,對于注冊、登錄和好友關系管理,我們需要有一張 user 表來存儲用戶信息。
我們使用 github.com/go-xorm/xorm 來操作 MySQL,首先看一下 MySQL 表的設計:
app/model/user.go:
package model
import "time"
const (
SexWomen="W"
SexMan="M"
SexUnknown="U"
)
type User struct {
Id int64 `xorm:"pk autoincr bigint(64)" form:"id" json:"id"`
Mobile string `xorm:"varchar(20)" form:"mobile" json:"mobile"`
Passwd string `xorm:"varchar(40)" form:"passwd" json:"-"` // 用戶密碼 md5(passwd + salt)
Avatar string `xorm:"varchar(150)" form:"avatar" json:"avatar"`
Sex string `xorm:"varchar(2)" form:"sex" json:"sex"`
Nickname string `xorm:"varchar(20)" form:"nickname" json:"nickname"`
Salt string `xorm:"varchar(10)" form:"salt" json:"-"`
Online int `xorm:"int(10)" form:"online" json:"online"` //是否在線
Token string `xorm:"varchar(40)" form:"token" json:"token"` //用戶鑒權
Memo string `xorm:"varchar(140)" form:"memo" json:"memo"`
Createat time.Time `xorm:"datetime" form:"createat" json:"createat"` //創建時間, 統計用戶增量時使用
}
我們 user 表中存儲了用戶名、密碼、頭像、用戶性別、手機號等一些重要的信息,比較重要的是我們也存儲了 Token 標示用戶在用戶登錄之后,HTTP 協議升級為 WebSocket 協議進行鑒權,這個細節點我們前邊提到過,下邊會有代碼演示。
接下來我們看一下 model 初始化要做的一些事情吧:
app/model/init.go:
package model
import (
"errors"
"fmt"
_ "github.com/go-sql-driver/mysql"
"github.com/go-xorm/xorm"
"log"
)
var DbEngine *xorm.Engine
func init() {
driverName :="mysql"
dsnName :="root:root@(127.0.0.1:3306)/chat?charset=utf8"
err :=errors.New("")
DbEngine, err=xorm.NewEngine(driverName, dsnName)
if err !=nil && err.Error() !=""{
log.Fatal(err)
}
DbEngine.ShowSQL(true)
//設置數據庫連接數
DbEngine.SetMaxOpenConns(10)
//自動創建數據庫
DbEngine.Sync(new(User), new(Community), new(Contact))
fmt.Println("init database ok!")
}
我們創建一個 DbEngine 全局 MySQL 連接對象,設置了一個大小為 10 的連接池。
Model 包里的 init 函數在程序加載的時候會先執行,對 Go 語言熟悉的同學應該知道這一點。
我們還設置了一些額外的參數用于調試程序,比如:設置打印運行中的 SQL,自動的同步數據表等,這些功能在生產環境中可以關閉。
我們的 Model 初始化工作就做完了,非常簡陋,在實際的項目中,像數據庫的用戶名、密碼、連接數和其他的配置信息,建議設置到配置文件中,然后讀取,而不像本文硬編碼的程序中。
注冊是一個普通的 API 程序,對于 Go 語言來說,完成這件工作太簡單了,我們來看一下代碼:
############################
//app/controller/user.go
############################
......
//用戶注冊
func UserRegister(writer http.ResponseWriter, request *http.Request) {
var user model.User
util.Bind(request, &user)
user, err :=UserService.UserRegister(user.Mobile, user.Passwd, user.Nickname, user.Avatar, user.Sex)
if err !=nil {
util.RespFail(writer, err.Error())
} else {
util.RespOk(writer, user, "")
}
}
......
############################
//app/service/user.go
############################
......
type UserService struct{}
//用戶注冊
func (s *UserService) UserRegister(mobile, plainPwd, nickname, avatar, sex string) (user model.User, err error) {
registerUser :=model.User{}
_, err=model.DbEngine.Where("mobile=? ", mobile).Get(?isterUser)
if err !=nil {
return registerUser, err
}
//如果用戶已經注冊,返回錯誤信息
if registerUser.Id > 0 {
return registerUser, errors.New("該手機號已注冊")
}
registerUser.Mobile=mobile
registerUser.Avatar=avatar
registerUser.Nickname=nickname
registerUser.Sex=sex
registerUser.Salt=fmt.Sprintf("%06d", rand.Int31n(10000))
registerUser.Passwd=util.MakePasswd(plainPwd, registerUser.Salt)
registerUser.Createat=time.Now()
//插入用戶信息
_, err=model.DbEngine.InsertOne(?isterUser)
return registerUser, err
}
......
############################
//main.go
############################
......
func main() {
http.HandleFunc("/user/register", controller.UserRegister)
}
首先我們使用 util.Bind(request, &user) 將用戶參數綁定到 user 對象上,使用的是 util 包中的 Bind 函數,具體實現細節讀者可以自行研究,主要模仿了 Gin 框架的參數綁定,可以拿來即用,非常方便。
然后我們根據用戶手機號搜索數據庫中是否已經存在,如果不存在就插入到數據庫中,返回注冊成功信息,邏輯非常簡單。
登錄邏輯更簡單:
############################
//app/controller/user.go
############################
...
//用戶登錄
func UserLogin(writer http.ResponseWriter, request *http.Request) {
request.ParseForm()
mobile :=request.PostForm.Get("mobile")
plainpwd :=request.PostForm.Get("passwd")
//校驗參數
if len(mobile)==0 || len(plainpwd)==0 {
util.RespFail(writer, "用戶名或密碼不正確")
}
loginUser, err :=UserService.Login(mobile, plainpwd)
if err !=nil {
util.RespFail(writer, err.Error())
} else {
util.RespOk(writer, loginUser, "")
}
}
...
############################
//app/service/user.go
############################
...
func (s *UserService) Login(mobile, plainpwd string) (user model.User, err error) {
//數據庫操作
loginUser :=model.User{}
model.DbEngine.Where("mobile=?", mobile).Get(&loginUser)
if loginUser.Id==0 {
return loginUser, errors.New("用戶不存在")
}
//判斷密碼是否正確
if !util.ValidatePasswd(plainpwd, loginUser.Salt, loginUser.Passwd) {
return loginUser, errors.New("密碼不正確")
}
//刷新用戶登錄的token值
token :=util.GenRandomStr(32)
loginUser.Token=token
model.DbEngine.ID(loginUser.Id).Cols("token").Update(&loginUser)
//返回新用戶信息
return loginUser, nil
}
...
############################
//main.go
############################
......
func main() {
http.HandleFunc("/user/login", controller.UserLogin)
}
實現了登錄邏輯,接下來我們就到了用戶首頁,這里列出了用戶列表,點擊即可進入聊天頁面。
用戶也可以點擊下邊的 Tab 欄查看自己所在的群組,可以由此進入群組聊天頁面。
具體這些工作還需要讀者自己開發用戶列表、添加好友、創建群組、添加群組等功能,這些都是一些普通的 API 開發工作,我們的代碼程序中也實現了,讀者可以拿去修改使用,這里就不再演示了。
我們再重點看一下用戶鑒權這一塊吧,用戶鑒權是指用戶點擊聊天進入聊天界面時,客戶端會發送一個 GET 請求給服務端。
請求建立一條 WebSocket 長連接,服務端收到建立連接的請求之后,會對客戶端請求進行校驗,以確實是否建立長連接,然后將這條長連接的句柄添加到 Map 當中(因為服務端不僅僅對一個客戶端服務,可能存在千千萬萬個長連接)維護起來。
我們下邊來看具體代碼實現:
############################
//app/controller/chat.go
############################
......
//本核心在于形成userid和Node的映射關系
type Node struct {
Conn *websocket.Conn
//并行轉串行,
DataQueue chan []byte
GroupSets set.Interface
}
......
//userid和Node映射關系表
var clientMap map[int64]*Node=make(map[int64]*Node, 0)
//讀寫鎖
var rwlocker sync.RWMutex
//實現聊天的功能
func Chat(writer http.ResponseWriter, request *http.Request) {
query :=request.URL.Query()
id :=query.Get("id")
token :=query.Get("token")
userId, _ :=strconv.ParseInt(id, 10, 64)
//校驗token是否合法
islegal :=checkToken(userId, token)
conn, err :=(&websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return islegal
},
}).Upgrade(writer, request, nil)
if err !=nil {
log.Println(err.Error())
return
}
//獲得websocket鏈接conn
node :=&Node{
Conn: conn,
DataQueue: make(chan []byte, 50),
GroupSets: set.New(set.ThreadSafe),
}
//獲取用戶全部群Id
comIds :=concatService.SearchComunityIds(userId)
for _, v :=range comIds {
node.GroupSets.Add(v)
}
rwlocker.Lock()
clientMap[userId]=node
rwlocker.Unlock()
//開啟協程處理發送邏輯
go sendproc(node)
//開啟協程完成接收邏輯
go recvproc(node)
sendMsg(userId, []byte("welcome!"))
}
......
//校驗token是否合法
func checkToken(userId int64, token string) bool {
user :=UserService.Find(userId)
return user.Token==token
}
......
############################
//main.go
############################
......
func main() {
http.HandleFunc("/chat", controller.Chat)
}
......
進入聊天室,客戶端發起 /chat 的 GET 請求,服務端首先創建了一個 Node 結構體,用來存儲和客戶端建立起來的 WebSocket 長連接句柄。
每一個句柄都有一個管道 DataQueue,用來收發信息,GroupSets 是客戶端對應的群組信息,后邊我們會提到。
type Node struct {
Conn *websocket.Conn
//并行轉串行,
DataQueue chan []byte
GroupSets set.Interface
}
服務端創建了一個 Map,將客戶端用戶 ID 和其 Node 關聯起來:
//userid和Node映射關系表
var clientMap map[int64]*Node=make(map[int64]*Node, 0)
接下來是主要的用戶邏輯了,服務端接收到客戶端的參數之后,首先校驗 Token 是否合法,由此確定是否要升級 HTTP 協議到 WebSocket 協議,建立長連接,這一步稱為鑒權。
//校驗token是否合法
islegal :=checkToken(userId, token)
conn, err :=(&websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return islegal
},
}).Upgrade(writer, request, nil)
鑒權成功以后,服務端初始化一個 Node,搜索該客戶端用戶所在的群組 ID,填充到群組的 GroupSets 屬性中。
然后將 Node 節點添加到 ClientMap 中維護起來,我們對 ClientMap 的操作一定要加鎖,因為 Go 語言在并發情況下,對 Map 的操作并不保證原子安全:
//獲得websocket鏈接conn
node :=&Node{
Conn: conn,
DataQueue: make(chan []byte, 50),
GroupSets: set.New(set.ThreadSafe),
}
//獲取用戶全部群Id
comIds :=concatService.SearchComunityIds(userId)
for _, v :=range comIds {
node.GroupSets.Add(v)
}
rwlocker.Lock()
clientMap[userId]=node
rwlocker.Unlock()
服務端和客戶端建立了長鏈接之后,會開啟兩個協程專門來處理客戶端消息的收發工作,對于 Go 語言來說,維護協程的代價是很低的。
所以說我們的單機程序可以很輕松的支持成千上完的用戶聊天,這還是在沒有優化的情況下。
......
//開啟協程處理發送邏輯
go sendproc(node)
//開啟協程完成接收邏輯
go recvproc(node)
sendMsg(userId, []byte("welcome!"))
......
至此,我們的鑒權工作也已經完成了,客戶端和服務端的連接已經建立好了,接下來我們就來實現具體的聊天功能吧。
④實現單聊和群聊
實現聊天的過程中,消息體的設計至關重要,消息體設計的合理,功能拓展起來就非常的方便,后期維護、優化起來也比較簡單。
我們先來看一下,我們消息體的設計:
############################
//app/controller/chat.go
############################
type Message struct {
Id int64 `json:"id,omitempty" form:"id"` //消息ID
Userid int64 `json:"userid,omitempty" form:"userid"` //誰發的
Cmd int `json:"cmd,omitempty" form:"cmd"` //群聊還是私聊
Dstid int64 `json:"dstid,omitempty" form:"dstid"` //對端用戶ID/群ID
Media int `json:"media,omitempty" form:"media"` //消息按照什么樣式展示
Content string `json:"content,omitempty" form:"content"` //消息的內容
Pic string `json:"pic,omitempty" form:"pic"` //預覽圖片
Url string `json:"url,omitempty" form:"url"` //服務的URL
Memo string `json:"memo,omitempty" form:"memo"` //簡單描述
Amount int `json:"amount,omitempty" form:"amount"` //其他和數字相關的
}
每一條消息都有一個唯一的 ID,將來我們可以對消息持久化存儲,但是我們系統中并沒有做這件工作,讀者可根據需要自行完成。
然后是 userid,發起消息的用戶,對應的是 dstid,要將消息發送給誰。還有一個參數非常重要,就是 cmd,它表示是群聊還是私聊。
群聊和私聊的代碼處理邏輯有所區別,我們為此專門定義了一些 cmd 常量:
//定義命令行格式
const (
CmdSingleMsg=10
CmdRoomMsg=11
CmdHeart=0
)
Media 是媒體類型,我們都知道微信支持語音、視頻和各種其他的文件傳輸,我們設置了該參數之后,讀者也可以自行拓展這些功能。
Content 是消息文本,是聊天中最常用的一種形式。Pic 和 URL 是為圖片和其他鏈接資源所設置的。
Memo 是簡介,Amount 是和數字相關的信息,比如說發紅包業務有可能使用到該字段。
消息體的設計就是這樣,基于此消息體,我們來看一下,服務端如何收發消息,實現單聊和群聊吧。
還是從上一節說起,我們為每一個客戶端長鏈接開啟了兩個協程,用于收發消息,聊天的邏輯就在這兩個協程當中實現。
############################
//app/controller/chat.go
############################
......
//發送邏輯
func sendproc(node *Node) {
for {
select {
case data :=<-node.DataQueue:
err :=node.Conn.WriteMessage(websocket.TextMessage, data)
if err !=nil {
log.Println(err.Error())
return
}
}
}
}
//接收邏輯
func recvproc(node *Node) {
for {
_, data, err :=node.Conn.ReadMessage()
if err !=nil {
log.Println(err.Error())
return
}
dispatch(data)
//todo對data進一步處理
fmt.Printf("recv<=%s", data)
}
}
......
//后端調度邏輯處理
func dispatch(data []byte) {
msg :=Message{}
err :=json.Unmarshal(data, &msg)
if err !=nil {
log.Println(err.Error())
return
}
switch msg.Cmd {
case CmdSingleMsg:
sendMsg(msg.Dstid, data)
case CmdRoomMsg:
for _, v :=range clientMap {
if v.GroupSets.Has(msg.Dstid) {
v.DataQueue <- data
}
}
case CmdHeart:
//檢測客戶端的心跳
}
}
//發送消息,發送到消息的管道
func sendMsg(userId int64, msg []byte) {
rwlocker.RLock()
node, ok :=clientMap[userId]
rwlocker.RUnlock()
if ok {
node.DataQueue <- msg
}
}
......
服務端向客戶端發送消息邏輯比較簡單,就是將客戶端發送過來的消息,直接添加到目標用戶 Node 的 Channel 中去就好了。
通過 WebSocket 的 WriteMessage 就可以實現此功能:
func sendproc(node *Node) {
for {
select {
case data :=<-node.DataQueue:
err :=node.Conn.WriteMessage(websocket.TextMessage, data)
if err !=nil {
log.Println(err.Error())
return
}
}
}
}
收發邏輯是這樣的,服務端通過 WebSocket 的 ReadMessage 方法接收到用戶信息,然后通過 dispatch 方法進行調度:
func recvproc(node *Node) {
for {
_, data, err :=node.Conn.ReadMessage()
if err !=nil {
log.Println(err.Error())
return
}
dispatch(data)
//todo對data進一步處理
fmt.Printf("recv<=%s", data)
}
}
dispatch 方法所做的工作有兩件:
Go 語言中的 Channel 是協程間通信的強大工具,dispatch 只要將消息添加到 Channel 當中,發送協程就會獲取到信息發送給客戶端,這樣就實現了聊天功能。
單聊和群聊的區別只是服務端將消息發送給群組還是個人,如果發送給群組,程序會遍歷整個 clientMap,看看哪個用戶在這個群組當中,然后將消息發送。
其實更好的實踐是我們再維護一個群組和用戶關系的 Map,這樣在發送群組消息的時候,取得用戶信息就比遍歷整個 clientMap 代價要小很多了。
func dispatch(data []byte) {
msg :=Message{}
err :=json.Unmarshal(data, &msg)
if err !=nil {
log.Println(err.Error())
return
}
switch msg.Cmd {
case CmdSingleMsg:
sendMsg(msg.Dstid, data)
case CmdRoomMsg:
for _, v :=range clientMap {
if v.GroupSets.Has(msg.Dstid) {
v.DataQueue <- data
}
}
case CmdHeart:
//檢測客戶端的心跳
}
}
......
func sendMsg(userId int64, msg []byte) {
rwlocker.RLock()
node, ok :=clientMap[userId]
rwlocker.RUnlock()
if ok {
node.DataQueue <- msg
}
}
可以看到,通過 Channel,我們實現用戶聊天功能還是非常方便的,代碼可讀性很強,構建的程序也很健壯。
下邊是筆者本地聊天的示意圖:
⑤發送表情和圖片
下邊我們再來看一下聊天中經常使用到的發送表情和圖片功能是如何實現的吧。
其實表情也是小圖片,只是和聊天中圖片不同的是,表情圖片比較小,可以緩存在客戶端,或者直接存放到客戶端代碼的代碼文件中(不過現在微信聊天中有的表情包都是通過網絡傳輸的)。
下邊是一個聊天中返回的圖標文本數據:
{
"dstid":1,
"cmd":10,
"userid":2,
"media":4,
"url":"/asset/plugins/doutu//emoj/2.gif"
}
客戶端拿到 URL 后,就加載本地的小圖標。聊天中用戶發送圖片也是一樣的原理,不過聊天中用戶的圖片需要先上傳到服務器,然后服務端返回 URL,客戶端再進行加載,我們的 IM 系統也支持此功能。
我們看一下圖片上傳的程序:
############################
//app/controller/upload.go
############################
func init() {
os.MkdirAll("./resource", os.ModePerm)
}
func FileUpload(writer http.ResponseWriter, request *http.Request) {
UploadLocal(writer, request)
}
//將文件存儲在本地/im_resource目錄下
func UploadLocal(writer http.ResponseWriter, request *http.Request) {
//獲得上傳源文件
srcFile, head, err :=request.FormFile("file")
if err !=nil {
util.RespFail(writer, err.Error())
}
//創建一個新的文件
suffix :=".png"
srcFilename :=head.Filename
splitMsg :=strings.Split(srcFilename, ".")
if len(splitMsg) > 1 {
suffix="." + splitMsg[len(splitMsg)-1]
}
filetype :=request.FormValue("filetype")
if len(filetype) > 0 {
suffix=filetype
}
filename :=fmt.Sprintf("%d%s%s", time.Now().Unix(), util.GenRandomStr(32), suffix)
//創建文件
filepath :="./resource/" + filename
dstfile, err :=os.Create(filepath)
if err !=nil {
util.RespFail(writer, err.Error())
return
}
//將源文件拷貝到新文件
_, err=io.Copy(dstfile, srcFile)
if err !=nil {
util.RespFail(writer, err.Error())
return
}
util.RespOk(writer, filepath, "")
}
......
############################
//main.go
############################
func main() {
http.HandleFunc("/attach/upload", controller.FileUpload)
}
我們將文件存放到本地的一個磁盤文件夾下,然后發送給客戶端路徑,客戶端通過路徑加載相關的圖片信息。
關于發送圖片,我們雖然實現功能,但是做的太簡單了,我們在接下來的章節詳細的和大家探討一下系統優化相關的方案。怎樣讓我們的系統在生產環境中用的更好。
程序優化和系統架構升級方案
我們上邊實現了一個功能健全的 IM 系統,要將該系統應用在企業的生產環境中,需要對代碼和系統架構做優化,才能實現真正的高可用。
本節主要從代碼優化和架構升級上談一些個人觀點,能力有限不可能面面俱到,希望讀者也在評論區給出更多好的建議。
代碼優化
我們的代碼沒有使用框架,函數和 API 都寫的比較簡陋,雖然進行了簡單的結構化,但是很多邏輯并沒有解耦,所以建議大家業界比較成熟的框架對代碼進行重構,Gin 就是一個不錯的選擇。
系統程序中使用 clientMap 來存儲客戶端長鏈接信息,Go 語言中對于大 Map 的讀寫要加鎖,有一定的性能限制。
在用戶量特別大的情況下,讀者可以對 clientMap 做拆分,根據用戶 ID 做 Hash 或者采用其他的策略,也可以將這些長鏈接句柄存放到 Redis 中。
上邊提到圖片上傳的過程,有很多可以優化的地方,首先是圖片壓縮(微信也是這樣做的),圖片資源的壓縮不僅可以加快傳輸速度,還可以減少服務端存儲的空間。
另外對于圖片資源來說,實際上服務端只需要存儲一份數據就夠了,讀者可以在圖片上傳的時候做 Hash 校驗。
如果資源文件已經存在了,就不需要再次上傳了,而是直接將 URL 返回給客戶端(各大網盤廠商的妙傳功能就是這樣實現的)。
代碼還有很多優化的地方,比如我們可以將鑒權做的更好,使用 wss:// 代替 ws://。
在一些安全領域,可以對消息體進行加密,在高并發領域,可以對消息體進行壓縮。
對 MySQL 連接池再做優化,將消息持久化存儲到 Mongo,避免對數據庫頻繁的寫入,將單條寫入改為多條一塊寫入;為了使程序耗費更少的 CPU,降低對消息體進行 Json 編碼的次數,一次編碼,多次使用......
系統架構升級
我們的系統太過于簡單,所在在架構升級上,有太多的工作可以做,筆者在這里只提幾點比較重要的:
①應用/資源服務分離
我們所說的資源指的是圖片、視頻等文件,可以選擇成熟廠商的 Cos,或者自己搭建文件服務器也是可以的,如果資源量比較大,用戶比較廣,CDN 是不錯的選擇。
②突破系統連接數,搭建分布式環境
對于服務器的選擇,一般會選擇 Linux,Linux 下一切皆文件,長鏈接也是一樣。
單機的系統連接數是有限制的,一般來說能達到 10 萬就很不錯了,所以在用戶量增長到一定程序,需要搭建分布式。
分布式的搭建就要優化程序,因為長鏈接句柄分散到不同的機器,實現消息廣播和分發是首先要解決的問題,筆者這里不深入闡述了,一來是沒有足夠的經驗,二來是解決方案有太多的細節需要探討。
搭建分布式環境所面臨的問題還有:怎樣更好的彈性擴容、應對突發事件等。
③業務功能分離
我們上邊將用戶注冊、添加好友等功能和聊天功能放到了一起,真實的業務場景中可以將它們做分離,將用戶注冊、添加好友、創建群組放到一臺服務器上,將聊天功能放到另外的服務器上。
業務的分離不僅使功能邏輯更加清晰,還能更有效的利用服務器資源。
④減少數據庫I/O,合理利用緩存
我們的系統沒有將消息持久化,用戶信息持久化到 MySQL 中去。
在業務當中,如果要對消息做持久化儲存,就要考慮數據庫 I/O 的優化,簡單講:合并數據庫的寫次數、優化數據庫的讀操作、合理的利用緩存。
上邊是就是筆者想到的一些代碼優化和架構升級的方案。
結束語
不知道大家有沒有發現,使用 Go 搭建一個 IM 系統比使用其他語言要簡單很多,而且具備更好的拓展性和性能(并沒有吹噓 Go 的意思)。
在當今這個時代,5G 將要普及,流量不再昂貴,IM 系統已經廣泛滲入到了用戶日常生活中。
對于程序員來說,搭建一個 IM 系統不再是困難的事情,如果讀者根據本文的思路,理解 WebSocket,Copy 代碼,運行程序,應該用不了半天的時間就能上手這樣一個 IM 系統。
IM 系統是一個時代,從 QQ、微信到現在的人工智能,都廣泛應用了即時通信,圍繞即時通信,又可以做更多產品布局。
筆者寫本文的目的就是想要幫助更多人了解 IM,幫助一些開發者快速的搭建一個應用,燃起大家學習網絡編程知識的興趣,希望的讀者能有所收獲,能將 IM 系統應用到更多的產品布局中。
GitHub 可下載查看源代碼:
https://github.com/GuoZhaoran/fastIM
作者:繪你一世傾城
編輯:陶家龍
出處:https://juejin.im/post/5e1b29366fb9a02fc31dda24
外的題果然考得與眾不同
[secrypt_cen.html]
這次是HTML網頁,然后JS加密判斷
翻看JS代碼
{width="5.75in" height="3.375in"}
很顯然,關鍵的代碼在checkPassword
JS混淆是必備的
去混淆一條龍走起
先將關鍵代碼提取出來
JavaScript
function _0x4857(_0x398c7a, _0x2b4590) { const _0x104914=_0x25ec(); _0x4857=function (_0x22f014, _0x212d58) { _0x22f014=_0x22f014 - (0x347 + 0x46a * -0x7 + 0x1cc6); let _0x321373=_0x104914[_0x22f014]; return _0x321373; }; return
_0x4857(_0x398c7a, _0x2b4590); } (function (_0x414f9c, _0x3d4799)
{
//...................省略大量代碼
} function safe_add(a, b) { var c=(65535 & a) + (65535 & b); return
(a >> 16) + (b >> 16) + (c >> 16) << 16 | 65535 & c } function
bit_rol(a, b) { return a << b | a >>> 32 - b }
使用在線的javascript去混淆即可
deobfuscate.relative.im
得到去混淆后的結果
function checkPassword(_0x38d32a) {
try {
if (_0x38d32a.length !==21) {
return false
}
if (
//......省略大量代碼
return [c, d, j, k]
}
function md5_cmn(a, b, c, d, e, f) {
return safe_add(bit_rol(safe_add(safe_add(b, a), safe_add(d, f)), e),
c)
}
function md5_ff(a, b, c, d, e, f, g) {
return md5_cmn((b & c) | (~b & d), a, b, e, f, g)
}
function md5_gg(a, b, c, d, e, f, g) {
return md5_cmn((b & d) | (c & ~d), a, b, e, f, g)
}
function md5_hh(a, b, c, d, e, f, g) {
return md5_cmn(b ^ c ^ d, a, b, e, f, g)
}
function md5_ii(a, b, c, d, e, f, g) {
return md5_cmn(c ^ (b | ~d), a, b, e, f, g)
}
function safe_add(a, b) {
var c=(65535 & a) + (65535 & b)
return (((a >> 16) + (b >> 16) + (c >> 16)) << 16) | (65535 &
c)
}
function bit_rol(a, b) {
return (a << b) | (a >>> (32 - b))
}
flag長度21
發現了MD5加密,和兩個MD5字符串
看起來無關聯?
后來審計整個代碼發現,對輸入的flag分部分進行判斷比較
寫出對應的部分,在控制臺console輸出相關信息是一個不錯的選擇
function checkPassword(_0x38d32a) {
try {
// Password length is 21.
if (_0x38d32a.length !==21) {
return false;
}
if (
_0x38d32a.slice(1, 2) !==(String.fromCodePoint + "")[
parseInt((parseInt + "").charCodeAt(3), 16) - 147
] /* password[1]='o' */ ||
_0x38d32a[(parseInt(41, 6) >> 2) - 2] !==String.fromCodePoint(123) /* password[4]='{' */ ||
_0x38d32a[4].charCodeAt(0) !==_0x38d32a[7].charCodeAt(0) + 72 /* password[7]='3'. */ ||
JSON.stringify(
Array.from(
_0x38d32a.slice(5, 7).split("").reverse().join(),
(_0x2d4d73)=> _0x2d4d73.codePointAt(0)
).map((_0x5b85c5)=> _0x5b85c5 + 213)
) !==JSON.stringify([
285, 257, 297,
]) /* password[5]='T', password[6]='H' password[7]='3'*/
) {
return false;
}
/* For password[8], password[9], password[10], password[11]
*/
let _0x3c7a5c=_0x38d32a.slice(8, 12).split("").reverse();
try {
for (let _0x396662=0; _0x396662 < 5; _0x396662++) {
_0x3c7a5c[_0x396662]=_0x3c7a5c[_0x396662].charCodeAt(0) + _0x396662 +
getAdder(_0x396662);
}
} catch (_0x1fbd51) {
_0x3c7a5c=_0x3c7a5c.map(
(_0x24cda7)=> (_0x24cda7 +=_0x1fbd51.constructor.name.length -
4)
);
}
if (
MD5(String.fromCodePoint(..._0x3c7a5c)) !=="098f6bcd4621d373cade4e832627b4f6" /* password[8]='0',
password[9]='R', password[10]='3', password[11]='M'
*/
) {
return false;
}
if (
MD5(_0x38d32a.charCodeAt(12) + "") !=="812b4ba287f5ee0bc9d43bbf5bbe87fb" /* password[12]='_' */
) {
return false;
}
_0x3c7a5c=(_0x38d32a[8] + _0x38d32a[11]).split("");
_0x3c7a5c.push(_0x3c7a5c.shift());
if (
_0x38d32a.substring(14, 16) !==String.fromCodePoint(
..._0x3c7a5c.map((_0x5b5ec8)=>
Number.isNaN(+_0x5b5ec8) ? _0x5b5ec8.charCodeAt(0) + 5 : 48
)
) /* password[14]='R' password[15]='0' */ ||
_0x38d32a[_0x38d32a[7] - _0x38d32a[10]] !==atob("dQ==") /* password[0]='u' */ ||
_0x38d32a.indexOf(String.fromCharCode(117)) !==_0x38d32a[7] - _0x38d32a[17] /* password[17]='3' */ ||
JSON.stringify(
_0x38d32a
.slice(2, 4)
.split("")
.map(
(_0x7bf0a6)=>
_0x7bf0a6.charCodeAt(0) ^
getAdder.name[_0x38d32a[7]].charCodeAt(0)
)
) !==JSON.stringify(
[72, 90].map(
(_0x40ab0d)=>
_0x40ab0d ^
String.fromCodePoint.name[_0x38d32a[17] - 1].charCodeAt(0)
)
) /* password[2]='f', password[3]='t' */
) {
return false;
}
if (
String.fromCodePoint(
..._0x38d32a
.split("")
.filter(
(_0x5edfac, _0x2965d2)=> _0x2965d2 > 15 && _0x2965d2 % 2==0
)
.map(
(_0x2ffa6d)=>
_0x2ffa6d.charCodeAt(0) ^ (_0x38d32a.length + _0x38d32a[7])
)
) !==atob(
"g5Go"
) /* password[16]='V', password[18]='D', password[20]='}' */
) {
return false;
}
if (
_0x38d32a[_0x38d32a.length - 2] !==String.fromCharCode(Math.floor((({} + "").charCodeAt(0) + 9) / 3))
||
_0x38d32a[1 + _0x38d32a[7]] !==giggity()[5] /* password[19]=! */
) {
return false;
}
return true;
} catch (_0x4d4983) {
return false;
}
}
function getAdder(_0x430c9d) {
switch (_0x430c9d) {
case 0:
return 34;
case 1:
return 44;
case 2:
return 26;
case 3:
return 60;
}
return 101;
}
function giggity() {
return giggity.caller.name;
}
得到flag
uoft{TH30R3M_PR0V3D!}
*請認真填寫需求信息,我們會在24小時內與您取得聯系。