返回首页

Golang项目实践:4.1 项目架构与工程规范

在 Go 项目进入多人协作和持续迭代阶段后,代码能跑 只是最低要求,真正决定研发效率的是:目录是否清晰、职责是否分层、依赖是否可控、配置是否规范、日志是否可观测。

这篇文章围绕「4.1 项目架构与工程规范」展开,系统讲解以下内容:

  • Go 项目标准布局(Standard Go Project Layout)
  • 分层架构设计:Handler → Service → Repository
  • 依赖注入与 Wire 框架
  • 配置管理:Viper 实战
  • 日志系统:Zap / Slog 结构化日志

本文不仅讲概念,也给出一套完整可运行的示例代码。你可以直接照着搭建一个小型 HTTP 服务,并在此基础上扩展真实业务。


一、为什么工程规范比“能跑”更重要

很多初学者写 Go 项目时,喜欢把所有代码都塞进 main.go

  • 路由写在一起
  • 数据库访问写在一起
  • 业务逻辑写在一起
  • 配置读取写在一起
  • 日志打印也写在一起

短期看似简单,长期一定失控。常见问题包括:

  1. 职责混乱:改一个接口,牵一发而动全身。
  2. 难以测试:业务依赖数据库、配置、日志,单元测试无从下手。
  3. 难以协作:不同同学很难快速理解目录结构和调用关系。
  4. 难以维护:需求一多,重复逻辑和“临时补丁”会越来越多。
  5. 难以扩展:想换配置中心、日志库、数据库实现时,改动面很大。

因此,工程规范的目标不是“看起来专业”,而是为了实现:

  • 更清晰的项目结构
  • 更稳定的依赖关系
  • 更可控的初始化流程
  • 更低的维护成本
  • 更强的可测试性与可观测性

二、Go 项目标准布局(Standard Go Project Layout)

Go 社区没有官方强制统一的目录规范,但在实际工程中,大家通常会遵循一套相对稳定的组织方式。常见参考就是 Standard Go Project Layout 的思路。

2.1 常见目录职责

下面是一个典型的 Web 服务目录结构:

myapp/
├── cmd/
│   └── server/
│       └── main.go
├── configs/
│   └── config.yaml
├── internal/
│   ├── handler/
│   │   └── user_handler.go
│   ├── service/
│   │   └── user_service.go
│   ├── repository/
│   │   └── user_repository.go
│   ├── model/
│   │   └── user.go
│   ├── config/
│   │   └── config.go
│   ├── logger/
│   │   └── logger.go
│   └── wire/
│       ├── provider.go
│       └── wire.go
├── pkg/
│   └── response/
│       └── response.go
├── go.mod
├── go.sum
└── Makefile

2.2 每个目录是做什么的

目录 作用 是否建议对外暴露
cmd/ 程序入口,不同二进制可放不同子目录
internal/ 核心业务代码,Go 语言层面限制外部引用
pkg/ 可复用公共库,适合被其他项目引用 视情况而定
configs/ 配置文件模板、默认配置
scripts/ 启动、部署、生成脚本
docs/ 设计文档、接口文档、说明文档 可读即可

2.3 为什么推荐 cmd + internal + pkg

这套布局的核心优点是:

  • 入口清晰cmd/server/main.go 明确告诉你程序从哪里启动。
  • 业务收敛internal/ 下聚合所有内部实现,避免无意间被外部依赖。
  • 复用边界清楚:只有真正通用的内容才放进 pkg/

一个很实用的原则是:

如果某段代码你还不确定是否应该复用,先放 internal/,不要急着放 pkg/

因为一旦放进 pkg/,就意味着它的设计可能会被外部依赖,未来修改成本更高。


三、分层架构设计:Handler → Service → Repository

在 Go Web 服务中,最常见也最实用的分层方式就是:

HTTP Request
   ↓
Handler(处理请求参数、响应格式、状态码)
   ↓
Service(承载业务逻辑)
   ↓
Repository(负责数据访问)
   ↓
DB / Cache / External API

这种分层方式的价值在于:每一层只做自己该做的事

3.1 Handler 层职责

Handler 直接面向 HTTP 请求,主要负责:

  • 路由注册
  • 参数解析
  • 参数校验
  • 调用 Service
  • 返回 JSON 响应
  • 处理 HTTP 状态码

Handler 不应该

  • 直接写复杂业务逻辑
  • 直接访问数据库
  • 承担过多初始化逻辑

3.2 Service 层职责

Service 是业务核心层,主要负责:

  • 编排业务流程
  • 执行业务规则校验
  • 聚合多个 Repository 或外部依赖
  • 对外输出业务结果

