IoT Workshop #4: วาง Foundation ด้วย Go + Fiber

IoT Workshop #4: วาง Foundation ด้วย Go + Fiber

ShowkhunWorkshop

IoT Workshop #4: วาง Foundation ด้วย Go + Fiber

Branch: step-01-foundation Phase: Development (1/9) — Backend Foundation Repo: kangana1024/showkhun-workshop


เคยสร้าง backend แล้วรู้สึกไหมครับว่า “โค้ดมันพันกันไปหมด” แก้ตรงนี้พังตรงโน้น พอจะเพิ่ม feature ทีก็กลัวของเก่าพัง? พี่โชว์ผ่านมาแล้วทั้งนั้น และบทเรียนที่ได้คือ — รากฐานที่ดีไม่ใช่การ copy โค้ดมาเยอะๆ แต่คือการเริ่มจาก skeleton ที่บางที่สุดที่ “รันได้จริง” แล้วค่อยๆ ต่อยอด วันนี้เราจะวาง foundation ของ IoT backend กันด้วย Go + Fiber แบบที่ go run แล้วได้ pong กลับมาจริงๆ ในตอนแรกนี้เลย ( ง •̀_•́)ง

ทำไม skeleton ถึงบางขนาดนี้? ตอนวาง draft แรกพี่โชว์เคยร่างโครงเป็น Clean Architecture เต็มสูบ — domain, usecase, repository, pkg/response, config YAML, JWT, MQTT ครบ ตั้งแต่ตอนที่ 1 แต่พอลงมือทำจริงกลับพบว่ามัน เยอะเกินไปสำหรับ step แรก น้องๆ จะงงว่าชั้นไหนทำอะไร เลยตัดสินใจใหม่: step-01 มีแค่สิ่งที่ “จำเป็นต้องบูต server ให้ขึ้น” เท่านั้น แล้วของอย่าง MongoDB, device API, alerting จะค่อยๆ ต่อใน step ถัดๆ ไป บทความนี้อ้างอิงโค้ดจริงใน branch step-01-foundation ทั้งหมด


สิ่งที่จะได้เรียนรู้

  • ตั้งโปรเจกต์ Go + Fiber แบบ monorepo ที่ go run ./cmd/server แล้วใช้ได้เลย
  • อ่าน config จาก environment variables ด้วย Viper (prefix APP_) — ไม่มีไฟล์ YAML ให้สับสน
  • ทำ structured logging ด้วย zap แยก dev/prod ให้อัตโนมัติ
  • เรียง middleware ให้ถูกลำดับ: recover → request id → logger → CORS
  • ทำ health check ที่ /healthz และ /api/v1/ping
  • ตั้ง graceful shutdown ที่รอ request ปัจจุบันจบก่อนค่อยปิด
  • Build เป็น distroless image เล็กๆ ที่รันด้วย non-root user

โครงสร้างโปรเจกต์ — เริ่มจากเท่าที่จำเป็น

ก่อนอื่นมาดู layout จริงของ step-01 กันครับ จะเห็นว่า backend อยู่ในโฟลเดอร์ backend/ ของ monorepo (ข้างๆ กันจะมี infra/ ที่เก็บ docker-compose ของ MongoDB, Mosquitto, InfluxDB, Telegraf):

showkhun-workshop/
├── backend/
│   ├── cmd/
│   │   └── server/
│   │       └── main.go          # entry point + graceful shutdown
│   ├── internal/
│   │   ├── config/              # โหลด config จาก env ด้วย Viper
│   │   ├── logger/              # zap structured logger
│   │   ├── middleware/          # request logger (zap)
│   │   ├── health/              # /healthz และ /api/v1/ping
│   │   └── server/              # ประกอบ Fiber app + middleware + routes
│   ├── Dockerfile               # multi-stage → distroless
│   ├── .env.example
│   ├── go.mod
│   └── go.sum
├── infra/                       # docker-compose: MongoDB, Mosquitto, InfluxDB, Telegraf
├── Makefile
└── README.md

ทำไมเป็น monorepo? เพราะ workshop นี้มีทั้ง backend, mobile app และ admin panel การวางทุกอย่างไว้ที่เดียวทำให้เปิด editor ครั้งเดียวเห็นภาพรวมทั้งระบบ ไม่ต้องสลับ repo ไปมา

