IoT Workshop #4: Bootstrap Go Fiber ให้ครบเครื่อง

IoT Workshop #4: Bootstrap Go Fiber ให้ครบเครื่อง

Showkhun · Workshop ·

Branch: workshop/dev-01-fiber-bootstrap Phase: Development — Backend Foundation Repository: https://github.com/kangana1024/iot-workshop


เพื่อนๆ เคยสร้าง backend แล้วรู้สึกว่า “โค้ดมันพันกันไปหมด” ไหม? แก้ตรงนี้พัง ตรงโน้น เพิ่มฟีเจอร์ใหม่ทีก็กลัวว่าของเก่าจะพัง วันนี้เราจะมาวางรากฐาน Backend ของ IoT Platform ให้แข็งแกร่งตั้งแต่ต้น ด้วย Go Fiber + Clean Architecture ที่อ่านง่าย ทดสอบง่าย และขยายได้ในอนาคต มาลุยกันเลย! ( ง •̀_•́)ง


สิ่งที่น้องๆ จะได้กลับบ้าน

  • ติดตั้งและตั้งค่าโปรเจกต์ Go Fiber ตั้งแต่ศูนย์
  • จัดโครงสร้างแบบ Clean Architecture (แยก Layer ชัดเจน)
  • ตั้งค่า Middleware ครบชุด: CORS, Logger, Recover, RequestID, Rate Limiter
  • จัดการ Config ด้วย Viper รองรับทั้ง YAML และ ENV vars
  • สร้าง Health Check Endpoint ที่บอกสถานะ dependencies
  • ตั้งค่า Graceful Shutdown ที่รอ request ปัจจุบันจบก่อนปิดเซิร์ฟเวอร์
  • Hot Reload ด้วย Air เพื่อ dev ได้ไว

Prerequisites

ก่อนเริ่มเช็คกันก่อนนะว่ามีครบไหม:

  • Go 1.21+ ติดตั้งแล้ว
  • Docker และ Docker Compose
  • IDE (VSCode หรือ GoLand แนะนำ)
  • ความรู้พื้นฐาน Go

ทำไมต้อง Clean Architecture?

ลองนึกภาพร้านอาหารดูนะ ถ้าพ่อครัวต้องวิ่งไปรับออเดอร์เอง เก็บเงินเอง ล้างจานเอง ด้วย — มันก็ทำงานได้ แต่วุ่นวายมาก พอร้านโตขึ้น จะจ้างคนเพิ่มก็ยากเพราะทุกอย่างอยู่ที่คนเดียว

Clean Architecture ก็คือการแบ่งหน้าที่ให้ชัดเจนเหมือนกัน:

Mermaid Diagram

  • Handler รู้แค่ HTTP — รับ request ส่ง response ไม่ยุ่งกับ DB
  • Usecase รู้แค่ Business Logic — ไม่รู้ว่า request มาจาก HTTP หรือ gRPC
  • Repository รู้แค่ Database — ไม่รู้ว่าใครเรียก

ผลลัพธ์คือ เปลี่ยน DB? แก้แค่ Repository. เปลี่ยน Framework? แก้แค่ Handler. ง่ายสุดๆ


โครงสร้างโปรเจกต์

iot-backend/
├── cmd/
│   └── server/
│       └── main.go          # Entry point
├── internal/
│   ├── config/
│   │   └── config.go        # App configuration
│   ├── domain/
│   │   ├── device.go        # Device entity
│   │   ├── user.go          # User entity
│   │   └── sensor.go        # Sensor data entity
│   ├── repository/
│   │   ├── device_repo.go   # Device repository interface
│   │   └── mongo/           # MongoDB implementations
│   ├── usecase/
│   │   └── device_usecase.go
│   ├── handler/
│   │   ├── device_handler.go
│   │   └── health_handler.go
│   └── middleware/
│       ├── auth.go
│       └── logger.go
├── pkg/
│   ├── logger/
│   │   └── logger.go        # Structured logger
│   ├── validator/
│   │   └── validator.go     # Request validator
│   └── response/
│       └── response.go      # Standard response helpers
├── configs/
│   ├── config.yaml          # Default config
│   └── config.local.yaml    # Local overrides (gitignored)
├── .air.toml                # Air hot-reload config
├── Dockerfile
├── docker-compose.yml
└── go.mod

กฎสำคัญของ Clean Architecture คือ dependency ชี้ไปทิศทางเดียว: Handler → Usecase → Repository → Database ห้ามย้อนกลับ!


ขั้นตอนที่ 1: เริ่มต้นโปรเจกต์

สร้าง Go Module

mkdir iot-backend && cd iot-backend
go mod init github.com/kangana1024/iot-workshop/backend

ติดตั้ง Dependencies

