Building REST API with Go and Gin

บทความโดย ผศ.ดร.ณัฐโชติ พรหมฤทธิ์ และ อ.ดร.สัจจาภรณ์ ไวจรรยา
ภาควิชาคอมพิวเตอร์
คณะวิทยาศาสตร์
มหาวิทยาลัยศิลปากร

บทความนี้ผู้อ่านจะได้พัฒนา REST API สำหรับ User Service ด้วย Gin Framework ซึ่งเป็น Web Framework ที่เป็นที่นิยมใน Golang โดยมี API Endpoint ดังต่อไปนี้

  1. GET /api/v1/users
  2. GET /api/v1/users/{user_id}
  3. POST /api/v1/users
  4. PUT /api/v1/users/{user_id}
  5. DELETE /api/v1/users/{user_id}
  6. GET /health

เราจะใช้ HTTP Verb (Method) หลัก ๆ ได้แก่ GET, POST, PUT และ DELETE เพื่อกําหนดการดําเนินการที่เราสามารถทำได้กับ Resource ของ User Service

โดย GET จะถูกใช้เพื่อดึง Resource และ POST จะถูกใช้สำหรับสร้าง Resource ใหม่ ส่วน PUT ถูกใช้สำหรับการ Update แบบแทนที่ และ DELETE สำหรับลบ Resource ที่มีอยู่

แต่ละ Endpoint มีรายละเอียดดังต่อไปนี้

GET /api/v1/users
- สำหรับดึงข้อมูลผู้ใช้ทั้งหมดจากฐานข้อมูล เรียงตาม id จากน้อยไปมาก
- ไม่ต้องส่ง Parameter
- ต้องมี Bearer Token ใน Header
- ส่งคืนข้อมูลเป็น Array ของผู้ใช้

# ดึงข้อมูลผู้ใช้ทั้งหมดจากฐานข้อมูล
# เรียงตาม id จากน้อยไปมาก
# query = "SELECT * FROM users ORDER BY id"

# ตัวอย่างผลลัพธ์
[
    {
        "id": 1,
        "name": "Nuttachot",
        "email": "[email protected]"
    },
    {
        "id": 2,
        "name": "Poohkan",
        "email": "[email protected]"
    }
]

GET /api/v1/users/{user_id}
- สำหรับดึงข้อมูลผู้ใช้ตาม id ที่ระบุ
- ต้องระบุ user_id ที่ต้องการใน Path
- ต้องมี Bearer Token ใน Header
- ถ้าไม่พบผู้ใช้จะ Return 404

# ดึงข้อมูลผู้ใช้ตาม id ที่ระบุ
# query = "SELECT * FROM users WHERE id = %s"

# ตัวอย่างผลลัพธ์
{
    "id": 1,
    "name": "Nuttachot",
    "email": "[email protected]"
}

POST /api/v1/users
- สำหรับสร้างผู้ใช้ใหม่จากข้อมูลที่ส่งมา
- ต้องส่งข้อมูล name และ email มาในรูปแบบ JSON
- ต้องมี Bearer Token ใน Header
- email ต้องไม่ซ้ำกับที่มีอยู่แล้ว
- ส่งคืนข้อมูลผู้ใช้ที่สร้างใหม่พร้อม ID

# สร้างผู้ใช้ใหม่จากข้อมูลที่ส่งมา

# ตัวอย่างข้อมูลที่ต้องส่ง
{
    "name": "Nuttachot",
    "email": "[email protected]"
}

# ตัวอย่างผลลัพธ์
{
    "id": 1,  # ID จะถูกสร้างอัตโนมัติ
    "name": "Nuttachot",
    "email": "[email protected]"
}

PUT /api/v1/users/{user_id}
- สำหรับอัพเดทข้อมูลผู้ใช้ทั้งหมดตาม id ที่ระบุ
- ต้องระบุ user_id ที่ต้องการแก้ไขใน Path
- ต้องส่งข้อมูลใหม่ทั้ง name และ email
- ต้องมี Bearer Token ใน header
- ถ้าไม่พบผู้ใช้จะ Return 404
- email ใหม่ต้องไม่ซ้ำกับคนอื่น
- ส่งคืนข้อมูลผู้ใช้ที่อัพเดทแล้ว

# อัพเดทข้อมูลผู้ใช้ตาม id ที่ระบุ

# ตัวอย่างข้อมูลที่ต้องส่ง
{
    "name": "Nuttachot Promrit",
    "email": "[email protected]"
}

# ตัวอย่างผลลัพธ์
{
    "id": 1,
    "name": "Nuttachot Promrit",
    "email": "[email protected]"
}

DELETE /api/v1/users/{user_id}
- สำหรับลบผู้ใช้ตาม id ที่ระบุ
- ต้องระบุ user_id ที่ต้องการลบใน Path
- ต้องมี Bearer Token ใน Header
- ถ้าไม่พบผู้ใช้จะ Return 404
- ส่งคืนข้อความยืนยันการลบสำเร็จ

# ลบผู้ใช้ตาม id ที่ระบุ

# ตัวอย่างผลลัพธ์
{
    "message": "User deleted successfully"
}

GET /health
- สำหรับตรวจสอบการเชื่อมต่อกับฐานข้อมูล
- ไม่ต้องใช้ Bearer Token

# ตรวจสอบการเชื่อมต่อกับฐานข้อมูล

# ตัวอย่างผลลัพธ์ (กรณีปกติ)
{
    "status": "healthy",
    "database": "connected"
}