เรายังไม่มี domain/, usecase/, repository/ ในตอนนี้นะครับ — พวกนั้นจะโผล่มาเมื่อเรา “ต้องใช้จริง” ใน step ถัดไป (ตอนต่อ MongoDB) นี่คือหลักคิดสำคัญ: อย่าสร้าง abstraction ก่อนที่จะมีอะไรให้ abstract


ภาพรวม: request วิ่งผ่านอะไรบ้าง?

ก่อนจะลงโค้ด มาดู flow ของ request หนึ่งก้อนกันก่อน — WHY ก่อน HOW เสมอ

graph LR
    A[📥 HTTP Request] --> B[recover]
    B --> C[request id]
    C --> D[zap logger]
    D --> E[CORS]
    E --> F{route?}
    F -->|/healthz| G[Health handler]
    F -->|/api/v1/ping| H[Ping handler]
    G --> I[📤 JSON Response]
    H --> I

ลองนึกว่า middleware แต่ละตัวเป็น ด่านตรวจที่สนามบิน — request ต้องเดินผ่านทีละด่าน: ด่านแรกคอยจับเหตุฉุกเฉิน (recover), ด่านสองแปะป้ายชื่อ (request id), ด่านสามจดบันทึก (logger), ด่านสี่เช็คว่ามาจากประเทศที่อนุญาต (CORS) แล้วถึงจะได้เข้าไปหา handler


Step 1: Config จาก Environment Variables

ทำไมไม่ใช้ไฟล์ YAML?

เหตุผลคือ 12-Factor App — config ควรมาจาก environment ไม่ใช่ฝังในไฟล์ที่ commit ขึ้น git ลองนึกว่าเราเอา database password ใส่ใน config.yaml แล้วเผลอ push ขึ้น GitHub… จบเลยครับ (╯°□°)╯ การอ่านจาก env ทำให้ secret อยู่นอก source code, deploy ขึ้น production ก็แค่เปลี่ยน env var ไม่ต้อง rebuild

backend/internal/config/config.go ใช้ Viper อ่าน env ที่ขึ้นต้นด้วย APP_ แล้ว map เข้า struct พร้อมตั้ง default ให้บูตได้แม้ไม่ตั้งค่าอะไรเลย:

// Config holds all runtime settings. Values come from APP_-prefixed env vars.
type Config struct {
	Env              string        // development | staging | production
	Host             string
	Port             int
	LogLevel         string        // debug | info | warn | error
	CORSAllowOrigins string        // comma-separated, "*" = all
	ReadTimeout      time.Duration
	WriteTimeout     time.Duration
	ShutdownTimeout  time.Duration
}

func Load() (*Config, error) {
	v := viper.New()

	// ผูกกับ env: APP_PORT, APP_HOST, APP_ENV, ...
	v.SetEnvPrefix("APP")
	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
	v.AutomaticEnv()

	// ถ้ามีไฟล์ .env ในโฟลเดอร์ปัจจุบันก็อ่านด้วย — ไม่มีก็ไม่ error
	v.SetConfigName(".env")
	v.SetConfigType("env")
	v.AddConfigPath(".")
	if err := v.ReadInConfig(); err != nil {
		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
			return nil, fmt.Errorf("read config file: %w", err)
		}
	}

	// ค่า default — บูตได้แม้ไม่ตั้งค่าอะไรเลย
	v.SetDefault("ENV", "development")
	v.SetDefault("HOST", "0.0.0.0")
	v.SetDefault("PORT", 3000)
	v.SetDefault("LOG_LEVEL", "info")
	v.SetDefault("CORS_ALLOW_ORIGINS", "*")
	v.SetDefault("READ_TIMEOUT", "15s")
	v.SetDefault("WRITE_TIMEOUT", "15s")
	v.SetDefault("SHUTDOWN_TIMEOUT", "10s")

	cfg := &Config{
		Env:              v.GetString("ENV"),
		Host:             v.GetString("HOST"),
		Port:             v.GetInt("PORT"),
		LogLevel:         v.GetString("LOG_LEVEL"),
		CORSAllowOrigins: v.GetString("CORS_ALLOW_ORIGINS"),
		ReadTimeout:      v.GetDuration("READ_TIMEOUT"),
		WriteTimeout:     v.GetDuration("WRITE_TIMEOUT"),
		ShutdownTimeout:  v.GetDuration("SHUTDOWN_TIMEOUT"),
	}

	if err := cfg.validate(); err != nil {
		return nil, err
	}
	return cfg, nil
}