เราต้องการ library หลายตัว แต่ละตัวมีเหตุผล:

  • Fiber v2 — HTTP framework ที่เร็วมาก เร็วกว่า Express.js ของ Node
  • Viper — อ่าน config จากไฟล์ YAML และ ENV vars ได้พร้อมกัน
  • MongoDB + InfluxDB drivers — สำหรับเชื่อมต่อ Database
  • Paho MQTT — รับส่งข้อมูล IoT
  • Zap Logger — log แบบ structured ที่ production-ready
# Fiber v2 — HTTP framework
go get github.com/gofiber/fiber/v2

# Viper — Config management
go get github.com/spf13/viper

# MongoDB Go Driver
go get go.mongodb.org/mongo-driver/mongo

# InfluxDB Go Client v2
go get github.com/influxdata/influxdb-client-go/v2

# Eclipse Paho MQTT
go get github.com/eclipse/paho.mqtt.golang

# Validator
go get github.com/go-playground/validator/v10

# JWT
go get github.com/golang-jwt/jwt/v5

# Zap Logger
go get go.uber.org/zap

# UUID
go get github.com/google/uuid

# Godotenv (fallback)
go get github.com/joho/godotenv

ขั้นตอนที่ 2: Configuration ด้วย Viper

ทำไมต้องมี Config Management?

ลองนึกว่าเราฮาร์ดโค้ด database URL ในโค้ด แล้ววันนึงต้อง deploy ขึ้น production URL ก็เปลี่ยน — ต้องแก้โค้ด re-build ใหม่ทุกครั้ง ยุ่งมาก

Viper แก้ปัญหานี้ด้วยการอ่าน config จากหลายแหล่งพร้อมกัน แล้ว merge กัน:

config.yaml (ค่าตั้งต้น)
    +
config.local.yaml (override สำหรับ local dev)
    +
ENV vars (override สำหรับ production)
    =
Config ที่ใช้จริง

configs/config.yaml

app:
  name: "IoT Workshop API"
  version: "1.0.0"
  env: "development"
  port: 8080
  debug: true

server:
  read_timeout: 30s
  write_timeout: 30s
  idle_timeout: 120s
  shutdown_timeout: 10s
  body_limit: "10MB"

mongodb:
  uri: "mongodb://localhost:27017"
  database: "iot_workshop"
  timeout: 10s
  pool_size: 10
  min_pool_size: 2

influxdb:
  url: "http://localhost:8086"
  token: "my-super-secret-token"
  org: "iot-workshop"
  bucket: "sensor_data"

mqtt:
  broker: "tcp://localhost:1883"
  client_id: "iot-backend"
  username: ""
  password: ""
  qos: 1
  keep_alive: 60s
  connect_timeout: 10s

jwt:
  secret: "change-this-secret-in-production"
  expire_hours: 24
  refresh_expire_hours: 168

cors:
  allowed_origins:
    - "http://localhost:3000"
    - "http://localhost:5173"
  allowed_methods:
    - "GET"
    - "POST"
    - "PUT"
    - "PATCH"
    - "DELETE"
    - "OPTIONS"

log:
  level: "debug"
  format: "console"  # console | json
  output: "stdout"

internal/config/config.go

เหตุผลที่เราใช้ struct แทน map คือ type safety — ถ้าพิมพ์ชื่อ field ผิด compiler จะบอกทันที แทนที่จะรู้ตอน runtime

package config

import (
	"fmt"
	"strings"
	"time"

	"github.com/spf13/viper"
)

// Config holds all application configuration
type Config struct {
	App      AppConfig      `mapstructure:"app"`
	Server   ServerConfig   `mapstructure:"server"`
	MongoDB  MongoDBConfig  `mapstructure:"mongodb"`
	InfluxDB InfluxDBConfig `mapstructure:"influxdb"`
	MQTT     MQTTConfig     `mapstructure:"mqtt"`
	JWT      JWTConfig      `mapstructure:"jwt"`
	CORS     CORSConfig     `mapstructure:"cors"`
	Log      LogConfig      `mapstructure:"log"`
}

type AppConfig struct {
	Name    string `mapstructure:"name"`
	Version string `mapstructure:"version"`
	Env     string `mapstructure:"env"`
	Port    int    `mapstructure:"port"`
	Debug   bool   `mapstructure:"debug"`
}

type ServerConfig struct {
	ReadTimeout     time.Duration `mapstructure:"read_timeout"`
	WriteTimeout    time.Duration `mapstructure:"write_timeout"`
	IdleTimeout     time.Duration `mapstructure:"idle_timeout"`
	ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"`
	BodyLimit       string        `mapstructure:"body_limit"`
}

type MongoDBConfig struct {
	URI         string        `mapstructure:"uri"`
	Database    string        `mapstructure:"database"`
	Timeout     time.Duration `mapstructure:"timeout"`
	PoolSize    uint64        `mapstructure:"pool_size"`
	MinPoolSize uint64        `mapstructure:"min_pool_size"`
}

