项目初始化:目录分层、配置与日志

本章目标:跑起 im-go 的网关(Gateway)与逻辑层(Logic)两个进程,配置走 YAML 加环境变量,日志统一结构化,目录分层一次定型。

1. 建模块与拉依赖

go mod init github.com/example/im-go
go get github.com/gin-gonic/gin@latest github.com/gorilla/websocket@latest github.com/redis/go-redis/v9@latest
go get github.com/golang-jwt/jwt/v5@latest gorm.io/gorm@latest gorm.io/driver/mysql@latest gopkg.in/yaml.v3@latest
go get google.golang.org/grpc@latest google.golang.org/protobuf@latest golang.org/x/crypto/bcrypt@latest

2. 目录分层

im-go/
├── cmd/gateway/main.go   # 网关入口:WebSocket 接入(第 02 章)
├── cmd/logic/main.go     # 逻辑层入口:HTTP + gRPC(第 04、05 章)
├── gateway/              # 接入层:升级、连接管理、心跳
├── logic/  service/      # 逻辑层(登录鉴权、消息处理)与业务编排(单聊、群聊、离线)
├── store/  proto/        # 存储封装(MySQL/Redis)、protobuf 定义(第 05 章)
├── pkg/                  # 基础库:config、logx、protocol
└── configs/config.yaml

依赖方向铁律:cmd → gateway / logic → service → store → pkg,只允许向下调;proto/ 只放 .proto,生成物落到 pkg/pb。

3. 配置:YAML + 环境变量

env: dev
gateway: {addr: "0.0.0.0:8080", max_message_size: 32768, jwt_secret: "", allowed_origins: ["http://localhost:5173"]}  # 密钥用环境变量覆盖
logic:   {http_addr: "0.0.0.0:8081", grpc_addr: "0.0.0.0:9090"}
mysql:   {dsn: "im:im123@tcp(127.0.0.1:3306)/im_go?charset=utf8mb4&parseTime=true&loc=Local"}
// pkg/config/config.go
type Config struct {
	Env     string  `yaml:"env"`
	Gateway Gateway `yaml:"gateway"`
	Logic   Logic   `yaml:"logic"`
	MySQL   MySQL   `yaml:"mysql"`
}
type Gateway struct {
	Addr           string   `yaml:"addr"`
	MaxMessageSize int64    `yaml:"max_message_size"`
	JWTSecret      string   `yaml:"jwt_secret"`
	AllowedOrigins []string `yaml:"allowed_origins"`
}
type Logic struct {
	HTTPAddr string `yaml:"http_addr"`
	GRPCAddr string `yaml:"grpc_addr"`
}
type MySQL struct{ DSN string `yaml:"dsn"` }
// Load 读 YAML → 环境变量覆盖敏感项 → 校验,任一步失败都不启动。
func Load(path string) (*Config, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read config %s: %w", path, err)
	}
	var cfg Config
	if err := yaml.Unmarshal(raw, &cfg); err != nil {
		return nil, fmt.Errorf("parse config %s: %w", path, err)
	}
	if v := os.Getenv("IM_GATEWAY_JWT_SECRET"); v != "" { // 密钥不进仓库
		cfg.Gateway.JWTSecret = v
	}
	if cfg.Gateway.Addr == "" || len(cfg.Gateway.JWTSecret) < 32 {
		return nil, errors.New("config: gateway.addr or jwt_secret invalid")
	}
	return &cfg, nil
}

4. 入口:能优雅退出的 HTTP 服务

// cmd/gateway/main.go;日志由 pkg/logx.Init 用 slog.NewJSONHandler + slog.SetDefault 初始化
func main() {
	confPath := flag.String("conf", "configs/config.yaml", "配置文件路径")
	flag.Parse()
	cfg, err := config.Load(*confPath)
	if err != nil {
		os.Stderr.WriteString("load config: " + err.Error() + "\n")
		os.Exit(1)
	}
	logger := logx.Init(cfg.Env, "gateway")
	r := gin.New()
	r.Use(gin.Recovery()) // 第 02 章在这里再挂 r.GET("/ws", ...)
	srv := &http.Server{Addr: cfg.Gateway.Addr, Handler: r, ReadHeaderTimeout: 5 * time.Second}
	go func() {
		logger.Info("gateway listening", "addr", cfg.Gateway.Addr)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			logger.Error("listen failed", "err", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(ctx); err != nil {
		logger.Error("shutdown", "err", err)
	}
	logger.Info("gateway stopped")
}

5. 验证方式

go vet ./... && go build -o bin/gateway ./cmd/gateway
./bin/gateway -conf configs/config.yaml
# 输出:level=INFO msg="gateway listening" service=gateway env=dev addr=0.0.0.0:8080
kill -TERM $(pgrep -f 'bin/gateway')   # 日志出现 msg="gateway stopped" 即优雅退出成功

常见坑

坑现象做法
密钥写进 YAML一提交就泄露敏感项用环境变量覆盖,YAML 留空串
70s 直接绑到 time.Durationyaml 解析报类型错误存字符串再 time.ParseDuration,或自定义 UnmarshalYAML
log.Printf 与 slog 混用两套格式,时间线对不上全项目只走 pkg/logx
网关与逻辑层挤在一个入口后续无法独立扩容两个 cmd/ 目录分别编译

小结:初始化只做四件事——建模块拉依赖、按 cmd/ gateway/ logic/ service/ store/ proto/ pkg/ 分层、YAML 加环境变量的配置加载、slog 结构化日志与优雅退出的入口。骨架立住后,第 02 章开始往 gateway/ 里填真正的长连接。

笔记加载中…