项目架构:构建可维护的 Go 应用

学习 Go 项目的架构设计,掌握分层架构、依赖注入和项目组织方式

项目架构:构建可维护的 Go 应用

当你的 Go 项目从几十行代码的小工具,成长为成千上万行代码的大型应用时,一个清晰的项目架构就变得至关重要了。

好的架构能让代码:

  • 易于理解:新人能快速找到相关代码
  • 易于测试:各层职责清晰,便于单元测试
  • 易于维护:修改一处不会影响其他部分
  • 易于扩展:新功能可以无缝集成

今天我们就来学习如何设计和组织一个可维护的 Go 项目。

标准项目布局

Go 社区推荐的项目布局(参考 golang-standards/project-layout):

myproject/
├── cmd/                   # 应用程序入口
│   ├── server/
│   │   └── main.go       # Web 服务器入口
│   ├── worker/
│   │   └── main.go       # 后台任务入口
│   └── cli/
│       └── main.go       # CLI 工具入口
├── internal/              # 私有代码(不会被外部项目导入)
│   ├── handler/          # HTTP 处理器
│   ├── service/          # 业务逻辑
│   ├── repository/       # 数据访问
│   ├── model/            # 数据模型
│   └── middleware/       # 中间件
├── pkg/                   # 公共库(可以被外部项目导入)
│   ├── logger/
│   ├── config/
│   └── utils/
├── api/                   # API 定义(OpenAPI/Swagger、gRPC proto)
├── configs/               # 配置文件
├── scripts/               # 脚本
├── deployments/           # 部署配置(Docker、K8s)
├── test/                  # 测试数据和工具
├── docs/                  # 文档
├── go.mod
├── go.sum
├── Dockerfile
├── Makefile
└── README.md

关键目录说明

cmd/:应用程序的入口点。每个子目录对应一个可执行文件。

// cmd/server/main.go
package main

import (
	"log"
	
	"myproject/internal/server"
	"myproject/pkg/config"
)

func main() {
	cfg, err := config.Load()
	if err != nil {
		log.Fatal(err)
	}
	
	server.Run(cfg)
}

internal/:Go 的特殊目录,其中的代码只能被同模块的代码导入。这强制了模块的边界。

pkg/:可以被外部项目导入的公共库。

分层架构

经典的分层架构将应用分为几层:

┌─────────────────────────────────────┐
│         Presentation Layer          │  HTTP/gRPC handlers
├─────────────────────────────────────┤
│          Business Layer             │  业务逻辑(services)
├─────────────────────────────────────┤
│          Data Access Layer          │  数据访问(repositories)
├─────────────────────────────────────┤
│             Database                │  MySQL/PostgreSQL/Redis
└─────────────────────────────────────┘

实战:用户管理服务

让我们实现一个完整的用户管理服务。

Model 层

// internal/model/user.go
package model

import "time"

