포스트

Go 기본 라이브러리 백엔드 - 개발기록 1

프로젝트 구조 (23/10/05 기준)

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
📂cmd
┣ 📂cli
┃┗ 📎cli.go
┣ 📂db
┃┗ 📎db.go
┣ 📂web
┃ ┣ 📎api.go
┃ ┣ 📎application.go
┃ ┣ 📎helpers.go
┃ ┣ 📎middlewares.go
┃ ┣ 📎models.go
┃ ┣ 📎routes.go
┗ ┗ 📎template.go
📂internal
┣ 📂models
┃ ┣ 📎gists.go
┗ ┗ 📎users.go
📂ui
┣ 📂html
┃ ┣ 📂 pages
┃ ┃ ┣ 📄 home.tmpl.html
┃ ┃ ┗ 📄 view.tmpl.html
┃ ┃ 📂 partials
┃ ┃ ┗ 📄 nav.tmpl.html
┃ ┗ 📄 base.tmpl.html
┣ 📂static
┃ ┗📂css
┗ ┗ ┗📄 style.css
📎 main.go
✏ test.http

CLI 커스텀 플래그 추가 (API / Template Mode)

이번 프로젝트에서는 일반적인 API와 홈페이지가 보이는 템플릿 모드 두가지를 지원해보고 싶었습니다.

이전에는 단순히 DB의 Config를 사용자에게 받는 것까지만 구현했지만, 여기서 더 나아가서 API 및 템플릿 두가지 모드를 지원 할 수 있도록 기능을 개선해봤습니다.

📎cli.go

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
package cli

import (
	"flag"
	"io"
)

type Config struct {
	IsTemplateMode bool
	User           string
	Password       string
	Dbname         string
	Port           int
}

func ParseArgs(w io.Writer, args []string) (Config, error) {
	c := Config{}
	fs := flag.NewFlagSet("Gist Application", flag.ContinueOnError)
	fs.SetOutput(w)
	fs.BoolVar(&c.IsTemplateMode, "tmp", false, "Whether running application on Template Mode")
	fs.StringVar(&c.User, "user", "root", "Database Username")
	fs.StringVar(&c.Password, "password", "", "Database User Password")
	fs.StringVar(&c.Dbname, "dbname", "", "Database Name")
	fs.IntVar(&c.Port, "port", 3306, "MySQL port")

	err := fs.Parse(args)
	if err != nil {
		return c, err
	}
	return c, nil
}

cli.go는 사용자에게 받는 파라미터를 파싱해서 Config 모델을 넘겨주는 역할을 하고 있습니다. flag.Parse() 함수를 통해 하나의 파라미터만 받을 수도 있지만, flag.NewFlagSet()을 이용하면 여러가지의 인수를 받을수 있습니다.

flag.NewFlagSet이 가지는 장점은 다음과 같습니다.

  • 자동으로 -help (-h) 파라미터를 파싱해서 사용자에게 Instruction을 보여줍니다.
  • 여러 파라미터를 받아 보다 유연한 CLI Application을 만들 수 있습니다.
  • Type casting 메소드가 지원되서 코드를 작성하기에 편합니다.

📎application.go / 📎 main.go

1
2
3
4
5
6
7
8
9
// 📎application.go
type Application struct {
	ServerLogger   *log.Logger
	ErrorLogger    *log.Logger
	Gists          *models.GistModel
	Users          *models.UserModel
	TemplateCache  map[string]*template.Template
	IsTemplateMode bool
}

📎application.go는 전반적인 애플리케이션의 config를 설정하는 역할을 하고 있습니다. Application 모델에 IsTemplateMode 라는 필드를 정의해서 애플리케이션이 현재 템플릿 모드로 돌아가고 있는지 아닌지 판단을 할 수 있도록 했습니다.

IsTemplateMode는 Middleware에서 Content-Type을 정할때나, 라우팅의 모드를 바꿀때 사용합니다.

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
// 📎main.go
c, err := cli.ParseArgs(os.Stderr, os.Args[1:])
	if err != nil {
		infoLog.Println(err)
		os.Exit(1)
	}

...

	app := &web.Application{
		ServerLogger:   infoLog,
		ErrorLogger:    errorLog,
		Gists:          &models.GistModel{DB: db},
		Users:          &models.UserModel{DB: db},
		IsTemplateMode: c.IsTemplateMode,
	}

	mux := app.InitRoutes()
	defaultMode := "API Mode"
	if app.IsTemplateMode {
		defaultMode = "Template Mode"
		templateCache, err := web.NewTemplateCache()
		if err != nil {
			errorLog.Fatal(err)
		}
		app.TemplateCache = templateCache
	}

main.go에서는 파싱한 파라미터를 Application모델에 넘겨주는 역할을 수행합니다.

📎routes.go

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
// 📎routes.go
package web

import (
	"net/http"

	"github.com/gorilla/mux"
)

