Goでデータベースなどを扱うサービスを実装する際には、接続情報などの環境ごとの「設定情報」がありますが、今回はそれをどう扱うかについてです。
発表資料
本記事に関する発表資料はこちらです。
#実現すること
接続情報を設定ファイルなどGoファイルから分離したい
アプリケーションを作るに当たり必須項目ですが、接続先やパスワードをGoの中にべた書きはだめなので、tomlやyamlファイルに分離したい。
パスワードといった秘匿性の高い情報をコードベースから分離したい
プロジェクトやチームによって異なるかもしれませんが、databaseのパスワードなどはコードベースからは分離して、本番環境の情報は一部のコアな開発者のみが知っている状態を実現したい。
上記を実現するに当たり、まず設定について考えるに当たり、Twelve-Factor Appを見ていきます。
#Twelve-Factor App
https://12factor.net/ja/
Twelve-Factor Appとは、Webアプリケーションを使いやすい形でスケーラブルにするための方法論について、Herokuの方がまとめてくださっているものです。
現代では、ソフトウェアは一般にサービスとして提供され、Webアプリケーション や Software as a Service と呼ばれる。Twelve-Factor Appは、次のようなSoftware as a Serviceを作り上げるための方法論である。
その中で、「設定」について取り扱っている章があります。
##Twelve-Factor App: 3.設定について
https://12factor.net/ja/configTwelve-Factor Appでは 設定をコードから厳密に分離すること を要求し、Twelve-Factor Appは設定を 環境変数 に格納する方法を推奨しています。
合わせて、環境変数に設定情報を置くこと以外の方法についても言及しています。
バージョン管理システムにチェックインされない設定ファイルを使う
まず、Railsのconfig/database.ymlのようにバージョン管理システムにチェックインされない設定ファイルを使う方法について下記のように言及しています。
設定に対するもう1つのアプローチは、バージョン管理システムにチェックインされない設定ファイルを使う方法である。例として、Railsにおけるconfig/database.ymlがある。この方法は、リポジトリにチェックインされる定数を使うことに比べると非常に大きな進歩であるが、まだ弱点がある。設定ファイルが誤ってリポジトリにチェックインされやすいことと、設定ファイルが異なる場所に異なるフォーマットで散乱し、すべての設定を一つの場所で見たり管理したりすることが難しくなりがちであることである。その上、これらのフォーマットは言語やフレームワークに固有のものになりがちである。
「誤ってコードをチェックインしてしまう」・「設定ファイルの管理が難しくなる」と行った点を指摘しています。
設定をグループにまとめる
また、設定をdevelopment
・production
のように環境ごとのグループにまとめる方法については下記のように言及しています。
設定管理のもう1つの側面はグルーピングである。アプリケーションは設定を名前付きのグループ(しばしば“環境”と呼ばれる)にまとめることがある。グループは、Railsにおけるdevelopment、test、production環境のように、デプロイの名前を取って名付けられる。この方法はうまくスケールしない。アプリケーションのデプロイが増えるにつれて、新しい環境名(stagingやqa)が必要になる。さらにプロジェクトが拡大すると、開発者はjoes-stagingのような自分用の環境を追加する。結果として設定が組み合わせ的に爆発し、アプリケーションのデプロイの管理が非常に不安定になる。
新しい環境が増えた際のスケールのしにくさ・デプロイ管理の不安定さについて指摘しています。
今回どうするか
結論から言うと、今回のケースは一部Twelve-Factor Appの推奨方法を採用して、設定をコードから一部分離する方式をやります。具体的は下記のやり方を進めていきます。
アプリケーションを環境ごとのグループにまとめる
development
やlocalhost
といった環境ごとにまとめて設定ファイルに格納します。こうした理由としては、デプロイの手順に、cp config.yml.localhost config.yml
といったものを入れずにビルドのみで完結したいという意図があります。
- パスワードといった情報のみ環境変数に格納する
環境変数にすべての設定情報を入れて、設定をコードから厳密に分離することを検討しました。その際リスクとして、サーバー設定ミスや脆弱性などで環境変数が読まれすべての情報が漏洩する可能性を危惧しました。そのため、すべての情報を入れるのではなく、もともとコードベースから分離したい対象であった「パスワード」のみを環境変数に格納することにしました。
#実装
以下 2点の方針のもと、実際の実装を見ていきます。
- アプリケーションを環境ごとのグループにまとめる
- パスワードといった情報のみ環境変数に格納する
また、今回の実装は以下のgithubリポジトリに全量公開しています。
##今回扱う「設定情報」
今回のサンプルでは、以下のdatabase接続情報を扱います。
情報 | 例 |
---|---|
host | localhost |
user | sample_user |
database | sample |
password | sample_password |
port | 3306 |
アプリケーションの基本的な流れ
今回のサンプルアプリケーションでは、主に下記のようなデータベースを利用するAPIサーバーを想定します。
// NewDB return database global connection handle.
func NewDB(conf DBConfig) (*sql.DB, error) {
db, err := sql.Open(
"mysql",
fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s",
conf.User,
conf.Password,
conf.Host,
conf.Port,
conf.Name))
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
return db, nil
}
func main() {
var err error
// Get configuration
conf, err := config.NewConfig()
if err != nil {
panic(err.Error())
}
// Get database Handle
db, err := NewDB(conf.DB)
if err != nil {
panic(err.Error())
}
}
以後、NewDB関数に渡している、Config構造体に対して設定情報を格納するかについて書いていきます。これからの流れとして説明の都合上、**「べた書き」→「設定ファイルに格納」→「環境変数に格納」**という流れで進めます。
目次
- べた書きの実装をする
- 設定ファイルに格納する
- 環境変数にパスワードを格納
1. べた書きの実装をする
(githubでは、v0.1タグで公開しています。)
接続情報を扱うに当たり、config
というパッケージを用意して以下のようにconfig構造体を返す実装をします。NewConfig
内には最初の期待値として、べた書き部分で設定情報で書いています。
// DBConfig represents database connection configuration information.
type DBConfig struct {
User string
Password string
Host string
Port int
Name string
}
// Config represents application configuration.
type Config struct {
DB DBConfig
}
// NewConfig get configuration struct.
func NewConfig() (Config, error) {
conf := Config{}
conf.DB = DBConfig{
User: "sample_user",
Password: "sample_password",
Host: "master_mysql",
Port: 3306,
Name: "sample",
}
return conf, nil
}
以後、設定情報をコードから逃がすリファクタリングのためのテストを書いておきます。
func TestNewConfig(t *testing.T) {
cases := []struct{
name string
expected config.Config
}{
{
name: "localhost",
expected: config.Config{
DB: config.DBConfig{
User: "sample_user",
Password: "sample_password",
Host: "master_mysql",
Port: 3306,
Name: "sample",
},
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
res, err := config.NewConfig()
assert.Equal(t, nil, err)
assert.Equal(t, c.expected.DB.User, res.DB.User)
assert.Equal(t, c.expected.DB.Password, res.DB.Password)
assert.Equal(t, c.expected.DB.Host, res.DB.Host)
assert.Equal(t, c.expected.DB.Port, res.DB.Port)
assert.Equal(t, c.expected.DB.Name, res.DB.Port)
})
}
}
次に、「設定情報」を設定ファイルに移動します。
2. 設定ファイルに格納する
(githubでは、v0.2タグで公開しています。)
設定情報を設定ファイルを移動します。設定ファイルには、TOMLファイルを利用しています。TOMLを利用するために、github.com/BurntSushi/tomlを使います。
今回、アプリケーションを環境ごとのグループにまとめるということを実現するに当たり、今後設定が増えていくことを考慮してそれぞれ環境ごとにファイルに分けています。
[database]
user = "sample_user"
password = "sample_password"
host = "master_mysql"
port = 3306
name = "sample"
今回、一時的にパスワードもTOMLファイルの中に入れていますが次に環境変数に逃します。
先程、書いたconfig
パッケージはTOMLファイルを利用するために下記のように書き換えます。
// DBConfig represents database connection configuration information.
type DBConfig struct { // toml内の名前を入れる
User string `toml:"user"`
Password string `toml:"password"`
Host string `toml:"host"`
Port int `toml:"port"`
Name string `toml:"name"`
}
// Config represents application configuration.
type Config struct { // toml内の名前を入れる
DB DBConfig `toml:"database"`
}
// NewConfig return configuration struct.
func NewConfig(path string, appMode string) (Config, error) {
var conf Config
confPath := path + appMode + ".toml" // tomlファイルを読み設定情報を取得
if _, err := toml.DecodeFile(confPath, &conf); err != nil {
return conf, err
}
return conf, nil
}
今回、tomlファイルがどこにあるかを求めるのに引数で渡しているのは、テスト時にNewConfig
関数内で使いたいTOMLファイルがテスト用を使いたいと行ったケースの際を考慮して、外から渡せるようにするという意図です。
環境ごとに必要な情報に過不足があると行ったことが無いよう、上記で書いたユニットテストではアプリケーションで利用する設定ファイルを使ったテストを書いて保護します。
func TestNewConfig(t *testing.T) {
cases := []struct {
name string
appMode string // どの環境かを示す
expected config.Config
}{
{
name: "localhost",
appMode: "localhost",
expected: config.Config{
DB: config.DBConfig{
User: "sample_user",
Password: "sample_password",
Host: "master_mysql",
Port: 3306,
Name: "sample",
},
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
confDir := "./env/" // config_testから設定ファイルdirへの相対パス
res, err := config.NewConfig(confDir, c.appMode)
assert.Equal(t, nil, err)
assert.Equal(t, c.expected.DB.User, res.DB.User)
assert.Equal(t, c.expected.DB.Password, res.DB.Password)
assert.Equal(t, c.expected.DB.Host, res.DB.Host)
assert.Equal(t, c.expected.DB.Port, res.DB.Port)
assert.Equal(t, c.expected.DB.Name, res.DB.Name)
})
}
}
configパッケージに対して、main関数では以下2点の処理を追加しています。
- 設定ファイルへの実行ファイルからの相対パスを指定
- 動作環境をAPP_MODEという形で環境変数に格納する
const confDir = "./config/env/" // 設定ファイルへの実行ファイルからの相対パスを指定
func main() {
var err error
appMode := os.Getenv("APP_MODE") // 動作環境をAPP_MODEという形で環境変数に格納する
if appMode == "" {
panic("failed to get application mode, check whether APP_MODE is set.")
}
// Get configuration
conf, err := config.NewConfig(confDir, appMode) // 引数に渡す
if err != nil {
panic(err.Error())
}
// 以降続く...
}
3. 環境変数にパスワードを格納
(githubでは、v0.3タグで公開しています。)
最後に、パスワード情報をソースコードから分離します。
まず、tomlファイルからパスワードを削除
[database]
user = "sample_user"
host = "master_mysql"
port = 3306
name = "sample"
configパッケージでは、パスワードの取得をtomlファイルから環境変数に変えます。
// DBConfig represents database connection configuration information.
type DBConfig struct {
User string `toml:"user"`
Password string // tomlファイルから分離するためタグを削除
Host string `toml:"host"`
Port int `toml:"port"`
Name string `toml:"name"`
}
// Config represents application configuration.
type Config struct {
DB DBConfig `toml:"database"`
}
// NewConfig return configuration struct.
func NewConfig(path string, appMode string) (Config, error) {
var conf Config
confPath := path + appMode + ".toml"
if _, err := toml.DecodeFile(confPath, &conf); err != nil {
return conf, err
}
conf.DB.Password = os.Getenv("DB_PASSWORD") // 環境変数から読み込む
return conf, nil
}
テストも修正します。(今回は説明の都合上テストから先に修正いますが、本来の流れでは、テストから直して実コードを修正する方が良さそうです。)
func TestNewConfig(t *testing.T) {
cases := []struct {
name string
appMode string
expected config.Config
}{
{
name: "localhost",
appMode: "localhost",
expected: config.Config{
DB: config.DBConfig{
User: "sample_user",
Password: "sample_password",
Host: "master_mysql",
Port: 3306,
Name: "sample",
},
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
confDir := "./env/"
os.Setenv("DB_PASSWORD", c.expected.DB.Password) // 環境変数に期待するパスワードをセットする
res, err := config.NewConfig(confDir, c.appMode)
assert.Equal(t, nil, err)
assert.Equal(t, c.expected.DB.User, res.DB.User)
assert.Equal(t, c.expected.DB.Password, res.DB.Password)
assert.Equal(t, c.expected.DB.Host, res.DB.Host)
assert.Equal(t, c.expected.DB.Port, res.DB.Port)
assert.Equal(t, c.expected.DB.Name, res.DB.Name)
})
}
}
テストを修正後、テストが通ればOKです。
go test ./config -v
=== RUN TestNewConfig
=== RUN TestNewConfig/localhost
--- PASS: TestNewConfig (0.00s)
--- PASS: TestNewConfig/localhost (0.00s)
PASS
ok github.com/Khigashiguchi/go-config-example/src/config 0.019s
まとめ
- Twelve-Factor Appの設定ファイルの思想を一部適用して、設定をコードから一部分離した
- 接続情報を設定ファイルとして環境ごとにTOMLファイルに定義した
- パスワードを環境変数に格納する方式にした