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ư:
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
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).
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.
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.
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