type User struct {
	ID        int64     `json:"id"`
	Name      string    `json:"name"`
	Email     string    `json:"email"`
	Password  string    `json:"-"`  // 不序列化到 JSON
	Age       int       `json:"age"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

type CreateUserRequest struct {
	Name     string `json:"name" binding:"required"`
	Email    string `json:"email" binding:"required,email"`
	Password string `json:"password" binding:"required,min=6"`
	Age      int    `json:"age" binding:"gte=0,lte=150"`
}

type UpdateUserRequest struct {
	Name  string `json:"name,omitempty"`
	Email string `json:"email,omitempty"`
	Age   int    `json:"age,omitempty"`
}

Repository 层

// internal/repository/user_repository.go
package repository

import (
	"context"
	"database/sql"
	
	"myproject/internal/model"
)

// UserRepository 用户数据访问接口
type UserRepository interface {
	Create(ctx context.Context, user *model.User) error
	GetByID(ctx context.Context, id int64) (*model.User, error)
	GetByEmail(ctx context.Context, email string) (*model.User, error)
	Update(ctx context.Context, user *model.User) error
	Delete(ctx context.Context, id int64) error
	List(ctx context.Context, offset, limit int) ([]*model.User, error)
}

// mysqlUserRepository MySQL 实现
type mysqlUserRepository struct {
	db *sql.DB
}

func NewMySQLUserRepository(db *sql.DB) UserRepository {
	return &mysqlUserRepository{db: db}
}

func (r *mysqlUserRepository) Create(ctx context.Context, user *model.User) error {
	query := `
		INSERT INTO users (name, email, password, age, created_at, updated_at)
		VALUES (?, ?, ?, ?, NOW(), NOW())
	`
	result, err := r.db.ExecContext(ctx, query,
		user.Name, user.Email, user.Password, user.Age)
	if err != nil {
		return err
	}
	
	id, err := result.LastInsertId()
	if err != nil {
		return err
	}
	user.ID = id
	
	return nil
}

func (r *mysqlUserRepository) GetByID(ctx context.Context, id int64) (*model.User, error) {
	query := `
		SELECT id, name, email, password, age, created_at, updated_at
		FROM users
		WHERE id = ?
	`
	
	user := &model.User{}
	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID, &user.Name, &user.Email, &user.Password,
		&user.Age, &user.CreatedAt, &user.UpdatedAt,
	)
	if err == sql.ErrNoRows {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	
	return user, nil
}

// ... 其他方法实现

Service 层

// internal/service/user_service.go
package service

import (
	"context"
	"errors"
	"fmt"
	
	"golang.org/x/crypto/bcrypt"
	
	"myproject/internal/model"
	"myproject/internal/repository"
)

// 错误定义
var (
	ErrUserNotFound     = errors.New("user not found")
	ErrEmailExists      = errors.New("email already exists")
	ErrInvalidInput     = errors.New("invalid input")
)

// UserService 用户业务逻辑接口
type UserService interface {
	CreateUser(ctx context.Context, req *model.CreateUserRequest) (*model.User, error)
	GetUser(ctx context.Context, id int64) (*model.User, error)
	UpdateUser(ctx context.Context, id int64, req *model.UpdateUserRequest) (*model.User, error)
	DeleteUser(ctx context.Context, id int64) error
	ListUsers(ctx context.Context, page, pageSize int) ([]*model.User, error)
}

// userService 实现
type userService struct {
	repo repository.UserRepository
}

func NewUserService(repo repository.UserRepository) UserService {
	return &userService{repo: repo}
}

func (s *userService) CreateUser(ctx context.Context, req *model.CreateUserRequest) (*model.User, error) {
	// 检查邮箱是否已存在
	existing, err := s.repo.GetByEmail(ctx, req.Email)
	if err != nil {
		return nil, fmt.Errorf("查询邮箱失败: %w", err)
	}
	if existing != nil {
		return nil, ErrEmailExists
	}
	
	// 哈希密码
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
	if err != nil {
		return nil, fmt.Errorf("密码哈希失败: %w", err)
	}
	
	user := &model.User{
		Name:     req.Name,
		Email:    req.Email,
		Password: string(hashedPassword),
		Age:      req.Age,
	}
	
	if err := s.repo.Create(ctx, user); err != nil {
		return nil, fmt.Errorf("创建用户失败: %w", err)
	}
	
	return user, nil
}

func (s *userService) GetUser(ctx context.Context, id int64) (*model.User, error) {
	user, err := s.repo.GetByID(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("查询用户失败: %w", err)
	}
	if user == nil {
		return nil, ErrUserNotFound
	}
	return user, nil
}

func (s *userService) UpdateUser(ctx context.Context, id int64, req *model.UpdateUserRequest) (*model.User, error) {
	user, err := s.GetUser(ctx, id)
	if err != nil {
		return nil, err
	}
	
	// 更新字段
	if req.Name != "" {
		user.Name = req.Name
	}
	if req.Email != "" {
		// 检查新邮箱是否已存在
		existing, err := s.repo.GetByEmail(ctx, req.Email)
		if err != nil {
			return nil, err
		}
		if existing != nil && existing.ID != id {
			return nil, ErrEmailExists
		}
		user.Email = req.Email
	}
	if req.Age > 0 {
		user.Age = req.Age
	}
	
	if err := s.repo.Update(ctx, user); err != nil {
		return nil, fmt.Errorf("更新用户失败: %w", err)
	}
	
	return user, nil
}

func (s *userService) DeleteUser(ctx context.Context, id int64) error {
	user, err := s.GetUser(ctx, id)
	if err != nil {
		return err
	}
	if user == nil {
		return ErrUserNotFound
	}
	
	return s.repo.Delete(ctx, id)
}

func (s *userService) ListUsers(ctx context.Context, page, pageSize int) ([]*model.User, error) {
	if page < 1 {
		page = 1
	}
	if pageSize < 1 || pageSize > 100 {
		pageSize = 20
	}
	
	offset := (page - 1) * pageSize
	return s.repo.List(ctx, offset, pageSize)
}

Handler 层

// internal/handler/user_handler.go
package handler

import (
	"encoding/json"
	"net/http"
	"strconv"
	
	"github.com/gorilla/mux"
	
	"myproject/internal/model"
	"myproject/internal/service"
)

type UserHandler struct {
	service service.UserService
}

func NewUserHandler(service service.UserService) *UserHandler {
	return &UserHandler{service: service}
}

func (h *UserHandler) RegisterRoutes(r *mux.Router) {
	r.HandleFunc("/users", h.Create).Methods("POST")
	r.HandleFunc("/users/{id}", h.Get).Methods("GET")
	r.HandleFunc("/users/{id}", h.Update).Methods("PUT")
	r.HandleFunc("/users/{id}", h.Delete).Methods("DELETE")
	r.HandleFunc("/users", h.List).Methods("GET")
}

func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
	var req model.CreateUserRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		respondError(w, http.StatusBadRequest, "无效的请求体")
		return
	}
	
	user, err := h.service.CreateUser(r.Context(), &req)
	if err != nil {
		switch err {
		case service.ErrEmailExists:
			respondError(w, http.StatusConflict, err.Error())
		default:
			respondError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	
	respondJSON(w, http.StatusCreated, user)
}

func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
	if err != nil {
		respondError(w, http.StatusBadRequest, "无效的用户 ID")
		return
	}
	
	user, err := h.service.GetUser(r.Context(), id)
	if err != nil {
		if err == service.ErrUserNotFound {
			respondError(w, http.StatusNotFound, err.Error())
		} else {
			respondError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	
	respondJSON(w, http.StatusOK, user)
}

// ... Update, Delete, List 方法

func respondJSON(w http.ResponseWriter, status int, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

func respondError(w http.ResponseWriter, status int, message string) {
	respondJSON(w, status, map[string]string{"error": message})
}

应用入口

// cmd/server/main.go
package main

import (
	"database/sql"
	"log"
	"net/http"
	
	"github.com/gorilla/mux"
	_ "github.com/go-sql-driver/mysql"
	
	"myproject/internal/handler"
	"myproject/internal/repository"
	"myproject/internal/service"
	"myproject/pkg/config"
)

func main() {
	// 加载配置
	cfg, err := config.Load()
	if err != nil {
		log.Fatal(err)
	}
	
	// 连接数据库
	db, err := sql.Open("mysql", cfg.Database.DSN())
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
	
	// 依赖注入
	userRepo := repository.NewMySQLUserRepository(db)
	userService := service.NewUserService(userRepo)
	userHandler := handler.NewUserHandler(userService)
	
	// 设置路由
	r := mux.NewRouter()
	userHandler.RegisterRoutes(r)
	
	// 启动服务器
	addr := ":" + cfg.Server.Port
	log.Printf("服务器启动在 %s", addr)
	log.Fatal(http.ListenAndServe(addr, r))
}

依赖注入

依赖注入(DI)是一种设计模式,它将依赖的创建和使用分离。这让代码更容易测试和维护。

手动依赖注入

上面的例子就是手动依赖注入。在 main.go 中,我们创建所有的依赖并组装它们。

使用 Wire

Google 的 Wire 是一个编译时依赖注入工具:

// cmd/server/wire.go
//go:build wireinject
// +build wireinject

package main

import (
	"github.com/google/wire"
	
	"myproject/internal/handler"
	"myproject/internal/repository"
	"myproject/internal/service"
)

func InitializeUserHandler(db *sql.DB) *handler.UserHandler {
	wire.Build(
		repository.NewMySQLUserRepository,
		service.NewUserService,
		handler.NewUserHandler,
	)
	return nil
}

生成代码:

wire

测试策略

Repository 层测试

使用内存数据库(SQLite)或 mock:

func TestMySQLUserRepository_Create(t *testing.T) {
	db := setupTestDB(t)
	defer db.Close()
	
	repo := repository.NewMySQLUserRepository(db)
	
	user := &model.User{
		Name:  "张三",
		Email: "zhangsan@example.com",
		Age:   25,
	}
	
	err := repo.Create(context.Background(), user)
	assert.NoError(t, err)
	assert.NotZero(t, user.ID)
	
	// 验证数据
	retrieved, err := repo.GetByID(context.Background(), user.ID)
	assert.NoError(t, err)
	assert.Equal(t, user.Name, retrieved.Name)
}

Service 层测试

使用 mock repository:

type MockUserRepository struct {
	users map[int64]*model.User
}

func (m *MockUserRepository) Create(ctx context.Context, user *model.User) error {
	user.ID = int64(len(m.users) + 1)
	m.users[user.ID] = user
	return nil
}

func TestUserService_CreateUser(t *testing.T) {
	mockRepo := &MockUserRepository{users: make(map[int64]*model.User)}
	service := service.NewUserService(mockRepo)
	
	req := &model.CreateUserRequest{
		Name:     "张三",
		Email:    "zhangsan@example.com",
		Password: "password123",
		Age:      25,
	}
	
	user, err := service.CreateUser(context.Background(), req)
	assert.NoError(t, err)
	assert.NotZero(t, user.ID)
	assert.Equal(t, req.Name, user.Name)
}

配置管理

// pkg/config/config.go
package config

import (
	"fmt"
	
	"github.com/spf13/viper"
)

type Config struct {
	Server   ServerConfig
	Database DatabaseConfig
	Log      LogConfig
}

type ServerConfig struct {
	Host string
	Port string
}

type DatabaseConfig struct {
	Host     string
	Port     int
	User     string
	Password string
	Name     string
}

func (c *DatabaseConfig) DSN() string {
	return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true",
		c.User, c.Password, c.Host, c.Port, c.Name)
}

type LogConfig struct {
	Level string
}

func Load() (*Config, error) {
	viper.SetConfigName("config")
	viper.SetConfigType("yaml")
	viper.AddConfigPath(".")
	viper.AddConfigPath("./configs")
	
	viper.AutomaticEnv()
	
	if err := viper.ReadInConfig(); err != nil {
		return nil, err
	}
	
	var cfg Config
	if err := viper.Unmarshal(&cfg); err != nil {
		return nil, err
	}
	
	return &cfg, nil
}

小结

今天我们学习了 Go 项目的架构设计:

  1. 项目布局:标准的目录结构
  2. 分层架构:Handler → Service → Repository
  3. 依赖注入:手动注入和 Wire 工具
  4. 测试策略:各层的测试方法
  5. 配置管理:使用 Viper

好的架构不是一蹴而就的,而是随着项目的发展逐步演进的。从小项目开始,保持代码的清晰和可测试性,当项目变大时,架构自然会变得更好。

系列总结

恭喜你完成了 Go 语言入门系列的全部 40 篇文章!🎉

从基础语法到高级特性,从并发编程到性能优化,从数据库操作到项目架构,你已经掌握了构建真实 Go 应用所需的核心知识。

学习路径回顾:

  • 第 1-10 篇:Go 基础(变量、控制流、函数、数据结构)
  • 第 11-20 篇:进阶特性(并发、I/O、网络、测试)
  • 第 21-30 篇:实战应用(数据库、Web、工具)
  • 第 31-40 篇:工程实践(安全、缓存、部署、架构)

下一步建议:

  1. 实践项目:选择一个真实的项目,从头到尾实现它
  2. 阅读源码:阅读优秀的 Go 开源项目(如 Docker、Kubernetes)
  3. 持续学习:关注 Go 官方博客、参加 Go 社区活动
  4. 分享知识:写博客、做分享,教是最好的学

Go 语言简洁而强大,它的设计哲学是"少即是多"。希望这个系列能帮助你掌握 Go,用它构建出优秀的软件!

继续前进,Go 的未来属于你!🚀


参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页