Case Study: Cách xây dựng các trường lặp lại (Repeater) với Gorm trong Golang

Gần đây trong quá trình phát triển Nam Digital CMS Nam gặp phải rất nhiều thách thức, điều đáng nói là những điều này lại rất dễ thực hiện với Wordpress, cụ thể như:

  • Các đối tượng được tạo mới có nhiều trường lặp lại tạo thành các mảng (Array). Ví dụ trong đối tượng Project thì sẽ có Sliders là 1 mảng gồm nhiều Image chẳng hạn. Với Wordpress thì quá đơn giản, chỉ cần dùng ACF Block tạo các khu Repeat Block là đã có thể xây dựng và custom khối trường rồi. 
  • Làm thế nào để xây dựng giao diện Admin giúp làm việc dễ dàng (Với Wordpress ta có wp-admin, cung cấp giao diện cũng như những tiêu chuẩn khi xây dựng trường qua các Custom Fields, Meta boxes và gần nhất là Block 

Chính vì 2 lý do kể tren mà đôi khi Nam cũng đặt câu hỏi về độ đầy đủ mà Nam Digital CMS mang lại vì nó cơ bản là ... quá sơ sài, điểm mạnh của nó nằm ở tốc độ, khả năng tùy biến và nhất là nó do mình phát triển mà thôi  ^^!

Tuy vậy qua quá trình tìm hiểu thì Nam đã tìm được cách rất hay để xây dựng các trường lặp lại với Golang, GORM chạy trên CSDL Postgres

Thay đổi Mindset trong cách xây dựng Database

Một ngày Nam lên đọc Stackoverflow về vấn đề cách lồng 1 Array vào CSDL Postgres thì BOOM!, nhận được câu trả lời là "cực kỳ không nên", thay vì làm cách khó khăn kể trên, hãy tạo các bảng và ánh xạ chúng sang nhau thì dễ dàng hơn rất nhiều. 

Giả dụ đối tượng Project sẽ là dạng 

 

type Project struct {
    ID           uint                `gorm:"primaryKey"`
    Name         string              `gorm:"not null"` // Tên dự án
    URL          string              `gorm:"unique"`   // Đường dẫn
    Slider       []SliderProject     // Khối Upload Slider
    Introduction IntroductionProject // Khối Giới thiệu
    Repeater     []RepeaterProject   // Khối Repeater
    Gallery      GalleryProject      // Khối Thư viện ảnh
    Location     LocationProject     // Vị trí Google Maps
    CreatedAt    time.Time
    UpdatedAt    time.Time
}

Thì trong đó đối tượng Slider sẽ là 1 mảng gồm các thành phần như sau:

 

type SliderProject struct {
    ID        uint   `gorm:"primaryKey"`
    ProjectID uint   // Liên kết đến dự án
    ImageURL  string // Đường dẫn ảnh
    Caption   string // Chú thích (nếu có)
    CreatedAt time.Time
    UpdatedAt time.Time
}

Như vậy ta sẽ xây dựng 2 bảng, 1 bảng là projects (Dự án) và 1 bảng slider_project (Slider của dự án), và tạo quan hệ một - nhiều tới bảng dữ liệu đó. 

Đây là đầy đủ những trường sẽ bám theo Project (đòi hỏi ta phải xây dựng các bảng dữ liệu tương ứng). Bạn có thể dùng AutoMirage nhưng Nam không thích lắm, do sợ mình làm sai, dữ liệu đổ ra sẽ không chuẩn. Nam thường hay tạo bằng Pg4admin để thử nghiệm cho chắc chắn đã. 

 

type Project struct {
    ID           uint                `gorm:"primaryKey"`
    Name         string              `gorm:"not null"` // Tên dự án
    URL          string              `gorm:"unique"`   // Đường dẫn
    Slider       []SliderProject     // Khối Upload Slider
    Introduction IntroductionProject // Khối Giới thiệu
    Repeater     []RepeaterProject   // Khối Repeater
    Gallery      GalleryProject      // Khối Thư viện ảnh
    Location     LocationProject     // Vị trí Google Maps
    CreatedAt    time.Time
    UpdatedAt    time.Time
}
type SliderProject struct {
    ID        uint   `gorm:"primaryKey"`
    ProjectID uint   // Liên kết đến dự án
    ImageURL  string // Đường dẫn ảnh
    Caption   string // Chú thích (nếu có)
    CreatedAt time.Time
    UpdatedAt time.Time
}

type IntroductionProject struct {
    ID          uint   `gorm:"primaryKey"`
    ProjectID   uint   // Liên kết đến dự án
    Title       string // Tiêu đề, lời dẫn
    Description string // Mô tả
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

type RepeaterProject struct {
    ID                uint   `gorm:"primaryKey"`
    ProjectID         uint   // Liên kết đến dự án
    Icon              string // Icon
    Title             string // Tiêu đề
    Description       string // Mô tả
    Number            int    // Con số (nếu có)
    NumberDescription string // Mô tả con số (nếu có)
    SectionTitle      string // Tiêu đề của section
    SectionContent    string // Content (có thể có hoặc không)
    SectionImageURL   string // Đường dẫn ảnh (Slider)
    CreatedAt         time.Time
    UpdatedAt         time.Time
}

type GalleryProject struct {
    ID        uint           `gorm:"primaryKey"`
    ProjectID uint           // Liên kết đến dự án
    Interiors []ImageProject `gorm:"foreignKey:GalleryID;where:category = 'Interior'"`
    Exteriors []ImageProject `gorm:"foreignKey:GalleryID;where:category = 'Exterior'"`
    LegalDocs []ImageProject `gorm:"foreignKey:GalleryID;where:category = 'Legal'"`
    CreatedAt time.Time
    UpdatedAt time.Time
}

type ImageProject struct {
    ID        uint   `gorm:"primaryKey"`
    GalleryID uint   `gorm:"index"` // Khóa ngoại liên kết đến GalleryProject
    ImageURL  string // Đường dẫn ảnh
    Category  string // Nội thất, Ngoại thất, Pháp lý
    CreatedAt time.Time
    UpdatedAt time.Time
}

type LocationProject struct {
    ID        uint `gorm:"primaryKey"`
    ProjectID uint // Liên kết đến dự án
    EmbedURL  string
    CreatedAt time.Time
    UpdatedAt time.Time
}

Cơ bản là như vậy, tiếp đó ta sẽ xây dựng Logic để Up nội dung Project mới (Lưu ý là ví dụ Nam đưa ra hoàn toàn dựa trên phiên bản thử nghiệm, không có dòng code nào thuộc về bản Production nhé)

 


func NewProjectHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method == http.MethodGet {

        // Render template cao-phong.html with the fetched articles
        err := config.Tpl.ExecuteTemplate(w, "new-project.html", nil)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
        }
    }

    if r.Method == http.MethodPost {
        var input ProjectInput

        // Parse dữ liệu form
        if err := r.ParseForm(); err != nil {
            http.Error(w, "Failed to parse form", http.StatusBadRequest)
            return
        }

        // Giải mã dữ liệu form vào ProjectInput
        if err := decoder.Decode(&input, r.PostForm); err != nil {
            http.Error(w, "Failed to decode form data: "+err.Error(), http.StatusBadRequest)
            return
        }

        // Tạo Project cùng với các dữ liệu liên quan
        if err := CreateProject(config.Db, input); err != nil {
            http.Error(w, "Failed to create project", http.StatusInternalServerError)
            return
        }

        // Chuyển hướng đến trang khác sau khi tạo thành công
        http.Redirect(w, r, "/projects", http.StatusSeeOther)
    }
}