สังเกตว่าเรา validate() ทันทีหลังโหลด — ถ้า port อยู่นอกช่วง 1–65535 หรือ shutdown timeout ติดลบ ก็ fail ตั้งแต่ตอนบูต ไม่ปล่อยให้พังตอน runtime:

func (c *Config) validate() error {
	if c.Port < 1 || c.Port > 65535 {
		return fmt.Errorf("invalid port %d: must be between 1 and 65535", c.Port)
	}
	if c.ShutdownTimeout <= 0 {
		return fmt.Errorf("shutdown timeout must be positive, got %s", c.ShutdownTimeout)
	}
	return nil
}

// Address คืน host:port ให้ Fiber ฟัง
func (c *Config) Address() string {
	return net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
}

เกร็ด: port default เป็น 3000 ไม่ใช่ 8080 นะครับ — เก็บ 8080 ไว้ให้ service อื่นในระบบ ส่วน .env.example ก็มีให้ copy เป็น .env ได้เลย

และเพื่อความมั่นใจว่า config ทำงานถูก เรามี unit test ที่เช็คทั้ง default, การ override ด้วย env, และการ reject port ที่เกินช่วง:

func TestLoadFromEnv(t *testing.T) {
	t.Setenv("APP_ENV", "production")
	t.Setenv("APP_PORT", "8080")
	t.Setenv("APP_HOST", "127.0.0.1")

	cfg, err := Load()
	if err != nil {
		t.Fatalf("Load() returned error: %v", err)
	}
	if got, want := cfg.Address(), "127.0.0.1:8080"; got != want {
		t.Errorf("Address() = %q, want %q", got, want)
	}
	if !cfg.IsProduction() {
		t.Error("IsProduction() = false for production env")
	}
}

Step 2: Structured Logger ด้วย zap

ทำไม fmt.Println ไม่พอ?

fmt.Println("error!") ใช้ได้ตอน dev แต่พอขึ้น production เราต้องการ log ที่ ค้นหาได้ (ด้วย request_id), machine-readable (ส่งเข้า Loki/ELK ได้), และ มี field แยกชัดเจน zap คือ logger ที่เร็วที่สุดใน Go ecosystem เพราะเลี่ยง reflection กับ allocation

backend/internal/logger/logger.go ฉลาดตรงที่ เลือก encoder ตาม env ให้อัตโนมัติ — dev ได้ console สีสวยอ่านง่าย, prod ได้ JSON ที่เครื่องอ่านได้:

// New returns a zap logger configured for the given level and environment.
func New(level, env string) (*zap.Logger, error) {
	lvl, err := parseLevel(level)
	if err != nil {
		return nil, err
	}

	var cfg zap.Config
	if strings.EqualFold(env, "production") {
		cfg = zap.NewProductionConfig() // JSON encoder
	} else {
		cfg = zap.NewDevelopmentConfig() // console encoder
		cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
	}
	cfg.Level = zap.NewAtomicLevelAt(lvl)
	cfg.EncoderConfig.TimeKey = "ts"
	cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder

	log, err := cfg.Build()
	if err != nil {
		return nil, fmt.Errorf("build zap logger: %w", err)
	}
	return log, nil
}

อุปมา: เหมือนเขียนไดอารี่สองเล่ม — เล่มสำหรับตัวเอง (dev) เขียนลายมือสวยๆ มีสีไฮไลต์ ส่วนเล่มสำหรับส่งให้ระบบประมวลผล (prod) เขียนเป็นตารางที่ scan เข้าคอมได้เลย


Step 3: Middleware — ลำดับมันสำคัญ!

Middleware เหมือน ด่านตรวจหน้าประตู ที่ทุก request ต้องผ่าน แต่จุดที่คนมักพลาดคือ ลำดับ ของมัน ใน backend/internal/server/server.go เราเรียงแบบนี้ (และมีเหตุผลทุกบรรทัด):

// Global middleware. ลำดับสำคัญ: recover มาก่อนเพื่อจับ panic ที่เกิดถัดลงไป,
// ตามด้วย request id, แล้ว logging, แล้ว CORS.
app.Use(recover.New())
app.Use(requestid.New())
app.Use(appmw.ZapLogger(log))
app.Use(cors.New(cors.Config{
	AllowOrigins: cfg.CORSAllowOrigins,
	AllowMethods: "GET,POST,PUT,PATCH,DELETE,OPTIONS",
	AllowHeaders: "Origin,Content-Type,Accept,Authorization",
}))

