Skip to content

Commit

Permalink
Update chapter17
Browse files Browse the repository at this point in the history
  • Loading branch information
gleaming9 committed Nov 16, 2024
1 parent a8440a2 commit 72ce5f3
Show file tree
Hide file tree
Showing 60 changed files with 1,718 additions and 5 deletions.
43 changes: 43 additions & 0 deletions _chapter17/section68/.air.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
root = '.'
tmp_dir = 'tmp'

[build]
cmd = "go build -o ./tmp/main ." #Go 프로젝트를 빌드
bin = "./tmp/main" #빌드된 실행 파일 경로
#실행 파일 실행 시 필요한 환경 변수와 인자 설정 -> 80번 포트 사용하도록 인수 지정
full_bin = "APP_ENV=dev APP_USER = air ./tmp/main 80"

# 파일 변경 감지 설정 -> 변경을 감지할 파일 확장자
include_ext = ["go", "tpl", "tmpl", "html"]
# 감지하지 않을 디렉터리 목록
exclude_dir = ["assets", "tmp", "vendor", "frontend/node_modules", "_tools", "cert", "testutil"]
include_dir = []
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = true
exclude_underscore = false
follow_symlink = false

#로깅 설정 : 로그파일 경로, 파일 변경 감지 후 재빌드 대기 시간
log = "air.log"
delay = 1000

#오류 처리 설정
stop_on_error = true
send_interrupt = false
kill_delay = 500

[log]
time = false

[color]
#로그 출력 시 사용할 색상 설정
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"

#프로그램 종료 시 임시 디렉터리 삭제 여부
[misc]
clean_on_exit = true

2 changes: 2 additions & 0 deletions _chapter17/section68/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.git
.DS_Store
43 changes: 43 additions & 0 deletions _chapter17/section68/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#최종 배포할 실행 파일 만드는 과정
#multi-stage build에서 이 단계의 이름을 'deploy-builder'로 지정
FROM golang:1.23.1-bullseye AS deploy-builder

#app 디렉토리 생성 및 작업 디렉토리로 설정
WORKDIR /app

#종속성 파일(go.mod go.sum)만 복사해서 캐시 활용
#소스코드가 변경되어도 종속성이 변경되지 않았다면 도커의 캐시 활용
COPY go.mod go.sum ./
#프로젝트의 모든 종속성 다운로드
RUN go mod download

#전체 소스 파일을 /app으로 복사하고 최적화된 바이너리 생성
COPY . .
RUN go build -trimpath -ldflags="-w -s" -o app

# 최종 배포 단계 : 실제 운영 환경에서 실행될 최소한의 이미지 생성
#경량화된 Degian 이미지 사용
FROM debian:bullseye-slim AS deploy

#시스템 패키지 최신화
RUN apt-get update

#첫 번째 단계에서 만든 실행 파일만 복사
COPY --from=deploy-builder /app/app .

#컨테이너 시작 시 애플리케이션 자동 실행
CMD ["./app"]

#개발자의 로컬 환경을 위한 설정
FROM golang:1.23 AS dev

#/app 디렉토리를 작업공간으로 설정
WORKDIR /app

#air 도구 설치 (코드 변경 시 자동 재빌드 지원)
RUN go install github.com/air-verse/air@latest

#개발 서버 자동 시작
CMD ["air"]

## docker-compose down -v <= 실행중인 컨테이너를 멈추고 매핑된 볼륨들을 제거
29 changes: 29 additions & 0 deletions _chapter17/section68/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
.PHONY: help build build-local up down logs ps test
.DEFAULT_GOAL := help

DOCKER_TAG := latest
build: ## Build the docker image
docker build -t gleaming9/go_todo_app:${DOCKER_TAG} \
--target deploy ./

build-local: ## Build the docker image for local development
docker compose build --no-cache

up: ## 자동 새로고침을 사용한 도커 컴포즈 실행
docker compose up -d

down: ## 도커 컴포즈 종료
docker compose down

