Go Web 程式設計--深入學習解析 HTTP 請求

KevinYan發表於2020-02-25

之前這個系列的文章一直在講用Go語言怎麼編寫HTTP伺服器來提供服務,如何給伺服器配置路由來匹配請求到對應的處理程式,如何新增中介軟體把一些通用的處理任務從具體的Handler中解耦出來,以及如何更規範地在專案中應用資料庫。不過一直漏掉了一個環節是伺服器接收到請求後如何解析請求拿到想要的資料,Go語言使用net/http包中的Request結構體物件來表示HTTP請求,通過Request結構物件上定義的方法和資料欄位,應用程式能夠便捷地訪問和設定HTTP請求中的資料。

一般服務端解析請求的需求有如下幾種

  • HTTP請求頭中的欄位值
  • URL 查詢字串中的欄位值
  • 請求體中的Form表單資料
  • 請求體中的JSON格式資料
  • 讀取客戶端的上傳的檔案

今天這篇文章我們就按照這幾種常見的服務端對HTTP請求的操作來說一下伺服器應用程式如何通過Request物件解析請求頭和請求體。

Request 結構定義

在說具體操作的使用方法之前我們先來看看net/http包中Request結構體的定義,瞭解一下Request擁有什麼樣的資料結構。Request結構在原始碼中的定義如下。

type Request struct {

    Method string

    URL *url.URL

    Proto      string // "HTTP/1.0"
    ProtoMajor int    // 1
    ProtoMinor int    // 0

    Header Header

    Body io.ReadCloser

    GetBody func() (io.ReadCloser, error)

    ContentLength int64

    TransferEncoding []string

    Close bool

    Host string

    Form url.Values

    PostForm url.Values

    MultipartForm *multipart.Form

    Trailer Header

    RemoteAddr string

    RequestURI string

    TLS *tls.ConnectionState

    Cancel <-chan struct{}

    Response *Response

    ctx context.Context
}

我們快速地瞭解一下每個欄位大致的含義,瞭解了每個欄位的含義在不同的應用場景下需要讀取訪問HTTP請求的不同部分時就能夠有的放矢了。

Method

指定HTTP方法(GET,POST,PUT等)。

URL

URL指定要請求的URI(對於伺服器請求)或要訪問的URL(用於客戶請求)。它是一個表示URL的型別url.URL的指標,url.URL的結構定義如下:

type URL struct {
    Scheme     string
    Opaque     string
    User       *Useri
    Host       string
    Path       string
    RawPath    string
    ForceQuery bool  
    RawQuery   string
    Fragment   string
}

Proto

ProtoProtoMajorProtoMinor三個欄位表示傳入伺服器請求的協議版本。對於客戶請求,這些欄位將被忽略。 HTTP客戶端程式碼始終使用HTTP / 1.1HTTP / 2

Header

Header包含服務端收到或者由客戶端傳送的HTTP請求頭,該欄位是一個http.Header型別的指標,http.Header型別的宣告如下:

type Header map[string][]string

map[string][]string型別的別名,http.Header型別實現了GETSETAdd等方法用於存取請求頭。如果服務端收到帶有如下請求頭的請求:

Host: example.com
accept-encoding: gzip, deflate
Accept-Language: en-us
fOO: Bar
foo: two

那麼Header的值為:

Header = map[string][]string{
    "Accept-Encoding": {"gzip, deflate"},
    "Accept-Language": {"en-us"},
    "Foo": {"Bar", "two"},
}

對於傳入的請求,Host標頭被提升為Request.Host欄位,並將其從Header物件中刪除。HTTP 定義頭部的名稱是不區分大小寫的。Go使用CanonicalHeaderKey實現的請求解析器使得請求頭名稱第一個字母以及跟隨在短橫線後的第一個字母大寫其他都為小寫形式,比如:Content-Length。對於客戶端請求,某些標頭,例如Content-LengthConnection會在需要時自動寫入,並且標頭中的值可能會被忽略。

Body

這個欄位的型別是io.ReadCloserBody是請求的主體。對於客戶端發出的請求,nil主體表示該請求沒有Body,例如GET請求。 HTTP客戶端的傳輸會負責呼叫Close方法。對於伺服器接收的請求,請求主體始終為非nil,但如果請求沒有主體,則將立即返回EOF。伺服器將自動關閉請求主體。伺服器端的處理程式不需要關心此操作。

GetBody

客戶端使用的方法的型別,其宣告為:

