トコロテンの日記

自分の活動内容や普段考えていることをアウトプットするためのブログです。ITに関わる話がメインであり、開発、競技プログラミング、気に入った技術などの話が多くなります。

Treasure2020 2日目

f:id:tokoroten_lab:20200806220543j:plain

はじめに

こんにちは。トコロテンです。

空、青いですね。雲、白いです。僕のMacBook Proより白くてびっくりしました。

最近、夏の空が綺麗なので外に出たときは是非一度見上げることをおすすめします。 ただ、熱中症には十分お気をつけください。

前回の記事に続き、Treasure2020の2日目に学んだことと感想をテキストに起こします。

Let's Go!

2日目は、バックエンドの講義ということでGoで書かれた簡単なWebアプリをベースに様々なことを教えていただきました。 Goは、インターンが始まる前にA Tour of Goで予習していたところ、とても気に入りました。 そのため、講義に対するモチベーションがかなり高かったです(実際楽しかった)。

講義で学んだことは、以下の通りです。

テスト駆動開発(TDD)

以下のような手順で行う開発手法をテスト駆動開発(Test driven development)と呼ぶのだそうです。

  1. テストを書く
  2. テストをPASSするような実装を行う
  3. リファクタリングする

これ、初めは「要はテストを先に書くってことね!」くらいしか思ってなかったんですが、よく考えてみるとトップダウンの考え方に沿っているのかなと思いました。 上の手順は、以下のように言い換えることができます。

  1. こういう風に動いてほしい!(テスト)
  2. そう動かすためにはこうやって…(テストをPASSするような実装を行う)
  3. 細かいとこの修正(リファクタリングする)

開発者の「こんな感じに動くやつが欲しい!」といった抽象的なアイデアからその抽象度を下げてより具体的な実装へと落とし込んでいく流れがトップダウンになっていると感じました。

実は、これまで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日間しか参加していないという事実に驚きを感じます。