func CreateProject(db *gorm.DB, input ProjectInput) error {
    return config.Db.Transaction(func(tx *gorm.DB) error {
        project := models.Project{
            Name:      input.Name,
            URL:       input.URL,
            CreatedAt: time.Now(),
            UpdatedAt: time.Now(),
        }

        if err := tx.Create(&project).Error; err != nil {
            return err
        }

        for _, slider := range input.Sliders {
            sliderProject := models.SliderProject{
                ProjectID: project.ID,
                ImageURL:  slider.ImageURL,
                Caption:   slider.Caption,
                CreatedAt: time.Now(),
                UpdatedAt: time.Now(),
            }
            if err := tx.Create(&sliderProject).Error; err != nil {
                return err
            }
        }

        introductionProject := models.IntroductionProject{
            ProjectID:   project.ID,
            Title:       input.Introduction.Title,
            Description: input.Introduction.Description,
            CreatedAt:   time.Now(),
            UpdatedAt:   time.Now(),
        }
        if err := tx.Create(&introductionProject).Error; err != nil {
            return err
        }

        for _, repeater := range input.Repeaters {
            repeaterProject := models.RepeaterProject{
                ProjectID:         project.ID,
                Icon:              repeater.Icon,
                Title:             repeater.Title,
                Description:       repeater.Description,
                Number:            repeater.Number,
                NumberDescription: repeater.NumberDescription,
                SectionTitle:      repeater.SectionTitle,
                SectionContent:    repeater.SectionContent,
                SectionImageURL:   repeater.SectionImageURL,
                CreatedAt:         time.Now(),
                UpdatedAt:         time.Now(),
            }
            if err := tx.Create(&repeaterProject).Error; err != nil {
                return err
            }
        }

        galleryProject := models.GalleryProject{
            ProjectID: project.ID,
            CreatedAt: time.Now(),
            UpdatedAt: time.Now(),
        }
        if err := tx.Create(&galleryProject).Error; err != nil {
            return err
        }

        for _, interior := range input.Gallery.Interiors {
            imageProject := models.ImageProject{
                GalleryID: galleryProject.ID,
                ImageURL:  interior.ImageURL,
                Category:  "Interior",
                CreatedAt: time.Now(),
                UpdatedAt: time.Now(),
            }
            if err := tx.Create(&imageProject).Error; err != nil {
                return err
            }
        }

        for _, exterior := range input.Gallery.Exteriors {
            imageProject := models.ImageProject{
                GalleryID: galleryProject.ID,
                ImageURL:  exterior.ImageURL,
                Category:  "Exterior",
                CreatedAt: time.Now(),
                UpdatedAt: time.Now(),
            }
            if err := tx.Create(&imageProject).Error; err != nil {
                return err
            }
        }

        for _, legalDoc := range input.Gallery.LegalDocs {
            imageProject := models.ImageProject{
                GalleryID: galleryProject.ID,
                ImageURL:  legalDoc.ImageURL,
                Category:  "Legal",
                CreatedAt: time.Now(),
                UpdatedAt: time.Now(),
            }
            if err := tx.Create(&imageProject).Error; err != nil {
                return err
            }
        }

        locationProject := models.LocationProject{
            ProjectID: project.ID,
            EmbedURL:  input.Location.EmbedURL,
            CreatedAt: time.Now(),
            UpdatedAt: time.Now(),
        }
        if err := tx.Create(&locationProject).Error; err != nil {
            return err
        }

        return nil
    })
}

 

