Go语言实战:应用篇-1:项目基础架构介绍

我习惯在真正动手开始做事之前首先思考一个大概的方向合理的路线,俗话说磨刀不误砍柴工,在对整体项目结构深入思考后再行动可以避免很多很多因一开始的误解而产生的问题,而且这样做也能让你更加了解自己的代码和各个部分的关联,每当出现问题时都能有一个很清晰的思路去逐个排查,在我真实的工作当中这些习惯帮助我解决了很多很多的问题。所以,在使用Go语言真正地写一个Web服务之前,让我们也先了解一个基础的Go语言项目该如何编排,每一个文件中的代码去实现什么样的功能?

由于我也是初次接触Go语言,网上搜寻的资料看下来也不够清晰,于是我就求助于AI来和我一起构建一个稍稍"合理"的框架。如果您有实践的经验欢迎给我提出建议,这一篇文章也会随着我的实践体验实时更新,争取介绍一个完整强大的框架。

一、项目目录

二、各个文件

[1. Config 文件夹](#1. Config 文件夹)

[2. Models 文件夹](#2. Models 文件夹)

[3. Initialize 文件夹](#3. Initialize 文件夹)

[4. Store 文件夹](#4. Store 文件夹)

[5. Service 文件夹](#5. Service 文件夹)

[6. handlers 文件夹](#6. handlers 文件夹)

[7. middleware 文件夹](#7. middleware 文件夹)

[8. routes 文件夹](#8. routes 文件夹)

[9. main.go](#9. main.go)


一、项目目录

询问AI后我决定使用这样的框架来编排代码,其中我initialize是根据以往的经验做出的一点儿改变,我认为更适合我写代码的习惯。

可以看到config目录下的依然是整个项目的配置代码,包含了所有功能需要使用的公共参数,比较明晰。其中的变量可以从外层的config.yaml加载也可以通过环境变量等方式读取,以环境变量为最优先,然后是config.yaml等配置文件,最后才是代码的默认配置。

models文件夹下就定义了需要和数据库配合的数据表结构,亦或是完成一些任务需要的结构体,通常来说会存放各个比较关键常用的struct。initialize则是初始化一些需要的工具。比如一些特定服务的API调用,一些日志工具、编排工具、雪花ID生成器等等可能使用的外部工具。这个在Python中我习惯把日志、Redis、Kafka等一同写在里面初始化连接池,但是我看Go项目中习惯把这些写在store下便移了出来。所以store的功能也很明确了,主要负责各种插件(数据库、缓存、消息队列等)的基础服务定义编写。

接下来就是有关服务功能的模块了,和Fastapi非常相似,service中定义每一个接口需要使用的功能,其调用的数据库、缓存等工具获得数据的方法写在repo文件夹下,负责操作插件和提供服务需要的数据。handlers将service的方法写成路由接口下真实的方法,包括接收的参数和返回的结果。routes则是将service中的方法和handlers等直接注册到路由下使用。main.go无非就是将所有的routes给添加进来就可以运行了。还有一项中间件则是在middleware下定义,在routes或者main.go下使用,也是和Fastapi一样注册的中间处理器,主要用于JWT认证、密码、安全等各种各样的校验。

二、各个文件

现在就让我们写代码实现一个简单的功能,来加深对整体框架的理解。这里是一个模拟调用API模型回复以及用户登录认证的例子:

1. Config 文件夹

其中只有一个config.go文件,使用viper包中的方法定义获取配置的地址,定义一个Load()方法初始化获取配置。如果会有多个协程一起使用我们可以把这个和其他需要初始化的东西使用一个唯一锁固定,这里我没有使用。

Go 复制代码
package config

import (
    "time"

    "github.com/spf13/viper"
)

type Config struct {
	// APP配置
	AppPort string `mapstructure:"APP_PORT"`
	// Redis配置
	RedisAddr     string `mapstructure:"REDIS_ADDR"`
	RedisPassword string `mapstructure:"REDIS_PASSWORD"`
	RedisDB       int    `mapstructure:"REDIS_DB"`
	// LLM配置
	LLMURI     string `mapstructure:"LLM_URI"`
	LLMModel   string `mapstructure:"LLM_MODEL"`
	LLMAPIKey  string `mapstructure:"LLM_API_KEY"`
	LLMRateQPS int    `mapstructure:"LLM_RATE_QPS"`
	// MongoDB配置
	MongoURI string `mapstructure:"MONGO_URI"`
	MongoDB  string `mapstructure:"MONGO_DB"`
	// PostgreSQL配置
	PGURI      string `mapstructure:"PG_URI"`
	DBHost     string `mapstructure:"DB_HOST"`
	DBPort     int    `mapstructure:"DB_PORT"`
	DBUser     string `mapstructure:"DB_USER"`
	DBPassword string `mapstructure:"DB_PASSWORD"`
	DBName     string `mapstructure:"DB_NAME"`
	DBSSLMode  string `mapstructure:"DB_SSL_MODE"`
	// 请求超时配置
	RequestTimeout time.Duration `mapstructure:"REQUEST_TIMEOUT"`
	// JWT配置
	JWTSecret       string        `mapstructure:"JWT_SECRET"`
	AccessTokenTTL  time.Duration `mapstructure:"ACCESS_TOKEN_TTL"`
	RefreshTokenTTL time.Duration `mapstructure:"REFRESH_TOKEN_TTL"`
	LoginRateLimit  int           `mapstructure:"LOGIN_RATE_LIMIT"`
}

func Load() (*Config, error) {
    // 1) 创建 viper 并设置默认值
    v := viper.New()
    v.SetDefault("APP_PORT", "8080")
    v.SetDefault("REQUEST_TIMEOUT", 5*time.Second)
    v.SetDefault("LLM_RATE_QPS", 5)
    // JWT 默认值
    v.SetDefault("JWT_SECRET", "default-jwt-secret")
    v.SetDefault("ACCESS_TOKEN_TTL", 15*time.Minute)
    v.SetDefault("REFRESH_TOKEN_TTL", 24*time.Hour)
    v.SetDefault("LOGIN_RATE_LIMIT", 5)

    // 2) 优先读取本地 YAML 配置文件(存在则只用文件,不再读取环境变量)
    v.SetConfigName("config")
    v.SetConfigType("yaml")
    v.AddConfigPath(".")          // 项目根目录
    v.AddConfigPath("./config")   // 可选子目录
    v.AddConfigPath("./configs")  // 可选子目录

    if err := v.ReadInConfig(); err != nil {
        // 若本地文件不存在则回退到环境变量
        v = viper.New()
        v.AutomaticEnv()
        // 重新设置默认值(环境变量模式下同样需要默认值)
        v.SetDefault("APP_PORT", "8080")
        v.SetDefault("REQUEST_TIMEOUT", 5*time.Second)
        v.SetDefault("LLM_RATE_QPS", 5)
        v.SetDefault("JWT_SECRET", "default-jwt-secret")
        v.SetDefault("ACCESS_TOKEN_TTL", 15*time.Minute)
        v.SetDefault("REFRESH_TOKEN_TTL", 24*time.Hour)
        v.SetDefault("LOGIN_RATE_LIMIT", 5)
    }

    return &Config{
        // 应用配置
        AppPort:        v.GetString("APP_PORT"),
        RequestTimeout: v.GetDuration("REQUEST_TIMEOUT"),
        // 数据库配置
        PGURI:     v.GetString("PG_URI"),
        DBHost:    v.GetString("DB_HOST"),
        DBPort:    v.GetInt("DB_PORT"),
        DBUser:    v.GetString("DB_USER"),
        DBPassword:v.GetString("DB_PASSWORD"),
        DBName:    v.GetString("DB_NAME"),
        DBSSLMode: v.GetString("DB_SSL_MODE"),
        // Redis 配置
        RedisAddr:     v.GetString("REDIS_ADDR"),
        RedisPassword: v.GetString("REDIS_PASSWORD"),
        RedisDB:       v.GetInt("REDIS_DB"),
        // LLM 配置
        LLMURI:     v.GetString("LLM_URI"),
        LLMModel:   v.GetString("LLM_MODEL"),
        LLMAPIKey:  v.GetString("LLM_API_KEY"),
        LLMRateQPS: v.GetInt("LLM_RATE_QPS"),
        // Mongo 配置
        MongoURI: v.GetString("MONGO_URI"),
        MongoDB:  v.GetString("MONGO_DB"),
        // JWT 配置
        JWTSecret:       v.GetString("JWT_SECRET"),
        AccessTokenTTL:  v.GetDuration("ACCESS_TOKEN_TTL"),
        RefreshTokenTTL: v.GetDuration("REFRESH_TOKEN_TTL"),
        LoginRateLimit:  v.GetInt("LOGIN_RATE_LIMIT"),
    }, nil
}

2. Models 文件夹

这里定义了数据库中的表结构或是需要使用的重要结构体。

Go 复制代码
package models

import "time"

type ChatSession struct {
	ID        string    `bson:"_id,omitempty" json:"id"`
	UserID    int64     `bson:"user_id" json:"user_id"`
	SessionID int64     `bson:"session_id" json:"session_id"`
	Title     string    `bson:"title" json:"title"`
	CreatedAt time.Time `bson:"created_at" json:"created_at"`
}

type ChatMessage struct {
	ID        string    `bson:"_id,omitempty" json:"id"`
	SessionID int64     `bson:"session_id" json:"session_id"`
	UserID    int64     `bson:"user_id" json:"user_id"`
	Role      string    `bson:"role" json:"role"`
	Content   string    `bson:"content" json:"content"`
	CreatedAt time.Time `bson:"created_at" json:"created_at"`
}

3. Initialize 文件夹

这里我定义了一个和调用API和llm对话的接口,和之前的习惯一样我们称它为 **Client,这里为EchoClient。

Go 复制代码
package llm

import "context"

type Message struct {
	Role    string
	Content string
}

type Client interface {
	Chat(ctx context.Context, messages []Message) (string, error)
}

type EchoClient struct{}

func (EchoClient) Chat(ctx context.Context, messages []Message) (string, error) {
	var last string
	for i := len(messages) - 1; i >= 0; i-- {
		if messages[i].Role == "user" {
			last = messages[i].Content
			break
		}
	}
	return "Echo: " + last, nil
}

4. Store 文件夹

在这里我们定义一个数据库的连接池,以及需要调用表格的初始化方法。和这个类似的还有像mongodb,redis等。

Go 复制代码
package store

import (
	"context"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)

func NewPGPool(ctx context.Context, uri string) (*pgxpool.Pool, error) {
	cfg, err := pgxpool.ParseConfig(uri)
	if err != nil {
		return nil, err
	}
	cfg.MaxConns = 20
	cfg.MaxConnLifetime = time.Hour
	cfg.MaxConnIdleTime = 10 * time.Minute
	return pgxpool.NewWithConfig(ctx, cfg)
}

func EnsureSchema(ctx context.Context, pool *pgxpool.Pool) error {
	// user表:username 唯一、密码哈希、时间字段
	_, err := pool.Exec(ctx, `
	CREATE TABLE IF NOT EXISTS users (
		id BIGSERIAL PRIMARY KEY,
		username TEXT UNIQUE NOT NULL,
		password_hash TEXT NOT NULL,
		email TEXT,
		created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
		updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
		last_login_at TIMESTAMPTZ
	);
	CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);
	`)
	return err
}

5. repo 文件夹

在这里我们写不同功能需要的数据库或者其他数据来源操作,负责提供数据给service和handler和其他。

Go 复制代码
package repo

import (
	"context"
	"learn2/internal/models"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)

type UserRepo interface {
	CreateUser(ctx context.Context, username, passwordHash, email string) (int64, error)
	GetByUsername(ctx context.Context, username string) (*models.AuthUser, error)
	UpdateLastLoginAt(ctx context.Context, id int64, t time.Time) error
	ExistsByUsername(ctx context.Context, username string) (bool, error)
}

type UserRepoPG struct {
	Pool *pgxpool.Pool
}

func NewUserRepoPG(pool *pgxpool.Pool) *UserRepoPG {
	return &UserRepoPG{Pool: pool}
}

func (r *UserRepoPG) CreateUser(ctx context.Context, username, passwordHash, email string) (int64, error) {
	var id int64
	err := r.Pool.QueryRow(ctx, "INSERT INTO users (username, password_hash, email) VALUES ($1, $2, $3) RETURNING id", username, passwordHash, email).Scan(&id)
	return id, err
}

func (r *UserRepoPG) GetByUsername(ctx context.Context, username string) (*models.AuthUser, error) {
	var user models.AuthUser
	err := r.Pool.QueryRow(ctx, "SELECT id, username, password_hash, email, last_login_at FROM users WHERE username = $1", username).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Email, &user.LastLoginAt)
	return &user, err
}

func (r *UserRepoPG) UpdateLastLoginAt(ctx context.Context, id int64, t time.Time) error {
	_, err := r.Pool.Exec(ctx, "UPDATE users SET last_login_at = $1 WHERE id = $2", t, id)
	return err
}

6. Service 文件夹

这里写所有需要的处理逻辑,最好可以包装好在handler中直接调用即可获取最终结果。逻辑的输入即是功能所需的所有字段,输出就是功能最终的结果。不要把太多的处理代码写道handler中,因为handler担任的是别的角色。

Go 复制代码
package service

import (
	"learn2/internal/initialize/llm"
	Strormongo "learn2/internal/store/mongo"
)

type ChatService struct {
	LLMClient    llm.Client
	ChatStore    *Strormongo.ChatStore
	HistortLimit int64
}

func NewChatService(st *Strormongo.ChatStore, c llm.Client, limit int64) *ChatService {
	return &ChatService{
		LLMClient:    c,
		ChatStore:    st,
		HistortLimit: limit,
	}
}

7. handlers 文件夹

这里负责的角色就是拿去请求体中所有需要的信息交给service,然后拿到结果后再拼装为最终的JSON或其他形式的输出给到输出。

Go 复制代码
package handlers

import (
	"net/http"
	"strconv"
	"time"

	"learn2/internal/service"

	"github.com/gin-gonic/gin"
)

type AuthHandlers struct {
	Svc *service.AuthService
}

func NewAuthHandlers(svc *service.AuthService) *AuthHandlers { return &AuthHandlers{Svc: svc} }

type RegisterRequest struct {
	Username string `json:"username" binding:"required"`
	Password string `json:"password" binding:"required"`
	Email    string `json:"email"`
}

type LoginRequest struct {
	Username string `json:"username" binding:"required"`
	Password string `json:"password" binding:"required"`
}

type RefreshRequest struct {
	RefreshToken string `json:"refreshToken" binding:"required"`
	RefreshID    string `json:"refreshId" binding:"required"`
	UserID       int64  `json:"userId" binding:"required"`
}

func (h *AuthHandlers) Register(c *gin.Context) {
	var req RegisterRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	id, err := h.Svc.Register(c.Request.Context(), req.Username, req.Password, req.Email)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusCreated, gin.H{"id": id, "username": req.Username})
}

func (h *AuthHandlers) Login(c *gin.Context) {
    var req LoginRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    // 你可以用用户名作为限流key
    access, refresh, refreshID, accessTTL, refreshTTL, err := h.Svc.Login(c.Request.Context(), req.Username, req.Username, req.Password)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
        return
    }
    c.JSON(http.StatusOK, gin.H{
        "accessToken":           access,
        "accessTokenExpiresIn":  int64(accessTTL.Seconds()),
        "refreshToken":          refresh,
        "refreshTokenExpiresIn": int64(refreshTTL.Seconds()),
        "refreshId":             refreshID,
    })
}

8. middleware 文件夹

这里定义了一个中间件,使用JWT认证的方式从请求头的Authorization中读取可能存在的Bearer密钥,然后进行认证后将请求的用户信息添加到请求体中进入下一步。这里需要一个类似JWT_User的结构体,并且在后端接口可以统一用Uid字段来存放这个结构体并获取。这个中间件在请求出去的时候不做操作,只对进入的请求操作。

Go 复制代码
package middleware

import (
	"fmt"
	"net/http"
	"strings"

	"learn2/internal/store"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
	"github.com/redis/go-redis/v9"
)

func AuthMiddleware(rdb *redis.Client, jwtSecret string) gin.HandlerFunc {
	return func(c *gin.Context) {
		auth := c.GetHeader("Authorization")
		if !strings.HasPrefix(auth, "Bearer ") {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
			return
		}
		tokenStr := strings.TrimPrefix(auth, "Bearer ")
		token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
			if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
				return nil, jwt.ErrTokenUnverifiable
			}
			return []byte(jwtSecret), nil
		})
		if err != nil || !token.Valid {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
			return
		}
		claims, ok := token.Claims.(jwt.MapClaims)
		if !ok {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid claims"})
			return
		}

		// 检查黑名单
		jti, _ := claims["jti"].(string)
		if jti != "" {
			blacklisted, err := store.IsTokenBlacklisted(c.Request.Context(), rdb, jti)
			if err != nil {
				c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "redis error"})
				return
			}
			if blacklisted {
				c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token revoked"})
				return
			}
			c.Set("jti", jti)
		}

		// 注入 userID
		switch v := claims["sub"].(type) {
		case float64:
			c.Set("userID", fmt.Sprintf("%d", int64(v)))
		case string:
			c.Set("userID", v)
		}
		c.Next()
	}
}

9. routes 文件夹

这里将之前定义的方法等都注册到对应的路由下,然后再有main.go把不同的模块再合并起来。

Go 复制代码
package routes

import (
	"learn2/internal/config"
	"learn2/internal/handlers"
	"learn2/internal/service"

	"github.com/gin-gonic/gin"
	"github.com/redis/go-redis/v9"
)

// 新增依赖注入版本(建议):由 main.go 传入
type Deps struct {
	Auth  *service.AuthService
	Redis *redis.Client
	Cfg   *config.Config
}

func RegisterRoutes(r *gin.Engine, deps Deps) {
    api := r.Group("/api/v1")
    {
        // 鉴权接口
        authHandlers := handlers.NewAuthHandlers(deps.Auth)
        api.POST("/auth/register", authHandlers.Register)
        api.POST("/auth/login", authHandlers.Login)
        api.POST("/auth/refresh", authHandlers.Refresh)
        api.POST("/auth/logout", authHandlers.Logout)

        // 受保护接口
        protected := api.Group("/")
        protected.Use(AuthMiddleware(deps.Redis, deps.Cfg.JWTSecret))
        {
            protected.GET("/me", authHandlers.Me)
        }
    }
}

10. main.go

在这里将最终的app全部组合完成(我之前把middleware放到routes下了,使用时可以提出来)。

Go 复制代码
package main

import (
    "context"
    "log"

    "github.com/gin-contrib/cors"
    "github.com/gin-gonic/gin"
    swaggerFiles "github.com/swaggo/files"
    ginSwagger "github.com/swaggo/gin-swagger"

    docs "learn2/docs"
    "learn2/internal/config"
    "learn2/internal/repo"
    "learn2/internal/routes"
    "learn2/internal/service"
    "learn2/internal/store"
)

func main() {
    // 配置 Swagger 元信息(确保文档包被引入并注册)
    docs.SwaggerInfo.Title = "Learn2 API"
    docs.SwaggerInfo.Version = "1.0"
    docs.SwaggerInfo.BasePath = ""
    docs.SwaggerInfo.Schemes = []string{"http"}

	// 1) 加载配置
	cfg, err := config.Load()
	if err != nil {
		log.Fatalf("config load error: %v", err)
	}

	// 2) 初始化存储
	ctx := context.Background()
	pg, err := store.NewPGPool(ctx, cfg.PGURI)
	if err != nil {
		log.Fatalf("pg pool error: %v", err)
	}
	if err := store.EnsureSchema(ctx, pg); err != nil {
		log.Fatalf("pg schema error: %v", err)
	}

	redis := store.NewRedis(cfg.RedisAddr, cfg.RedisPassword)

	// 3) 初始化仓库与服务
	userRepo := repo.NewUserRepoPG(pg)
	authSvc := service.NewAuthService(userRepo, redis, cfg.JWTSecret, cfg.AccessTokenTTL, cfg.RefreshTokenTTL, cfg.LoginRateLimit)

	// 4) Gin 初始化与跨域
	r := gin.Default()
	r.Use(cors.New(cors.Config{
		AllowOrigins:     []string{"http://localhost:654", "http://127.0.0.1:654"},
		AllowMethods:     []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
		AllowHeaders:     []string{"Origin", "Content-Type", "Accept", "Authorization"},
		ExposeHeaders:    []string{"Content-Length"},
		AllowCredentials: true,
	}))

    routes.RegisterRoutes(r, routes.Deps{
        Auth:  authSvc,
        Redis: redis,
        Cfg:   cfg,
    })

    // 方便访问:根路径重定向到 SwaggerUI
    r.GET("/", func(c *gin.Context) { c.Redirect(302, "/swagger/index.html") })
    r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
    if err := r.Run(":654"); err != nil {
        log.Fatalf("Failed to start server: %v", err)
    }
}

至此,我们完成了整体框架的介绍,接下来让我们开始写一个真正的web后端接口服务吧~

相关推荐
龘龍龙2 小时前
Python基础学习(二)
开发语言·python·学习
froginwe112 小时前
PHP 表单 - 必需字段
开发语言
周杰伦_Jay2 小时前
【Golang 核心特点与语法】简洁高效+并发原生
开发语言·后端·golang
小坏讲微服务2 小时前
Spring Boot 4.0 + MyBatis-Plus 实战响应式编程的能力实战
java·spring boot·后端·mybatis
by__csdn2 小时前
javascript 性能优化实战:垃圾回收优化
java·开发语言·javascript·jvm·vue.js·性能优化·typescript
IT_陈寒2 小时前
Java 21新特性实战:5个杀手级功能让你的代码效率提升50%
前端·人工智能·后端
by__csdn2 小时前
JavaScript性能优化:减少重绘和回流(Reflow和Repaint)
开发语言·前端·javascript·vue.js·性能优化·typescript·vue
扶苏-su2 小时前
Java---泛型
java·开发语言·泛型
Dolphin_Home2 小时前
Java Stream 实战:订单商品ID过滤技巧(由浅入深)
java·开发语言·spring boot