# ตัวอย่างผลลัพธ์ (กรณีมีปัญหา)
{
    "detail": "Database connection failed: error message"
}

Project นี้จะมีการทดลอง Deploy API และ Database Server บน Docker Container โดยจะมีการสร้าง Git Repository 2 Repo บน Github ได้แก่

  1. userservice Repo สำหรับเก็บ Codebase ของ REST API
  2. userdatabase Repo สำหรับเก็บ Codebase ของการ Config PostgreSQL

นอกจากนี้ยังมีรูปแบบในการจัดการ Branch ใน Git ที่มีลักษณะการพัฒนา Code แบบรวมศูนย์บน Branch หลัก (main Branch) ที่เรียกว่า Trunk-Based Development ซึ่งหลาย ๆ บริษัท เช่น Google, Facebook, Netflix และ Amazon นำมาปรับใช้เพื่อให้สามารถพัฒนาและ Deploy Codeใหม่ ๆ ได้อย่างต่อเนื่องและรวดเร็ว

โดยนักพัฒนาทุกคนในทีมจะทำงานและ Merge Code เข้ามายัง Trunk โดยตรงอย่างสม่ำเสมอ ซึ่งต่างจาก Git flow ทั่วไปที่อาจมี Branch หลายระดับ เช่น main, develop หรือ feature Branch

ดังนั้นบนความนี้ไม่ใช่มีเนื้อหาเฉพาะการพัฒนา REST API เท่านั้น แต่ยังแสดงตัวอย่าง Workflow ของการพัฒนา Software แบบสมัยใหม่ที่เน้นการทำงานร่วมกันเป็นทีมด้วยแนวทางแบบ Trunk-Based Development ซึ่งผู้พัฒนาจะได้ทดสอบการ Deploy Software บน Docker Container ก่อนนำขึ้น Production ต่อไป

สร้าง SSH Key

  • ก่อนอื่นให้สร้าง SSH Key สำหรับการ Push และ Pull Code กับ Git Repo ที่ได้เชื่อมต่อ โดยไม่ต้องป้อน Username และ Password ทุกครั้ง
สร้าง ssh-keygen
ssh-keygen -t ed25519 -C "[email protected]"

แสดง public key บน Linux
cat ~/.ssh/id_ed25519.pub
  • ใช้คำสั่ง cat ~/.ssh/id_ed25519.pub เพื่อแสดง Public Key ดังเช่นตัวอย่างต่อไปนี้แล้ว Copy ไว้
ssh-ed25519 ABCAC3NzaC1lZFI1NTE3AAAAIMmPOcXyJu+c/2Ork3pmgBU9FBl1iwxBr97Bh1MxI6sB [email protected]
  • นำ Public Key ไปเพิ่มบน Github Repo โดยไปที่รูป Profile เลือก Your organizations เลือกเมนู SSH and GPG keys แล้วกด New SSH key
  • ตั้งชื่อ userdatabase และนำ Public key ที่ Copy ไปวาง แล้วกด Add SSH key
  • ยืนยันตัวตน

Config PostgreSQL และ Deploy บน Docker Container

เราจะใช้แนวทางในการพัฒนา Software แบบ Trunk-Based Development (TBD) ซึ่งมีหลักการสำคัญ คือ

  • การ Commit และ Merge บ่อย ๆ นักพัฒนาจะ Commit และ Merge การเปลี่ยนแปลงของตัวเองเข้ากับ Trunk (Branch หลัก) บ่อยครั้ง อาจจะเป็นรายวันหรือบ่อยกว่านั้น การรวม Code บ่อย ๆ ช่วยให้ทีมทำงานร่วมกันได้อย่างคล่องตัวและลดโอกาสการเกิด Merge Conflicts ขนาดใหญ่
  • ขนาดงานที่เล็กและแตก Branch สั้น ๆ ใน TBD งานแต่ละชิ้นควรจะเล็กพอที่จะทำเสร็จได้ภายในระยะสั้น ๆ นักพัฒนาอาจแตก Branch ย่อยขึ้นมาเพื่อทำงานในบางงาน แต่ Branch ย่อยนั้นควรจะใช้เวลาไม่นานก่อนจะ Merge กลับไปยัง Trunk โดยไม่มี Branch ระยะยาว

เราจะสร้าง userdatabase Repo สำหรับเก็บ Codebase ของการ Config PostgreSQL ที่มีโครงสร้าง Project ดังต่อไปนี้

.
├── README.md
├── backup
│   ├── Dockerfile
│   └── backup.sh
├── backups
├── docker
│   ├── Dockerfile
│   └── init.sql
├── .env
├── .gitignore
└── docker-compose.yml
  • สร้าง Folder userdatabase เข้าไปใน Folder นี้แล้วเริ่มต้นใช้งาน Git ด้วยคำสั่งต่อไปนี้
git init
  • ไปที่ Github.com สร้าง Git Repo ชื่อ userdatabase เลือกชนิด Repo แบบ Private แล้วกด Create repository
  • เลือก SSH แล้วกด Copy URL
  • เชื่อมต่อกับ Git Repo ด้วยคำสั่ง git remote add origin ตามด้วย URL ที่ได้ Copy มา เช่น
git remote add origin [email protected]:promritn/userdatabase.git
  • สร้าง File README.md ใน Folder userdatabase
  touch README.md
  • พิมพ์หัวข้อ PostgreSQL config แบบหัวเรื่องระดับ 1 ใน File README.md ด้วย Tag ของ Markdown แล้ว Save
