総合プロジェクト(パート1)

総合プロジェクト(パート1)

ゼロから完全なGoプロジェクトを構築する — Blog APIシステム。

このレッスンではブログ記事管理システム(Blog API)を構築し、要件分析、ディレクトリ設計、データモデル、ビジネスロジック、エラー処理、ユニットテストをカバーします。次のレッスンでHTTPルーティングとミドルウェアの部分を完成させます。


プロジェクト要件

機能リスト

モジュール 機能 説明
記事 作成、クエリ、一覧、更新、削除 完全なCRUDカバレッジ
カテゴリ 作成、クエリ、一覧 記事はカテゴリに属する
タグ 作成、クエリ、一覧 記事は複数のタグに関連付け可能
ユーザー 登録、クエリ 簡易版、ログイン認証なし

非機能要件


システムアーキテクチャ設計

古典的な三層アーキテクチャを採用:

TEXT
┌─────────────────────────────────────────────┐
│            Handler(HTTPレイヤー)            │
│   リクエスト受信 → パラメータ解析 → Service呼び出し → 返却 │
└──────────────────────┬──────────────────────┘
                       │
┌──────────────────────▼──────────────────────┐
│          Service(ビジネスロジックレイヤー)      │
│   ビジネスロジック → パラメータ検証 → Repository呼び出し │
└──────────────────────┬──────────────────────┘
                       │
┌──────────────────────▼──────────────────────┐
│          Repository(データレイヤー)           │
│   データアクセス → インメモリストレージ / データベース │
└─────────────────────────────────────────────┘

各層は下位の層にのみ依存し、インターフェースを通じてデカップリングされており、テストと実装の置き換えが容易です。


プロジェクトディレクトリ構造

TEXT
blog-api/
├── cmd/
│   └── server/
│       └── main.go            # プログラムエントリポイント
├── internal/
│   ├── handler/               # HTTPハンドラ(レッスン30で実装)
│   │   ├── article.go
│   │   ├── category.go
│   │   └── response.go
│   ├── service/               # ビジネスロジックレイヤー
│   │   ├── article.go
│   │   ├── category.go
│   │   └── service.go
│   ├── repository/            # データアクセスレイヤー
│   │   ├── article.go
│   │   ├── category.go
│   │   └── store.go
│   └── model/                 # データモデル
│       ├── article.go
│       ├── category.go
│       └── user.go
├── pkg/
│   └── errcode/               # 統一エラーコード
│       └── errcode.go
├── go.mod
└── README.md

ディレクトリの責任:

ディレクトリ 責任 可視性
cmd/ プログラムエントリポイント、各サブディレクトリは実行ファイルに対応 公開
internal/ プロジェクトのコアコード、外部パッケージはインポート不可 非公開
pkg/ 外部プロジェクトでも再利用可能なユーティリティパッケージ 公開

ステップ1:プロジェクトの初期化

BASH
mkdir -p blog-api && cd blog-api
go mod init blog-api

ディレクトリ構造を作成:

BASH
mkdir -p cmd/server
mkdir -p internal/model internal/repository internal/service internal/handler
mkdir -p pkg/errcode

ステップ2:データモデルの定義

ユーザーモデル

GO
// internal/model/user.go
package model

import "time"

// Userはユーザーモデル
type User struct {
	ID        int64     `json:"id"`
	Username  string    `json:"username"`
	Email     string    `json:"email"`
	CreatedAt time.Time `json:"created_at"`
}

カテゴリモデル

GO
// internal/model/category.go
package model

import "time"

// Categoryは記事カテゴリ
type Category struct {
	ID        int64     `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"created_at"`
}

記事モデル

GO
// internal/model/article.go
package model

import "time"

// Articleは記事モデル
type Article struct {
	ID         int64     `json:"id"`
	Title      string    `json:"title"`
	Content    string    `json:"content"`
	AuthorID   int64     `json:"author_id"`
	CategoryID int64     `json:"category_id"`
	Tags       []string  `json:"tags"`
	Status     int8      `json:"status"` // 0=下書き 1=公開済み
	CreatedAt  time.Time `json:"created_at"`
	UpdatedAt  time.Time `json:"updated_at"`
}

// CreateArticleRequestは記事作成リクエスト
type CreateArticleRequest struct {
	Title      string   `json:"title"`
	Content    string   `json:"content"`
	AuthorID   int64    `json:"author_id"`
	CategoryID int64    `json:"category_id"`
	Tags       []string `json:"tags"`
}

// UpdateArticleRequestは記事更新リクエスト
type UpdateArticleRequest struct {
	Title      *string  `json:"title,omitempty"`
	Content    *string  `json:"content,omitempty"`
	CategoryID *int64   `json:"category_id,omitempty"`
	Tags       []string `json:"tags,omitempty"`
	Status     *int8    `json:"status,omitempty"`
}