Service 不应该

  • 感知 HTTP 细节,如 http.ResponseWriter
  • 拼装 SQL
  • 依赖具体框架细节过深

3.3 Repository 层职责

Repository 专注数据访问,主要负责:

  • 查询数据库
  • 持久化数据
  • 隔离底层存储细节
  • 为 Service 提供抽象的数据读写能力

Repository 不应该

  • 承担业务规则判断
  • 直接拼 HTTP 响应
  • 污染上层业务模型

3.4 一个简单但规范的调用链

下面先看业务场景:

  • 提供一个 GET /users/{id} 接口
  • 根据用户 ID 查询用户信息
  • 找不到则返回 404
  • 成功则返回用户数据

目录结构如下:

internal/
├── handler/
│   └── user_handler.go
├── model/
│   └── user.go
├── repository/
│   └── user_repository.go
└── service/
    └── user_service.go

先定义模型。

3.5 模型定义

文件:internal/model/user.go

package model

type User struct {
	ID    int64  `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

3.6 Repository 实现

这里为了保证示例能直接运行,我们先用内存仓储模拟数据库。

文件:internal/repository/user_repository.go

package repository

import (
	"context"
	"errors"

	"myapp/internal/model"
)

var ErrUserNotFound = errors.New("user not found")

type UserRepository interface {
	GetByID(ctx context.Context, id int64) (*model.User, error)
}

type InMemoryUserRepository struct {
	data map[int64]*model.User
}

func NewInMemoryUserRepository() UserRepository {
	return &InMemoryUserRepository{
		data: map[int64]*model.User{
			1: {ID: 1, Name: "Alice", Email: "alice@example.com"},
			2: {ID: 2, Name: "Bob", Email: "bob@example.com"},
		},
	}
}

func (r *InMemoryUserRepository) GetByID(ctx context.Context, id int64) (*model.User, error) {
	user, ok := r.data[id]
	if !ok {
		return nil, ErrUserNotFound
	}
	return user, nil
}

3.7 Service 实现

文件:internal/service/user_service.go

package service

import (
	"context"
	"errors"
	"fmt"

	"myapp/internal/model"
	"myapp/internal/repository"
)

type UserService interface {
	GetUser(ctx context.Context, id int64) (*model.User, error)
}

type userService struct {
	repo repository.UserRepository
}

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

func (s *userService) GetUser(ctx context.Context, id int64) (*model.User, error) {
	if id <= 0 {
		return nil, fmt.Errorf("invalid user id: %d", id)
	}

	user, err := s.repo.GetByID(ctx, id)
	if err != nil {
		if errors.Is(err, repository.ErrUserNotFound) {
			return nil, err
		}
		return nil, fmt.Errorf("get user from repository failed: %w", err)
	}

	return user, nil
}

3.8 Handler 实现

文件:internal/handler/user_handler.go

package handler

import (
	"encoding/json"
	"errors"
	"net/http"
	"strconv"
	"strings"

	"myapp/internal/repository"
	"myapp/internal/service"
)

type UserHandler struct {
	userService service.UserService
}

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

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
	idStr := strings.TrimPrefix(r.URL.Path, "/users/")
	id, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]any{
			"message": "invalid user id",
		})
		return
	}

	user, err := h.userService.GetUser(r.Context(), id)
	if err != nil {
		if errors.Is(err, repository.ErrUserNotFound) {
			writeJSON(w, http.StatusNotFound, map[string]any{
				"message": "user not found",
			})
			return
		}

		writeJSON(w, http.StatusInternalServerError, map[string]any{
			"message": err.Error(),
		})
		return
	}

	writeJSON(w, http.StatusOK, map[string]any{
		"message": "ok",
		"data":    user,
	})
}

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

3.9 路由注册与入口

先不引入 Wire、Viper、Zap,单纯看分层是如何串起来的。

文件:cmd/server/main.go

package main

import (
	"log"
	"net/http"

	"myapp/internal/handler"
	"myapp/internal/repository"
	"myapp/internal/service"
)

func main() {
	repo := repository.NewInMemoryUserRepository()
	userService := service.NewUserService(repo)
	userHandler := handler.NewUserHandler(userService)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /users/", userHandler.GetUser)

	log.Println("server started at :8080")
	if err := http.ListenAndServe(":8080", mux); err != nil {
		log.Fatal(err)
	}
}

这个版本虽然已经具备分层,但初始化代码依然写死在 main.go 中。一旦依赖变多,比如:

  • 配置对象
  • 数据库连接
  • 缓存客户端
  • 日志实例
  • 多个 Service
  • 多个 Handler

那么 main.go 很快就会膨胀。于是就需要引入依赖注入


四、依赖注入与 Wire 框架

4.1 什么是依赖注入

依赖注入(Dependency Injection,DI)的核心思想非常简单:

一个对象需要什么依赖,不要自己在内部创建,而是从外部传进来。

比如 UserService 依赖 UserRepository,正确方式是:

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

而不是在 UserService 内部自己 new 一个 Repository。

这样做有三个直接收益:

  1. 解耦:Service 不依赖具体实现细节。
  2. 可测试:测试时可注入 Mock Repository。
  3. 可替换:未来从内存仓储切到 MySQL/PostgreSQL 时,上层逻辑不用改。

4.2 为什么选择 Google Wire

Wire 是 Google 提供的编译期依赖注入工具。它和运行时反射型 DI 框架相比,有几个明显优势:

  • 无运行时反射开销
  • 依赖关系在编译期生成,类型安全
  • 生成代码清晰,可读性强
  • 更符合 Go 的显式依赖风格

4.3 引入 Wire 后的项目结构

internal/
├── wire/
│   ├── provider.go
│   ├── wire.go
│   └── wire_gen.go

其中:

  • provider.go:声明 ProviderSet
  • wire.go:声明注入入口
  • wire_gen.go:由 wire 命令自动生成

4.4 配置、日志、仓储、服务、处理器统一注入

下面给出一套完整示例,包含:

  • Viper 加载配置
  • Zap 初始化日志
  • Repository / Service / Handler 组装
  • Wire 统一生成应用依赖

先看 go.mod

文件:go.mod

module myapp

go 1.22

require (
	github.com/google/wire v0.6.0
	github.com/spf13/viper v1.19.0
	go.uber.org/zap v1.27.0
)

4.5 配置结构体

文件:internal/config/config.go

package config

type Config struct {
	App AppConfig `mapstructure:"app"`
}

type AppConfig struct {
	Name    string `mapstructure:"name"`
	Port    string `mapstructure:"port"`
	LogMode string `mapstructure:"log_mode"`
}

4.6 使用 Viper 读取配置

文件:internal/config/load.go

package config

import (
	"fmt"

	"github.com/spf13/viper"
)

func Load(path string) (*Config, error) {
	v := viper.New()
	v.SetConfigFile(path)
	v.SetConfigType("yaml")

	v.SetDefault("app.name", "myapp")
	v.SetDefault("app.port", ":8080")
	v.SetDefault("app.log_mode", "zap")

	if err := v.ReadInConfig(); err != nil {
		return nil, fmt.Errorf("read config failed: %w", err)
	}

	var cfg Config
	if err := v.Unmarshal(&cfg); err != nil {
		return nil, fmt.Errorf("unmarshal config failed: %w", err)
	}

	return &cfg, nil
}

4.7 配置文件示例

文件:configs/config.yaml

app:
  name: myapp
  port: ":8080"
  log_mode: zap

4.8 Zap 日志封装

文件:internal/logger/logger.go

package logger

import (
	"fmt"
	"log/slog"
	"os"

	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"

	"myapp/internal/config"
)

type Logger struct {
	Zap  *zap.Logger
	Slog *slog.Logger
}

func NewLogger(cfg *config.Config) (*Logger, error) {
	zapLogger, err := newZapLogger()
	if err != nil {
		return nil, err
	}

	slogLogger := newSlogLogger()

	return &Logger{
		Zap:  zapLogger,
		Slog: slogLogger,
	}, nil
}

func newZapLogger() (*zap.Logger, error) {
	encoderConfig := zap.NewProductionEncoderConfig()
	encoderConfig.TimeKey = "time"
	encoderConfig.LevelKey = "level"
	encoderConfig.MessageKey = "msg"
	encoderConfig.CallerKey = "caller"
	encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
	encoderConfig.EncodeLevel = zapcore.LowercaseLevelEncoder
	encoderConfig.EncodeCaller = zapcore.ShortCallerEncoder

	core := zapcore.NewCore(
		zapcore.NewJSONEncoder(encoderConfig),
		zapcore.AddSync(os.Stdout),
		zap.InfoLevel,
	)

	return zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1)), nil
}

func newSlogLogger() *slog.Logger {
	handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelInfo,
	})
	return slog.New(handler)
}

func (l *Logger) Sync() {
	if l.Zap != nil {
		_ = l.Zap.Sync()
	}
}

func (l *Logger) Info(msg string, fields ...any) {
	if l.Zap != nil {
		zapFields := make([]zap.Field, 0, len(fields)/2)
		for i := 0; i+1 < len(fields); i += 2 {
			key, ok := fields[i].(string)
			if !ok {
				continue
			}
			zapFields = append(zapFields, zap.Any(key, fields[i+1]))
		}
		l.Zap.Info(msg, zapFields...)
		return
	}

	if l.Slog != nil {
		l.Slog.Info(msg, fields...)
		return
	}

	fmt.Println(msg)
}
}

4.9 Repository、Service、Handler 接入日志

文件:internal/repository/user_repository.go

package repository

import (
	"context"
	"errors"

	"myapp/internal/logger"
	"myapp/internal/model"
)

var ErrUserNotFound = errors.New("user not found")

type UserRepository interface {
	GetByID(ctx context.Context, id int64) (*model.User, error)
}

type InMemoryUserRepository struct {
	log  *logger.Logger
	data map[int64]*model.User
}

func NewInMemoryUserRepository(log *logger.Logger) UserRepository {
	return &InMemoryUserRepository{
		log: log,
		data: map[int64]*model.User{
			1: {ID: 1, Name: "Alice", Email: "alice@example.com"},
			2: {ID: 2, Name: "Bob", Email: "bob@example.com"},
		},
	}
}

func (r *InMemoryUserRepository) GetByID(ctx context.Context, id int64) (*model.User, error) {
	r.log.Info("repository get user by id", "user_id", id)

	user, ok := r.data[id]
	if !ok {
		return nil, ErrUserNotFound
	}
	return user, nil
}

文件:internal/service/user_service.go

package service

import (
	"context"
	"errors"
	"fmt"

	"myapp/internal/logger"
	"myapp/internal/model"
	"myapp/internal/repository"
)

type UserService interface {
	GetUser(ctx context.Context, id int64) (*model.User, error)
}

type userService struct {
	log  *logger.Logger
	repo repository.UserRepository
}

func NewUserService(log *logger.Logger, repo repository.UserRepository) UserService {
	return &userService{log: log, repo: repo}
}

func (s *userService) GetUser(ctx context.Context, id int64) (*model.User, error) {
	s.log.Info("service get user", "user_id", id)

	if id <= 0 {
		return nil, fmt.Errorf("invalid user id: %d", id)
	}

	user, err := s.repo.GetByID(ctx, id)
	if err != nil {
		if errors.Is(err, repository.ErrUserNotFound) {
			return nil, err
		}
		return nil, fmt.Errorf("get user failed: %w", err)
	}

	return user, nil
}

文件:internal/handler/user_handler.go

package handler

import (
	"encoding/json"
	"errors"
	"net/http"
	"strconv"
	"strings"

	"myapp/internal/logger"
	"myapp/internal/repository"
	"myapp/internal/service"
)

type UserHandler struct {
	log         *logger.Logger
	userService service.UserService
}

func NewUserHandler(log *logger.Logger, userService service.UserService) *UserHandler {
	return &UserHandler{
		log:         log,
		userService: userService,
	}
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
	idStr := strings.TrimPrefix(r.URL.Path, "/users/")
	id, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		h.log.Info("invalid user id", "raw_id", idStr)
		writeJSON(w, http.StatusBadRequest, map[string]any{
			"message": "invalid user id",
		})
		return
	}

	user, err := h.userService.GetUser(r.Context(), id)
	if err != nil {
		if errors.Is(err, repository.ErrUserNotFound) {
			writeJSON(w, http.StatusNotFound, map[string]any{
				"message": "user not found",
			})
			return
		}

		writeJSON(w, http.StatusInternalServerError, map[string]any{
			"message": err.Error(),
		})
		return
	}

	writeJSON(w, http.StatusOK, map[string]any{
		"message": "ok",
		"data":    user,
	})
}

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

4.10 Wire ProviderSet 定义

文件:internal/wire/provider.go

package wire

import (
	"github.com/google/wire"

	"myapp/internal/handler"
	"myapp/internal/logger"
	"myapp/internal/repository"
	"myapp/internal/service"
)

var ProviderSet = wire.NewSet(
	logger.NewLogger,
	repository.NewInMemoryUserRepository,
	service.NewUserService,
	handler.NewUserHandler,
)

4.11 Wire 注入入口

文件:internal/wire/wire.go

//go:build wireinject

package wire

import (
	"github.com/google/wire"

	"myapp/internal/config"
	"myapp/internal/handler"
)

func InitializeUserHandler(cfg *config.Config) (*handler.UserHandler, error) {
	wire.Build(ProviderSet)
	return &handler.UserHandler{}, nil
}

生成代码命令:

go install github.com/google/wire/cmd/wire@latest
wire ./internal/wire

生成后会得到 internal/wire/wire_gen.go。示例内容如下:

// Code generated by Wire. DO NOT EDIT.

//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject

package wire

import (
	"myapp/internal/config"
	"myapp/internal/handler"
	"myapp/internal/logger"
	"myapp/internal/repository"
	"myapp/internal/service"
)

func InitializeUserHandler(cfg *config.Config) (*handler.UserHandler, error) {
	loggerLogger, err := logger.NewLogger(cfg)
	if err != nil {
		return nil, err
	}
	userRepository := repository.NewInMemoryUserRepository(loggerLogger)
	userService := service.NewUserService(loggerLogger, userRepository)
	userHandler := handler.NewUserHandler(loggerLogger, userService)
	return userHandler, nil
}

Wire 的价值并不是“省几行代码”,而是让依赖关系变得:

  • 可声明
  • 可追踪
  • 可维护
  • 可扩展

当项目里有几十个 Provider 时,你会明显感受到它比手写初始化更可靠。


五、配置管理:Viper 实战

配置管理是工程规范里经常被低估的一环。很多项目早期喜欢这样写:

port := ":8080"
dbDSN := "root:123456@tcp(127.0.0.1:3306)/demo"

这样的问题很多:

  • 不同环境配置无法切换
  • 敏感信息容易硬编码
  • 配置项缺少统一管理
  • 无法做默认值、覆盖、热更新等能力

Viper 是 Go 生态里非常常用的配置管理库,支持:

  • JSON / YAML / TOML / ENV
  • 默认值设置
  • 配置文件读取
  • 环境变量覆盖
  • 配置反序列化到结构体

5.1 Viper 的推荐实践

推荐遵循以下原则:

  1. 所有配置收敛为结构体,不要在业务代码里散落 viper.GetString()
  2. 启动时一次性加载,将配置对象注入到各模块。
  3. 提供默认值,避免配置缺失直接崩溃。
  4. 区分开发、测试、生产环境

5.2 一个更完整的配置结构示例

文件:internal/config/config.go

package config

type Config struct {
	App AppConfig `mapstructure:"app"`
	DB  DBConfig  `mapstructure:"db"`
}

type AppConfig struct {
	Name    string `mapstructure:"name"`
	Port    string `mapstructure:"port"`
	LogMode string `mapstructure:"log_mode"`
}

type DBConfig struct {
	Driver string `mapstructure:"driver"`
	DSN    string `mapstructure:"dsn"`
}

文件:configs/config.yaml

app:
  name: myapp
  port: ":8080"
  log_mode: zap

db:
  driver: mysql
  dsn: root:123456@tcp(127.0.0.1:3306)/demo?parseTime=true

5.3 支持环境变量覆盖

文件:internal/config/load.go

package config

import (
	"fmt"
	"strings"

	"github.com/spf13/viper"
)

func Load(path string) (*Config, error) {
	v := viper.New()
	v.SetConfigFile(path)
	v.SetConfigType("yaml")
	v.SetEnvPrefix("MYAPP")
	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
	v.AutomaticEnv()

	v.SetDefault("app.name", "myapp")
	v.SetDefault("app.port", ":8080")
	v.SetDefault("app.log_mode", "zap")
	v.SetDefault("db.driver", "mysql")
	v.SetDefault("db.dsn", "")

	if err := v.ReadInConfig(); err != nil {
		return nil, fmt.Errorf("read config failed: %w", err)
	}

	var cfg Config
	if err := v.Unmarshal(&cfg); err != nil {
		return nil, fmt.Errorf("unmarshal config failed: %w", err)
	}

	return &cfg, nil
}

此时你可以通过环境变量覆盖配置:

export MYAPP_APP_PORT=":9090"
export MYAPP_DB_DSN="root:pwd@tcp(127.0.0.1:3306)/prod?parseTime=true"
go run ./cmd/server

5.4 配置管理的工程建议

建议 说明
配置集中定义 统一在 internal/config 中维护
不要在业务层直接依赖 Viper 业务层只依赖结构体配置对象
为关键配置设置默认值 降低启动失败概率
生产环境优先使用环境变量注入敏感信息 比硬编码更安全
配置项命名统一 例如统一使用 app.portdb.dsn

六、日志系统:Zap / Slog 结构化日志

6.1 为什么不要只用 fmt.Println

fmt.Println 在 demo 里很好用,但在真实工程里有明显短板:

  • 没有统一字段结构
  • 不方便检索
  • 不利于接入日志平台
  • 无法方便地区分级别
  • 难以关联请求上下文

工程日志应该具备这些能力:

  • 结构化输出(JSON)
  • 日志级别(Debug / Info / Warn / Error)
  • 固定字段(service、trace_id、user_id、cost_ms 等)
  • 可接入 ELK、Loki、云日志平台

6.2 Zap 与 Slog 怎么选

日志方案 特点 适用场景
Zap 高性能、成熟稳定、生态广 生产环境、高性能服务
Slog Go 标准库风格、接口统一、上手简单 新项目、偏标准库方案

如果你更看重:

  • 性能与成熟度:优先 Zap
  • 标准库一致性与简洁性:可以考虑 Slog

实际项目里常见做法是:

  • 核心服务使用 Zap
  • 对外封装统一日志接口
  • 保留切换到底层实现的能力

6.3 结构化日志示例

Zap 写法:

log.Info("get user success",
	"user_id", 1,
	"module", "user_handler",
)

如果底层封装为 zap.Any,就能得到结构化 JSON 输出,例如:

{"level":"info","time":"2026-06-08T09:00:00+08:00","caller":"handler/user_handler.go:35","msg":"get user success","user_id":1,"module":"user_handler"}

Slog 写法则更贴近标准库风格:

slogLogger.Info("get user success",
	"user_id", 1,
	"module", "user_handler",
)

6.4 日志工程实践建议

规范 说明
统一日志入口 不要每个包各自维护日志实例
日志必须带上下文字段 如请求 ID、用户 ID、接口名
错误日志要保留错误对象 避免只打印字符串
避免打印敏感信息 如密码、Token、身份证号
区分访问日志与业务日志 便于检索与分析

七、完整可运行示例:一个规范化的 Go HTTP 服务

下面给出一套完整示例。你把这些文件放入对应目录后,执行 go mod tidywirego run 即可启动。

7.1 完整目录结构

myapp/
├── cmd/
│   └── server/
│       └── main.go
├── configs/
│   └── config.yaml
├── internal/
│   ├── config/
│   │   ├── config.go
│   │   └── load.go
│   ├── handler/
│   │   └── user_handler.go
│   ├── logger/
│   │   └── logger.go
│   ├── model/
│   │   └── user.go
│   ├── repository/
│   │   └── user_repository.go
│   ├── service/
│   │   └── user_service.go
│   └── wire/
│       ├── provider.go
│       ├── wire.go
│       └── wire_gen.go
└── go.mod

7.2 go.mod

module myapp

go 1.22

require (
	github.com/google/wire v0.6.0
	github.com/spf13/viper v1.19.0
	go.uber.org/zap v1.27.0
)

7.3 internal/model/user.go

package model

type User struct {
	ID    int64  `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

7.4 internal/config/config.go

package config

type Config struct {
	App AppConfig `mapstructure:"app"`
}

type AppConfig struct {
	Name    string `mapstructure:"name"`
	Port    string `mapstructure:"port"`
	LogMode string `mapstructure:"log_mode"`
}

7.5 internal/config/load.go

package config

import (
	"fmt"
	"strings"

	"github.com/spf13/viper"
)

func Load(path string) (*Config, error) {
	v := viper.New()
	v.SetConfigFile(path)
	v.SetConfigType("yaml")
	v.SetEnvPrefix("MYAPP")
	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
	v.AutomaticEnv()

	v.SetDefault("app.name", "myapp")
	v.SetDefault("app.port", ":8080")
	v.SetDefault("app.log_mode", "zap")

	if err := v.ReadInConfig(); err != nil {
		return nil, fmt.Errorf("read config failed: %w", err)
	}

	var cfg Config
	if err := v.Unmarshal(&cfg); err != nil {
		return nil, fmt.Errorf("unmarshal config failed: %w", err)
	}

	return &cfg, nil
}

7.6 internal/logger/logger.go

package logger

import (
	"fmt"
	"log/slog"
	"os"

	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"

	"myapp/internal/config"
)

type Logger struct {
	Zap  *zap.Logger
	Slog *slog.Logger
}

func NewLogger(cfg *config.Config) (*Logger, error) {
	if cfg.App.LogMode == "slog" {
		return &Logger{Slog: newSlogLogger()}, nil
	}

	zapLogger, err := newZapLogger()
	if err != nil {
		return nil, err
	}
	return &Logger{Zap: zapLogger}, nil
}

func newZapLogger() (*zap.Logger, error) {
	encoderConfig := zap.NewProductionEncoderConfig()
	encoderConfig.TimeKey = "time"
	encoderConfig.LevelKey = "level"
	encoderConfig.MessageKey = "msg"
	encoderConfig.CallerKey = "caller"
	encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
	encoderConfig.EncodeLevel = zapcore.LowercaseLevelEncoder
	encoderConfig.EncodeCaller = zapcore.ShortCallerEncoder

	core := zapcore.NewCore(
		zapcore.NewJSONEncoder(encoderConfig),
		zapcore.AddSync(os.Stdout),
		zap.InfoLevel,
	)

	return zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1)), nil
}

