在 Go 项目进入多人协作和持续迭代阶段后,代码能跑 只是最低要求,真正决定研发效率的是:目录是否清晰、职责是否分层、依赖是否可控、配置是否规范、日志是否可观测。
这篇文章围绕「4.1 项目架构与工程规范」展开,系统讲解以下内容:
- Go 项目标准布局(Standard Go Project Layout)
- 分层架构设计:Handler → Service → Repository
- 依赖注入与 Wire 框架
- 配置管理:Viper 实战
- 日志系统:Zap / Slog 结构化日志
本文不仅讲概念,也给出一套完整可运行的示例代码。你可以直接照着搭建一个小型 HTTP 服务,并在此基础上扩展真实业务。
一、为什么工程规范比“能跑”更重要
很多初学者写 Go 项目时,喜欢把所有代码都塞进 main.go:
- 路由写在一起
- 数据库访问写在一起
- 业务逻辑写在一起
- 配置读取写在一起
- 日志打印也写在一起
短期看似简单,长期一定失控。常见问题包括:
- 职责混乱:改一个接口,牵一发而动全身。
- 难以测试:业务依赖数据库、配置、日志,单元测试无从下手。
- 难以协作:不同同学很难快速理解目录结构和调用关系。
- 难以维护:需求一多,重复逻辑和“临时补丁”会越来越多。
- 难以扩展:想换配置中心、日志库、数据库实现时,改动面很大。
因此,工程规范的目标不是“看起来专业”,而是为了实现:
- 更清晰的项目结构
- 更稳定的依赖关系
- 更可控的初始化流程
- 更低的维护成本
- 更强的可测试性与可观测性
二、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。
这样做有三个直接收益:
- 解耦:Service 不依赖具体实现细节。
- 可测试:测试时可注入 Mock Repository。
- 可替换:未来从内存仓储切到 MySQL/PostgreSQL 时,上层逻辑不用改。
4.2 为什么选择 Google Wire
Wire 是 Google 提供的编译期依赖注入工具。它和运行时反射型 DI 框架相比,有几个明显优势:
- 无运行时反射开销
- 依赖关系在编译期生成,类型安全
- 生成代码清晰,可读性强
- 更符合 Go 的显式依赖风格
4.3 引入 Wire 后的项目结构
internal/
├── wire/
│ ├── provider.go
│ ├── wire.go
│ └── wire_gen.go
其中:
provider.go:声明 ProviderSetwire.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 的推荐实践
推荐遵循以下原则:
- 所有配置收敛为结构体,不要在业务代码里散落
viper.GetString()。 - 启动时一次性加载,将配置对象注入到各模块。
- 提供默认值,避免配置缺失直接崩溃。
- 区分开发、测试、生产环境。
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.port、db.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 tidy、wire、go 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 项目架构与工程规范」的核心,不在于目录有多“标准”,而在于是否建立了一套清晰、稳定、可扩展的工程基础。
你可以记住下面这五个关键点:
- 目录清晰:优先采用
cmd / internal / pkg / configs的组织方式。 - 职责分层:坚持
Handler → Service → Repository,避免逻辑串层。 - 依赖显式:通过构造函数注入依赖,复杂场景使用 Wire 统一装配。
- 配置集中:通过 Viper 管理配置文件、默认值与环境变量覆盖。
- 日志结构化:优先使用 Zap 或 Slog,保证日志可检索、可分析、可追踪。
当你把这五件事做好,Go 项目就不再只是“能跑”,而是进入了真正具备工程化能力的阶段。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!