// ListRequestはページネーションクエリリクエスト
type ListRequest struct {
	Page     int `json:"page"`
	PageSize int `json:"page_size"`
}

// ListResponseはページネーションクエリレスポンス
type ListResponse struct {
	Items      interface{} `json:"items"`
	Total      int64       `json:"total"`
	Page       int         `json:"page"`
	PageSize   int         `json:"page_size"`
	TotalPages int         `json:"total_pages"`
}

コード解析:


ステップ3:統一エラーコード

GO
// pkg/errcode/errcode.go
package errcode

import "fmt"

// AppErrorはアプリケーションエラー
type AppError struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

// Errorはerrorインターフェースを実装
func (e *AppError) Error() string {
	return fmt.Sprintf("code=%d, message=%s", e.Code, e.Message)
}

// Newはアプリケーションエラーを作成する
func New(code int, message string) *AppError {
	return &AppError{Code: code, Message: message}
}

// 定義済みエラーコード
var (
	ErrNotFound        = New(10001, "リソースが見つかりません")
	ErrInvalidParam    = New(10002, "無効なパラメータ")
	ErrDuplicate       = New(10003, "リソースは既に存在します")
	ErrInternal        = New(10004, "内部エラー")
	ErrArticleNotFound = New(20001, "記事が見つかりません")
	ErrCategoryNotFound = New(20002, "カテゴリが見つかりません")
	ErrUserNotFound    = New(20003, "ユーザーが見つかりません")
	ErrTitleRequired   = New(20004, "タイトルは空にできません")
	ErrContentRequired = New(20005, "コンテンツは空にできません")
)

コード解析:


ステップ4:データアクセスレイヤー(Repository)

ストレージインターフェース

GO
// internal/repository/store.go
package repository

import "blog-api/internal/model"

// Storeは統一ストレージインターフェース(インターフェース指向プログラミング)
type Store interface {
	ArticleRepo() ArticleRepository
	CategoryRepo() CategoryRepository
	UserRepo() UserRepository
}

// ArticleRepositoryは記事データアクセスインターフェース
type ArticleRepository interface {
	Create(article *model.Article) error
	GetByID(id int64) (*model.Article, error)
	List(page, pageSize int) ([]*model.Article, int64, error)
	Update(article *model.Article) error
	Delete(id int64) error
}

// CategoryRepositoryはカテゴリデータアクセスインターフェース
type CategoryRepository interface {
	Create(category *model.Category) error
	GetByID(id int64) (*model.Category, error)
	List() ([]*model.Category, error)
}

// UserRepositoryはユーザーデータアクセスインターフェース
type UserRepository interface {
	Create(user *model.User) error
	GetByID(id int64) (*model.User, error)
}

インメモリ実装

GO
// internal/repository/memory_store.go
package repository

import (
	"fmt"
	"sync"
	"sync/atomic"

	"blog-api/internal/model"
)

// MemoryStoreはインメモリストレージ実装
type MemoryStore struct {
	articles   map[int64]*model.Article
	categories map[int64]*model.Category
	users      map[int64]*model.User
	mu         sync.RWMutex
	nextID     atomic.Int64
}

// NewMemoryStoreはインメモリストレージを作成する
func NewMemoryStore() *MemoryStore {
	return &MemoryStore{
		articles:   make(map[int64]*model.Article),
		categories: make(map[int64]*model.Category),
		users:      make(map[int64]*model.User),
	}
}

func (s *MemoryStore) next() int64 {
	return s.nextID.Add(1)
}

// ArticleRepoは記事リポジトリを返す
func (s *MemoryStore) ArticleRepo() ArticleRepository {
	return &memoryArticleRepo{store: s}
}

// CategoryRepoはカテゴリリポジトリを返す
func (s *MemoryStore) CategoryRepo() CategoryRepository {
	return &memoryCategoryRepo{store: s}
}

// UserRepoはユーザーリポジトリを返す
func (s *MemoryStore) UserRepo() UserRepository {
	return &memoryUserRepo{store: s}
}

// ---- 記事リポジトリ実装 ----

type memoryArticleRepo struct {
	store *MemoryStore
}

func (r *memoryArticleRepo) Create(article *model.Article) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	article.ID = r.store.next()
	r.store.articles[article.ID] = article
	return nil
}

func (r *memoryArticleRepo) GetByID(id int64) (*model.Article, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	article, ok := r.store.articles[id]
	if !ok {
		return nil, fmt.Errorf("記事 %d が見つかりません", id)
	}
	return article, nil
}

func (r *memoryArticleRepo) List(page, pageSize int) ([]*model.Article, int64, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	// すべての記事を収集
	all := make([]*model.Article, 0, len(r.store.articles))
	for _, a := range r.store.articles {
		all = append(all, a)
	}

	total := int64(len(all))
	start := (page - 1) * pageSize
	if start >= len(all) {
		return []*model.Article{}, total, nil
	}
	end := start + pageSize
	if end > len(all) {
		end = len(all)
	}

	return all[start:end], total, nil
}