func newSlogLogger() *slog.Logger {
	handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
	return slog.New(handler)
}

func (l *Logger) Info(msg string, fields ...any) {
	if l.Zap != nil {
		zapFields := make([]zap.Field, 0, len(fields)/2)
		for i := 0; i+1 < len(fields); i += 2 {
			key, ok := fields[i].(string)
			if !ok {
				continue
			}
			zapFields = append(zapFields, zap.Any(key, fields[i+1]))
		}
		l.Zap.Info(msg, zapFields...)
		return
	}

	if l.Slog != nil {
		l.Slog.Info(msg, fields...)
		return
	}

	fmt.Println(msg)
}

func (l *Logger) Sync() {
	if l.Zap != nil {
		_ = l.Zap.Sync()
	}
}

7.7 internal/repository/user_repository.go

package repository

import (
	"context"
	"errors"

	"myapp/internal/logger"
	"myapp/internal/model"
)

var ErrUserNotFound = errors.New("user not found")

type UserRepository interface {
	GetByID(ctx context.Context, id int64) (*model.User, error)
}

type InMemoryUserRepository struct {
	log  *logger.Logger
	data map[int64]*model.User
}

func NewInMemoryUserRepository(log *logger.Logger) UserRepository {
	return &InMemoryUserRepository{
		log: log,
		data: map[int64]*model.User{
			1: {ID: 1, Name: "Alice", Email: "alice@example.com"},
			2: {ID: 2, Name: "Bob", Email: "bob@example.com"},
		},
	}
}