type InfluxDBConfig struct {
	URL    string `mapstructure:"url"`
	Token  string `mapstructure:"token"`
	Org    string `mapstructure:"org"`
	Bucket string `mapstructure:"bucket"`
}

type MQTTConfig struct {
	Broker         string        `mapstructure:"broker"`
	ClientID       string        `mapstructure:"client_id"`
	Username       string        `mapstructure:"username"`
	Password       string        `mapstructure:"password"`
	QoS            byte          `mapstructure:"qos"`
	KeepAlive      time.Duration `mapstructure:"keep_alive"`
	ConnectTimeout time.Duration `mapstructure:"connect_timeout"`
}

type JWTConfig struct {
	Secret             string `mapstructure:"secret"`
	ExpireHours        int    `mapstructure:"expire_hours"`
	RefreshExpireHours int    `mapstructure:"refresh_expire_hours"`
}

type CORSConfig struct {
	AllowedOrigins []string `mapstructure:"allowed_origins"`
	AllowedMethods []string `mapstructure:"allowed_methods"`
}

type LogConfig struct {
	Level  string `mapstructure:"level"`
	Format string `mapstructure:"format"`
	Output string `mapstructure:"output"`
}

// Load reads configuration from file and environment variables
func Load() (*Config, error) {
	v := viper.New()

	// Config file settings
	v.SetConfigName("config")
	v.SetConfigType("yaml")
	v.AddConfigPath("./configs")
	v.AddConfigPath(".")

	// Environment variable settings
	// ENV vars override file config: APP_PORT=9090 overrides app.port
	v.SetEnvPrefix("IOT")
	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
	v.AutomaticEnv()

	// Read config file
	if err := v.ReadInConfig(); err != nil {
		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
			return nil, fmt.Errorf("failed to read config file: %w", err)
		}
		// Config file not found — use defaults and env vars
	}

	// Try to read local override config
	v.SetConfigName("config.local")
	_ = v.MergeInConfig()

	var cfg Config
	if err := v.Unmarshal(&cfg); err != nil {
		return nil, fmt.Errorf("failed to unmarshal config: %w", err)
	}

	return &cfg, nil
}

// IsDevelopment returns true when running in development mode
func (c *Config) IsDevelopment() bool {
	return c.App.Env == "development"
}

// IsProduction returns true when running in production mode
func (c *Config) IsProduction() bool {
	return c.App.Env == "production"
}

// ServerAddress returns the formatted server listen address
func (c *Config) ServerAddress() string {
	return fmt.Sprintf(":%d", c.App.Port)
}

ขั้นตอนที่ 3: Structured Logger

ทำไมต้อง Structured Logger?

fmt.Println("error!") มันใช้งานได้ แต่ใน production เราต้องการ log ที่:

  • searchable — ค้นหาได้ด้วย request_id หรือ user_id
  • machine-readable — ส่งเข้า ELK Stack หรือ Grafana Loki ได้
  • structured — แต่ละ field แยกกันชัดเจน

Zap คือ logger ที่เร็วที่สุดใน Go ecosystem เพราะหลีกเลี่ยง reflection และ memory allocation

pkg/logger/logger.go

package logger

import (
	"os"

	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
)

// New creates a new zap logger based on config
func New(level, format, output string) (*zap.Logger, error) {
	// Parse log level
	var zapLevel zapcore.Level
	if err := zapLevel.UnmarshalText([]byte(level)); err != nil {
		zapLevel = zapcore.InfoLevel
	}

	// Encoder config
	encoderCfg := zap.NewProductionEncoderConfig()
	encoderCfg.TimeKey = "timestamp"
	encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
	encoderCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder

	// Choose encoder
	var encoder zapcore.Encoder
	if format == "json" {
		encoderCfg.EncodeLevel = zapcore.LowercaseLevelEncoder
		encoder = zapcore.NewJSONEncoder(encoderCfg)
	} else {
		encoder = zapcore.NewConsoleEncoder(encoderCfg)
	}

	// Choose output
	var writeSyncer zapcore.WriteSyncer
	if output == "stderr" {
		writeSyncer = zapcore.AddSync(os.Stderr)
	} else {
		writeSyncer = zapcore.AddSync(os.Stdout)
	}

	core := zapcore.NewCore(encoder, writeSyncer, zapLevel)
	logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))

	return logger, nil
}

ขั้นตอนที่ 4: Standard Response Helper

ทำไมต้องมี Response Helper?

ลองนึกถึงการไปร้านอาหาร 5 ร้าน แต่ละร้านเสิร์ฟแบบต่างกันหมด — ร้านหนึ่งใส่จาน ร้านหนึ่งใส่กล่อง ร้านหนึ่งวางบนใบตอง คนที่รับออเดอร์ก็งงว่าอันไหนคืออะไร