func (r *memoryArticleRepo) Update(article *model.Article) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	if _, ok := r.store.articles[article.ID]; !ok {
		return fmt.Errorf("記事 %d が見つかりません", article.ID)
	}
	r.store.articles[article.ID] = article
	return nil
}

func (r *memoryArticleRepo) Delete(id int64) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	if _, ok := r.store.articles[id]; !ok {
		return fmt.Errorf("記事 %d が見つかりません", id)
	}
	delete(r.store.articles, id)
	return nil
}

// ---- カテゴリリポジトリ実装 ----

type memoryCategoryRepo struct {
	store *MemoryStore
}

func (r *memoryCategoryRepo) Create(category *model.Category) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	category.ID = r.store.next()
	r.store.categories[category.ID] = category
	return nil
}

func (r *memoryCategoryRepo) GetByID(id int64) (*model.Category, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	cat, ok := r.store.categories[id]
	if !ok {
		return nil, fmt.Errorf("カテゴリ %d が見つかりません", id)
	}
	return cat, nil
}

func (r *memoryCategoryRepo) List() ([]*model.Category, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	all := make([]*model.Category, 0, len(r.store.categories))
	for _, c := range r.store.categories {
		all = append(all, c)
	}
	return all, nil
}

// ---- ユーザーリポジトリ実装 ----

type memoryUserRepo struct {
	store *MemoryStore
}

func (r *memoryUserRepo) Create(user *model.User) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	user.ID = r.store.next()
	r.store.users[user.ID] = user
	return nil
}

func (r *memoryUserRepo) GetByID(id int64) (*model.User, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	user, ok := r.store.users[id]
	if !ok {
		return nil, fmt.Errorf("ユーザー %d が見つかりません", id)
	}
	return user, nil
}

コード解析:


ステップ5:ビジネスロジックレイヤー(Service)

Serviceインターフェース

GO
// internal/service/service.go
package service

import "blog-api/internal/model"

// ArticleServiceは記事ビジネスインターフェース
type ArticleService interface {
	Create(req *model.CreateArticleRequest) (*model.Article, error)
	GetByID(id int64) (*model.Article, error)
	List(page, pageSize int) (*model.ListResponse, error)
	Update(id int64, req *model.UpdateArticleRequest) (*model.Article, error)
	Delete(id int64) error
}

// CategoryServiceはカテゴリビジネスインターフェース
type CategoryService interface {
	Create(name string) (*model.Category, error)
	GetByID(id int64) (*model.Category, error)
	List() ([]*model.Category, error)
}

記事Service実装

GO
// internal/service/article.go
package service

import (
	"strings"
	"time"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

type articleService struct {
	store repository.Store
}

// NewArticleServiceは記事サービスを作成する
func NewArticleService(store repository.Store) ArticleService {
	return &articleService{store: store}
}

// Createは記事を作成する
func (s *articleService) Create(req *model.CreateArticleRequest) (*model.Article, error) {
	// パラメータバリデーション
	if strings.TrimSpace(req.Title) == "" {
		return nil, errcode.ErrTitleRequired
	}
	if strings.TrimSpace(req.Content) == "" {
		return nil, errcode.ErrContentRequired
	}

	// 著者の存在を確認
	_, err := s.store.UserRepo().GetByID(req.AuthorID)
	if err != nil {
		return nil, errcode.ErrUserNotFound
	}

	// カテゴリの存在を確認
	_, err = s.store.CategoryRepo().GetByID(req.CategoryID)
	if err != nil {
		return nil, errcode.ErrCategoryNotFound
	}

	// 記事オブジェクトを構築
	now := time.Now()
	article := &model.Article{
		Title:      req.Title,
		Content:    req.Content,
		AuthorID:   req.AuthorID,
		CategoryID: req.CategoryID,
		Tags:       req.Tags,
		Status:     0, // デフォルトは下書き
		CreatedAt:  now,
		UpdatedAt:  now,
	}

	// 永続化
	if err := s.store.ArticleRepo().Create(article); err != nil {
		return nil, errcode.ErrInternal
	}

	return article, nil
}

// GetByIDはIDで記事を取得する
func (s *articleService) GetByID(id int64) (*model.Article, error) {
	article, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrArticleNotFound
	}
	return article, nil
}

// Listはページネーションされた記事リストを返す
func (s *articleService) List(page, pageSize int) (*model.ListResponse, error) {
	// デフォルトパラメータ値
	if page <= 0 {
		page = 1
	}
	if pageSize <= 0 || pageSize > 100 {
		pageSize = 10
	}

	articles, total, err := s.store.ArticleRepo().List(page, pageSize)
	if err != nil {
		return nil, errcode.ErrInternal
	}

	// 総ページ数を計算
	totalPages := int(total) / pageSize
	if int(total)%pageSize > 0 {
		totalPages++
	}

	return &model.ListResponse{
		Items:      articles,
		Total:      total,
		Page:       page,
		PageSize:   pageSize,
		TotalPages: totalPages,
	}, nil
}