logs: ## 도커 컴포즈 로그 출력
docker compose logs -f

ps: ## 실행중인 컨테이너 확인
docker compose ps

test: ## 테스트 실행
go test -race -shuffle=on ./...

help: ## 옵션 보기
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
19 changes: 19 additions & 0 deletions _chapter17/section68/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package config

import "github.com/caarlos0/env/v6"

// Config 구조체는 애플리케이션 설정을 위한 구조체이다.
// 환경 변수에서 값을 가져와서 필드를 초기화한다.
type Config struct {
Env string `env:"TODO_ENV" envDefault:"dev"`
Port int `env:"PORT" envDefault:"80"`
}

// New 함수는 Config 구조체를 생성하고 환경 변수를 파싱하여 필드를 초기화
func New() (*Config, error) {
cfg := &Config{} // config 구조체 초기화
if err := env.Parse(cfg); err != nil {
return nil, err
}
return cfg, nil
}
23 changes: 23 additions & 0 deletions _chapter17/section68/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package config

import (
"fmt"
"testing"
)

func TestNeow(t *testing.T) {
wantPort := 3333
t.Setenv("PORT", fmt.Sprint(wantPort))

got, err := New()
if err != nil {
t.Fatalf("cannot create config: %v", err)
}
if got.Port != wantPort {
t.Errorf("want %d, but %d", wantPort, got.Port)
}
wantEnv := "dev"
if got.Env != wantEnv {
t.Errorf("want %s, but %s", wantEnv, got.Env)
}
}
28 changes: 28 additions & 0 deletions _chapter17/section68/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
services:

# 'app'이라는 이름의 서비스(컨테이너)를 정의
app:
# 사용할 Docker 이미지 이름 : 'gotodo'
image: gotodo

build:
#빌드할 파일들이 있는 위치를 지정 : .. <- 한칸 위 폴더에 go.mod 위치
#go_todo_app 폴더를 컨텍스트로 설정
context: .
dockerfile: Dockerfile
# target=dev로 설정하여 Dockerfile의 dev stage를 빌드
args:
target: dev
environment: # 환경 변수 설정
TODO_ENV: dev
PORT: 8080

# 로컬 컴퓨터의 파일과 컨테이너 안의 파일을 연결, 코드 수정 시 바로 컨테이너에 반영
volumes:
- .:/app # go_todo_app 루트 디렉터리를 컨테이너의 /app에 마운트

# 컨테이너가 사용할 포트를 지정
ports:
# 웹 브라우저에서 localhost:18000으로 접속하면 컨테이너의 80번 포트로 연결
# 왼쪽은 내 컴퓨터의 포트, 오른쪽은 컨테이너 안의 포트
- "18000:8080"
21 changes: 21 additions & 0 deletions _chapter17/section68/entity/task.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package entity

import "time"

type TaskID int64
type TaskStatus string

const (
TaskStatusTodo TaskStatus = "todo"
TaskStatusDoing TaskStatus = "doing"
TaskStatusDone TaskStatus = "done"
)

type Task struct {
ID TaskID `json:"id"`
Title string `json:"title"`
Status TaskStatus `json:"status"`
Created time.Time `json:"created"`
}

type Tasks []*Task
14 changes: 14 additions & 0 deletions _chapter17/section68/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module go_todo_app

go 1.23.1

require (
github.com/caarlos0/env/v6 v6.10.1 // indirect
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator v9.31.0+incompatible // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
golang.org/x/sync v0.9.0 // indirect
)
8 changes: 8 additions & 0 deletions _chapter17/section68/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
62 changes: 62 additions & 0 deletions _chapter17/section68/handler/add_task.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package handler

import (
"encoding/json"
"github.com/go-playground/validator"
"go_todo_app/entity"
"go_todo_app/store"
"net/http"
"time"
)

type AddTask struct {
Store *store.TaskStore
Validator *validator.Validate
}

func (at *AddTask) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