# PostgreSQL config
  • Commit เข้า Git ด้วยคำสั่งต่อไปนี้
git add .

git commit -m 'first commit'
  • Push Code ที่ Commit ขึ้น Github Server
git push origin main
  • ไปที่ Browser แล้ว Refresh หน้าต่าง userdatabase Repo จะเห็นหัวข้อ PostgreSQL config แบบตัวใหญ่

เราจะ Config PostgreSQL บน Branch ย่อย ซึ่งตามหลักการของ Trunk-Based Developmen แล้วนักพัฒนาควรจะสร้าง Branch ย่อยเฉพาะงานของตัวเอง (Short-Lived Branch) จาก Trunk ซึ่งงานควรมีขนาดเล็กและสามารถทำให้เสร็จได้ภายในเวลาอันสั้น (ไม่เกิน 1 วัน) เพื่อให้ง่ายต่อการ Merge กลับไปยัง Trunk อย่างรวดเร็ว

  • สร้าง Short-Lived Branch ชื่อ feature/config-postgresql
git checkout -b feature/config-postgresql
  • สร้าง File และ Folder ของ Project ตามโครงสร้างดังต่อไปนี้
.
├── README.md
├── backup
│   ├── Dockerfile
│   └── backup.sh
├── backups
├── docker
│   ├── Dockerfile
│   └── init.sql
├── .env
├── .gitignore
└── docker-compose.yml
  • แก้ไข File docker-compose.yml สำหรับการ Deploy PosgreSQL, PGAdmin และ Backup Container
# docker-compose.yml

services:
  db:
    build: ./docker
    container_name: user_postgres
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./backups:/backups  # volume สำหรับเก็บ File backup
    ports:
      - "${POSTGRES_PORT}:5432"
    networks:
      - database_network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  pgadmin:
    image: dpage/pgadmin4
    container_name: user_pgadmin
    environment:
      PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL}
      PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD}
    volumes:
      - pgadmin_data:/var/lib/pgadmin
    ports:
      - "${PGADMIN_PORT}:80"
    networks:
      - database_network
    restart: unless-stopped
    depends_on:
      - db

  backup:
    build: ./backup
    container_name: postgres_backup
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_HOST: db
      BACKUP_RETENTION_DAYS: 7  # เก็บ backup ไว้ 7 วัน
      BACKUP_SCHEDULE: "0 0 * * *"  # ทำ backup ทุกวันเวลาเที่ยงคืน
    volumes:
      - ./backups:/backups
    networks:
      - database_network
    depends_on:
      - db

networks:
  database_network:
    name: database_network
    driver: bridge

volumes:
  postgres_data:
  pgadmin_data:
docker-compose.yml
  • แก้ไข File .env
# .env
POSTGRES_DB=postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres123
POSTGRES_PORT=5432
[email protected]
PGADMIN_DEFAULT_PASSWORD=admin123
PGADMIN_PORT=5050
.env
  • แก้ไข File .gitignore
# .gitignore
.env

# Ignore backup files
/backups/
*.backup
*.backup.gz
*.dump
*.sql
*.gz
.gitignore
  • แก้ไข File Dockerfile ใน Folder docker
# Dockerfile
FROM postgres:17-alpine

# Copy initialization scripts
COPY init.sql /docker-entrypoint-initdb.d/

# Set locale (optional)
ENV LANG en_US.utf8
docker/Dockerfile
  • แก้ไข File init.sql ใน Folder docker
-- สร้างตาราง users
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- สร้าง function สำหรับอัพเดท updated_at โดยอัตโนมัติ
CREATE OR REPLACE FUNCTION update_modified_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = now();
    RETURN NEW;
END;
$$ language 'plpgsql';

-- สร้าง trigger สำหรับอัพเดท updated_at
CREATE TRIGGER update_users_modtime
    BEFORE UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION update_modified_column();

-- สร้าง indexes เพื่อเพิ่มประสิทธิภาพ
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_name ON users(name);

-- เพิ่มข้อมูลตัวอย่าง
INSERT INTO users (name, email) VALUES 
    ('ณัฐโชติ พรหมฤทธิ์', '[email protected]'),
    ('สัจจาภรณ์ ไวจรรยา', '[email protected]'),
    ('สมศรี มีสุข', '[email protected]');
docker/init.sql
  • แก้ไข File Dockerfile ใน Folder backup
# backup/Dockerfile
FROM postgres:17-alpine

# ติดตั้ง dependencies ที่จำเป็น
RUN apk add --no-cache \
    bash \
    curl \
    pigz \
    tar \
    dcron \
    tzdata

# Copy backup script
COPY backup.sh /backup.sh
RUN chmod +x /backup.sh

# สร้าง directory สำหรับ log
RUN mkdir -p /var/log/cron && \
    touch /var/log/cron/cron.log && \
    chmod 0644 /var/log/cron/cron.log

# Create a script to setup and run cron
RUN echo '#!/bin/sh' > /entrypoint.sh && \
    echo 'printenv | grep -v "no_proxy" >> /etc/environment' >> /entrypoint.sh && \
    echo 'crond -f -d 8 >> /var/log/cron/cron.log 2>&1' >> /entrypoint.sh && \
    chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
backup/Dockerfile
  • แก้ไข File backup.sh ใน Folder backup
#!/bin/bash
# backup/backup.sh

# กำหนดตัวแปร
BACKUP_DIR="/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="backup_${TIMESTAMP}"

# สร้าง Folder backup ถ้ายังไม่มี
mkdir -p ${BACKUP_DIR}