// Updateは記事を更新する
func (s *articleService) Update(id int64, req *model.UpdateArticleRequest) (*model.Article, error) {
	// 既存の記事をクエリ
	article, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrArticleNotFound
	}

	// 必要に応じてフィールドを更新(送信された空でないフィールドのみ更新)
	if req.Title != nil {
		if strings.TrimSpace(*req.Title) == "" {
			return nil, errcode.ErrTitleRequired
		}
		article.Title = *req.Title
	}
	if req.Content != nil {
		if strings.TrimSpace(*req.Content) == "" {
			return nil, errcode.ErrContentRequired
		}
		article.Content = *req.Content
	}
	if req.CategoryID != nil {
		// カテゴリの存在を確認
		_, err := s.store.CategoryRepo().GetByID(*req.CategoryID)
		if err != nil {
			return nil, errcode.ErrCategoryNotFound
		}
		article.CategoryID = *req.CategoryID
	}
	if req.Tags != nil {
		article.Tags = req.Tags
	}
	if req.Status != nil {
		article.Status = *req.Status
	}
	article.UpdatedAt = time.Now()

	// 永続化
	if err := s.store.ArticleRepo().Update(article); err != nil {
		return nil, errcode.ErrInternal
	}

	return article, nil
}

// Deleteは記事を削除する
func (s *articleService) Delete(id int64) error {
	_, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return errcode.ErrArticleNotFound
	}

	if err := s.store.ArticleRepo().Delete(id); err != nil {
		return errcode.ErrInternal
	}

	return nil
}

コード解析:

カテゴリService実装

GO
// internal/service/category.go
package service

import (
	"strings"
	"time"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

type categoryService struct {
	store repository.Store
}

// NewCategoryServiceはカテゴリサービスを作成する
func NewCategoryService(store repository.Store) CategoryService {
	return &categoryService{store: store}
}

// Createはカテゴリを作成する
func (s *categoryService) Create(name string) (*model.Category, error) {
	if strings.TrimSpace(name) == "" {
		return nil, errcode.New(10002, "カテゴリ名は空にできません")
	}

	category := &model.Category{
		Name:      name,
		CreatedAt: time.Now(),
	}

	if err := s.store.CategoryRepo().Create(category); err != nil {
		return nil, errcode.ErrInternal
	}

	return category, nil
}

// GetByIDはIDでカテゴリを取得する
func (s *categoryService) GetByID(id int64) (*model.Category, error) {
	cat, err := s.store.CategoryRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrCategoryNotFound
	}
	return cat, nil
}

// Listはすべてのカテゴリを返す
func (s *categoryService) List() ([]*model.Category, error) {
	cats, err := s.store.CategoryRepo().List()
	if err != nil {
		return nil, errcode.ErrInternal
	}
	return cats, nil
}

ステップ6:プログラムエントリポイント

GO
// cmd/server/main.go
package main

import (
	"fmt"
	"log"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/internal/service"
)

func main() {
	// ストレージを初期化
	store := repository.NewMemoryStore()

	// サービスを初期化
	articleSvc := service.NewArticleService(store)
	categorySvc := service.NewCategoryService(store)

	// テストデータを投入
	seedData(store, articleSvc, categorySvc)

	// ビジネスロジックを検証
	runDemo(articleSvc, categorySvc)
}

// seedDataはテストデータを投入する
func seedData(store *repository.MemoryStore, articleSvc service.ArticleService, categorySvc service.CategoryService) {
	// ユーザーを作成
	user := &model.User{
		Username: "alice",
		Email:    "alice@example.com",
	}
	if err := store.UserRepo().Create(user); err != nil {
		log.Fatalf("ユーザーの作成に失敗しました: %v", err)
	}
	fmt.Printf("✓ ユーザー作成: ID=%d, ユーザー名=%s\n", user.ID, user.Username)

	// カテゴリを作成
	goCategory, _ := categorySvc.Create("Go言語")
	pythonCategory, _ := categorySvc.Create("Python")
	fmt.Printf("✓ カテゴリ作成: ID=%d, 名前=%s\n", goCategory.ID, goCategory.Name)
	fmt.Printf("✓ カテゴリ作成: ID=%d, 名前=%s\n", pythonCategory.ID, pythonCategory.Name)

	// 記事を作成
	article1, err := articleSvc.Create(&model.CreateArticleRequest{
		Title:      "Go並行処理入門",
		Content:    "この記事ではgoroutineとchannelの基本的な使い方を紹介します...",
		AuthorID:   user.ID,
		CategoryID: goCategory.ID,
		Tags:       []string{"go", "concurrency", "goroutine"},
	})
	if err != nil {
		log.Fatalf("記事の作成に失敗しました: %v", err)
	}
	fmt.Printf("✓ 記事作成: ID=%d, タイトル=%s\n", article1.ID, article1.Title)

	article2, err := articleSvc.Create(&model.CreateArticleRequest{
		Title:      "Goインターフェース設計パターン",
		Content:    "優れたインターフェース設計はGoプログラムの鍵です...",
		AuthorID:   user.ID,
		CategoryID: goCategory.ID,
		Tags:       []string{"go", "interface", "design-patterns"},
	})
	if err != nil {
		log.Fatalf("記事の作成に失敗しました: %v", err)
	}
	fmt.Printf("✓ 記事作成: ID=%d, タイトル=%s\n", article2.ID, article2.Title)
}