ทำไม recover ต้องมาก่อน? เพราะถ้า handler ข้างในเกิด panic แล้วไม่มีใครจับ — server ทั้งตัวพังเลย recover เป็นเหมือน ตาข่ายนิรภัยใต้นักกายกรรม ต้องกางไว้ก่อนที่คนจะปีนขึ้นไป

ทำไม request id มาก่อน logger? เพราะ logger จะหยิบ request id ไปแปะในทุก log line ถ้า request id ยังไม่ถูกสร้าง logger ก็ไม่มีอะไรให้แปะ

ตัวที่เราเขียนเองคือ ZapLogger ใน backend/internal/middleware/requestlogger.go — มันวัด latency, จับ status code ที่ถูกต้องแม้ handler จะ return error, แล้ว log ออกมาเป็น structured fields:

func ZapLogger(log *zap.Logger) fiber.Handler {
	return func(c *fiber.Ctx) error {
		start := time.Now()

		// ปล่อยให้ chain ที่เหลือทำงานก่อน เราจะได้ status ที่แท้จริง
		err := c.Next()

		// ถ้า handler return error, Fiber ErrorHandler จะรันทีหลัง middleware นี้
		// คลายตัว เลย resolve status จาก error เอง
		status := c.Response().StatusCode()
		if err != nil {
			status = fiber.StatusInternalServerError
			var fe *fiber.Error
			if errors.As(err, &fe) {
				status = fe.Code
			}
		}

		fields := []zap.Field{
			zap.String("method", c.Method()),
			zap.String("path", c.Path()),
			zap.Int("status", status),
			zap.String("ip", c.IP()),
			zap.String("request_id", c.GetRespHeader(fiber.HeaderXRequestID)),
			zap.Duration("latency", time.Since(start)),
		}
		if err != nil {
			fields = append(fields, zap.Error(err))
			log.Error("request failed", fields...)
			return err
		}
		log.Info("request handled", fields...)
		return nil
	}
}

Step 4: Error Envelope ที่เป็นมาตรฐานเดียว

ลองนึกถึงร้านอาหาร 5 ร้านที่เสิร์ฟไม่เหมือนกันเลย — คนกินงงว่าอันไหนคืออะไร API ก็เหมือนกัน ถ้า error แต่ละแบบหน้าตาไม่เหมือนกัน frontend จะ parse ลำบากมาก เราเลยตั้ง ErrorHandler กลาง ที่ทุก unhandled error ไหลมารวมกัน แล้วออกเป็น JSON รูปแบบเดียว:

// errorHandler renders unhandled errors เป็น JSON envelope เดียวกัน
// และ log ทุก 5xx เพื่อ observability
func errorHandler(log *zap.Logger) fiber.ErrorHandler {
	return func(c *fiber.Ctx, err error) error {
		code := fiber.StatusInternalServerError

		var fe *fiber.Error
		if errors.As(err, &fe) {
			code = fe.Code
		}

		if code >= fiber.StatusInternalServerError {
			log.Error("unhandled error",
				zap.Int("status", code),
				zap.String("path", c.Path()),
				zap.Error(err),
			)
		}

		return c.Status(code).JSON(fiber.Map{
			"error": fiber.Map{
				"code":    code,
				"message": err.Error(),
			},
		})
	}
}

หน้าตา error ที่ client ได้จะเป็นแบบนี้เสมอ:

{
  "error": {
    "code": 500,
    "message": "something went wrong"
  }
}

เกร็ด: envelope นี้คือ “เมล็ดพันธุ์” ของระบบ response ที่จะโตขึ้นใน step ถัดๆ ไป (เดี๋ยวพอถึง device API เราจะมี package httpx ที่เพิ่ม data, pagination, และ per-field validation error เข้ามา — แต่ shape ของ error ยังเหมือนเดิมเป๊ะ)


Step 5: Health Check ที่ orchestrator เข้าใจ

ทำไม health check ถึงสำคัญ?

ลองนึกว่ามี server หลายตัวอยู่หลัง load balancer ถ้าตัวนึงพัง LB จะรู้ได้ยังไงว่าอย่าส่ง traffic ไปตัวนั้น? คำตอบคือ health check Kubernetes / Docker / cloud LB จะ ping endpoint นี้เป็นระยะ ได้ 200 = ปกติ, ได้ 503 = เอาออกจาก pool ชั่วคราว

