Treasure2020 2日目
はじめに
こんにちは。トコロテンです。
空、青いですね。雲、白いです。僕のMacBook Proより白くてびっくりしました。
最近、夏の空が綺麗なので外に出たときは是非一度見上げることをおすすめします。 ただ、熱中症には十分お気をつけください。
前回の記事に続き、Treasure2020の2日目に学んだことと感想をテキストに起こします。
Let's Go!
2日目は、バックエンドの講義ということでGoで書かれた簡単なWebアプリをベースに様々なことを教えていただきました。 Goは、インターンが始まる前にA Tour of Goで予習していたところ、とても気に入りました。 そのため、講義に対するモチベーションがかなり高かったです(実際楽しかった)。
講義で学んだことは、以下の通りです。
- テスト駆動開発(TDD)
- テーブル駆動テスト(TDT)
- Health check
- REST APIにおけるエンドポイントの設計
- RESTfulのレベルの概念
- HTTPステータスコードの選び方
- サーバーサイドのエラーの詳細の公開範囲
テスト駆動開発(TDD)
以下のような手順で行う開発手法をテスト駆動開発(Test driven development)と呼ぶのだそうです。
- テストを書く
- テストをPASSするような実装を行う
- リファクタリングする
これ、初めは「要はテストを先に書くってことね!」くらいしか思ってなかったんですが、よく考えてみるとトップダウンの考え方に沿っているのかなと思いました。 上の手順は、以下のように言い換えることができます。
- こういう風に動いてほしい!(テスト)
- そう動かすためにはこうやって…(テストをPASSするような実装を行う)
- 細かいとこの修正(リファクタリングする)
開発者の「こんな感じに動くやつが欲しい!」といった抽象的なアイデアからその抽象度を下げてより具体的な実装へと落とし込んでいく流れがトップダウンになっていると感じました。
実は、これまでGoでテストコードを書いたことが無く、Treasureで初めてGoでテストコードを書きました。
以下のUnique関数を対象にして、ユニットテスト(単体テスト)を行いました。
Unique関数(unique.go)
package etc func Unique(elems []string) []string { result := make([]string, 0) exists := map[string]bool{} for _, elem := range elems { if _, ok := exists[elem]; !ok { result = append(result, elem) exists[elem] = true } } return result }
ユニーク関数は、文字列のリストを受け取って重複を取り除いたリストを返す関数です。
Unique関数のテストコード(unique_test.go)
package etc import ( "reflect" "testing" ) func TestUnique(t *testing.T) { elems := []string{"go", "js", "go"} if want, got := []string{"go", "js"}, Unique(elems); !reflect.DeepEqual(want, got) { t.Fatalf("Want %v but got %v", want, got) } }
テストケースが1つしかないですが、とりあえずテスト入門なので目を瞑ります。
Goにおけるテストコードのファイル名は、テスト対象のコードのファイル名に_test.go
を付与するという命名規則があるそうです。
Goに入ってはGoに従え
といった有名な言葉もあるのでこれに従います。
テストしてみよう!
カレントディレクトリにあるテストを走らせたい時には、以下のコマンドを実行します。
コマンドを実行すると、ファイル名の末尾が_test.go
となっているテストコードが実行されます。
go test -v .
テーブル駆動テスト(TDT)
Goでテストができた!めでたしめでたし。といきたいところですが、上のテストは改善の余地があります。 改善すべき点は、以下の通りです。
- テストケースの網羅性が低すぎる(複数個欲しい)
- テストケースが何を保証するためのケースなのか分かりづらい
- 入力と期待する出力はケースごとに対であるにも関わらず、データの在り処が分散している
これらを解決できるテスト手法として、テーブル駆動テスト(Table driven test)なるものがあるようです。 テーブル駆動テストを利用すれば、テストケース毎に入力と期待する出力を1つのデータ構造として表現し、そのリストを用意することで複数のテストケースを綺麗にまとめることができます。
上で示したテストケースを、テーブル駆動テストの方式に変更してみると以下のようになります。
package etc import ( "fmt" "reflect" "testing" ) type TestCase struct { in []string out []string desc string } func TestUnique(t *testing.T) { testcases := []TestCase{ { in: []string{"go", "go", "js"}, out: []string{"go", "js"}, desc: "First unique", }, { in: []string{"go", "js", "js"}, out: []string{"go", "js"}, desc: "Last unique", }, { in: []string{"go", "js", "go"}, out: []string{"go", "js"}, desc: "Middle unique", }, { in: []string{"", "js"}, out: []string{"", "js"}, desc: "Empty string", }, { in: []string{}, out: []string{}, desc: "Empty list", }, } for _, testcase := range testcases { t.Run(fmt.Sprintf("%v", testcase.in), func(t *testing.T) { if want, got := testcase.out, Unique(testcase.in); !reflect.DeepEqual(want, got) { t.Fatalf("Want %v but got %v, [%s]", want, got, testcase.desc) } }) } }
上記の変更によって、以下の恩恵が受けられます。
- 複数のテストケースに対応
desc
プロパティでテストケースが何を保証するのかわかる- 入力と期待する出力が1つの構造体の中にまとまっている
美しいですね。これからテストはテーブル駆動テストを使うことにします。
Health check
みなさん、健康ですか。僕は健康です。
ラーメンばっか食べてちゃだめですよ。僕は食べますが。
それはさておき、題名のHealth checkってなんでしょうか。 ここでのHealth checkとは、サーバが正常に動作しているか確認することを指します。
サーバが生きているか確認するための方法として、ping
コマンドなんかがあると思います。
サーバがネットワークに正常に接続しているか、または正常に動作しているかといったことを確認する時に使いますよね。
講義では、もうちょっとレベルの高いHealth checkについて学びました。
ping
コマンドでは、コンピュータ自身が正常に動作しているか確認することができても、特定のサービスやコンテナが動作しているかを確認することはできません(僕が無知なだけでもしかしたらできるかもしれません)。
そこで、より詳細な情報を得られるようにするために、Health check用のエンドポイントを作成します。
以下は、Web APIサーバが利用するDBが生きているか確認するためのエンドポイントです。
// Health check endpoint r.Methods(http.MethodGet).Path("/ping").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // DBの生存確認 if s.db == nil { w.WriteHeader(500) } else { err := s.db.Ping() if err != nil { w.WriteHeader(500) } else { w.WriteHeader(200) } } })
以上のエンドポイントを作成することで/ping
のエンドポイントにGETリクエストを送るとレスポンスのステータスコードからDBが正常に動作しているかどうか分かります。
一般的なGETメソッドを利用しているため、エンドポイントのURLに確認したい項目のパラメータを指定してカスタマイズすることも可能になります。
JSONで詳細データをレスポンスとして返すようにして、定期的にエンドポイントを叩いてJSONをパースすれば、解像度の高いHealth checkが行えそうです。
REST APIにおけるエンドポイントの設計
みんな大好きREST APIを設計する時に直面する問題について議論しました。
主に以下のことについて議論しました。
エンドポイントとリクエストのどちらに資源の情報を持たせるのか
以下のような部屋情報を表すRoom
といったリソースが存在します。
Roomリソースの定義
type Room struct { ID int64 `db:"id" json:"id"` Title string `db:"title" json:"title"` }
Roomリソースに対応するエンドポイント
/room
現時点では、誰でもRoom
資源にアクセスできますが、これに公開・非公開といった概念を加えたいとなったとします。
ここで、部屋が非公開かどうかといった情報をどこに持たせるかといった問題が発生します。
新規モデルを作成
1つのアイデアとしては、以下のような非公開の部屋を表現するモデルを新たに作成し、エンドポイントも専用のものを用意します。
type PrivateRoom struct { ID int64 `db:"id" json:"id"` Title string `db:"title" json:"title"` }
/private_room
既存モデルを拡張
もう1つのアイデアとしては、以下のようにRoom
モデルのアトリビュートに非公開情報を追加し、リクエスト時にこのアトリビュートに値を設定してPOSTしたり、GETパラメータで指定したりします。
type Room struct { ID int64 `db:"id" json:"id"` Title string `db:"title" json:"title"` IsPrivate bool `db:"is_private" json:"is_private"` }
例えば、非公開の部屋を取得したい時には、以下のようなエンドポイントに対してGETリクエストを送ります。
/room?is_private=1
どっちがいいの
上で述べた2つのアイデアは、一長一短です。 具体的には、以下のようなメリット・デメリットがあります。
個人的には、既存モデルを拡張する方がやりやすいかなと思います。 なぜなら、エンドポイントが増えるとどこのエンドポイントにリクエストを飛ばせばいいか分からなくなりそうだからです。
新規モデルを作成
メリット
エンドポイントを別々に分けることができるため、1つのエンドポイントの処理がスリムになる。
デメリット
モデルのアトリビュートが増えるにつれて、エンドポイントが増えていく。
既存モデルを拡張
メリット
既存のエンドポイントをそのまま利用することができる。
デメリット
モデルのアトリビュートが増えるにつれて、1つのエンドポイントの処理が複雑になる。
エンドポイントの命名規則
REST APIのエンドポイントを設計する上で命名規則は大事な要素になります。 命名規則のうち、資源の名前を単数形にするか複数形にするかといった問題があります。
ネットでREST APIについて調べてみると資源の名前は複数形にしている場合が多いように感じました。 調べたわけではありませんが、多くの場合において資源が単一で存在していることはなく、レスポンスもリストを返した方が使い勝手が良いこともあって複数形になっているのではないかと思います。
しかし、上で登場していた/room
エンドポイントは単数形でした。
一見、命名が不適切であるかのように思われますが、あえて単数形を採用するメリットがあります。
それは、資源の名前を複数形にする際に単語の変形を考えなくても良いということです。
たとえば、man
といった単語は、複数形にする場合、語尾にs
を付けるのではなくmen
になります。
こういった単語は、いくつも存在し、それぞれに対応するのは面倒くさく、間違える可能性があります。
また、多くの場合において資源が単一で存在していることが無いのだったらそもそもあえて複数形にする理由も無い気がします。 シンタックスとセマンティクスの癒着を考え、論理的には複数形にすべきだと思いますが、実用的・合理的なのは単数形の方であると思います。
RESTfulのレベルの概念
RESTfulにレベルがあるって知ってましたか。僕は知りませんでした。
厳密には、RESTful成熟度の3レベルモデル(0~3レベル)といったものが存在するようです。 それぞれのレベルは、以下のようになっています。
レベル0
レベル0は、Web APIに1つのURLと1つのHTTPメソッドしか存在しない状態です。 どのAPIが呼ばれたのかは、リクエストボディ等の送信したデータを用いて識別します。
レベル1
レベル1は、レベル0の状態からWeb APIのURLが複数個になった状態です。
レベル2
レベル2は、レベル1の状態からHTTPメソッドが分かれた状態です。 ここがいわゆるRESTfulな状態になります。
レベル3
レベル3は、レベル2の状態からWeb APIのレスポンスにハイパーリンクが含まれる様になった状態です。 例えば、商品を購入するAPIを叩くとレスポンスに返金用のエンドポイントのハイパーリンクが含まれていたりします。 これによって、Web APIの全体像を知っていなくても、エンドポイントから他のエンドポイントへとリンクしていくことが可能になります。
HTTPステータスコードの選び方
HTTPのステータスコードは、大きく分けて以下の5つがあります。
- 1xx系 情報
- 2xx系 成功
- 3xx系 リダイレクション
- 4xx系 クライアントエラー
- 5xx系 サーバエラー
よく利用するのは、2xx系と4xx系と5xx系だと思います。
以前まで不具合や不正があった場合に4xx系と5xx系のどちらを返せばいいか迷うことが多々ありました。 例えば、パスワードが間違っていた場合に4xx系と5xx系のどちらを返せばいいのか迷ったりしました。 しかし、これはステータスコードの範囲の捉え方を変えることで簡単に解決してしまいました。
- 4xx系 クライアントエラー
- 5xx系 サーバエラー
元々は、上記のようになっていましたが、これを以下のように捉え直します。
- 4xx系 クライアント側の責任
- 5xx系 サーバ側の責任
このように「責任」がどこにあるのかといったことに着目すると、パスワードが間違っていた場合はクライアント側の責任であるため、4xx系のステータスコードを返せば良いことがすぐにわかります。
サーバーサイドのエラーの詳細の公開範囲
サーバーサイドのエラー(ステータスコード5xx系)の詳細は、基本的に隠したほうが良いと思います。 講師の方もそのように言っていました。
なぜなら、詳細なエラーをレスポンスとして返してしまうとサービスのネットワーク構成やソフトウェアのバージョン等、脆弱性の発見に繋がる情報が漏れてしまう可能性があるからです。
以下のように5xx系のエラーの場合のみエラーメッセージを空文字列としてステータスコードだけレスポンスとして返すのが個人的なおすすめです。
func RespondErrorJson(w http.ResponseWriter, code int, err error) { log.Printf("code=%d, err=%s", code, err) if e, ok := err.(*HTTPError); ok { RespondJSON(w, code, e) } else if err != nil { he := HTTPError{ Message: err.Error(), } if code >= 500 { he.Message = "" } RespondJSON(w, code, he) } }
さいごに
1日ではテキストに起こしきれない量のことを学びました。 この記事に書いてあることも講義で学んだことと自分が考えたことの一部だけです。 それだけ濃密な時間でした。 本当は全部忘れないように書き残したい気持ちでいっぱいなのですがそれだとブログの記事を書くのが一生終わらなくなりそうなのでこの程度に留めました。
まだインターンに2日間しか参加していないという事実に驚きを感じます。