가장 빠르다는 Go-fiber 웹프레임워크를 이용하여 GORM 과 SQLite 로 간단한 REST API 를 만들어봅니다. (5일차)
- Go 언어 배우기 - 1일차 개요, 특징
- Go 언어 배우기 - 2일차 문법, 고루틴
- Go 언어 배우기 - 3일차 GIN, GORM
- Go 언어 배우기 - 4일차 유틸리티 코드
- Go 언어 배우기 - 5일차 Go Fiber API ✔
1. REST API using Go Fiber and GORM
최대 6~7배 정도 빠르다는데, 정말인지는 써보면서 알아보자.
1) 기능 설명
GoFiber 와 GORM 을 이용해 간단한 REST API 를 구현함 (boilerplate)
Go-fiber 웹서버
.env
로부터 DB_URL, PORT 등을 읽어 적용- Go-Fiber 의 미들웨어 조립 : Logger, CORS, Cache, Views
- API Group 과 GET / POST / PUT / PATCH / DELETE 메소드
- 다양한 Route Parameters 형식을 등록하여 테스트
- Query Params 읽어와 DB Where 조건에 사용
- View Engine 을 마운트 하고 HTML 템플릿 페이지 출력
- Cache 미들웨어를 등록했으나, refresh 가 작동하지 않음
- 디버깅을 위해 cacheHit 페이지 추가
GORM & SQLite
- 최초 샘플 데이터 입력
- SQLite DB 에 대해 CRUD 구현
- validator 로 입력 struct 에 적합한지 타입 검사
- GORM 의 sql.NullInt16 필드를 사용 (Dog.Age)
- JSON 출력을 위해 별도의 MarshalJSON/UnmarshalJSON 함수를 구현
- Null 업데이트를 위해 Age 에 대한 Update 문을 추가
- 트랜잭션을 사용해 실패시 Rollback 처리
그 외 (API 와 관계없지만)
- AES 암호화/복호화
- 하위 디렉토리 모듈 임포트 연습
- 여러 예제와 유틸리티들을 모두 모아서 작성
참조 : How to Build REST API using Go Fiber and Gorm ORM
2. 프로젝트 Setup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
$ mkdir fiber-example && cd fiber-example
$ go mod init example.com
$ cat <<EOF > main.go
package main
func main() {}
EOF
$ go get -u gorm.io/gorm
$ go get -u gorm.io/driver/sqlite
$ go get -u github.com/gofiber/fiber/v2
$ go get -u github.com/joho/godotenv
$ go get -u github.com/gofiber/template
$ go get -u gopkg.in/go-playground/validator.v9
$ go get -u golang.org/x/exp/maps # maps.Keys() 함수
$ go get -u golang.org/x/exp/slices # slices.Contains() 함수
$ go mod tidy
$ go run .
9f4yohBU0rUoq6ajOcC3hA==
hello world
{1 Go}
false
2022/11/15 15:03:12 init: 3 records inserted
views: parsed template: index
Fiber v2.39.0
http://127.0.0.1:3000
# ...
2022/11/15 19:06:03 params = map[]
19:06:03 | 200 | 1ms | 127.0.0.1 | GET | /api/dogs
main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// main.go
import (
"example.com/db" // DB 접속 및 CRUD 함수
m "example.com/models" // 모델 및 JSON 변환, 인터페이스 함수
u "example.com/utils" // map 처리, env 등등 유틸리티 함수들
"example.com/web" // 웹서버 미들웨어 및 라우터 설정
)
func main() {
db.Connect()
app := fiber.New()
web.SetupFiber(app)
var port = db.Config("PORT")
log.Fatal(app.Listen(":" + port))
}
3. Go-Fiber 웹서버
1) 미들웨어
- Logger
- CORS
- Cache : 기본으로 메모리 캐시를 사용
- refresh 쿼리 파라미터가 들어가면 캐시 갱신이 되어야 하는데 안됨
- refresh 쿼리 파라미터까지 캐싱되어 통째로 무시되는듯 함
- refresh 쿼리 파라미터가 들어가면 캐시 갱신이 되어야 하는데 안됨
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Logger middleware
app.Use(logger.New(logger.Config{
Format: "[${time}] ${status} - ${latency} ${method} ${path}\n",
}))
// CORS middleware
app.Use(cors.New(cors.Config{
// AllowOrigins: "https://gofiber.io, https://gofiber.net",
AllowOrigins: "*",
AllowHeaders: "Origin, Content-Type, Accept",
}))
// 특정 API 그룹에만 캐시 적용
cacheGroup := app.Group("/click")
// Cache middleware
cacheGroup.Use(cache.New(cache.Config{
Next: func(c *fiber.Ctx) bool {
return c.Query("refresh") == "true"
},
Expiration: 30 * time.Minute,
CacheControl: true,
}))
cache Hit 조사
/click
,/click?refresh=true
를 여러차례 요청/cacheHits
에서 캐시 Hit 비율을 출력시켰는데- 캐시 갱신이 먹지 않는다. (hander 함수에 진입하지 못함)
1
2
3
4
5
{
"cacheHits": 5,
"cacheHitsPercentage": 83,
"requests": 6
}
2) HTML 템플릿 - server-side template engines
템플릿 view 를 /home
에 연결 (API 와 함께 사용)
- {PRJ_ROOT}/views
- index.html
1
2
3
4
5
6
7
<!-- 템플릿 파일 index.html -->
<!DOCTYPE html>
<body>
<h1>{ {.Title} }</h1>
<p>{ { greet "Fiber" } }</p> <!-- 사용자 함수 greet 사용 -->
</body>
</html>
3) 파라미터
참고 : Stackoverflow - How to iterate over query params in Golang
- 설명으로는
c.AllParams()
로 모든 Query 파라미터를 가져온다는데- 안된다. 소스 코드를 봐도 딱히 안될 부분은 안보이는데.
그래서 따로 getQueryParams
함수를 작성했더니, 이건 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
params := getQueryParams(c)
log.Printf("params = %+v", params)
// GET /api/dogs?age=5&name=abc
// ==> params: map[string]string{"age":"5", "name":"abc"}
////////////////////////////////////
func getQueryParams(c *fiber.Ctx) map[string]string {
params := make(map[string]string)
var err error
c.Context().QueryArgs().VisitAll(func(key, val []byte) {
if err != nil {
return
}
k := utils.UnsafeString(key)
v := utils.UnsafeString(val)
params[k] = v
})
return params
}
3) Endpoints
PUT 은 필드 전체를 업데이트하고, PATCH 는 부분 업데이트를 한다.
라우터의 Path 파라미터에 제약사항을 설정할 수 있다.
- 제약사항에 위배되면
404 Not Found
로 처리됨 - 참고 Route constraints
- 제약사항에 위배되면
1
2
3
4
5
6
7
8
9
10
// Create a new route group '/api'
api := app.Group("/api")
// id 는 int 만 가능
api.Get("/dogs", db.GetDogs)
api.Get("/dogs/:id<int>", db.GetDog)
api.Post("/dogs", db.AddDog)
api.Put("/dogs/:id<int>", db.UpdateDog)
api.Patch("/dogs/:id<int>", db.UpdateDogPartial)
api.Delete("/dogs/:id<int>", db.RemoveDog)
4. GORM with SQLite3
1) 설정
DB 모델을 위한 Dog
구조체
- sql.NullInt16 대신에 wrapper 타입 NullInt16 을 사용
- Null 처리가 가능하면서 JSON 출력시 값만 나오게 하려고 적용
- 참고 How can I work with SQL NULL values and JSON?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Dog type with sql.NullInt16
type Dog struct {
ID int `json:"id" gorm:"primaryKey"`
Name string `json:"name" validate:"required,min=3,max=32"`
Breed string `json:"breed" validate:"required"`
Age NullInt16 `json:"age" validate:"number" form:"age"`
IsGoodBoy bool `json:"isGoodBoy" gorm:"default:true"`
}
// NullInt16 is wrapper for sql.NullInt16
// 참고 https://stackoverflow.com/a/33072822/6811653
type NullInt16 struct {
sql.NullInt16
}
// ToNullInt16 convert int to sql.NullInt16
func ToNullInt16(v int) NullInt16 { ... }
// MarshalJSON marshal json of NullInt16
func (v NullInt16) MarshalJSON() ([]byte, error) { ... }
// UnmarshalJSON unmarshal json of NullInt16
func (v *NullInt16) UnmarshalJSON(data []byte) error { ... }
초기 데이터 삽입
- nullable 필드를 누락하면, null 또는 기본값이 들어간다
1
2
3
4
5
6
7
//You can insert multiple records too
var dogs []m.Dog = []m.Dog{
{Name: "Ricky", Breed: "Chihuahua", Age: m.ToNullInt16(2), IsGoodBoy: false},
{Name: "Adam", Breed: "Pug", IsGoodBoy: true},
{Name: "Justin", Breed: "Poodle", Age: m.ToNullInt16(3), IsGoodBoy: false},
}
tx := db.Create(&dogs)
2) 트랜잭션
Updates multiple columns - 다수의 필드 업데이트
다중 필드 업데이트는 구조체 또는 Map 인터페이스로 할 수 있다.
- 반드시 대상을 특정할 수 있는 ID 가 명시되어야 함
- 단, not-Null / non-Zero 값만 업데이트함
User{Active: false}
=> 무시/누락
NOTE When updating with struct, GORM will only update non-zero fields. You might want to use map to update attributes or use Select to specify fields to update
주의!! 구조체로 업데이트할 때 GORM은 0이 아닌 필드만 업데이트합니다. 지도를 사용하여 속성을 업데이트하거나 선택을 사용하여 업데이트할 필드를 지정할 수 있습니다.
PUT /dogs/:id<int>
전체 필드 업데이트
Null / Zero 값을 업데이트 하려면 Select 를 포함하여야 함
- 트랜잭션 처리 (절차식으로 나열하는 것보다 함수형이 안전하다)
- 오류가 나면 err 를 내보내고, 맨 나중에 웹응답 처리
- 트랜잭션 진입
tx.Model(&dog)
로 갱신 대상 테이블을 알려주고Select("*")
로 필드 전체가 갱신 대상임을 알려주고Where("ID = ?", id)
로 업데이트 대상을 명시하고Omit("ID")
혹시나 중요 필드가 업데이트 되지 않도록 보호- Body 에서 받아온 struct 데이터로
Updates(dog)
적용 - 별 문제 없으면 nil 반환 (커밋)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Transaction return nil or error
err := Database.Transaction(func(tx *gorm.DB) error {
id := c.Params("id")
// 모든 필드에 대해 업데이트 (ID 제외)
if err := tx.Model(&dog).Select("*").Where("ID = ?", id).Omit("ID").Updates(dog).Error; err != nil {
return err
}
return nil // commit
})
if err != nil {
log.Fatalln(err)
return c.Status(503).SendString(err.Error())
}
return c.Status(200).JSON(dog)
PATCH /dogs/:id<int>
부분 필드 업데이트
Body 를 통해 생성된 모델 구조체는 모든 필드를 포함하고 있다. 따라서, Select 를 이용해 갱신 대상을 제한하도록 해야 한다. (안그러면 필드 전체가 변경됨)
Select 사용시 JSON 태그명을 구조체의 필드명으로 바꾸어 주어야함
- 모델 구조체에서 필드명과 JSON 태그명 사전(map)을 생성
- c.Body() 에서 사용된 JSON 태그명 슬라이스를 추출
- 사전(map) 으로 업데이터 대상인 필드명 슬라이스를 생성
- 트랜잭션 진입
Select(fields)
과 함께Updates(dog)
적용- 이상 없으면 nil 반환 (커밋)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
tableName, fieldNames := GetTableJSONTags(Database, dog)
if fieldNames == nil {
return c.Status(503).SendString("Any JSON tag is not defined")
}
// 업데이트 대상 json tag 추출
var tags []string = u.ExtractFields(c.Body())
// json tag 를 field name 로 변환
var fields []string = u.ReplaceSliceByMap(tags, u.MapS(fieldNames).Reverse())
log.Printf("%s: tags %v => fields %+v", tableName, tags, fields)
err := Database.Transaction(func(tx *gorm.DB) error {
id := c.Params("id")
// 업데이트 대상 필드(fields)들만 업데이트
if err := tx.Model(&dog).Select(fields).Where("ID = ?", id).Updates(dog).Error; err != nil {
return err
}
return nil // commit
})
if err != nil {
log.Fatalln(err)
return c.Status(503).SendString(err.Error())
}
return c.Status(200).JSON(dog)
3) Delete 할 때 사전에 검사하기 위해 Hook (훅) 사용
샘플데이터 ID=[1,2,3] 에 대해 삭제하지 못하도록 검사 후 삭제
- ID 값으로 Delete 실행
- BeforeDelete 인터페이스 함수 (Hook) 진입
- 검사할 수 있는 값은 구조체 값뿐이라 사전에 ID 값을 넣어두어야 함!
- 조건을 만족하지 않으면 Error 반환 (취소됨)
- 이상 없으면, Delete 적용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func RemoveDog(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
var dog m.Dog = m.Dog{ID: id} // for BeforeDelete
result := Database.Model(&dog).Delete(&dog, id)
// ...
}
// BeforeDelete prevent delete sample data which ID < 4
// **NOTE: 같은 모듈 안에서만 정의할 수 있음
func (d *Dog) BeforeDelete(tx *gorm.DB) (err error) {
if d.ID < 4 {
log.Printf("cancel: ID=%d", d.ID)
return errors.New("Sample Data (ID<4) not allowed to delete")
}
return
}
4) CRUD 실행
REST API 요청 및 결과
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// GET http://localhost:3000/api/dogs HTTP/1.1
[
{ "id": 1, "name": "Ricky", "breed": "Chihuahua",
"age": 2, "isGoodBoy": false
},
{ "id": 2, "name": "Adam", "breed": "Pug",
"age": null, // <-- nullable
"isGoodBoy": true
},
{ "id": 3, "name": "Justin", "breed": "Poodle",
"age": 3, "isGoodBoy": false
}
]
// POST http://localhost:3000/api/dogs HTTP/1.1
// {
// "name": "Max Junior",
// "breed": "Shepherd",
// "age": 4,
// "isGoodBoy": true
// }
{
"id": 4,
"name": "Max 2nd",
"breed": "Shepherd",
"age": 4,
"isGoodBoy": true
}
// PUT http://localhost:3000/api/dogs/1 HTTP/1.1
// {
// "name": "Max Junior",
// "breed": "Shepherd (German)",
// "age": 9
// }
{
"id": 0, // <-- Omit
"name": "Max Junior",
"breed": "Shepherd (German)",
"age": 9,
"isGoodBoy": false
}
// GET http://localhost:3000/api/dogs/4 HTTP/1.1
{
"id": 4,
"name": "Max Junior",
"breed": "Shepherd (German)",
"age": 9,
"isGoodBoy": false
}
// PATCH http://localhost:3000/api/dogs/4 HTTP/1.1
// {
// "age": null,
// "isGoodBoy": false
// }
{
"id": 0,
"name": "",
"breed": "",
"age": null, // <-- select
"isGoodBoy": false // <-- select
}
// GET http://localhost:3000/api/dogs/4 HTTP/1.1
{
"id": 4,
"name": "Max Junior",
"breed": "Shepherd (German)",
"age": null,
"isGoodBoy": false
}
// DELETE http://localhost:3000/api/dogs/1 HTTP/1.1
[403 Forbidden]
Sample Data (ID<4) not allowed to delete
// DELETE http://localhost:3000/api/dogs/4 HTTP/1.1
OK
// GET http://localhost:3000/api/dogs/4
Not Found
Gofiber 로깅
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌───────────────────────────────────────────────────┐
│ Fiber v2.39.0 │
│ http://127.0.0.1:3000 │
│ (bound on host 0.0.0.0 and port 3000) │
│ │
│ Handlers ............ 34 Processes ........... 1 │
│ Prefork ....... Disabled PID ............. 31294 │
└───────────────────────────────────────────────────┘
14:47:10 | 200 | 1ms | 127.0.0.1 | GET | /api/dogs
14:48:50 | 201 | 2ms | 127.0.0.1 | POST | /api/dogs
2022/11/16 14:50:16 update: &{ID:0 Name:Max Junior Breed:Shepherd (German) Age:{NullInt16:{Int16:9 Valid:true}} IsGoodBoy:false}
14:50:16 | 200 | 0s | 127.0.0.1 | PUT | /api/dogs/4
14:52:56 | 200 | 0s | 127.0.0.1 | GET | /api/dogs/4
2022/11/16 14:53:06 dogs: tags [age isGoodBoy] => fields [Age IsGoodBoy]
14:53:06 | 200 | 0s | 127.0.0.1 | PATCH | /api/dogs/4
14:53:11 | 200 | 0s | 127.0.0.1 | GET | /api/dogs/4
2022/11/16 14:53:47 cancel: ID=1
14:53:46 | 403 | 0s | 127.0.0.1 | DELETE | /api/dogs/1
14:54:00 | 200 | 0s | 127.0.0.1 | DELETE | /api/dogs/4
14:54:32 | 404 | 0s | 127.0.0.1 | GET | /api/dogs/4
5. Others
1) 유틸리티 함수들
golang.org/x/exp/maps - Generic 타입 실험 패키지
golang.org/x/exp
는 실험적인 또는 폐기된 패키지를 포함하고 있기 때문에, 반드시 하위 디렉토리까지 지정해서 사용하도록 경고하고 있음
- map 타입에서 Key 추출하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import (
"fmt"
"golang.org/x/exp/maps"
)
func main() {
intMap := map[int]int{1: 1, 2: 2}
intKeys := maps.Keys(intMap)
// intKeys is []int
fmt.Println(intKeys)
strMap := map[string]int{"alpha": 1, "bravo": 2}
strKeys := maps.Keys(strMap)
// strKeys is []string
fmt.Println(strKeys)
}
// 출력 ==>
// [2 1]
// [alpha bravo]
slices 의 Contains 함수
1
2
3
4
5
// go get golang.org/x/exp/slices
import "golang.org/x/exp/slices"
things := []string{"foo", "bar", "baz"}
slices.Contains(things, "foo") // true
문자열 map 의 Key 와 Value 뒤바꾸기
1
2
3
4
5
6
7
8
9
10
11
// MapS is a map with string keys and values.
type MapS map[string]string
// Reverse returns a new map with the keys and values swapped.
func (m MapS) Reverse() map[string]string {
n := make(map[string]string, len(m))
for k, v := range m {
n[v] = k
}
return n
}
인터페이스 map 을 특정 Key 리스트로 필터링하기
1
2
3
4
5
6
7
8
9
10
11
12
13
// MapT is a map with string keys and values.
type MapT map[string]interface{}
// Filter returns a new map with matched keys
func (m MapT) Filter(keys []string) map[string]interface{} {
n := make(map[string]interface{}, len(m))
for k, v := range m {
if slices.Contains(keys, k) {
n[k] = v
}
}
return n
}
9. Review
- Gofiber 기동 시간이 엄청 빨라서 쾌적하게 작업했다.
- Gofiber 리포지토리에
v3-beta
태그도 있다. 언제 나오려나. - GORM 쓰려면 nullable 은 사용 안하는게 정신건강에 좋을듯
- true / false 도 모두 code 값으로 변환하여 처리해야
- 인터페이스 map 를 다루거나, filter/map/reduce 등 제너릭이 필요한 경우가 자주 발생하는데 관련 함수가 없어 무척 번거로웠다.
- 정말 Python 이 좋은(편한) 언어로군
- Java 만큼 참고 코드가 풍족하지 않지만, 나름 심플해서 좋다
- 좀 지저분하게 흘러가도 워낙 빨라 표시도 안날듯
- 이것으로 도커를 만들고 클라우드에도 배포하는 작업을 해보자.
끝! 읽어주셔서 감사합니다.