Response helper คือการกำหนด “มาตรฐานรูปแบบจาน” ที่ทุก endpoint ใช้เหมือนกัน ไม่ว่าจะสำเร็จหรือ error:

// สำเร็จ
{ "success": true, "data": {...} }

// error
{ "success": false, "error": { "code": "NOT_FOUND", "message": "..." } }

pkg/response/response.go

package response

import "github.com/gofiber/fiber/v2"

// Response is the standard API response envelope
type Response struct {
	Success bool        `json:"success"`
	Message string      `json:"message,omitempty"`
	Data    interface{} `json:"data,omitempty"`
	Error   *ErrorInfo  `json:"error,omitempty"`
	Meta    *Meta       `json:"meta,omitempty"`
}

// ErrorInfo contains structured error details
type ErrorInfo struct {
	Code    string      `json:"code"`
	Message string      `json:"message"`
	Details interface{} `json:"details,omitempty"`
}

// Meta contains pagination info
type Meta struct {
	Page       int   `json:"page"`
	Limit      int   `json:"limit"`
	Total      int64 `json:"total"`
	TotalPages int   `json:"total_pages"`
}

// OK sends a successful response with data
func OK(c *fiber.Ctx, data interface{}) error {
	return c.Status(fiber.StatusOK).JSON(Response{
		Success: true,
		Data:    data,
	})
}

// OKWithMessage sends a successful response with message
func OKWithMessage(c *fiber.Ctx, message string, data interface{}) error {
	return c.Status(fiber.StatusOK).JSON(Response{
		Success: true,
		Message: message,
		Data:    data,
	})
}

// Created sends a 201 Created response
func Created(c *fiber.Ctx, data interface{}) error {
	return c.Status(fiber.StatusCreated).JSON(Response{
		Success: true,
		Data:    data,
	})
}

// Paginated sends a paginated list response
func Paginated(c *fiber.Ctx, data interface{}, meta *Meta) error {
	return c.Status(fiber.StatusOK).JSON(Response{
		Success: true,
		Data:    data,
		Meta:    meta,
	})
}

// BadRequest sends a 400 response
func BadRequest(c *fiber.Ctx, code, message string, details interface{}) error {
	return c.Status(fiber.StatusBadRequest).JSON(Response{
		Success: false,
		Error: &ErrorInfo{
			Code:    code,
			Message: message,
			Details: details,
		},
	})
}

// Unauthorized sends a 401 response
func Unauthorized(c *fiber.Ctx, message string) error {
	return c.Status(fiber.StatusUnauthorized).JSON(Response{
		Success: false,
		Error: &ErrorInfo{
			Code:    "UNAUTHORIZED",
			Message: message,
		},
	})
}

// NotFound sends a 404 response
func NotFound(c *fiber.Ctx, resource string) error {
	return c.Status(fiber.StatusNotFound).JSON(Response{
		Success: false,
		Error: &ErrorInfo{
			Code:    "NOT_FOUND",
			Message: resource + " not found",
		},
	})
}

// InternalError sends a 500 response
func InternalError(c *fiber.Ctx, message string) error {
	return c.Status(fiber.StatusInternalServerError).JSON(Response{
		Success: false,
		Error: &ErrorInfo{
			Code:    "INTERNAL_ERROR",
			Message: message,
		},
	})
}

ขั้นตอนที่ 5: Middleware Setup

Middleware คืออะไร? ทำไมต้องมี?

Middleware เหมือนกับพนักงานรักษาความปลอดภัยที่ยืนอยู่หน้าประตูร้านอาหาร ทุก request ที่เข้ามาต้องผ่านเขาก่อน เขาจะ:

  • ตรวจดูว่ามี ID ไหม (RequestID)
  • เช็คว่ามาจากโดเมนที่อนุญาตไหม (CORS)
  • จดบันทึกว่าใครเข้ามา (Logger)
  • จับ panic ไม่ให้แอปพังทั้งหมด (Recover)
  • กัน DDoS ด้วยการจำกัดจำนวน request (Rate Limiter)

Mermaid Diagram

internal/middleware/middleware.go

package middleware

import (
	"strings"
	"time"

	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/fiber/v2/middleware/cors"
	"github.com/gofiber/fiber/v2/middleware/limiter"
	"github.com/gofiber/fiber/v2/middleware/logger"
	"github.com/gofiber/fiber/v2/middleware/recover"
	"github.com/gofiber/fiber/v2/middleware/requestid"
	"go.uber.org/zap"

	"github.com/kangana1024/iot-workshop/backend/internal/config"
)