// runDemoはコア機能をデモする
func runDemo(articleSvc service.ArticleService, categorySvc service.CategoryService) {
	fmt.Println("\n========== 機能デモ ==========")

	// 単一記事をクエリ
	article, err := articleSvc.GetByID(3)
	if err != nil {
		log.Printf("記事のクエリに失敗しました: %v", err)
	} else {
		fmt.Printf("\n📄 記事クエリ: %s\n", article.Title)
		fmt.Printf("   コンテンツ: %s\n", article.Content)
		fmt.Printf("   タグ: %v\n", article.Tags)
	}

	// ページネーションクエリ
	fmt.Println("\n📋 記事リスト(1ページ目、10件/ページ):")
	list, err := articleSvc.List(1, 10)
	if err != nil {
		log.Printf("リストのクエリに失敗しました: %v", err)
	} else {
		articles := list.Items.([]*model.Article)
		for _, a := range articles {
			fmt.Printf("   [%d] %s (タグ: %v)\n", a.ID, a.Title, a.Tags)
		}
		fmt.Printf("   合計 %d 記事、%d ページ\n", list.Total, list.TotalPages)
	}

	// 記事を更新
	newTitle := "Go並行処理応用"
	newStatus := int8(1)
	updated, err := articleSvc.Update(3, &model.UpdateArticleRequest{
		Title:  &newTitle,
		Status: &newStatus,
	})
	if err != nil {
		log.Printf("記事の更新に失敗しました: %v", err)
	} else {
		fmt.Printf("\n✏️  記事更新: %s (ステータス: %d)\n", updated.Title, updated.Status)
	}

	// 記事を削除
	err = articleSvc.Delete(4)
	if err != nil {
		log.Printf("記事の削除に失敗しました: %v", err)
	} else {
		fmt.Println("\n🗑️  記事ID=4を削除しました")
	}

	// 削除後のクエリを検証
	_, err = articleSvc.GetByID(4)
	if err != nil {
		fmt.Printf("   削除された記事をクエリ: %v ✓\n", err)
	}

	// カテゴリリスト
	categories, _ := categorySvc.List()
	fmt.Println("\n📂 カテゴリリスト:")
	for _, c := range categories {
		fmt.Printf("   [%d] %s\n", c.ID, c.Name)
	}

	// エラー処理デモ
	fmt.Println("\n❌ エラー処理デモ:")

	// 空のタイトル
	_, err = articleSvc.Create(&model.CreateArticleRequest{
		Title:   "",
		Content: "テスト",
	})
	fmt.Printf("   空のタイトル: %v\n", err)

	// 存在しない著者
	_, err = articleSvc.Create(&model.CreateArticleRequest{
		Title:      "テスト",
		Content:    "テスト",
		AuthorID:   999,
		CategoryID: 2,
	})
	fmt.Printf("   存在しない著者: %v\n", err)

	// 存在しない記事をクエリ
	_, err = articleSvc.GetByID(999)
	fmt.Printf("   存在しない記事: %v\n", err)
}

デモの実行

BASH
go run cmd/server/main.go

期待される出力:

TEXT
✓ ユーザー作成: ID=1, ユーザー名=alice
✓ カテゴリ作成: ID=2, 名前=Go言語
✓ カテゴリ作成: ID=3, 名前=Python
✓ 記事作成: ID=4, タイトル=Go並行処理入門
✓ 記事作成: ID=5, タイトル=Goインターフェース設計パターン

========== 機能デモ ==========

📄 記事クエリ: Go並行処理入門
   コンテンツ: この記事ではgoroutineとchannelの基本的な使い方を紹介します...
   タグ: [go concurrency goroutine]

📋 記事リスト(1ページ目、10件/ページ):
   [4] Go並行処理入門 (タグ: [go concurrency goroutine])
   [5] Goインターフェース設計パターン (タグ: [go interface design-patterns])
   合計 2 記事、1 ページ

✏️  記事更新: Go並行処理応用 (ステータス: 1)

