総合プロジェクト(パート1)
総合プロジェクト(パート1)
ゼロから完全なGoプロジェクトを構築する — Blog APIシステム。
このレッスンではブログ記事管理システム(Blog API)を構築し、要件分析、ディレクトリ設計、データモデル、ビジネスロジック、エラー処理、ユニットテストをカバーします。次のレッスンでHTTPルーティングとミドルウェアの部分を完成させます。
プロジェクト要件
機能リスト
| モジュール | 機能 | 説明 |
|---|---|---|
| 記事 | 作成、クエリ、一覧、更新、削除 | 完全なCRUDカバレッジ |
| カテゴリ | 作成、クエリ、一覧 | 記事はカテゴリに属する |
| タグ | 作成、クエリ、一覧 | 記事は複数のタグに関連付け可能 |
| ユーザー | 登録、クエリ | 簡易版、ログイン認証なし |
非機能要件
- 入力パラメータバリデーション
- 統一エラーレスポンス形式
- ページネーションクエリサポート
- ユニットテストカバレッジ > 70%
システムアーキテクチャ設計
古典的な三層アーキテクチャを採用:
┌─────────────────────────────────────────────┐
│ Handler(HTTPレイヤー) │
│ リクエスト受信 → パラメータ解析 → Service呼び出し → 返却 │
└──────────────────────┬──────────────────────┘
│
┌──────────────────────▼──────────────────────┐
│ Service(ビジネスロジックレイヤー) │
│ ビジネスロジック → パラメータ検証 → Repository呼び出し │
└──────────────────────┬──────────────────────┘
│
┌──────────────────────▼──────────────────────┐
│ Repository(データレイヤー) │
│ データアクセス → インメモリストレージ / データベース │
└─────────────────────────────────────────────┘
各層は下位の層にのみ依存し、インターフェースを通じてデカップリングされており、テストと実装の置き換えが容易です。
プロジェクトディレクトリ構造
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:プロジェクトの初期化
mkdir -p blog-api && cd blog-api
go mod init blog-api
ディレクトリ構造を作成:
mkdir -p cmd/server
mkdir -p internal/model internal/repository internal/service internal/handler
mkdir -p pkg/errcode
ステップ2:データモデルの定義
ユーザーモデル
// 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"`
}
カテゴリモデル
// 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"`
}
記事モデル
// 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"`
}
コード解析:
CreateArticleRequest:作成時にはすべてのフィールドが必須で、独立した構造体を使用UpdateArticleRequest:更新時にはポインタを使用して「未送信」と「空の値」を区別ListRequest/Response:統一ページネーション形式で、すべての一覧エンドポイントで再利用
ステップ3:統一エラーコード
// 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, "コンテンツは空にできません")
)
コード解析:
AppError構造体はエラーコードとメッセージを含み、フロントエンドでの統一処理が容易varでエラーを事前定義し、実行時の文字列連結を回避- エラーコードはモジュールごとに範囲を分割:1xxxxは一般、2xxxxは記事関連
ステップ4:データアクセスレイヤー(Repository)
ストレージインターフェース
// 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)
}
インメモリ実装
// 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
}
コード解析:
MemoryStoreはすべてのデータを保持し、sync.RWMutexで並行安全性を確保- 3つのリポジトリメソッドはそれぞれ対応するインターフェース実装を返す
atomic.Int64はロックなしで自動インクリメントIDを生成- インメモリ実装は開発テストに便利で、後でデータベース実装に置き換え可能(上位レイヤーのコード変更不要)
ステップ5:ビジネスロジックレイヤー(Service)
Serviceインターフェース
// 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実装
// 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
}
コード解析:
Createメソッドはパラメータバリデーション → 関連リソース検証 → オブジェクト構築 → 永続化の順に処理Updateはポインタフィールドを使用した部分更新:送信されたフィールドのみ変更Listメソッドにはデフォルト値保護が組み込まれており、無効なパラメータを防止- 下位レベルのエラーはすべて
AppErrorに統一変換され、上位層は特定のストレージ実装を知る必要がない
カテゴリService実装
// 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:プログラムエントリポイント
// 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)
}
デモの実行
go run cmd/server/main.go
期待される出力:
✓ ユーザー作成: 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レイヤーのテスト
// 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レイヤーのテスト
// 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)
}
}
テストの実行
go test ./internal/... -v -cover
期待される出力:
=== 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 |
プログラムエントリ、初期化、デモ |
重要な設計決定:
- インターフェースデカップリング:RepositoryとServiceは両方ともインターフェースを通じて相互作用し、ユニットテスト(モック)と将来のストレージ置き換えを容易にする
- ポインタ更新:
UpdateArticleRequestはポインタフィールドを使用してゼロ値と未設定値を区別 - 統一エラー:
AppErrorはエラーコードとメッセージをカプセル化し、フロントエンドでの統一処理を可能にする - 並行安全性:
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プロジェクトの骨格を構築しました:
- 要件分析:機能境界と非機能要件を定義
- ディレクトリ構造:
cmd/internal/pkg標準レイアウトを採用 - データモデル:User、Category、Article、リクエスト/レスポンス構造体を定義
- Repositoryレイヤー:インターフェース + インメモリ実装を設計し、並行安全性を確保
- Serviceレイヤー:ビジネスロジック、パラメータバリデーション、エラー処理を実装
- 統一エラーコード:
AppErrorはエラー情報をカプセル化し、フロントエンドでの統一処理を可能にする - ユニットテスト:RepositoryとServiceレイヤーにそれぞれ正常系と異常系をカバーするテストを作成
次のレッスンではHTTP Handlerレイヤーを完成させ、ルーティングとミドルウェアを統合してビジネスロジックをRESTful APIとして公開します。
📝 演習
演習1:独立したタグ管理を追加
タグ用の独立したRepositoryとServiceを実装してください:
- タグを作成
POST /tags - タグリストをクエリ
GET /tags - 各タグに関連付けられた記事数をカウント
演習2:記事検索機能を実装
ArticleServiceにSearchメソッドを追加してください:
- タイトルキーワードでのあいまい検索
- カテゴリIDによるフィルタリング
- タグによるフィルタリング
- ページネーションサポート
ヒント:インメモリ実装では、mapをイテレートしてフィルタリングします。大文字小文字を区別しないマッチングが必要です。
演習3:並行安全性テストを作成
100個のgoroutineが同時にArticleService.Createを呼び出すテストケースを作成し、以下を検証してください:
- すべての記事が正常に作成される
- IDに重複がない
- データ競合がない(
go test -raceで検出)



