第九章:综合实战-构建一个分层结构的Web服务

第九章:综合实战——构建一个分层结构的Web服务 #

在前面的章节中,我们用一个main.go文件构建了一个功能完备的API。这对于小型项目或快速原型设计来说非常棒。但随着项目变得复杂,将所有逻辑都放在一个文件里会迅速导致代码难以维护、测试和扩展。

在真实的项目中,我们会像在PHP框架(如Laravel或Symfony)中那样,将代码按照职责进行组织和分层。本章,我们将重构我们的API,采用经典的分层架构(Layered Architecture),并引入一个流行的第三方路由库chi,来构建一个更健壮、更专业的“待办事项(Todo)”服务。

9.1 项目目标与分层理念 #

目标: 创建一个RESTful API来管理待办事项,支持:

  • POST /todos - 创建一个新的待办事项
  • GET /todos - 获取所有待办事项列表
  • GET /todos/{id} - 获取单个待办事项

分层架构: 我们将把应用分为以下几层:

  1. Handler (或 Controller) 层: 负责处理HTTP请求和响应。它解析传入的JSON,调用业务逻辑层,然后将结果格式化为JSON返回给客户端。它不应包含任何业务逻辑。
  2. Service (或 Business) 层: 包含核心的业务逻辑。例如,创建一个Todo之前需要做什么检查。它不关心HTTP或数据库,只关心业务规则。
  3. Repository (或 Data Access) 层: 负责与数据存储进行交互。它的任务是提供简单的CRUD(创建、读取、更新、删除)操作。这一层将HTTP和业务逻辑与具体的数据库实现(是内存、MySQL还是PostgreSQL)解耦。
  4. 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

  1. 运行服务:

    go run ./cmd/api/main.go
    

    你应该会看到 Server starting on port :8080 的日志。

  2. 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服务。这个项目结构为你将来开发更复杂的应用打下了坚实的基础。你可以尝试在此基础上进行扩展,比如添加UpdateDelete功能,或者将InMemoryTodoRepository替换为真正的数据库实现。