IoT Workshop #4: Bootstrap Go Fiber ให้ครบเครื่อง
Branch:
workshop/dev-01-fiber-bootstrapPhase: 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 ก็คือการแบ่งหน้าที่ให้ชัดเจนเหมือนกัน:
- 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)
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 — มาลุยกันต่อ!