核心目标:通过一个完整的 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 初始化

1
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)

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
// 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

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

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
// 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 业务逻辑层

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

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
// 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 统一响应格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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 路由分组

1
2
3
4
5
6
7
8
9
// 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 生成与解析

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
// 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 认证中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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 地址访问

1
2
3
4
5
6
# 查看 WSL IP
ip addr show eth0 | grep inet
# 输出类似:inet 192.168.64.33/20

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

一劳永逸:开启镜像网络

1
2
3
# 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 模式
  • 单聊(点对点消息)
  • 群聊 + 消息广播
  • 消息持久化 + 离线消息
  • 在线状态 + 心跳检测