ORM, Object-Relational Mapping은 객체 지향 프로그래밍 언어에서 관계형 데이터베이스와 매핑하는 것을 의미한다.
ORM을 이용하면 데이터베이스와의 사용이 더 쉬워지고, SQL injection과 같은 공격에도 안전하다.
객체 지향적인 프로그래밍으로 데이터베이스에 접근하여, 코드가 더 간결해질 수 있다.
그러나, 쿼리 복잡도가 높아질 수 있고 복잡한 경우 성능저하가 올 수도 있다.
객체와 테이블 간에 불일치 시 문제가 발생할 수도 있다.
GORM은 GO에서 가장 유명한 ORM 라이브러리이다.
지원하는 기능은 아래 외에도 훨씬 많다.
프로젝트의 루트 디렉터리에서 다음을 실행한다:
$ go get -u gorm.io/gorm
$ go get -u gorm.io/driver/mysql ##DB마다 다른 드라이버를 선택하시오.
config/db.go
package config
import (
"fmt"
"proj-name/internal/models"
"log"
"os"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// DB object
var DB *gorm.DB
// ConnectToDB - establish db connection..
func ConnectToDB() {
dsn := fmt.Sprintf(
"%s:%s@tcp(%s)/%s?charset=utf8&parseTime=True&loc=Local",
os.Getenv("DB_USER"),
os.Getenv("DB_PASS"),
os.Getenv("DB_ADDR"),
os.Getenv("DB_TABLE"),
)
var err error
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("Failed to connect to database: ", err)
}
DB.AutoMigrate(&models.User{}, &models.Notice{}, &models.Treasure{}, &models.LostItem{}, &models.Image{})
fmt.Println("DB connection established")
}
우선, gorm패키지의 DB객체 DB를 만든다.
그리고, Open(mysql.Open(dsn), &gorm.Config{})를 통해서 dsn을 입력한다.
dsn에는 미리 연결 정보에 대한 문자열을 저장하는데, 보안을 위해 환경 변수에서 민감한 값을 가져온다.
만약 Open이 에러를 반환하지 않는다면, 연결에 성공한 것이다.
DB.AutoMigrate()는 구조체 인자로 주어진 모델들에 맞춰서 데이터베이스 스키마를 동기화시킨다는 의미이다.
GORM에서는 Go의 구조체를 데이터베이스 테이블에 쉽게 매핑할 수 있도록 단순화한다.
모델은 기본적인 구조체를 정의하는데 쓰인다.
이러한 구조체는 기본적인 Go의 타입들 및 별칭, 사용자 지정 타입을 따를 수 있다.
(단, database/sql패키지에서 Scanner와 Valuer 인터페이스가 구현되어야 함)
User 모델을 설계한다고 해보자:
type User struct {
ID uint // Primary Key
Name string // 문자열 필드
Email *string // null을 허용하는 문자열 포인터
Age uint8 // 부호없는 8비트정수
Birthday *time.Time // time.Time에 대한 포인터, null가능.
MemberNumber sql.NullString // nullable string을 다루는 sql.NullString
ActivatedAt sql.NullTime // nullable time fields을 다루는 sql.NullTime
CreatedAt time.Time // GORM에 의해 자동으로 생성될 수 있는 컬럼.
UpdatedAt time.Time // GORM에 의해 자동으로 업데이트되는 타임스탬프 컬럼.
}
database/sql패키지의 sql.NullString과 sql.NullTime은 더 많은 제어가 가능한 nullable필드이다.CreatedAt과 UpdatedAt은 GORM이 생성 또는 수정 시 자동으로 찍어주는 타임스탬프이다.모델 생성 규칙은 다음과 같다:
1. Primary Key: GORM은 각 모델에서 기본적으로 ID필드를 primary key로 인식한다.
2. Table 이름: 기본적으로, GORM은 구조체 이름을 snake_case로 변환한다.
예를 들어, User 구조체는 데이터베이스에서는 users테이블에 매핑된다.
3. Column 이름: GRORM은 자동으로 구조체의 필드를 snake_case로 변환하여 데이터베이스의 컬럼으로 매핑한다.
4. Timestamp 필드: GORM은 CreatedAt과 UpdatedAt필드가 있으면, 자동으로 생성과 업데이트 타임스탬프 기록을 해준다.
태그는 모델 선언에서 선택적이다.
태그의 표기방법은 관대하지만, camelCase가 권장된다.
만약 여러 태그가 붙는다면, ;로 구분해주면 된다.
| 태그 이름 | 설명 |
|---|---|
| column | column이름 |
| type | column데이터 유형. 호환가능한 일반 유형(bool, int, uint, float, string, time, bytes)을 사용하는 것을 선호한다. 이는 모든 데이터베이스에서 작동하며, not null, size, autoIncrement와 같은 태그들과 함께 사용될 수도 있다. |
| serializer | db에 직렬화와 역직렬화를 어떻게 할지 정할 수 있다. |
| size | 데이터의 크기/길이를 정한다. size:256 |
| primaryKey | 해당 컬럼을 PK로 설정 |
| unique | 중복되는 값이 없게 설정 |
| default | 기본값을 설정 |
| precision | 컬럼의 정밀도를 설정 |
| scale | 컬럼의 크기를 지정 |
| not null | null불가능하게 지정 |
| autoIncrement | 컬럼을 자동 증가하도록 설정 |
| autoIncrementIncrement | 지정된 값만큼 증가하도록 설정 |
| embedded | 구조체 안에 다른 구조체를 포함시킴 |
| embeddedPreix | 이름 앞에 접두사를 추가 |
| autoCreateTime | 생성 시의 시간을 기록함. unix타임을 초단위로 담은 int이고, nano/milli도 가능. |
| autoUpdateTime | 생성 및 수정 시의. ㅣ간을 기록함. unix타임을 초단위로 담은 int이고, nano/milli단위도 가능. |
| index | 데이터베이스 인덱스 생성 |
| uniqueIndex | 해당 필드에 대해 유니크 인덱스 생성 |
| check | 필드에 제약조건 설정 |
| <- | 쓰기권한 지정. <-:create생성만 가능한 필드 |
| -> | 읽기권한 지정. ->false시 읽기 권한 없음. |
| - | 이 필드 무시. 읽기 쓰기 권한없음. -:migration시 마이그레이션 권한 없음. -:all시 모든 권한 없음. |
| comemnt | 마이그레이션 시 열에 주석 남김 |
user := User{Name: "riveroverflow", Age: 23, Birthday: time.Now()}
res := db.Create(&user) // 생성할 데이터의 포인터를 넘겨서 생성 요청
user.ID // 입력된 데이터의 PK를 반환
res.Error // 에러 반환
res.RowsAffected // 적용된 레코드의 개수 반환
단일객체 조회하기:
var user User
// 맨 위 레코드 가져오기
db.First(&user) // SELECT * FROM users ORDER BY id LIMIT 1;
// PK가 10인 레코드 조회
db.First(&user, 10) // SELECT * FROM users WHERE id = 10;
// uuid와 같이 PK가 문자열인 경우
db.First(&user, "id = ?", "1b74413f-f3b4-345d-da26-e8c062e3472a")
// SELECT * FROM users WHERE id = "1b74413f-f3b4-345d-da26-e8c062e3472a";
다중객체 조회하기:
var users []User
db.Find(&users) // SELECT * FROM users;
res.RowsAffected // 레코드의 개수
res.Error // 에러를 반환(에러 없으면 nil)
not조건 붙이기
db.Not("name = ?", "riveroverflow").First(&user)
// SELECT * FROM users WHERE NOT name = "riveroverflow" ORDER BY id LIMIT 1;
or조건 붙이기
db.Where("role = ?", "admin").Or("role = ?", "super_admin").Find(&users)
// SELECT * FROM users WHERE role = 'admin' OR role = 'super_admin';
특정 필드만 고르기
db.Select("name", "age").Find(&users)
// SELECT name, age FROM users;
정렬
db.Order("age desc, name").Find(&users)
// SELECT * FROM users ORDER BY age desc, name;
limit, offset
db.Limit(10).Offset(5).Find(&users)
// SELECT * FROM users OFFSET 5 LIMIT 10;
groupby
db.Model(&User{}).Select("name, sum(age) as total").Where("name LIKE ?", "group%").Group("name").First(&result)
// SELECT name, sum(age) as total FROM `users` WHERE name LIKE "group%" GROUP BY `name` LIMIT 1
db.First(&user)
user.Name = "riveroverflow2"
user.Age = 200
db.Save(&user) // 레코드의 주소를 전달하여 업데이트 요청
db.Delete(&User{}, 10)
// DELETE FROM users WHERE id = 10;
관계형 데이터베이스에서는 테이블 간에 관계를 지정할 수 있다.
여기서는, 1대1 관계인 Has one을 봐보자.
분실물에 대한 구조체 LostItem과, 이미지에 대한 구조체 Image가 있다.
분실물 LostItem은 하나의 이미지를 업로드한다고 하자.
-> LostItem has one Image.
모델 구조체 정의를 아래와 같이 해주면 된다.
package models
// Image Model
type Image struct {
ImageID uint `gorm:"type:integer;primaryKey;autoIncrement"`
ImageKey string `gorm:"type:varchar(255);not null"`
ImageURL string `gorm:"type:varchar(2048);not null"`
}
package models
// LostItem Model
type LostItem struct {
ItemID uint `gorm:"type:integer;primaryKey;autoIncrement"`
ItemName string `gorm:"type:varchar(255);not null"`
FoundIn string `gorm:"type:varchar(255);not null"`
ImageID uint `gorm:"type:integer"`
Image Image `gorm:"foreginKey:ImageId"`
}
가지는 모델이 그 모델의 PK에 대한 foreign key를 가지면 된다.
그리고 구조체의 필드에 소유한 모델도 명시하면 된다.
Join연산을 이용하여 연관된 Image까지 하나의 쿼리로 가져올 수 있다.
var lostItem *[]models.LostItem
res := config.DB.Joins("Image").Find(&lostItem)
if res.Error != nil {
return nil, res.Error
}
공지사항을 CRUD하는 예시 코드이다.
routers/initRouters.go
package routers
import (
"proj-name/internal/middlewares"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
// SetupRouter - Initalize All of Routers
func SetupRouter() *gin.Engine {
router := gin.Default()
router.Use(middlewares.JSONParsingMiddleware)
auth := router.Group("/auth")
{
auth.POST("/login", middlewares.ValidateBodyMiddleware("user_id", "user_password"), loginHandler)
}
notice := router.Group("/notices")
{
notice.GET("/", getNoticesHandler)
notice.PUT("/:noticeId", middlewares.RequireAuth, middlewares.ValidateBodyMiddleware("title", "content"), modifyNoticeHandler)
notice.POST("/", middlewares.RequireAuth, middlewares.ValidateBodyMiddleware("title", "content"), createNoticeHandler)
notice.DELETE("/:noticeId", middlewares.RequireAuth, deleteNoticeHandler)
}
return router
}
routers/noticeHander.go
package routers
import (
"proj-name/internal/services"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func getNoticesHandler(c *gin.Context) {
notices, err := services.GetNotices()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err,
})
return
}
c.JSON(http.StatusOK, gin.H{
"notices": notices,
})
}
func modifyNoticeHandler(c *gin.Context) {
noticeID, err := strconv.Atoi(c.Param("noticeId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "cannot convert string to integer",
})
return
}
body, exists := c.Get("body")
if !exists {
c.JSON(http.StatusBadRequest, gin.H{"error": "error loading body:"})
return
}
bodyMap := body.(map[string]interface{})
title := bodyMap["title"].(string)
content := bodyMap["content"].(string)
err = services.ModifyNotice(noticeID, title, content)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusNoContent, gin.H{})
}
func createNoticeHandler(c *gin.Context) {
body, exists := c.Get("body")
if !exists {
c.JSON(http.StatusBadRequest, gin.H{"error": "error loading body:"})
return
}
bodyMap := body.(map[string]interface{})
title := bodyMap["title"].(string)
content := bodyMap["content"].(string)
err := services.CreateNotice(title, content)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusCreated, gin.H{})
}
func deleteNoticeHandler(c *gin.Context) {
noticeID, err := strconv.Atoi(c.Param("noticeId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "cannot convert string to integer",
})
return
}
err = services.DeleteNotice(noticeID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err,
})
return
}
c.JSON(http.StatusNoContent, gin.H{})
}
services/noticeService.go
package services
import (
"proj-name/internal/models"
"proj-name/internal/repositories"
)
// GetNotices - get All Notices
func GetNotices() (*[]models.Notice, error) {
notices, err := repositories.FindAllNotices()
if err != nil {
return nil, err
}
return notices, nil
}
// ModifyNotice - Edit notice data
func ModifyNotice(noticeID int, title, content string) error {
err := repositories.UpdateNoticeByID(noticeID, title, content)
if err != nil {
return err
}
return nil
}
// CreateNotice - Create Notice with body data
func CreateNotice(title, content string) error {
err := repositories.CreateNewNotice(title, content)
if err != nil {
return err
}
return nil
}
// DeleteNotice - Delete notice by its ID
func DeleteNotice(noticeID int) error {
err := repositories.DeleteNoticeByID(noticeID)
if err != nil {
return err
}
return nil
}
`repositories/noticeRepository.go``
package repositories
import (
"proj-name/config"
"proj-name/internal/models"
)
// FindAllNotices - get All Notice data
func FindAllNotices() (*[]models.Notice, error) {
var notices []models.Notice
res := config.DB.Find(¬ices)
if res.Error != nil {
return nil, res.Error
}
return ¬ices, nil
}
// UpdateNoticeByID - Update Notice By its id
func UpdateNoticeByID(noticeID int, title, content string) error {
var notice models.Notice
res := config.DB.First(¬ice, noticeID)
if res.Error != nil {
return res.Error
}
notice.Title = title
notice.Content = content
res = config.DB.Save(notice)
if res.Error != nil {
return res.Error
}
return nil
}
// CreateNewNotice - Create New Notice data to DB
func CreateNewNotice(title, content string) error {
notice := models.Notice{Title: title, Content: content}
res := config.DB.Create(¬ice)
if res.Error != nil {
return res.Error
}
return nil
}
// DeleteNoticeByID - Delete Notice data by its ID
func DeleteNoticeByID(noticeID int) error {
var notice models.Notice
res := config.DB.Delete(¬ice, noticeID)
if res.Error != nil {
return res.Error
}
return nil
}
// CountAllOfNotices - Amounts all of Notices
func CountAllOfNotices() (int64, error) {
var count int64
res := config.DB.Model(&models.Notice{}).Count(&count)
if res.Error != nil {
return 0, res.Error
}
return count, nil
}