// Setup registers all global middleware on the Fiber app
func Setup(app *fiber.App, cfg *config.Config, log *zap.Logger) {
	// 1. Request ID — adds X-Request-ID header to every request
	app.Use(requestid.New(requestid.Config{
		Header: fiber.HeaderXRequestID,
		Generator: func() string {
			return generateRequestID()
		},
	}))

	// 2. Recover — catches panics and returns 500
	app.Use(recover.New(recover.Config{
		EnableStackTrace: cfg.IsDevelopment(),
		StackTraceHandler: func(c *fiber.Ctx, e interface{}) {
			log.Error("panic recovered",
				zap.Any("error", e),
				zap.String("path", c.Path()),
				zap.String("method", c.Method()),
			)
		},
	}))

	// 3. CORS — configure allowed origins
	app.Use(cors.New(cors.Config{
		AllowOrigins:     strings.Join(cfg.CORS.AllowedOrigins, ","),
		AllowMethods:     strings.Join(cfg.CORS.AllowedMethods, ","),
		AllowHeaders:     "Origin, Content-Type, Accept, Authorization, X-Request-ID",
		ExposeHeaders:    "X-Request-ID",
		AllowCredentials: true,
		MaxAge:           86400,
	}))

	// 4. Request Logger — structured request logging
	app.Use(logger.New(logger.Config{
		Format:     "[${time}] ${status} - ${latency} ${method} ${path} | ${ip} | ReqID: ${locals:requestid}\n",
		TimeFormat: "2006-01-02T15:04:05",
		TimeZone:   "Asia/Bangkok",
		Done: func(c *fiber.Ctx, logString []byte) {
			if c.Response().StatusCode() >= 500 {
				log.Error("request error",
					zap.String("method", c.Method()),
					zap.String("path", c.Path()),
					zap.Int("status", c.Response().StatusCode()),
					zap.String("request_id", c.Locals("requestid").(string)),
				)
			}
		},
	}))

	// 5. Rate Limiter — 100 requests per minute per IP
	app.Use(limiter.New(limiter.Config{
		Max:               100,
		Expiration:        1 * time.Minute,
		LimiterMiddleware: limiter.SlidingWindow{},
		KeyGenerator: func(c *fiber.Ctx) string {
			return c.IP()
		},
		LimitReached: func(c *fiber.Ctx) error {
			return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
				"success": false,
				"error": fiber.Map{
					"code":    "RATE_LIMIT_EXCEEDED",
					"message": "Too many requests, please slow down",
				},
			})
		},
	}))
}

func generateRequestID() string {
	// Use UUID v4 for request IDs
	// In production use github.com/google/uuid
	return time.Now().Format("20060102150405.000000000")
}

ขั้นตอนที่ 6: Health Check Handler

ทำไม Health Check ถึงสำคัญ?

ลองนึกว่าเรามีเซิร์ฟเวอร์ 10 ตัวอยู่หลัง Load Balancer — ถ้าตัวหนึ่งพัง Load Balancer จะรู้ได้ยังไงว่าอย่าส่ง traffic ไปที่ตัวนั้น? คำตอบคือ Health Check!

Kubernetes, Docker Swarm, หรือแม้แต่ AWS ELB จะ ping /health ทุกไม่กี่วินาที ถ้าได้ 200 OK = ตัวนี้ OK ถ้าได้ 503 = เอาออกจาก pool ชั่วคราว

internal/handler/health_handler.go

package handler

import (
	"runtime"
	"time"

	"github.com/gofiber/fiber/v2"
	"go.mongodb.org/mongo-driver/mongo"
)

var startTime = time.Now()

// HealthHandler handles health check requests
type HealthHandler struct {
	mongoClient *mongo.Client
	appVersion  string
}

// NewHealthHandler creates a new HealthHandler
func NewHealthHandler(mongoClient *mongo.Client, appVersion string) *HealthHandler {
	return &HealthHandler{
		mongoClient: mongoClient,
		appVersion:  appVersion,
	}
}

// HealthResponse represents the health check response
type HealthResponse struct {
	Status    string            `json:"status"`
	Version   string            `json:"version"`
	Uptime    string            `json:"uptime"`
	Timestamp string            `json:"timestamp"`
	Checks    map[string]string `json:"checks"`
	Memory    MemoryStats       `json:"memory"`
}

// MemoryStats contains Go runtime memory statistics
type MemoryStats struct {
	Alloc      uint64 `json:"alloc_mb"`
	TotalAlloc uint64 `json:"total_alloc_mb"`
	Sys        uint64 `json:"sys_mb"`
	NumGC      uint32 `json:"num_gc"`
}