# ทำ Database Backup
echo "Starting backup of PostgreSQL database: ${POSTGRES_DB}"
PGPASSWORD=${POSTGRES_PASSWORD} pg_dump -h ${POSTGRES_HOST} -U ${POSTGRES_USER} -d ${POSTGRES_DB} -F c -b -v -f "${BACKUP_DIR}/${BACKUP_FILE}.backup"

# บีบอัด File backup
echo "Compressing backup file..."
pigz "${BACKUP_DIR}/${BACKUP_FILE}.backup"

# ลบ File backup เก่า
echo "Removing old backups..."
find ${BACKUP_DIR} -type f -name "*.backup.gz" -mtime +${BACKUP_RETENTION_DAYS} -delete

# ตรวจสอบสถานะการทำงาน
if [ $? -eq 0 ]; then
    echo "Backup completed successfully: ${BACKUP_FILE}.backup.gz"
else
    echo "Backup failed!"
    exit 1
fi

# สร้าง symlink ไปยัง backup ล่าสุด
ln -sf "${BACKUP_DIR}/${BACKUP_FILE}.backup.gz" "${BACKUP_DIR}/latest.backup.gz"

ก่อน Commit Code เข้า Git นักพัฒนาต้องทดสอบก่อนว่ามันทำงานได้

  • Deploy PostgreSQL, PGAdmin และ Backup Container
docker-compose up -d
  • ตรวจสอบ Container ที่ Deploy
docker-compose ps
  • ดู Logs ของ 3 Container ที่รัน
docker-compose logs
  • ทดลอง Backup Database
docker exec postgres_backup /backup.sh
  • สำหรับการ Restore เราจะใช้คำสั่งดังต่อไปนี้
# Latest backup
docker exec -it user_postgres pg_restore -U [user] -d [dbname] -v /backups/latest.backup.gz

# Specific backup
docker exec -it user_postgres pg_restore -U [user] -d [dbname] -v /backups/backup_[timestamp].backup.gz

ทดลอง Query ข้อมูลผ่าน PG Admin ดังนี้

  • ไปยัง  URL http://localhost:5050 ใส่ Username และ Password ตามที่ได้กำหนดไว้ใน File .env
  • กด Add New Server
  • ตั้งชื่อ Connection
  • กำหนดค่า Connection ต่าง ๆ
  • คลิ๊กขวาที่ postgres เลือก Query Tool
  • ทดสอบ Query ข้อมูลใน Table users ด้วยคำสั่งต่อไปนี้ แล้วกดปุ่ม Play
select * from users
  • เมื่อทดสอบการ Deploy, Backup Database และ Query ข้อมูลแล้วจึง Commit Code เข้า Git ด้วยคำสั่งต่อไปนี้
git add .

git commit -m 'config postgresql, backup container and pgadmin'
  • ดู History ที่ Commit
git log --oneline

Rebase โดยนำโค้ดใน branch ของเรามาอัปเดตให้ตรงกับ Trunk ล่าสุด

  • แต่ก่อน Rebase ควรดึงการเปลี่ยนแปลงล่าสุดจาก Trunk เพื่อให้แน่ใจว่า Branch ของเราอยู่ในสถานะล่าสุด เหมือนบน Github Server
git checkout main

git pull origin main
  • Rebase เพื่อนำโค้ดใน Branch ของเรามา Update ให้ตรงกับ Trunk ล่าสุด
git checkout feature/config-postgresql

git rebase main
  • เมื่อ Code ใน Branch feature/config-postgresql ตรงกับ Trank ล่าสุดแล้ว จึง Merge กลับไปยัง Trunk (ถ้ามี Conflict ตอน Rebase ให้แก้ไข Conflict ก่อน Merge)
git checkout main

git merge feature/config-postgresql

*อาจ Push Branch feature/config-postgresql ขึ้น Github Server เพื่อเปิด Pull Request (PR) ให้ทีมตรวจสอบก่อนก็ได้

**ขั้นตอนการแก้ไข Conflict หลังจาก Rebase

1. ดู File ที่เกิด Conflict
2. เปิด File ที่มี Conflict และแก้ไขความขัดแย้ง
3. บันทึก File ที่แก้ไขแล้ว
4. ใช้ git add กับ File ที่แก้ไข
5. ใช้ git rebase --continue เพื่อดำเนินการ Rebase ต่อ
6. หากยังมี Conflict ให้ทำซ้ำขั้นตอนจนกว่าจะเสร็จสิ้น

ยกเลิกการ Rebase (ถ้าจำเป็น)
git rebase --abort

  • Push Code ขึ้น Github Server
git push origin main
  • ลบ Branch ย่อยที่เสร็จแล้ว
git branch -d feature/config-postgresql
  • ดู History ทั้งหมด
git log --oneline

Trunk ของเราจะมี History เป็นเส้นตรงสวยงาม

พัฒนา API เชื่อมต่อกับ PostgreSQL และ Deploy บน Docker Container

เราจะสร้าง userservice Repo สำหรับเก็บ Codebase ของ REST API โดยมีโครงสร้างของ Project ดังต่อไปนี้

.
├── .env
├── .gitignore
├── Dockerfile
├── README.md
├── cmd
│   └── main.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── internal
│   ├── config
│   │   └── config.go
│   ├── handler
│   │   └── user_handler.go
│   ├── middleware
│   │   └── auth_middleware.go
│   ├── models
│   │   └── user.go
│   ├── repository
│   │   └── user_repository.go
│   └── service
│       └── user_service.go
└── main