//요청 본문을 JSON으로 파싱하여 Title 필드를 가져옵니다.
var b struct {
// Title 필드는 필수입니다.
Title string `json:"title" validate:"required"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
// JSON 파싱 중 오류가 발생하면 500 상태 코드와 오류 메시지를 반환
RespondJSON(ctx, w, &ErrResponse{
Message: err.Error(),
}, http.StatusBadRequest)
return
}
// Validator를 사용하여 Title 필드가 있는지 검증
// Unmarshal한 타입에 대한 검증을 위해 Validator를 사용, JSON 구조가 방대하거나 복잡한 경우 자주 사용
if err := at.Validator.Struct(b); err != nil {
// 필수 필드가 없으면 400 상태 코드와 오류 메시지를 반환
RespondJSON(ctx, w, &ErrResponse{
Message: err.Error(),
}, http.StatusBadRequest)
return
}

// Task 구조체를 생성하여, Title, Status, Created 필드를 설정
t := &entity.Task{
Title: b.Title, // 입력받은 Title을 설정
Status: "todo", // 초기 상태는 "todo"로 설정
Created: time.Now(), // 생성된 시간을 현재 시간으로 설정
}
// TaskStore에 Task를 추가하고 ID를 반환
id, err := store.Tasks.Add(t)
if err != nil {
// Task 추가 중 오류가 발생하면 500 상태 코드와 오류 메시지를 반환
RespondJSON(ctx, w, &ErrResponse{
Message: err.Error(),
}, http.StatusInternalServerError)
return
}
// Task의 ID를 JSON 응답으로 반환
rsp := struct {
ID int `json:"id"` // JSON 응답에서 ID 필드의 이름을 "id"로 설정
}{ID: int(id)}
RespondJSON(ctx, w, rsp, http.StatusOK)
}
66 changes: 66 additions & 0 deletions _chapter17/section68/handler/add_task_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package handler

import (
"bytes"
"github.com/go-playground/validator"
"go_todo_app/entity"
"go_todo_app/store"
"go_todo_app/testutil"
"net/http"
"net/http/httptest"
"testing"
)

func TestAddTask(t *testing.T) {
type want struct {
status int // 예상되는 HTTP 상태 코드
rspFile string // 예상되는 응답 JSON 파일 경로
}
//테스트 케이스 정의
tests := map[string]struct {
reqFile string // 요청 JSON 파일 경로
want want // 예상되는 응답
}{ // 익명 구조체 정의 부분
"ok": { // 정상적인 요청에 대한 테스트 케이스
reqFile: "testdata/add_task/ok_req.json.golden",
want: want{
status: http.StatusOK,
rspFile: "testdata/add_task/ok_rsp.json.golden",
},
},
"badRequest": { // 잘못된 요청에 대한 테스트 케이스
reqFile: "testdata/add_task/bad_req.json.golden",
want: want{
status: http.StatusBadRequest,
rspFile: "testdata/add_task/bad_rsp.json.golden",
},
},
}
// 각 테스트 케이스에 대한 반복 실행
for n, tt := range tests {
tt := tt // tt 변수를 로컬로 복사하여 병렬 실행 시 데이터 경합 방지
t.Run(n, func(t *testing.T) {
t.Parallel() // 각 테스트 케이스를 병렬로 실행

//가짜 응답 기록기
w := httptest.NewRecorder()
//가짜 요청 생성
r := httptest.NewRequest(
http.MethodPost,
"/tasks",
bytes.NewReader(testutil.LoadFile(t, tt.reqFile)),
)

sut := AddTask{Store: &store.TaskStore{
Tasks: map[entity.TaskID]*entity.Task{}, // 빈 TaskStore를 사용하여 저장
}, Validator: validator.New()} // 입력 검증을 위한 validator 생성
// AddTask 핸들러 실행
sut.ServeHTTP(w, r)

// 예상 응답과 비교
resp := w.Result()
testutil.AssertResponse(t,
resp, tt.want.status, testutil.LoadFile(t, tt.want.rspFile))
})
}
}
Loading

0 comments on commit 72ce5f3

Please sign in to comment.