Published on

API Service with Go: Configuration

Authors

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 และแปลงชนิดข้อมูล

util/env.go
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
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")
main.go
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
config/config.go
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
config/config.go
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 เพื่อให้สะดวกต่อการใช้งานได้
config/config.go
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 ขึ้นมาเองแทน
config/config.go
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
config/config.go
func LoadConfig() {
  // ...

	// ตรวจสอบว่ากำหนดค่ามาครบหรือไม่
	validate := validator.New()

	err = validate.Struct(Config)
	if err != nil {
		log.Fatalf("load config error, %v", err)
	}
}
  • เรียกใช้งาน
main.go
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 สามารถแก้ไขได้อิสระ โดยไม่ต้องมาแก้ไขโค้ดของเรา