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

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(),  

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