IoT Workshop #4: วาง Foundation ด้วย Go + Fiber
IoT Workshop #4: วาง Foundation ด้วย Go + Fiber
Branch:
step-01-foundationPhase: 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 อะไรเลย — ถ้ามีใครเจาะเข้ามาได้ก็แทบทำอะไรต่อไม่ได้เพราะไม่มีเครื่องมือให้ใช้ แถมรันด้วย
nonrootuser เป็น 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 |
| Fiber | github.com/gofiber/fiber/v2 v2.52.13 |
| Logger | go.uber.org/zap v1.28.0 |
| Config | github.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 ของเรามีอะไรบ้าง
| ส่วนประกอบ | รายละเอียด |
|---|---|
| Config | env-based ผ่าน Viper (prefix APP_) + validate ตอนบูต |
| Logger | zap แยก console (dev) / JSON (prod) อัตโนมัติ |
| Middleware | recover → request id → zap logger → CORS (ลำดับสำคัญ) |
| Error envelope | {"error":{"code","message"}} รูปแบบเดียวทั้งระบบ |
| Health | /healthz (liveness) + /api/v1/ping |
| Shutdown | graceful ภายใน ShutdownTimeout |
| Docker | multi-stage → distroless + non-root |
| Test | config มี 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 ได้ มาลุยกันต่อ! ٩(◕‿◕。)۶
Navigation
- ก่อนหน้า: IoT Workshop #3: Project Setup & DevOps
- ถัดไป: IoT Workshop #5: MongoDB Models & Repository
- แผนการ Workshop ทั้งหมด: IoT Workshop Master Plan