- Published on
 
API Service with Go: Configuration
- Authors
 
- Name
 - Somprasong Damyos
 - @somprasongd
 
Configuration
ค่า configurations ต่างๆ ในโปรแกรม เช่น server port และ dsn สำหรับ database ไม่ควรระบุลงไปในโค้ดตรงๆ ควรที่จะเปลี่ยนแปลงได้ตามค่า environments เมื่อถูกนำไป deploy
ซึ่งในภาษา Go สามารถอ่านค่า environment ได้จาก os.Getenv("KEY")
// starting server
port := os.Getenv("APP_PORT")
log.Printf("Starting server at port %v\n", port)
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%v", port), handler))
เมื่อรันโปรแกรมทุกครั้ง เราจะต้องกำหนดค่า APP_PORT มาด้วยเสมอ และถ้ามี environment หลายตัว ก็ต้องพิมพ์ระบุไปทุกครั้ง
$ APP_PORT=8081 go run .
2022/02/02 10:10:52 Starting server at port 8081
ถ้าไม่ต้องการกำหนดค่าทุกครั้ง
ในขณะที่กำลังพัฒนาอยู่ถ้าต้องรัน และมากำหนดค่าแบบนี้ไม่สะดวกแน่ๆ หรือบางครั้งก็อาจใส่ไม่ครบบ้างรันโปรแกรมไม่ได้อีก เราสามารถทำยังไงได้บ้าง
วิธีการง่ายๆ เราสามารถสร้างเป็น utility functionเพื่อกำหนดค่า default และแปลงชนิดข้อมูล
package util
import (
	"os"
	"strconv"
)
func GetEnv(key string, defaultValue string) string {
	val, ok := os.LookupEnv(key)
	if !ok {
		return defaultValue
	}
	return val
}
func GetEnvInt(key string, defaultValue int) int {
	val, ok := os.LookupEnv(key)
	if !ok {
		return defaultValue
	}
	v, err := strconv.Atoi(val)
	if err != nil {
		return defaultValue
	}
	return v
}
func GetEnvBool(key string, defaultValue bool) bool {
	val, ok := os.LookupEnv(key)
	if !ok {
		return defaultValue
	}
	v, err := strconv.ParseBool(val)
	if err != nil {
		return defaultValue
	}
	return v
}
ตัวอย่างการใช้งาน
// starting server
port := util.GetEnvInt("APP_PORT", 8080)
log.Printf("Starting server at port %v\n", port)
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%v", port), handler))
แต่วิธีข้างต้นก็ยังไม่สามารถกำหนดค่า default ได้ทุกตัว ดังนั้นจะใช้อีกวิธี คือ ในระหว่างที่กำลังพัฒนาอยู่จะให้มาอ่าน config จากไฟล์ ส่วนเมื่อนำไป deploy ใช้งานจริง ก็ให้ไปอ่านค่าออกมาจาก environment
package ที่นิยมใช้งานกัน คือ godotenv ซึ่งสามารถกำหนดค่าแบบ key=value ผ่านไฟล์ .env แต่ในบทความนี้จะแนะนำการใช้งาน viper แทน เพราะสามารถ config ค่าได้หลายรูปแบบ เช่น yaml
- ติดตั้ง viper 
go get github.com/spf13/viper - ใส่ค่า config ลงในไฟล์ 
config.yamlตามนี้ 
app:
  port: 8080
db:
  driver: 'postgres'
  host: 'john.db.elephantsql.com'
  port: 5432
  username: 'fcricryh'
  password: 'F5a7wATfocTUNww1Dm14AfebtPaysqIn'
  database: 'fcricryh'
- โหลดไฟล์ config โดยเพิ่มไฟล์ 
config/config.go 
package config
import (
	"fmt"
	"strings"
	"github.com/spf13/viper"
)
func LoadConfig() {
	viper.SetConfigName("config")                          // กำหนดชื่อไฟล์ config (without extension)
	viper.SetConfigType("yaml")                            // ระบุประเภทของไฟล์ config
	viper.AddConfigPath(".")                               // ระบุตำแหน่งของไฟล์ config อยู่ที่ working directory
	err := viper.ReadInConfig() // อ่านไฟล์ config
	if err != nil {             // ถ้าอ่านไฟล์ config ไม่ได้ให้ panic ไปเลย
		panic(fmt.Errorf("fatal error config file: %w", err))
	}
}
- การเรียกใช้งาน ใช้ 
viper.GetXXX("ชื่อใช้ . dot notation") 
func main() {
	config.LoadConfig()
	database.ConnectDB()
  // ...
	// starting server
	port := viper.GetInt("app.port")
	log.Printf("Starting server at port %v\n", port)
	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%v", port), handler))
}
- แต่เนื่องจากเมื่อนำไป deploy ใช้งานจริง ใส่ค่า environment มาในรูปแบบ 
APP_PORTดังนั้นต้องเพิ่ม config ให้แปลงค่า environment ให้ด้วย และต้องไม่panicถ้าไม่ใช้ไฟล์ config 
func init() {
	viper.SetConfigName("config")                          // กำหนดชื่อไฟล์ config (without extension)
	viper.SetConfigType("yaml")                            // ระบุประเภทของไฟล์ config
	viper.AddConfigPath(".")                               // ระบุตำแหน่งของไฟล์ config อยู่ที่ working directory
	viper.AutomaticEnv()                                   // ให้อ่านค่าจาก env มา replace ในไฟล์ config
	viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) // แปลง _ underscore ใน env เป็น . dot notation ใน viper
	err := viper.ReadInConfig() // อ่านไฟล์ config
	if err != nil {             // ถ้าอ่านไฟล์ config ไม่ได้ให้ข้ามไปเพราะให้เอาค่าจาก env มาแทนได้
		fmt.Println("please consider environment variables", err.Error())
	}
}
// APP_PORT=8081 go run .
- เพิ่มกำหนดค่า default เช่น ถ้าไม่ได้ระบุ app.port มา ให้ default เป็น 8080
 
