今読んでいる技術書の内容で大事だと思った部分を少しずつまとめていきたいと思います。
今回読んでいる本はこちら
Goプログラミング実践入門
この書籍では、フレームワークに頼らず標準ライブラリでweb開発をすることができます。
個人的にフレームワークはブラックボックスが多すぎて面倒であまり好きではない派なので、これを理解できることで理解が深まるのではないかと考え購入しました。
この本ではプログラミング言語Goとその標準のライブラリだけを使って、
ゼロからWebアプリケーションを開発するのに必要な事柄を解説します。
ほかのライブラリやその他のトピック(Webアプリケーションのテストやデプロイなど)について解説するページもありますが、Go言語の標準ライブラリのみを用いたWeb開発を解説することがこの本の主目的です。
本記事では、リクエストとレスポンスについてまとめています。
Request
まず、net/http
パッケージのhttp.Request
構造体は以下の通り定義されています。
フィールド | 説明 |
---|---|
Method |
HTTPメソッド(GET, POST, PUTなど)を指定します。クライアントリクエストの場合、空文字列はGETを意味します。 |
URL |
要求されているURI(サーバーリクエストの場合)またはアクセスするURL(クライアントリクエストの場合)を指定します。 |
Proto |
サーバーリクエストのプロトコルバージョンを指定します。 |
ProtoMajor |
プロトコルのメジャーバージョン(例:1)。 |
ProtoMinor |
プロトコルのマイナーバージョン(例:0)。 |
Header |
リクエストヘッダーを含みます。 |
Body |
リクエストのボディ。クライアントリクエストの場合、nilはボディがないことを意味します。 |
GetBody |
クライアントリクエストでリダイレクトが必要な場合に、ボディの新しいコピーを返すための関数。 |
ContentLength |
関連するコンテンツの長さを記録します。-1は長さが不明であることを示します。 |
TransferEncoding |
外側から内側への転送エンコーディングのリストを示します。 |
Close |
応答後に接続を閉じるかどうかを示します。 |
Host |
サーバーリクエストの場合、URLが探されるホストを指定します。クライアントリクエストの場合、送信するHostヘッダーをオーバーライドします。 |
Form |
解析されたフォームデータ(URLフィールドのクエリパラメーターとPATCH, POST, PUTのフォームデータを含む)。 |
PostForm |
PATCH, POST, PUTのボディパラメーターから解析されたフォームデータ。 |
MultipartForm |
解析されたマルチパートフォーム(ファイルアップロードを含む)。 |
Trailer |
リクエストボディの後に送信される追加ヘッダーを指定します。 |
RemoteAddr |
リクエストを送信したネットワークアドレス。 |
RequestURI |
クライアントがサーバーに送信したリクエストターゲットの未修正のRequest-Line。 |
TLS |
リクエストが受信されたTLS接続に関する情報を記録します。 |
Cancel |
クライアントリクエストがキャンセルされるべきことを示すチャネル。 |
Response |
このリクエストが作成される原因となったリダイレクト応答。 |
ctx |
クライアントまたはサーバーのコンテキスト。 |
pat |
ServeMuxによってマッチされたリクエストのパターン。 |
matches |
パターンのワイルドカードに対応する値。 |
otherValues |
ワイルドカードと一致しないPathValueの設定に使用される値。 |
リクエストのURL
フィールド名 | 型 | 説明 |
---|---|---|
Scheme |
string |
スキーム (例: "http", "https") |
Opaque |
string |
エンコードされた不透明データ |
User |
*Userinfo |
ユーザー名とパスワード情報 |
Host |
string |
ホストまたはホスト:ポート (例: "example.com", "example.com:8080") |
Path |
string |
パス (相対パスは先頭のスラッシュを省略可能) |
RawPath |
string |
エンコードされたパスのヒント (EscapedPath メソッド参照) |
OmitHost |
bool |
空のホスト (authority) を出力しない |
ForceQuery |
bool |
RawQuery が空でもクエリ ('?') を追加する |
RawQuery |
string |
エンコードされたクエリ値 ( '?' は含まない) |
Fragment |
string |
参照用のフラグメント ( '#' は含まない) |
RawFragment |
string |
エンコードされたフラグメントのヒント (EscapedFragment メソッド参照) |
URLの一般形式を以下のフィールド名を使って表すと、次のようになります。
Scheme://[UserInfo@]Host[Path][?RawQuery][#Fragment]
例えば、以下のURLの場合URL.RawQuery
を使うと、次のような結果が得られます。
http://example.com/search?q=golang&lang=en
u, _ := url.Parse("http://example.com/search?q=golang&lang=en")
fmt.Println(u.RawQuery) // "q=golang&lang=en"
同じようにScheme
やPath
なども取得して使用できます。便利ですね。
リクエストヘッダ 値の追加・削除・取得・設定
リクエストとレスポンスのヘッダはHeader型で記述され、この型はHTTPヘッダ内のキーと値のペアを表すマップになっています。キーは文字列型、値は文字列型のスライスです。type Header map[string][]string
具体的にはこんな形です。
Header{
"Content-Type": {"application/json"},
"Accept": {"text/html", "application/xhtml+xml", "application/xml;q=0.9", "*/*;q=0.8"},
"X-Custom-Header": {"custom value"},
}
それぞれのメソッドはheader.goに定義されていますが、使い方は以下の通りです。
func handler(w http.ResponseWriter, r *http.Request) {
// 値の追加
r.Header.Add("X-Custom-Header", "custom value")
w.Header().Add("Content-Type", "application/json")
// 値の取得
customHeader := r.Header
customHeader := r.Header.Get("X-Custom-Header")
contentType := w.Header().Get("Content-Type")
// 値の設定
r.Header.Set("X-Custom-Header", "new custom value")
w.Header().Set("Content-Type", "application/xml")
// 値の削除
r.Header.Del("X-Custom-Header")
w.Header().Del("Content-Type")
}
リクエストボディ
リクエストとレスポンスのボディはともにBodyフィールドで表され、このフィールドはインタフェースio.ReadCloser
で実現されます。
引用
Body の型として io.ReadCloserというものが指定されています。これは標準パッケージ io の中で定義されているインターフェース型で、2 つのメソッドを持ちそれぞれ以下のような処理を行うことができます。
• Read メソッド: ボディの中身を、引数として渡したバイトスライスに格納する
• Close メソッド: ボディの中身を読み終わった時にcloseする
Read(p []byte) (n int, err error)
Close() error
そのため、http.Request.Body の中からリクエストボディの内容を読み出す処理としては、
- 何らかの方法でバイトスライスを用意
- Body の Read メソッドを呼び出して、1 で用意したバイトスライスに内容を書き込む
- 使い終わった Body を Close メソッドで閉じる
という3ステップとなります。
io.ReadAll
にbodyを渡すことで初期に512バイトのスライスを作成し、容量が足りなくなったら追加で増やしそこに書き込んでいきます。最終的に書き込まれたスライスとエラーを返却します。
func ReadAll(r Reader) ([]byte, error) {
b := make([]byte, 0, 512)
for {
n, err := r.Read(b[len(b):cap(b)])
b = b[:len(b)+n]
if err != nil {
if err == EOF {
err = nil
}
return b, err
}
if len(b) == cap(b) {
// Add more capacity (let append pick how much).
b = append(b, 0)[:len(b)]
}
}
}
これを使ってハンドラーを作成します。
大きな流れとして、リクエストのBodyに格納されたデータをio.ReadAllに渡し、バイトスライスを取得します。そして、それを文字列へ型変換しFPrint
でw
に書き込みます。
package main
import (
"fmt"
"io"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request){
body, err := io.ReadAll(r.Body)
if err != nil{
http.Error(w, "Failed to read request body", http.StatusInternalServerError)
fmt.Printf("something went wrong %s", err)
return
}
defer r.Body.Close()
fmt.Fprint(w, string(body))
}
func main(){
server := http.Server{
Addr: "127.0.0.1:8080",
}
http.HandleFunc("/body", handler)
server.ListenAndServe()
}
そして、以下のようにPOSTでボディに'Hello, World!'とデータを指定してPOSTすると、Hello, World!
とレスポンスが返ってきました。
curl -X POST http://127.0.0.1:8080/body -d 'Hello, World!'
Hello, World!
io.ReadAll
で返却されるのはバイトスライスなので今回の場合は[72 101 108 108 111 44 32 87 111 114 108 100 33]
が返却されます。このままでは使えないので、string
へ型変換することで期待しているHello, World!
という文字列になります。
また、今回はfmt.Fprint
を使用していますがw.Write(body)
でも書き込むことが可能です。
EOFについて
ReadAll
メソッドの中でEOF(End Of File)
かどうか判定していますが、ここを少し深ぼっておきます。
例えば、RequestにはReadメソッドが用意されていますが、このメソッドはバイトスライスを引数で受け取り、読み取ったデータをそこに書き込んでくれます。
そして、正常にボディの中身を最後まで読み取ることができた場合はエラーとしてio.EOFを返却します。そして、エラーがio.EOF以外だった場合はボディの読み取り中に異常が発生したことを意味します。
これを先ほどのReadAllメソッドで確認すると以下のように判定しています。
つまり、エラーの場合でもEOFは厳密には正常終了なので、エラーは空として扱います。それ以外は通常通りエラーなので、エラーを返却しています。
if err != nil {
if err == EOF {
err = nil
}
return b, err
}
正常に終了していますがEOFはエラーとして返ってくるので、err != nil
と安易に判定しないよう注意が必要です。
HTMLフォームとGO言語
POSTリクエストからのフォームデータの取得に深入りする前にHTMLフォームについて詳しくみておく。
enctype(encoding type)
は、HTMLフォームがデータを送信する際のエンコーディング方法を指定するための属性です。特にPOSTリクエストでフォームデータを送信する際に使われます。enctype属性
にはいくつかの異なる値があり、それぞれの形式によってデータが異なる方法でエンコードされます。
application/x-www-form-urlencoded(デフォルト値)
テキストデータやURLで使用される標準的な形式
- フォームデータをURLエンコードされた形式で送信
- すべての文字はキーバリューペア(name=value)としてエンコードされ、&で区切られる
例:name1=value1&name2=value2
<form action="/submit" method="post" enctype="application/x-www-form-urlencoded">
<input type="text" name="username" value="user1">
<input type="submit" value="Submit">
</form>
multipart/form-data
ファイルアップロード時やバイナリデータの送信時に使用
- バイナリデータやファイルを含むフォームデータを送信する際に使用
- フォームデータは境界線(boundary)で区切られた部分(パート)に分けられ、各パートにはヘッダと内容が含まれる
--boundary
Content-Disposition: form-data; name="field1"
value1
--boundary
Content-Disposition: form-data; name="file"; filename="example.txt"
Content-Type: text/plain
(file content here)
--boundary--
<form action="/submit" method="post" enctype="multipart/form-data">
<input type="text" name="username" value="user1">
<input type="file" name="profile_picture">
<input type="submit" value="Submit">
</form>
text/plain
もありますが、ほとんど使用されないため割愛。
では、これらのテキストやファイルデータをどのようにGo側で使用することができるのか確認します。
基本的にはParseForm()もしくはParseMultipartForm
を使用してリクエストを解析し、フィールドにアクセスし取得することができる。
例えばapplication/x-www-form-urlencoded
の場合、ざっくりと以下のように取得できる。
r.ParseForm()
username := r.Form("username")
この2つは、以下のような使い分けで使用することができる。
形式 | application/x-www-form-urlencoded | multipart/form-data | クエリパラメータを含む | 解析の必要性 |
---|---|---|---|---|
フォームデータの解析方法 | r.ParseForm() | r.ParseMultipartForm(maxMemory) | - | - |
取得方法 | r.Form | - | ○ | ○ |
取得方法 | r.PostForm | r.MultipartForm | × | ○ |
取得方法 | r.FormValue | - | ○ | × |
取得方法 | r.PostFormValue | - | × | × |
取得方法 | - | r.FormFile | × | × |
* ただし、ParseMultipartForm
で解析する際はマルチパートのフォームから何バイト取得するかを指定する必要がある。
それぞれ実際に取得できる値を試してみた。
リクエストで使用するコマンドは以下の通りでフォームデータにはname=formData
、クエリパラメータにはqueryParam
を指定した。(ただし、マルチパートの場合は-d
ではなく-F
とする。)
%curl -X POST -d "name=formData" "http://127.0.0.1:8080/test?name=queryParam"
map[name:[formData queryParam]]
r.Form
期待:解析後、フォームデータとクエリパラメータを取得できること
形式 | application/x-www-form-urlencoded | multipart/form-data | クエリパラメータを含む | 解析の必要性 |
---|---|---|---|---|
フォームデータの解析方法 | r.ParseForm() | r.ParseMultipartForm(maxMemory) | - | - |
取得方法 | r.Form | - | ○ | ○ |
func hello(w http.ResponseWriter, r *http.Request){
r.ParseForm()
fmt.Fprintln(w, r.Form)
}
解析後、Form
にアクセスするとフォームデータとクエリパラメータの両方を取得できている。
%curl -X POST -d "name=formData" "http://127.0.0.1:8080/test?name=queryParam"
map[name:[formData queryParam]]
ちなみにFormは解析が必要だが、しない場合はエラーではなく空のマップが返却された。
%curl "http://127.0.0.1:8080/test?name=example"
map[]
r.PostForm & MultipartForm
期待:解析後、フォームデータを取得できること
形式 | application/x-www-form-urlencoded | multipart/form-data | クエリパラメータを含む | 解析の必要性 |
---|---|---|---|---|
フォームデータの解析方法 | r.ParseForm() | r.ParseMultipartForm(maxMemory) | - | - |
取得方法 | r.PostForm | r.MultipartForm | × | ○ |
まずPostFormを使って、
func hello(w http.ResponseWriter, r *http.Request){
r.ParseForm()
fmt.Fprintln(w, r.PostForm)
}
リクエストを投げるとフォームデータのみ取得できている。
curl -X POST -d "name=formData" "http://127.0.0.1:8080/test?name=queryParam"
map[name:[formData]]
次にMultipartForm
を使うと、
func hello(w http.ResponseWriter, r *http.Request){
r.ParseMultipartForm(32 << 20) // 32MBのメモリを使用
fmt.Fprintln(w, r.MultipartForm)
}
以下の通りとなった。
%curl -X POST -F "name=formData" "http://127.0.0.1:8080/test?name=queryParam"
&{map[name:[formData]] map[]}
r.FormValue
期待:解析なしで、フォームデータとクエリパラメータを取得できること
形式 | application/x-www-form-urlencoded | multipart/form-data | クエリパラメータを含む | 解析の必要性 |
---|---|---|---|---|
フォームデータの解析方法 | r.ParseForm() | r.ParseMultipartForm(maxMemory) | - | - |
取得方法 | r.FormValue | - | ○ | × |
続いて、r.FormValue
を使用します。ただし、これはキーを指定する必要があります。
func hello(w http.ResponseWriter, r *http.Request){
fmt.Fprintln(w, r.FormValue("name"))
}
フォームデータのみを取得することができました。
これは期待していたことと違います。ただし、同じキーに複数の値がある場合には最初の値を返します。
フォームデータとクエリパラメータの場合はフォームデータが最初の値であるため、こちらが返却される形となったようです。
%curl -X POST -d "name=formData" "http://127.0.0.1:8080/test?name=queryParam"
formData
試しに、データを消してリクエストするとクエリパラメータを取得できました。
%curl -X POST "http://127.0.0.1:8080/test?name=queryParam"
queryParam
以上よりFormValue
ではフォームデータとクエリパラメータのどちらも取得できるが、同時に取得することはできないようです。
もし、複数取りたい場合はForm
を使用するか、r.URL.Query().Get("name")
のように別で取得するようにする必要があります。
r.PostFormValue
期待:解析なしで、フォームデータを取得できること
形式 | application/x-www-form-urlencoded | multipart/form-data | クエリパラメータを含む | 解析の必要性 |
---|---|---|---|---|
フォームデータの解析方法 | r.ParseForm() | r.ParseMultipartForm(maxMemory) | - | - |
取得方法 | r.PostFormValue | - | × | × |
最後にPostFormValue
を使うと、
func hello(w http.ResponseWriter, r *http.Request){
fmt.Fprintln(w, r.PostFormValue("name"))
}
以下のようにリクエストするとフォームデータを取得できました。ちなみにクエリパラメータを取得できないことも確認できました。
%curl -X POST -d "name=formData" "http://127.0.0.1:8080/test?name=queryParam"
formData
これらをざっくりまとめると、こんな感じ
multipart/form-data: MultipartFormもしくは後述のFormFile
application/x-www-form-urlencoded: 取得したいデータに応じて使い分ける
ではファイルアップロードしてみる
ここまでリクエストからデータを取得する方法についてみてきました。
その中でmultipart/form-data
が出てきました。
これはファイルのアップロードをする場合によく使われます。それをGoで実装してみたいと思います。
以下のようにenctype="multipart/form-data"
でtype="file"
を用意しました。
これをサーバーに送ってGoで受信してみます。
<form action="/submit" method="post" enctype="multipart/form-data">
<input type="file" name="uploaded">
<input type="submit" value="Upload">
</form>
コードはこんな感じになります。
package main
import (
"fmt"
"io"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request){
r.ParseMultipartForm(1024)
fileHeader := r.MultipartForm.File["uploaded"][0]
file, err := fileHeader.Open()
if err == nil {
data, err := io.ReadAll(file)
if err == nil {
fmt.Fprint(w, string(data))
}
}
}
func main(){
server := http.Server{
Addr: "127.0.0.1:8080",
}
http.HandleFunc("/process", handler)
server.ListenAndServe()
}
MultiPartフィールドのFileフィールドからを取得します。
Fileフィールド
は*FileHeaderのマップを返すようです。
type Form struct {
Value map[string][]string
File map[string][]*FileHeader
}
で、そのFileHeader構造体が以下の通りです。
type FileHeader struct {
Filename string
Header textproto.MIMEHeader
Size int64
content []byte
tmpfile string
tmpoff int64
tmpshared bool
}
イメージしづらいので具体的にForm構造体にはこんな値が入るんじゃないかなと思います。
form := &Form{
Value: map[string][]string{
"username": {"john_doe"},
"email": {"john@example.com"},
},
File: map[string][]*FileHeader{
"uploaded": {
{
Filename: "example.txt",
Header: textproto.MIMEHeader{
"Content-Disposition": {"form-data; name=\"uploaded\"; filename=\"example.txt\""},
"Content-Type": {"text/plain"},
},
Size: 1234,
content: []byte("This is the content of the file."),
},
},
},
}
したがって、File["uploaded"][0]
でファイルの情報を取得できるようです。(複数ある場合はfor rangeで取得)
そして、この情報をfileHeader.Open()
メソッドを使ってその内容を読み取ることができます。
この関数は、ファイルの内容がメモリ内にあるかディスク上にあるかに応じて適切な方法でファイルを開き、ファイルを返却します。
最後に、そのファイルをio.ReadAll
で読み込み問題なければw
に文字列で書き込みます。
func (fh *FileHeader) Open() (File, error) {
if b := fh.content; b != nil {
r := io.NewSectionReader(bytes.NewReader(b), 0, int64(len(b)))
return sectionReadCloser{r, nil}, nil
}
if fh.tmpshared {
f, err := os.Open(fh.tmpfile)
if err != nil {
return nil, err
}
r := io.NewSectionReader(f, fh.tmpoff, fh.Size)
return sectionReadCloser{r, f}, nil
}
return os.Open(fh.tmpfile)
}
しかし、これだと長い
実は上のような書き方とは別にFormFile
を使用するとより簡潔にアップロードファイルを取得することができます。
こちらだと解析はしなくて良い上にfileHeader.Open()
の手間も減るのでこちらを使う方が良さそうです。
ただ、複数ファイルには対応できないので、その場合は先述のr.MultipartForm
を使った書き方になりそうです。
func handler(w http.ResponseWriter, r *http.Request){
file, _, err := r.FormFile("uploaded")
if err == nil {
data, err := io.ReadAll(file)
if err == nil {
fmt.Fprintln(w, string(data))
}
}
}