// Health godoc
// @Summary      Health check
// @Description  Returns the health status of the API and its dependencies
// @Tags         system
// @Produce      json
// @Success      200  {object}  HealthResponse
// @Router       /health [get]
func (h *HealthHandler) Health(c *fiber.Ctx) error {
	checks := make(map[string]string)

	// Check MongoDB
	ctx := c.Context()
	if err := h.mongoClient.Ping(ctx, nil); err != nil {
		checks["mongodb"] = "unhealthy: " + err.Error()
	} else {
		checks["mongodb"] = "healthy"
	}

	// Determine overall status
	status := "healthy"
	for _, v := range checks {
		if v != "healthy" {
			status = "degraded"
			break
		}
	}

	// Memory stats
	var m runtime.MemStats
	runtime.ReadMemStats(&m)

	uptime := time.Since(startTime).Round(time.Second)

	resp := HealthResponse{
		Status:    status,
		Version:   h.appVersion,
		Uptime:    uptime.String(),
		Timestamp: time.Now().UTC().Format(time.RFC3339),
		Checks:    checks,
		Memory: MemoryStats{
			Alloc:      m.Alloc / 1024 / 1024,
			TotalAlloc: m.TotalAlloc / 1024 / 1024,
			Sys:        m.Sys / 1024 / 1024,
			NumGC:      m.NumGC,
		},
	}

	statusCode := fiber.StatusOK
	if status != "healthy" {
		statusCode = fiber.StatusServiceUnavailable
	}

	return c.Status(statusCode).JSON(resp)
}

// Ready godoc
// @Summary      Readiness check
// @Description  Returns 200 when the service is ready to accept traffic
// @Tags         system
// @Produce      json
// @Success      200
// @Router       /ready [get]
func (h *HealthHandler) Ready(c *fiber.Ctx) error {
	return c.Status(fiber.StatusOK).JSON(fiber.Map{
		"status": "ready",
	})
}

ขั้นตอนที่ 7: Router Setup

internal/router/router.go

เรา group route ตาม resource เหมือนกับการจัดโต๊ะในร้านอาหาร — โต๊ะ devices, โต๊ะ sensors, โต๊ะ auth แยกกันชัดเจน

package router

import (
	"github.com/gofiber/fiber/v2"
	"github.com/kangana1024/iot-workshop/backend/internal/handler"
)

// Setup registers all API routes
func Setup(app *fiber.App, h *handler.Handlers) {
	// System routes (unversioned)
	app.Get("/health", h.Health.Health)
	app.Get("/ready", h.Health.Ready)

	// API v1 group
	v1 := app.Group("/api/v1")

	// Device routes
	devices := v1.Group("/devices")
	devices.Get("/", h.Device.List)
	devices.Post("/", h.Device.Create)
	devices.Get("/:id", h.Device.GetByID)
	devices.Put("/:id", h.Device.Update)
	devices.Delete("/:id", h.Device.Delete)
	devices.Post("/:id/commands", h.Device.SendCommand)

	// Sensor data routes
	sensors := v1.Group("/sensors")
	sensors.Post("/ingest", h.Sensor.Ingest)
	sensors.Post("/ingest/batch", h.Sensor.IngestBatch)
	sensors.Get("/:deviceId/data", h.Sensor.GetData)

	// Auth routes
	auth := v1.Group("/auth")
	auth.Post("/login", h.Auth.Login)
	auth.Post("/refresh", h.Auth.Refresh)
	auth.Post("/logout", h.Auth.Logout)

	// User routes (protected)
	users := v1.Group("/users")
	users.Get("/me", h.Auth.Me)
	users.Put("/me", h.Auth.UpdateMe)
}

ขั้นตอนที่ 8: Main Entry Point + Graceful Shutdown

ทำไมต้อง Graceful Shutdown?

นึกภาพเซิร์ฟเวอร์ที่กำลังประมวลผล request สำคัญอยู่ 100 request แล้วเราดึงปลั๊กออกทันที — request ทั้งหมดพังหมด ข้อมูลอาจเสียหาย

Graceful Shutdown คือการ “ประกาศปิดร้าน” ก่อน — ไม่รับลูกค้าใหม่แล้ว แต่รอให้ลูกค้าที่อยู่ในร้านแล้วทานเสร็จก่อนค่อยปิดประตู

SIGTERM/SIGINT
     |
     v
หยุดรับ request ใหม่
     |
     v
รอ request ที่ค้างอยู่ (timeout: 10s)
     |
     v
ปิด DB connections
     |
     v
Exit

cmd/server/main.go

package main

import (
	"context"
	"fmt"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/gofiber/fiber/v2"
	"go.uber.org/zap"

	"github.com/kangana1024/iot-workshop/backend/internal/config"
	"github.com/kangana1024/iot-workshop/backend/internal/handler"
	"github.com/kangana1024/iot-workshop/backend/internal/middleware"
	"github.com/kangana1024/iot-workshop/backend/internal/router"
	"github.com/kangana1024/iot-workshop/backend/pkg/logger"
)