GetBody func() (io.ReadCloser, error)

ContentLength

ContentLength記錄請求關聯內容的長度。值-1表示長度未知。值>=0表示從Body 中讀取到的位元組數。對於客戶請求,值為0且非nilBody也會被視為長度未知。

####TransferEncoding

TransferEncoding為字串切片,其中會列出從最外層到最內層的傳輸編碼,TransferEncoding通常可以忽略;在傳送和接收請求時,分塊編碼會在需要時自動被新增或者刪除。

Close

Close表示在服務端回覆請求或者客戶端讀取到響應後是否要關閉連線。對於伺服器請求,HTTP伺服器會自動處理
並且處理程式不需要此欄位。對於客戶請求,設定此欄位為true可防止重複使用到相同主機的請求之間的TCP連線,就像已設定Transport.DisableKeepAlives一樣。

Host

對於伺服器請求,Host指定URL所在的主機,為防止DNS重新繫結攻擊,伺服器處理程式應驗證Host標頭具有的值。
http庫中的ServeMux(複用器)支援註冊到特定Host的模式,從而保護其註冊的處理程式。對於客戶端請求,Host可以用來選擇性地覆蓋請求頭中的Host,如果不設定,Request.Write使用URL.Host來設定請求頭中的Host

Form

Form包含已解析的表單資料,包括URL欄位的查詢引數以及PATCHPOSTPUT表單資料。此欄位僅在呼叫Request.ParseForm之後可用。HTTP客戶端會忽略Form並改用BodyForm欄位的型別是url.Values型別的指標。url.Values型別的宣告如下:

type Values map[string][]string

也是map[string][]string型別的別名。url.Values型別實現了GETSETAddDel等方法用於存取表單資料。

PostForm

PostForm型別與Form欄位一樣,包含來自PATCHPOST的已解析表單資料或PUT主體引數。此欄位僅在呼叫ParseForm之後可用。HTTP客戶端會忽略PostForm並改用Body

####MultipartForm

MultipartForm是已解析的多部分表單資料,包括檔案上傳。僅在呼叫Request.ParseMultipartForm之後,此欄位才可用。HTTP客戶端會忽略MultipartForm並改用Body。該欄位的型別是*multipart.Form

RemoteAddr

RemoteAddr允許HTTP伺服器和其他軟體記錄傳送請求的網路地址,通常用於記錄。 net/http包中的HTTP伺服器在呼叫處理程式之前將RemoteAddr設定為“ IP:埠”, HTTP客戶端會忽略此欄位。

RequestURI

RequestURI是未修改的request-target客戶端傳送的請求行(RFC 7230,第3.1.1節)。在伺服器端,通常應改用URL欄位。在HTTP客戶端請求中設定此欄位是錯誤的。

Response

Response欄位型別為*Response,它指定了導致此請求被建立的重定向響應,此欄位僅在客戶端發生重定向時被填充。

ctx

ctx 是客戶端上下文或伺服器上下文。它應該只通過使用WithContext複製整個Request進行修改。這個欄位未匯出以防止人們錯誤使用Context並更改同一請求的呼叫方所擁有的上下文。

讀取請求頭

上面分析了GoHTTP請求頭儲存在Request結構體物件的Header欄位裡,Header欄位實質上是一個Map,請求頭的名稱為MapkeyMap Value的型別為字串切片,有的請求頭像Accept會有多個值,在切片中就對應多個元素。

Header型別的Get方法可以獲取請求頭的第一個值,

func exampleHandler(w http.ResponseWriter, r *http.Request) {
    ua := r.Header.Get("User-Agent")
    ...
}

或者是獲取值時直接通過key獲取對應的切片值就好,比如將上面的改為:

ua := r.Header["User-Agent"]

下面我們寫個遍歷請求頭資訊的示例程式,同時也會通上面介紹的Request結構中定義的MethodURLHostRemoteAddr等欄位把請求的通用資訊列印出來。在我們一直使用的http_demo專案中增加一個DisplayHeadersHandler,其原始碼如下:

package handler

import (
    "fmt"
    "net/http"
)

func DisplayHeadersHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Method: %s URL: %s Protocol: %s \n", r.Method, r.URL, r.Proto)
    // 遍歷所有請求頭
    for k, v := range r.Header {
        fmt.Fprintf(w, "Header field %q, Value %q\n", k, v)
    }

    fmt.Fprintf(w, "Host = %q\n", r.Host)
    fmt.Fprintf(w, "RemoteAddr= %q\n", r.RemoteAddr)
    // 通過 Key 獲取指定請求頭的值
    fmt.Fprintf(w, "\n\nFinding value of \"Accept\" %q", r.Header["Accept"])
}

