综合项目(上)
综合项目(上)
从零搭建一个完整的 Go 项目——博客 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/ |
可被外部项目复用的工具包 | 公开 |
第一步:初始化项目
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
第二步:定义数据模型
用户模型
// 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:统一分页格式,所有列表接口复用
第三步:统一错误码
// 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 文章相关
第四步:数据访问层(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("article %d not found", 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("article %d not found", 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("article %d not found", 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("category %d not found", 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("user %d not found", id)
}
return user, nil
}
代码解析:
MemoryStore持有所有数据,使用sync.RWMutex保证并发安全- 三个仓库方法分别返回各自的接口实现
atomic.Int64生成自增 ID,无需加锁- 内存实现便于开发测试,后续可替换为数据库实现而不改动上层代码
第五步:业务逻辑层(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)
}
文章服务实现
// 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,上层无需关心具体存储实现
分类服务实现
// 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
}
第六步:程序入口
// 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: "zhangsan",
Email: "zhangsan@example.com",
}
if err := store.UserRepo().Create(user); err != nil {
log.Fatalf("创建用户失败: %v", err)
}
fmt.Printf("✅ 用户创建成功: ID=%d, Username=%s\n", user.ID, user.Username)
// 创建分类
goCategory, _ := categorySvc.Create("Go 语言")
pythonCategory, _ := categorySvc.Create("Python")
fmt.Printf("✅ 分类创建成功: ID=%d, Name=%s\n", goCategory.ID, goCategory.Name)
fmt.Printf("✅ 分类创建成功: ID=%d, Name=%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", "并发", "goroutine"},
})
if err != nil {
log.Fatalf("创建文章失败: %v", err)
}
fmt.Printf("✅ 文章创建成功: ID=%d, Title=%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", "接口", "设计模式"},
})
if err != nil {
log.Fatalf("创建文章失败: %v", err)
}
fmt.Printf("✅ 文章创建成功: ID=%d, Title=%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: "test",
})
fmt.Printf(" 空标题: %v\n", err)
// 不存在的作者
_, err = articleSvc.Create(&model.CreateArticleRequest{
Title: "test",
Content: "test",
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, Username=zhangsan
✅ 分类创建成功: ID=2, Name=Go 语言
✅ 分类创建成功: ID=3, Name=Python
✅ 文章创建成功: ID=4, Title=Go 并发编程入门
✅ 文章创建成功: ID=5, Title=Go 接口设计模式
========== 功能演示 ==========
📄 查询文章: Go 并发编程入门
内容: 本文介绍 goroutine 和 channel 的基本用法...
标签: [go 并发 goroutine]
📋 文章列表 (第1页, 每页10条):
[4] Go 并发编程入门 (标签: [go 并发 goroutine])
[5] Go 接口设计模式 (标签: [go 接口 设计模式])
共 2 篇, 1 页
✏️ 文章已更新: Go 并发编程进阶 (状态: 1)
🗑️ 文章 ID=5 已删除
查询已删除文章: code=20001, message=文章不存在 ✅
📂 分类列表:
[2] Go 语言
[3] Python
❌ 错误处理演示:
空标题: code=20004, message=标题不能为空
不存在的作者: code=20003, message=用户不存在
不存在的文章: code=20001, message=文章不存在
第七步:单元测试
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: "内容",
})
}
// 第一页
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))
}
// 第二页
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{"test"},
})
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 := "test"
_, 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,
})
}
// 第一页
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
代码总结
本课完成了博客 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 都通过接口交互,方便单元测试(mock)和后续替换存储
- 指针更新:
UpdateArticleRequest使用指针字段区分零值和未传值 - 统一错误:
AppError封装错误码和消息,前端可统一解析 - 并发安全:
sync.RWMutex保护内存数据,读操作用读锁,写操作用写锁
❓ 常见问题
Q1:为什么 Service 层不直接依赖具体的 Repository 实现?
Service 依赖接口而非具体实现,这样在单元测试中可以注入 mock 实现,无需真实存储。同时如果将来要把内存存储换成 MySQL,只需新建一个 mysqlArticleRepo 实现 ArticleRepository 接口,Service 层代码完全不用改。
Q2:UpdateArticleRequest 为什么用指针字段?
Go 中 string 的零值是空字符串 "",如果用普通字段,无法区分"用户没传 title"和"用户传了空 title"。用 *string 则 nil 表示未传,非 nil 表示有值。这是 Go API 设计中的常见模式。
Q3:内存存储的 sync.RWMutex 会不会成为性能瓶颈?
对于本教程的演示场景足够。如果读多写少(文章列表查询远多于创建更新),RWMutex 的读锁是共享的,多个读操作可以并发执行,只有写操作才互斥。生产环境建议用数据库,连接池本身就是并发安全的。
Q4:测试覆盖率怎么提升?
当前已覆盖 80%+。如需更高覆盖,可以补充:边界值测试(超长标题、特殊字符)、并发安全测试(多个 goroutine 同时创建文章)、分类服务的完整测试用例。
📖 小节
本课我们从零构建了一个完整的 Go 项目骨架:
- 需求分析:明确了功能边界和非功能需求
- 目录结构:采用
cmd/internal/pkg标准布局 - 数据模型:定义了 User、Category、Article 及请求/响应结构体
- Repository 层:设计接口 + 内存实现,保证并发安全
- Service 层:实现业务逻辑、参数校验、错误处理
- 统一错误码:
AppError封装错误信息,便于前端统一处理 - 单元测试:Repository 和 Service 层分别编写测试,覆盖正常和异常场景
下一课将完成 HTTP Handler 层,接入路由、中间件,将业务逻辑暴露为 RESTful API。
📝 作业
作业 1:添加标签独立管理
为标签(Tag)实现独立的 Repository 和 Service,支持:
- 创建标签
POST /tags - 查询标签列表
GET /tags - 统计每个标签关联的文章数量
作业 2:实现文章搜索功能
在 ArticleService 中添加 Search 方法:
- 按标题关键字模糊搜索
- 按分类 ID 过滤
- 按标签过滤
- 支持分页
提示:内存实现中遍历 map 过滤即可,需注意大小写不敏感匹配。
作业 3:编写并发安全测试
编写一个测试用例,使用 100 个 goroutine 同时调用 ArticleService.Create,验证:
- 所有文章都创建成功
- ID 没有重复
- 没有 data race(使用
go test -race检测)