func (r *InMemoryUserRepository) GetByID(ctx context.Context, id int64) (*model.User, error) {
	r.log.Info("repository get user by id", "user_id", id)

	user, ok := r.data[id]
	if !ok {
		return nil, ErrUserNotFound
	}
	return user, nil
}

7.8 internal/service/user_service.go

package service

import (
	"context"
	"errors"
	"fmt"

	"myapp/internal/logger"
	"myapp/internal/model"
	"myapp/internal/repository"
)

type UserService interface {
	GetUser(ctx context.Context, id int64) (*model.User, error)
}

type userService struct {
	log  *logger.Logger
	repo repository.UserRepository
}

func NewUserService(log *logger.Logger, repo repository.UserRepository) UserService {
	return &userService{log: log, repo: repo}
}

func (s *userService) GetUser(ctx context.Context, id int64) (*model.User, error) {
	s.log.Info("service get user", "user_id", id)

	if id <= 0 {
		return nil, fmt.Errorf("invalid user id: %d", id)
	}

	user, err := s.repo.GetByID(ctx, id)
	if err != nil {
		if errors.Is(err, repository.ErrUserNotFound) {
			return nil, err
		}
		return nil, fmt.Errorf("get user failed: %w", err)
	}

	return user, nil
}

7.9 internal/handler/user_handler.go