Project นี้มีการจัดการโครงสร้าง Code เป็น Layer โดยแยกความรับผิดชอบออกเป็นส่วน ๆ ดังนี้

  1. cmd/
    มี File main.go ซึ่งเป็นจุดเริ่มต้นของ Program (Entry point) ทำหน้าที่
    - โหลด Configuration
    - เชื่อมต่อฐานข้อมูล
    - สร้าง Instance ของ Repository, Service และ Handler
    - ตั้งค่า HTTP Routes และ Middleware
    - รัน HTTP Server โดยใช้ Gin
  2. internal/config/
    เก็บ Code เกี่ยวกับการโหลด Configuration เช่น ข้อมูลการเชื่อมต่อฐานข้อมูล API Token โดยอ่านจาก Environment Variables เพื่อความยืดหยุ่นในการ Deploy ในสภาพแวดล้อมต่าง ๆ
  3. internal/middleware/
    เก็บ Middleware เช่น BearerAuth ซึ่งใช้สำหรับตรวจสอบ Bearer Token ใน Header ของ HTTP Request เพื่อใช้ป้องกันการเข้าถึง Resources ที่ต้อง Authentication
  4. internal/models/
    เก็บข้อมูลโครงสร้างของ Entity เช่น User ซึ่งจะระบุ Field และชนิดข้อมูล ที่สะท้อนถึงตารางในฐานข้อมูล โดยไม่มี Logic อื่น ๆ ปะปน
  5. internal/repository/
    ทำหน้าที่เป็นชั้น Data Access Layer ติดต่อกับฐานข้อมูล PostgreSQL โดยตรง ใช้คำสั่ง SQL (เช่น SELECT, INSERT, UPDATE และ DELETE) เพื่อจัดการข้อมูล User
    - มี Interface UserRepository กำหนดสัญญา (Contract) ของการเข้าถึงข้อมูล User
    - มี Struct ที่ Implement Interface เพื่อเชื่อมกับ DB
    - มี Function อย่างเช่น ConnectDB และ CheckDBConnection ใช้สำหรับเชื่อมและตรวจสอบการเชื่อมต่อกับฐานข้อมูล
    - เมื่อมีการเปลี่ยนแปลงฐานข้อมูล หรือย้ายไปใช้แหล่งข้อมูลอื่นก็สามารถสร้าง UserRepository ตัวใหม่ที่ Implement Interface เดิมได้โดยไม่กระทบชั้นบน
  6. internal/service/
    ทำหน้าที่เป็นชั้น Business Logic หรือ Domain Logic อยู่ระหว่าง Repository และ Handler
  7. internal/handler/
    ชั้นนี้เป็นชั้น Interface กับภายนอกผ่าน HTTP/Gin Router

Request Flow

  1. Client ยิง Request -> เข้า Gin -> เช็ค Middleware Auth -> เข้าถึง Handler
  2. Handler รับ Request -> เรียก Service ทำงาน
  3. Service ทำงาน -> เรียก Repository เมื่อต้องใช้ DB
  4. Repository ดึง/แก้ไขข้อมูลใน DB ส่งกลับ Service
  5. Service ตีความข้อมูลหรือ Error ส่งกลับ Handler
  6. Handler แปลงผลลัพธ์เป็น HTTP Response JSON กลับไปยัง Client

ให้นักศึกษาสร้าง Project โดยมีขั้นตอนดังต่อไปนี้

  • สร้าง Folder userservice เข้าไปใน Folder นี้แล้วเริ่มต้นใช้งาน Git ด้วยคำสั่งต่อไปนี้
git init
  • ไปที่ Github.com สร้าง Git Repo ชื่อ userservice เลือกชนิด Repo แบบ Private แล้วกด Create repository
  • เลือก SSH แล้วกด Copy URL
  • เชื่อมต่อกับ Git Repo ด้วยคำสั่ง git remote add origin ตามด้วย URL ที่ได้ Copy มา เช่น
git remote add origin [email protected]:promritn/userservice.git
  • สร้าง File README.md ใน Folder userservice
touch README.md
  • พิมพ์หัวข้อ REST API Project แบบหัวเรื่องระดับ 1 ใน File README.md ด้วย Tag ของ Markdown แล้ว Save
# REST API Project
  • Commit เข้า Git ด้วยคำสั่งต่อไปนี้
git add .

git commit -m 'first commit'
  • Push Code ที่ Commit ขึ้น Github Server
git push origin main

สร้าง Short-Lived Branch ชื่อ feature/restapi-dev

git checkout -b feature/restapi-dev
  • สร้าง File และ Folder ของ Project ตามโครงสร้างดังต่อไปนี้
.
├── .env
├── .gitignore
├── Dockerfile
├── README.md
├── cmd
│   └── main.go
├── docker-compose.yml
└── internal
    ├── config
    │   └── config.go
    ├── handler
    │   └── user_handler.go
    ├── middleware
    │   └── auth_middleware.go
    ├── models
    │   └── user.go
    ├── repository
    │   └── user_repository.go
    └── service
        └── user_service.go
  • แก้ไข File docker-compose.yml
services:
  api:
    build: .
    ports:
      - "${API_PORT}:80"
    env_file:
      - .env
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:80/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 5s
    restart: unless-stopped  # จะ Restart Container อัตโนมัติถ้า Health Check ไม่ผ่าน
docker-compose.yml
  • แก้ไข File .env โดยเปลี่ยน IP Address ของ DB_HOST เป็นเครื่องของคุณ