🗑️  記事ID=5を削除しました
   削除された記事をクエリ: code=20001, message=記事が見つかりません ✓

📂 カテゴリリスト:
   [2] Go言語
   [3] Python

❌ エラー処理デモ:
   空のタイトル: code=20004, message=タイトルは空にできません
   存在しない著者: code=20003, message=ユーザーが見つかりません
   存在しない記事: code=20001, message=記事が見つかりません

ステップ7:ユニットテスト

Repositoryレイヤーのテスト

GO
// internal/repository/memory_store_test.go
package repository

import (
	"testing"
	"time"

	"blog-api/internal/model"
)

// ヘルパー関数:テストストレージを作成
func newTestStore() *MemoryStore {
	return NewMemoryStore()
}

func TestArticleRepository_Create(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:     "テスト記事",
		Content:   "テストコンテンツ",
		AuthorID:  1,
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}

	err := repo.Create(article)
	if err != nil {
		t.Fatalf("記事の作成に失敗しました: %v", err)
	}

	// IDは自動割り当てされるべき
	if article.ID == 0 {
		t.Fatal("記事IDは0であってはなりません")
	}
}

func TestArticleRepository_GetByID(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	// 記事を作成
	article := &model.Article{
		Title:   "テスト記事",
		Content: "テストコンテンツ",
	}
	_ = repo.Create(article)

	// クエリ
	got, err := repo.GetByID(article.ID)
	if err != nil {
		t.Fatalf("記事のクエリに失敗しました: %v", err)
	}
	if got.Title != "テスト記事" {
		t.Errorf("期待されるタイトル %q、実際 %q", "テスト記事", got.Title)
	}

	// 存在しない記事をクエリ
	_, err = repo.GetByID(999)
	if err == nil {
		t.Error("存在しない記事のクエリはエラーを返すべき")
	}
}

func TestArticleRepository_List(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	// 15件の記事を作成
	for i := 0; i < 15; i++ {
		_ = repo.Create(&model.Article{
			Title:   "記事",
			Content: "コンテンツ",
		})
	}

	// 1ページ目
	articles, total, err := repo.List(1, 10)
	if err != nil {
		t.Fatalf("リストのクエリに失敗しました: %v", err)
	}
	if total != 15 {
		t.Errorf("期待される合計 15、実際 %d", total)
	}
	if len(articles) != 10 {
		t.Errorf("期待される件数 10、実際 %d", len(articles))
	}

	// 2ページ目
	articles, _, _ = repo.List(2, 10)
	if len(articles) != 5 {
		t.Errorf("期待される件数 5、実際 %d", len(articles))
	}

	// 範囲外のページ
	articles, _, _ = repo.List(100, 10)
	if len(articles) != 0 {
		t.Errorf("範囲外は空を返すべき、実際 %d 件", len(articles))
	}
}

func TestArticleRepository_Update(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:   "元のタイトル",
		Content: "元のコンテンツ",
	}
	_ = repo.Create(article)

	// 更新
	article.Title = "新しいタイトル"
	err := repo.Update(article)
	if err != nil {
		t.Fatalf("更新に失敗しました: %v", err)
	}

	// 検証
	got, _ := repo.GetByID(article.ID)
	if got.Title != "新しいタイトル" {
		t.Errorf("期待される %q、実際 %q", "新しいタイトル", got.Title)
	}
}

func TestArticleRepository_Delete(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:   "削除対象",
		Content: "コンテンツ",
	}
	_ = repo.Create(article)

	// 削除
	err := repo.Delete(article.ID)
	if err != nil {
		t.Fatalf("削除に失敗しました: %v", err)
	}

	// 削除を検証
	_, err = repo.GetByID(article.ID)
	if err == nil {
		t.Error("削除された記事のクエリはエラーを返すべき")
	}
}

Serviceレイヤーのテスト

GO
// internal/service/article_test.go
package service

import (
	"testing"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

// ヘルパー関数:テストデータ付きサービスを作成
func newTestArticleService() (ArticleService, repository.Store) {
	store := repository.NewMemoryStore()

	// テストユーザーを作成
	_ = store.UserRepo().Create(&model.User{
		Username: "testuser",
		Email:    "test@example.com",
	})

	// テストカテゴリを作成
	_ = store.CategoryRepo().Create(&model.Category{
		Name: "Go",
	})

	return NewArticleService(store), store
}

func TestArticleService_Create_Success(t *testing.T) {
	svc, _ := newTestArticleService()

	article, err := svc.Create(&model.CreateArticleRequest{
		Title:      "テスト記事",
		Content:    "テストコンテンツ",
		AuthorID:   1,
		CategoryID: 2,
		Tags:       []string{"テスト"},
	})
	if err != nil {
		t.Fatalf("記事の作成に失敗しました: %v", err)
	}
	if article.Title != "テスト記事" {
		t.Errorf("期待される %q、実際 %q", "テスト記事", article.Title)
	}
	if article.Status != 0 {
		t.Errorf("デフォルトステータスは0であるべき、実際 %d", article.Status)
	}
}

func TestArticleService_Create_EmptyTitle(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:   "",
		Content: "コンテンツ",
	})

	// エラーコードを確認する型アサーション
	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("AppErrorを期待、実際 %T", err)
	}
	if appErr.Code != errcode.ErrTitleRequired.Code {
		t.Errorf("期待されるエラーコード %d、実際 %d", errcode.ErrTitleRequired.Code, appErr.Code)
	}
}