將其處理程式繫結到/index/display_headers路由上:

indexRouter.HandleFunc("/display_headers", handler.DisplayHeadersHandler)

然後啟動專案,開啟瀏覽器訪問:

http://localhost:8000/index/display_headers

可以看到如下輸出:

headers

http_demo專案中已經新增了本文中所有示例的原始碼,關注文末公眾號回覆gohttp06可以獲取原始碼的下載連結。

獲取URL引數值

GET請求中的URL查詢字串中的引數可以通過url.Query(),我們來看一下啊url.Query()函式的原始碼:

func (u *URL) Query() Values {
    v, _ := ParseQuery(u.RawQuery)
    return v
}

它通過ParseQuery函式解析URL引數然後返回一個url.Values型別的值。url.Values型別上面我們已經介紹過了是map[string][]string型別的別名,實現了GETSETAddDel等方法用於存取資料。

所以我們可以使用r.URL.Query().Get("ParamName")獲取引數值,也可以使用r.URL.Query()["ParamName"]。兩者的區別是Get只返回切片中的第一個值,如果引數對應多個值時(比如核取方塊表單那種請求就是一個name對應多個值),記住要使用第二種方式。

我們通過執行一個示例程式display_url_params.go來看一下兩種獲取URL引數的區別

package handler

import (
"fmt"
"net/http"
)

func DisplayUrlParamsHandler(w http.ResponseWriter, r *http.Request) {
    for k, v := range r.URL.Query() {
        fmt.Fprintf(w, "ParamName %q, Value %q\n", k, v)
        fmt.Fprintf(w, "ParamName %q, Get Value %q\n", k, r.URL.Query().Get(k))
    }
}

將其處理程式繫結到/index/display_url_params路由上:

indexRouter.HandleFunc("/display_url_params", handler.DisplayUrlParamsHandler)

開啟瀏覽器訪問

http://localhost:8000/index/display_url_params?a=b&c=d&a=c

瀏覽器會輸出:

ParamName "a", Value ["b" "c"]
ParamName "a", Get Value "b"
ParamName "c", Value ["d"]
ParamName "c", Get Value "d"

我們為引數a傳遞了兩個引數值,可以看到通過url.Query.Get()只能讀取到第一個引數值。

獲取表單中的引數值

Request結構的Form欄位包含已解析的表單資料,包括URL欄位的查詢引數以及PATCHPOSTPUT表單資料。此欄位僅在呼叫Request.ParseForm之後可用。不過Request物件提供一個FormValue方法來獲取指定名稱的表單資料,FormValue方法會根據Form欄位是否有設定來自動執行ParseForm方法。

func (r *Request) FormValue(key string) string {
   if r.Form == nil {
      r.ParseMultipartForm(defaultMaxMemory)
   }
   if vs := r.Form[key]; len(vs) > 0 {
      return vs[0]
   }
   return ""
}

可以看到FormValue方法也是隻返回切片中的第一個值。如果需要獲取欄位對應的所有值,那麼需要通過欄位名訪問Form欄位。如下:

獲取表單欄位的單個值

r.FormValue(key) 

獲取表單欄位的多個值

r.ParseForm()

r.Form["key"]

下面是我們的示例程式,以及對應的路由:

//handler/display_form_data.go
package handler

import (
    "fmt"
    "net/http"
)

func DisplayFormDataHandler(w http.ResponseWriter, r *http.Request) {
    if err := r.ParseForm(); err != nil {
        panic(err)
    }

    for key, values := range r.Form {
        fmt.Fprintf(w, "Form field %q, Values %q\n", key, values)

        fmt.Fprintf(w, "Form field %q, Value %q\n", key, r.FormValue(key))
    }
}

//router.go
indexRouter.HandleFunc("/display_form_data", handler.DisplayFormDataHandler)

我們在命令列中使用cURL命令傳送表單資料到處理程式,看看效果。

curl -X POST -d 'username=James&password=123' \
     http://localhost:8000/index/display_form_data

返回的響應如下:

Form field "username", Values ["James"]
Form field "username", Value "James"
Form field "password", Values ["123"]
Form field "password", Value "123"

獲取 Cookie