backend/internal/health/health.go ใน step นี้ยังไม่มี dependency ให้เช็ค เลยตอบ static healthy ไปก่อน (พอ step หน้าต่อ MongoDB เราจะเสียบ checker เพิ่มเข้ามา):

// Healthz เป็น liveness probe — return 200 ตราบใดที่ process ยังรับ request ได้
func (h *Handler) Healthz(c *fiber.Ctx) error {
	return c.Status(fiber.StatusOK).JSON(Response{
		Status:    "ok",
		Service:   h.service,
		Version:   h.version,
		Timestamp: time.Now().UTC(),
	})
}

// Ping เป็น reachability check ง่ายๆ ใต้ prefix /api/v1
func (h *Handler) Ping(c *fiber.Ctx) error {
	return c.Status(fiber.StatusOK).JSON(fiber.Map{
		"message":   "pong",
		"service":   h.service,
		"timestamp": time.Now().UTC(),
	})
}

route ทั้งสองถูก mount ใน registerRoutes/healthz อยู่นอก prefix (ให้ orchestrator เรียกง่าย) ส่วน /ping อยู่ใต้ /api/v1:

func registerRoutes(app *fiber.App) {
	h := health.NewHandler("showkhun-iot-platform", Version)

	// liveness probe อยู่นอก API version prefix โดยตั้งใจ
	app.Get("/healthz", h.Healthz)

	// versioned API — handler ของ devices/sensors/auth จะมาต่อใน step ถัดไป
	v1 := app.Group("/api/v1")
	v1.Get("/ping", h.Ping)
}

Step 6: main.go + Graceful Shutdown

ทำไมต้อง graceful?

นึกภาพ server กำลังประมวลผล request สำคัญอยู่ 100 ตัว แล้วเราดึงปลั๊กออกทันที — พังหมด ข้อมูลอาจเสียหาย graceful shutdown คือการ “ประกาศปิดร้าน” ก่อน: ไม่รับลูกค้าใหม่ แต่รอลูกค้าที่อยู่ในร้านทานเสร็จก่อนค่อยปิดประตู

graph TD
    A[SIGINT / SIGTERM] --> B[หยุดรับ connection ใหม่]
    B --> C[รอ request ที่ค้างอยู่จบ]
    C --> D{ภายใน ShutdownTimeout?}
    D -->|ทัน| E[ปิดสะอาด ✓]
    D -->|ไม่ทัน| F[force exit]

backend/cmd/server/main.go รัน listener ใน goroutine แยก แล้วให้ main goroutine นั่งรอ OS signal:

func main() {
	cfg, err := config.Load()
	if err != nil {
		// logger ยังไม่พร้อม เลย fall back ไปที่ stderr
		panic("failed to load configuration: " + err.Error())
	}

	log, err := logger.New(cfg.LogLevel, cfg.Env)
	if err != nil {
		panic("failed to initialise logger: " + err.Error())
	}
	defer func() { _ = log.Sync() }()

	app := server.New(cfg, log)

	// รัน HTTP listener ใน goroutine ของตัวเอง เพื่อให้ main รอ OS signal ได้
	go func() {
		log.Info("starting http server",
			zap.String("addr", cfg.Address()),
			zap.String("env", cfg.Env),
		)
		if err := app.Listen(cfg.Address()); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatal("http server stopped unexpectedly", zap.Error(err))
		}
	}()

	// block จนกว่าจะได้ signal ปิด
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	sig := <-quit
	log.Info("shutdown signal received", zap.String("signal", sig.String()))

	ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
	defer cancel()

	if err := app.ShutdownWithContext(ctx); err != nil {
		log.Error("graceful shutdown failed", zap.Error(err))
		os.Exit(1)
	}
	log.Info("server stopped cleanly")
}

สังเกตว่า config โหลด ก่อน logger — เพราะ logger ต้องรู้ level กับ env จาก config ก่อนถึงจะสร้างได้ ตอนนั้น logger ยังไม่มี เลยต้อง panic ไป stderr ตรงๆ ถ้า config พัง


Step 7: Distroless Docker

Go compile เป็น binary เดี่ยวๆ ที่ไม่ต้องการ runtime อะไรเพิ่ม เราเลยใช้ multi-stage build — stage แรกใช้ image ที่มี Go tools ครบ แต่ stage สุดท้ายใช้ distroless ที่ไม่มีแม้แต่ shell ผลคือ image เล็กและ attack surface ต่ำมาก:

