良さげな実装テクを発見したので忘れないようにメモ.
functional optionsとは
何かを「設定」したいときにきれいに書けるAPIのお作法.何かを設定したいだけなら,色んな方法があるけど,このお作法に則ってると読みやすいし書きやすい.
読みやすくて書きやすいことは大事なので,知っておくと良い.
何かを設定する他の方法だと,例えば設定情報を表現する構造体を定義してそれをコンストラクタに渡すとか,設定のsetterを設けるとか.これらの方法だと,たくさん設定事項があるときに困ったりする.
何らかのオブジェクトを生成するとき,大抵の場合こんな感じで書く.
functional optionsのお作法に則って書くと
とも書けるし,オブジェクトの生成時に設定も一緒に仕込むなら
1
| obj := New(arg0, arg1, option0, option1)
|
とも書ける.
functional optionsのお作法は,そのプログラミング言語がサポートする「任意個の引数を取る」記法を使う.この記法がサポートされてないとできないかも.英語だと,「任意個の引数を取る」という様をvariadicと言うらしい.
具体的なコードでないと意味がよくわからないので具体的にしてみる.なんらかのサーバを想定するとわかりやすい.
1
2
3
4
5
6
7
8
9
| type Server struct {
addr string
}
func NewServer(addr string) *Server {
return &Server {
addr: addr,
}
}
|
これはまあ普通によくある書き方.では,タイムアウトの設定をしたサーバを生成するコンストラクタを書いてみよう.functional optionsのお作法に従って書くとこんな感じ.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| type Server struct {
addr string
timeout time.Duration
}
func Timeout(timeout time.Duration) func(*Server) {
return func(s *Server) {
s.timeout = timeout
}
}
func NewServer(addr string, opts ...func(*Server)) *Server {
server := &Server {
addr: addr,
}
for _, opt := range opts {
opts(server)
}
return server
}
|
こうやって書いてあると,このコードの利用者側はこんな感じのコードを書くことになる.
1
2
3
4
5
6
7
8
| // no options, use defaults
server := NewServer(":8080")
// configured to timeout after 10 seconds with address
server := NewServer(":8080", Timeout(10 * time.Second))
// configured to timeout after 10 seconds and use TLS for connection with address
server := NewServer("8080", Timeout(10 * time.Second), TLS(&TLSConfig{}))
|
なるほど,わかりやすい.これを例えばコンストラクタにたくさん引数を渡して設定するやり方でやるとこんな感じになる.
1
2
3
| server := NewServer(":8080")
server := NewServerWithTimeout(":8080", 10 * time.Second)
server := NewServerWithTimeoutAndTLS(":8080", 10 * time.Second, &TLSConfig{})
|
渡す設定によって引数が変わっちゃうのでそれに合わせたコンストラクタが必要になってしまう.これは大変.
じゃあそれらをまとめてConfig構造体を作るぞってやると
1
2
3
| server := NewServer(":8080", Config{})
server := NewServer(":8080", Config{ Timeout: 10 * time.Second })
server := NewServer(":8080", Config{ Timeout: 10 * time.Second, TLS: &TLSConfig{} })
|
となる.まあこれでもいいんだけど,何も設定しないときに空のConfig{}を渡さないといけないのはチョット不格好だし,何より設定事項が増えたときに読みづらくなりそう.
というわけで,設定したいものを引数に取って,設定を「適用」していくような関数を用意するとかっこよく書ける.
さらに読みやすくする工夫
func (s *Server)に名前をつけてしまえばもっとわかりやすくなる.
1
| type Option func(s *Server)
|
こうすれば
1
2
3
| func Timeout(timeout time.Duration) Option { /*...*/ }
func NewServer(addr string, opts ...Option) *Server { /*...*/ }
|
となって,より「あ,オプション取るんだな」ってのがわかる.うれしい😄
こうなると複数オプションもいい感じにまとめることができそう.
1
2
3
4
5
6
7
| defaultOptions := []Option{Timeout(5 * time.Second)}
server1 := NewServer(":8080", append(defaultOptions, MaxConnections(10))...)
server2 := NewServer(":8080", append(defaultOptions, RateLimit(10, time.Minute))...)
server3 := NewServer(":8080", append(defaultOptions, Timeout(10 * time.Second))...)
|
[]Optionをもっと賢くしたいので,
1
2
3
4
5
6
7
| func Options(opts ...Option) Option {
return func(s *Server) {
for _, opt := range opts {
opt(s)
}
}
}
|
を用意すれば,
1
2
3
4
5
6
7
| defaultOptions := Options(Timeout(5 * time.Second))
server1 := NewServer(":8080", defaultOptions, MaxConnections(10))
server2 := NewServer(":8080", defaultOptions, RateLimit(10, time.Minute))
server3 := NewServer(":8080", defaultOptions, Timeout(10 * time.Second))
|
とできて,イイ感じ!
With/Set
Loggerとかも設定したいものとしてはよくある.
1
2
3
4
| type Logger interface {
Info(msg string)
Error(msg string)
}
|
とかを用意しておいて,
1
2
3
4
5
6
7
| func WithLogger(logger Logger) Option {
return func(s *Server) {
s.logger = logger
}
}
NewServer(":8080", WithLogger(logger))
|
とすると,なるほどわかりやすい.
更に他の例だと,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| type Server struct {
// ...
whitelistIPs []string
}
func WithWhitelistedIP(ip string) Option {
return func(s *Server) {
s.whitelistIPs = append(s.whitelistIPs, ip)
}
}
func SetWhitelistedIP(ip string) Option {
return func(s *Server) {
s.whitelistIPs = []string{ip}
}
}
NewServer(
":8080",
WithWhitelistedIP("10.0.0.0/8"),
WithWhitelistedIP("172.16.0.0/12"),
SetWhitelistedIP("192.168.0.0/16"), // overwrites any previous values
)
|
Withは「追加」で,Setは「上書き」という雰囲気.
Option型の関数を返す関数を用意することで,特定の設定のプリセットみたいなものを定義できてこれまた便利.
Config構造体との掛け合わせ
Config構造体を用意して,Config構造体を引数に取るOption型の関数としてもいい.たくさんある設定をConfigという一つの場所に閉じ込められるので,設定事項がめちゃめちゃある場合には便利.
1
2
3
4
5
6
7
8
9
10
11
| type Config struct {
Timeout time.Duration
}
type Option func(c *Config)
type Server struct {
// ...
config Config
}
|
1
2
3
4
5
6
7
| config := Config{
Timeout: 10 * time.Second
// ...
// lots of other options
}
NewServer(":8080", WithConfig(config), WithTimeout(20 * time.Second))
|
Optionを関数型ではなくてinterfaceとしてさらに柔軟に設定を受け入れる
Optionをinterfaceにしてしまえば,もっといろんな設定を受け入れられるようになる.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| // Option configures a Server.
type Option interface {
// apply is unexported,
// so only the current package can implement this interface.
apply(s *Server)
}
// Timeout configures a maximum length of idle connection in Server.
type Timeout time.Duration
func (t Timeout) apply(s *Server) {
s.timeout = time.Duration(t)
}
// Options turns a list of Option instances into an Option.
type Options []Option
func (o Options) apply(s *Server) {
for _, opt := range o {
o.apply(s)
}
}
type Config struct {
Timeout time.Duration
}
func (c Config) apply(s *Server) {
s.config = c
}
|
Futher readings