일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
Tags
- Classification
- LG
- PCA
- LG Aimers
- deep learning
- 딥러닝
- 티스토리챌린지
- 해커톤
- 분류
- ChatGPT
- AI
- 오블완
- regression
- GPT-4
- 머신러닝
- Machine Learning
- supervised learning
- 회귀
- LLM
- gpt
- OpenAI
- LG Aimers 4th
- 지도학습
Archives
- Today
- Total
SYDev
[Go 언어로 배우는 웹 애플리케이션 개발] Chapter 17. 엔드포인트 추가하기 본문
Section 65. Entity.Task 타입 정의와 영구 저장 방법의 임시 구현
1. Entity.Task 타입 정의
- 자체 타입을 지정하여 잘못된 대입 방지
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
-> 3번째 줄은 타입 추론(type inference)에 의해서 컴파일러가 TaskID 타입의 1로 해석
2. entity.Task의 영구 저장 방법 임시 구현
- entity.Task 타입값을 영구 저장하는 임시 구현
- store directory에 store.go라는 파일로 저장
package stroe
import (
"errors"
"github.com/Imsyp/go_todo_app/chapter17/section65/entity"
)
var (
Tasks = &TaskStore{Tasks: map[entity.TaskID]*entity.Task{}}
ErrNotFound = errors.New("not found")
)
type TaskStore struct {
// 동작 확인용
LastID entity.TaskID
Tasks map[entity.TaskID]*entity.Task
}
func (ts *TaskStore) Add(t *entity.Task) (entity.TaskID, error) {
ts.LastID++
t.ID = ts.LastID
ts.Tasks[t.ID] = t
return t.ID, nil
}
func (ts *TaskStore) Get(id entity.TaskID) (*entity.Task, error) {
if ts, ok := ts.Tasks[id]; ok {
return ts, nil
}
return nil, ErrNotFound
}
// 정렬이 끝난 task 목록을 반환
func (ts *TaskStore) All() entity.Tasks {
tasks := make([]*entity.Task, len(ts.Tasks))
for i, t := range ts.Tasks {
tasks[i-1] = t
}
return tasks
}
-> Task.ID field: 실제 구현에서는 RDBMS에 의해 자동 증가한 값을 설정해야 하므로, TaskStore 타입에서도 내부에 할당이 끝난 ID 번호를 저장
Section 66. 헬퍼 함수 구현
- 지금부터 작성하는 몇 개의 HTTP handler -> response data를 JSON으로 변환해서 상태 코드와 함게 http.ResponseWriter 인터페이스를 충족하는 type의 값으로 기록하는 작업을 반복
- HTTP handler 구현을 공통화하기 위해 미리 helper function 작성
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
type ErrResponse struct {
Message string `json:"message"`
Details []string `json:"details,omitempty"`
}
func RespondJSON(ctx context.Context, w http.ResponseWriter, body any, status int) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
bodyBytes, err := json.Marshal(body)
if err != nil {
fmt.Printf("encode response error: %v", err)
w.WriterHeader(http.StatusInternalServerError)
rsp := ErrResponse{
Message: http.StatusText(http.StatusInternalServerError),
if err := json.NewEncoder(w).Encode(rsp); err != nil {
fmt.Printf("write error response error: %v", err)
}
return
}
}
w.WriteHeader(status)
if _, err := fmt.Fprintf(w, "%s", bodyBytes); err != nil {
fmt.Printf("write response error: %v", err)
}
}
-> HTTP handler 내에서 귀찮은 JSON 응답 작성을 간략화
1. 테스트용 헬퍼 함수 구현
- HTTP 핸들러의 테스트에서 사용할 검증 순서도 공통 헬퍼로 작성
- 복잡한 JSON 구조를 문자열로 비교하면 어디에 차이가 있는지 파악하기 어려움
- AssetJSON: JSON 문자열을 Unmarshal하고 cmp 패키지를 사용해 차이를 비교
package testutil
import (
"encoding/json"
"io"
"net/http"
"os"
"testing"
"github.com/google/go-cmp/cmp"
)
func AssertJson(t *testing.T, want, got []byte) {
t.Helper()
var jw, jg any
if err := json.Unmarshal(want, &jw); err != nil {
t.Fatalf("cannot unmarshal want %q: %v", want, err)
}
if err := json.Unmarshal(got, &jg); err != nil {
t.Fatalf("cannot unmarshal got %q: %v", got, err)
}
if diff := cmp.Diff(jg, jw); diff != "" {
t.Errorf("got differs: (-got +want)\n%s", diff)
}
}
func AssertResponse(t *testing.T, got *http.Response, status int, body []byte) {
t.Helper()
t.Cleanup(func() { _ = got.Body.Close() })
gb, err := io.ReadAll(got.Body)
if err != nil {
t.Fatal(err)
}
if got.StatusCode != status {
t.Fatalf("want status %d, but got %d, body: %q", status, got.StatusCode, gb)
}
if len(gb) == 0 && len(body) ==0 {
// 어느 쪽이든 응답 바디가 없으므로 AssetJSON 호출할 필요 X
return
}
AssetJSON(t, body, gb)
}
// 입력값, 기댓값을 파일에서 읽어옴
func LoadFile(t *testing.T, path string) []byte {
t.Helper()
bt, err := os.ReadFile(path)
if err != nil {
t.Fatalf("cannot read from %q: %v", path, err)
}
return bt
}
marshal, unmarshal?
- marshal: json 형태의 구조체 -> byte나 string 형태의 data로 encoding
- unmarshal: byte나 string 형태의 data -> json 형태의 구조체로 decoding
Section 67. Task를 등록하는 Endpoint 구현
- ToDo application에서 task를 등록하는 HTTP handler 구현
- POST /task로 오는 요청 처리
- task title을 포함한 JSON request body를 반드시 지정해야 함
- task가 성공적으로 등록되면 해당 task의 ID를 response body로 반환
- AddTask type: http.HandlerFunc type을 충족하는 ServeHTTP method를 구현
- request 처리가 정상 완료된 경우 RespondJSON을 사용해 JSON 응답을 반환
- 오류가 발생한 경우 ErrResponse type에 정보를 포함시켜 RespondJSON을 사용해 JSON 응답을 반환
package handler
import (
"encoding/json"
"net/http"
"time"
"github.com/Imsyp/go_todo_app/chapter17/section65/entity"
"github.com/Imsyp/go_todo_app/chapter17/section65/store"
"github.com/go-playground/validator/v10"
)
type AddTask struct {
Store *store.TaskStore
validator *validator.Validate
}
func (at *AddTask) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var b struct {
Title string `json:"title" validate:"required"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
RespondJSON(ctx, w, &ErrResponse{
Message: err.Error(),
}, http.StatusInternalServerError)
return
}
if err := at.Validator.Struct(b); err != nil {
RespondJSON(ctx, w, &ErrResponse{
Message: err.Error(),
}, http.StatusBadRequest)
return
}
t := &entity.Task{
Title: b.Title,
Status: "todo",
Created: time.Now(),
}
id, err := store,Tasks.Add(t)
if err != nil {
RespondJSON(ctx, w, &ErrResponse{
Message: err.Error(),
}, http.StatusInternalServerError)
return
}
rsp := struct {
ID int `json:"id"`
}{ID: int(id)}
RespondJSON(ctx, w, rsp, http.StatusOK)
}
요청 바디 검증
- 해당 HTTP handler에서는 task title을 포함한 JSON request body를 필수로 요구
- request body를 검증하기 위해 request body를 Unmarshal한 type의 값에 대해 if문을 반복해서 검증하는 방법도 존재
- 하지만, JSON 구조가 방대하거나 JSON의 각 필드 조건이 복잡한 경우 JSON 로직만으로도 많은 로직 작성해야함, 경우에 따라 로직을 작성하지 않아 놓치는 필드가 발생
- github.com/go-playground/validator: Unmarshal한 type에 validate라는 key를 태그로 사용해 해당 필드의 검증 조건을 설정할 수 있음
- 설정한 조건은 *validator.ValidateStruct method로 검증할 수 있음
- 정의 완료된 조건도 다수 있으며, 'ip 주소로 유효한가', '전화번호로 유효한가' 등의 검증도 간단히 정의 가능
- 위 코드에서는 task title이 필수적 -> Title string 'json: "title" validate: "required"'라고 설정
Section 68. 테이블 주도 테스트와 골든 테스트를 조합한 테스트 코드
- TestAddTask 함수는 httptest 패키지를 사용해 HTTP handler에 request를 보내서 테스트 수행
- tests 변수로 여러 개의 테스트 데이터 선언 -> t.Run method로 서브 테스트 실행
- 테이블 주도 테스트(Table Driven Test): 여러 개의 입력 및 기댓값을 조합해서 공통화된 실행 순서로 테스트하는 패턴을 Go에서는 테이블 주도 테스트라 한다.
- 골든 테스트(Golden Test): 테스트 입력값이나 기댓값을 파일에 저장한 테스트 -> 테스트 코드와 별도로 저장하는 데이터를 *.json.golden 형태로 저장하기 때문에 golden test라 부름
package handler
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/Imsyp/go_todo_app/chapter17/section65/entity"
"github.com/Imsyp/go_todo_app/chapter17/section65/store"
"github.com/Imsyp/go_todo_app/chapter17/section65/testutil"
"github.com/go-playground/validator/v10"
)
func TestAddTask(t *testing.T) {
type want struct {
status int
rspFile string
}
tests := map[string]struct {
reqFile string
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 test {
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{},
}, Validator: validator.New()}
sut.ServeHTTP(w, r)
resp := w.Result()
testutil.AssertResponse(t,
resp, tt.want.status, testutil.LoadFile(t, tt.want.rspFile),
)
})
}
}
-> IDE에서 *.json.golden 확장자를 JSON 파일로 인식하도록 설정해야 함
Section 70. HTTP Handler를 Routing으로 설정
- 위에서 구현한 AddTask type, ListTask type의 HTTP handler를 HTTP 서버의 엔드 포인트로 설정
- problem -> 표준 패키지인 http.ServerMux type의 routing 설정이 쉽지 않음 -> 아래 구현이 어려움
- /users/10처럼 url에 포함된 경로 파라미터의 해석
- GET /users와 POST /users같이 HTTP method의 차이에 의한 다른 HTTP handler 구현
- 이런 이유로 open source를 이용하여 routing 구현
1. Github.com/go-chi/chi를 사용한 유연한 routing 설정
- github.com/go-chi/chi: go 언어에서 라우팅 기능을 제공하는 오픈소스
- chi.NewRoudter 함수의 *chi.Mux 타입값은 http.Handler 인터페이스를 충족 -> NewMux 함수의 시그니처를 변경하지 않고 내부 구현을 변경
package main
import (
"net/http"
"github.com/Imsyp/go_todo_app/chapter17/section70/entity"
"github.com/Imsyp/go_todo_app/chapter17/section70/store"
"github.com/Imsyp/go_todo_app/chapter17/section70/testutil"
"github.com/go-chi/chi/v5"
"github.com/go-playground/validator/v10"
)
func NewMux() http.Handler {
mux := http.NewRouter()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]bute(`{"status": "ok"}`))
})
v := validator.New()
mux.Handle("/tasks", &handler.AddTask{Store: store:Tasks, Validator: v})
at := &handler.AddTask{Store: store.Tasks, Validator: v}
mux.Post("/tasks", at.ServeHTTP)
lt := &handler.ListTask{Store: store.Tasks}
mux.Get("/tasks", lt.serveHTTP)
return mux
}
Section 71. 동작 검증
참고자료
'GDGoC KHU 1th' 카테고리의 다른 글
gRPC란? (2) | 2024.11.16 |
---|---|
[Go 언어로 배우는 웹 애플리케이션 개발] Chapter 18. RDBMS를 사용한 데이터베이스 처리 구현하기 (0) | 2024.11.14 |
[백엔드를 위한 GO 프로그래밍] Chapter 6. 상호 호환성 (6) | 2024.10.07 |