Request物件專門提供了一個Cookie方法用來訪問請求中攜帶的Cookie資料,方法會返回一個*Cookie型別的值以及errorCookie型別的定義如下:

type Cookie struct {
   Name  string
   Value string

   Path       string    // optional
   Domain     string    // optional
   Expires    time.Time // optional
   RawExpires string    // for reading cookies only

   MaxAge   int
   Secure   bool
   HttpOnly bool
   SameSite SameSite
   Raw      string
   Unparsed []string 
}

所以要讀取請求中指定名稱的Cookie值,只需要

cookie, err := r.Cookie(name)
// 錯誤檢查
...
value := cookie.Value

Request.Cookies()方法會返回[]*Cookie切片,其中會包含請求中所有的Cookie

下面的示例程式,會列印請求中所有的Cookie

// handler/read_cookie.go
package handler

import (
    "fmt"
    "net/http"
)

func ReadCookieHandler(w http.ResponseWriter, r *http.Request) {
    for _, cookie := range r.Cookies() {
        fmt.Fprintf(w, "Cookie field %q, Value %q\n", cookie.Name, cookie.Value)
    }
}
//router/router.go
indexRouter.HandleFunc("/read_cookie", handler.ReadCookieHandler)

我們通過cURL在命令列請求http://localhost:8000/index/read_cookie

curl --cookie "USER_TOKEN=Yes" http://localhost:8000/index/read_cookie

執行命令後會返回:

Cookie field "USER_TOKEN", Value "Yes"

解析請求體中的JSON資料

現在前端都傾向於把請求資料以JSON格式放到請求主體中傳給伺服器,針對這個使用場景,我們需要把請求體作為json.NewDecoder()的輸入流,然後將請求體中攜帶的JSON格式的資料解析到宣告的結構體變數中

//handler/parse_json_request
package handler

import (
    "encoding/json"
    "fmt"
    "net/http"
)

type Person struct {
    Name string
    Age  int
}

func DisplayPersonHandler(w http.ResponseWriter, r *http.Request) {
    var p Person

    // 將請求體中的 JSON 資料解析到結構體中
    // 發生錯誤,返回400 錯誤碼
    err := json.NewDecoder(r.Body).Decode(&p)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    fmt.Fprintf(w, "Person: %+v", p)
}

// router/router.go
indexRouter.HandleFunc("/parse_json_request", handler.ParseJsonRequestHandler)

在命令列裡用cURL命令測試我們的程式:

curl -X POST -d '{"name": "James", "age": 18}' \
     -H "Content-Type: application/json" \
     http://localhost:8000/index/parse_json_request

返回響應如下:

Person: {Name:James Age:18}%   

讀取上傳檔案

伺服器接收客戶端上傳的檔案,使用Request定義的FormFile()方法。該方法會自動呼叫r.ParseMultipartForm(32 << 20)方法解析請求多部表單中的上傳檔案,並把檔案可讀入記憶體的大小設定為32M(32向左位移20位),如果記憶體大小需要單獨設定,就要在程式裡單獨呼叫ParseMultipartForm()方法才行。

func ReceiveFile(w http.ResponseWriter, r *http.Request) {
    r.ParseMultipartForm(32 << 20) 
    var buf bytes.Buffer

    file, header, err := r.FormFile("file")
    if err != nil {
        panic(err)
    }
    defer file.Close()
    name := strings.Split(header.Filename, ".")
    fmt.Printf("File name %s\n", name[0])

    io.Copy(&buf, file)
    contents := buf.String()
    fmt.Println(contents)
    buf.Reset()

    return
}

Go語言解析HTTP請求比較常用的方法我們都介紹的差不多了。因為想總結全一點,篇幅還是有點長,不過整體不難懂,而且也可以下載程式中的原始碼自己執行除錯,動手實踐一下更有助於理解吸收。HTTP客戶端傳送請求要設定的內容也只今天講的Request結構體的欄位,Request物件也提供了一些設定相關的方法供開發人員使用,今天就先說這麼多了。

關注下方公眾號回覆gohttp06可以下載文章中專案的原始碼,趕快下載下來自己試一試吧。

前文回顧

深入學習用Go編寫HTTP伺服器

Web伺服器路由

十分鐘學會用Go編寫Web中介軟體

Go Web程式設計–應用ORM

本作品採用《CC 協議》,轉載必須註明作者和本文連結

公眾號:網管叨bi叨 | Golang、PHP、Laravel、Docker等學習經驗分享

相關文章