func main() {
	// Load configuration
	cfg, err := config.Load()
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
		os.Exit(1)
	}

	// Initialize logger
	log, err := logger.New(cfg.Log.Level, cfg.Log.Format, cfg.Log.Output)
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to initialize logger: %v\n", err)
		os.Exit(1)
	}
	defer log.Sync()

	log.Info("Starting IoT Workshop API",
		zap.String("version", cfg.App.Version),
		zap.String("env", cfg.App.Env),
		zap.Int("port", cfg.App.Port),
	)

	// Create Fiber app with custom config
	app := fiber.New(fiber.Config{
		AppName:       cfg.App.Name,
		ServerHeader:  "IoT-Workshop",
		StrictRouting: false,
		CaseSensitive: false,
		BodyLimit:     10 * 1024 * 1024, // 10MB
		ReadTimeout:   cfg.Server.ReadTimeout,
		WriteTimeout:  cfg.Server.WriteTimeout,
		IdleTimeout:   cfg.Server.IdleTimeout,
		// Custom error handler
		ErrorHandler: func(c *fiber.Ctx, err error) error {
			code := fiber.StatusInternalServerError
			if e, ok := err.(*fiber.Error); ok {
				code = e.Code
			}
			log.Error("unhandled error",
				zap.Error(err),
				zap.String("path", c.Path()),
				zap.String("method", c.Method()),
			)
			return c.Status(code).JSON(fiber.Map{
				"success": false,
				"error": fiber.Map{
					"code":    "INTERNAL_ERROR",
					"message": err.Error(),
				},
			})
		},
	})

	// Setup middleware
	middleware.Setup(app, cfg, log)

	// Initialize dependencies (MongoDB, InfluxDB, MQTT)
	// These will be added in subsequent workshops
	// For now, pass nil to show structure
	handlers := &handler.Handlers{
		Health: handler.NewHealthHandler(nil, cfg.App.Version),
		// Device, Sensor, Auth handlers added later
	}

	// Setup routes
	router.Setup(app, handlers)

	// 404 handler
	app.Use(func(c *fiber.Ctx) error {
		return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
			"success": false,
			"error": fiber.Map{
				"code":    "NOT_FOUND",
				"message": fmt.Sprintf("Route %s %s not found", c.Method(), c.Path()),
			},
		})
	})

	// Start server in goroutine
	serverErrCh := make(chan error, 1)
	go func() {
		addr := cfg.ServerAddress()
		log.Info("Server listening", zap.String("address", addr))
		if err := app.Listen(addr); err != nil {
			serverErrCh <- err
		}
	}()

	// Wait for interrupt signal for graceful shutdown
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, os.Interrupt, syscall.SIGTERM)

	select {
	case err := <-serverErrCh:
		log.Error("Server error", zap.Error(err))
	case sig := <-quit:
		log.Info("Received shutdown signal", zap.String("signal", sig.String()))
	}

	// Graceful shutdown
	log.Info("Shutting down server...")
	shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout)
	defer cancel()

	if err := app.ShutdownWithContext(shutdownCtx); err != nil {
		log.Error("Server forced to shutdown", zap.Error(err))
	}

	log.Info("Server shutdown complete")
}

ขั้นตอนที่ 9: Hot Reload ด้วย Air

ทำไมต้อง Hot Reload?

ถ้าไม่มี Air ทุกครั้งที่แก้โค้ด เราต้อง Ctrl+C แล้ว go run ใหม่ เสียเวลามาก Air จะ watch ไฟล์ให้ แล้ว rebuild+restart ให้อัตโนมัติ เหมือน nodemon ใน Node.js นั่นแหละ

.air.toml

root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"

[build]
  args_bin = []
  bin = "./tmp/main"
  cmd = "go build -o ./tmp/main ./cmd/server/main.go"
  delay = 1000
  exclude_dir = ["assets", "tmp", "vendor", "testdata", "docs"]
  exclude_file = []
  exclude_regex = ["_test.go"]
  exclude_unchanged = false
  follow_symlink = false
  full_bin = ""
  include_dir = []
  include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml"]
  include_file = []
  kill_delay = "0s"
  log = "build-errors.log"
  poll = false
  poll_interval = 0
  rerun = false
  rerun_delay = 500
  send_interrupt = false
  stop_on_error = false

[color]
  app = ""
  build = "yellow"
  main = "magenta"
  runner = "green"
  watcher = "cyan"

[log]
  main_only = false
  time = false

[misc]
  clean_on_exit = false

[screen]
  clear_on_rebuild = true
  keep_scroll = true

ติดตั้งและรัน Air

# ติดตั้ง Air
go install github.com/cosmtrek/air@latest

# รัน hot reload
air

# หรือรันโดยตรงโดยไม่ใช้ Air
go run ./cmd/server/main.go

ขั้นตอนที่ 10: Docker Setup

ทำไมต้อง Multi-stage Dockerfile?

