项目初始化:目录分层、配置与日志
本章目标:跑起 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.Duration | yaml 解析报类型错误 | 存字符串再 time.ParseDuration,或自定义 UnmarshalYAML |
log.Printf 与 slog 混用 | 两套格式,时间线对不上 | 全项目只走 pkg/logx |
| 网关与逻辑层挤在一个入口 | 后续无法独立扩容 | 两个 cmd/ 目录分别编译 |
小结:初始化只做四件事——建模块拉依赖、按 cmd/ gateway/ logic/ service/ store/ proto/ pkg/ 分层、YAML 加环境变量的配置加载、slog 结构化日志与优雅退出的入口。骨架立住后,第 02 章开始往 gateway/ 里填真正的长连接。