func TestArticleService_Create_InvalidAuthor(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:      "記事",
		Content:    "コンテンツ",
		AuthorID:   999,
		CategoryID: 2,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("AppErrorを期待、実際 %T", err)
	}
	if appErr.Code != errcode.ErrUserNotFound.Code {
		t.Errorf("期待されるエラーコード %d、実際 %d", errcode.ErrUserNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Create_InvalidCategory(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:      "記事",
		Content:    "コンテンツ",
		AuthorID:   1,
		CategoryID: 999,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("AppErrorを期待、実際 %T", err)
	}
	if appErr.Code != errcode.ErrCategoryNotFound.Code {
		t.Errorf("期待されるエラーコード %d、実際 %d", errcode.ErrCategoryNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Update_PartialUpdate(t *testing.T) {
	svc, _ := newTestArticleService()

	// まず記事を作成
	article, _ := svc.Create(&model.CreateArticleRequest{
		Title:      "元のタイトル",
		Content:    "元のコンテンツ",
		AuthorID:   1,
		CategoryID: 2,
	})

	// タイトルのみ更新
	newTitle := "新しいタイトル"
	updated, err := svc.Update(article.ID, &model.UpdateArticleRequest{
		Title: &newTitle,
	})
	if err != nil {
		t.Fatalf("更新に失敗しました: %v", err)
	}
	if updated.Title != "新しいタイトル" {
		t.Errorf("期待される %q、実際 %q", "新しいタイトル", updated.Title)
	}
	if updated.Content != "元のコンテンツ" {
		t.Errorf("コンテンツは変更されるべきではありません、実際 %q", updated.Content)
	}
}

func TestArticleService_Update_NotFound(t *testing.T) {
	svc, _ := newTestArticleService()

	newTitle := "テスト"
	_, err := svc.Update(999, &model.UpdateArticleRequest{
		Title: &newTitle,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("AppErrorを期待、実際 %T", err)
	}
	if appErr.Code != errcode.ErrArticleNotFound.Code {
		t.Errorf("期待されるエラーコード %d、実際 %d", errcode.ErrArticleNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Delete_Success(t *testing.T) {
	svc, _ := newTestArticleService()

	article, _ := svc.Create(&model.CreateArticleRequest{
		Title:      "削除対象",
		Content:    "コンテンツ",
		AuthorID:   1,
		CategoryID: 2,
	})

	err := svc.Delete(article.ID)
	if err != nil {
		t.Fatalf("削除に失敗しました: %v", err)
	}

	// 削除を検証
	_, err = svc.GetByID(article.ID)
	if err == nil {
		t.Error("削除された記事のクエリはエラーを返すべき")
	}
}

func TestArticleService_List_Pagination(t *testing.T) {
	svc, _ := newTestArticleService()

	// 25件の記事を作成
	for i := 0; i < 25; i++ {
		_, _ = svc.Create(&model.CreateArticleRequest{
			Title:      "記事",
			Content:    "コンテンツ",
			AuthorID:   1,
			CategoryID: 2,
		})
	}

	// 1ページ目
	resp, err := svc.List(1, 10)
	if err != nil {
		t.Fatalf("クエリに失敗しました: %v", err)
	}
	if resp.Total != 25 {
		t.Errorf("期待される合計 25、実際 %d", resp.Total)
	}
	if resp.TotalPages != 3 {
		t.Errorf("期待される総ページ数 3、実際 %d", resp.TotalPages)
	}

	// デフォルト値処理
	resp, _ = svc.List(0, 0)
	if resp.Page != 1 {
		t.Errorf("page=0はデフォルトで1であるべき、実際 %d", resp.Page)
	}
	if resp.PageSize != 10 {
		t.Errorf("pageSize=0はデフォルトで10であるべき、実際 %d", resp.PageSize)
	}
}

テストの実行

BASH
go test ./internal/... -v -cover

期待される出力:

TEXT
=== RUN   TestArticleRepository_Create
--- PASS: TestArticleRepository_Create (0.00s)
=== RUN   TestArticleRepository_GetByID
--- PASS: TestArticleRepository_GetByID (0.00s)
=== RUN   TestArticleRepository_List
--- PASS: TestArticleRepository_List (0.00s)
=== RUN   TestArticleRepository_Update
--- PASS: TestArticleRepository_Update (0.00s)
=== RUN   TestArticleRepository_Delete
--- PASS: TestArticleRepository_Delete (0.00s)
=== RUN   TestArticleService_Create_Success
--- PASS: TestArticleService_Create_Success (0.00s)
=== RUN   TestArticleService_Create_EmptyTitle
--- PASS: TestArticleService_Create_EmptyTitle (0.00s)
=== RUN   TestArticleService_Create_InvalidAuthor
--- PASS: TestArticleService_Create_InvalidAuthor (0.00s)
=== RUN   TestArticleService_Create_InvalidCategory
--- PASS: TestArticleService_Create_InvalidCategory (0.00s)
=== RUN   TestArticleService_Update_PartialUpdate
--- PASS: TestArticleService_Update_PartialUpdate (0.00s)
=== RUN   TestArticleService_Update_NotFound
--- PASS: TestArticleService_Update_NotFound (0.00s)
=== RUN   TestArticleService_Delete_Success
--- PASS: TestArticleService_Delete_Success (0.00s)
=== RUN   TestArticleService_List_Pagination
--- PASS: TestArticleService_List_Pagination (0.00s)
PASS
coverage: 82.4% of statements
ok      blog-api/internal/repository    0.002s
ok      blog-api/internal/service       0.002s

コードまとめ

このレッスンではBlog APIのコア部分を完成させました:

レイヤー ファイル 責任
Model internal/model/*.go データ構造定義、リクエスト/レスポンスボディ
Repository internal/repository/*.go データアクセスインターフェース + インメモリ実装
Service internal/service/*.go ビジネスロジック、パラメータバリデーション、エラー変換
Error pkg/errcode/errcode.go 統一エラーコード定義
Entry cmd/server/main.go プログラムエントリ、初期化、デモ

重要な設計決定:

  1. インターフェースデカップリング:RepositoryとServiceは両方ともインターフェースを通じて相互作用し、ユニットテスト(モック)と将来のストレージ置き換えを容易にする
  2. ポインタ更新UpdateArticleRequestはポインタフィールドを使用してゼロ値と未設定値を区別
  3. 統一エラーAppErrorはエラーコードとメッセージをカプセル化し、フロントエンドでの統一処理を可能にする
  4. 並行安全性sync.RWMutexはインメモリデータを保護し、読み取り操作は読み取りロック、書き込み操作は書き込みロックを使用

❓ よくある質問

質問1:なぜServiceレイヤーは具体的なRepository実装に直接依存しないのですか?

Serviceは具体的な実装ではなくインターフェースに依存するため、ユニットテストでは実際のストレージなしでモック実装を注入できます。また、後でインメモリストレージをMySQLに置き換えたい場合、ArticleRepositoryインターフェースを実装する新しいmysqlArticleRepoを作成するだけで、Serviceレイヤーのコードを変更する必要はありません。

質問2:なぜUpdateArticleRequestはポインタフィールドを使用するのですか?

Goでは、stringのゼロ値は空文字列""です。通常のフィールドでは、「ユーザーがタイトルを送信しなかった」と「ユーザーが空のタイトルを送信した」を区別できません。*stringでは、nilは未送信を意味し、nil以外は値があることを意味します。これはGo API設計の一般的なパターンです。

質問3:インメモリストレージのsync.RWMutexはパフォーマンスのボトルネックになりますか?

このチュートリアルのデモンストレーションシナリオには十分です。読み取りが書き込みを大幅に上回る場合(記事リストのクエリが作成/更新を大幅に上回る場合)、RWMutexの読み取りロックは共有され、複数の読み取り操作が同時に実行でき、書き込み操作のみが相互排他的になります。本番環境ではデータベースを使用してください。接続プールは本質的に並行安全です。

質問4:テストカバレッジを向上させるには?

現在80%以上です。さらに高いカバレッジのために、以下を追加できます:境界値テスト(非常に長いタイトル、特殊文字)、並行安全性テスト(複数のgoroutineが同時に記事を作成)、カテゴリサービスの完全なテストケース。


📖 まとめ

このレッスンではゼロから完全なGoプロジェクトの骨格を構築しました:

次のレッスンではHTTP Handlerレイヤーを完成させ、ルーティングとミドルウェアを統合してビジネスロジックをRESTful APIとして公開します。


📝 演習

演習1:独立したタグ管理を追加

タグ用の独立したRepositoryとServiceを実装してください:

演習2:記事検索機能を実装

ArticleServiceSearchメソッドを追加してください:

ヒント:インメモリ実装では、mapをイテレートしてフィルタリングします。大文字小文字を区別しないマッチングが必要です。

演習3:並行安全性テストを作成

100個のgoroutineが同時にArticleService.Createを呼び出すテストケースを作成し、以下を検証してください:


次のレッスン:総合プロジェクト(パート2) — HTTPルーティング、ミドルウェア、完全なAPI

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%