共用的抽象模型(客戶及伺服器端)- HTTP 模型
HTTP 以及相關的規範中定義了相當大數目的,而且不特定用於客戶端或伺服器端的概念及其功能,因為這些定義對於 HTTP 連接的兩端都是同樣有意義的。在這一章裡面,我們會介紹 Akka HTTP 里相對應的模型,而不是在各 客戶端 API ,低抽象層伺服器端 API 或 高抽象層伺服器端 API 專門章節中描述。各章節則負責其對應的部分。
(譯註:本章配合 HTTP 官方規範文檔觀看為佳)
HTTP 模型
Akka HTTP 有一套,結構嚴密,全不可變,並基於 case class 的模型對應主要的 HTTP 數據結構,例如 HTTP 請求,響應 以及 通用的頭域。這套模型包含在 akka-http-core 模組裡,並構成了大部分 Akka HTTP API 的基礎。
(譯註:case class 為 Scala 特有的類型定義, Java 開發者可以參考相關文檔理解)
概述
既然 akka-http-core 提供了主要的 HTTP 數據結構,開發者會發現以下的引入會經常出現在代碼(akka http 庫或開發者自己的代碼庫)里。
import akka.http.scaladsl.model._n
這個包引入基本上包括了全部的主要相關類型,比如:
- HttpRequest 和 HttpResponse,主要的信息模型
- headers 頭域包,所有預定義好的 HTTP 頭域模型以及相關的輔助類型
- 相關的輔助類型如 Uri, HttpMethods, MediaTypes, StatusCodes,等等。
在 Akka HTTP 的包定義規則里,一個比較通用的做法是,一個數據類型的抽象一般都是用一個不可變的類型(可以是 class 或者 trait)進行描述,而對應 HTTP 規範中具體的實例/值則由其對應的伴生對象生成並存放。伴生對象的命名規則為相關定義的名稱複數,也就是在其對應規範類型名稱後加『s
例如:
- HttpMethod 類型的相關實例(例如 CONNECT ) 在 HttpMethods 單例(伴生對象)中.
- HttpCharset 類型的相關實例(例如 `UTF-8` ) 在 HttpCharsets 單例(伴生對象)中.
- HttpEncoding 類型的相關實例(例如 gzip ) 在 HttpEncodings 單例(伴生對象)中.
- HttpProtocol 類型的相關實例(例如 `HTTP/2.0` ) 在 HttpProtocols 單例(伴生對象)中.
- MediaType 類型的相關實例(例如 `application/json` ) 在 MediaTypes 單例(伴生對象)中.
- StatusCode 類型的相關實例(例如 NotFound ) 在 StatusCodes 單例(伴生對象)中.
(譯註:這裡舉的幾個例如在原文中並不包含,只是翻譯時加上,以便理解)
HttpRequest
HttpRequest and HttpResponse 是描述 HTTP 信息最基本的 case classes
一個 HttpRequest 由以下幾項組成
- 一個 HTTP 請求方法 (GET, POST, 等等)
- 一個 URI
- 一個頭域列
- 一個 HTTP 正文
- 一個協議定義
下面有一些例子用於構造一個 HttpRequest 對象
import HttpMethods._nn//構造一個 對於 `homeUri` 的 GET 請求nval homeUri = Uri("/abc")nHttpRequest(GET, uri = homeUri)nn//構造一個 對於 `/index` 的 GET 請求(字元串可被隱式轉換為 Uri 對象)nHttpRequest(GET, uri = "/index")nn//構造一個 有正文內容 的 POST 請求nval data = ByteString("abc")nHttpRequest(POST, uri = "/receive", entity = data)nn//對應每個參數都提供一點內容的請求nimport HttpProtocols._nimport MediaTypes._nimport HttpCharsets._nval userData = ByteString("abc")nval authorization = headers.Authorization(BasicHttpCredentials("user", "pass"))nHttpRequest(n PUT,n uri = "/user",n entity = HttpEntity(`text/plain` withCharset `UTF-8`, userData),n headers = List(authorization),n protocol = `HTTP/1.0`)n
所有 HttpRequest.apply 構造器所需要的參數都有預設值,如果沒有相關的數據則無需刻意提供,例如 headers。而且大部分的參數類型(如 HttpEntity 和 Uri)都有相對應的隱式轉換,以便在大多數的情況下簡化生成 request 和 response 實例的過程。
HttpResponse
一個 HttpResponse 由以下幾項組成
- 一個狀態碼
- 一個頭域列
- 一個 HTTP 正文
- 一個協議定義
下面有一些例子用於構造一個 HttpResponse 對象
import StatusCodes._nn// 一個簡單的 OK 響應,沒有包含數據,並使用正整數狀態碼生成nHttpResponse(200)nn// 一個 404 響應,用 StatusCode 的常量實例 NotFound 生成nHttpResponse(NotFound)nn//一個 404 響應,在正文中包含了相關錯誤信息nHttpResponse(404, entity = "Unfortunately, the resource couldnt be found.")nn//一個要求重定向的響應,包含一個一個頭域nval locationHeader = headers.Location("http://example.com/other")nHttpResponse(Found, headers = List(locationHeader))n
除了提供基礎的 HttpEntity 構造器(從固定的 String 或 ByteString 生成正文數據類型)外(參考上述例子中 entity = ...),Akka HTTP 模型還定義了一堆 HttpEntity 的子類型處理位元組流式的正文數據。
HttpEntity
一個 HttpEntity 實例中攜帶著位元組(具體的內容/數據)以及其相關的 Content-Type 和 Content-Length(假如長度已知)。在 Akka HTTP 里,有著 5 種不同的 HTTP 正文抽象模型用於處理收/發不同的信息內容。
HttpEntity.Strict
最簡單的模型,可用於當整個正文內容都已被載入到內存的時候。它把一個 ByteString 包裝起來構成一個標準的,非分塊的,及帶有已知內容長度 Content-Length 的 正文。
HttpEntity.Default
最通用,非分塊的 HTTP/1.1 信息正文模型。 它具有已知的內容長度並其內容數據類型為 Source[ByteString],該類型只能被實例化一次。當數據來源不能生成與內容長度定義的位元組數一致的內容時,使用該模型則是一個錯誤。Strict 和 Default 兩模型間的區別只是在 API 定義上,在實際傳輸連接上,兩者的正文內容是一樣的。
HttpEntity.Chunked
為 HTTP/1.1 分塊傳輸內容 (即發送時定義 Transfer-Encoding: chunked)設計的模型。內容信息長度未知,而且每一個分塊數據體現為 Source[HttpEntity.ChunkStreamPart]。一個 ChunkStreamPart 可以是 Chunk 類型(非空內容)或 LastChunk 類型(包含可能相關的頭域)。相應的數據流由零或多個 Chunked 對象組成,並可以被一個 LastChunk 對象(非必要)終止。
(譯註,Chunked transfer encoding 分塊傳輸編碼 相關知識可查看wiki定義)
HttpEntity.CloseDelimited
該模型針對一個非分塊的 HTTP 個體,其未知的傳輸內容長度是當連接結束時才確立的。數據內容的表現方式為 Source[ByteString] 類型。因為連接的結束必須發生在完成傳輸該類型正文之後,所以這種模型只能用於在伺服器端生成響應內容。同時,設計 CloseDelimited 類型的主要原因在於它和 HTTP/1.0 的兼容性,因為舊的協議不支持分塊傳輸。如果開發者在開發一個新的應用程序時沒有舊需求的約束,最好不要使用 CloseDelimited 類型。因為使用 Connection:Close 斷開 HTTP 連接並不是一個穩健的做法,特別對於大量使用代理伺服器的今天。再加上這個類型使得 HTTP 連接無法復用,會嚴重影響性能。建議使用 HttpEntity.Chunked 類型!
HttpEntity.IndefiniteLength
一個內存長度不確定的流式正文數據模型,對應於 Multipart.BodyPart
正文類型裡面的 Strict, Default, 和 Chunked 是 HttpEntity.Regular 的子類型,它們都適用於 HTTP 的 請求 和 響應。相比較之下,HttpEntity.CloseDelimited 則只能用於 HTTP 響應 。
流式正文類型(除了 Strict 以外的所有) 是不能被共享或序列化的。當需要建立一個完備的,可共享的正文內容或消息的時候可使用 HttpEntity.toStrict 或 HttpMessage.toStrict 返回一個 Future ,數據流中的位元組會被收集成 ByteString 到這個被 Future 包著的 Strict 實例內。
(譯註:這裡原文有點對讀者背景知識期待有點高,例如,為什麼 toStrict 要用 Future 包一下,又或,為啥要把流數據又變成 Strict 的呢。這個有興趣的人可以深入學習一下。)
HttpEntity 的伴生對象里包含了好幾個輔助性的構造器,使得開發者很方便地從一些常用類型生成 HTTP 正文。
t如果需要提供針對每種子類型進行一些特殊的處理,開發者可以使用子類型的模式匹配方案。不過,很多時候 HttpEntity 的接收方並不在意收到的是哪一種子類型(以及數據是如何在 HTTP 層上傳輸的)。那麼,通常來說可以通過 HttpEntity.dataBytes 這個方法會用來返回一個 Source[ByteString, Any] ,使得使用者可以無需在意是哪種子類型的情況下獲取數據。
什麼時候用什麼子類型?
- 如果數據本身「不大」而且已經全部載入到內存里時(例如,String 或 ByteString),使用 Strict
- 如果數據是從一個流式數據源生成的,並且數據大小已知時,使用 Default
- 當正文長度未知時,使用 Chunked
- 當僅做為 Chunked 類型的替代品,以便為某些不支持分塊傳輸模式的舊式客戶端提供服務時,使用 CloseDelimited 在響應信息中。否則,請使用 Chunked。
- 當需要在 Multipart.Bodypart 里提供未知長度的正文內容,使用 IndefiniteLength 。
注意事項
當從連接上接收到一個非完備的信息時,只有通過不斷消費正文數據流,應用程序才會繼續從網路上讀取後續的位元組。這意味著,如果程序運行時不消費正文流的話,連接將會停擺,後續的 HTTP 請求或響應將不會從連接上被讀取,因為當前的信息正文正「堵塞」著整個通道。所以,開發者必須確認程序總是會消費掉整個正文數據,即使在對具體數據內容並不感興趣的情景下。
限制正文長度
所有 Akka HTTP 從網路上讀取的信息正文都會通過一個長度校驗。這個校驗確定了正文的總長度不大於程序配置好的 max-content-length 大小,以便作為對抗某些 Denial-of-Service 攻擊的重要防範手段。但是,一個單一的全局長度上限對應與所有的請求/響應的確是很缺乏彈性,特別是某些需要在特定需求里(而其它大部分場景下不需要)有大上限的請求/響應程序而言。
為了能讓開發者在按需設定正文大小時有最大程度的靈活性,HttpEntity 提供了 withSizeLimit 方法,可以讓開發者為相應的對象調整(增加或減少此前設定的)全局最大正文長度值。這就是說應用程序會接收所有的 HTTP層 上的 請求/響應,包括那些 Content-Length 超出預定義長度的信息。只有當正文對象中包含的實際數據流 Source 具現化後,相關的邊際校驗才會被執行。如果長度校驗失效,對應的數據流就拋出 EntityStreamSizeException 並終結,終止可以直接發生在具現化的時候(Content-Length 已知)或者再讀入多幾個超標位元組之後。
調用 Strict 類型對象的 withSizeLimit 方法時,如果正文長度在規定範圍內,則返回對象本身,否則,則返回一個 Default 類型的,帶有單一元素數據流的正文對象。這使得程序可以在晚一點的時候(在具現化整個數據流之前)再去做正文長度的調整。
默認情況下,所有在 HTTP 層生成的信息正文數據會自動帶有相關的長度限制(由程序配置裡面的 max-content-length 參數設置)。如果正文數據轉換時影響了內容長度值,並設定新的長度限制,那麼新的限制則會應用與新的值。如果正文數據轉換時影響了內容長度值但並不設定新的限制,那麼舊的限制則規限舊的值。一般來說,這樣的行為模式應該符合開發者預期。
HEAD 請求的特殊處理
RFC 7320 很明確地闡明了 HTTP 信息里關於正文長度的規定
尤其是以下這一條軌道需要 Akka HTTP 特別對待
Any response to a HEAD request and any response with a 1xx (Informational), 204 (No Content), or 304 (Not Modified) status code is always terminated by the first empty line after the header fields, regardless of the header fields present in the message, and thus cannot contain a message body.
任何對 HEAD 請求 的響應,以及任何 響應 是 1xx(信息參考類),204(No Content),304 (Not Modified)狀態碼的信息都是被在頭域後的第一條空行終止,不論其頭域內容是何種表示形式,因此,這類信息不能攜帶任何正文內容。
針對 HEAD 請求的響應信息里因為雖帶有 Content-Length 或 Transfer-Encoding 等頭域而正文卻可以為空,這為程序設計帶來了複雜性。這在設計程序模型時允許 HttpEntity.Default 和 HttpEntity.Chunked 類型作為 HEAD 響應的正文數據模型並攜帶一個空的數據流。
並且,當一個 HEAD 響應 帶有一個 HttpEntity.CloseDelimited 類型的正文域時,Akka HTTP 實現將不會在送返響應後關掉連接。這使得在多個HTTP持久連接間在輸送 HEAD 響應時無需帶有 Content-Length 頭域。
頭域模型
Akka HTTP 帶有一套豐富的抽象模式對應常用的頭域。模型與文本間相互的分析和描繪都是自動完成的,程序本身並不需要太關心頭域的相關語法。如果有頭域沒有對應的模型會以 RawHeader 類型體現,(簡單來說就是一個 名稱:值 都是 String 的 case class 類型)。
可以參考以下幾個處理頭域的例子
import akka.http.scaladsl.model.headers._nn// 生成一個 ``Location`` 頭域nval loc = Location("http://example.com/other")nn// 生成一個 ``Authorization`` 頭域,並帶有 Basic 定義的驗證數據nval auth = Authorization(BasicHttpCredentials("joe", "josepp"))nn//應用程序模型ncase class User(name: String, pass: String)nn// 一個函數用於抽取請求中 Basic 定義的用戶信息ndef credentialsOfRequest(req: HttpRequest): Option[User] =n for {n Authorization(BasicHttpCredentials(user, pass)) <- req.header[Authorization]n } yield User(user, pass)n
HTTP 相關頭域模型
當 Akka HTTP 伺服器接收到一個 HTTP 請求時,它會嘗試去解析這個信息的所有頭域數據到其相應的模型。無論這個嘗試是否成功,HTTP 層都會把所有接收到的頭域數據傳到應用層。未知的頭域數據,甚至一些有非法語意(基於頭域語法分析)的頭域數據都會被轉換成 RawHeader 類型的實例。出現解析錯誤的頭域會有相應的警告信息記錄(具體是否記錄取決於 illegal-header-warnings 這個配置的值)在日誌中。
某些頭域在 HTTP 中有著特殊地位,所有它們也有相對與其它「普通」頭域的特殊處理:
Content-Type
HTTP 信息裡面的 Content-Type 域是 HttpEntity 類型里的 contentType 值。所以,Content-Type 不會出現在信息的頭域列 headers 里。 即使一個 Content-Type 類型的實例被強制加到頭域列 headers 中,其內容也不會被生成到最終通訊渠道上,而是生成一個警告信息並記錄在日誌里。
Transfer-Encoding
HTTP 信息中帶有 Transfer-Encoding: chunked 頭域的,其正文數據會被轉化成 HttpEntity.Chunked 類型。因此,分塊信息里,如果沒有更深層次嵌套傳輸編碼的,將不會在其頭域列 headers 裡帶有 Transfer-Encoding 類型的頭域。即使一個 Transfer-Encoding 類型的實例被強制加到頭域列 headers 中,其內容也不會被生成到最終通訊渠道上,而是生成一個警告信息並記錄在日誌里。
Content-Length
一個信息的內容長度由 HttpEntity 定義。因此 Content-Length 頭域內容永遠都不會解析成為頭域串的一部分。同樣地,強制添加的 Content-Length 類型實例,也不會被生成到最終通訊渠道上,而是生成一個警告信息並記錄在日誌里。
Server
一個 Server 類型的頭域實例一般會被自動加到響應中,它的值可以通過 akka.http.server.server-header 設置。
另外,應用程序可以通過增加一個客制化的新實例到響應信息的頭域列 headers中以覆蓋之前設置的值。
User-Agent
一個 User-Agent 類型的頭域實例一般會被自動加到請求中,它的值可以通過 akka.http.client.user-agent-header 設置。
另外,應用程序可以通過增加一個客制化的新實例到請求信息的頭域列 headers 中以覆蓋之前設置的值。
Date
Date 類型的頭域實例一般會被自動加到響應中,但也可以手動加入以便覆蓋舊值。
Connection
伺服器端的 Akka HTTP 會監控強制加入響應信息中的 Connection: close 頭域實例,以便完成程序在送出響應後希望斷開相關的連接的意願。有關如何判斷是否斷開連接的具體邏輯相對複雜,它會考慮到信息的請求方式,協議,相關的 Connection 頭域,以及響應信息的協議,正文內容以及其對應的 Connection 頭域。參考鏈接中列出的場景。
Strict-Transport-Security
HTTP Strict Transport Security (HSTS HTTP嚴格傳輸安全) 是一套網頁安全機制,它的信息交換主要通過 Strict-Transport-Security 頭域完成。HSTS 能修復的安全漏洞中最重要的是 SSL剝離攻擊。SSL剝離攻擊是中間人攻擊的一種,其攻擊的主要原理是通過把一個 安全的 HTTPS 連接 無痕迹地轉化成 明文的 HTTP 連接。用戶能看到連接是不安全的,但是沒有任何方式去驗證該連接是否應該安全。HSTS 嘗試通過知會瀏覽器該網站必須始終使用 TLS/SSL 來解決這個問題。參考 連接
客制化頭域模型
有時候開發者需要建立一些客制的頭域模型,即使它們並非 HTTP 規範的一部分,但又希望像內置類型一樣方便的使用。
因為開發者希望可能有各種與頭域類型的互動(例如:可以嘗試用 CustomHeader 去模式匹配 RawHeader 類型,或者倒過來)的方式,Akka HTTP 提供了一個輔助用的 trait 對應客制頭域類型以及其相關的伴生對象。而擴展了 ModeledCustomHeader(而不是直接繼承 CustomHeader )的頭域類型則可以使用模式匹配。
final class ApiTokenHeader(token: String) extends ModeledCustomHeader[ApiTokenHeader] {n override def renderInRequests = falsen override def renderInResponses = falsen override val companion = ApiTokenHeadern override def value: String = tokenn}nobject ApiTokenHeader extends ModeledCustomHeaderCompanion[ApiTokenHeader] {n override val name = "apiKey"n override def parse(value: String) = Try(new ApiTokenHeader(value))n}n
使得 CustomHeader 可以在以下場景中使用
val ApiTokenHeader(t1) = ApiTokenHeader("token")nt1 should ===("token")nnval RawHeader(k2, v2) = ApiTokenHeader("token")nk2 should ===("apiKey")nv2 should ===("token")nn//可以匹配,頭域鍵是大小寫無關的nval ApiTokenHeader(v3) = RawHeader("APIKEY", "token")nv3 should ===("token")nnintercept[MatchError] {n //不會匹配,不一樣的頭域名稱n val ApiTokenHeader(v4) = DifferentHeader("token")n}nnintercept[MatchError] {n //不會匹配,不一樣的頭域名稱n val RawHeader("something", v5) = DifferentHeader("token")n}nnintercept[MatchError] {n //不會匹配,不一樣的頭域名稱n val ApiTokenHeader(v6) = RawHeader("different", "token")n}n
也可以用於頭域內部指令,如以下的 headerValuePF 例子
def extractFromCustomHeader = headerValuePF {n case t @ ApiTokenHeader(token) ? s"extracted> $t"n case raw: RawHeader ? s"raw> $raw"n}nnval routes = extractFromCustomHeader { s ?n complete(s)n}nnGet().withHeaders(RawHeader("apiKey", "TheKey")) ~> routes ~> check {n status should ===(StatusCodes.OK)n responseAs[String] should ===("extracted> apiKey: TheKey")n}nnGet().withHeaders(RawHeader("somethingElse", "TheKey")) ~> routes ~> check {n status should ===(StatusCodes.OK)n responseAs[String] should ===("raw> somethingElse: TheKey")n}nnGet().withHeaders(ApiTokenHeader("TheKey")) ~> routes ~> check {n status should ===(StatusCodes.OK)n responseAs[String] should ===("extracted> apiKey: TheKey")n}n
開發者可以直接繼承 CustomHeader 類型以便減少重複代碼,但是弊端則是對 RawHeader 類型的模式匹配就無法簡單完成了,因此局限其在 Akka HTTP 的路由層面的用處。但是,如果只是用於生成內容倒是夠的。
要點
當定義客制化類型頭域時,應該優先考慮擴展 ModeledCustomHeader 而不是直接從 CustomHeader 繼承。這樣做可以使開發者自己定義的頭域類型能符合所有需要模式匹配的場景,就如使用那些內置類型時一樣(例如在路由層,一個客制頭域匹配一個 RawHeader 類型是很常見的的情形)
解析和渲染
解析和渲染 HTTP 數據結構已經被重度優化過了,而且對於大部分類型並沒有公開的 API 可用於相關字元串或位元組數組的解析或渲染。
要點
在配置文件 akka.http.client[.parsing], akka.http.server[.parsing] 和 akka.http.host-connection-pool[.client.parsing] 中有各種相關解析和渲染有關的設置可以用於調配,它們所有的預設值都已經在 akka.http.parsing 部分定義好了。
例如,如果開發者希望修改一個設置對應所有的組件,可以修改 akka.http.parsing.illegal-header-warnings = off 的值。然而,這個設置依然可以被其它特定的參數區重置。例如 akka.http.server.parsing.illegal-header-warnings = on。
這種情況下客戶端以及 host-connection-pool 的 API 就會認為該值是 off,而伺服器端 API 則是 on。
對於 akka.http.host-connection-pool.client 的相關設置,它們的預設值則在 akka.http.client 里,當然也可以被重置。既然客戶端以及 host-connection-pool 的 API, 例如 Http().outgoingConnection (客戶端 API)或 Http().singleRequest / Http().superPool (連接 API),通常共享某些參數設置,而伺服器端則很大可能用一套不同的設置,這樣的設計就很有用了。
登記客制化的媒體類型
Akka HTTP 預設了經常會碰到的媒體類型,並能從 http 信息中提取對應的實例。有時候可能開發者想定義一個新的模型並指引語法分析工具如何處理這些新的媒體類別,例如 application/custom 會可被看作 NonBinary 以及 WithFixedCharset 類型。開發者需要在伺服器設置中的 ParserSettings 登記一下相關的類型
// Java 是: `akka.http.javadsl.settings.[...]`nimport akka.http.scaladsl.settings.ParserSettingsnimport akka.http.scaladsl.settings.ServerSettingsnn// 定義一些客制化的媒體類型:nval utf8 = HttpCharsets.`UTF-8`nval `application/custom`: WithFixedCharset =n MediaType.customWithFixedCharset("application", "custom", utf8)nn// 添加新的媒體類型到語法分析設置里:nval parserSettings = ParserSettings(system).withCustomMediaTypes(`application/custom`)nval serverSettings = ServerSettings(system).withParserSettings(parserSettings)nnval routes = extractRequest { r ?n complete(r.entity.contentType.toString + " = " + r.entity.contentType.getClass)n}nval binding = Http().bindAndHandle(routes, host, port, settings = serverSettings)n
開發者可能也需要讀一下有關 MediaType 登記樹 的內容,以便正確地放置相關開發商專屬的媒體類型。
推薦閱讀:
※關於scala的寫法問題,implicit request =>是什麼意思?
※為什麼 Scala 不建議用 return?
※請教Spark 中 combinebyKey 和 reduceByKey的傳入函數參數的區別?
※Vert.x各種語言中的Lambda寫法
※Dotty 開發環境搭建