総合プロジェクト(パート2)
レッスン30:総合プロジェクト(パート2)
現実世界のアナロジー
前回のレッスンでは「タスク管理システムTaskFlow」の要件分析、ディレクトリ構造設計、データモデル、コアビジネスロジックを完成させました。家を建てるのに、基礎が敷かれ骨組みが立ったようなものです。このレッスンでは仕上げと引き渡しを完成させます:
- REST API = ドアと窓を設置し、外の人々が入れるようにする
- ミドルウェア = アクセス制御システム(認証)、防犯カメラ(ログ記録)、来訪者登録(CORS)
- データベース統合 = 仮設家具をオーダーメイドの家具に交換し、耐久性を持たせる
- 統合テスト = 検収検査で、水道、電気、ガスがすべて正常に機能することを確認
- APIドキュメント = ユーザーマニュアル
- Dockerfile = 家をコンテナに梱包し、どこに移動しても住めるようにする
このレッスンの後、デプロイ可能、テスト可能、ドキュメント化された完全なGoプロジェクトを手にできます。
プロジェクトの振り返り
前回のレッスンではTaskFlowのコア骨格を構築しました。このレッスンではそれをさらに洗練させます。レッスン29:総合プロジェクト(パート1)をまだお読みでない場合は、まず前半を完了することをお勧めします。
最終プロジェクト構造:
taskflow/
├── cmd/
│ └── server/
│ └── main.go # Program entry point
├── internal/
│ ├── model/
│ │ ├── task.go # Data model
│ │ └── task_test.go # Model tests
│ ├── store/
│ │ ├── store.go # Storage interface
│ │ ├── memory.go # In-memory implementation (previous lesson)
│ │ └── sqlite.go # SQLite implementation (this lesson)
│ ├── service/
│ │ ├── task.go # Business logic
│ │ └── task_test.go # Unit tests
│ ├── handler/
│ │ ├── task.go # HTTP handler
│ │ └── task_test.go # Handler tests
│ └── middleware/
│ ├── auth.go # Authentication middleware
│ ├── logging.go # Logging middleware
│ └── cors.go # CORS middleware
├── docs/
│ └── api.md # API documentation
├── Dockerfile # Container packaging
├── docker-compose.yml # Orchestration config
├── go.mod
├── go.sum
└── README.md
1. REST APIレイヤー完成
1.1 ルート設計
RESTful規約に基づいて設計されたAPIルート:
| メソッド | パス | 説明 |
|---|---|---|
GET |
/api/tasks |
タスクリストを取得 |
GET |
/api/tasks/{id} |
単一タスクを取得 |
POST |
/api/tasks |
タスクを作成 |
PUT |
/api/tasks/{id} |
タスクを更新 |
DELETE |
/api/tasks/{id} |
タスクを削除 |
GET |
/health |
ヘルスチェック |
1.2 完全なハンドラ実装
// internal/handler/task.go
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"taskflow/internal/model"
"taskflow/internal/service"
)
// TaskHandler task HTTP handler
type TaskHandler struct {
svc *service.TaskService
}
// NewTaskHandler creates a handler instance
func NewTaskHandler(svc *service.TaskService) *TaskHandler {
return &TaskHandler{svc: svc}
}
// ErrorResponse unified error response format
type ErrorResponse struct {
Error string `json:"error"`
Code int `json:"code"`
Message string `json:"message,omitempty"`
}
// SuccessResponse unified success response format
type SuccessResponse struct {
Data interface{} `json:"data"`
Message string `json:"message,omitempty"`
}
// writeJSON writes a JSON response
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
// writeError writes an error response
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, ErrorResponse{
Error: msg,
Code: status,
})
}
// extractID extracts the ID parameter from the URL path
func extractID(r *http.Request) (int, error) {
// Path format: /api/tasks/{id}
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 3 {
return 0, errors.New("missing ID parameter")
}
return strconv.Atoi(parts[2])
}
// ListTasks handles GET /api/tasks
func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
// Parse query parameters
query := r.URL.Query()
filter := service.TaskFilter{
Status: model.TaskStatus(query.Get("status")),
}
// Parse pagination parameters
page, _ := strconv.Atoi(query.Get("page"))
pageSize, _ := strconv.Atoi(query.Get("page_size"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
tasks, total, err := h.svc.ListTasks(r.Context(), filter, page, pageSize)
if err != nil {
writeError(w, http.StatusInternalServerError, "Query failed: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"data": tasks,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// GetTask handles GET /api/tasks/{id}
func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
id, err := extractID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "Invalid task ID")
return
}
task, err := h.svc.GetTask(r.Context(), id)
if err != nil {
if errors.Is(err, service.ErrTaskNotFound) {
writeError(w, http.StatusNotFound, "Task not found")
return
}
writeError(w, http.StatusInternalServerError, "Query failed")
return
}
writeJSON(w, http.StatusOK, SuccessResponse{Data: task})
}
// CreateTask handles POST /api/tasks
func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
var req model.CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request format")
return
}
task, err := h.svc.CreateTask(r.Context(), &req)
if err != nil {
if errors.Is(err, service.ErrValidation) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "Creation failed")
return
}
writeJSON(w, http.StatusCreated, SuccessResponse{
Data: task,
Message: "Task created successfully",
})
}
// UpdateTask handles PUT /api/tasks/{id}
func (h *TaskHandler) UpdateTask(w http.ResponseWriter, r *http.Request) {
id, err := extractID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "Invalid task ID")
return
}
var req model.UpdateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request format")
return
}
task, err := h.svc.UpdateTask(r.Context(), id, &req)
if err != nil {
switch {
case errors.Is(err, service.ErrTaskNotFound):
writeError(w, http.StatusNotFound, "Task not found")
case errors.Is(err, service.ErrValidation):
writeError(w, http.StatusBadRequest, err.Error())
default:
writeError(w, http.StatusInternalServerError, "Update failed")
}
return
}
writeJSON(w, http.StatusOK, SuccessResponse{
Data: task,
Message: "Task updated successfully",
})
}
// DeleteTask handles DELETE /api/tasks/{id}
func (h *TaskHandler) DeleteTask(w http.ResponseWriter, r *http.Request) {
id, err := extractID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "Invalid task ID")
return
}
if err := h.svc.DeleteTask(r.Context(), id); err != nil {
if errors.Is(err, service.ErrTaskNotFound) {
writeError(w, http.StatusNotFound, "Task not found")
return
}
writeError(w, http.StatusInternalServerError, "Deletion failed")
return
}
writeJSON(w, http.StatusOK, SuccessResponse{Message: "Task deleted successfully"})
}
// HealthCheck health check endpoint
func (h *TaskHandler) HealthCheck(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"service": "taskflow",
})
}
1.3 ルート登録とサーバー起動
// cmd/server/main.go
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"taskflow/internal/handler"
"taskflow/internal/middleware"
"taskflow/internal/service"
"taskflow/internal/store"
)
func main() {
// Initialize logging
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
// Initialize storage layer (default to SQLite)
dbPath := getEnv("DB_PATH", "taskflow.db")
st, err := store.NewSQLiteStore(dbPath)
if err != nil {
slog.Error("Database initialization failed", "error", err)
os.Exit(1)
}
defer st.Close()
// Initialize business layer
svc := service.NewTaskService(st)
// Initialize handlers
h := handler.NewTaskHandler(svc)
// Register routes
mux := http.NewServeMux()
// Health check
mux.HandleFunc("GET /health", h.HealthCheck)
// API routes
mux.HandleFunc("GET /api/tasks", h.ListTasks)
mux.HandleFunc("POST /api/tasks", h.CreateTask)
mux.HandleFunc("GET /api/tasks/{id}", h.GetTask)
mux.HandleFunc("PUT /api/tasks/{id}", h.UpdateTask)
mux.HandleFunc("DELETE /api/tasks/{id}", h.DeleteTask)
// Assemble middleware chain: CORS → Logging → Auth → Business handler
apiKey := getEnv("API_KEY", "dev-secret-key")
handlerChain := middleware.Chain(
mux,
middleware.CORS(),
middleware.Logging(),
middleware.Auth(apiKey),
)
// Create HTTP server
addr := ":" + getEnv("PORT", "8080")
srv := &http.Server{
Addr: addr,
Handler: handlerChain,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Start server (non-blocking)
go func() {
slog.Info("TaskFlow service started", "addr", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("Server exited abnormally", "error", err)
os.Exit(1)
}
}()
// Graceful shutdown: listen for system signals
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("Received shutdown signal, gracefully shutting down...")
// Give in-progress requests 5 seconds to complete
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("Server shutdown error", "error", err)
}
slog.Info("TaskFlow service stopped")
}
// getEnv gets an environment variable with default value support
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}
net/http supports method matching ("GET /path") and path parameters ({id}), allowing you to build RESTful APIs without third-party routing libraries.
2. ミドルウェア統合
2.1 ミドルウェアチェーンパターン
Middleware are "interceptors" in the HTTP request processing pipeline that can execute common logic before and after requests reach the handler:
Request → [CORS] → [Logging] → [Auth] → [Handler] → Response
↓ ↓ ↓
Cross-origin Record Verify
control duration identity
2.2 ミドルウェアインフラストラクチャ
// internal/middleware/middleware.go
package middleware
import "net/http"
// Middleware type definition
type Middleware func(http.Handler) http.Handler
// Chain combines multiple middleware into a chain, first passed = first executed
func Chain(h http.Handler, middlewares ...Middleware) http.Handler {
// Wrap from back to front, ensuring execution order is left to right
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}
2.3 CORSミドルウェア
// internal/middleware/cors.go
package middleware
import "net/http"
// CORS handles Cross-Origin Resource Sharing
func CORS() Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Set CORS response 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, Authorization, X-Request-ID")
w.Header().Set("Access-Control-Max-Age", "86400") // Preflight cache 24 hours
// Preflight request returns directly
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
2.4 ログ記録ミドルウェア
// internal/middleware/logging.go
package middleware
import (
"log/slog"
"net/http"
"time"
)
// responseWriter wraps ResponseWriter to capture status code
type responseWriter struct {
http.ResponseWriter
statusCode int
written int64
}
func newResponseWriter(w http.ResponseWriter) *responseWriter {
return &responseWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
}
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
n, err := rw.ResponseWriter.Write(b)
rw.written += int64(n)
return n, err
}
// Logging records processing logs for each request
func Logging() Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap ResponseWriter to capture status code
rw := newResponseWriter(w)
// Call next handler
next.ServeHTTP(rw, r)
// Record request log
duration := time.Since(start)
slog.Info("HTTP request",
"method", r.Method,
"path", r.URL.Path,
"status", rw.statusCode,
"duration_ms", duration.Milliseconds(),
"remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
)
})
}
}
2.5 認証ミドルウェア
// internal/middleware/auth.go
package middleware
import (
"net/http"
"strings"
)
// Auth validates API key
func Auth(apiKey string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Health check endpoint doesn't need authentication
if r.URL.Path == "/health" {
next.ServeHTTP(w, r)
return
}
// Extract token from request header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
w.Header().Set("WWW-Authenticate", `Bearer realm="taskflow"`)
http.Error(w, `{"error":"Missing authentication","code":401}`, http.StatusUnauthorized)
return
}
// Validate Bearer token format
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, `{"error":"Invalid auth format, should be Bearer <token>","code":401}`, http.StatusUnauthorized)
return
}
// Validate token value
if parts[1] != apiKey {
http.Error(w, `{"error":"Authentication failed: invalid API key","code":403}`, http.StatusForbidden)
return
}
// Authentication passed, continue processing
next.ServeHTTP(w, r)
})
}
}
3. データベース統合(SQLite)
3.1 なぜSQLite?
| 特徴 | SQLite | MySQL/PostgreSQL |
|---|---|---|
| インストール | ゼロ設定、単一ファイル | 別のサービスが必要 |
| 用途 | 中小プロジェクト、組み込み | 大規模本番環境 |
| 並行性 | 読み取り並行、書き込み直列 | 高並行性 |
| 移行 | 自分で実装が必要 | 豊富なツール |
3.2 SQLiteストレージ実装
// internal/store/sqlite.go
package store
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/mattn/go-sqlite3" // SQLite driver, anonymous import triggers initialization
"taskflow/internal/model"
)
// SQLiteStore SQLite storage implementation
type SQLiteStore struct {
db *sql.DB
}
// NewSQLiteStore creates and initializes SQLite storage
func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Verify connection
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("database connection failed: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(1) // SQLite single write, limit concurrent writes
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0) // Connections don't expire
// Execute table migration
if err := migrate(db); err != nil {
return nil, fmt.Errorf("database migration failed: %w", err)
}
return &SQLiteStore{db: db}, nil
}
// migrate executes database migration
func migrate(db *sql.DB) error {
query := `
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
priority TEXT NOT NULL DEFAULT 'medium',
due_date DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
`
_, err := db.Exec(query)
return err
}
// Close closes the database connection
func (s *SQLiteStore) Close() error {
return s.db.Close()
}
// Create creates a task
func (s *SQLiteStore) Create(ctx context.Context, task *model.Task) error {
query := `
INSERT INTO tasks (title, description, status, priority, due_date, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`
now := time.Now()
result, err := s.db.ExecContext(ctx, query,
task.Title, task.Description, task.Status, task.Priority,
task.DueDate, now, now,
)
if err != nil {
return fmt.Errorf("failed to insert task: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get insert ID: %w", err)
}
task.ID = int(id)
task.CreatedAt = now
task.UpdatedAt = now
return nil
}
// GetByID queries a task by ID
func (s *SQLiteStore) GetByID(ctx context.Context, id int) (*model.Task, error) {
query := `
SELECT id, title, description, status, priority, due_date, created_at, updated_at
FROM tasks WHERE id = ?
`
var task model.Task
var dueDate sql.NullTime
err := s.db.QueryRowContext(ctx, query, id).Scan(
&task.ID, &task.Title, &task.Description,
&task.Status, &task.Priority, &dueDate,
&task.CreatedAt, &task.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil // Not found returns nil
}
if err != nil {
return nil, fmt.Errorf("failed to query task: %w", err)
}
if dueDate.Valid {
task.DueDate = &dueDate.Time
}
return &task, nil
}
// List queries task list (supports filtering and pagination)
func (s *SQLiteStore) List(ctx context.Context, filter TaskFilter, page, pageSize int) ([]model.Task, int, error) {
// Build query conditions
where := "1=1"
args := []interface{}{}
if filter.Status != "" {
where += " AND status = ?"
args = append(args, filter.Status)
}
if filter.Priority != "" {
where += " AND priority = ?"
args = append(args, filter.Priority)
}
// Query total count
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM tasks WHERE %s", where)
var total int
if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("failed to query total: %w", err)
}
// Paginated query
offset := (page - 1) * pageSize
query := fmt.Sprintf(`
SELECT id, title, description, status, priority, due_date, created_at, updated_at
FROM tasks WHERE %s
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`, where)
args = append(args, pageSize, offset)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("failed to query list: %w", err)
}
defer rows.Close()
var tasks []model.Task
for rows.Next() {
var task model.Task
var dueDate sql.NullTime
if err := rows.Scan(
&task.ID, &task.Title, &task.Description,
&task.Status, &task.Priority, &dueDate,
&task.CreatedAt, &task.UpdatedAt,
); err != nil {
return nil, 0, fmt.Errorf("failed to scan row: %w", err)
}
if dueDate.Valid {
task.DueDate = &dueDate.Time
}
tasks = append(tasks, task)
}
return tasks, total, nil
}
// Update updates a task
func (s *SQLiteStore) Update(ctx context.Context, task *model.Task) error {
query := `
UPDATE tasks
SET title = ?, description = ?, status = ?, priority = ?, due_date = ?, updated_at = ?
WHERE id = ?
`
result, err := s.db.ExecContext(ctx, query,
task.Title, task.Description, task.Status, task.Priority,
task.DueDate, time.Now(), task.ID,
)
if err != nil {
return fmt.Errorf("failed to update task: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("task not found")
}
return nil
}
// Delete deletes a task
func (s *SQLiteStore) Delete(ctx context.Context, id int) error {
query := "DELETE FROM tasks WHERE id = ?"
result, err := s.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete task: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("task not found")
}
return nil
}
// TaskFilter task filter conditions
type TaskFilter struct {
Status model.TaskStatus
Priority model.TaskPriority
}
3.3 統一ストレージインターフェース
// internal/store/store.go
package store
import (
"context"
"taskflow/internal/model"
)
// TaskStore defines the task storage interface
// All storage implementations (in-memory, SQLite, PostgreSQL) must implement this interface
type TaskStore interface {
Create(ctx context.Context, task *model.Task) error
GetByID(ctx context.Context, id int) (*model.Task, error)
List(ctx context.Context, filter TaskFilter, page, pageSize int) ([]model.Task, int, error)
Update(ctx context.Context, task *model.Task) error
Delete(ctx context.Context, id int) error
Close() error
}
TaskStoreインターフェースのみに依存し、基盤のストレージがSQLiteかインメモリかを気にしません。テストにはモックを注入し、本番には実際の実装を使用します。
4. 統合テスト
4.1 ハンドラ統合テスト
統合テストは複数のコンポーネントが正しく連携するかを検証し、httptestを使用して実際のHTTPリクエストをシミュレートします:
// internal/handler/task_test.go
package handler_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"taskflow/internal/handler"
"taskflow/internal/middleware"
"taskflow/internal/model"
"taskflow/internal/service"
"taskflow/internal/store"
)
// setupTestServer creates a test server
func setupTestServer(t *testing.T) *httptest.Server {
t.Helper()
// Use in-memory storage
memStore := store.NewMemoryStore()
svc := service.NewTaskService(memStore)
h := handler.NewTaskHandler(svc)
// Register routes
mux := http.NewServeMux()
mux.HandleFunc("GET /health", h.HealthCheck)
mux.HandleFunc("GET /api/tasks", h.ListTasks)
mux.HandleFunc("POST /api/tasks", h.CreateTask)
mux.HandleFunc("GET /api/tasks/{id}", h.GetTask)
mux.HandleFunc("PUT /api/tasks/{id}", h.UpdateTask)
mux.HandleFunc("DELETE /api/tasks/{id}", h.DeleteTask)
// Add middleware
apiKey := "test-key"
handlerChain := middleware.Chain(
mux,
middleware.CORS(),
middleware.Logging(),
middleware.Auth(apiKey),
)
return httptest.NewServer(handlerChain)
}
// TestIntegration_CRUD complete CRUD flow integration test
func TestIntegration_CRUD(t *testing.T) {
srv := setupTestServer(t)
defer srv.Close()
client := srv.Client()
authHeader := "Bearer test-key"
// 1. Health check
t.Run("Health check", func(t *testing.T) {
resp, err := client.Get(srv.URL + "/health")
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Status code = %d, expected 200", resp.StatusCode)
}
})
// 2. Create task
var createdTask model.Task
t.Run("Create task", func(t *testing.T) {
body := `{"title":"Integration test task","description":"Testing CRUD flow","priority":"high"}`
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/api/tasks", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", authHeader)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Errorf("Status code = %d, expected 201", resp.StatusCode)
}
var result struct {
Data model.Task `json:"data"`
Message string `json:"message"`
}
json.NewDecoder(resp.Body).Decode(&result)
createdTask = result.Data
if createdTask.Title != "Integration test task" {
t.Errorf("Title = %q, expected %q", createdTask.Title, "Integration test task")
}
})
// 3. Query single task
t.Run("Query task", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/tasks/1", nil)
req.Header.Set("Authorization", authHeader)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Status code = %d, expected 200", resp.StatusCode)
}
})
// 4. Update task
t.Run("Update task", func(t *testing.T) {
body := `{"title":"Updated task","status":"completed"}`
req, _ := http.NewRequest(http.MethodPut, srv.URL+"/api/tasks/1", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", authHeader)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Status code = %d, expected 200", resp.StatusCode)
}
})
// 5. Query list
t.Run("Query list", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/tasks?page=1&page_size=10", nil)
req.Header.Set("Authorization", authHeader)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Status code = %d, expected 200", resp.StatusCode)
}
var result struct {
Data []model.Task `json:"data"`
Total int `json:"total"`
}
json.NewDecoder(resp.Body).Decode(&result)
if result.Total != 1 {
t.Errorf("Total = %d, expected 1", result.Total)
}
})
// 6. Delete task
t.Run("Delete task", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/api/tasks/1", nil)
req.Header.Set("Authorization", authHeader)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Status code = %d, expected 200", resp.StatusCode)
}
})
// 7. Verify query after deletion returns 404
t.Run("Query after deletion should return 404", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/tasks/1", nil)
req.Header.Set("Authorization", authHeader)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("Status code = %d, expected 404", resp.StatusCode)
}
})
}
// TestIntegration_Auth authentication middleware integration test
func TestIntegration_Auth(t *testing.T) {
srv := setupTestServer(t)
defer srv.Close()
client := srv.Client()
tests := []struct {
name string
authHeader string
wantCode int
}{
{"No auth header", "", http.StatusUnauthorized},
{"Wrong format", "Basic abc123", http.StatusUnauthorized},
{"Wrong key", "Bearer wrong-key", http.StatusForbidden},
{"Correct auth", "Bearer test-key", http.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/tasks", nil)
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != tt.wantCode {
t.Errorf("Status code = %d, expected %d", resp.StatusCode, tt.wantCode)
}
})
}
}
// TestIntegration_CORS cross-origin test
func TestIntegration_CORS(t *testing.T) {
srv := setupTestServer(t)
defer srv.Close()
client := srv.Client()
// OPTIONS preflight request
req, _ := http.NewRequest(http.MethodOptions, srv.URL+"/api/tasks", nil)
req.Header.Set("Origin", "http://example.com")
req.Header.Set("Access-Control-Request-Method", "POST")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("Status code = %d, expected 204", resp.StatusCode)
}
if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
t.Error("Missing CORS response header")
}
}
4.2 統合テストの実行
# Run all tests
go test ./... -v
# Only run integration tests
go test -run TestIntegration -v
# View coverage
go test ./... -cover -coverprofile=coverage.out
# Generate HTML coverage report
go tool cover -html=coverage.out -o coverage.html
# Race detection
go test -race ./...
5. APIドキュメント(Swagger/OpenAPI概要)
5.1 OpenAPI仕様概要
OpenAPI (formerly Swagger) is the standard specification for describing RESTful APIs, defining interfaces in YAML/JSON format:
# docs/api.yaml (simplified version)
openapi: "3.0.3"
info:
title: TaskFlow API
description: Task management system RESTful API
version: "1.0.0"
contact:
name: Development Team
email: dev@taskflow.example.com
servers:
- url: http://localhost:8080
description: Local development environment
paths:
/api/tasks:
get:
summary: Get task list
operationId: listTasks
tags: [Task Management]
parameters:
- name: status
in: query
schema:
type: string
enum: [pending, in_progress, completed]
- name: page
in: query
schema:
type: integer
default: 1
- name: page_size
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
"200":
description: Successfully returned task list
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/Task"
total:
type: integer
page:
type: integer
post:
summary: Create new task
operationId: createTask
tags: [Task Management]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateTaskRequest"
responses:
"201":
description: Task created successfully
"400":
description: Invalid request parameters
/api/tasks/{id}:
get:
summary: Get single task
operationId: getTask
tags: [Task Management]
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"200":
description: Success
"404":
description: Task not found
put:
summary: Update task
operationId: updateTask
tags: [Task Management]
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"200":
description: Updated successfully
"404":
description: Task not found
delete:
summary: Delete task
operationId: deleteTask
tags: [Task Management]
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"200":
description: Deleted successfully
"404":
description: Task not found
components:
schemas:
Task:
type: object
properties:
id:
type: integer
example: 1
title:
type: string
example: "Learn Go"
description:
type: string
example: "Complete Lesson 30 comprehensive project"
status:
type: string
enum: [pending, in_progress, completed]
priority:
type: string
enum: [low, medium, high, urgent]
due_date:
type: string
format: date-time
nullable: true
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
CreateTaskRequest:
type: object
required: [title]
properties:
title:
type: string
minLength: 1
maxLength: 200
description:
type: string
priority:
type: string
enum: [low, medium, high, urgent]
default: medium
due_date:
type: string
format: date-time
nullable: true
securitySchemes:
BearerAuth:
type: http
scheme: bearer
security:
- BearerAuth: []
5.2 Swaggoによるドキュメント自動生成
For Go projects, it's recommended to use swaggo/swag to auto-generate Swagger documentation from code comments:
// Add comments above handler functions
// ListTasks godoc
// @Summary Get task list
// @Description Supports filtering by status and pagination
// @Tags Task Management
// @Accept json
// @Produce json
// @Param status query string false "Task status"
// @Param page query int false "Page number" default(1)
// @Param page_size query int false "Items per page" default(20)
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} ErrorResponse
// @Security BearerAuth
// @Router /api/tasks [get]
func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
// ...
}
Generation command:
# Install swag tool
go install github.com/swaggo/swag/cmd/swag@latest
# Generate documentation in project root directory
swag init -g cmd/server/main.go
# Generated files are in docs/ directory
# docs/swagger.json, docs/swagger.yaml, docs/docs.go
6. README作成ガイドライン
An excellent README should include the following sections:
# TaskFlow - Task Management System
> A lightweight task management RESTful API service built with Go
## Features
- Complete task CRUD operations
- Filter by status and priority
- Pagination support
- API key authentication
- CORS cross-origin support
- Structured logging
- SQLite persistent storage
- One-click Docker deployment
## Quick Start
### Prerequisites
- Go 1.22+
- SQLite3 (development environment)
### Installation and Running
```bash
# Clone project
git clone https://github.com/yourname/taskflow.git
cd taskflow
# Install dependencies
go mod tidy
# Run service
go run cmd/server/main.go
# Service listens on http://localhost:8080 by default
Environment Variables
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
Service port |
DB_PATH |
taskflow.db |
SQLite database file path |
API_KEY |
dev-secret-key |
API authentication key |
API Usage Examples
# Create task
curl -X POST http://localhost:8080/api/tasks \
-H "Authorization: Bearer dev-secret-key" \
-H "Content-Type: application/json" \
-d '{"title":"Learn Go","priority":"high"}'
# Query list
curl http://localhost:8080/api/tasks \
-H "Authorization: Bearer dev-secret-key"
# Health check (no auth needed)
curl http://localhost:8080/health
Project Structure
taskflow/
├── cmd/server/ # Program entry point
├── internal/
│ ├── model/ # Data models
│ ├── store/ # Data storage layer
│ ├── service/ # Business logic layer
│ ├── handler/ # HTTP handler layer
│ └── middleware/ # Middleware
├── docs/ # API documentation
├── Dockerfile
└── README.md
Testing
# Run all tests
go test ./... -v
# View coverage
go test ./... -cover
# Race detection
go test -race ./...
Deployment
Docker Deployment
# Build image
docker build -t taskflow:latest .
# Run container
docker run -d \
-p 8080:8080 \
-e API_KEY=your-production-key \
-e DB_PATH=/data/taskflow.db \
-v taskflow-data:/data \
--name taskflow \
taskflow:latest
License
MIT License
> 💡 README Writing Principle: Get others running in 30 seconds first, then gradually expand details.
---
## 7. Dockerfileパッケージングとリリース
### 7.1 マルチステージビルド
```dockerfile
# Dockerfile
# ========== Stage 1: Build ==========
FROM golang:1.22-alpine AS builder
# Install SQLite compilation dependencies
RUN apk add --no-cache gcc musl-dev
WORKDIR /app
# Copy dependency files first, leveraging Docker cache layers
COPY go.mod go.sum ./
RUN go mod download
# Copy source and compile
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build \
-ldflags="-s -w" \
-o /app/taskflow \
./cmd/server/main.go
# ========== Stage 2: Run ==========
FROM alpine:3.19
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata
# Create non-root user
RUN adduser -D -g '' appuser
WORKDIR /app
# Copy binary from build stage
COPY --from=builder /app/taskflow .
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/health || exit 1
# Startup command
ENTRYPOINT ["./taskflow"]
7.2 Docker Compose設定
# docker-compose.yml
version: "3.8"
services:
taskflow:
build: .
ports:
- "8080:8080"
environment:
- PORT=8080
- DB_PATH=/data/taskflow.db
- API_KEY=${API_KEY:-change-me-in-production}
- TZ=Asia/Shanghai
volumes:
- taskflow-data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
timeout: 3s
retries: 3
volumes:
taskflow-data:
7.3 ビルドと実行
# Build Docker image
docker build -t taskflow:latest .
# View image size
docker images taskflow
# Start with Docker Compose
export API_KEY=your-secret-key
docker compose up -d
# View logs
docker compose logs -f
# Stop service
docker compose down
# Stop and remove data volume
docker compose down -v
7.4 クロスコンパイル(Docker環境なしの場合)
# Compile Linux binary (on Windows/macOS)
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
CC=x86_64-linux-musl-gcc \
go build -ldflags="-s -w" -o taskflow-linux ./cmd/server/main.go
# Compile CGO-free version (doesn't support SQLite, needs pure Go database driver)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o taskflow-linux ./cmd/server/main.go
8. デプロイガイド
8.1 デプロイチェックリスト
✅ Security Checks
□ Production API_KEY changed to strong password
□ CORS config restricts allowed origin domains
□ Logs don't contain sensitive information
□ Using HTTPS (configure reverse proxy)
✅ Performance Checks
□ Database connection pool configured
□ HTTP timeouts set (Read/Write/Idle)
□ Request body size limited
□ Paginated queries have maximum limits
✅ Reliability Checks
□ Health check endpoint available
□ Graceful shutdown logic implemented
□ Log format is structured JSON
□ Docker health check configured
8.2 Nginxリバースプロキシの使用
# /etc/nginx/conf.d/taskflow.conf
server {
listen 80;
server_name api.taskflow.example.com;
# Force HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name api.taskflow.example.com;
ssl_certificate /etc/ssl/certs/taskflow.crt;
ssl_certificate_key /etc/ssl/private/taskflow.key;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeout settings
proxy_connect_timeout 10s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
}
❓ よくある質問
質問1:SQLiteがDockerで「database is locked」と表示される場合は?
回答: SQLiteは同時書き込みに制限があります。以下を確認してください:
// 1. Set connection pool to 1 (single connection)
db.SetMaxOpenConns(1)
// 2. Enable WAL mode (allows read/write concurrency)
db.Exec("PRAGMA journal_mode=WAL")
// 3. Set busy wait timeout
// Add ?_busy_timeout=5000 to connection string
並行性が高い場合は、PostgreSQLへの切り替えを検討してください。
質問2:ミドルウェアの実行順序は重要ですか?
回答: はい。推奨順序:CORS → Logging → Auth → ビジネスハンドラ。理由:
- CORSは最初に処理する必要がある。プリフライトリクエスト(OPTIONS)は認証情報を携帯しない
- Authの前にLoggingで認証されていないリクエストをキャプチャ
- ビジネスハンドラの前にAuthで無効なリクエストをインターセプト
// In Chain, the first passed middleware executes first
handler := middleware.Chain(mux,
middleware.CORS(), // 1. Executes first
middleware.Logging(), // 2. Second
middleware.Auth(key), // 3. Executes last
)
質問3:本番環境でAPIキーをどう管理すべきですか?
回答: キーをハードコードしたり、コードリポジトリにコミットしたりしないでください。推奨方法:
# Method 1: Environment variables
export API_KEY=$(openssl rand -hex 32)
# Method 2: .env file (add to .gitignore)
echo "API_KEY=$(openssl rand -hex 32)" > .env
# Method 3: Docker Secrets (Swarm mode)
echo "my-secret-key" | docker secret create api_key -
質問4:プロジェクトにはSQLiteとPostgreSQLのどちらを使うべきですか?
回答: シナリオに基づいて選択してください:
| シナリオ | 推奨 | 理由 |
|---|---|---|
| 学習/プロトタイピング | SQLite | ゼロ設定、単一ファイル |
| 社内ツール/低トラフィック | SQLite | 十分でメンテナンスが容易 |
| 複数サービスでデータ共有 | PostgreSQL | ネットワークアクセス、集中管理 |
| 高並行書き込み | PostgreSQL | SQLiteの書き込みは直列 |
| JSONクエリが必要 | PostgreSQL | 強力なJSONBサポート |
📖 コースまとめ
30のGo言語チュートリアルレッスンをすべて完了しました!知識体系全体を振り返りましょう:
フェーズ1 — Go基礎(レッスン1-6)
| レッスン | コアポイント |
|---|---|
| Go入門 | Goのポジショニング:クラウドネイティブ時代のシステムレベル言語 |
| 変数と型 | :=短い変数、ゼロ値メカニズム、iota列挙型 |
| 制御フロー | forは唯一のループ、defer遅延実行、switchのfallthroughなし |
| 関数 | 複数戻り値、クロージャ、init関数、関数は第一級市民 |
| 配列とスライス | スライスは参照型、append拡張メカニズム、内部原理 |
| Map | comma okパターン、順序なし性質、スライスとの選択基準 |
フェーズ2 — 構造体とインターフェース(レッスン7-12)
| レッスン | コアポイント |
|---|---|
| 構造体 | 値型 vs ポインタ、構造体タグ、匿名フィールド |
| メソッド | 値レシーバ vs ポインタレシーバ、継承ではなくコンポジション |
| インターフェース | 暗黙の実装(ダックタイピング)、型アサーション、インターフェース合成 |
| エラー処理 | errorインターフェース、errors.Is/As、カスタムエラー、明示的エラー処理哲学 |
| パッケージとモジュール | go mod、エクスポートルール、internalパッケージ |
| 練習 | 構造体 + インターフェース + エラー処理の総合使用 |
フェーズ3 — 並行プログラミング(レッスン13-18)
| レッスン | コアポイント |
|---|---|
| Goroutine | 軽量コルーチン、sync.WaitGroup、リーク防止 |
| Channel | 非バッファ vs バッファ、close/range、方向制限 |
| Select | マルチプレクシング、タイムアウト制御、ファンイン/ファンアウトパターン |
| syncパッケージ | Mutex/RWMutex、Once、sync.Map、レース検出 |
| タスクスケジューラ | context.Context、並行協調、タイムアウトリトライ |
| Webクローラー | レート制限、重複排除、エラーリトライ、グレースフル終了 |
フェーズ4 — 標準ライブラリと実践スキル(レッスン19-24)
| レッスン | コアポイント |
|---|---|
| 文字列処理 | strings/strconvパッケージ、strings.Builder |
| ファイルIO | osパッケージ、bufio効率的な読み書き、ディレクトリトラバーサル |
| JSON処理 | Marshal/Unmarshal、構造体タグ、ストリーム処理 |
| HTTPプログラミング | net/httpサーバー、Handlerインターフェース、ミドルウェアパターン |
| テスト | テーブル駆動テスト、httptest、ベンチマーク、カバレッジ |
| 正規表現と日付 | regexpパッケージ、timeフォーマットと解析、タイマー |
フェーズ5 — 総合プロジェクト(レッスン25-30)
| レッスン | コアポイント |
|---|---|
| CLIツール | flagパッケージ、cobraライブラリ、引数バリデーション |
| REST API | RESTful設計、JSON処理、ミドルウェアチェーン |
| データベース | database/sql、SQLiteドライバ、CRUDカプセル化 |
| デプロイと最適化 | クロスコンパイル、Dockerビルド、グレースフルシャットダウン |
| 総合プロジェクト(パート1) | アーキテクチャ設計、ディレクトリ構造、ビジネスロジック、ユニットテスト |
| 総合プロジェクト(パート2) | API完成、ミドルウェア統合、統合テスト、コンテナ化リリース |
コアコンピテンシーチェックリスト
30のレッスンの後、以下を習得しました:
✅ Go言語の基本構文と型システム
✅ オブジェクト指向プログラミング(構造体 + インターフェース)
✅ 並行プログラミング(Goroutine + Channel + sync)
✅ 標準ライブラリコアパッケージ(net/http、encoding/json、testingなど)
✅ RESTful API設計と開発
✅ データベース操作(SQLドライバ、CRUDカプセル化)
✅ テスト駆動開発(ユニットテスト、統合テスト、ベンチマーク)
✅ プロジェクトエンジニアリング(ディレクトリ構造、依存管理、設定管理)
✅ コンテナ化デプロイ(Docker、マルチステージビルド、ヘルスチェック)
📝 今後の学習提案
上級方向
| 方向 | 推奨リソース | 説明 |
|---|---|---|
| Webフレームワーク | Gin、Echo | 本番グレードのHTTPフレームワーク |
| ORM | GORM、sqlx | データベース操作の簡素化 |
| マイクロサービス | go-kit、Kratos、go-zero | マイクロサービスフレームワーク |
| 設定管理 | Viper | マルチフォーマット設定読み取り |
| ロギング | zap、zerolog | 高性能ログライブラリ |
| APIゲートウェイ | Kong、Traefik | トラフィック管理 |
| クラウドネイティブ | Kubernetes、Docker Compose、Helm | コンテナオーケストレーション |
| メッセージキュー | NATS、Kafka、RabbitMQ | 非同期通信 |
推奨書籍
- 「The Go Programming Language」 — Donovan & Kernighan
- 「Go in Action」 — William Kennedy
- 「Concurrency in Go」 — Katherine Cox-Buday
- 「100 Go Mistakes and How to Avoid Them」 — Teiva Harsanyi
推奨オンラインリソース
プロジェクト実践提案
| 難易度 | プロジェクト | 実践ポイント |
|---|---|---|
| ⭐⭐ | URL短縮サービス | HTTP、JSON、SQLite |
| ⭐⭐⭐ | チャットルーム | WebSocket、Goroutine、Channel |
| ⭐⭐⭐ | 個人ブログシステム | テンプレートエンジン、セッション、ファイルアップロード |
| ⭐⭐⭐⭐ | 分散クローラー | マルチノード協調、メッセージキュー、重複排除 |
| ⭐⭐⭐⭐ | APIゲートウェイ | ミドルウェアチェーン、負荷分散、レート制限とサーキットブレーカー |
🎉 結び
"Less is more" — Goの設計哲学
30のレッスン、fmt.Println("Hello, World!")からデプロイ可能なRESTful APIサービスの構築まで、Go言語の学習旅程全体を完了しました。
Goの魅力はそのシンプルさにあります。あまりにも多くの選択肢を与えることはありませんが、すべての選択肢は慎重に検討されています。並行問題に直面したときはgoroutineとchannelを、エラーを処理するときは「暗黙ではなく明示的」を、インターフェースを設計するときは「ダックタイピング」と「継承ではなくコンポジション」を考えましょう。
プログラミングは職人芸であり、学習の最良の方法はコードを書くことです。30のレッスンのサンプルをすべて打ち込み、すべての演習を完了し、その後、実際の小さなプロジェクトを見つけて練習してください。Goが想像していたよりもシンプルで強力であることに気づくでしょう。
╔══════════════════════════════════════════════════╗
║ 30レッスンのGo言語チュートリアルを ║
║ 完了しました! ║
║ 本番グレードのGoアプリケーションを開発する ║
║ 基礎を身につけました。 ║
║ 素晴らしいものを作りましょう! ║
╚══════════════════════════════════════════════════╝
次のレッスン
これは最後のレッスンです。コースの出発点に戻りたい場合は、レッスン1:Go入門に戻ってください。
ご質問やご意見がある場合は、GitHub Issuesでお気軽にご連絡ください。



