Day 7: 実践プロジェクト - 解答例
プロダクショングレード TODO API
Day 7では、これまで学んだ全ての概念を統合したプロダクショングレードのREST APIを構築します。
---
アプローチ1: 標準ライブラリベース(推奨)
完全実装
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"time"
)
// ===================================
// Domain Models
// ===================================
// Todo represents a task item with metadata
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Completed bool `json:"completed"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateTodoRequest represents the request body for creating a todo
type CreateTodoRequest struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
Priority int `json:"priority"`
}
// UpdateTodoRequest represents the request body for updating a todo
type UpdateTodoRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Completed *bool `json:"completed,omitempty"`
Priority *int `json:"priority,omitempty"`
}
// Validate checks if the create request is valid
func (r *CreateTodoRequest) Validate() error {
if strings.TrimSpace(r.Title) == "" {
return errors.New("title is required")
}
if len(r.Title) > 200 {
return errors.New("title must be less than 200 characters")
}
if r.Priority < 0 || r.Priority > 5 {
return errors.New("priority must be between 0 and 5")
}
return nil
}
// ===================================
// Data Store Layer
// ===================================
// TodoStore manages todo items with thread-safety
type TodoStore struct {
mu sync.RWMutex
todos map[int]*Todo
nextID int
}
// NewTodoStore creates a new todo store
func NewTodoStore() *TodoStore {
return &TodoStore{
todos: make(map[int]*Todo),
}
}
// Create adds a new todo to the store
func (s *TodoStore) Create(req CreateTodoRequest) (*Todo, error) {
if err := req.Validate(); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
now := time.Now()
todo := &Todo{
ID: s.nextID,
Title: strings.TrimSpace(req.Title),
Description: strings.TrimSpace(req.Description),
Completed: false,
Priority: req.Priority,
CreatedAt: now,
UpdatedAt: now,
}
s.todos[todo.ID] = todo
return todo, nil
}
// Get retrieves a todo by ID
func (s *TodoStore) Get(id int) (*Todo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
todo, exists := s.todos[id]
if !exists {
return nil, errors.New("todo not found")
}
return todo, nil
}
// List returns all todos, optionally filtered
func (s *TodoStore) List(completed *bool) []*Todo {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]*Todo, 0, len(s.todos))
for _, todo := range s.todos {
if completed == nil || todo.Completed == *completed {
result = append(result, todo)
}
}
// Sort by priority (descending) then by ID
for i := 0; i < len(result)-1; i++ {
for j := i + 1; j < len(result); j++ {
if result[i].Priority < result[j].Priority ||
(result[i].Priority == result[j].Priority && result[i].ID > result[j].ID) {
result[i], result[j] = result[j], result[i]
}
}
}
return result
}
// Update modifies an existing todo
func (s *TodoStore) Update(id int, req UpdateTodoRequest) (*Todo, error) {
s.mu.Lock()
defer s.mu.Unlock()
todo, exists := s.todos[id]
if !exists {
return nil, errors.New("todo not found")
}
// Apply updates
if req.Title != nil {
title := strings.TrimSpace(*req.Title)
if title == "" {
return nil, errors.New("title cannot be empty")
}
todo.Title = title
}
if req.Description != nil {
todo.Description = strings.TrimSpace(*req.Description)
}
if req.Completed != nil {
todo.Completed = *req.Completed
}
if req.Priority != nil {
if *req.Priority < 0 || *req.Priority > 5 {
return nil, errors.New("priority must be between 0 and 5")
}
todo.Priority = *req.Priority
}
todo.UpdatedAt = time.Now()
return todo, nil
}
// Delete removes a todo from the store
func (s *TodoStore) Delete(id int) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.todos[id]; !exists {
return errors.New("todo not found")
}
delete(s.todos, id)
return nil
}
// Stats returns statistics about todos
func (s *TodoStore) Stats() map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
total := len(s.todos)
completed := 0
for _, todo := range s.todos {
if todo.Completed {
completed++
}
}
return map[string]interface{}{
"total": total,
"completed": completed,
"active": total - completed,
}
}
// ===================================
// HTTP Handler Layer
// ===================================
// TodoHandler handles HTTP requests for todos
type TodoHandler struct {
store *TodoStore
}
// NewTodoHandler creates a new todo handler
func NewTodoHandler(store *TodoStore) *TodoHandler {
return &TodoHandler{store: store}
}
// ServeHTTP implements http.Handler
func (h *TodoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// CORS headers
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
// Route based on path and method
path := strings.TrimPrefix(r.URL.Path, "/api/todos")
switch {
case path == "" || path == "/":
h.handleTodos(w, r)
case strings.HasPrefix(path, "/"):
h.handleTodoByID(w, r, strings.TrimPrefix(path, "/"))
default:
http.NotFound(w, r)
}
}
// handleTodos handles /api/todos
func (h *TodoHandler) handleTodos(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.listTodos(w, r)
case http.MethodPost:
h.createTodo(w, r)
default:
respondError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
// handleTodoByID handles /api/todos/{id}
func (h *TodoHandler) handleTodoByID(w http.ResponseWriter, r *http.Request, idStr string) {
id, err := strconv.Atoi(idStr)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid todo ID")
return
}
switch r.Method {
case http.MethodGet:
h.getTodo(w, r, id)
case http.MethodPut:
h.updateTodo(w, r, id)
case http.MethodDelete:
h.deleteTodo(w, r, id)
default:
respondError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
// listTodos handles GET /api/todos
func (h *TodoHandler) listTodos(w http.ResponseWriter, r *http.Request) {
var completed *bool
if completedStr := r.URL.Query().Get("completed"); completedStr != "" {
val := completedStr == "true"
completed = &val
}
todos := h.store.List(completed)
respondJSON(w, http.StatusOK, todos)
}
// createTodo handles POST /api/todos
func (h *TodoHandler) createTodo(w http.ResponseWriter, r *http.Request) {
var req CreateTodoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
todo, err := h.store.Create(req)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
respondJSON(w, http.StatusCreated, todo)
}
// getTodo handles GET /api/todos/{id}
func (h *TodoHandler) getTodo(w http.ResponseWriter, r *http.Request, id int) {
todo, err := h.store.Get(id)
if err != nil {
respondError(w, http.StatusNotFound, err.Error())
return
}
respondJSON(w, http.StatusOK, todo)
}
// updateTodo handles PUT /api/todos/{id}
func (h *TodoHandler) updateTodo(w http.ResponseWriter, r *http.Request, id int) {
var req UpdateTodoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
todo, err := h.store.Update(id, req)
if err != nil {
if err.Error() == "todo not found" {
respondError(w, http.StatusNotFound, err.Error())
} else {
respondError(w, http.StatusBadRequest, err.Error())
}
return
}
respondJSON(w, http.StatusOK, todo)
}
// deleteTodo handles DELETE /api/todos/{id}
func (h *TodoHandler) deleteTodo(w http.ResponseWriter, r *http.Request, id int) {
if err := h.store.Delete(id); err != nil {
respondError(w, http.StatusNotFound, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// ===================================
// Stats Handler
// ===================================
// StatsHandler handles statistics requests
type StatsHandler struct {
store *TodoStore
}
// NewStatsHandler creates a new stats handler
func NewStatsHandler(store *TodoStore) *StatsHandler {
return &StatsHandler{store: store}
}
// ServeHTTP implements http.Handler
func (h *StatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
respondError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
stats := h.store.Stats()
respondJSON(w, http.StatusOK, stats)
}
// ===================================
// Middleware
// ===================================
// loggingMiddleware logs HTTP requests
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap the ResponseWriter to capture status code
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
log.Printf(
"%s %s %d %s",
r.Method,
r.URL.Path,
wrapped.statusCode,
time.Since(start),
)
})
}
// responseWriter wraps http.ResponseWriter to capture status code
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// recoveryMiddleware recovers from panics
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic: %v", err)
respondError(w, http.StatusInternalServerError, "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
// ===================================
// Helper Functions
// ===================================
// respondJSON sends a JSON response
func respondJSON(w http.ResponseWriter, code int, payload interface{}) {
response, err := json.Marshal(payload)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encode response")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write(response)
}
// respondError sends an error response
func respondError(w http.ResponseWriter, code int, message string) {
respondJSON(w, code, map[string]string{"error": message})
}
// ===================================
// Main Application
// ===================================
func main() {
// Initialize store
store := NewTodoStore()
// Setup routes
mux := http.NewServeMux()
mux.Handle("/api/todos", NewTodoHandler(store))
mux.Handle("/api/todos/", NewTodoHandler(store))
mux.Handle("/api/stats", NewStatsHandler(store))
// Health check endpoint
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// Apply middleware
handler := recoveryMiddleware(loggingMiddleware(mux))
// Configure server
server := &http.Server{
Addr: ":8080",
Handler: handler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Graceful shutdown
go func() {
log.Printf("Server starting on %s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server exited")
}
---
アプローチ2: テスト駆動開発(TDD)
テストファイル例
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestTodoStore_Create(t *testing.T) {
store := NewTodoStore()
tests := []struct {
name string
req CreateTodoRequest
wantErr bool
}{
{
name: "valid todo",
req: CreateTodoRequest{
Title: "Test Todo",
Priority: 1,
},
wantErr: false,
},
{
name: "empty title",
req: CreateTodoRequest{
Title: "",
Priority: 1,
},
wantErr: true,
},
{
name: "invalid priority",
req: CreateTodoRequest{
Title: "Test",
Priority: 10,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
todo, err := store.Create(tt.req)
if (err != nil) != tt.wantErr {
t.Errorf("Create() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && todo.Title != tt.req.Title {
t.Errorf("Title = %v, want %v", todo.Title, tt.req.Title)
}
})
}
}
func TestTodoHandler_CreateTodo(t *testing.T) {
store := NewTodoStore()
handler := NewTodoHandler(store)
reqBody := CreateTodoRequest{
Title: "Test Todo",
Description: "Test Description",
Priority: 2,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/todos", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusCreated {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusCreated)
}
var todo Todo
if err := json.NewDecoder(rr.Body).Decode(&todo); err != nil {
t.Errorf("failed to decode response: %v", err)
}
if todo.Title != reqBody.Title {
t.Errorf("Title = %v, want %v", todo.Title, reqBody.Title)
}
}
func TestTodoHandler_ListTodos(t *testing.T) {
store := NewTodoStore()
handler := NewTodoHandler(store)
// Create some todos
store.Create(CreateTodoRequest{Title: "Todo 1", Priority: 1})
store.Create(CreateTodoRequest{Title: "Todo 2", Priority: 2})
req := httptest.NewRequest(http.MethodGet, "/api/todos", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
var todos []*Todo
if err := json.NewDecoder(rr.Body).Decode(&todos); err != nil {
t.Errorf("failed to decode response: %v", err)
}
if len(todos) != 2 {
t.Errorf("expected 2 todos, got %d", len(todos))
}
}
func TestConcurrentAccess(t *testing.T) {
store := NewTodoStore()
const goroutines = 100
done := make(chan bool)
for i := 0; i < goroutines; i++ {
go func(id int) {
store.Create(CreateTodoRequest{
Title: fmt.Sprintf("Todo %d", id),
Priority: 1,
})
done <- true
}(i)
}
for i := 0; i < goroutines; i++ {
<-done
}
todos := store.List(nil)
if len(todos) != goroutines {
t.Errorf("expected %d todos, got %d", goroutines, len(todos))
}
}
---
アプローチ3: ベンチマーク
package main
import (
"fmt"
"testing"
)
func BenchmarkTodoStore_Create(b *testing.B) {
store := NewTodoStore()
req := CreateTodoRequest{
Title: "Benchmark Todo",
Priority: 1,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
store.Create(req)
}
}
func BenchmarkTodoStore_List(b *testing.B) {
store := NewTodoStore()
// Prepare data
for i := 0; i < 1000; i++ {
store.Create(CreateTodoRequest{
Title: fmt.Sprintf("Todo %d", i),
Priority: i % 5,
})
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
store.List(nil)
}
}
func BenchmarkConcurrentCreate(b *testing.B) {
store := NewTodoStore()
req := CreateTodoRequest{
Title: "Concurrent Todo",
Priority: 1,
}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
store.Create(req)
}
})
}
---
実行方法
サーバー起動
go run main.go
APIテスト
# Create todo
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" \
-d '{"title":"Learn Go","priority":1}'
# List todos
curl http://localhost:8080/api/todos
# Get todo by ID
curl http://localhost:8080/api/todos/1
# Update todo
curl -X PUT http://localhost:8080/api/todos/1 \
-H "Content-Type: application/json" \
-d '{"completed":true}'
# Delete todo
curl -X DELETE http://localhost:8080/api/todos/1
# Get stats
curl http://localhost:8080/api/stats
# Health check
curl http://localhost:8080/health
テスト実行
# All tests
go test -v
# With coverage
go test -cover -coverprofile=coverage.out
go tool cover -html=coverage.out
# Benchmarks
go test -bench=. -benchmem
---
プロダクション改善案
- データ永続化: SQLite, PostgreSQL
- 認証: JWT, OAuth2
- レート制限: golang.org/x/time/rate
- メトリクス: Prometheus
- ロギング: structured logging (zerolog, zap)
- 構成管理: 環境変数、設定ファイル
- API文書: OpenAPI/Swagger
- コンテナ化: Docker, Kubernetes