# .env
# เปลี่ยน IP Address ของ DB_HOST เป็นเครื่องของคุณ
DB_HOST=172.20.10.2
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres123
DB_NAME=postgres
API_TOKEN=fjwfji3399
API_PORT=8080
.env
  • แก้ไข File .gitignore
# .gitignore
.env
.gitignore
  • แก้ไข File Dockerfile
# Build Stage
FROM golang:1.23.3 AS builder

WORKDIR /app

# คัดลอก File go.mod และ go.sum เพื่อให้สามารถ Cache Dependency ได้
COPY go.mod go.sum ./
RUN go mod download

# คัดลอก Code ทั้งหมด
COPY . .

# สร้าง File Binary 'main' จาก Source Code ใน cmd/main.go 
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/main.go

# Run Stage
FROM alpine:latest  

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

WORKDIR /root/
# คัดลอก Binary จาก Builder Stage
COPY --from=builder /app/main .

# กำหนด Entrypoint เป็น Binary main
ENTRYPOINT ["./main"]
  • แก้ไข main.go
package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/gin-gonic/gin"
	_ "github.com/lib/pq"

	"users/internal/config"
	"users/internal/handler"
	"users/internal/middleware"
	"users/internal/repository"
	"users/internal/service"
)

func main() {
	cfg := config.LoadConfig()

	// เชื่อมต่อ Database
	db, err := repository.ConnectDB(cfg)
	if err != nil {
		log.Fatalf("Failed to connect to DB: %v", err)
	}
	defer db.Close()

	userRepo := repository.NewUserRepository(db)
	userService := service.NewUserService(userRepo)
	userHandler := handler.NewUserHandler(userService)

	r := gin.Default()

	// Health Check ไม่ต้องใช้ Token
	r.GET("/health", func(c *gin.Context) {
		if err := repository.CheckDBConnection(db); err != nil {
			c.JSON(http.StatusServiceUnavailable, gin.H{"detail": "Database connection failed"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"status": "healthy", "database": "connected"})
	})

	// Protected routes ต้องใช้ Bearer Token
	authRequired := r.Group("/api/v1", middleware.BearerAuth(cfg.APIToken))
	{
		authRequired.GET("/users", userHandler.GetAllUsers)
		authRequired.GET("/users/:id", userHandler.GetUserByID)
		authRequired.POST("/users", userHandler.CreateUser)
		authRequired.PUT("/users/:id", userHandler.UpdateUser)
		authRequired.DELETE("/users/:id", userHandler.DeleteUser)
	}
	r.Run(":80")
}
  • แก้ไข config.go
package config

import (
	"os"
)

type Config struct {
	DBHost     string
	DBPort     string
	DBUser     string
	DBPassword string
	DBName     string
	APIToken   string
	APIPORT    string
}

func LoadConfig() Config {
	return Config{
		DBHost:     getEnv("DB_HOST", "localhost"),
		DBPort:     getEnv("DB_PORT", "5432"),
		DBUser:     getEnv("DB_USER", "postgres"),
		DBPassword: getEnv("DB_PASSWORD", "postgres123"),
		DBName:     getEnv("DB_NAME", "postgres"),
		APIToken:   getEnv("API_TOKEN", "fjwfji3399"),
		APIPORT:    getEnv("API_PORT", "80"),
	}
}

func getEnv(key, fallback string) string {
	v := os.Getenv(key)
	if v == "" {
		return fallback
	}
	return v
}
  • แก้ไข user_handler.go
package handler

import (
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"

	"users/internal/service"
)

type UserHandler struct {
	userService service.UserService
}

// Inject UserService ผ่าน Constructor
func NewUserHandler(us service.UserService) *UserHandler {
	return &UserHandler{userService: us}
}

func (h *UserHandler) GetAllUsers(c *gin.Context) {
	users, err := h.userService.GetAllUsers()
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
		return
	}
	c.JSON(http.StatusOK, users)
}

func (h *UserHandler) GetUserByID(c *gin.Context) {
	id, _ := strconv.Atoi(c.Param("id"))
	user, err := h.userService.GetUserByID(id)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
		return
	}
	c.JSON(http.StatusOK, user)
}

func (h *UserHandler) CreateUser(c *gin.Context) {
	var req struct {
		Name  string `json:"name"`
		Email string `json:"email"`
	}
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
		return
	}
	user, err := h.userService.CreateUser(req.Name, req.Email)
	if err != nil {
		if err.Error() == "email already exists" {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"})
		} else {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		}
		return
	}
	c.JSON(http.StatusCreated, user)
}

func (h *UserHandler) UpdateUser(c *gin.Context) {
	id, _ := strconv.Atoi(c.Param("id"))

	var req struct {
		Name  string `json:"name"`
		Email string `json:"email"`
	}
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
		return
	}

	user, err := h.userService.UpdateUser(id, req.Name, req.Email)
	if err != nil {
		if err.Error() == "not found" {
			c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
			return
		}
		if err.Error() == "email already exists" {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"})
			return
		}
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, user)
}

func (h *UserHandler) DeleteUser(c *gin.Context) {
	id, _ := strconv.Atoi(c.Param("id"))
	err := h.userService.DeleteUser(id)
	if err != nil {
		if err.Error() == "not found" {
			c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"})
}
  • แก้ไข auth_middleware.go
package middleware

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
)

// BearerAuth ตรวจสอบ Bearer Token ใน Header
func BearerAuth(apiToken string) gin.HandlerFunc {
	return func(c *gin.Context) {
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid Authorization header"})
			return
		}
		token := strings.TrimPrefix(authHeader, "Bearer ")
		if token != apiToken {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
			return
		}
		c.Next()
	}
}
  • แก้ไข user.go
