核心目标 :通过一个完整的 IM 项目,系统性地掌握 Go 语言后端开发的核心技能——从项目初始化、数据库操作、用户认证到 WebSocket 实时通信。
项目概览 技术栈
组件
选型
理由
Web 框架
Gin
轻量高效,Go 生态主流
数据库
PostgreSQL + pgx
原生驱动,学习 SQL
配置管理
Viper
支持 YAML,功能强大
认证
JWT + bcrypt
无状态认证,安全哈希
实时通信
gorilla/websocket
最成熟的 WebSocket 库
项目结构(Go 标准布局) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 go-im/ ├── cmd/server/main.go # 程序入口 ├── config/config.yaml # 配置文件 ├── internal/ # 私有代码(外部不可导入) │ ├── config/ # 配置加载 │ ├── model/ # 数据模型 │ ├── repository/ # 数据访问层 │ ├── service/ # 业务逻辑层 │ ├── handler/ # HTTP 处理层 │ ├── ws/ # WebSocket 相关 │ └── middleware/ # 中间件 ├── pkg/ # 公共代码(可被外部导入) │ ├── database/ # 数据库连接 │ └── utils/ # 工具函数 └── go.mod
知识点 :internal 目录是 Go 的约定,该目录下的包只能被当前模块导入,外部模块无法访问。这是一种代码可见性的控制机制。
第一部分:项目初始化与配置管理 1.1 Go Module 初始化
这会创建 go.mod 文件,类似 Node.js 的 package.json 或 PHP 的 composer.json。
知识点 :Go Module 是 Go 1.11 引入的依赖管理方案。go.mod 记录模块名和依赖版本,go.sum 记录依赖的哈希校验值。
1.2 配置加载(Viper) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 package configimport ( "log" "github.com/spf13/viper" ) type Config struct { Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` JWT JWTConfig `mapstructure:"jwt"` } type DatabaseConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` User string `mapstructure:"user"` Password string `mapstructure:"password"` DBName string `mapstructure:"dbname"` } func (d DatabaseConfig) DSN() string { return fmt.Sprintf( "host=%s user=%s password=%s dbname=%s port=%d" , d.Host, d.User, d.Password, d.DBName, d.Port, ) } var C Config func Load (path string ) { viper.SetConfigFile(path) if err := viper.ReadInConfig(); err != nil { log.Fatalf("failed to read config: %v" , err) } if err := viper.Unmarshal(&C); err != nil { log.Fatalf("failed to unmarshal config: %v" , err) } }
配置文件 config.yaml:
1 2 3 4 5 6 7 8 9 10 server: port: 8080 mode: debug database: host: localhost port: 5432 user: postgres password: postgres dbname: go_im
知识点 :
mapstructure tag 是 Viper 特有的,用于将 YAML key 映射到 Go struct 字段
Go 的 struct tag 用反引号 ` 定义,格式为 key:"value"
var C Config 是包级别的全局变量,其他包通过 config.C 访问
第二部分:数据库连接与 Repository 模式 2.1 PostgreSQL 连接(pgx) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package databaseimport ( "context" "go-im/internal/config" "github.com/jackc/pgx/v5" ) var db *pgx.Connfunc Init () error { var err error db, err = pgx.Connect(context.Background(), config.C.Database.DSN()) return err } func DB () *pgx.Conn { return db }
知识点 :
小写 db 是包私有变量,外部无法访问
通过 DB() 函数暴露连接,这是 Go 的惯用模式(封装 + 访问器)
context.Background() 是根上下文,用于控制超时和取消
2.2 Repository 模式(数据访问层) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 package repositoryimport ( "context" "go-im/internal/model" "go-im/pkg/database" ) type UserRepo struct {}func (r *UserRepo) CreateUser(ctx context.Context, user *model.User) error { _, err := database.DB().Exec(ctx, "INSERT INTO users (username, nickname, password) VALUES ($1, $2, $3)" , user.Username, user.Nickname, user.Password, ) return err } func (r *UserRepo) FindByUsername(ctx context.Context, username string ) (*model.User, error ) { var user model.User err := database.DB().QueryRow(ctx, "SELECT id, username, nickname, password FROM users WHERE username = $1" , username, ).Scan(&user.ID, &user.Username, &user.Nickname, &user.Password) if err != nil { return nil , err } return &user, nil }
知识点 :
Repository 模式将数据库操作与业务逻辑分离
$1, $2, $3 是 PostgreSQL 的参数占位符,防止 SQL 注入
QueryRow 返回单行,Scan 将结果映射到 struct 字段
返回 *model.User(指针)避免大结构体的值拷贝
第三部分:Service 层与密码安全 3.1 业务逻辑层 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 package serviceimport ( "context" "errors" "go-im/internal/model" "go-im/internal/repository" "golang.org/x/crypto/bcrypt" ) type AuthService struct {}func (s *AuthService) Register(ctx context.Context, username, nickname, password string ) error { hash, err := bcrypt.GenerateFromPassword([]byte (password), bcrypt.DefaultCost) if err != nil { return errors.New("密码哈希失败" ) } return repository.UserRepo{}.CreateUser(ctx, &model.User{ Username: username, Nickname: nickname, Password: string (hash), }) } func (s *AuthService) Login(ctx context.Context, username, password string ) (*model.User, error ) { user, err := repository.UserRepo{}.FindByUsername(ctx, username) if err != nil { return nil , errors.New("用户不存在" ) } err = bcrypt.CompareHashAndPassword([]byte (user.Password), []byte (password)) if err != nil { return nil , errors.New("密码错误" ) } return user, nil }
知识点 :
bcrypt 是专门用于密码哈希的算法,自带盐值(salt),每次哈希结果都不同
GenerateFromPassword 生成哈希,CompareHashAndPassword 校验密码
密码永远不能明文存储,这是安全开发的基本原则
Service 层不直接操作数据库,而是调用 Repository 层
第四部分:Handler 层与 Gin 框架 4.1 请求绑定与参数校验 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 package handlerimport ( "go-im/internal/service" "go-im/pkg/utils" "github.com/gin-gonic/gin" ) type RegisterRequest struct { Username string `json:"username" binding:"required"` Nickname string `json:"nickname" binding:"required"` Password string `json:"password" binding:"required"` } type AuthHandler struct { authService *service.AuthService } func NewAuthHandler (s *service.AuthService) *AuthHandler { return &AuthHandler{authService: s} } func (h *AuthHandler) Register() gin.HandlerFunc { return func (c *gin.Context) { var req RegisterRequest if err := c.ShouldBindJSON(&req); err != nil { utils.Error(c, 400 , "参数错误" ) return } err := h.authService.Register(c.Request.Context(), req.Username, req.Nickname, req.Password) if err != nil { utils.Error(c, 500 , "注册失败" ) return } utils.Success(c, nil ) } }
知识点 :
binding:"required" 是 Gin 的校验 tag,字段为空时自动报错
ShouldBindJSON 自动解析请求体 JSON 到 struct
gin.HandlerFunc 是 Gin 要求的 handler 签名:func(*gin.Context)
用闭包(return func)实现依赖注入,handler 内部可以访问 h.authService
4.2 统一响应格式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 type Response struct { Code int `json:"code"` Message string `json:"message"` Data interface {} `json:"data,omitempty"` } func Success (c *gin.Context, data interface {}) { c.JSON(http.StatusOK, Response{Code: 0 , Message: "ok" , Data: data}) } func Error (c *gin.Context, httpCode int , msg string ) { c.JSON(httpCode, Response{Code: -1 , Message: msg}) }
知识点 :
interface{} 是 Go 的空接口,任何类型都实现了它,类似 PHP 的 mixed
omitempty tag 表示字段为空时 JSON 中省略该字段
统一响应格式让前端处理更简单
4.3 路由分组 1 2 3 4 5 6 7 8 9 r := gin.Default() api := r.Group("/api" ) { authHandler := handler.NewAuthHandler(&service.AuthService{}) api.POST("/register" , authHandler.Register()) api.POST("/login" , authHandler.Login()) }
知识点 :
Group 创建路由组,统一添加前缀,适合组织 API 版本(/api/v1/)
Gin 的中间件可以挂在 Group 上,组内所有路由都会经过
第五部分:JWT 认证 5.1 JWT 生成与解析 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 package utilsimport ( "time" "github.com/golang-jwt/jwt/v5" "go-im/internal/config" ) func GenerateToken (userID int ) (string , error ) { claims := jwt.MapClaims{ "user_id" : userID, "exp" : time.Now().Add(time.Duration(config.C.JWT.ExpireHours) * time.Hour).Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte (config.C.JWT.Secret)) } func ParseToken (tokenStr string ) (int , error ) { token, err := jwt.Parse(tokenStr, func (t *jwt.Token) (interface {}, error ) { return []byte (config.C.JWT.Secret), nil }) if err != nil { return 0 , err } claims, ok := token.Claims.(jwt.MapClaims) if !ok || !token.Valid { return 0 , errors.New("invalid token" ) } userID := int (claims["user_id" ].(float64 )) return userID, nil }
5.2 认证中间件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 func AuthMiddleware () gin.HandlerFunc { return func (c *gin.Context) { token := c.GetHeader("Authorization" ) if token == "" { utils.Error(c, 401 , "未登录" ) c.Abort() return } token = strings.TrimPrefix(token, "Bearer " ) userID, err := utils.ParseToken(token) if err != nil { utils.Error(c, 401 , "token 无效" ) c.Abort() return } c.Set("user_id" , userID) c.Next() } }
知识点 :
JWT(JSON Web Token)是无状态认证方案,服务端不需要存储 session
c.Abort() 终止后续 handler 执行
c.Set("user_id", userID) 将数据存入 Gin 的上下文,后续通过 c.Get("user_id") 获取
c.Next() 放行请求,继续执行后续中间件/handler
第六部分:WSL2 网络问题 在 WSL2 中开发时,Windows 宿主机无法直接访问 WSL 内的服务。
原因 WSL2 默认使用 NAT 模式,Linux 有独立的 IP 地址。
解决方案 快速方案 :使用 WSL 的 IP 地址访问
1 2 3 4 5 6 ip addr show eth0 | grep inet http://192.168.64.33:8080/ping
一劳永逸 :开启镜像网络
1 2 3 [wsl2] networkingMode =mirrored
然后 PowerShell 执行 wsl --shutdown,重启后 localhost 直通。
知识点速查表
概念
说明
go mod init
初始化模块,类似 npm init
internal/
包私有目录,外部不可导入
mapstructure
Viper 的 tag,YAML → struct 映射
binding:"required"
Gin 的请求校验 tag
ShouldBindJSON
Gin 自动解析 JSON 请求体
gin.HandlerFunc
Gin handler 的标准签名
bcrypt
密码哈希算法,自带盐值
JWT
无状态认证,服务端不存 session
c.Set / c.Get
Gin 上下文存取数据
c.Abort()
终止请求链
$1, $2
PostgreSQL 参数占位符,防 SQL 注入
*T (指针)
避免值拷贝,可修改原数据
interface{}
空接口,任意类型
下一步计划