Go compile ได้เป็น binary เดี่ยวๆ ที่ไม่ต้องการ runtime เพิ่มเติม เราจึงใช้ multi-stage build — ขั้นแรกใช้ image ใหญ่ที่มี Go tools ครบ แต่ขั้นสุดท้ายใช้แค่ Alpine ที่เล็กมาก ผลลัพธ์คือ production image ขนาดแค่ ~15MB แทนที่จะเป็น ~800MB!

Dockerfile

# Build stage
FROM golang:1.21-alpine AS builder

RUN apk add --no-cache git ca-certificates tzdata

WORKDIR /app

# Copy and download dependencies
COPY go.mod go.sum ./
RUN go mod download

# Copy source code
COPY . .

# Build binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-w -s" -o /app/server ./cmd/server/main.go

# Final stage
FROM alpine:3.19

RUN apk add --no-cache ca-certificates tzdata

ENV TZ=Asia/Bangkok

WORKDIR /app

COPY --from=builder /app/server .
COPY --from=builder /app/configs ./configs

EXPOSE 8080

USER nobody:nobody

ENTRYPOINT ["/app/server"]

docker-compose.yml

version: '3.8'

services:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      - IOT_APP_ENV=development
      - IOT_MONGODB_URI=mongodb://mongo:27017
      - IOT_INFLUXDB_URL=http://influxdb:8086
      - IOT_MQTT_BROKER=tcp://mosquitto:1883
    depends_on:
      - mongo
      - influxdb
      - mosquitto
    restart: unless-stopped

  mongo:
    image: mongo:7.0
    ports:
      - "27017:27017"
    volumes:
      - mongo_data:/data/db
    restart: unless-stopped

  influxdb:
    image: influxdb:2.7
    ports:
      - "8086:8086"
    environment:
      - DOCKER_INFLUXDB_INIT_MODE=setup
      - DOCKER_INFLUXDB_INIT_USERNAME=admin
      - DOCKER_INFLUXDB_INIT_PASSWORD=password123
      - DOCKER_INFLUXDB_INIT_ORG=iot-workshop
      - DOCKER_INFLUXDB_INIT_BUCKET=sensor_data
      - DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=my-super-secret-token
    volumes:
      - influxdb_data:/var/lib/influxdb2
    restart: unless-stopped

  mosquitto:
    image: eclipse-mosquitto:2
    ports:
      - "1883:1883"
      - "9001:9001"
    volumes:
      - ./configs/mosquitto:/mosquitto/config
      - mosquitto_data:/mosquitto/data
    restart: unless-stopped

volumes:
  mongo_data:
  influxdb_data:
  mosquitto_data:

ทดสอบ API กัน!

รัน Server

# Development mode with hot reload
air

# หรือ run ตรง
go run ./cmd/server/main.go

# Docker
docker-compose up --build

ทดสอบ Health Check

# Health check
curl -s http://localhost:8080/health | jq .

# Expected output:
# {
#   "status": "healthy",
#   "version": "1.0.0",
#   "uptime": "5s",
#   "timestamp": "2026-03-26T10:00:00Z",
#   "checks": {
#     "mongodb": "healthy"
#   },
#   "memory": {
#     "alloc_mb": 8,
#     "total_alloc_mb": 10,
#     "sys_mb": 22,
#     "num_gc": 1
#   }
# }

# Readiness check
curl -s http://localhost:8080/ready | jq .

# Test 404
curl -s http://localhost:8080/api/v1/not-found | jq .

ถ้าเห็น "status": "healthy" แสดงว่าเราทำสำเร็จแล้ว! (^_^)v


สรุปสิ่งที่เราได้ทำวันนี้

╔══════════════════════════════════════════════╗
║   IoT Backend Foundation — COMPLETE!  ✓     ║
╠══════════════════════════════════════════════╣
║  Clean Architecture      ✓ แยก Layer ชัดเจน ║
║  Config with Viper       ✓ YAML + ENV vars  ║
║  Middleware Suite        ✓ ครบ 5 ตัว        ║
║  Health Check            ✓ /health /ready   ║
║  Graceful Shutdown       ✓ รอ request จบ    ║
║  Hot Reload (Air)        ✓ dev ไวขึ้น       ║
║  Docker + Compose        ✓ พร้อม deploy     ║
╚══════════════════════════════════════════════╝

รากฐานที่แข็งแกร่งวันนี้ จะทำให้เราเพิ่ม feature ใหม่ในตอนต่อๆ ไปได้อย่างมั่นใจ ไม่ต้องกลัวว่าจะพังของเดิม เพราะ layer แต่ละชั้นแยกจากกันชัดเจน


Next Step

ตอนหน้าเราจะมาเชื่อม MongoDB จริงๆ กัน สร้าง Models, Repository Pattern, และ CRUD operations สำหรับ Device — มาลุยกันต่อ!