- Published on
เรียนรู้ Go - ทำ Todo API ต่อกับ MongoDB
อันยองงง วันนี้เราจะมาเขียน Go ต่อยอดจากบทความก่อน ๆ คือเดี๋ยววันนี้เจมส์จะเขียนให้ Connect กับ MongoDB database แล้วก็ปรับให้ API ต่าง ๆ ที่ตอนแรกข้อมูลเรียกจาก memory ให้ไปเรียกจาก MongoDB แทนกันครับ
ในส่วนนี้เจมส์จะต่อยอดจากบทความก่อนหน้านี้เน้อครับ เรียนรู้ Go - compile อัตโนมัติ ด้วย Air บน Docker
มาเริ่มกันเลย
สร้าง MongoDB ใน docker-compose.yaml
ขั้นแรกเดี๋ยวเราจะมาสร้าง MongoDB ใน docker-compose.yaml
กันก่อนเลยครับ โดยเราจะปรับ docker-compose.yaml
เป็นแบบนี้ครับ
version: "3.8"
services:
database:
image: mongo:8.0.0-rc9
container_name: db.simple-todo
restart: always
ports:
- 27017:27017
env_file: .env
volumes:
- ./database/init-mongo.sh:/docker-entrypoint-initdb.d/init-mongo.sh
- ./resources/db:/data/db
จากนั้นเราจะสร้าง directory ชื่อ database
ขึ้นมาและสร้างไฟล์ในนั้นชื่อ init-mongo.sh
และใส่โค้ดดังนี้ครับ
mongosh -- "$MONGO_INITDB_DATABASE" <<EOF
var rootUser = '$MONGO_INITDB_ROOT_USERNAME';
var rootPassword = '$MONGO_INITDB_ROOT_PASSWORD';
var admin = db.getSiblingDB('admin');
admin.auth(rootUser, rootPassword);
var user = '$MONGO_INITDB_USERNAME';
var passwd = '$MONGO_INITDB_PASSWORD';
db.createUser({ user: user, pwd: passwd, roles: ["readWrite"] });
EOF
จากนั้นให้สร้างไฟล์ .env
ที่ root project ครับ และใส่โค้ดดังนี้ เพื่อตั้งค่า MongoDB
MONGO_INITDB_DATABASE=simple-todo
MONGO_INITDB_ROOT_USERNAME=admin
MONGO_INITDB_ROOT_PASSWORD=123456
MONGO_INITDB_USERNAME=root
MONGO_INITDB_PASSWORD=123456
MONGO_INITDB_DATABASE
คือชื่อ database MONGO_INITDB_USERNAME
คือชื่อ username (สำหรับเข้า database) MONGO_INITDB_PASSWORD
คือชื่อ password (สำหรับเข้า database)
ในตอนนี้หากเราสั่ง docker-compose up
ก็จะสามารถสร้าง MongoDB ขึ้นมาได้แล้ว
เอา service api ใส่ใน docker-compose.yaml
ขั้นต่อไปเดี๋ยวเราจะเอา service api มาใส่ใน docker-compose.yaml
กันครับ โดยโค้ดของ docker-compose.yaml
จะเป็นดังนี้ครับ
version: "3.8"
services:
database:
image: mongo:8.0.0-rc9
container_name: db.simple-todo
restart: always
ports:
- 27017:27017
env_file: .env
volumes:
- ./database/init-mongo.sh:/docker-entrypoint-initdb.d/init-mongo.sh
- ./resources/db:/data/db
api:
container_name: api.simple-todo
ports:
- 2000:2000
build:
context: ./
dockerfile: dev.Dockerfile
env_file: .env
environment:
- PORT=2000
volumes:
- ./:/app
depends_on:
- database
ซึ่งถ้าใครทำมาจากบทความก่อนหน้านี้จะมีไฟล์ dev.Dockerfile
อยู่แล้ว แต่ถ้าใครพึ่งมาอ่านบทความนี้ โค้ดในส่วน dev.Dockerfile
จะเป็นดังนี้ครับ
FROM golang:1.22-alpine
WORKDIR /app
RUN go install github.com/air-verse/air@latest
COPY go.mod go.sum ./
RUN go mod download
CMD ["air"]
หากคุณผู้อ่านไม่ได้อ่านบทความก่อน ๆ มา มาอ่านบทความนี้เลย สิ่งที่ต้องทำเพิ่มคือ init project go โดยใช้คำสั่ง
go mod init simple-todo
และ Install gin web framework โดยใช้คำสั่งgo get -u github.com/gin-gonic/gin
ลองทำ GET /todos โดยดึงข้อมูลจาก MongoDB
ในขั้นตอนต่อไปเดี๋ยวเรามาทำให้ในส่วน GET /todos โดยจะเรียกข้อมูลจากใน MongoDB กันครับ
ขั้นแรกให้เราติดตั้ง MongoDB Go Driver ก่อนครับ โดยใช้คำสั่ง
go get go.mongodb.org/mongo-driver/mongo
จากนั้นให้เราปรับไฟล์ .env โดยเพิ่ม config ด้านล่างเข้าไป
MONGO_SERVER=mongodb://root:123456@database:27017/simple-todo
ขั้นต่อมาให้สร้าง directory ชื่อ config
ขึ้นมาครับ จากนั้นให้สร้างไฟล์ชื่อ config.go
ใน directory นั้นและใส่โค้ดดังนี้ครับ
package config
import (
"context"
"log"
"os"
"time"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
var Client *mongo.Client = CreateMongoClient()
func CreateMongoClient() *mongo.Client {
mongoURI := os.Getenv("MONGO_SERVER")
if mongoURI == "" {
log.Fatal("[ConnectDB] - Error: MONGO_SERVER is not set in .env file")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoURI))
if err != nil {
log.Fatal("[ConnectDB] - Error: Connection error!")
}
// ตรวจสอบการเชื่อมต่อด้วย Ping
err = client.Ping(ctx, nil)
if err != nil {
log.Fatal("[ConnectDB] - Error: Ping error!")
}
log.Println("[ConnectDB] - DEBUG: Connected to MongoDB!")
return client
}
func OpenCollection(client *mongo.Client, collectionName string) *mongo.Collection {
var database = os.Getenv("MONGO_INITDB_DATABASE")
return client.Database(database).Collection(collectionName)
}
ซึ่งจะเป็น config เกี่ยวกับการ connect database และเกี่ยวกับ เรียกใช้ collection
จากนั้นเดี๋ยวเราจะมาสร้าง models ครับ โดยเราจะเอา Todo struct
ที่เราเคยสร้างไว้ในบทความแรกมาแก้ไขนิดหน่อยครับ
เราจะเพิ่ม ในส่วนที่เป็น bson เข้าไป หรือใครที่พึ่งเข้ามาอ่านบทความนี้ ก็สามารถ copy code ด้านล่างไปใช้ได้เลยครับ
เดี๋ยวเราจะมาสร้าง directory ชื่อ models และสร้าง models.go
ด้านใน โดยใส่โค้ดลงไปดังนี้ครับ
package models
import "go.mongodb.org/mongo-driver/bson/primitive"
type Todo struct {
Id primitive.ObjectID `bson:"_id"`
Task string `json:"task" binding:"required" bson:"task"`
Status string `json:"status" bson:"status"`
}
เราจะมาแก้ไขไฟล์ main.go
ใหม่เป็นดังนี้ครับ
package main
import (
"net/http"
"simple-todo/controllers"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/mongo"
)
var Client *mongo.Client
func main() {
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Server is running",
})
})
r.GET("/todos", controllers.GetTodos)
r.Run(":2000")
}
จากนั้นเราจะมาสร้าง directory ชื่อ controllers
และสร้างไฟล์ชื่อ todoController.go
โดยใส่โค้ดลงไปดังนี้ครับ
package controllers
import (
"context"
"net/http"
"simple-todo/config"
"simple-todo/models"
"time"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
)
var todoCollection *mongo.Collection = config.OpenCollection(config.Client, "todos")
func GetTodos(c *gin.Context) {
// สร้าง context สำหรับการดึงข้อมูล
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cursor, err := todoCollection.Find(ctx, bson.M{})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"FindError": err.Error()})
return
}
defer cursor.Close(ctx)
var todos []models.Todo
for cursor.Next(ctx) {
var todo models.Todo
err := cursor.Decode(&todo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"Decode Error": err.Error()})
return
}
todos = append(todos, todo)
}
if todos == nil {
todos = []models.Todo{}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"todos": todos,
})
}
คือเราจะให้ GET /todos
เรียก function GetTodos ใน todoController.go
ซึ่งใน GetTods เราจะ find ขึ้นมูลใน todoCollection แล้ว return กลับไปครับ
เมื่อคุณผู้อ่านลอง GET ไปที่ http://localhost:2000/todos จะพบว่าได้ return ดังนี้
{
"success": true,
"todos": []
}
ลองทำ POST /todos ให้เพิ่มข้อมูลลงใน MongoDB
ในขั้นต่อมา เดี๋ยวเรามาทำในส่วนของ POST /todos
กันบ้างครับ โดยจะทำให้เพิ่มข้อมูล แล้ว insert ข้อมูลใส่ใน MongoDB ครับ
โดยใน main.go
เราจะเพิ่ม route
สำหรับ POST todos โดยโค้ดใหม่จะเป็นดังโค้ดด้านล่างนี้ครับ
r.POST("/todos", controllers.CreateTodo)
จากนั้นเราจะเพิ่ม function CreateTodo เข้ามาใน todoController.go
ครับ โดยจะเพิ่มโค้ดด้านล่างเข้าไปครับ
func CreateTodo(c *gin.Context) {
var todo models.Todo
// ผูกข้อมูลจาก request body กับ struct User
if err := c.ShouldBindJSON(&todo); err != nil {
// ถ้ามีข้อผิดพลาดในการผูกข้อมูล
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
todo.Id = primitive.NewObjectID()
todo.Status = "todo"
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := todoCollection.InsertOne(ctx, todo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"success": true,
"todo": todo,
})
}
ด้านบนต้อง import "go.mongodb.org/mongo-driver/bson/primitive" เพิ่มเข้ามาด้วยเน้อครับ
ในส่วน CreateTodo เราจะรับ params ชื่อ task เข้ามาเพื่อให้เพิ่มงานนั้น ๆ ลงไป โดยเราจะกำหนด status เริ่มต้นให้เป็น todo
ซึ่งเมื่อ insert ค่า todo เรียบร้อยแล้วเราจะ return ข้อมูล todo สุดที่เพิ่ม return กลับออกไป
เราลองมายิง POST ไปที่ http://localhost:2000/todos โดยส่ง task
ไป จะพบว่าเมื่อเรายิง post request เรียบร้อย จะได้ response success และค่า todo ตามที่เราได้สร้าง ซึ่งเมื่อไปดูใน MongoDB ของเรา จะพบว่ามีข้อมูลอยู่ใน MongoDB เรียบร้อยแล้ว
ลองทำ GET /todos/:id ดึงข้อมูลจาก id
เพื่อไม่เป็นการเสียเวลาเรามาต่อกันที่ การดึงข้อมูล todo ด้วย id กันครับ
เราจะเพิ่ม route ใน main.go
ต่อครับ
r.GET("/todos/:id", controllers.GetTodoById)
จากนั้นเราจะเพิ่ม function GetTodoById ใน todoController.go
โดยจะเพิ่ม
func GetTodoById(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
id := c.Param("id")
objId, _ := primitive.ObjectIDFromHex(id)
var todo models.Todo
err := todoCollection.FindOne(ctx, bson.M{"_id": objId}).Decode(&todo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error ": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"todo": todo,
})
}
ใน function GetTodoById เราจะรับ param id เข้ามา และเอาไป findOne ใน todoCollection แล้ว return ผลลัพธ์ที่ได้ออกไปครับ
ลองทำ DELETE /todos/:id ลบข้อมูลจาก id
มาต่อกันที่การลบ Task ด้วย id กันครับ
เราจะเพิ่ม route ใน main.go
สำหรับ DELETE เข้าไปครับ
r.DELETE("/todos/:id", controllers.DeleteTodo)
จากนั้นเราจะมาเพิ่ม function DeleteTodo ใน todoController.go
กันครับ โดยจะเพิ่ม
func DeleteTodo(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
id := c.Param("id")
objId, _ := primitive.ObjectIDFromHex(id)
deleteResult, err := todoCollection.DeleteOne(ctx, bson.M{"_id": objId})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if deleteResult.DeletedCount == 0 {
msg := fmt.Sprintf("No todo with id : %v was found, no deletion occurred.", id)
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
c.JSON(http.StatusNoContent, gin.H{
"success": true,
"id": objId,
})
}
ด้านบนต้อง import "fmt" เพิ่มเข้ามาด้วยเน้อครับ
ซึ่งการทำงานใน function DeleteTodo ก็คือรับ param id เข้ามา และเอาไปเรียกคำสั่ง DeleteOne ใน todoController โดยส่ง id ที่จะลบเข้าไปด้วย
ลองทำ PATCH /todos/:id แก้ไขข้อมูลด้วย id
เดี๋ยวเราจะมาทำแก้ไขข้อมูลจาก id โดยจะส่ง task หรือ status ที่ต้องการแก้ไขเข้ามาด้วยครับ ซึ่ง status เราจะให้ส่งได้แค่ todo
, doing
หรือ done
เท่านั้น
ในส่วนนี้เราจะลง github.com/go-playground/validator/v10
เพิ่มเติมครับ
go get github.com/go-playground/validator/v10
ขั้นตอนถัดมา เราจะเพิ่ม route ใน main.go
เข้าไปดังนี้ครับ
r.PATCH("/todos/:id", controllers.UpdateTodo)
จากนั้นเราจะมาแก้ไขไฟล์ models.go
ที่อยู่ใน directory models กันครับ โดยจะเพิ่ม struct ชื่อ UpdateTodo โดยมีโค้ดดังนี้ครับ
type UpdateTodo struct {
Task *string `json:"task,omitempty" bson:"task"`
Status *string `json:"status,omitempty" validate:"oneof=todo doing done" bson:"status"`
}
จากนั้นเราจะมาเพิ่ม function UpdateTodo ใน todoController.go
กันครับ โดยจะเพิ่มโค้ดส่วนนี้ลงไป
func UpdateTodo(c *gin.Context) {
var validate *validator.Validate = validator.New()
var reqTodo models.UpdateTodo
if err := c.BindJSON(&reqTodo); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if reqTodo.Task != nil && *reqTodo.Task == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Task cannot be empty"})
return
}
updateData := bson.M{}
if reqTodo.Task != nil {
updateData["task"] = *reqTodo.Task
}
if reqTodo.Status != nil {
if err := validate.Var(reqTodo.Status, "oneof=todo doing done"); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status value"})
return
}
updateData["status"] = *reqTodo.Status
}
if len(updateData) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No valid fields to update"})
return
}
id := c.Param("id")
objId, _ := primitive.ObjectIDFromHex(id)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := todoCollection.UpdateOne(ctx, bson.M{"_id": objId}, bson.M{"$set": updateData})
if result.ModifiedCount == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task id"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
fmt.Println(err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"todo": reqTodo,
})
}
ด้านบนต้อง import "github.com/go-playground/validator" เพิ่มเข้ามาด้วยเน้อครับ
โค้ดในส่วนอัพเดทคือ ถ้าหากมี task ส่งมา โดยต้องไม่เป็นค่าว่าง หรือมี status ส่งมาโดยต้องเป็น todo
, doing
หรือ done
ก็จะอัพเดทข้อมูลใน MongoDB โดยดูจาก id ที่ส่งเข้ามา
ถ้าหากไม่มี task และ status ส่งมาจะ return No valid fields to update
ออกไป
โค้ดทั้งหมดของ main.go
จะเป็นดังนี้ครับ
package main
import (
"net/http"
"simple-todo/controllers"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/mongo"
)
var Client *mongo.Client
func main() {
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Server is running",
})
})
r.GET("/todos", controllers.GetTodos)
r.GET("/todos/:id", controllers.GetTodoById)
r.POST("/todos", controllers.CreateTodo)
r.DELETE("/todos/:id", controllers.DeleteTodo)
r.PATCH("/todos/:id", controllers.UpdateTodo)
r.Run(":2000")
}
โค้ดทั้งหมดของ models.go
จะเป็นดังนี้ครับ
package models
import "go.mongodb.org/mongo-driver/bson/primitive"
type Todo struct {
Id primitive.ObjectID `bson:"_id"`
Task string `json:"task" binding:"required" bson:"task"`
Status string `json:"status" bson:"status"`
}
type UpdateTodo struct {
Task *string `json:"task,omitempty" bson:"task"`
Status *string `json:"status,omitempty" validate:"oneof=todo doing done" bson:"status"`
}
โค้ดทั้งหมดของ todoController.go
จะเป็นดังนี้ครับ
package controllers
import (
"context"
"fmt"
"net/http"
"simple-todo/config"
"simple-todo/models"
"time"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
var todoCollection *mongo.Collection = config.OpenCollection(config.Client, "todos")
func GetTodos(c *gin.Context) {
// สร้าง context สำหรับการดึงข้อมูล
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cursor, err := todoCollection.Find(ctx, bson.M{})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"FindError": err.Error()})
return
}
defer cursor.Close(ctx)
var todos []models.Todo
for cursor.Next(ctx) {
var todo models.Todo
err := cursor.Decode(&todo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"Decode Error": err.Error()})
return
}
todos = append(todos, todo)
}
if todos == nil {
todos = []models.Todo{}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"todos": todos,
})
}
func GetTodoById(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
id := c.Param("id")
objId, _ := primitive.ObjectIDFromHex(id)
var todo models.Todo
err := todoCollection.FindOne(ctx, bson.M{"_id": objId}).Decode(&todo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error ": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"todo": todo,
})
}
func CreateTodo(c *gin.Context) {
var todo models.Todo
// ผูกข้อมูลจาก request body กับ struct User
if err := c.ShouldBindJSON(&todo); err != nil {
// ถ้ามีข้อผิดพลาดในการผูกข้อมูล
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
todo.Id = primitive.NewObjectID()
todo.Status = "todo"
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := todoCollection.InsertOne(ctx, todo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"success": true,
"todo": todo,
})
}
func DeleteTodo(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
id := c.Param("id")
objId, _ := primitive.ObjectIDFromHex(id)
deleteResult, err := todoCollection.DeleteOne(ctx, bson.M{"_id": objId})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if deleteResult.DeletedCount == 0 {
msg := fmt.Sprintf("No todo with id : %v was found, no deletion occurred.", id)
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
c.JSON(http.StatusNoContent, gin.H{
"success": true,
"id": objId,
})
}
func UpdateTodo(c *gin.Context) {
var validate *validator.Validate = validator.New()
var reqTodo models.UpdateTodo
if err := c.BindJSON(&reqTodo); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if reqTodo.Task != nil && *reqTodo.Task == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Task cannot be empty"})
return
}
updateData := bson.M{}
if reqTodo.Task != nil {
updateData["task"] = *reqTodo.Task
}
if reqTodo.Status != nil {
if err := validate.Var(reqTodo.Status, "oneof=todo doing done"); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status value"})
return
}
updateData["status"] = *reqTodo.Status
}
if len(updateData) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No valid fields to update"})
return
}
id := c.Param("id")
objId, _ := primitive.ObjectIDFromHex(id)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := todoCollection.UpdateOne(ctx, bson.M{"_id": objId}, bson.M{"$set": updateData})
if result.ModifiedCount == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task id"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
fmt.Println(err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"todo": reqTodo,
})
}
เรียบร้อยแล้วครับ หากบทความนี้มีส่วนไหนผิดพลาดประการใด ก็ขออภัยมา ณ ที่นี้ด้วยเน้อครับ
Github: https://github.com/jame3032002/simple-todo-with-go