package handler

import (
	"encoding/json"
	"errors"
	"net/http"
	"strconv"
	"strings"

	"myapp/internal/logger"
	"myapp/internal/repository"
	"myapp/internal/service"
)

type UserHandler struct {
	log         *logger.Logger
	userService service.UserService
}

func NewUserHandler(log *logger.Logger, userService service.UserService) *UserHandler {
	return &UserHandler{
		log:         log,
		userService: userService,
	}
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
	idStr := strings.TrimPrefix(r.URL.Path, "/users/")
	id, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		h.log.Info("invalid user id", "raw_id", idStr)
		writeJSON(w, http.StatusBadRequest, map[string]any{
			"message": "invalid user id",
		})
		return
	}

	user, err := h.userService.GetUser(r.Context(), id)
	if err != nil {
		if errors.Is(err, repository.ErrUserNotFound) {
			writeJSON(w, http.StatusNotFound, map[string]any{
				"message": "user not found",
			})
			return
		}

		writeJSON(w, http.StatusInternalServerError, map[string]any{
			"message": err.Error(),
		})
		return
	}

	h.log.Info("get user success", "user_id", id)
	writeJSON(w, http.StatusOK, map[string]any{
		"message": "ok",
		"data":    user,
	})
}

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

7.10 internal/wire/provider.go