package models

import "time"

type User struct {
	ID        int       `db:"id" json:"id"`
	Name      string    `db:"name" json:"name"`
	Email     string    `db:"email" json:"email"`
	CreatedAt time.Time `db:"created_at" json:"created_at"`
	UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
  • แก้ไข user_service.go
package service

import (
	"errors"
	"strings"

	"users/internal/models"
	"users/internal/repository"
)

type UserService interface {
	GetAllUsers() ([]models.User, error)
	GetUserByID(id int) (*models.User, error)
	CreateUser(name, email string) (*models.User, error)
	UpdateUser(id int, name, email string) (*models.User, error)
	DeleteUser(id int) error
}

type userService struct {
	repo repository.UserRepository
}

// Inject Repository ผ่าน Constructor ตามหลัก DIP
func NewUserService(repo repository.UserRepository) UserService {
	return &userService{repo: repo}
}

func (s *userService) GetAllUsers() ([]models.User, error) {
	return s.repo.GetAll()
}

func (s *userService) GetUserByID(id int) (*models.User, error) {
	return s.repo.GetByID(id)
}

func (s *userService) CreateUser(name, email string) (*models.User, error) {
	if strings.TrimSpace(name) == "" || strings.TrimSpace(email) == "" {
		return nil, errors.New("name and email are required")
	}
	user, err := s.repo.Create(name, email)
	if err != nil && strings.Contains(err.Error(), "duplicate key") {
		return nil, errors.New("email already exists")
	}
	return user, err
}

func (s *userService) UpdateUser(id int, name, email string) (*models.User, error) {
	if strings.TrimSpace(name) == "" || strings.TrimSpace(email) == "" {
		return nil, errors.New("name and email are required")
	}
	user, err := s.repo.Update(id, name, email)
	if err != nil && strings.Contains(err.Error(), "duplicate key") {
		return nil, errors.New("email already exists")
	}
	return user, err
}

func (s *userService) DeleteUser(id int) error {
	return s.repo.Delete(id)
}
  • แก้ไข user_repository.go
package repository

import (
	"database/sql"
	"errors"
	"fmt"
	"time"

	"users/internal/config"
	"users/internal/models"

	_ "github.com/lib/pq"
)

// UserRepository interface ตามหลัก DIP
type UserRepository interface {
	GetAll() ([]models.User, error)
	GetByID(id int) (*models.User, error)
	Create(name, email string) (*models.User, error)
	Update(id int, name, email string) (*models.User, error)
	Delete(id int) error
}

type userRepository struct {
	db *sql.DB
}

func NewUserRepository(db *sql.DB) UserRepository {
	return &userRepository{db: db}
}

func ConnectDB(cfg config.Config) (*sql.DB, error) {
	// (DSN) Data Source Name คือ String ที่ใช้ระบุข้อมูลการเชื่อมต่อกับฐานข้อมูล
	// การใช้งาน DSN จะขึ้นอยู่กับ Library หรือ Framework ที่ใช้งาน
	dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName)

	db, err := sql.Open("postgres", dsn)
	if err != nil {
		return nil, fmt.Errorf("failed to connect to database: %w", err)
	}
	// ตั้งค่า Connection Pool
	db.SetMaxOpenConns(25)                 // จำนวน Connection สูงสุดที่สามารถเปิดได้
	db.SetMaxIdleConns(10)                 // จำนวน Connection สูงสุดที่สามารถอยู่ใน Idle State
	db.SetConnMaxLifetime(5 * time.Minute) // อายุการใช้งานสูงสุดของ Connection

	if err := db.Ping(); err != nil {
		return nil, fmt.Errorf("failed to ping database: %w", err)
	}

	return db, nil
}

func CheckDBConnection(db *sql.DB) error {
	return db.Ping()
}

func (r *userRepository) GetAll() ([]models.User, error) {
	rows, err := r.db.Query("SELECT id, name, email, created_at, updated_at FROM users ORDER BY id")
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var users []models.User
	for rows.Next() {
		var u models.User
		if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt, &u.UpdatedAt); err != nil {
			return nil, err
		}
		users = append(users, u)
	}
	return users, rows.Err()
}

func (r *userRepository) GetByID(id int) (*models.User, error) {
	var u models.User
	err := r.db.QueryRow("SELECT id, name, email, created_at, updated_at FROM users WHERE id=$1", id).
		Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt, &u.UpdatedAt)

	if err == sql.ErrNoRows {
		return nil, errors.New("not found")
	} else if err != nil {
		return nil, err
	}
	return &u, nil
}

func (r *userRepository) Create(name, email string) (*models.User, error) {
	var u models.User
	err := r.db.QueryRow(
		"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email, created_at, updated_at",
		name, email,
	).Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt, &u.UpdatedAt)

	if err != nil {
		return nil, err
	}
	return &u, nil
}

func (r *userRepository) Update(id int, name, email string) (*models.User, error) {
	var u models.User
	err := r.db.QueryRow(
		"UPDATE users SET name=$1, email=$2, updated_at=now() WHERE id=$3 RETURNING id, name, email, created_at, updated_at",
		name, email, id,
	).Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt, &u.UpdatedAt)

	if err == sql.ErrNoRows {
		return nil, errors.New("not found")
	} else if err != nil {
		return nil, err
	}
	return &u, nil
}