func (app *Application) InitRoutes() *mux.Router {
	mux := mux.NewRouter()
	mux.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("./ui/static"))))
	if app.IsTemplateMode {
		mux.HandleFunc("/", app.TemplateHome)
		mux.HandleFunc("/gists/view/{id:[0-9]+}", app.TemplateViewOneGists)
		mux.HandleFunc("/gists/create", app.TemplateCreateGist)
	} else {
		s := mux.PathPrefix("/api").Subrouter()
		s.HandleFunc("/", app.ApiHome)
		s.HandleFunc("/gists/view", app.ApiViewLatestGists)
		s.HandleFunc("/gists/view/{id:[0-9]+}", app.ApiViewOneGists)
		s.HandleFunc("/gists/create", app.ApiCreateGist)
	}
	mux.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		app.NotFound(w)
	})
	mux.Use(app.RecoverPanics)
	mux.Use(app.HTTPLogger)
	mux.Use(app.ContentTypeHeader)
	mux.Use(app.SecureHeaders)
	return mux
}

routes.go는 애플리케이션의 route를 관리해주는 역할을 하고 있습니다. Application 모델의 메소드로 정해놓은 덕택에 손쉽게 두가지 모드의 route를 관리할 수 있습니다.

DB Connect / Access Layer

이번 프로젝트에서는 최대한 기본 라이브러리를 이용해서 백엔드를 구현하는 것이 목표이기 때문에 DB access와 sql 모두 Go의 기본 라이브러리를 활용해서 구현했습니다.

📎db.go

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
// 📎db.go
package db

import (
	"database/sql"
	"fmt"

	"github.com/go-sql-driver/mysql"
	"github.com/milkymilky0116/go-std-backend/cmd/cli"
)

func DBinit(c cli.Config) (*sql.DB, error) {
	cfg := mysql.Config{
		User:      c.User,
		Passwd:    c.Password,
		Net:       "tcp",
		Addr:      fmt.Sprintf("127.0.0.1:%d", c.Port),
		DBName:    c.Dbname,
		ParseTime: true,
	}
	db, err := sql.Open("mysql", cfg.FormatDSN())
	if err != nil {
		return nil, err
	}
	pingErr := db.Ping()
	if pingErr != nil {
		return nil, pingErr
	}
	return db, nil
}

DB를 활용하려면 당연히 DB에 연결하는 과정도 필요하겠죠. CLI를 통해 받은 파라미터들을 다시 MySQL의 Config에 넘겨주고, DB를 연결합니다.

추가적으로 핑 테스트도 해서 DB와 제대로 연결이 됐는지 확인한 뒤에 db를 반환합니다.

📎gists.go

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
84
85
86
87
88
89
90
91
92
93
94
95
96
// 📎gists.go
package models

import (
	"database/sql"
	"errors"
	"time"
)

type Gist struct {
	Id        int       `json:"id"`
	Title     string    `json:"title"`
	Content   string    `json:"content"`
	Writer    User      `json:"writer"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

type GistParam struct {
	Title   string `json:"title"`
	Content string `json:"content"`
	Writer  int    `json:"writer"`
}

type GistModel struct {
	DB *sql.DB
}

func (m *GistModel) FindOne(id int) (*Gist, error) {
	var result Gist
	var gistId, writerId int
	var title, content string
	var created_at, updated_at time.Time
	statement := "SELECT * FROM gist WHERE id = ?"
	row := m.DB.QueryRow(statement, id)

	if err := row.Scan(&gistId, &title, &content, &writerId, &created_at, &updated_at); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return &result, sql.ErrNoRows
		}
		return &result, err
	}
	var userModel = &UserModel{DB: m.DB}
	user, err := userModel.findUser(writerId)
	if err != nil {
		return &result, err
	}
	result = Gist{
		Id:        gistId,
		Title:     title,
		Content:   content,
		Writer:    *user,
		CreatedAt: created_at,
		UpdatedAt: updated_at,
	}
	return &result, nil
}

func (m *GistModel) FindMany(limit int) ([]*Gist, error) {
	gists := []*Gist{}
	statement := "SELECT id FROM gist ORDER BY created_at DESC LIMIT ?"
	rows, err := m.DB.Query(statement, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var gistId int
		gist := &Gist{}
		if err := rows.Scan(&gistId); err != nil {
			return nil, err
		}
		gist, err = m.FindOne(gistId)
		if err != nil {
			return nil, err
		}
		gists = append(gists, gist)
	}
	if err = rows.Err(); err != nil {
		return nil, err
	}
	return gists, err
}

func (m *GistModel) Insert(params GistParam) (int, error) {
	statement := "INSERT INTO gist (title, content, writer) VALUES (?, ?, ?)"
	result, err := m.DB.Exec(statement, params.Title, params.Content, params.Writer)
	if err != nil {
		return 0, err
	}
	id, err := result.LastInsertId()
	if err != nil {
		return 0, err
	}
	return int(id), nil
}

gists.go 에서는 Gist의 모델과 SQL 쿼리를 수행하는 DB Access Layer를 정의합니다.

SQL 쿼리가 single row 일때는 QueryRow() 메소드를, SQL 쿼리가 multiple row 일때는 Query() 메소드를, DELETE, UPDATE 와 같이 반환하는 값이 없을때는 Exec() 메소드를 사용합니다.

받아온 데이터들을 go의 값으로 받기 위해서 반드시 row.Scan() 메소드를 사용해서 값들을 받아오는데 이상 없는지 에러 체크를 해줘야 합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.