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