func (r *userRepository) Delete(id int) error {
	res, err := r.db.Exec("DELETE FROM users WHERE id=$1", id)
	if err != nil {
		return err
	}
	rowsAffected, _ := res.RowsAffected()
	if rowsAffected == 0 {
		return errors.New("not found")
	}
	return nil
}

สร้าง File go.mod ด้วยคำสั่ง go mod init users

go mod init users

ติดตั้ง Package "github.com/lib/pq"

go get github.com/lib/pq

ติดตั้ง Package "github.com/gin-gonic/gin"

go get github.com/gin-gonic/gin

รันคำสั่ง go mod tidy ทุกครั้งที่มีการเปลี่ยนแปลง Dependency เพื่อรักษาความสอดคล้องและความสมบูรณ์ของ Dependency ใน Project

go mod tidy

ก่อน Commit Code เข้า Git นักพัฒนาต้องทดสอบก่อนว่ามันทำงานได้

  • Deploy API
docker-compose up -d
  • ตรวจสอบ Container ที่ Deploy
docker-compose ps
  • ดู Logs ของ Container ที่รัน
docker-compose logs
  • ทดลองยิง API เส้น /health ผ่าน curl
curl http://localhost:8080/health
  • ทดลองยิง API เส้น /health ผ่าน Postman
  • ทดลองดึงข้อมูล users ทั้งหมดจาก Database ผ่าน Postman ด้วย Method GET (ต้องมี Bearer Token ใน Header)
http://localhost:8080/api/v1/users
  • ทดลองดึงข้อมูล users คนที่ 1 จาก Database ผ่าน Postman ด้วย Method GET (ต้องมี Bearer Token ใน Header)
http://localhost:8080/api/v1/users/1
  • ทดลองเพิ่ม users ใหม่ลง Database โดยรับข้อมูลแบบ JSON Format ผ่าน Postman ด้วย Method POST (ต้องมี Bearer Token ใน Header)
http://localhost:8080/api/v1/users
{
        "name": "apple",
        "email": "[email protected]"
}
JSON Format
  • ทดลองแก้ไข Email ของ User คนที่ 1 เป็น [email protected] ผ่าน JSON Format ด้วย Method PUT (ต้องมี Bearer Token ใน Header)
http://localhost:8080/api/v1/users/1
{
        "name": "nuttachot promrit",
        "email": "[email protected]"
}
  • ทดลองลบ User คนที่ 3 ด้วย Method DELETE (ต้องมี Bearer Token ใน Header)
http://localhost:8080/api/v1/users/3
  • เมื่อทดสอบการยิง API แต่ละเส้นแล้วจึง Commit Code เข้า Git ด้วยคำสั่งต่อไปนี้
git add .

git commit -m 'rest api dev'

ดู History ที่ Commit

git log --oneline

Rebase โดยนำโค้ดใน Branch ของเรามา Update ให้ตรงกับ Trunk ล่าสุด

  • แต่ก่อน Rebase ควรดึงการเปลี่ยนแปลงล่าสุดจาก Trunk เพื่อให้แน่ใจว่า Branch ของเราอยู่ในสถานะล่าสุด เหมือนบน Github Server
git checkout main

git pull origin main
  • Rebase เพื่อนำโค้ดใน Branch ของเรามา Update ให้ตรงกับ Trunk ล่าสุด
git checkout feature/restapi-dev

git rebase main
  • เมื่อ Code ใน Branch feature/restapi-dev ตรงกับ Trank ล่าสุดแล้ว จึง Merge กลับไปยัง Trunk (ถ้ามี Conflict ให้แก้ไข Conflict ก่อน Merge)
git checkout main
git merge feature/restapi-dev
  • Push Code ขึ้น Github Server
git push origin main
  • ลบ Branch ย่อยที่เสร็จแล้ว
git branch -d feature/restapi-dev
  • ดู History ทั้งหมด
git log --oneline

Expose API ให้เข้าถึงจาก Internet

การติดตั้ง Cloudflared บน Linux

sudo apt update

sudo wget -O cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64

sudo mv cloudflared /usr/local/bin/

sudo chmod +x /usr/local/bin/cloudflared

cloudflared --version

การติดตั้ง Cloudflared บน maxOS

# ติดตั้ง wget (macOS ไม่มี wget มาให้ตั้งแต่ต้น อาจใช้ brew เพื่อติดตั้ง)
brew install wget

# Download File สำหรับ macOS (Darwin)
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz -O cloudflared.tgz

# แตก File
tar -xvzf cloudflared.tgz

# เพิ่ม Permission ให้ File executable ได้
chmod +x cloudflared

# ย้าย Binary File ไปยัง /usr/local/bin
sudo mv cloudflared /usr/local/bin/cloudflared

# ตรวจสอบ Version
cloudflared --version

การติดตั้ง Cloudflared บน Windows

  • เปิด Command Prompt (กดปุ่ม Start พิมพ์ cmd แล้ว Enter)
# ใช้คำสั่ง curl เพื่อ Download File cloudflared.exe
curl -L -o cloudflared.exe https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe

# ตรวจสอบ Version
cloudflared.exe --version

Expose API

  • สร้าง Cloudflare Tunnel
cloudflared tunnel --url http://localhost:8080
  • Copy URL ที่ได้นำไปเปิดบน Browser โดยเพิ่ม Path /health เช่น
https://processed-om-continued-communication.trycloudflare.com/health