GoでWebサーバーを一から作る
ここまでで参考にした記事
TCP
TCPは、データの 送り方 を決める約束事の一種で、特に 「漏れなく順序良く送る」 ための約束事です
TCP は双方向に通信を行うことを前提としたプロトコルですので、クライアント側のプログラムにもポート番号を割り当てる必要があります。
(でないと、サーバーは正確にクライアントプログラムに宛ててデータを送ることができません。)
当然、メールを送るサービスとWebサービスでは違うフォーマットで相手にメッセージを伝えることになりますし、それはプロトコルが変わってくるということを意味しています。
しかし、HTTPもSMTPもPOPも、全てメッセージのフォーマットに関する約束事であり、送る側も受け取る側も、メッセージは「漏れなく順序よく」届くことは大前提として作られています。
つまり、HTTPもSMTPもPOPも、 TCP通信を行うことを前提としたプロトコル ということになります。
なぜApacheを使うのか
Webサーバーというと、Nginx, Unicorn, IIS, Tomcatだったり、、各言語ごとの標準ライブラリとして組み込まれているもの(Node.jsのhttpモジュールや、Pythonのhttpモジュール、Goのhttpパッケージなど)もある。
今回実験対象としてApacheを選んだのは、2020年11月現在も世界的シェアトップ[1] でありながら標準機能がシンプルで、かつMacに標準で搭載されているため教材としては向いていたためです。
Webサーバーを作るまでにやること
- まずはChromeとApacheで通信してみる
- ブラウザがWebサーバーにChromeがどんなリクエストを送っているかを確認する
- 仮のWebサーバーを作って、Chromeがリクエストで送ってきたバイト列を全部ファイルに書き込む
- Webサーバー(apache)がどのようなレスポンスを返すのかを確認する
- 仮のHTTPクライアントを作って、さっきChromeがリクエストで送ってきたファイルの内容をバイト列としてApacheに送ってみる
- 自作Webサーバーのレスポンスをちゃんとしたレスポンスにしてみる
- 前のステップでApacheがどのようなレスポンスを返すかが確認できるので、それを模倣すれば良い。
ここまでで参考にした記事
コンテキストマネージャーによる socket
python にはコンテキストマネージャー(with 句)というものが用意されており、file の close や、socket の close のような忘れてはいけない処理を自動的にやってくれる仕組みがあります。
socket ライブラリもそれに対応しており、以下の記法を使うと with 句を抜ける際に自動的に close を実行してくれます。
IETF
IETFがインターネット技術に関するルールを定めていて、RFCというドキュメントとして残している。RFCはインターネットにおける法律のようなもの。
RFCにHTTPのルールが書かれている
リクエストラインのリクエストターゲットについて
リクエストライン
<method> <request-target> <HTTP-version>
最近のWebアプリケーションのように動的にレスポンスを生成するようなサービスにおいてはドキュメントの「場所を示す」と言ってもいまいちピンとは来ないかもしれませんが、HTTPが開発された当初はディスク内に置かれた特定のファイルを返すだけのような用途で使われることを想定されていたため、ファイルパスのような感覚で今もpathと呼ばれています。
Webアプリケーションにおいては、ファイルパスとして扱うというよりはどんな情報を求めているかを示す単なる文字列として扱うことが多いでしょう。
例)/user/profileというパスは、userディレクトリのprofileというファイルを意味するのではなく、「ユーザーのプロフィールが欲しい」ことを意味する文字列として扱う
リクエストラインのHTTPバージョンについて
バージョンは、通信の際にクライアントがHTTPのルールのうちどのバージョンのルールに従って通信をしようとしているかを示します。
単にHTTPといっても、時間とともに改善が加えられ、使えるヘッダーが増えたり減ったり少しずつルールは変化しています。
サーバー側はこのバージョンを見ることで、
「ごめん、うちはHTTP/2のルールには対応してないから、HTTP1.1のルールで話してくれない?」
と返事を返すことができたりします。
CRLF
LFはLine Feedの略。つまり、行を供給するという意味だとわかる。
(カーソルを下に落とすと考えられる)
CRはカーソルを行頭に戻すって意味。
##ステータスライン
ステータスライン
<Http-Version> <Status-Code> <Reason-Phrase>
ステータスコード
ステータスコードとは、レスポンスの概要(=HTTPリクエストがどのようにサーバーによって処理されたのか)を簡潔に示す3桁のコードです。
リーズンフレーズ
レスポンスの概要について、ステータスコードに追加で人間に理解しやすい完結な文として付け加えられるものです。
こちらはステータスコードと違い、文章がきっちりと定義されているわけではありません。
RFC内で推奨の文章というものはありますが、HTTPのルール内で自由に付与して良いです。
レスポンスボディ
レスポンスボディは様々なデータを返すことができ、今回はHTMLですが、JavascriptやCSS、画像のバイナリデータを返すこともできます。
(ただし、Content-Typeというヘッダーでデータ形式を指定する必要があり、本書でも後ほど扱います。)
↑ ソケットに書き込むときは、どうせバイト列に変換しないといけないんだけどね。
WebサーバーはHTTPのルールに従って通信するサーバー
「Webサーバー」なるものの正体は、ただの「HTTPを喋るデーモンプログラム[1]」だったのです。
- バッグクラウンドでずっと動き続けているプログラムのこと
HTTPヘッダーのうち必須とされているものは、リクエストにおけるHOSTヘッダーのみであり、レスポンスには必須ヘッダーはない。
あと、Content-Lengthは返すべきだそう。
それと、HTTP/1.1では通信はデフォルトでコネクションの再利用をすることになっており、 コネクションの再利用に対応していないサーバーはConnection: Closeを返却する必要があるそう。
あと、Content-TypeへッダーとDateヘッダーもあったほうが良いな。
あと、Serverヘッダーではレスポンスを生成したプログラムに関する情報を返す
HTTPメッセージの定義を見ればわかる。
HTTP-message = start-line
*( header-field CRLF )
CRLF
[ message-body ]
*は0個以上って意味。つまり、ヘッダーは0個以上なので、必須ではない。
(ヘッダーはHTTPメッセージのメタデータ)
しかし、リクエストメッセージではHOSTヘッダーをつけないとエラーが出た(気がする)。
Last-Modifiedヘッダは、更新後の同じコンテンツが常に返せる場合、最終更新日時を指定して返すべきだそう。
返したいHTTPレスポンス
HTTP/1.1 200 OK
Date: Fri, 01 Sep 2023 02:58:15 GMT
Server: Apache/2.4.54 (Unix)
# Range Requestsは、大きなサイズのファイルをダウンロードする際に分割ダウンロードなどができるようにするための機能です。
# Accept-Ranges: bytes
# レスポンスボディのバイトサイズ
Content-Length: 45
# いつまでコネクションを使って良いかの情報を返す
# Keep-Alive: timeout=5, max=100
# Connectionヘッダーは、一度確立したTCPコネクションを次のリクエストで再利用して良いかどうかを返します。
# 使って良いなら、Keep-Alive。ダメならCloseを指定する。
Connection: Close
Content-Type: text/html
<html><body><h1>It works!</h1></body></html>
```
コンテントネゴシエーション
キープアライブ
ここまでで参考にした記事
自作Webサーバー ver0.2
package web
import (
"fmt"
"log"
"net"
"os"
"time"
"github.com/leekchan/timeutil"
)
type Server struct{}
// 自作Webサーバー ver0.2
func (server Server) Serve() error {
log.Print("=== サーバーを起動します ===")
ln, err := net.Listen("tcp", ":8080")
if err != nil {
fmt.Printf("failed to listen: %s\n", err)
return err
}
log.Print("=== クライアントからの接続を待ちます ===")
// Acceptはブロッキング処理
// clientとの接続が確立されたコネクションインスタンスが返される
clientConn, err := ln.Accept()
if err != nil {
fmt.Printf("failed to accept: %s\n", err)
return err
}
// TCP通信を終了するときはcloseをする」という習慣をつけておく
defer clientConn.Close()
log.Printf("=== クライアントとの接続が完了しました。 remote_address: %s\n", clientConn.RemoteAddr().String())
log.Printf("=== クライアントとの接続が完了しました。 local_address: %s\n", clientConn.LocalAddr().String())
buf := make([]byte, 1024)
// Readもおそらくブロッキング処理
requestBytesSize, err := clientConn.Read(buf)
if err != nil {
fmt.Printf("failed to read: %s\n", err.Error())
return err
}
// 0から始まる数字は8進数
// umaskがデフォルトで022だから、ファイルのパーミッションは0644(rw--r--r--)になる
// umaskはumaskコマンドで確認できる
err = os.WriteFile("http_messages/server_recv_from_chrome.txt", buf[:requestBytesSize], 0666)
if err != nil {
fmt.Printf("failed to write: %s\n", err.Error())
return err
}
statusLine := "HTTP/1.1 200 OK\r\n"
responseBody := "<html><body><h1>It Go Go works!</h1></body></html>\r\n"
t := time.Now()
responseHeader := fmt.Sprintf("Date: %v\r\n", timeutil.Strftime(&t, "%a, %d %b %Y %H:%M:%S"))
responseHeader += "Server: HenaGoServer/0.1\r\n"
responseHeader += fmt.Sprintf("Content-Length: %v\r\n", len([]byte(responseBody)))
responseHeader += "Connection: Close\r\n"
responseHeader += "Content-Type: text/html\r\n"
responseMessage := statusLine + responseHeader + "\r\n" + responseBody
// Writeもおそらくブロッキング処理
_, err = clientConn.Write([]byte(responseMessage))
if err != nil {
fmt.Printf("failed to write: %s\n", err.Error())
return err
}
return nil
}
Webサーバーに静的ファイル(htmlファイル)を配信機能を追加する
package web
import (
"fmt"
"log"
"net"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/leekchan/timeutil"
)
type Server struct{}
// 自作Webサーバー ver0.3
func (server Server) Serve() error {
log.Print("=== サーバーを起動します ===")
ln, err := net.Listen("tcp", ":8080")
if err != nil {
fmt.Printf("failed to listen: %s\n", err)
return err
}
log.Print("=== クライアントからの接続を待ちます ===")
// Acceptはブロッキング処理
// clientとの接続が確立されたコネクションインスタンスが返される
clientConn, err := ln.Accept()
if err != nil {
fmt.Printf("failed to accept: %s\n", err)
return err
}
// TCP通信を終了するときはcloseをする」という習慣をつけておく
defer clientConn.Close()
log.Printf("=== クライアントとの接続が完了しました。 remote_address: %s\n", clientConn.RemoteAddr().String())
log.Printf("=== クライアントとの接続が完了しました。 local_address: %s\n", clientConn.LocalAddr().String())
buf := make([]byte, 1024)
// Readもおそらくブロッキング処理
requestBytesSize, err := clientConn.Read(buf)
if err != nil {
fmt.Printf("failed to read: %s\n", err.Error())
return err
}
// リクエストを解析する
// ターゲットを取得したい
requestMessage := string(buf[:requestBytesSize])
// nは返す部分文字列の数を決める
requestLine := strings.SplitN(requestMessage, "\r\n", 2)[0]
targetPath := strings.SplitN(requestLine, " ", 3)[1]
targetFile := strings.TrimPrefix(targetPath, "/")
// カレントディレクトリの取得
currentDir, err := os.Getwd()
if err != nil {
fmt.Println("カレントディレクトリを取得できませんでした:", err)
return err
}
// 絶対パスの取得
BASE_DIR, err := filepath.Abs(currentDir)
if err != nil {
fmt.Println("絶対パスを取得できませんでした:", err)
return err
}
STATIC_ROOT := path.Join(BASE_DIR, "src", "static")
// レスポンスの生成
statusLine := "HTTP/1.1 200 OK\r\n"
responseBodyBytes, err := os.ReadFile(path.Join(STATIC_ROOT, targetFile))
if err != nil {
fmt.Println("fail to read file:", err)
statusLine = "HTTP/1.1 404 Not Found"
responseBodyBytes, _ = os.ReadFile(path.Join(STATIC_ROOT, "404.html"))
}
t := time.Now()
responseHeader := fmt.Sprintf("Date: %v\r\n", timeutil.Strftime(&t, "%a, %d %b %Y %H:%M:%S"))
responseHeader += "Server: HenaGoServer/0.1\r\n"
responseHeader += fmt.Sprintf("Content-Length: %v\r\n", len(responseBodyBytes))
responseHeader += "Connection: Close\r\n"
responseHeader += "Content-Type: text/html\r\n"
responseMessage := (statusLine + responseHeader + "\r\n") + string(responseBodyBytes)
// Writeもおそらくブロッキング処理
_, err = clientConn.Write([]byte(responseMessage))
if err != nil {
fmt.Printf("failed to write: %s\n", err.Error())
return err
}
return nil
}
本来なら実行ファイルパスを取れると良い。go runだとめんどくさかったので、カレンドディレクトリのやり方にした。go buildならできたかも。
静的ファイル配信機能ってめっちゃシンプルやな
要は、
- リクエストされてきたHTTPメッセージのバイナリをstringにキャストして文字列を取得する
- リクエストラインのパスを取得して、そのパスからファイル名を取得する
- ファイル名から、そのファイルを指し示す絶対パスを作成する
- 絶対パスが指し示すファイルをos.OpenFileでバイト列として開く(これがレスポンスボディになる)
- レスポンスヘッダーを作成する。ファイルの拡張子から、Content-Typeヘッダーに適切なMIMEタイプを指定する
- ステータスラインを作成する。
- ステータスライン、レスポンスヘッダー、レスポンスボディを足して、HTTPレスポンスメッセージを作成する
- HTTPレスポンスメッセージをバイト列にして、クライアントコネクションに書き込めば終了
後でわかったけど、HTTPリクエストメッセージのボディが画像の場合、文字列変換に失敗するので、手順1はバイナリのままsplitした方が良い。
404
HTTPのルールでは、クライアントに「リソースが存在しなかった」ことを明示的に伝えたい場合はステータスコード404のHTTPレスポンスを返却することになっています。
ブラウザの再リクエストの性質
ブラウザはWebサーバーからレスポンスを受け取った際、レスポンスボディのHTML内に外部ファイル参照(<img src="">、<script src="">、<link href="">など)が記載されていると、再度リクエストを送信しなおしてファイル内容を取得しようとします。
しかし、私たちのWebサーバーは最初のリクエストを処理したあと、すぐにプログラムを終了させてしまうため、追加のリクエスト(今回でいうとCSSと画像のリクエスト)を処理できていないのです。
cssもjsもhtmlもpngファイルも、それらは静的ファイルだから、Webサーバーは特定のディレクトリからレスポンスとして返していることが分かった。
ここまでで参考にした記事
サーバーを再起動するタイミング
サーバーのソースコードはプログラム実行時に一度だけ読み込まれ、staticのファイルはリクエストが来た時に毎回読み込まれるという違いを理解しておくことは重要です。
いつメソッド・関数を作るか
クライアントからのリクエストを処理するときに、毎回手続き書くのだるいから関数にした。
あと手続きがばーっと書いてあるより抽象化されているほうがわかりやすい。どう抽象化するのかが大事。手続き的に書くことで、見にいく必要がないっていうメリットもあるから悩ましい。
- 意味のあるまとまり単位(手続きでばーっと書かれてたら関連しているかどうかわかりにくいやつ)。目的単位
- 再利用可能な単位。
- これ抽象化してもどうせ見に行くな、手続き的に書いてあるほうがわかりやすいってやつは手続き的に書いたほうが良いかも(おそらくへんな単位で抽象化しているかも。抽象化失敗しているパターン)。
これらの3つの基準で関数やメソッドを作る
動的とか静的とか言われたら、何に対して何が動的なのか、何に対して何が静的なのかを意識する
ここまでで参考にした記事
goには正規表現リテラルがないから辛い。
<pre>タグで囲まれたテキストは、通常のHTMLテキストと異なり、改行や空白などの空白文字をそのまま表示し、フォントも等幅フォントとして扱います。このため、コードの表示やテキストの整形に非常に便利です。
Goで複数行にまたがる文字列をかきたいならバッククオートで文字列を作成すれば良い
Content-Typeヘッダーには文字列のエンコーディングを指定することができ、ブラウザで日本語を表示させるためには日本語に対応したエンコーディングの指定が必要になります。
UTF-8を指定しておけばとりあえずOK
文字列のエンコーディングとか、ユニコードとか、チャーセットとかいずれふかぼった方が良いかもね。
application/x-www-form-urlencoded
- ブラウザが<form>タグでenctype属性を指定しなかった場合に使われるデフォルトのContent-Typeである。
- このContent-Typeが指定されている場合、リクエストボディは、
[HTML要素のname属性の値]=[フォームに入力された値]
というペアで、別々のフォームの値同士は&で連結されているようなフォーマットになる。 - 別名「URLエンコーディング」や「パーセントエンコーディング」とも呼ばれ、URLとして利用可能な文字のみを使って様々なデータを表せるようにフォーマットが決められている。
- UTF-8で符号化できないバイナリデータは扱えない(ファイルアップロード時、ファイルの中身は送信しない。ファイル名だけ送信されている)
multipart/form-data
- こちらは、<form enctype="multipart/form-data">のように、enctype属性で明示的に指定することで利用できる。
- Content-Typeにセパレータが指定してある。(---------------------------10847194838586372301567045317がセパレータ。セパレータはリクエストを送る側が自由に決めて良いですが、フォームデータ本文の中に絶対に出てこない文字列にする必要がある。今回のようにブラウザがPOSTリクエストを自動生成する場合は、ブラウザが毎回ランダムに生成してくれる)
- multipart/form-dataフォーマットでは、項目ごとに「この項目はテキストデータ、この項目はPDF、この項目はpng画像」と指定できる。そのため、バイナリデータとテキストデータを混在して送信することができるようになっています。
なお、同じ項目名で複数のデータを送りたい場合は、application/x-www-form-urlencodedのときと同じく別々の項目として送信している。
application/json
- HTMLとブラウザの組み合わせでは、このフォーマットでデータを送ることはできないが、JavaScriptのAjaxという機能を使うことで、利用できる。
- こちらのフォーマットでは、レスポンスボディをJSONのフォーマットで表現する。
- JSONを使うことで、配列を表現できたり、数値と文字列を区別できたりする。データをサーバーサイドで扱いやすい。
しかし、JSONフォーマット自体はテキストデータなので、バイナリデータは送信できない。
てことは、バイナリデータを送るには、URLエンコードをして文字列にしてからwww-form-urlencoded or application/jsonのフォーマットに含めるか、もしくは、multipart/form-dataにバイナリデータとして含めるかだな。画像のアップロードはめんどくさそう。
multipart/form-dataをいつか解析したい
ブラウザはURLバーに直接URLを入力したり<a>タグのリンクをクリックして移動したりして(フォームの送信ではない)通常のページ遷移を行った場合はGETリクエストを送信 します。
urlエンコーディッドって書いとるな。
views関数を切り出したのは良いのですが、今のままでは関数ごとに引数の数が違い、
「このpathを処理する仮数はコレとコレの引数が必要で、こっちのpathを処理する関数はアレとアレとアレの引数が必要で・・・」
といった具合に呼び出す側が、呼び出される側の詳細を知っていなくてはいけなくなっています。
プログラミングの世界では、片方のモジュールが、相手のモジュールの詳細をできるだけ知らなくて良いように作ると、ソースコードはシンプルになることが知られています。
呼び出す側は呼び出される側の詳細を知らなくても良い状態に常にする。相手のモジュールの詳細をできるだけ知らなくても良いように作って呼び出せるようにすると、コードがシンプルになるし変更に強くなる
関数を呼び出す際に呼び出し側が関数の詳細を知らないといけない理由の一つとして、関数ごとに引数として何がいくつ必要かわからないと関数が呼び出せないことが考えられる。
if文に応じた関数を実行する処理
if targetPath == "/now" {
response = controller.Now(request)
} else if targetPath == "/show_request" {
response = controller.ShowRequest(request)
} else if targetPath == "/parameters" {
response = controller.Parameters(request)
こんな条件分岐しないといけない理由は、関数のinputとoutputのインターフェースが統一されていないから。inputとoutputのインターフェースが統一されていれば、関数がどんな名前の関数なのか、知らなくても呼び出せる。インターフェースが統一されていれば、呼び出し側は何の引数が必要でどんな戻り値が返ってくるかを関数ごとにいちいち考えなくて済む。
ただ、インターフェースを合わせるだけってわけではなくて、if文の条件文をキー、マッチした関数をvalueにするようなmap型を事前に定義する必要がある。呼び出し方や戻り値を統一していれば、その関数がどんな名前なのかを意識しなくても呼び出せる。
変更後
package web
import (
"github.com/yukiHaga/web_server/src/internal/app/controller"
)
var UrlController = map[string]controller.Controller{
"/now": controller.Now,
"/show_request": controller.ShowRequest,
"/parameters": controller.ShowRequest,
}
if _, isThere := UrlController[targetPath]; isThere {
controller := UrlController[targetPath]
response = controller(request)
} else {
抽象化
注目すべきなのは、 全てのview関数が同じ引数(method, path, http_version, request_header, request_body)を受け取るようになったことで、view関数が抽象化されている点です。
以前までは関数ごとに引数が違ったので、ひとくちに「view関数を呼び出す」と言っても「その関数が具体的になんという関数なのか」が分からないと正しく呼び出せませんでした。
しかし、引数が統一(= インターフェースが統一)されることで、 「具体的に何ていう関数なのかは知らないけど、とにかく呼び出せる」 ようになっているのです。
このように、「それが具体的なモノの中から、共通な性質の一部だけを抜き出すことで、具体的なモノを扱わなくてすむようにする」ことを 抽象化する と呼び、プログラミングにおいては非常に重要なテクニックとなります。
今回でいうと、now() show_rewuest() parametersといった具体的な関数から、インターフェースを統一することで
「method, path, http_version, request_header, request_bodyという5つの引数を受け取り、response_body, content_type, response_lineという3つの値を返す」
という性質だけを抜き出す(=抽象化する)ことで、呼び出す側は
「具体的に何関数かしらないけど、5つの引数を与えて呼び出す」
というように扱えるようにしたということです。
あるいは、「抽象化するためにインターフェースを統一した」とも言えるでしょう。
プログラミングにおける抽象化は、具体的なものを持つものたちから、共通な性質を抜き出すことなのか。 そうすることで、具体的なものを扱わなくて済むってことか。なるほどなあ。
具体的に何関数か知らんけど、同じ数かつ同じ型の引数を渡して、同じ数かつ同じ型のレスポンスが帰ってくるなら、具体的な関数を知らなくてもその関数を他の関数と同じように扱うことができる。
「エンドポイントを増やす時に共通機能部分を一切変更しなくてよいし、意識すらしなくてよい」
という状態になっているとかなり拡張性が高い(安全かつ簡単に拡張できる)と言えるでしょう。
個人的に0 -> 1やるならPythonかNode * TSが良い気がしてきた
Goは関数のデフォルト引数がないし、共用体がないから型の表現力もないし、やっぱ言語としてはTSとかPythonのが強いな。Goはしっかりかきたかったり、成功しているプロダクトを長期的に運用させるために書き換えるのに使える。初期開発には向いていない気がする。ゴニョゴニョ書くし。
Rails得意ならRailsでも良いかも。Railsは抽象化しすぎていて個人的には苦手。コントロールしている感がないので、楽しくない。
ここまでで参考にした記事
静的ファイルを配置するディレクトリはプロジェクトごとに変化することは十分考えられる
goのimport cycledめっちゃめんどいな。なんやこれ。インターフェースを定義するってことはどこかで依存性を注入しないといけないし。それがめんどい。
ワーカースレッドをワーカーというのか。なるほど。
ターゲットパスから特定のコントローラを呼び出すにはどうすれば良いか。
map型だとurlパラメータに対応できない。
ターゲットパスとルーティングに書いたパスの比較処理が欲しい。
Goは正規表現の後読みサポートされていないんか。まじか、、
URL解決のための処理が外部に切り出せたおかげで、WorkerクラスはURL解決の方法について何も知らなくて良くなり、また一つ責務が減りました。
やっぱインターフェースを統一して、プラスα(今の所mapを作るか、戻り値を変えるか)をすれば抽象化に成功する
// パス解決処理の戻り値でコントローラを返していたけど、解決しなかったら静的ファイルを返すコントローラを返すようにしたら
// 抽象化が成功してすごくスッキリした
controller := resolve.NewURLResolver().Resolve(request)
response := controller.Action(request)
// パスに応じてレスポンスボディが変化するので、事前に変数として定義した
// response := &http.Response{}
// if controller, isThere := resolve.NewURLResolver().Resolve(request); isThere {
// response = controller.Action(request)
// } else {
// response = view.GetStaticFile(request)
// }
ここまでで参考にした記事
goにはpythonのformatメソッド的なのもないのか。文字列にキーワード引数で値を入れるとかができないのがなんかなー。結局正規表現でゴリ押しした。
goにはデフォルト引数がないのがなー。辛いな
Cookie
Cookieはサーバーからブラウザに送られ、ブラウザ内で保存される小さな文字列データ
Set-Cookieヘッダを使ってクライアント側にクッキーを送信する。その際に、クッキーのメタデータを指定したりできる。
サーバーから複数のクッキーを送りたい場合、その数だけSet-Cookieヘッダーが必要
しかし、クライアントからサーバーに対してクッキーを送る場合、複数のクッキーがあっても一つのCookieヘッダしか使ってはダメ。Cookieヘッダに複数のクッキーを指定する場合、セミコロンで区切る
一度サーバーからブラウザに対して送信されブラウザ内に保存されたCookieは、ブラウザが次回以降同じサーバーにリクエストを送る際にHTTPヘッダーにそのまま付与して送信されます。
Expires属性
クライアントが自動的に期限の過ぎたクッキーを削除してくれる。なので、サーバーに送られる心配はない。
Domain属性
このDomainにしかクライアントはクッキーを送らなくなる。Domain属性が省略された場合、UAは生成元サーバーにしかクッキーを返さなくなる。
path属性
UAは、指定したpathが含まれているURLにしかクッキーを届けなくなる。Path属性を省略した場合、現在アクセスしているパスが指定されたものとされる
Secure属性
Secure属性を指定していると、HTTPS通信の時にしかクッキーが送信されなくなる。
HttpOnly属性
httpOnlyはhttp通信でしかクッキーが使えないことを意味する
Same-Site属性
Same-Site属性は、リクエスト元とリクエスト先が異なるドメインの場合、クッキーを送信するかしないかを制限できる属性です。Sam-Site属性があることで、CSRFを防ぐことができるそうです。
None: 過去にサイトBから付与されたCookieは、無条件にサイトBへ送信されます
Lax: 過去にサイトBから付与されたCookieは、(大雑把にいえば)GETリクエストのみ送信しますが、POSTやPUTなどのリクエストではCookieは送信しません。
Strict: 過去にサイトBから付与されたCookieは、サイトBへは送信されません
ここで重要なのは、ここまで見てきたようなサーバー側からのCookieの使用制限とは別に、そもそもブラウザは独自の判断で勝手にCookieを無視してよい、ということです。
「無視しないならこう解釈してね」というのが定められているにすぎないのです。
そのため、例えばブラウザを利用するユーザーがCookieを拒否するサイトを指定できるような設定が備わっていたり、あるいは手動でブラウザ内に保存されたCookieを削除できる機能が備わっていたりします。
Cookieそのものは悪用しなければユーザー体験を向上させる非常に便利な機能ですのでブラウザも危険のない範囲でできるだけ送り返す努力はしてくれますが、いつでも付与したCookieが送り返されてくるとは限らないということは常に心に留めておきましょう。
ビジュアルモードにしてuを押せば、大文字を小文字に変換できる
ビジュアルモードにしてshift + uを押せば、小文字を大文字に変換できる
Cookieを使えるようになると、「以前どのような行動をしたユーザーであるか」というのをサーバー側でトラッキングできるようになります。
Cookieを使う代表的な機能といえば、ログイン(認証)機能です。
というわけで、超簡単なログイン機能を実装してみましょう。
ここで実装するログイン機能は本当に簡素で、パスワード認証もありません。
過去にログインページで名前を入力してフォームを送信したことがあればログイン済みとみなします。
パーセントエンコーディングされたボディをデコードする
302の良いところはクライアントのURLを強制的に切り替えられるところ。
フォームでPOSTリクエストしても、レスポンスボディはちゃんと画面に表示される。なので、GETリクエストしか画面に表示されないというわけではない。
ログイン成功した時になぜリダイレクトをするのかなんとなく分かった気がする。
ログインの場合、formをクリックすると、post /loginでリクエストが出される。
てことは、普通にマイページのレスポンスを返した場合、/loginのパスが表示されつつ画面が表示される。
これは分かりづらい。ログインフォームのurlなのになぜマイページが出ているんだって話になる。なのでログイン成功したら、サーバー側はリダイレクトのレスポンスを返す。クライアントは再リクエストを出す。
JavaScriptから簡単にクッキーを操作できる
これは外部サイトで勝手にいつの間にスクリプト実行されてて、クッキーいじられたら終わりやな。
const cookie = document.cookie
=> undefined
console.log(cookie)
=> VM535:1 password=hoho; email=haga@gmail.co.jp
クッキーのメタデータとしてhttpOnlyは必須でつけた方が良いかも
とはいえhttpで使えるとなると、CSRFもできちゃうからSame-Site属性も必須でつけた方が良いかも
あと、https通信が前提ならsecure属性もあった方が良い。
やっぱセッション導入するなら、セッションストレージ的なのは導入した方が良いな。危険だわ。
クッキーにemailとかパスワードとか入れん方が良いな。まじで危険。session_idくらいにしておいた方が良いよ。このsession_idも推測されない文字列にした方が良い。
httpOnlyつけたらちゃんとJavaScriptで実行できないことを確認できた
const cookies = document.cookie
undefined
console.log(cookies)
画面には出ていないけど確かundefinedになった
セキュリティをGoで実感したい
クロスサイトスクリプティング(XSS)
クロスサイトリクエストフォージェリ(CSRF)
セッションハイジャック
セッションタイムアウト
ログイン認証
- セッションベース
- redisベース
- 認証の機構
ファイルアップロード
メール送信
MySQLのデッドロック
機能の利用者からすると、Cookieがdictで表現されているのか、オブジェクトなのか、それらは配列で格納されているのかなどといった実装の詳細には興味はありません。
確かに、クッキーが何型で表現されてようがどうでも良いもんな。その型を知らないと操作できないってことは、実装の詳細を読み解く必要があるから辛い。だからメソッドや関数にした方が良いのか。