Ta sẽ xây dựng NewProject Handler để hàm route sử dụng khi ta truy cập vào /new-project, trong điều kiện là phương thức Get thì hàm sẽ tải ra cú pháp html của trường new-project.html, nếu method là POST thì sẽ tiến hành phân tích cú pháp Form, cần sử dụng var decoder = schema.NewDecoder() để xây dựng Gorilla Schema, giúp giải mã Form thành các đối tượng phù hợp, Dưới đây là các đối tượng được giải mã, lưu ý là các đối tượng này không giống với bảng được tạo, mà chỉ là cách mình định danh thứ mình tạo ra thôi

 

type ProjectInput struct {
    Name         string
    URL          string
    Sliders      []SliderInput
    Introduction IntroductionInput
    Repeaters    []RepeaterInput
    Gallery      GalleryInput
    Location     LocationInput
}

type SliderInput struct {
    ImageURL string
    Caption  string
}

type IntroductionInput struct {
    Title       string
    Description string
}

type RepeaterInput struct {
    Icon              string
    Title             string
    Description       string
    Number            int
    NumberDescription string
    SectionTitle      string
    SectionContent    string
    SectionImageURL   string
}

type GalleryInput struct {
    Interiors []ImageInput
    Exteriors []ImageInput
    LegalDocs []ImageInput
}

type ImageInput struct {
    ImageURL string
}

type LocationInput struct {
    EmbedURL string
}

Từ bước tạo dự án, ta sẽ ghép đối tượng tạo từ Gorilla Schema ProjectInput  vào các dữ liệu mang muốn. Ở đây tôi có Slider, Introduction, Repeater (Dùng cho các khối tùy chỉnh như mặt bằng, vị trí, pháp lý...) và gallery chia theo 3 chuyên mục là nội thất, ngoại thất và pháp lý... (Interior, Exterior, Legal). 

Hiển thị dự án Project thế nào?

Tiếp theo, Nam Digital xin hướng dẫn bạn cách hiển thị dữ liệu dự án với hàm GetProject

 

// Hiển thị dữ liệu dự án
func GetProject(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id := vars["id"]

    var project models.Project
    if err := config.Db.Preload("Slider").
        Preload("Introduction").
        Preload("Repeater").
        Preload("Gallery.Interiors", "category = ?", "Interior").
        Preload("Gallery.Exteriors", "category = ?", "Exterior").
        Preload("Gallery.LegalDocs", "category = ?", "Legal").
        Preload("Location").
        First(&project, id).Error; err != nil {
        http.Error(w, "Project not found", http.StatusNotFound)
        return
    }

    // Render dữ liệu vào template
    config.Tpl.ExecuteTemplate(w, "project.html", project)
}

Lý giải 1 chút, việc hiển thị dữ liệu dự án cần xử lý ở biến Project, chỗ này tôi dùng Gorilla Mux để lấy id trong url, từ đó tải ra loại dữ liệu mong muốn, lúc này cơ chế Preload của GORM khá hay, nó cho phép ta lục vào từng bảng, lấy các giá trị có liên quan rồi ghép vào đối tượng. Như tôi đã lấy Slider trong bảng slider_project, Repeater trong bảng repeater_project, Introduction trong bảng introduction_project...và đưa chúng thành bản ghi mong muốn. 

Cần xử lý Parse Multipart Form đối với Form có dữ liệu gửi ảnh

ParseMulti part form là kỹ thuật rất quan trọng trong việc xử lý gửi Form có dữ liệu ảnh.

Preload trong GORM đôi khi cũng không chính xác, mà ta cần chỉ đích danh dữ liệu cần lấy

Nam gặp phải một trường hợp khá buồn cười đó là với cơ cấu bảng dữ liệu sau