package wire

import (
	"github.com/google/wire"

	"myapp/internal/handler"
	"myapp/internal/logger"
	"myapp/internal/repository"
	"myapp/internal/service"
)

var ProviderSet = wire.NewSet(
	logger.NewLogger,
	repository.NewInMemoryUserRepository,
	service.NewUserService,
	handler.NewUserHandler,
)

7.11 internal/wire/wire.go

//go:build wireinject

package wire

import (
	"github.com/google/wire"

	"myapp/internal/config"
	"myapp/internal/handler"
)

func InitializeUserHandler(cfg *config.Config) (*handler.UserHandler, error) {
	wire.Build(ProviderSet)
	return &handler.UserHandler{}, nil
}

7.12 internal/wire/wire_gen.go

// Code generated by Wire. DO NOT EDIT.

//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject

package wire

import (
	"myapp/internal/config"
	"myapp/internal/handler"
	"myapp/internal/logger"
	"myapp/internal/repository"
	"myapp/internal/service"
)

func InitializeUserHandler(cfg *config.Config) (*handler.UserHandler, error) {
	loggerLogger, err := logger.NewLogger(cfg)
	if err != nil {
		return nil, err
	}
	userRepository := repository.NewInMemoryUserRepository(loggerLogger)
	userService := service.NewUserService(loggerLogger, userRepository)
	userHandler := handler.NewUserHandler(loggerLogger, userService)
	return userHandler, nil
}

