Go 语言学习笔记:从零搭建 IM 即时通讯系统

核心目标:通过一个完整的 IM 项目,系统性地掌握 Go 语言后端开发的核心技能——从项目初始化、数据库操作、用户认证到 WebSocket 实时通信。


项目概览

技术栈

组件 选型 理由
Web 框架 Gin 轻量高效,Go 生态主流
数据库 PostgreSQL + pgx 原生驱动,学习 SQL
配置管理 Viper 支持 YAML,功能强大
认证 JWT + bcrypt 无状态认证,安全哈希
实时通信 gorilla/websocket 最成熟的 WebSocket 库

项目结构(Go 标准布局)

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 init go-im

这会创建 go.mod 文件,类似 Node.js 的 package.json 或 PHP 的 composer.json

知识点:Go Module 是 Go 1.11 引入的依赖管理方案。go.mod 记录模块名和依赖版本,go.sum 记录依赖的哈希校验值。

1.2 配置加载(Viper)

// internal/config/config.go
package config

import (
    "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

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)

// pkg/database/database.go
package database

import (
    "context"
    "go-im/internal/config"
    "github.com/jackc/pgx/v5"
)

var db *pgx.Conn

func 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 模式(数据访问层)

// internal/repository/user_repo.go
package repository

import (
    "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 业务逻辑层

// internal/service/auth.go
package service

import (
    "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 请求绑定与参数校验

// internal/handler/auth.go
package handler

import (
    "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
        // 自动绑定 JSON 并校验
        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 统一响应格式

// pkg/utils/response.go
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 路由分组

// cmd/server/main.go
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 生成与解析

// pkg/utils/jwt.go
package utils

import (
    "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 认证中间件

// internal/middleware/auth.go
func AuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        token := c.GetHeader("Authorization")
        if token == "" {
            utils.Error(c, 401, "未登录")
            c.Abort()
            return
        }

        // 去掉 "Bearer " 前缀
        token = strings.TrimPrefix(token, "Bearer ")
        userID, err := utils.ParseToken(token)
        if err != nil {
            utils.Error(c, 401, "token 无效")
            c.Abort()
            return
        }

        // 将 userID 存入上下文,后续 handler 可以获取
        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 地址访问

# 查看 WSL IP
ip addr show eth0 | grep inet
# 输出类似:inet 192.168.64.33/20

# Windows 浏览器访问
http://192.168.64.33:8080/ping

一劳永逸:开启镜像网络

# C:\Users\用户名\.wslconfig
[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{} 空接口,任意类型

下一步计划

  • WebSocket 长连接 + Hub 模式
  • 单聊(点对点消息)
  • 群聊 + 消息广播
  • 消息持久化 + 离线消息
  • 在线状态 + 心跳检测