Percobaan Membuat Template Aplikasi Web Dengan Golang dan Gin
Hello, world!
Setelah menulis tentang deployment pada pekan lalu, saya ingin membagikan pengalaman ketika menyusun template yang digunakan oleh kelompok kami pada mata kuliah Proyek Perangkat Lunak Fasilkom UI semester genap 2022.
Ide Awal
Kelompok kami mendapatkan proyek untuk membuat sebuah aplikasi web. Arsitektur yang kami gunakan seperti pada proyek aplikasi web yang banyak dilakukan pada zaman sekarang adalah membaginya menjadi dua, yaitu:
- Frontend: bagian aplikasi yang berinteraksi langsung dengan pengguna melalui tampilan.
- Backend: bagian aplikasi yang tidak berinteraksi langsung dengan pengguna, berupa Application Programming Interface / API yang akan menerima permintaan dari frontend dan menghubungkannya dengan database.
Saya pribadi lebih familiar dengan backend development, sehingga saya merasa sangat tertarik ketika mengetahui bahasa pemrograman yang diminta untuk digunakan, yaitu golang, python, atau php.
Setelah berdiskusi dengan teman-teman sekelompok, kami memutuskan untuk menggunakan golang sebagai bahasa pemrograman untuk backend. Melihat bahwa tidak ada framework yang lengkap seperti django di golang, kami memilih untuk menyusun sebuah template yang dapat kami gunakan selama masa development nantinya. Template yang kami buat bukan bermaksud untuk “reinventing the wheel”, namun lebih kepada mengadopsi apa yang sudah ada (template yang sudah beredar di internet), dan menyesuaikannya berdasarkan apa yang kami sama-sama pahami agar lebih lancar dan mengurangi kompleksitas pengembangan karena kebingungan mengenai konvensi.
Model View Controller
Mengikuti contoh dari kakak tingkat mengenai arsitektur aplikasi web dengan golang miliknya (tulisan miliknya dapat dilihat di sini), kami memutuskan untuk menggunakan pattern Model-View-Controller (MVC). Design pattern ini terbagi menjadi tiga bagian:
- Model: merepresentasikan objek yang memiliki data.
- View: tampilan / visualisasi dari model.
- Controller: mengontrol data flow dari model ke view.
Implementasinya terdapat pada folder yang kami buat. Seperti proyek milik kakak tingkat kami, kami juga membuat empat buah folder untuk merepresentasikan struktur MVC, yaitu sebagai berikut:
controllers/
models/
repositories
services/
Fungsi dari kode program yang akan ditempatkan di setiap folder tersebut adalah sebagai berikut:
models/
: isi dari file pada direktori ini adalah struct yang akan dimigrasikan ke database, dan juga sebagai struct yang dapat digunakan untuk mentransfer data dari service ke repository.repositories/
: isi dari file pada direktori ini adalah sebuah interface yang memiliki fungsi-fungsi yang akan melakukan operasi kepada database, dengan struct implementasinya berupa database client.services/
: isi dari file pada direktori ini adalah sebuah interface yang memiliki fungsi-fungsi yang akan mengoperasikan business logic, berkomunikasi dengan repository, atau juga dengan service lainnya yang disimpan pada struct implementasinya. Jika melihat dari definisi MVC, sebetulnya saya menganggap ini lebih mirip seperti bagian dari controller, yang mana view-nya adalah data yang dikirimkan oleh program pada foldercontrollers/
. Namun saya rasa ini hanya masalah sudut pandang 😅.controllers/
: isi dari file pada direktori ini adalah fungsi-fungsi yang akan mem-parsing data yang diterima dari gin context (request yang masuk ke endpoint), dan memanggil service untuk mengolah datanya, lalu mengembalikan response berdasarkan kembalian dari service.
Sebagai konvensi, interface akan ditulis dengan huruf kapital pada huruf pertamanya (contoh: type ExampleService interface{}
), dan struct yang mengimplementasikan interface tersebut tidak ditulis dengan huruf kapital pada huruf pertamanya (contoh: type exampleService struct{}
). Hal ini dilakukan karena golang akan mengekspor suatu tipe data ketika huruf pertamanya kapital, sehingga package lain dapat mengaksesnya. Kami lebih setuju untuk hanya mengekspor interface-nya, sehingga akan selalu ada fungsi untuk “instansiasi” interface yang mengembalikan struct tersebut (mungkin ada term yang lebih tepat, tetapi saya suka menggunakan term ini berhubung dulu saya pernah belajar java juga). Contohnya adalah sebagai berikut:
func NewExampleService(repository repositories.ExampleRepository) ExampleService {
return &exampleService{}
}
Models dan Database Migration
Salah satu masalah yang kami hadapi ketika mengintegrasikan model ke database adalah banyaknya model yang akan kami buat. Kami menggunakan GORM sebagai ORM untuk berkomunikasi dengan database. GORM sebetulnya memiliki fitur AutoMigrate
. Namun, fitur ini membutuhkan developer untuk menulis secara manual model yang akan dimigrasikan. Untuk menunjang kebutuhan developer dan menghindari banyak konflik yang terjadi pada git hanya karena satu file migrasi, saya memutuskan untuk mengembangkan satu tool sederhana yang akan membuat file tersebut.
Dengan menggunakan library AST yang dimiliki golang (contohnya dapat dilihat di sini), program ini akan menelusuri semua file pada direktori models/
dan menyimpan semua model yang diekspor (yang memiliki huruf kapital pada huruf pertamanya) untuk dimasukkan ke database. File yang di-generate oleh program ini tidak di-commit ke dalam repository, sehingga file ini akan dibuat pada saat melakukan deployment, dan fungsi Migrate()
akan selalu dijalankan dari dalam main.go
.
Masalah lain muncul karena ada beberapa model yang memiliki foreign key. Sehingga harus dilakukan pengurutan terlebih dahulu berdasarkan dependensi. Hal ini baru diketahui ketika sudah 2 minggu development berjalan, dan melihat pola dari yang sudah dikerjakan, ini bisa untuk dilakukan.
Implementasi dari konsep di atas adalah sebagai berikut:
package mainimport (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"strings"
)func main() {
// run this from project root directory
// go run tools/makemigrations/main.godir := "models"
fset := token.NewFileSet()
packageDir, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
if err != nil {
panic(err)
}models := []string{}
foreignFieldNames := [][]string{}for _, v := range packageDir[dir].Files {
ast.Inspect(v, func(x ast.Node) bool {
switch x.(type) {
case *ast.TypeSpec:
if _, ok := x.(*ast.TypeSpec).Type.(*ast.StructType); ok {
name := x.(*ast.TypeSpec).Name.Name
if len(name) > 1 && 65 <= name[0] && name[0] <= 90 {
models = append(models, name)
}
}
case *ast.StructType:
fieldNames := []string{}
for _, y := range x.(*ast.StructType).Fields.List {
for _, field := range y.Names {
// assuming ID is the primary key
if len(field.Name) > 2 {
if strings.ToLower(field.Name[len(field.Name) - 2:len(field.Name)]) == "id" {
fkName := field.Name[:len(field.Name) - 2]
if fkName != models[len(models) - 1] {
fieldNames = append(fieldNames, fkName)
}
}
}
}
}
foreignFieldNames = append(foreignFieldNames, fieldNames)
return false
}
return true
})
}if len(models) == 0 {
fmt.Println("No models to migrate found.")
return
}// sorting, the referenced models should be placed first
i := 0
n := len(models)
for i < n {
swapped := false
for _, fk := range foreignFieldNames[i] {
for j := i + 1; j < n; j++ {
if fk == models[j] {
models[i], models[j] = models[j], models[i]
foreignFieldNames[i], foreignFieldNames[j] = foreignFieldNames[j], foreignFieldNames[i]
swapped = true
break
}
}
if swapped {
break
}
}if !swapped {
i++
}
}dir = "database"
f, err := os.Create(dir + "/migrate.go")
if err != nil {
panic(err)
}
defer f.Close()toMigrate := ""
for _, model := range models {
toMigrate += fmt.Sprintf("\t\t&models.%s{},\n", model)
}res := `package databaseimport (
"gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2022/Kelas-D/sirclo/sirclo-learning-lab-backend/models"
"fmt"
)func Migrate() {
toMigrate := []interface{}{
%s }db, err := GetClient()if err != nil {
fmt.Println("Error:", err)
return
}
db.AutoMigrate(toMigrate...)
}
`f.WriteString(fmt.Sprintf(res, toMigrate))fmt.Println("Make migration success.")
}
Data Transfer Object
Untuk berkomunikasi dari controllers
ke services
, kami memiliki konvensi untuk berusaha menggunakan Data Transfer Object (DTO). Implementasinya berupa struct yang tidak memiliki method dan hanya digunakan untuk mentransfer data dari controllers
ke services
. Selain itu, kami juga mem-parsing request ke dalam struct DTO tersebut. Sehingga, controller yang kami buat hanya mengoper hasil parsing ke dalam service.
Keuntungan dari pendekatan design pattern ini adalah mengurangi perubahan pada file service jika terjadi penambahan parameter. Hal ini menurut saya penting, terutama ketika melakukan TDD dengan mock. Ketika parameter sebuah fungsi berubah, kita juga perlu mengubah parameter pada mock. Dengan melakukan pemisahan antara parameter, kita tidak perlu lagi mengubah file service, hanya perlu mengubah DTO dan request-nya.
Definisi DTO-nya sendiri adalah sebuah struct, dan dapat dengan mudah kita ubah. Contohnya adalah sebagai berikut:
type ExampleDTO{
Name string
Age int
}
File tersebut kami simpan di folder dto/
. Contoh penggunaannya adalah sebagai berikut:
// contoh pada controller
var req dto.ExampleDTOif err := gc.ShouldBind(&req); err != nil {
gc.JSON(http.StatusBadRequest, dto.BaseResponse{
Message: err.Error(),
})
return
}res, err := c.service.ExampleFunc(req)// implementasi pada service
type ExampleService interface {
ExampleFunc(dto.ExampleDTO) (models.Example, error)
}
Singleton Pada Router
Terdapat satu hal lagi yang rasanya belum ada, yaitu bagaimana cara membuat mengatur endpoint. Gin membuat kita mudah untuk mengatur endpoint. Sebagai contoh, kita hanya perlu memanggil r.GET("/endpoint", controller.ExampleFunction)
untuk membuat sebuah GET endpoint, dengan r
adalah sebuah *gin.Engine
. Seluruh repository, service, dan controller dibuat dari file ini yang kami namakan router.go
. Ini mirip dengan design pattern singleton, yang mana hanya terdapat satu instance untuk sebuah interface.
Namun, ini menjadi masalah karena kita perlu terus menerus mengedit sebuah file yang sama dengan perubahan yang besar untuk membuat endpoint tersebut terdefinisi. Masalah ini juga sama dengan saat kita perlu melakukan “instansiasi” yang sebelumnya saya singgung dengan memanggil fungsi NewXX()
. Kami menggabungkan semua hal ini di dalam satu file, dan saya pribadi khawatir dengan banyaknya baris yang perlu kami perhatikan saat me-resolve konflik yang terjadi.
Sebetulnya, ini mungkin bisa diselesaikan dengan dependency injection seperti yang ada pada framework springboot, sayangnya, ini tidak ada pada golang. Cukup sulit juga untuk membuat tool seperti pada kasus database migration karena kita perlu memperhatikan dependency yang dimiliki. Oleh karena itu, kami memutuskan untuk mengoper pembuatan endpoint pada controller, dengan mendefinisikan satu fungsi Setup
pada controller, dan memanggil hanya satu baris pada router.go
.
Contohnya adalah sebagai berikut:
func (c *courseContentController) Setup(router *gin.RouterGroup) {
content := router.Group(COURSECONTENT_PATH)
content.GET("/section-id/:sectionId", c.GetCourseContentBySectionId)material := content.Group(MATERIAL_PATH)
material.POST("", c.CreateMaterial)
material.POST("/upload", c.CreateMaterialWithUpload)
material.GET("/:id", c.GetMaterialByCourseContentId)
material.PUT("", c.UpdateMaterialByCourseContentId)
material.PUT("/upload", c.UpdateMaterialWithUploadByCourseContentId)
material.DELETE("/:id", c.DeleteMaterialByCourseContentId)
material.GET("/module/:id", c.GetModuleMaterials)
}
Ini mengurangi conflict, namun conflict tersebut tetap ada. Mungkin di lain kesempatan jika sudah ada metode yang lebih baik, kita dapat sepenuhnya menghindari conflict untuk file ini.
Penutup
Berikut adalah link repository dari template yang kami buat:
Sebagai catatan, template ini belum dilengkapi dengan database client, tool untuk migrasi (seperti yang saya tunjukkan di atas), dan beberapa hal lainnya karena kami memutuskan untuk mencobanya langsung di repository utama kami setelah kami diberikan akses ke sana, sehingga repository yang lengkap mungkin belum dapat saya tunjukkan untuk saat ini. Teman-teman yang berminat untuk menggunakannya atau mengembangkannya sangat diizinkan 😺. Semoga template ini dapat berguna juga di kemudian hari baik bagi kami atau orang lain.
Sekian dan terima kasih sudah membaca 😄.
Referensi
- https://www.freecodecamp.org/news/whats-boilerplate-and-why-do-we-use-it-let-s-check-out-the-coding-style-guide-ac2b6c814ee7/
- https://kenzie.snhu.edu/blog/front-end-vs-back-end-whats-the-difference/
- https://www.baeldung.com/java-dto-pattern
- https://www.tutorialspoint.com/design_pattern/mvc_pattern.htm
- https://medium.com/@thegalang/testing-in-go-mocking-mvc-using-testify-and-mockery-c25344a88691
- https://developers.mattermost.com/blog/instrumenting-go-code-via-ast/