func LoadConfig() {
	viper.SetConfigName("config")                          // กำหนดชื่อไฟล์ config (without extension)
	viper.SetConfigType("yaml")                            // ระบุประเภทของไฟล์ config
	viper.AddConfigPath(".")                               // ระบุตำแหน่งของไฟล์ config อยู่ที่ working directory
	viper.AutomaticEnv()                                   // ให้อ่านค่าจาก env มา replace ในไฟล์ config
	viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) // แปลง _ underscore ใน env เป็น . dot notation ใน viper
	err := viper.ReadInConfig() // อ่านไฟล์ config
	if err != nil {             // ถ้าอ่านไฟล์ config ไม่ได้ให้ข้ามไปเพราะให้เอาค่าจาก env มาแทนได้
		fmt.Println("please consider environment variables", err.Error())
	}
  // กำหนด Default Value
	viper.SetDefault("app.port", 8080)
}
- สามารถสร้าง struct ขึ้นมารับค่า config เพื่อให้สะดวกต่อการใช้งานได้
 
package config
import (
	"fmt"
	"log"
	"strings"
	"github.com/spf13/viper"
)
type configuration struct {
	App appConfig
	Db  dbConfig
}
type appConfig struct {
	Port uint
}
type dbConfig struct {
	Driver   string
	Host     string
	Port     uint
	Username string
	Password string
	Database string
}
var Config *configuration
func LoadConfig() {
	viper.SetConfigName("config")                          // กำหนดชื่อไฟล์ config (without extension)
	viper.SetConfigType("yaml")                            // ระบุประเภทของไฟล์ config
	viper.AddConfigPath(".")                               // ระบุตำแหน่งของไฟล์ config อยู่ที่ working directory
	viper.AutomaticEnv()                                   // ให้อ่านค่าจาก env มา replace ในไฟล์ config
	viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) // แปลง _ underscore ใน env เป็น . dot notation ใน viper
	err := viper.ReadInConfig() // อ่านไฟล์ config
	if err != nil {             // ถ้าอ่านไฟล์ config ไม่ได้ให้ข้ามไปเพราะให้เอาค่าจาก env มาแทนได้
		fmt.Println("please consider environment variables", err.Error())
	}
	// กำหนด Default Value
	viper.SetDefault("app.port", 8080)
	// Decode config ด้วย Unmarshling
	Config = &configuration{}
	err = viper.Unmarshal(Config)
	if err != nil {
		log.Fatalf("unable to decode config into struct, %v", err)
	}
}
- แต่ ณ ตอนที่เขียนบทความนี้ การ Decode config ด้วยการทำ Unmarshling นั้นไม่สามารถใช้ได้กับการอ่านค่า config จากค่า environments ผ่าน 
viper.AutomaticEnv()ดังนั้นเราจะใช้วิธีสร้าง struct Config ขึ้นมาเองแทน 
func LoadConfig() {
	// ...
	// กำหนด Default Value
	viper.SetDefault("app.port", 8080)
	Config = &configuration{
		App: appConfig{
			Port: viper.GetUint("app.port"),
		},
		Db: dbConfig{
			Driver:   viper.GetString("db.driver"),
			Host:     viper.GetString("db.host"),
			Port:     viper.GetUint("db.port"),
			Username: viper.GetString("db.username"),
			Password: viper.GetString("db.password"),
			Database: viper.GetString("db.database"),
		},
  }
  // err = viper.Unmarshal(Config)
	// if err != nil {
	// 	log.Fatalf("unable to decode config into struct, %v", err)
	// }
}
- สุดท้ายให้เพิ่มการตรวจสอบว่าได้กำหนดค่า config มาครบหรือไม่ โดยใช้ validator
 
func LoadConfig() {
  // ...
	// ตรวจสอบว่ากำหนดค่ามาครบหรือไม่
	validate := validator.New()
	err = validate.Struct(Config)
	if err != nil {
		log.Fatalf("load config error, %v", err)
	}
}
- เรียกใช้งาน
 
func main() {
	config.LoadConfig()
	database.ConnectDB()
  // ...
	// starting server
	port := config.Config.App.Port
	log.Printf("Starting server at port %v\n", port)
	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%v", port), handler))
}
สรุป
เมื่อเราเปลี่ยนการกำหนดค่า configurations ทั้งหมดในโปรแกรม มาใช้วิธีอ่านค่าจาก environments ทำให้เราสามารถแยกโค้ดกับ configurations ออกจากกันได้
ดังนั้น เมื่อเรา push โค้ดขึ้น git จะทำให้ข้อมูลที่เป็นความลับ เช่น apikey หรือ database password จะไม่หลุดออกไป
และทำให้การเปลี่ยนแปลงค่า configurations เมื่อนำไป deploy สามารถแก้ไขได้อิสระ โดยไม่ต้องมาแก้ไขโค้ดของเรา