第九章:综合实战——构建一个分层结构的Web服务 #
在前面的章节中,我们用一个main.go
文件构建了一个功能完备的API。这对于小型项目或快速原型设计来说非常棒。但随着项目变得复杂,将所有逻辑都放在一个文件里会迅速导致代码难以维护、测试和扩展。
在真实的项目中,我们会像在PHP框架(如Laravel或Symfony)中那样,将代码按照职责进行组织和分层。本章,我们将重构我们的API,采用经典的分层架构(Layered Architecture),并引入一个流行的第三方路由库chi
,来构建一个更健壮、更专业的“待办事项(Todo)”服务。
9.1 项目目标与分层理念 #
目标: 创建一个RESTful API来管理待办事项,支持:
POST /todos
- 创建一个新的待办事项GET /todos
- 获取所有待办事项列表GET /todos/{id}
- 获取单个待办事项
分层架构: 我们将把应用分为以下几层:
- Handler (或 Controller) 层: 负责处理HTTP请求和响应。它解析传入的JSON,调用业务逻辑层,然后将结果格式化为JSON返回给客户端。它不应包含任何业务逻辑。
- Service (或 Business) 层: 包含核心的业务逻辑。例如,创建一个Todo之前需要做什么检查。它不关心HTTP或数据库,只关心业务规则。
- Repository (或 Data Access) 层: 负责与数据存储进行交互。它的任务是提供简单的CRUD(创建、读取、更新、删除)操作。这一层将HTTP和业务逻辑与具体的数据库实现(是内存、MySQL还是PostgreSQL)解耦。
- Model 层: 定义我们的数据结构,即
Todo
结构体。
这种分层的好处是关注点分离 (Separation of Concerns)。每一层只做一件事,使得代码更容易理解、独立测试和替换。例如,我们可以轻松地将内存存储库换成一个真正的数据库存储库,而无需修改Service或Handler层的任何代码。
9.2 项目结构 #
让我们先创建项目目录和文件。我们将遵循Go社区推荐的一种标准项目布局。
/todo-api
├── cmd/
│ └── api/
│ └── main.go # 程序入口,负责组装和启动服务
├── internal/
│ ├── handler/ # HTTP处理器层
│ │ └── todo.go
│ ├── model/ # 数据模型
│ │ └── todo.go
│ ├── repository/ # 数据仓库层
│ │ ├── memory.go # 一个基于内存的仓库实现
│ │ └── todo.go # 仓库接口定义
│ └── service/ # 业务逻辑层
│ └── todo.go
└── go.mod
cmd/api/
: 这是我们应用的主程序目录。cmd
目录通常用于存放可执行程序的入口。internal/
: 这是一个特殊的目录。Go工具链会阻止其他项目导入internal
目录下的包。这强制我们把项目内部的、不希望被外部使用的代码放在这里,确保了良好的封装。
9.3 编码实现 (一步步来) #
1. 初始化项目并添加依赖 #
首先,在你的终端里创建项目并初始化Go Module。
mkdir todo-api
cd todo-api
go mod init todo.com/api # 你可以使用任何你喜欢的模块名
我们将使用chi
作为路由库,它是一个轻量级、对标准库net/http
兼容性极好的路由器。
go get github.com/go-chi/chi/v5
2. Model 层 #
这是最简单的一层,我们只定义数据结构。
internal/model/todo.go
:
package model
import "time"
// Todo 代表一个待办事项
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
CreatedAt time.Time `json:"created_at"`
}
3. Repository 层 #
在这里,我们将首先定义一个接口,这非常重要!接口定义了“契约”,即一个待办事项仓库应该能做什么。
internal/repository/todo.go
(创建一个新文件来定义接口):
package repository
import "todo.com/api/internal/model"
// TodoRepository 定义了与待办事项数据存储交互的方法
type TodoRepository interface {
Create(todo model.Todo) (model.Todo, error)
FindByID(id int) (model.Todo, error)
FindAll() ([]model.Todo, error)
}
现在,我们来创建一个具体的实现。为了简单起见,我们使用内存中的map
来模拟数据库。
internal/repository/memory.go
:
package repository
import (
"fmt"
"sync"
"time"
"todo.com/api/internal/model"
)
// InMemoryTodoRepository 是 TodoRepository 接口的内存实现
type InMemoryTodoRepository struct {
mu sync.RWMutex
todos map[int]model.Todo
currentID int
}
// NewInMemoryTodoRepository 创建一个新的内存仓库实例
func NewInMemoryTodoRepository() *InMemoryTodoRepository {
return &InMemoryTodoRepository{
todos: make(map[int]model.Todo),
currentID: 0,
}
}
func (r *InMemoryTodoRepository) Create(todo model.Todo) (model.Todo, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.currentID++
todo.ID = r.currentID
todo.CreatedAt = time.Now()
r.todos[todo.ID] = todo
return todo, nil
}
func (r *InMemoryTodoRepository) FindByID(id int) (model.Todo, error) {
r.mu.RLock()
defer r.mu.RUnlock()
todo, exists := r.todos[id]
if !exists {
return model.Todo{}, fmt.Errorf("todo with id %d not found", id)
}
return todo, nil
}
func (r *InMemoryTodoRepository) FindAll() ([]model.Todo, error) {
r.mu.RLock()
defer r.mu.RUnlock()
allTodos := make([]model.Todo, 0, len(r.todos))
for _, todo := range r.todos {
allTodos = append(allTodos, todo)
}
return allTodos, nil
}
注意:InMemoryTodoRepository
实现了 TodoRepository
接口所有的方法,因此它自动满足了该接口的契约。
4. Service 层 #
Service层负责业务逻辑。它依赖于我们刚才定义的TodoRepository
接口,而不是具体的InMemoryTodoRepository
实现。这叫做依赖注入(Dependency Injection),是实现解耦的关键。
internal/service/todo.go
:
package service
import (
"todo.com/api/internal/model"
"todo.com/api/internal/repository"
)
// TodoService 封装了待办事项相关的业务逻辑
type TodoService struct {
repo repository.TodoRepository // 依赖于接口,而不是具体实现
}
// NewTodoService 创建一个新的TodoService实例
func NewTodoService(repo repository.TodoRepository) *TodoService {
return &TodoService{repo: repo}
}
func (s *TodoService) CreateTodo(title string) (model.Todo, error) {
// 可以在这里添加业务逻辑,比如检查title是否为空,是否包含敏感词等
if title == "" {
return model.Todo{}, fmt.Errorf("title cannot be empty")
}
newTodo := model.Todo{
Title: title,
Completed: false,
}
return s.repo.Create(newTodo)
}
func (s *TodoService) GetTodo(id int) (model.Todo, error) {
return s.repo.FindByID(id)
}
func (s *TodoService) ListTodos() ([]model.Todo, error) {
return s.repo.FindAll()
}
5. Handler 层 #
Handler层负责HTTP交互。它依赖于TodoService
。
internal/handler/todo.go
:
package handler
import (
"encoding/json"
"net/http"
"strconv"
"todo.com/api/internal/service"
"github.com/go-chi/chi/v5"
)
// TodoHandler 负责处理待办事项的HTTP请求
type TodoHandler struct {
service *service.TodoService
}
// NewTodoHandler 创建一个新的TodoHandler实例
func NewTodoHandler(s *service.TodoService) *TodoHandler {
return &TodoHandler{service: s}
}
// CreateTodoHandler 处理创建待办事项的请求
func (h *TodoHandler) CreateTodoHandler(w http.ResponseWriter, r *http.Request) {
var request struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
todo, err := h.service.CreateTodo(request.Title)
if err != nil {
// 更具体地返回业务错误
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(todo)
}
// ListTodosHandler 处理获取所有待办事项的请求
func (h *TodoHandler) ListTodosHandler(w http.ResponseWriter, r *http.Request) {
todos, err := h.service.ListTodos()
if err != nil {
http.Error(w, "Failed to retrieve todos", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(todos)
}
// GetTodoHandler 处理获取单个待办事项的请求
func (h *TodoHandler) GetTodoHandler(w http.ResponseWriter, r *http.Request) {
// chi 帮助我们方便地获取URL参数
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid todo ID", http.StatusBadRequest)
return
}
todo, err := h.service.GetTodo(id)
if err != nil {
// 更好地处理 "not found" 错误
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(todo)
}
6. main.go
- 把所有东西组装起来
#
最后,在程序的入口main.go
中,我们进行依赖注入:创建所有层的实例,并将它们“连接”在一起,然后定义路由并启动服务器。
cmd/api/main.go
:
package main
import (
"log"
"net/http"
"todo.com/api/internal/handler"
"todo.com/api/internal/repository"
"todo.com/api/internal/service"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
// 1. 创建依赖实例 (从底层到上层)
// 如果要换成PostgreSQL,只需要在这里替换这一行!
repo := repository.NewInMemoryTodoRepository()
todoService := service.NewTodoService(repo)
todoHandler := handler.NewTodoHandler(todoService)
// 2. 创建路由器实例
r := chi.NewRouter()
// 使用一些有用的中间件
r.Use(middleware.Logger) // 记录每个请求的日志
r.Use(middleware.Recoverer) // 从panic中恢复,返回500错误
// 3. 设置路由
r.Route("/todos", func(r chi.Router) {
r.Post("/", todoHandler.CreateTodoHandler)
r.Get("/", todoHandler.ListTodosHandler)
r.Get("/{id:[0-9]+}", todoHandler.GetTodoHandler) // 使用正则表达式确保ID是数字
})
// 4. 启动服务器
log.Println("Server starting on port :8080")
if err := http.ListenAndServe(":8080", r); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
9.4 运行与测试 #
确保你的终端位于项目根目录 /todo-api
。
运行服务:
go run ./cmd/api/main.go
你应该会看到
Server starting on port :8080
的日志。用
curl
测试:- 创建:
curl -X POST http://localhost:8080/todos -d '{"title": "Learn Go"}' -H "Content-Type: application/json" # 响应: {"id":1,"title":"Learn Go","completed":false,"created_at":"..."}
- 创建第二个:
curl -X POST http://localhost:8080/todos -d '{"title": "Build a real project"}' -H "Content-Type: application/json" # 响应: {"id":2,"title":"Build a real project","completed":false,"created_at":"..."}
- 列出所有:
curl http://localhost:8080/todos # 响应: [{"id":1,...},{"id":2,...}]
- 获取单个:
curl http://localhost:8080/todos/2 # 响应: {"id":2,"title":"Build a real project","completed":false,"created_at":"..."}
- 获取不存在的:
curl -i http://localhost:8080/todos/99 # 响应头会包含 HTTP/1.1 404 Not Found
- 创建:
恭喜你!你已经构建了一个结构良好、易于扩展的Go Web服务。这个项目结构为你将来开发更复杂的应用打下了坚实的基础。你可以尝试在此基础上进行扩展,比如添加Update
和Delete
功能,或者将InMemoryTodoRepository
替换为真正的数据库实现。