Mat Ryer著『Go Programming Blueprints』
原文は Michael Lynch により に公開されました。 このブログを購読する

- 私の評価: 8 / 10
- 購入リンク
私はMat Ryerの仕事のファンで、彼のブログ投稿は私のGoの書き方に大きな影響を与えてきました。本書については当たり外れがあるという印象でした。いくつかの章は非常に興味深く、貴重なGoの教訓を学べましたが、他の章は退屈で、サードパーティ製ライブラリの細かな詳細に深入りしすぎていると感じました。全体として、Goの初学者や中級者を自認する人には、やはりおすすめできる一冊です。
良かった点
- 多彩なサンプルアプリが、現実的なシナリオの中でGoの機能をうまく示していた。
- 見事にエレガントなGoのコードが掲載されており、いくつかの新しいイディオマティックな言語パターンを学べた。
- Goの標準ライブラリを興味深い方法で活用している。
- これまで理解できずにいたHTTPコンテキストが、ようやく腑に落ちた。
- DRMフリーの形式で入手できる。
気に入らなかった点
- ほとんどの例が、私が普段書いているようなシングルサーバーのGoアプリケーションではなく、高いスケーラビリティを重視したアプリケーションに焦点を当てている。
- 本書は重量級のGoogle製ライブラリ(例:Google Maps、OAuth、gRPC、App Engine)に依存しすぎていると感じた。
- 多くの例が、解決策のうちGoに関連する部分よりも、特定のライブラリの細部に深く入り込みすぎていた。
- ひどく安全でないソフトウェアプラクティスをいくつか推奨している:
- どのパーミッションを割り当てるべきかわからない場合に、デフォルトのビットマスクとして
0777を使うよう開発者に助言している。 - ディレクトリトラバーサルへの対策がなく、サンプルアプリケーションに任意の書き込みが可能な脆弱性があり、リモートコード実行につながる。
- ユーザーによるアップロードに対する単純なDoS攻撃への対策がない。
- どのパーミッションを割り当てるべきかわからない場合に、デフォルトのビットマスクとして
- 文章の編集やコードのエラーチェックがお粗末。
- 不注意な文法ミスやコードの誤りが非常に多かった。
- ユーザーが修正を投稿しているが、何年も放置されている。
- 素のJavaScriptで十分あるいはより良く書ける箇所でも、jQueryを使って例を複雑にしている。
- bashスクリプトの例が雑な印象だった。
- 本全体を通してコードの品質にばらつきがある。
- エレガントで直感的な例もあれば、まるで初稿のような例もある。
- 独立したGitHubリポジトリが2つ存在する。1つは著者によるもの、もう1つは出版社によるものだ。
- 著者のリポジトリの方が正しいもののようだ。
- Windowsでサンプルを動かすための手順もあるが、テストされていない後付けのように感じられる。
- 一部の例は、依存していたサードパーティが消滅したため、もはやコンパイルできない。
主な学び
Go言語と標準ライブラリのヒント
シグナルチャネル
- シグナルチャネルは、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ハンドラでcontextを使う
私は5年間、趣味でGoのウェブプログラミングをしてきたが、本書を読むまでHTTPハンドラにおけるcontext.Contextの意義を理解できていなかった。第6章に良い説明があるが、ここで要約してみたい。
例えば、ウェブアプリで、すべての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のヘルパーメソッドのアイデアは好きだが、得られる利点に対して抽象化のコストが高すぎると思う。ウェブアプリを別のエンコーディング方式に書き換えることがどれだけあるだろうか。
しかも、ルートハンドラがフォーマットについて何も知らないはずなのに、構造体で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行なので大した問題ではない。
クライアントから内部構造体の詳細を隠す
すべての言語に共通するウェブ開発の落とし穴の1つが、意図しないデータ露出だ。ユーザーデータを表す内部構造体があるとする:
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メソッドを追加することで、上記の問題を解決することを提案している ( adding a Public method):
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でこの問題を解決する手法としては私の一番のお気に入りではない。
私がRyerの手法で主に気になるのは、カプセル化に反することだ。私は内部の型はできるだけシンプルにし、クライアントがどのようにデータを使うかについての仮定を最小限にしたい。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シグナルを受信した際にカスタムクリーンアップを行うため、デフォルトのシグナルハンドラをオーバーライドする良い例。
記事をランダムに読む
コメント
ログインしてコメントする