Mat Ryer 著『Go Programming Blueprints』

- 私の評価: 8 / 10
- 購入リンク
私はMat Ryerの仕事のファンで、彼のブログ記事は私のGoの書き方に大きな影響を与えてきました。本書については当たり外れがあるという印象です。魅力的でGoについて貴重な学びを得られた章もあれば、退屈でサードパーティライブラリの細部に入り込みすぎていると感じた章もありました。それでも、Goの初学者や中級者を自認する人には、全体としておすすめできる一冊です。
良かった点
- サンプルアプリのバリエーションが豊富で、現実的なシナリオの中でGoの機能を示すのに役立っていました。
- 非常にエレガントなGoのコードが掲載されており、いくつもの新しいイディオマティックなパターンを学べました。
- Goの標準ライブラリを興味深い形で活用しています。
- これまで理解できずにいたHTTPにおけるコンテキストが、ようやく腑に落ちました。
- DRMフリーの形式で入手できます。
気に入らなかった点
- ほとんどのサンプルが、私が普段書いているような単一サーバーで動くGoアプリではなく、高いスケーラビリティを前提としたアプリケーションに焦点を当てています。
- 本書はGoogle Maps、OAuth、gRPC、App Engineといった重量級のGoogle製ライブラリへの依存度が高すぎると感じました。
- 多くのサンプルが、Go本体に関わる部分よりも、特定のライブラリの細部に深く入り込んでいました。
- 深刻なセキュリティ上の問題を抱えたプラクティスがいくつか推奨されています。
- どのパーミッションを割り当てるか分からないときはデフォルトのビットマスクとして
0777を使うよう開発者に勧めています。 - ディレクトリトラバーサルへの対策がなく、サンプルアプリケーションに任意の書き込みが可能な脆弱性があり、リモートコード実行につながる可能性があります。
- ユーザーのアップロードに対する単純なサービス拒否攻撃への対策がありません。
- どのパーミッションを割り当てるか分からないときはデフォルトのビットマスクとして
- 文章の校正やコードのエラーチェックが不十分です。
- 不注意による文法ミスやコードの誤りが多数ありました。
- ユーザーから修正が投稿されていますが、何年も放置されています。
- 素のJavaScriptで十分、むしろその方が良い場面でもjQueryを使ってサンプルを複雑にしています。
- bashスクリプトのサンプルは雑な印象でした。
- 本全体を通してコードの品質にばらつきがあります。
- エレガントで直感的なサンプルもあれば、まるで下書きのようなものもあります。
- GitHubリポジトリが2つ独立して存在します。1つは著者によるもの、もう1つは出版社によるものです。
- 著者側のリポジトリが正しいもののようです。
- Windowsでサンプルを実行するための手順も載っていますが、検証されていない付け足しのように感じられます。
- サードパーティの依存関係がすでに消失しているため、コンパイルできなくなっているサンプルもあります。
主な学び
Go言語と標準ライブラリのTips
シグナルチャネル
- シグナルチャネルは、Goでスレッドセーフなイベントを実装するためのイディオマティックな方法です。
- シグナルチャネルは、型が
struct{}のchanにすぎません。- シグナルチャネルは何もデータを渡しません。単にイベントが発生したことを通知するだけです。
- Twitter投票アプリは、シグナルチャネルを使って次のことを行う良い例です。
- クライアントがサーバーを中断できるようにする。
- バックグラウンド処理が完了したことを示す。
time.Ticker
これまでtime.Tickerという型を知らず、自分で同じものを再実装してしまっていました。一定間隔でコードを実行するシンプルな方法です。
for range time.NewTicker(5 * time.Minute).C {
// Execute this code every five minutes.
}私はPicoShareでtime.Tickerを使って定期的なデータベースメンテナンスをスケジュールしています。
flags.Durationの柔軟性が印象的です
flags.Durationは55sや10mといった異なる時間単位をネイティブにサポートしています。- つまり、
flags.Durationをコマンドラインフラグとして使うと、--interval 10mのようなフラグをコマンドラインインターフェースで受け取り、flagsパッケージが自動的にtime.Durationにパースしてくれます。
- つまり、
テスト用パッケージと本番用パッケージを分ける
- テストを本番コードとは別のパッケージで書くと、より良いテストになります。
- たとえば、パッケージ
fooのテストは、同じディレクトリにfoo_testというパッケージを作って書きます。 - 通常、Goのツールは同じフォルダに複数のパッケージを置くことを禁じていますが、テストに関しては例外としています。
- たとえば、パッケージ
- 別の
_testパッケージにすることで、テストが本番パッケージの公開されたメンバーにしかアクセスできないようになります。- これにより、内部の実装の詳細ではなく、クライアントから見た振る舞いを検証するテストを書くよう促されます。
関数を引数に取る場合はパラメータリストの最後に置く
関数を引数に取る場合、その引数はパラメータリストの最後に置いてください。そうしないと、どの引数が内側の関数に紐づくもので、どれが外側の関数の引数なのか、読み手が追いづらくなります。
悪い引数の順序
値の変更をポーリングしてローカルのコピーを定期的に更新するupdateValueという関数があり、SetValFnを受け取る必要があるとします。
type SetValFn func(key, value string) boolSetValFnパラメータを最初の引数にすると、関数の定義自体は問題なく見えます。
func updateValue(setFn SetValFn, interval time.Duration) {
for range time.NewTicker(interval).C {
value := fetchValue()
setFn("somekey", value)
}
}しかし、実際にupdateValueを呼び出すとき、呼び出し元は読みづらくなります。
updateValue(func(key, value string) bool {
if err := DB.SetKey(key, value); err != nil {
return false
}
return true
}, 5*time.Minute) // Which function call is this for?微妙な点は、5*time.MinuteがupdateValueの引数であるにもかかわらず、SetValFnのインラインな関数定義全体の後に現れるため、updateValueとのつながりが分かりにくいことです。
良い引数の順序
上記の例をより良く書き直すなら、関数の引数をリストの最後にするだけで済みます。
// Reorder arguments so that SetValFn is last
func updateValue(interval time.Duration, setFn SetValFn) {そうすれば、呼び出し元でも両方の引数がupdateValueのものであることがより明確になります。
updateValue(5*time.Minute, func(key, value string) bool {
if err := DB.SetKey(key, value); err != nil {
return false
}
return true
})コードでは「見通しの良さ」を優先する
本書でも「見通しの良さ(line of sight)」という考え方に触れていますが、Ryerが自身のブログでより分かりやすく説明しています。
コンテキストや条件分岐が深くネストし、条件分岐の各ブロックが離れていると、コードは読みづらくなり、文脈を保つのが難しくなります。Ryerは、ロジックが画面の左端近くに収まるようにコードを構成することを推奨しています。
見通しが悪い例
見通しが悪いと、ロジックが深くネストし、条件ブロックが大きくなります。
if something.OK() {
something.Lock()
defer something.Unlock()
err := something.Do()
if err == nil {
stop := StartTimer()
defer stop()
log.Println("working...")
doWork(something)
<-something.Done()
log.Println("finished")
return nil
} else {
return err
}
} else {
return errors.New("something not ok")
}見通しが良い例
見通しを良くするには、条件分岐のロジックを反転させてエラー時に早期リターンし、残りのロジックを条件の外に出します。
if !something.OK() { // flipped
return errors.New("something not ok")
}
something.Lock()
defer something.Unlock()
err := something.Do()
if err != nil { // flipped
return err
}
stop := StartTimer()
defer stop()
log.Println("working...")
doWork(something)
<-something.Done()
log.Println("finished")
return nilHTTPハンドラでコンテキストを使う
私は趣味で5年間GoのWebプログラミングをしてきましたが、HTTPハンドラにおけるcontext.Contextの意味が、本書を読むまで一度も理解できませんでした。第6章に良い解説がありますが、ここで要約してみます。
Webアプリで、すべてのHTTPリクエストにAPIキーを付ける必要があるとします。ヘッダーでもURLクエリパラメータでもCookieでも構いませんが、簡単のためクエリパラメータだとしましょう。ユーザーが/foo?key=abc123のようなキー付きでAPIを呼ぶことを想定し、すべてのエンドポイントでリクエストに正しいAPIキーがあることを保証したいとします。
これを実現するために、HTTPミドルウェア関数を作ることができます。ミドルウェア関数はチェーン状に動作し、同じHTTPリクエストを複数のミドルウェア関数が順に処理できます。ミドルウェア関数はcontext.Contextを使って後続のHTTPハンドラにデータを渡します。
APIキーを強制するには、まずContextオブジェクトにAPIキーを格納するためのキーを作成します。
type contextKey struct {
name string
}
var contextKeyAPIKey = &contextKey{"api-key"}いまだによく分からないのですが、キーは単純な文字列ではなく、文字列を含む構造体である必要があるそうです。
追記(2023-01-02): 当初、なぜcontextKeyが単なる文字列ではなく文字列を含む構造体なのか理解できませんでした。本書でRyerは、同じ値を持つ他のキーとの衝突を防ぐためだと説明していますが、なぜ開発者が単に同じキーを別用途で再利用しなければいいだけではないのか分かりませんでした。Matthew Riley氏がこの挙動を解説してくれ、単純な文字列では防げないのに対し、パッケージにローカルな型であればパッケージをまたいだ衝突を防げるのだと理解できました。
const contextKeyToken := "token"のようなコンテキストキーを使い、同じリクエストを処理する別のパッケージでも"token"というキーを使っていた場合、互いのコンテキストの値を上書きしてしまいます。パッケージにローカルな独自の型を定義することで、型が異なるため、他のパッケージのトークンが自分のものと等しいとContextに評価されることがないと保証できます。
コンテキストキーを定義できたら、次のようなミドルウェア関数を作成します。
func withAPIKey(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")
if key != "abc123" {
http.Error(w, "Invalid API key", http.StatusUnauthorized)
return
}
// Add the API key to the request context.
ctx := context.WithValue(r.Context(), contextKeyAPIKey, key)
fn(w, r.WithContext(ctx))
}
}ルーティングを定義する際は、リクエストハンドラをwithAPIKeyミドルウェアでラップします。
mux := http.NewServeMux()
mux.HandleFunc("/foo", withAPIKey(s.handleFoo))withAPIKeyミドルウェアにより、リクエスト内のAPIキーが有効で存在することが保証されます。withAPIKeyより後段のハンドラでAPIキーが必要な場合は、このヘルパー関数を呼び出すことができます。
func APIKey(ctx context.Context) string {
k := ctx.Value(contextKeyAPIKey)
if k == nil {
panic("no API key in request")
}
key, ok := k.(string)
if !ok {
panic("API key in request is not a string")
}
return key
}handleFooハンドラはwithAPIKeyミドルウェアの後段にあるので、リクエストのコンテキストからAPIキーを取得できます。
func (s *Server) handleFoo(w http.ResponseWriter, r *http.Request) {
log.Printf("handling /foo, API key=%v", APIKey(r.Context()))
}HTTPヘルパー関数
Mat RyerのHTTPエンコードヘルパーパターン
Ryerは、エンコード形式を抽象化することでHTTPハンドラが交換フォーマットを意識しなくて済むようにすることを提唱しています。そうすれば、インターフェースがJSONを話す場合でも、protobufに変更したいときに1つのファイルを直すだけで済みます。
Ryerは、エンコードの詳細を隠すためにdecodeとrespondというヘルパー関数を使い、ルートハンドラを次のように書いています。
func handleFooPost(w http.ResponseWriter, r *http.Request) {
var payload struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
}
if err := decode(r, &payload); err != nil {
respondErr(ctx, w, r, err, http.StatusBadRequest)
return
}
// Do something with the request.
response := struct {
ID string `json:"id"`
}{
ID: "1234",
}
respond(ctx, w, r, response, http.StatusOK)
}そしてdecodeとrespondがそれぞれJSONのデシリアライズとシリアライズを担当します。
// decode parses JSON from an HTTP request body.
func decode(r *http.Request, v interface{}) error {
err := json.NewDecoder(r.Body).Decode(v)
if err != nil {
return err
}
if valid, ok := v.(interface {
OK() error
}); ok {
err = valid.OK()
if err != nil {
return err
}
}
return nil
}
// respond serializes response data to JSON in the body of an HTTP request.
func respond(ctx context.Context, w http.ResponseWriter, r *http.Request, v interface{}, code int) {
var buf bytes.Buffer
err := json.NewEncoder(&buf).Encode(v)
if err != nil {
respondErr(ctx, w, r, err, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_, err = buf.WriteTo(w)
if err != nil {
log.Errorf(ctx, "respond: %s", err)
}
}Ryerのエンコードヘルパーパターンを私なりにアレンジした方法
Ryerのヘルパーメソッドの発想は好きですが、得られる利便性のわりに抽象化のコストが高すぎると思います。Webアプリを別のエンコード方式に書き換えることがどれほどあるでしょうか。
しかも、ルートハンドラが構造体にJSONタグを指定しなければならない時点で、フォーマットを知らないはずという抽象化が漏れています。
また、エラーメッセージをJSONで書くのも好みではありません。GoのHTTPスタックのほとんどのコンポーネントはプレーンテキストのエラーで失敗するため、JSON形式のエラーにすると、クライアントは正しく整形されたJSONとしてのエラーと、プレーンテキストとしてのエラーの両方を探さなければなりません。エラーメッセージは常にプレーンテキストで送る方がシンプルです。
成功時のJSONレスポンスについては、次のようなrespondJSONという関数を使っています。
func respondJSON(w http.ResponseWriter, data interface{}) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Fatalf("failed to encode JSON response: %v", err)
}
}そしてJSONのデコードはインラインで行うので、私のhandleFooPostは次のようになります。
func handleFooPost(w http.ResponseWriter, r *http.Request) {
var payload struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "JSON is invalid", http.StatusBadRequest)
return
}
// Do something with the request.
respondJSON(w, struct {
ID string `json:"id"`
}{
ID: "1234",
})
}結局json.NewDecoder(r.Body).Decode(&payload)というスニペットを繰り返すことになりますが、1行だけなので大した問題ではありません。
クライアントに内部構造体の詳細を隠す
すべての言語に共通するWeb開発の落とし穴の一つに、意図しないデータの露出があります。ユーザーに関するデータを表す内部構造体があるとします。
type User struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
}/user?id=1234のようなJSON APIを公開したいので、次のように書きます。
func handleUserGet(w http.ResponseWriter, r *http.Request) {
user, err := loadUser(r.URL.Query().Get("id"))
if err != nil {
http.Error(w, "Failed to load user", http.StatusInternalServerError)
return
}
respondJSON(w, user)
}ユーザーが/userルートをクエリすると、ユーザーの公開情報が返ってきます。
curl https://example.com/user?id=1234{
"username": "alice123",
"displayName": "Alice"
}ここまでは問題ありません。ところが1か月後、メールアドレスやパスワードハッシュといった追加のデータを内部構造体で扱いたくなります。
type User struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
Email string `json:"email"` // Add these for
PasswordHash string `json:"passwordHash"` // internal operations.
}handleUserGetにはまったく手を加えていなくても、ユーザーが/userルートを呼び出すと、大量の新しい情報が返ってきます。
curl https://example.com/users?id=1234{
"username": "alice123",
"displayName": "Alice",
"email": "[email protected]",
"passwordHash": "$2a$10$J5zqqeQgH80ScyOSeCNCD.1V3ApJ1ULYMwMEhOjG6j4SM1mqL84YO"
}おっと!全員のメールアドレスとパスワードハッシュを漏らしてしまいました。
以前ペネトレーションテストをしていたとき、現実の世界でもこのミスを犯している企業をいくつか見つけました。開発者から見ればhandlerUserGetを実装した時点ではすべて意図通りに動いていたため、微妙なバグです。User構造体にフィールドを追加する際、handleUsersGetには触れないので、アプリの生のHTTPトラフィックを日常的にチェックしていない限り、情報の露出に気づきません。
私はこの種のミスを自分のアプリで犯さないか常に神経をとがらせているので、他の人がどう対処しているのかいつも気になります。
RyerのPublicメソッドパターン
Ryerは、内部向けと外部向けの両方の表現を持つ構造体にPublicメソッドを追加することで、上記の問題を解決することを提案しています。
type obj struct {
value1 string
value2 string
value3 string
}
func (o *obj) Public() interface{} {
return map[string]interface{}{"one": o.value1, "three": o.value3}
}
func TestPublic(t *testing.T) {
is := is.New(t)
o := &obj{
value1: "value1",
value2: "value2",
value3: "value3",
}
v, ok := meander.Public(o).(map[string]interface{})
is.Equal(true, ok)
is.Equal(v["one"], "value1")
is.Nil(v["two"])
is.Equal(v["three"], "value3")
}Mat Ryerの手法は気に入っていますし、コードベースでこの規約を徹底すればうまく機能すると思いますが、Goにおけるこの問題の解決策として私が最も好むものではありません。
この手法で気になる主な点は、カプセル化に反することです。私は内部の型はできるだけシンプルにし、クライアントがデータをどう使うかについての前提を最小限にしたいと考えています。Publicメソッドを追加すると、その型がクライアントによるデータの使われ方を先回りして想定していることになり、すべてのエンドポイントに同じフィールドを公開することを強制してしまいます。
私が好んで使う情報隠蔽の方法
私のGoコードでは、外部に公開するデータには別の構造体を使います。外部のクライアントにデータを公開する必要があるときは、内部構造体から外部用の構造体へデータをコピーします。
通常は、わざわざ名前付きの型を作る必要がないよう、インラインで宣言した無名構造体を使います。
// my internal data
type User struct {
Username string
DisplayName string
Email string
PasswordHash string
}
func handleUserGet(w http.ResponseWriter, r *http.Request) {
user, err := loadUser(r.URL.Query().Get("id"))
if err != nil {
http.Error(w, "Failed to load user", http.StatusInternalServerError)
return
}
// Copy the fields from User that I want to publish into a new anonymous
// struct.
respondJSON(w, struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
}{
Username: user.Username,
DisplayName: user.DisplayName,
})
}私がこの方法を好む理由はいくつかあります。
- 意図しない情報漏洩に対する保護が一層強固になります。
- 万が一、内部構造体を外部向けの型に誤って含めてしまったとしても、内部構造体のフィールドにはJSONタグがないため何も出力されません。
- 返しているデータがより明示的になります。
- データをよりきめ細かく制御できます。
Publicパターンでは、その型を含むすべてのエンドポイントが同じ形式でデータを返さなければなりませんが、上記の方法ではエンドポイントごとにどのフィールドをどの形式で公開するかを決められます。
特に面白かった章の紹介
WebSocketを使ったチャットアプリケーション
- goroutineとWebSocketを使ったクールなデモです。
ユーザーアカウントの追加
- HTTPハンドラをチェーンさせる良い例です。
分散システムの構築と柔軟なデータの扱い
- この章だけでも本の値段分の価値がありました。
- 水平スケーリング: ノードを追加して信頼性やパフォーマンスを向上させることでシステムをスケールさせること
- 垂直スケーリング: 個々のノードのリソースを増やして(例: RAMやCPUの追加)システムをスケールさせること
- 水平スケール可能なサービスを組み合わせたクールな例です。
- NSQを使ってメッセージを配信しています。
- Twitter Streaming APIを使ってTwitterからライブデータを読み取っています。
- MongoDBを使ってデータを保存しています。
- シンプルな部品で構成されながら、高いスケーラビリティを持つシステムを見るのはとても面白いです。
- HTTP接続でカスタムのトランスポート関数を使って、基盤となるTCP接続の低レベルな挙動をカスタマイズする良い例です。
- アプリがOSから
SIGINTやSIGTERMシグナルを受け取ったときにカスタムのクリーンアップを行うため、デフォルトのシグナルハンドラをオーバーライドする方法の良い例です。
記事をランダムに読む