Goでサーバーアプリケーションを書いていると、設定項目が多い構造体の初期化で困ることがありませんか?
例えば、HTTPサーバーを作る時、タイムアウト設定、TLS設定、ログ設定など、多くのオプションを管理する必要があります。
// こんな感じで引数が増えていく...
server := NewServer(":8080", handler, 10*time.Second, 10*time.Second,120*time.Second, 1<<20, tlsConfig, logger, middleware1, middleware2...)
このような課題を解決する優れたパターンが「Functional Options Pattern」です。Go標準ライブラリやGoogle、Uberなど多くの企業のGoプロジェクトで採用されているこのパターンを、実践的な例とともに解説します。
この記事で学べること
- 構造体の初期化における具体的な課題
- Functional Options Patternの基本的な実装方法
- パターンの利点と適切な使用場面
前提知識
Go言語の基本的な文法、特に構造体、インターフェース、関数型について理解していることを前提とします。
環境
- Go 1.21以降
まず、Functional Options Patternを使わない場合の課題を見てみる
HTTPサーバーの設定を例に、従来のアプローチの問題点を見てみましょう。
アプローチ1: 引数を増やしていく方法
package main
import (
"net/http"
"time"
)
func NewServer(addr string, handler http.Handler, readTimeout, writeTimeout, idleTimeout time.Duration, maxHeaderBytes int) *http.Server {
return &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
MaxHeaderBytes: maxHeaderBytes,
}
}
func main() {
mux := http.NewServeMux()
// 引数の順番を覚えておく必要がある...
server := NewServer(":8080", mux, 10*time.Second, 10*time.Second, 120*time.Second, 1<<20)
}
冒頭でも見ましたが、引数が6個もあり、どれがどの設定なのか分かりにくいです。
また、本来デフォルト値でも良い部分も引数で受け取れるようにしているので、例えばReadTimeoutをデフォルト値にしたくても、必須の値が呼び出し側から分からなくなってしまいます。
さらに、time.Duration
型の引数が3つ連続しているため、順番を間違えてもコンパイルエラーになりません。
アプローチ2: 構造体を使う方法
次は構造体を使うパターンです。
type ServerConfig struct {
Addr string
Handler http.Handler
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
MaxHeaderBytes int
}
func NewServerWithConfig(config ServerConfig) *http.Server {
return &http.Server{
Addr: config.Addr,
Handler: config.Handler,
ReadTimeout: config.ReadTimeout,
WriteTimeout: config.WriteTimeout,
IdleTimeout: config.IdleTimeout,
MaxHeaderBytes: config.MaxHeaderBytes,
}
}
func main() {
mux := http.NewServeMux()
server := NewServerWithConfig(ServerConfig{
Addr: ":8080",
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
})
}
可読性が向上しましたね。
ただ、デフォルト値を使いたい場合でもすべてのフィールドを指定する必要があります。
Functional Options Patternによる解決
では、Functional Options Patternを使って、これらの問題を解決していきましょう。
ステップ1: 最小限の実装
まず、最もシンプルな形から始めます。
package main
import (
"net/http"
"time"
)
// オプションを表す関数型
type ServerOption func(*http.Server)
// 必須パラメータのみを受け取るコンストラクタ
func NewServer(addr string, handler http.Handler, opts ...ServerOption) *http.Server {
// デフォルト値を持つサーバーを作成
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 10 * time.Second, // デフォルト値
WriteTimeout: 10 * time.Second, // デフォルト値
IdleTimeout: 120 * time.Second, // デフォルト値
MaxHeaderBytes: 1 << 20, // 1MB
}
// オプションを適用
for _, opt := range opts {
opt(srv)
}
return srv
}
// 各オプション関数
func WithReadTimeout(timeout time.Duration) ServerOption {
return func(s *http.Server) {
s.ReadTimeout = timeout
}
}
func WithWriteTimeout(timeout time.Duration) ServerOption {
return func(s *http.Server) {
s.WriteTimeout = timeout
}
}
func main() {
mux := http.NewServeMux()
// デフォルト値で作成
server1 := NewServer(":8080", mux)
// 一部のオプションだけカスタマイズ
server2 := NewServer(":8081", mux,
WithReadTimeout(30*time.Second),
WithWriteTimeout(30*time.Second),
)
}
必要なオプションだけを指定できるようになりました。
各オプションが明確な名前を持っているため、何を設定しているのかが一目瞭然ですね。
エラーハンドリングを追加
要件によっては、不正な値に対するバリデーションが必要です。
これで不正なオプション値が指定されても、初期化時にすぐ気づけるようになります。分かりやすいですね。
package main
import (
"fmt"
"net/http"
"time"
)
// エラーを返すようにオプション関数を拡張
type ServerOption func(*http.Server) error
func NewServer(addr string, handler http.Handler, opts ...ServerOption) (*http.Server, error) {
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
}
// オプションを適用し、エラーをチェック
for _, opt := range opts {
if err := opt(srv); err != nil {
return nil, fmt.Errorf("failed to apply option: %w", err)
}
}
return srv, nil
}
func WithReadTimeout(timeout time.Duration) ServerOption {
return func(s *http.Server) error {
if timeout <= 0 {
return fmt.Errorf("read timeout must be positive, got %v", timeout)
}
s.ReadTimeout = timeout
return nil
}
}
func WithMaxHeaderBytes(maxBytes int) ServerOption {
return func(s *http.Server) error {
if maxBytes <= 0 {
return fmt.Errorf("max header bytes must be positive, got %d", maxBytes)
}
if maxBytes > 10<<20 { // 10MB上限
return fmt.Errorf("max header bytes too large: %d (max: 10MB)", maxBytes)
}
s.MaxHeaderBytes = maxBytes
return nil
}
}
func main() {
mux := http.NewServeMux()
// 正常なケース
server, err := NewServer(":8080", mux,
WithReadTimeout(30*time.Second),
WithMaxHeaderBytes(2<<20), // 2MB
)
if err != nil {
panic(err)
}
// エラーケース:不正な値
_, err = NewServer(":8081", mux,
WithReadTimeout(-1*time.Second), // エラー:負の値
)
if err != nil {
fmt.Printf("Expected error: %v\n", err)
}
}
いつFunctional Options Patternを使うべきか
引数が多くなった場合、もしくはデフォルトで済ませられるがオプション値も設定できるようにしたい場合にはFunctional Options Patternを使っていくと良いでしょう。
あえてトレードオフを上げるなら、関数呼び出しのオーバーヘッドがあることです(サーバー起動時だけだと考えると、通常は無視できるレベルだとは思いますが)
まとめ
Functional Options Patternを使用することで、Goの構造体初期化の際における様々な課題を解決することができました。
各オプションが明確な名前を持つため、コードの意図が伝わりやすくなったかなと思います。
必要な設定だけを指定でき、デフォルト値の管理も容易になるのも良いですね。
また、新しいオプションを追加しても既存のコードに影響を与えないため、APIの進化が容易です。
このパターンは、Go標準ライブラリやGoogle、Uber等の大規模プロジェクトでも採用されている実績のあるパターンです。構造体の初期化で悩んだ時は、ぜひ検討してみてください。
参考
最近ホットなmcp-goでも採用されています。
uber-goのStyleGuideではOptionにinterfaceを使用して、構造体を持ったOptionを渡せるようにしてますね。
oauth2/oauth2.goでも同様でした。
今回の記事でmockを使う際に同じ値を渡してもreflet.DeepEqualでfalseになってしまい、Newのテストが失敗してしまうのでこちらのパターンでも良いですね。