# --- Build stage ---
FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# static binary เพื่อรันบน base image ที่เล็กที่สุดได้
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server

# --- Runtime stage ---
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/server /app/server
EXPOSE 3000
USER nonroot:nonroot
ENTRYPOINT ["/app/server"]

ทำไม distroless แทน alpine? เพราะ distroless ไม่มี shell, package manager, หรือ utility อะไรเลย — ถ้ามีใครเจาะเข้ามาได้ก็แทบทำอะไรต่อไม่ได้เพราะไม่มีเครื่องมือให้ใช้ แถมรันด้วย nonroot user เป็น default ปลอดภัยขึ้นอีกชั้น


ลองรันกัน!

ใน step นี้ backend ยังไม่ต้องพึ่ง MongoDB จึงรันได้เลยโดยไม่ต้องเปิด infra ก่อน (โปรเจกต์มี Makefile ที่ห่อคำสั่งไว้ให้):

# รัน backend ตรงๆ
make run
# เทียบเท่า: cd backend && go run ./cmd/server

# server จะฟังที่ http://localhost:3000

ทดสอบ health check:

curl http://localhost:3000/healthz
# {"status":"ok","service":"showkhun-iot-platform","version":"0.1.0","timestamp":"..."}

curl http://localhost:3000/api/v1/ping
# {"message":"pong","service":"showkhun-iot-platform","timestamp":"..."}

ถ้าเห็น pong กลับมา — แสดงว่า foundation ของเราขึ้นแล้วครับ!

   ╔══════════════════════════════════╗
   ║                                  ║
   ║       (ノ◕ヮ◕)ノ*:・゚✧             ║
   ║                                  ║
   ║   "pong! รากฐานพร้อมต่อยอดแล้ว"    ║
   ║                                  ║
   ╚══════════════════════════════════╝

และอย่าลืมรัน test ด้วย:

make test
# cd backend && go test ./...
# ok  .../internal/config   (TestLoadDefaults, TestLoadFromEnv, TestValidateRejectsBadPort)

เวอร์ชันที่ใช้จริง (Pinned)

ทุกอย่าง pin ไว้เพื่อให้ workshop ทำซ้ำได้เหมือนกันทุกเครื่อง:

ส่วนประกอบเวอร์ชัน
Go (module directive)go 1.24
Fibergithub.com/gofiber/fiber/v2 v2.52.13
Loggergo.uber.org/zap v1.28.0
Configgithub.com/spf13/viper v1.21.0
Base image (runtime)gcr.io/distroless/static-debian12:nonroot

infra ฝั่ง docker-compose ก็ pin ไว้หมด: mongo:8.0.16, eclipse-mosquitto:2.0.22, influxdb:2.7.12, telegraf:1.39.0 — เราจะเริ่มใช้พวกนี้จริงๆ ใน step ถัดไป


สรุป: foundation ของเรามีอะไรบ้าง

ส่วนประกอบรายละเอียด
Configenv-based ผ่าน Viper (prefix APP_) + validate ตอนบูต
Loggerzap แยก console (dev) / JSON (prod) อัตโนมัติ
Middlewarerecover → request id → zap logger → CORS (ลำดับสำคัญ)
Error envelope{"error":{"code","message"}} รูปแบบเดียวทั้งระบบ
Health/healthz (liveness) + /api/v1/ping
Shutdowngraceful ภายใน ShutdownTimeout
Dockermulti-stage → distroless + non-root
Testconfig มี unit test ครบ default/env/validation

หัวใจของ step นี้ไม่ใช่ “โค้ดเยอะ” แต่คือ skeleton ที่บางและรันได้จริง ทุกชั้นทำหน้าที่เดียว ชัดเจน พอ step ถัดไปเราต่อ MongoDB เข้ามา มันจะเสียบเข้าที่ของมันได้พอดี โดยไม่ต้องรื้อของเดิม


Next Step

ตอนหน้าเราจะต่อ MongoDB ของจริง — ออกแบบ domain model (Device, User, Group, AlertRule), เชื่อมต่อด้วย official Go driver v2 พร้อม connection pooling, สร้าง index และ Repository Pattern ที่ unit-test ได้ มาลุยกันต่อ! ٩(◕‿◕。)۶