7.13 configs/config.yaml

app:
  name: myapp
  port: ":8080"
  log_mode: zap

7.14 cmd/server/main.go

package main

import (
	"log"
	"net/http"

	"myapp/internal/config"
	"myapp/internal/wire"
)

func main() {
	cfg, err := config.Load("configs/config.yaml")
	if err != nil {
		log.Fatalf("load config failed: %v", err)
	}

	userHandler, err := wire.InitializeUserHandler(cfg)
	if err != nil {
		log.Fatalf("initialize handler failed: %v", err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /users/", userHandler.GetUser)

	log.Printf("server %s started at %s", cfg.App.Name, cfg.App.Port)
	if err := http.ListenAndServe(cfg.App.Port, mux); err != nil {
		log.Fatal(err)
	}
}

7.15 运行步骤

go mod tidy
go install github.com/google/wire/cmd/wire@latest
wire ./internal/wire
go run ./cmd/server

启动后访问:

curl http://localhost:8080/users/1

返回结果示例:

{"message":"ok","data":{"id":1,"name":"Alice","email":"alice@example.com"}}

八、如何把这套规范落到真实项目里

如果你已经理解了上面的示例,那么在真实项目中可以继续按以下思路演进:

8.1 从单体目录开始,但边界要先定好

小项目没必要一开始就拆很多服务,但一定要先把边界划清:

  • Handler 只处理协议层
  • Service 只处理业务层
  • Repository 只处理数据层

哪怕只有 3 个文件,这个边界也值得坚持。

8.2 先统一初始化,再扩展中间件

项目初始化建议统一收敛到启动阶段完成,例如:

  • 配置加载
  • 日志初始化
  • 数据库连接
  • 缓存初始化
  • 路由装配
  • 应用优雅退出

而不是分散在各个包的 init() 中。

8.3 优先依赖接口,而不是具体实现

例如:

  • Service 依赖 UserRepository 接口
  • Handler 依赖 UserService 接口

这样测试时就可以快速替换 Mock 实现。

8.4 将工程规范固化为团队共识

真正的规范不是“写在文档里”,而是变成团队日常习惯,比如:

  • 新接口必须按 Handler / Service / Repository 分层提交
  • 新增配置必须进入统一配置结构体
  • 新日志必须使用结构化字段
  • 新依赖优先通过构造函数注入

当这些约定长期执行后,项目的可维护性会明显提升。


九、总结

「4.1 项目架构与工程规范」的核心,不在于目录有多“标准”,而在于是否建立了一套清晰、稳定、可扩展的工程基础。

你可以记住下面这五个关键点:

  1. 目录清晰:优先采用 cmd / internal / pkg / configs 的组织方式。
  2. 职责分层:坚持 Handler → Service → Repository,避免逻辑串层。
  3. 依赖显式:通过构造函数注入依赖,复杂场景使用 Wire 统一装配。
  4. 配置集中:通过 Viper 管理配置文件、默认值与环境变量覆盖。
  5. 日志结构化:优先使用 Zap 或 Slog,保证日志可检索、可分析、可追踪。

当你把这五件事做好,Go 项目就不再只是“能跑”,而是进入了真正具备工程化能力的阶段。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Golang高级:3.7 设计模式在 Go 中的实践

下一篇

Golang项目实践:4.2 Web 服务开发实战